高并发场景中,乐观锁和悲观锁哪个更适合?
结论:没有绝对更优,取决于冲突概率。冲突低(读多写少、行级争用少)用乐观锁(Optimistic Lock),避免长时间持锁;冲突高(秒杀扣库存、同一行被高频更新)用悲观锁(Pessimistic Lock)或「串行化 + 排队」,因为乐观锁在高冲突下会退化成大量失败重试,把 CPU 全烧在自旋上。
一、两种锁的代价模型
| 维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 实现 | 版本号、CAS、WHERE version = ? | SELECT ... FOR UPDATE、分布式锁 |
| 持锁时间 | 短,仅提交瞬间 | 长,覆盖整个事务 |
| 冲突低时 | 吞吐高,无阻塞 | 无谓排队 |
| 冲突高时 | 重试风暴、失败率飙升 | 排队但可控,易死锁 |
| 跨行或跨库 | 天然支持 | 需分布式锁,代价高 |
| 用户体验 | 冲突时重试或提示稍后 | 等待,超时失败 |
二、冲突率决定成败
单次尝试成功概率 P ≈ 1 - c (c 为冲突概率)
重试 k 次后的失败概率 ≈ c^k
c = 0.1,k = 3 → 失败率 0.1%
c = 0.5,k = 3 → 失败率 12.5%
c = 0.9,k = 3 → 失败率 72.9% ← 乐观锁在高冲突下基本失效
冲突率可用「同一秒内同一行的更新次数 ÷ 该行 QPS」估算。超过 30% 就该考虑换方案。
三、乐观锁实现
UPDATE t_stock
SET quantity = quantity - #{n},
version = version + 1
WHERE sku_id = #{skuId}
AND version = #{version}
AND quantity >= #{n};
-- 影响行数为 0:被其他请求抢先,应用层退避重试或直接失败
public boolean deduct(long skuId, int n, int maxRetry) {
for (int i = 0; i < maxRetry; i++) {
Stock s = stockMapper.selectById(skuId);
if (s.getQuantity() < n) {
return false;
}
int rows = stockMapper.deductWithVersion(skuId, n, s.getVersion());
if (rows == 1) {
return true;
}
backoff(i); // 指数退避 + 抖动,避免重试同步化
}
return false;
}
四、高冲突改用分段加原子操作
秒杀更常见的做法是「库存分段 + Redis 原子扣减」,把单行争用拆成 N 行:
-- KEYS[1] = stock:sku:1001:seg (Hash:段号 -> 剩余库存)
-- ARGV[1] = 购买数量
local segs = redis.call('HGETALL', KEYS[1])
for i = 1, #segs, 2 do
local seg, qty = segs[i], tonumber(segs[i + 1])
if qty >= tonumber(ARGV[1]) then
redis.call('HINCRBY', KEYS[1], seg, -tonumber(ARGV[1]))
return 1
end
end
return 0
数据库侧再异步落库,用消息队列把同一 SKU 的写操作串行化,把「锁竞争」转换为「排队」。
五、常见追问
Q1:乐观锁会有 ABA 问题吗?
版本号自增不会 ABA;用 CAS(旧值, 新值) 直接比较数值才会。加版本号或时间戳即可规避。
Q2:悲观锁会不会把连接池占满?
会。FOR UPDATE 的事务越长,连接占用越久,高并发下极易打满连接池。必须把事务缩到「查、改、提交」三步,禁止在锁内调用 RPC。
Q3:分布式场景怎么选? 同一行数据在单库时用数据库行锁;跨库或跨服务时用 Redis、etcd 分布式锁,但要接受锁服务本身的延迟与故障风险。更优的是无锁设计:幂等、唯一约束、状态机。
Q4:乐观锁失败后要不要重试? 要,但要有上限(通常 3 次)和退避抖动。无上限重试在冲突高时会把线程池和数据库一起拖垮。
小结
- 低冲突用乐观锁省锁开销,高冲突用悲观锁或排队避免重试风暴。
- 冲突率超过 30% 就该换方案,别靠加大重试次数硬扛。
- 真正的解法常常是拆热点(分段库存),而不是换锁。