乐观锁与悲观锁怎么实现?

#中间件#面试八股

乐观锁与悲观锁怎么实现?

结论:悲观锁假定冲突一定会发生,先加锁再操作,靠数据库的 SELECT ... FOR UPDATE 或分布式锁实现;乐观锁假定冲突很少,先读版本号、提交时校验版本是否被改过,靠 UPDATE ... WHERE version = ? 的影响行数判断成败。选型看冲突概率与临界区长度,而不是看哪个「更先进」。

一、对照表

维度悲观锁乐观锁
核心思想先锁后改,冲突在入口排队先改后验,冲突在提交时暴露
实现载体数据库行锁、FOR UPDATE、Redis 锁版本号、时间戳、状态机条件更新
加锁时机读取时提交时(无显式锁)
冲突代价等待、可能死锁失败重试,多一次读
适合场景冲突高、临界区长、必须串行冲突低、读多写少、可重试
数据库压力持锁期间占用连接与行锁无长事务,连接周转快
失败表现锁等待超时(1205)、死锁(1213)影响行数为 0

二、悲观锁:SELECT ... FOR UPDATE

START TRANSACTION;

-- 对 id=88 这一行加排他锁,其他事务的写与 FOR UPDATE 都要等
SELECT stock FROM sku WHERE id = 88 FOR UPDATE;

-- 业务判断 ...
UPDATE sku SET stock = stock - 1 WHERE id = 88;

COMMIT;

三个必须注意的点:

  1. 必须走索引。WHERE 没索引时,InnoDB 会锁住扫描过的所有行,等价于锁表。
  2. RR 下会加间隙锁。范围条件 WHERE id BETWEEN 1 AND 10 FOR UPDATE 会锁住区间与间隙,阻塞插入。
  3. 事务要短。持锁期间不要做 RPC、发消息、等用户输入,否则连接池很快被打满。

共享锁版本(允许其他事务读、禁止写):

SELECT stock FROM sku WHERE id = 88 LOCK IN SHARE MODE;   -- 8.0 也可写 FOR SHARE

三、乐观锁:版本号 CAS

-- 表结构
ALTER TABLE sku ADD COLUMN version INT NOT NULL DEFAULT 0;

-- 读取
SELECT stock, version FROM sku WHERE id = 88;   -- stock=10, version=3

-- 提交:只有版本没变才更新成功
UPDATE sku
SET stock = stock - 1, version = version + 1
WHERE id = 88 AND version = 3;
-- 影响行数 = 1 成功;= 0 说明被别人改过,重试整个「读-算-写」

应用层的重试封装:

public boolean deduct(long skuId, int qty) {
    for (int i = 0; i < 3; i++) {
        Sku sku = skuMapper.selectById(skuId);
        if (sku.getStock() < qty) return false;
        int rows = skuMapper.casStock(skuId, sku.getVersion(), qty);
        if (rows == 1) return true;      // 成功
        // rows == 0:版本冲突,短暂退避后重试
    }
    return false;
}

冲突极高时,版本号重试会退化成「一直失败」,此时应改用「原子扣减 + 库存判断」:

UPDATE sku SET stock = stock - 1 WHERE id = 88 AND stock >= 1;

这条语句本身就是原子的,不需要版本号,也不需要先读——它把「比较」与「更新」压进了一条 SQL。

四、怎么选

场景推荐理由
秒杀扣库存原子 UPDATE ... WHERE stock >= n无读放大,失败即售罄
账户余额变更悲观锁或原子更新金额必须严格串行
后台编辑商品乐观锁(version)并发低,冲突时提示用户
状态机流转乐观锁(WHERE status = '待支付')天然带状态校验
跨服务资源分布式锁(Redis/ZooKeeper)数据库锁管不到其他资源

Redis 分布式锁在语义上属于悲观锁,SET key value NX PX 30000 加锁,释放时用 Lua 校验持有者。它解决的是「跨进程、跨库」的互斥,而不是单行并发。

常见追问

  • 乐观锁会有 ABA 问题吗? 版本号单调递增不会;如果只用「值相等」判断(如 WHERE stock = 10),就可能 ABA,必须用版本号或时间戳。
  • FOR UPDATE 没走索引会怎样? 扫描到的所有行都加锁,等价于表锁,极易引发锁等待与死锁。
  • 乐观锁重试几次合适? 2~3 次足够,再多说明冲突率已超出乐观锁的适用范围,应换方案。
  • 乐观锁要不要加锁? 数据库层面不加锁,但应用层可能用分布式锁保护重试过程,二者可以叠加。
  • UPDATE ... WHERE version = ? 会不会死锁? 单行更新不会;多行批量更新要固定顺序。
  • 读已提交和可重复读对乐观锁有影响吗? 有,RR 下 FOR UPDATE 带间隙锁,RC 下锁范围更小但可能出现不可重复读。

小结

悲观锁用「等待」换确定性,乐观锁用「重试」换吞吐。判断标准是冲突率与临界区长度:冲突高、逻辑长就悲观;冲突低、可重试就乐观。而像扣库存这种「比较 + 更新」能压进一条 SQL 的场景,原子更新往往比两者都简单。

笔记加载中…