秒杀开抢那一刻,库存 100 件的商品成交了 102 单。代码里明明加了 synchronized,为什么没挡住?
因为 synchronized 锁的是单个 JVM 的内存。服务部署成集群之后,每台机器各锁各的:实例 1 和实例 2 同时读到「库存还剩 1」,同时判断通过,同时扣减——超卖就这么发生了。进程内的并发控制是另一个话题(Python 的多线程甚至无法真正并行执行字节码,见 GIL 详解:Python 多线程为什么快不起来),这里要面对的是跨实例的问题。
分布式锁要解决的,就是把「同一时刻只有一个执行者」从单机内存搬到所有实例都认的共享存储上。但先给结论:电商里大部分看似需要分布式锁的场景,最优解其实不是锁,而是原子操作和唯一约束。这篇从数据库和 Redis 两条主流路线把实现讲透,再快速过一遍 ZooKeeper、etcd,最后落到选型决策和实践清单。
先立标准:一把合格的分布式锁要满足四条
在比较方案之前,先定验收标准,后面每个方案都拿这四条对照:
- 互斥:同一时刻,全集群只有一个客户端持有锁。
- 超时释放:持锁者宕机,锁不能永远占着,否则就是死锁。
- 不误删:只能释放自己持有的锁,不能删掉别人的。
- 高可用:锁服务自己不能是单点,它挂了互斥语义也就没了。
听起来简单,但每条都有方案栽在上面。
数据库方案:零新组件的三种玩法
电商系统本来就有 MySQL,用数据库实现锁不需要引入任何新组件,排查问题时链路也最短。按用法分三种。
InnoDB 是什么InnoDB 是 MySQL 5.5 以来的默认存储引擎,本节三种玩法都建立在它的两个特性上:事务(一组操作要么全部生效,要么全部回滚)和行级锁(锁加在索引记录上,只挡住访问同一行的并发)。对照组是老引擎 MyISAM:只有表锁、不支持事务,如今基本只存在于历史系统里。「锁加在索引记录上」这一点后面还会用到——
FOR UPDATE走不到索引时锁范围扩大,根源就在这里。
唯一索引:insert 成功才算拿到锁
建一张锁表,抢锁就是插入一行:
CREATE TABLE distribute_lock ( lock_key VARCHAR(128) NOT NULL PRIMARY KEY, holder VARCHAR(64) NOT NULL, expire_at DATETIME NOT NULL);
-- 抢锁:插入成功 = 拿到锁;报 Duplicate entry 就是没抢到INSERT INTO distribute_lock (lock_key, holder, expire_at)VALUES ('seckill:1001', 'instance-3', NOW() + INTERVAL 30 SECOND);
-- 释放:带上 holder 条件,防止删掉别人重新抢到的锁DELETE FROM distribute_lockWHERE lock_key = 'seckill:1001' AND holder = 'instance-3';PRIMARY KEY 的唯一性由 InnoDB 保证,并发 insert 同一个 lock_key 只有一个能成功,互斥性是天生的。
但它对照四条标准,缺口明显:
- 超时释放没有。持锁实例宕机,行就一直留在表里,得靠定时任务扫
expire_at清理,清理间隔就是死锁的最长持续时间。 - 非阻塞。没抢到只是收到一个 duplicate key 报错,想等待就得应用层自己写重试循环。
- 不可重入。同一个实例再次 insert 只会再报一次错,得自己在应用层记录持有状态。
所以这张锁表实际很少用。真正常见的是它的变种——业务唯一约束:
- 防重复下单:订单表给
user_id + idempotency_key建唯一索引,用户双击、前端重试、网络重传,最多落一单。 - 防重复领券:
user_coupon表对(user_id, coupon_id)建唯一索引,一个用户一张券就是一行。
这已经不太像「锁」,更像幂等设计。但它才是唯一索引在电商里的主力形态:与其抢到锁再保证只做一次,不如让「重复」在数据库层根本插不进去。
悲观锁:SELECT … FOR UPDATE 把行锁住
BEGIN;-- 锁住这一行,其他事务的 FOR UPDATE 会阻塞在这里SELECT stock FROM sku_stock WHERE sku_id = 1001 FOR UPDATE;-- 事务内完成判断和扣减UPDATE sku_stock SET stock = stock - 1 WHERE sku_id = 1001;COMMIT;这是最「像锁」的用法:读出来、判断、扣减,三步都在行锁保护下,其他事务进来就排队。账户余额、订单状态推进这种不能错的场景,它至今是稳妥的选择。
三个使用前提:
- 必须在事务里,事务提交锁才释放。
- WHERE 必须命中索引。InnoDB 锁的是索引记录,走不到索引时会锁住扫描过的所有行(RR 隔离级别下还附带间隙锁),表现上接近锁全表,并发直接塌掉。
- 事务要短。持锁期间做一次远程调用,就是把这条数据的并发上限交给了对端的响应时间。
硬伤也很直白:锁的时长 = 事务时长 = 数据库连接被占用的时长。秒杀这种热点行场景,所有请求在同一行上串行排队,连接池很快被打满。它保护得了正确性,扛不住热点。
乐观锁与原子 UPDATE:很多时候根本不需要锁
乐观锁的标准做法是 version 字段:
-- 读的时候记下 versionSELECT stock, version FROM sku_stock WHERE sku_id = 1001;-- 更新时比对 version,相等才更新并 +1UPDATE sku_stock SET stock = stock - 1, version = version + 1WHERE sku_id = 1001 AND version = 3;affected rows 是 1 就成功,是 0 说明期间被别人改过了,应用层重试。
但对扣库存这个具体场景,连 version 都可以省掉:
UPDATE sku_stock SET stock = stock - 1WHERE sku_id = 1001 AND stock > 0;-- affected rows = 1 扣减成功,= 0 库存不足单行 UPDATE 的原子性由 InnoDB 行锁在内部保证,stock > 0 的判断和扣减之间不会被并发插入。一行 SQL,没有锁表、没有事务管理、没有重试逻辑,就把超卖问题解决了。
这就是为什么开头说「最优解往往不是锁」:锁是协调手段,原子性才是正确性手段。能用一条原子 SQL 表达的不变量,就不该上升到分布式锁。
当然,单机 MySQL 扛不住秒杀级别的热点行更新压力,高并发场景会把库存前置到 Redis——这就引出了第二条路线。
Redis 方案:从 SETNX 到 Redisson 的五次进化
Redis 做锁的逻辑很直白:单线程执行命令、内存操作、所有实例连同一个 Redis,天然是共享存储。但「能跑」和「能上线」之间隔着好几层坑,按进化的顺序过一遍。
加锁必须原子:SET key value NX EX
最早的写法是两条命令:
SETNX lock:seckill:1001 1 # 不存在才设置成功EXPIRE lock:seckill:1001 30 # 再补一个过期时间问题:两条命令之间客户端宕机,锁永远没有过期时间——死锁,直接违反「超时释放」。
Redis 2.6.12 起 SET 支持扩展参数,一条命令原子完成:
SET lock:seckill:1001 7f3a9c2d NX EX 30NX 不存在才设置(互斥),EX 30 带上过期时间(防死锁)。这是加锁的最低正确形态。
释放必须原子:唯一 value + Lua
注意上面 value 不是随便填的 1,而是一个唯一标识(UUID 或实例 ID)。它要解决的是「误删」:
- 实例 A 拿到锁,业务执行超过 30 秒,锁自动过期。
- 实例 B 抢到同一把锁,开始执行。
- A 终于执行完,调用 DEL——删掉的是 B 的锁。
- 实例 C 趁机也抢到锁,互斥失效。
修法是释放前先确认「锁还是我的」,且比对和删除必须原子:否则比对完、删除前锁又过期被别人抢走,老问题换个时间点重演。DEL 没有条件删除的参数,所以用 Lua 把两步焊在一起(Redis 保证脚本整体原子执行):
-- KEYS[1] = 锁的 key, ARGV[1] = 加锁时写入的唯一标识if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1])else return 0end到这里,一个「最小正确」的 Redis 锁成型了:SET k uuid NX EX 30 加锁,Lua 脚本解锁。自己手写客户端,封装到这个程度才算及格。
业务没跑完,锁先过期了:Redisson 看门狗
最小版本还剩一个根本矛盾:过期时间设多长?设短了,业务没执行完锁就放走;设长了,持锁者宕机后其他实例干等。
Redisson(Java 生态最常用的 Redis 客户端)的答案是看门狗(watchdog):加锁默认给 30 秒,后台线程每 10 秒(过期时间的 1/3)检查一次——只要实例还活着、锁还被持有,就续回 30 秒。实例宕机,续期线程跟着死,锁最多残留 30 秒自动释放。
什么是租约时间租约时间(lease time)是锁的持有期限:到期没续上,锁就自动释放,「超时释放」这条标准就是靠它实现的。Redis 锁的
EX 30、Redisson 的leaseTime参数、后面会提到的 ZooKeeper 会话和 etcd lease,本质都是租约。难点在于设多长:太短误伤正常业务,太长宕机后恢复慢。看门狗的思路是不设固定租约,改成活着就自动续。
RLock lock = redisson.getLock("lock:seckill:" + skuId);try { // 最多等待 3 秒抢锁;不传租约时间,持有时长交给看门狗 if (!lock.tryLock(3, TimeUnit.SECONDS)) { return Result.fail("当前抢购人数过多,请稍后再试"); } // 拿到锁后的业务:Lua 预减库存、写订单消息……} catch (InterruptedException e) { Thread.currentThread().interrupt();} finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); }}生产环境建议直接上 Redisson 而不是手撸 Lua:看门狗、可重入计数、阻塞等待、锁重试,这些边界它都处理了。
传了 leaseTime,看门狗就没了
tryLock(3, 10, TimeUnit.SECONDS)这种带租约时间的重载会关闭看门狗:10 秒一到锁准时过期,业务没跑完也不管。只有在业务耗时有硬上限、并且这个上限远小于租约时才这么用;估不准就别传。
看门狗不是免死金牌看门狗续的是「实例活着」,续不了「实例被卡住」。一次几十秒的 Full GC、一次网络分区,都能让业务线程在锁早已过期后继续执行。锁过期和业务完成之间没有硬保证,这是 Redis 路线要认的账。
主从切换丢锁:Redis 路线的先天缺陷
生产 Redis 必然主从 + 哨兵,这带来最后一个坑:主从复制是异步的。
这不是配置问题。Redis 的 WAIT 命令能等到至少 N 个副本确认、收窄窗口,但消除不了——只要复制是异步的,「锁写入」和「锁可见」之间就永远有缝。
Redlock:多节点多数派,以及那场著名争论
antirez(Redis 作者)给出的官方补丁是 Redlock:部署奇数个(通常 5 个)互相独立、不做主从的 Redis 节点,客户端依次向所有节点加锁,拿到过半(≥3)才算成功;锁的有效时间 = TTL 减去加锁总耗时,超时就放弃并到所有节点解锁。思路是躲开异步复制窗口:单个节点宕机不影响多数派。
2016 年 Martin Kleppmann(《Designing Data-Intensive Applications》作者)发文《How to do distributed locking》公开质疑:Redlock 的安全性依赖三个假设——时钟不跳变、网络延迟有界、进程不被 GC 长时间停顿,而这三个假设在生产环境都会破。antirez 随后撰文《Is Redlock safe?》反驳。这场争论没有公认赢家,但留下了两个有价值的共识:
- 业务只是追求效率(比如定时任务防重,锁失效最多多跑一次),Redlock 乃至单节点 Redis 都够用。
- 锁失效意味着资金错误,任何基于超时假设的锁都不够,需要 fencing token:拿锁时同时发一个单调递增的序号(Redis 里用
INCR就能生成),下游资源拒绝执行序号更旧的请求。这样即使锁过期、两个客户端同时持锁,持旧序号的写也会被拒。
Kleppmann 的观点其实更激进:很多需要 fencing 的场景,用数据库的乐观锁和事务实现更便宜。这和前面「原子性优于锁」是同一个道理。
其他方案速览:ZooKeeper 与 etcd
这两条路线实现思路相近,知道什么时候该想到它们就够了。
ZooKeeper:抢锁 = 在锁目录下创建临时顺序节点,序号最小者持锁;没抢到的 watch 自己的前驱节点,前驱被删时才唤醒,避免所有等待者同时惊醒的羊群效应。关键优势是会话即锁:客户端宕机或网络分区导致会话过期,临时节点被服务端自动删除——「持锁者死亡」由服务端心跳判定,不依赖客户端自觉续期。Java 里直接用 Curator 的 InterProcessMutex,可重入、阻塞等待都是现成的。代价是重:写入要走 ZAB 共识协议,延迟和吞吐都比不上单机内存的 Redis,为了一把锁单独部署一套 ZK 集群不划算;系统里本来就有(老 Kafka、Dubbo 架构)才顺手用。
etcd:思路类似,租约(lease)+ keepalive 实现会话即锁,写 key 时绑定租约,续租失败 key 自动消失。额外送一个福利:每次写入产生全局递增的 revision,天然就是 fencing token,这是 etcd 相对 Redis 的硬优势。已经在 K8s 生态里的团队几乎零成本接入。
电商场景怎么选
回到业务。把常见场景摊开,答案基本是一一对应的:
| 场景 | 推荐做法 | 为什么 |
|---|---|---|
| 秒杀扣库存 | Redis Lua 预减 + 消息队列削峰落库 | 热点行扛不住 DB 行锁,Lua 原子扣减不需要锁 |
| 普通下单扣库存 | 原子 UPDATE(stock > 0) | 一行 SQL 的事,不上锁 |
| 防重复下单 / 防重复领券 | 业务表唯一索引 | 约束是最强保证,比锁可靠 |
| 账户余额、资金变动 | 数据库悲观锁,或乐观锁 + 重试 | 宁可慢、宁可失败重试,不能错 |
| 分布式定时任务防重(订单超时关闭、对账) | Redis SET NX EX,或 Redisson | 锁失效最多多跑一次,业务幂等兜住就行 |
| 跨实例互斥执行批量任务 | Redisson 看门狗 | 需要真正的阻塞互斥和自动续期 |
| 已有 ZK / etcd 基础设施 | Curator / etcd lease | 不引入新组件,会话即锁更省心 |
| 锁失效 = 资金事故 | 不只用锁:fencing token + 数据库约束双保险 | 超时假设挡不住 GC 和时钟跳变 |
秒杀场景的标准姿势值得单独写一下,它不用任何「锁」:
-- 预减库存:KEYS[1] = seckill:stock:1001, ARGV[1] = 购买数量local stock = tonumber(redis.call('get', KEYS[1]) or '-1')if stock < tonumber(ARGV[1]) then return 0 -- 库存不足,直接拒endreturn redis.call('decrby', KEYS[1], ARGV[1])活动开始前把库存从 DB 加载进 Redis,抢的时候 Lua 原子扣减,扣成功的丢进消息队列异步落库。整个链路没有显式的锁,靠的是 Redis 单线程执行脚本的原子性。
还没有把握时,走这张决策图:
实践清单
不管选了哪条路线,这些坑是共通的:
- 锁的 key 粒度尽量细。锁
lock:seckill:{skuId}而不是lock:seckill,锁单个账户而不是账户表。粒度每细一级,并发上限高一截,不同商品、不同用户之间互不阻塞。 - 超时时间永远要设,且按 P99 业务耗时估。估不准就用看门狗续期,不要拍一个巨大的值——那是拿宕机后的恢复时间换安全感。
- 锁是优化,不是正确性。锁挡住的冲突,最终都要靠数据库约束(唯一索引、原子 UPDATE、
CHECK stock >= 0)兜底。锁的作用是减少冲突和重试的成本,不是保证「绝对不发生」。按「锁可能失效」设计业务,锁失效就只是性能事件,而不是资损事故。 - 可重入想清楚。同一个线程在链路内重复进入持锁区,要自己计数或用 Redisson 的 RLock(默认可重入),否则自己把自己锁死。
- 持锁期间别调外部接口。锁时长 = 业务耗时 + 所有下游的耗时,把第三方接口的抖动引进锁里,等于把并发上限交给别人。
- 释放放在 finally,先
isHeldByCurrentThread()再 unlock,别让 unlock 的异常盖住业务异常。 - 给锁配监控:加锁失败率、平均持锁时长、看门狗续期失败。持锁时长缓慢上涨,往往比报错更早暴露问题。
收个尾:选型的顺序是先问「能不能不用锁」,再问「锁丢了会怎样」。大部分电商场景停在前两格——原子 SQL 和唯一索引;真正需要分布式锁的地方,Redis + 看门狗是性价比最高的答案;而资金场景值得为数据库锁和 fencing token 多付一点成本。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时