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 LockX 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_sizemax_heap_table_size 配置太小
SHOW VARIABLES LIKE 'tmp_table_size';
SHOW VARIABLES LIKE 'max_heap_table_size';

锁的最佳实践

  1. 选择合适的锁类型

    • 冲突多、写密集 → 悲观锁
    • 冲突少、读多写少 → 乐观锁
  2. 加索引减少锁范围

    • 无索引的查询会锁整张表
    • 唯一索引比范围查询锁粒度小
  3. 保持加锁顺序一致

    • 多表操作时,按相同顺序加锁
    • 避免不同事务交叉等待形成环路
  4. 事务尽量短

    • 减少锁持有时间
    • 把非必要操作移出事务
  5. 避免在事务里做远程调用

    • RPC、消息发送都可能在持锁期间失败
    • 导致事务回滚但锁未释放
  6. 做好超时和重试

    • 设置合理的 innodb_lock_wait_timeout
    • 乐观锁失败要有重试机制

总结

MySQL 锁机制选择的核心是:

  • 悲观锁:先锁后操作,适合冲突频繁场景
  • 乐观锁:最后检测,适合冲突少场景
  • 行锁 vs 表锁:行锁并发好但开销大,表锁反之
  • 记录锁 vs 间隙锁 vs Next-Key Lock:锁的范围递增

实际工作中,悲观锁和乐观锁往往配合使用:

  • 业务层用乐观锁做版本校验
  • 数据库层用悲观锁做并发控制
  • 分布式锁做最后一层保护

理解锁机制是解决并发问题的前提,选择合适的锁类型和用法,能让系统在性能和一致性之间找到平衡。

最后更新 4/20/2026, 6:02:32 AM