一次库存超发,我们把手写的 setnx 锁全换成了 Redisson
3 月底的一次促销活动,某款商品库存 200 件,实际卖出 213 件。超发 13 件。客服赔了 13 张 50 元券,加上运营的抱怨,够我记很久。
查下来是分布式锁失效。我们当时用的是自己封装的 setnx 锁,问题出在一个很隐蔽的地方:锁过期了,但业务还没执行完。
第一版:setnx 加 expire,两条命令
2018 年刚开始用 Redis 锁的时候,代码是这样的:
public boolean tryLock(String key, String value, int expireSec) {
Long ok = jedis.setnx(key, value);
if (ok == 1) {
jedis.expire(key, expireSec); // 这两步之间如果进程挂了……
return true;
}
return false;
}
这不是分布式锁的问题,这是原子性的问题。setnx 成功之后、expire 之前进程崩溃,这把锁就永远不过期了,其他节点全部阻塞。
线上出过一次:2019 年 6 月,Redis 里有个 lock:stock:10037 存活了 11 天,直到 Redis 重启才释放。期间这个商品的库存调整全部超时。
第二版:一条命令搞定
Redis 2.6.12 之后 SET 支持 NX 和 PX,可以一条命令完成加锁加过期:
public boolean tryLock(String key, String requestId, long expireMs) {
String ret = jedis.set(key, requestId, "NX", "PX", expireMs);
return "OK".equals(ret);
}
requestId 不能是固定值,必须是每个线程唯一的(我们用 UUID + threadId),用来在解锁时确认"这把锁是不是我加的"。
第三版:解锁要判断 owner,而且得用 Lua
解锁的时候不能直接 del。想一下这个时序:
线程 A 加锁,过期时间 10 秒
线程 A 执行了 12 秒(GC 停顿、数据库慢查询),锁已经自动过期了
线程 B 加锁成功
线程 A 终于执行完了,del(key) —— 把 B 的锁删了
线程 C 加锁成功,B 和 C 同时在临界区
正确姿势是"取值判断 + 删除"两步,而这两步又必须是原子的,所以要 Lua:
private static final String UNLOCK_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
public void unlock(String key, String requestId) {
jedis.eval(UNLOCK_LUA, Collections.singletonList(key),
Collections.singletonList(requestId));
}
到这里,单实例的 Redis 锁基本是完备的。但我们还是超发了 13 件。为什么?
真正的坑:锁过期时间不好定
出事那天的代码:
String lockKey = "lock:stock:" + skuId;
try {
if (redisLock.tryLock(lockKey, requestId, 5000)) { // 锁 5 秒
Stock stock = stockMapper.selectForUpdate(skuId);
if (stock.getAvailable() > 0) {
stockMapper.deduct(skuId, 1);
createOrder(...); // 这里调了风控,偶尔要 3 到 8 秒
}
}
} finally {
redisLock.unlock(lockKey, requestId);
}
超时设了 5 秒,是因为当时大部分订单处理只要 800 毫秒。但那天风控服务抖动,13 个请求的处理时间超过了 5 秒。锁自动释放,下一个请求进来,看到的是还没扣减的旧库存。
从监控上看得很清楚:
20:31:07 接口 P99 从 780ms 涨到 6.2s(风控 RT 飙升)
20:31:12 第一个超发订单产生
20:31:19 第 13 个超发订单
20:32:40 风控恢复,超发停止
把过期时间调大到 30 秒?那如果进程真的挂了,要等 30 秒锁才释放,可用性又变差。这是个两难。
第四版:Redisson 的看门狗
Redisson 的答案是自动续期。加锁成功之后,后台起一个定时任务,每隔 lockWatchdogTimeout / 3 的时间检查一次,如果锁还在持有,就把过期时间重置。业务跑多久,锁就续多久。
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.11.5</version>
</dependency>
@Service
public class StockService {
@Autowired
private RedissonClient redisson;
public boolean deduct(Long skuId, int qty) {
RLock lock = redisson.getLock("lock:stock:" + skuId);
boolean locked = false;
try {
// 最多等 3 秒拿锁,拿到之后由看门狗自动续期
locked = lock.tryLock(3, TimeUnit.SECONDS);
if (!locked) {
return false;
}
Stock stock = stockMapper.selectForUpdate(skuId);
if (stock.getAvailable() < qty) {
return false;
}
stockMapper.deduct(skuId, qty);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
if (locked) {
lock.unlock();
}
}
}
}
关键细节:
tryLock(waitTime, unit)只传等待时间,不传 leaseTime。这才是看门狗模式。lockWatchdogTimeout默认 30 秒,续期间隔 10 秒。- 如果写成
tryLock(3, 30, TimeUnit.SECONDS)传了 leaseTime,看门狗不会启动,锁 30 秒后无条件释放。这个坑我见过好几次,源码里tryLockInnerAsync只在leaseTime == -1时才注册续期任务。 - 看门狗是 Redisson 实例级别的后台线程。进程被
kill -9之后续期停止,锁最多再活 30 秒自动过期,不会永久死锁。
关于 Redlock 的争议
单 Redis 实例有个绕不开的问题:主从切换会丢锁。
客户端 A 在 master 上加锁成功
master 还没把这条 set 同步给 slave,master 挂了
slave 被提升为新 master
客户端 B 在新 master 上加锁,也成功了
A 和 B 同时持有锁
Redis 的复制是异步的,这个窗口客观存在。Redlock 就是 antirez 给出的解法:部署 5 个互相独立的 Redis master(不是主从,是没有关系的 5 个实例),客户端要拿到多数(3 个)才算加锁成功。
Martin Kleppmann 在 2016 年写过一篇很出名的反驳,核心论点是:
- Redlock 依赖"本地时钟是可靠的"这个假设。如果客户端发生长时间 GC 停顿,锁过期了它还以为自己持有,Redlock 无能为力。
- 异步模型下,没有 fencing token(单调递增的令牌)就无法保证互斥。
antirez 的回应是"时钟跳跃可以通过运维避免,GC 停顿可以通过合理设置超时缓解"。
我们的结论是不用 Redlock。理由很实在:
| 考虑 | 我们的判断 |
|---|---|
| 成本 | 5 个独立 Redis 实例,运维复杂度和机器成本翻倍,我们 Redis 只有 3 个节点 |
| 收益 | 它解决的是极端情况下的锁失效,而我们有数据库层的兜底 |
| 争议 | 连作者和分布式系统专家都没达成共识,工程上不该押注 |
真正的兜底在数据库
这是我最想强调的一点:Redis 锁是性能优化手段,不是数据一致性手段。
我们改完之后,减库存的 SQL 长这样:
UPDATE t_stock
SET available = available - #{qty},
version = version + 1
WHERE sku_id = #{skuId}
AND available >= #{qty}
available >= #{qty} 这个条件让减库存变成了原子操作,返回的影响行数是 0 就说明库存不足。即使 Redis 锁完全失效,也不会超卖。
压测验证过:把 Redis 锁整个停掉,只靠这条 SQL,200 件库存并发卖出,实际卖出 200 件,0 超发。代价是所有请求都打到数据库,QPS 从 3400 掉到 890。
所以我们的分层是:
- Redis 锁:挡住 99% 的并发,让数据库少干活。这是性能层。
- 数据库条件更新:保证数据正确性。这是安全层。
选型对比
| 方案 | 正确性 | 复杂度 | 我们的场景 |
|---|---|---|---|
| 手写 setnx + Lua | 单实例够用,主从切换丢锁 | 中,续期要自己做 | 已废弃 |
| Redisson RLock | 同上,但有可重入和续期 | 低 | 当前主力 |
| Redlock | 理论上更强,但有争议 | 高,要 5 个独立实例 | 不用 |
数据库排他锁 select for update | 最强 | 低,但性能差 | 对账、定时补偿这类低频任务 |
Redisson 还有个我们常用的:RReadWriteLock。商品信息缓存更新用它,读锁共享、写锁互斥,比普通锁吞吐高不少。
就写到这。如果哪天你也被《Redis 分布式锁从 setnx 到 Redisson 的演进》里同一个坑绊住,回来翻这篇,能省半小时。