乐观锁与悲观锁怎么实现?
结论:悲观锁假定冲突一定会发生,先加锁再操作,靠数据库的
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;
三个必须注意的点:
- 必须走索引。
WHERE没索引时,InnoDB 会锁住扫描过的所有行,等价于锁表。 - RR 下会加间隙锁。范围条件
WHERE id BETWEEN 1 AND 10 FOR UPDATE会锁住区间与间隙,阻塞插入。 - 事务要短。持锁期间不要做 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 的场景,原子更新往往比两者都简单。