分布式数据库的乐观锁与悲观锁选型,本质上是在并发冲突率和系统吞吐量之间做权衡。秒杀场景下这个选择尤其敏感,因为流量高度集中,数据热点极度聚焦,选错锁策略轻则库存超卖,重则数据库连接池被打满导致整个服务雪崩。很多人一上来就排斥悲观锁,觉得它性能差、容易死锁,但在秒杀这种库存扣减场景里,悲观锁恰恰是最安全、最不容易出错的方案。问题的核心不在于用不用悲观锁,而在于怎么用、在哪里用、以及如何配合分布式数据库的特性去设计锁的粒度。
悲观锁在秒杀中的正确打开方式悲观锁的逻辑很简单:在读取库存数据的那一刻,就假定一定会有别人来抢,所以先把数据锁住,别人别想动,等我操作完再说。在单体数据库里,这通常通过 SELECT ... FOR UPDATE 实现。但在分布式数据库中,事情变得复杂了。以 TiDB 为例,它的悲观锁实现经历了多个版本的迭代,早期版本在悲观事务下遇到写写冲突时,客户端会收到类似 "Write Conflict" 的错误,需要应用层重试,这在高并发秒杀下重试风暴会直接把系统打崩。现在主流的分布式数据库如 TiDB 6.0 以后、OceanBase 4.x,都支持了内存悲观锁机制,锁信息不再全部持久化到磁盘,而是在内存中管理,锁冲突检测效率大幅提升。
具体到秒杀扣库存的 SQL,正确的写法不是简单的 SELECT FOR UPDATE 然后 UPDATE,而是要把锁竞争压缩到最小粒度。比如在扣减库存时,直接在 UPDATE 语句中带上条件判断,利用数据库的行锁机制完成原子扣减。像下面这样:
UPDATE seckill_stock SET stock = stock - 1 WHERE product_id = 1001 AND stock > 0;
这条 SQL 在分布式数据库中执行时,会先对 product_id=1001 那一行加悲观锁,然后检查 stock>0 的条件,满足才扣减。如果 stock 已经为 0,这条 UPDATE 影响行数为 0,应用层判断返回值就知道库存没了。整个过程只有一次网络往返,锁持有时间极短。如果先 SELECT 查库存,再在应用层判断,最后再 UPDATE,这中间的时间差在秒杀场景下足够让库存被超卖几十次。
悲观锁最大的坑在于死锁和锁等待超时。分布式数据库的死锁检测机制通常比单机数据库更复杂,因为事务可能跨多个节点。秒杀场景下如果多个热点商品同时被大量并发请求冲击,锁等待队列会迅速拉长。解决思路是缩小事务范围,把非必要的操作移出事务。比如扣减库存和生成订单,绝对不要放在同一个数据库事务里。库存扣减用悲观锁保证不超卖,订单生成可以用异步消息队列,失败了可以补偿重试,但库存扣减一旦成功就不能回滚去等订单创建。
乐观锁的适用边界与常见翻车现场乐观锁的思路反过来:我先不锁,读库存的时候记一个版本号,更新的时候检查版本号有没有变,变了就说明有人抢在我前面改了,我就重试或者失败。在秒杀场景用乐观锁,最经典的翻车就是 ABA 问题和大量无效重试。比如库存从 10 减到 9,版本号从 1 变成 2,另一个请求读到版本号 1,它想减库存的时候发现版本号已经是 2 了,更新失败,触发重试。在高并发下,大量请求会陷入重试循环,数据库的 CPU 和网络带宽被大量无效的冲突检测消耗掉,吞吐量反而比悲观锁还低。
乐观锁在秒杀中不是不能用,而是要满足一个前提条件:冲突率足够低。如果秒杀的商品库存量很大,比如几万件,瞬时并发虽然高但冲突概率相对分散,乐观锁配合合理的重试策略是可以工作的。但如果库存只有几百件甚至几十件,几十万人同时抢,乐观锁的重试开销会指数级上升。这时候用乐观锁就是在用数据库资源做无用功。
在分布式数据库中实现乐观锁,通常依赖表上的 version 字段。写法如下:
UPDATE seckill_stock SET stock = stock - 1, version = version + 1 WHERE product_id = 1001 AND stock > 0 AND version = 10;
应用层检查 affected rows,如果是 0 就说明版本号变了或者库存没了,需要重试或者直接返回失败。这个方案的问题在于,分布式数据库的版本号更新本身也需要在多个副本间同步,乐观锁的冲突检测并不是免费的。在 Spanner 或 CockroachDB 这类基于全局时钟的数据库中,乐观事务在提交时如果发现时间戳冲突,整个事务会被 abort,应用层必须实现重试逻辑。秒杀场景下这种 abort 率可能高达 90% 以上。
分布式数据库特有的锁机制考量分布式数据库的锁实现和单体 MySQL 有本质区别。单体 MySQL 的锁是集中式的,锁管理器就在同一个进程里,加锁解锁的延迟在微秒级。分布式数据库的锁可能跨节点协调,比如 TiDB 的悲观锁需要和 TiKV 的 Raft Leader 交互,OceanBase 的锁需要和分区 Leader 交互。虽然现代分布式数据库都在优化锁的本地化,比如让热点行的 Leader 尽量固定在某个节点,但网络往返的延迟依然比本地内存操作高一个数量级。
这意味着在分布式数据库中,锁持有的时间要尽可能短。一个常见的错误是在事务中执行外部服务调用,比如扣库存后调用用户服务检查用户资格,这会让数据库锁持有时间从毫秒级变成秒级,锁等待队列瞬间爆炸。正确的做法是把所有依赖外部服务的逻辑放在数据库事务之前完成,事务内只做纯粹的库存扣减。
另一个容易被忽略的点是分布式数据库的隔离级别。很多分布式数据库默认的隔离级别是读已提交或快照隔离,而不是可串行化。在秒杀场景下,如果用读已提交配合乐观锁,可能会出现读取到未提交的中间状态,导致库存判断出错。比如事务 A 扣了库存但还没提交,事务 B 读到的库存已经是扣减后的值,事务 B 以为还有库存继续扣,结果事务 A 回滚了,库存就凭空多扣了一次。解决方法是把秒杀相关的查询和更新都放在可重复读或串行化隔离级别下,或者直接用 SELECT FOR UPDATE 强制加锁读取最新提交的数据。
混合策略:热点分离与库存分段真正高并发的秒杀系统,很少只用单一的锁策略。一个经过验证的有效方案是库存分段加悲观锁。把总库存拆成多个桶,比如 1000 件库存拆成 20 个桶,每个桶 50 件。用户请求进来时,通过哈希算法路由到某个桶,然后对这个桶的库存行加悲观锁扣减。这样原本 1000 个并发请求争抢一行数据,变成了 20 个桶各自承载约 50 个并发请求,锁竞争强度直接降了一个数量级。桶的数量可以根据压测结果动态调整,桶越多竞争越小,但库存碎片化也越严重,可能导致部分桶卖光后其他桶还有剩余,需要有个汇总逻辑来判断总库存是否耗尽。
这个方案的 SQL 实现并不复杂:
-- 假设库存分成 20 个桶,bucket_id 从 0 到 19
UPDATE seckill_stock_bucket
SET stock = stock - 1
WHERE product_id = 1001
AND bucket_id = #{bucket_id}
AND stock > 0;
应用层根据用户 ID 或请求序列号计算 bucket_id,比如取模 20。如果某个桶的库存变成 0,后续路由到该桶的请求直接快速失败,不用再竞争数据库锁。这个方案结合了悲观锁的安全性和分段锁的并发能力,在分布式数据库上表现稳定。
还有一种方案是 Redis 预扣库存加数据库最终确认。Redis 用 Lua 脚本原子性地扣减库存,扛住第一波流量冲击,然后异步同步到数据库。这个方案的核心在于 Redis 和数据库之间的数据一致性怎么保证。如果 Redis 扣减成功但数据库写入失败,需要回滚 Redis 库存,这个过程如果处理不好就会导致少卖。实践中通常用消息队列做最终一致性,Redis 扣减成功后发消息到队列,消费者负责写入数据库,写入失败则通过补偿机制回补 Redis 库存。这个方案对运维要求较高,但吞吐量可以达到数据库直接扣减的十倍以上。
监控与兜底:锁策略不是银弹无论选哪种锁策略,秒杀系统的稳定性最终取决于监控和兜底机制。锁等待时间、死锁次数、事务重试率、库存扣减成功率,这些指标必须实时监控。分布式数据库通常提供了丰富的监控视图,比如 TiDB 的 INFORMATION_SCHEMA.DEADLOCKS 表可以查到最近发生的死锁信息,OceanBase 的 V$OB_TRANSACTION_PARTICIPANTS 可以看事务参与者的锁状态。一旦发现锁等待时间异常增长,就要考虑是否要触发限流或者降级,比如把库存扣减从实时扣减切换为预扣模式,或者直接提示用户排队等待。
兜底机制上,库存对账是最重要的一环。秒杀结束后,必须用离线任务对账,把数据库里的最终库存和订单量做比对,发现差异立即告警。超卖一件在业务上可能就是几百个客诉,少卖一件则是收入损失。对账脚本不需要实时跑,秒杀结束后五分钟跑一次,用简单的 SQL 就能查出问题:
SELECT product_id,
initial_stock - final_stock AS deducted,
COUNT(order_id) AS order_count
FROM stock_log
JOIN orders ON stock_log.product_id = orders.product_id
WHERE stock_log.seckill_id = 20241201
GROUP BY product_id
HAVING deducted != order_count;
这个查询把库存扣减量和实际订单量做比对,不一致的就是异常,需要人工介入排查。这套对账机制是最后的安全网,不管你前面用的是乐观锁还是悲观锁,最终都要靠数据说话。
回到选型本身,我的建议很明确:秒杀场景下,库存扣减用悲观锁,而且是带条件更新的单条 SQL 悲观锁,不要用先查后改的两步操作。如果并发量实在太大,悲观锁的行级竞争成为瓶颈,再考虑库存分段或者 Redis 预扣。乐观锁在秒杀中的适用面很窄,只适合库存量极大、冲突率极低的场景,大部分业务秒杀都不满足这个条件。分布式数据库的锁机制在持续进化,但锁竞争的本质没变,减少锁持有时间、缩小锁粒度、避免长事务,这些原则在哪个数据库上都适用。
