选锁之前,先让数据说话

「并发扣减库存该用乐观锁还是悲观锁」这个问题,网上的答案经常是矛盾的:有人说乐观锁在冲突少时性能好,有人说悲观锁在 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%」在技术评审里的分量完全不同。

前一句是观点,后一句是证据。观点可以被另一个观点顶回去,证据只能被另一份证据推翻——而推翻证据的成本,比推翻观点高得多。

写压测的那两个小时,通常比争论两个小时划算。