2436 字
约 8 分钟
16
S08-A15 · 乐观锁与悲观锁:在持久层怎么选

库存扣减用乐观锁:UPDATE ... SET stock = stock - 1, version = version + 1 WHERE id = ? AND version = ?。低峰时一切正常,促销峰值时失败率飙到 40%——因为同一条记录被十几个请求同时更新,绝大多数 version 校验失败,业务层只好重试,重试又加剧了争用。

这个乘积可以读成一个判断顺序:先问"同一行会被多少人同时改",再问"改一次要占多久"。前一问决定要不要乐观,后一问决定能不能悲观——顺序反了就会得到"我用了乐观锁但还是排队"这类结论。

乐观锁与悲观锁的选择不是"哪个先进",而是冲突概率与持有时间的乘积:冲突少时乐观锁几乎无成本;冲突多时,乐观锁的重试成本会指数上升。


1. 两种锁的成本结构

维度 乐观锁 悲观锁
冲突少 几乎无额外成本 无谓地阻塞
冲突多 重试风暴 排队但可预测
持有时间短 不合适(重试贵) 合适
持有时间长 略优 长时间占锁

第三项是关键:事务很短时,悲观锁的等待时间也短,而乐观锁的重试要重新走一遍完整事务。所以"短事务 + 高冲突"反而是悲观锁更合适——这与直觉相反。

2. 冲突概率怎么估

依据 说明
热点数据占比 少数行承担多数更新
峰值并发同键率 同一 key 的并发请求数
更新频率 每秒对同一行的更新次数
重试代价 一次重试的事务成本

第二项是最直接的指标:同一秒内对同一 id 的更新请求数。如果是 10,乐观锁的失败率可能接近 90%;如果是 1,几乎不会失败。

第三项(更新频率)还可以从一个侧面观察:同一行的 binlog 条目密度。如果某张表的写入有 90% 集中在少数几行,那这张表迟早要为那几行单独设计。

第一项在业务上很常见:库存、余额、计数器、状态机都是天然热点,而用户资料、配置项很少有并发。

// 冲突率粗算(Little 定律的简化用法)
同键并发数 N,单次事务耗时 T
冲突概率 ≈ 1 - (1 - 1/N)^N → N=10 时约 0.65,N=20 时约 0.64 但重试代价翻倍
// 经验阈值:N > 3 时乐观锁开始明显退化;N > 8 时应考虑悲观锁或分段

3. 四种实现与代价

实现 特点
version 字段 标准乐观锁,需读写两次
WHERE stock >= n 条件更新,天然幂等
SELECT ... FOR UPDATE 悲观锁,占用连接
分段/分桶计数 把热点拆成 N 份

第二项最被低估:"库存足够才扣减"这件事可以只用一条带条件的 UPDATE 表达,不需要 version,也不需要两次往返。它在冲突多时表现远好于 version。

第四项的代价要算清:总量查询从"一次点查"变成"聚合 10 行",而且当某个桶扣空时不能简单跳到下一个(会造成分布不均)。所以它只在"读总量频率远低于扣减"的场景里划算。

第四项是应对极致热点的做法:把一行拆成 10 行,随机选一行扣减,总量是求和。代价是"查总量"变贵、扣减有上限(每桶独立)。

-- 条件更新:一条语句完成"判断 + 扣减",无重试
UPDATE stock SET available = available - 1
WHERE sku = 'A-12' AND available >= 1;
-- 影响行数 0 → 库存不足;1 → 扣减成功。无需 version,无需重试

4. 悲观锁的两个真实成本

成本 表现
连接占用 连接池被长时间持有
锁等待链 排队导致 P99 上升

第一项在高并发下非常致命:FOR UPDATE 期间连接不能归还,等待锁的线程都在占连接。若连接池 20,而热点行有 30 个并发事务,其余 10 个连数据库都进不来。

还有一个更隐蔽的成本:FOR UPDATE 会锁索引记录,锁定范围取决于查询条件。条件没有走索引时,锁可能退化为大范围扫描锁——于是"只锁一行"变成了"锁住一片",而执行计划里看不出来。

第二项表现为"平均延迟不高但 P99 很差",与队头阻塞同源。

所以悲观锁的正确用法是尽量缩短持有时间:先做所有非数据库的准备工作,最后一步才进事务加锁。

// 悲观锁:把准备工作放在事务外,缩短持锁时间
public void deduct(String sku, int n) {
    var pre = prepare(sku, n);                 // 事务外:校验、计算
    txTemplate.executeWithoutResult(s -> {
        var row = repo.selectForUpdate(sku);    // 事务内:只做加锁与更新
        if (row.available() < n) throw new InsufficientStock();
        repo.update(sku, row.available() - n);
    });
}

5. 重试策略:乐观锁的必要配套

参数 建议
最大重试次数 3(超过则降级或报错)
退避 随机化(避免同步重试)
重试命中率监控 失败率与重试次数
幂等保障 重试不能重复执行副作用

第一项是硬要求:无限重试在冲突高时会形成正反馈,把所有请求都拖成"重试机器"。

第三项(重试命中率监控)要落到两个数:首次成功率与平均重试次数。首次成功率下降到 70% 以下时,说明冲突已经超出乐观锁的适用区间,此时加机器无益,应当换机制。

第二项常被忽略:固定间隔重试会让所有失败者同时重试,形成新的冲突峰值。加随机抖动能打散。

第四项在"重试 + 外部调用"时最危险:重试把"发消息"执行了两次,所以带副作用的操作必须先落地幂等键。

// 乐观锁重试的正确形态
for (attempt = 1; attempt <= 3; attempt++) {
    if (tryUpdate()) return ok;
    sleep(rand(10, 50) * attempt);      // 随机退避 + 递增
}
return conflict();                       // 3 次后放弃,交给上层或用户重试

6. 排错:五种"锁用错了"

现象 成因 处理方向
峰值时失败率高 乐观锁冲突 改条件更新或悲观锁
平均延迟正常、P99 差 悲观锁排队 缩短持锁时间
连接池耗尽 长事务持锁 减少事务内非 DB 工作
计数总是偏少 重试中有非幂等副作用 加幂等键
热点行导致全表慢 单行锁竞争 分段/分桶

第五项(单行锁竞争导致全表慢)的排查方式:看锁等待的 top 对象(MySQL 的 performance_schema.data_locks,或 SHOW ENGINE INNODB STATUS 的最近死锁段)。热点行会以极高频率出现在那里。

-- 当前锁等待:谁在等谁
SELECT * FROM performance_schema.data_locks WHERE lock_status = 'WAITING' LIMIT 20;
SELECT * FROM sys.innodb_lock_waits\G
-- 同一 lock_trx_id 反复出现 → 该行是热点

第四项很隐蔽:"扣了库存但订单没创建成功",重试时又扣一次——表现为库存少得比订单多。这类问题只有对账才能发现。

7. 边界:什么情况不需要锁

场景 为什么 做法
单写入者(同一线程/队列) 无并发 不需要
追加型数据(日志、事件) 无更新冲突 不需要
幂等的绝对值更新(SET x = 5) 无读改写 不需要
已用消息队列串行化 顺序保证 不需要

第三项容易被忽略:"设为某值"不需要锁,"基于当前值计算新值"才需要。区分这两者能去掉一半不必要的锁。

最后一条与"数据一致性"有关的边界:锁只解决并发写,不解决"多副本"。如果同一份库存在读写分离或缓存,锁的作用范围止于主库——读到的旧值仍可能来自从库或缓存,那需要另外的机制。

判断口径一句话:这次更新是否依赖"读到的旧值"。依赖,就需要锁或条件更新;不依赖(覆盖写),不需要。


8. 自测

  1. 两种锁的成本结构在不同冲突概率下如何反转?为什么"短事务 + 高冲突"反而适合悲观锁?
  2. 冲突概率该用什么指标估?"同键并发数"为什么最直接?
  3. 为什么"条件更新"比 version 乐观锁更省?它在冲突多时的表现如何?
  4. 悲观锁的两个真实成本是什么?为什么它会导致连接池耗尽?
  5. 乐观锁重试为什么必须随机退避?固定间隔会造成什么?
  6. 判断"是否需要锁"的那个问题是什么?
S08-A15 · 乐观锁与悲观锁:在持久层怎么选
https://www.clxhxhhr.top/posts/6478/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。