MySQL 锁机制深度解析,从乐观锁到悲观锁全面掌握
MySQL 锁机制深度解析,从乐观锁到悲观锁全面掌握
库存系统并发扣减时,两个请求同时读到库存为 1,都认为自己可以扣减,结果库存变成了负数。
这是典型的并发控制问题。MySQL 提供了多种锁机制来解决这类问题,但选错锁类型或用法不对,就会出现数据不一致。
乐观锁 vs 悲观锁
这是两种截然不同的并发控制思想:
悲观锁(Pessimistic Locking)
假设并发冲突大概率发生,先加锁再操作。
SELECT * FROM inventory WHERE sku_id = 1001 FOR UPDATE;
-- 查询时锁定这行,其他事务无法修改
UPDATE inventory SET available = available - 1 WHERE sku_id = 1001;
特点:
- 先锁后操作
- 适合写多读多、冲突频繁的场景
- 可能导致死锁和等待
乐观锁(Optimistic Locking)
假设并发冲突小概率发生,不加锁,提交时检查版本。
-- 读取时记录版本号
SELECT available, version FROM inventory WHERE sku_id = 1001;
-- available=1, version=5
-- 更新时检查版本
UPDATE inventory
SET available = available - 1, version = version + 1
WHERE sku_id = 1001 AND version = 5;
-- 影响行数为 0 说明被其他事务修改了,重试
特点:
- 不阻塞,提交时检测冲突
- 适合读多写少、冲突不多的场景
- 需要业务层配合重试
InnoDB 行锁详解
InnoDB 是 MySQL 默认存储引擎,支持行级锁。
共享锁(S Lock)和排他锁(X Lock)
-- 共享锁:允许其他事务同时获取共享锁,但不能获取排他锁
SELECT * FROM orders WHERE id = 100 LOCK IN SHARE MODE;
-- 排他锁:不允许其他事务获取任何锁
SELECT * FROM orders WHERE id = 100 FOR UPDATE;
UPDATE orders SET status = 2 WHERE id = 100;
兼容矩阵:
| S Lock | X Lock | |
|---|---|---|
| S Lock | 兼容 | 冲突 |
| X Lock | 冲突 | 冲突 |
记录锁(Record Lock)
锁定索引记录本身。
SELECT * FROM orders WHERE id = 100 FOR UPDATE;
锁定 id=100 这条记录。
间隙锁(Gap Lock)
锁定索引记录之间的间隙。
SELECT * FROM orders WHERE id BETWEEN 50 AND 100 FOR UPDATE;
锁定 (50, 100) 这个范围,防止插入新记录。
Next-Key Lock
Record Lock + Gap Lock 的组合。
SELECT * FROM orders WHERE id <= 100 FOR UPDATE;
锁定 (-∞, 100] 范围,包括记录 100 和之前的间隙。
这是 InnoDB 在 REPEATABLE READ 下的默认锁模式,用来防止幻读。
插入意向锁(Insert Intention Lock)
INSERT 操作在等待时获取的锁。
-- 事务 A 插入 id=50
INSERT INTO orders (id, ...) VALUES (50, ...);
-- 获取插入意向锁,等待
-- 事务 B 插入 id=60
INSERT INTO orders (id, ...) VALUES (60, ...);
-- 获取插入意向锁,等待
多个 INSERT 并发时,它们不会互相阻塞,只会等待。
锁等待与死锁
查看锁等待
-- 查看当前锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
-- 查看当前锁
SELECT * FROM information_schema.INNODB_LOCKS;
-- 查看当前事务
SELECT * FROM information_schema.INNODB_TRX;
模拟锁等待
-- 终端 1
BEGIN;
SELECT * FROM orders WHERE id = 100 FOR UPDATE; -- 锁定 id=100
-- 终端 2
BEGIN;
SELECT * FROM orders WHERE id = 100 FOR UPDATE; -- 等待锁
超时机制
innodb_lock_wait_timeout 控制锁等待超时时间:
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 默认 50 秒
-- 临时修改
SET innodb_lock_wait_timeout = 5;
-- 永久修改在 my.cnf
[mysqld]
innodb_lock_wait_timeout = 5
死锁检测
InnoDB 有死锁检测机制,自动回滚代价最小的事务:
SHOW ENGINE INNODB STATUS;
-- LATEST DETECTED DEADLOCK 部分显示死锁信息
不同场景的锁选择
场景一:库存扣减(强一致性)
需要严格串行化,用悲观锁:
BEGIN;
SELECT * FROM inventory WHERE sku_id = ? FOR UPDATE;
-- 检查库存
IF available < quantity THEN ROLLBACK; RETURN '库存不足'; END IF;
UPDATE inventory SET available = available - ? WHERE sku_id = ?;
COMMIT;
也可以用乐观锁:
-- Java 伪代码
Inventory inv = jdbc.queryForObject(
"SELECT available, version FROM inventory WHERE sku_id = ?", skuId);
if (inv.available < quantity) {
throw new BizException("库存不足");
}
int updated = jdbc.update(
"UPDATE inventory SET available = available - ?, version = version + 1 WHERE sku_id = ? AND version = ?",
quantity, skuId, inv.version);
if (updated == 0) {
// 乐观锁失败,重试
return retry();
}
场景二:用户余额修改(金融级)
用悲观锁 + 分布式锁双重保护:
// 分布式锁
lock.lock(userId);
try {
// 悲观锁
jdbc.execute("SELECT * FROM account WHERE user_id = ? FOR UPDATE", userId);
// 余额校验和更新
jdbc.execute("UPDATE account SET balance = balance - ? WHERE user_id = ?", amount, userId);
} finally {
lock.unlock(userId);
}
场景三:文章阅读量更新(最终一致)
不需要强一致,用异步更新:
-- 直接更新,不加锁
UPDATE article SET read_count = read_count + 1 WHERE id = ?;
或者用内存队列批量更新,减少数据库压力。
场景四:订单状态流转(状态机)
用悲观锁,但配合状态校验:
BEGIN;
SELECT * FROM orders WHERE id = ? FOR UPDATE;
-- 状态校验
IF status != '待支付' THEN ROLLBACK; RETURN '状态不允许'; END IF;
-- 状态更新
UPDATE orders SET status = '已支付', pay_time = NOW() WHERE id = ?;
COMMIT;
锁性能优化
减少锁粒度
-- 锁定整张表
SELECT * FROM orders FOR UPDATE; -- 危险
-- 锁定特定记录
SELECT * FROM orders WHERE id = ? FOR UPDATE; -- 推荐
缩小锁范围
-- 锁定整张表(如果 user_id 无索引)
SELECT * FROM orders WHERE user_id = 100 FOR UPDATE;
-- 加索引,锁定特定记录
ALTER TABLE orders ADD INDEX idx_user_id (user_id);
SELECT * FROM orders WHERE user_id = 100 FOR UPDATE; -- 只锁 100 这条
减少锁时间
-- 错误:在事务里做不必要的查询
BEGIN;
SELECT * FROM orders WHERE id = ? FOR UPDATE;
SELECT * FROM products WHERE id = ?; -- 不相关,浪费时间
SELECT * FROM config WHERE key = 'xxx'; -- 不相关
UPDATE orders SET status = 2 WHERE id = ?;
COMMIT;
-- 正确:只锁必要的
BEGIN;
UPDATE orders SET status = 2 WHERE id = ?;
COMMIT;
使用低隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
-- 在允许幻读的场景,减少 Next-Key Lock
COMMIT;
常见锁问题排查
问题一:Lock wait timeout exceeded
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
原因:事务等待锁时间超过 innodb_lock_wait_timeout。
排查:
SELECT * FROM information_schema.INNODB_TRX
WHERE trx_state = 'LOCK WAIT';
处理:
- 优化 SQL,减少锁持有时间
- 降低并发度
- 检查是否有死锁
问题二:Table 'xxx' was locked with a READ lock and can't be executed
这是 MyISAM 表的表锁,InnoDB 一般不会有这个问题。
如果用了 MyISAM,建议迁移到 InnoDB。
问题三:The table 'xxx' is full
InnoDB 临时表满了,可能是:
- 排序操作产生临时表过大
tmp_table_size和max_heap_table_size配置太小
SHOW VARIABLES LIKE 'tmp_table_size';
SHOW VARIABLES LIKE 'max_heap_table_size';
锁的最佳实践
选择合适的锁类型:
- 冲突多、写密集 → 悲观锁
- 冲突少、读多写少 → 乐观锁
加索引减少锁范围:
- 无索引的查询会锁整张表
- 唯一索引比范围查询锁粒度小
保持加锁顺序一致:
- 多表操作时,按相同顺序加锁
- 避免不同事务交叉等待形成环路
事务尽量短:
- 减少锁持有时间
- 把非必要操作移出事务
避免在事务里做远程调用:
- RPC、消息发送都可能在持锁期间失败
- 导致事务回滚但锁未释放
做好超时和重试:
- 设置合理的
innodb_lock_wait_timeout - 乐观锁失败要有重试机制
- 设置合理的
总结
MySQL 锁机制选择的核心是:
- 悲观锁:先锁后操作,适合冲突频繁场景
- 乐观锁:最后检测,适合冲突少场景
- 行锁 vs 表锁:行锁并发好但开销大,表锁反之
- 记录锁 vs 间隙锁 vs Next-Key Lock:锁的范围递增
实际工作中,悲观锁和乐观锁往往配合使用:
- 业务层用乐观锁做版本校验
- 数据库层用悲观锁做并发控制
- 分布式锁做最后一层保护
理解锁机制是解决并发问题的前提,选择合适的锁类型和用法,能让系统在性能和一致性之间找到平衡。
