选锁之前,先让数据说话
「并发扣减库存该用乐观锁还是悲观锁」这个问题,网上的答案经常是矛盾的:有人说乐观锁在冲突少时性能好,有人说悲观锁在 MySQL 上更稳,还有人说直接上 Redis 分布式锁。
这些说法各自都有成立的场景,所以争论本身没有意义。冲突概率、临界区大小、部署形态这三个变量不同,结论就会翻转。
这篇讲的是怎么把这个问题变成一次可以复现的实验。
先定义清楚三种方案
乐观锁:不加数据库锁,靠版本号或条件更新保证正确性。
UPDATE stocks
SET available = available - ?, version = version + 1
WHERE sku_id = ? AND available >= ? AND version = ?;
检查 RowsAffected(),为 0 就说明发生了冲突,重试或返回失败。
悲观锁:先把行锁住再操作。
BEGIN;
SELECT available FROM stocks WHERE sku_id = ? FOR UPDATE;
UPDATE stocks SET available = available - ? WHERE sku_id = ?;
COMMIT;
分布式锁:在数据库之外用 Redis 之类的组件加锁,适用于多实例、或者临界区包含非数据库资源的情况。
三个决定性变量
在写压测之前,先把变量定下来。因为它们才是结论的真正来源。
冲突概率
就是「同一时刻有多少请求在操作同一行」。这是影响最大的变量:
- 冲突低(比如每个用户操作自己的数据)→ 乐观锁几乎没有重试,性能最好
- 冲突高(比如秒杀同一个 SKU)→ 乐观锁会大量重试,重试本身又加剧冲突,可能出现雪崩
临界区大小
事务里除了那一条 UPDATE 还做了什么。悲观锁会把整个临界区串行化,所以临界区里如果有个 50ms 的 HTTP 调用,QPS 上限就被钉死在 20。
部署形态
单实例还是多实例。乐观锁和悲观锁都依赖数据库本身,天然跨实例安全;进程内的 sync.Mutex 在多实例下完全无效。
压测怎么写
Go 的 testing.B 配 RunParallel 就能做,不需要引入额外的压测框架:
func BenchmarkOptimistic(b *testing.B) {
db := setupDB(b)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
for {
err := optimisticDeduct(db, skuID, 1)
if errors.Is(err, ErrConflict) {
continue // 冲突就重试
}
if err != nil {
b.Fatal(err)
}
break
}
}
})
}
几个需要注意的点:
用 RunParallel 而不是自己起 goroutine。 它会根据 -cpu 参数调整并发度,让你能一条命令跑出不同并发下的曲线:
go test -bench=. -cpu=1,4,8,16 -benchtime=5s
每个 benchmark 独立准备数据。 共用一个数据库会让前一个测试的锁等待污染后一个。
必须校验正确性,不能只看吞吐。 压测结束后检查最终库存是否等于「初始值 - 成功次数」。如果为了跑得快而丢掉了正确性,那数字毫无意义。上面那段代码里 b.Fatal 就是干这个的。
该记录哪些指标
光看 ns/op 是不够的,它会被少数极慢请求拉偏。至少记录四个:
| 指标 | 说明 |
|---|---|
| 吞吐(op/s) | benchmark 直接给出 |
| P50 / P95 / P99 延迟 | 需要自己用 hdrhistogram 之类的库收集 |
| 冲突/重试次数 | 乐观锁的关键指标,直接反映冲突率 |
| 错误数 | 死锁、超时、锁等待超时都算 |
其中冲突次数最容易被忽略,但它解释了为什么乐观锁在高冲突下会崩:重试放大了总请求量,而总请求量上升又提高了冲突率——一个正反馈循环。
悲观锁的表现则通常是:吞吐随并发上升到某个点就平台化,因为瓶颈变成了锁排队;但 P99 相对平稳,因为大家都在队列里等着,不会突然雪崩。
结论该怎么下
做完实验后,结论应该长这样才算完整:
在冲突概率约 X%、临界区仅包含单条 UPDATE 的条件下,乐观锁在并发 ≤ N 时吞吐高于悲观锁;超过 N 后冲突率上升,吞吐反超。因此本场景选用悲观锁,并把临界区内的外部调用全部移出事务。
注意几个要素:条件、数字、适用范围、对应的决策。缺了条件的结论就是又一次制造争论的素材。
为什么值得花这个时间
因为「我觉得乐观锁更快」和「我测出来在冲突率 20% 时悲观锁吞吐高 40%」在技术评审里的分量完全不同。
前一句是观点,后一句是证据。观点可以被另一个观点顶回去,证据只能被另一份证据推翻——而推翻证据的成本,比推翻观点高得多。
写压测的那两个小时,通常比争论两个小时划算。