曾几何时,我认为分布式锁不过是 Redis 的一行命令。直到2021年的那个双十一预热活动,我们的库存中心在流量高峰期突然“假死”,整个系统像被施了定身术,重启服务都无济于事。
那次事故导致核心业务停摆近2小时,老板在钉钉群里的每一个问号,都像敲在我头上的重锤。
复盘当晚,我盯着屏幕上看似完美的 SETNX 代码发呆。作为负责中小团队技术选型的架构师,我甚至一度陷入自我怀疑:为什么教科书式的写法,到了真实的高并发战场上却不堪一击?
这也是很多资深开发容易陷入的误区:把“实现了功能”等同于“生产可用”。
在这篇文章里,我不讲枯燥的 CAP 理论,只想和你分享我在实战中踩过的三个深坑,以及我和团队是如何填坑的。
坑一:原子性的“薛定谔”陷阱
那是项目早期的代码。为了防止商品超卖,我们在下单接口加了锁。当时的逻辑简单粗暴:
// 伪代码:当时的“教科书”式写法
if (redis.setnx(lockKey, value) == 1) {
redis.expire(lockKey, 30); // 设置30秒过期,防止死锁
try {
// 执行业务逻辑
} finally {
redis.del(lockKey);
}
}
这段代码跑了半年没出事,直到那次服务器因负载过高突然宕机。
事故现场:
宕机发生的时间点,偏偏卡在 setnx 执行成功之后,expire 执行之前。这意味着,这个锁没有过期时间!当服务重启后,新请求进来发现锁一直存在(死锁),所有相关业务全部卡死。我们需要手动去 Redis 里一个个删除 Key 才恢复了业务。
深度反思: 在分布式环境下,“加锁”和“设置超时”必须是原子的。只要它们是两条指令,就存在中间状态失效的风险。
落地方法: 对于中小团队,不要自己造轮子去拼接命令。现在的我,会在代码 Review 规范中强制要求:必须使用 Lua 脚本或成熟的客户端(如 Redisson)来保证原子性。
如果你还在裸写 Redis 命令,请立即替换为如下逻辑(Lua 脚本示例):
if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end
坑二:锁不住的“幽灵”与误删
原子性问题解决后,我们以为万事大吉。但在2022年的一次月度财务报表生成任务中,数据又出错了。
事故现场: 财务同事拿着两份报表找我:“为什么同一笔订单,在两个时间段都被统计了?”
排查日志发现,这是一个长耗时任务。
- 线程 A 获取锁,设置过期时间 30秒。
- 因为数据库抖动,线程 A 的业务执行了 45秒。
- 在第 30秒 时,锁自动过期。
- 线程 B 趁机获取了新锁,开始执行业务。
- 第 45秒,线程 A 执行完毕,执行
DEL操作。重点来了:它删除的不是自己的锁,而是线程 B 刚刚加上去的锁! - 线程 C 随即进入…
这就造成了连锁反应,锁形同虚设,多个线程在裸奔,我们称之为“幽灵写入”。
深度反思: 这个坑告诉我们两点:
- 锁的过期时间很难评估准确(网络波动、GC 都会影响耗时)。
- 解铃还须系铃人,线程绝对不能删除别人的锁。
落地方法: 这是我看重 Redisson 的核心原因。它引入了一个“看门狗(Watchdog)”机制。
我现在的架构设计原则是:凡是耗时超过 500ms 的业务,禁止手动设置固定过期时间。
使用 Redisson 的默认配置,只要线程 A 的业务还没结束,Watchdog 后台线程就会每隔 10秒 自动把锁的超时时间“续命”到 30秒。直到业务结束,线程 A 显式释放锁。
// 使用 Redisson 避免手动维护过期时间
RLock lock = redisson.getLock("finance_report_lock");
lock.lock(); // 默认开启 Watchdog,自动续期
try {
// 执行超长业务逻辑
} finally {
lock.unlock(); // 只有持有锁的线程才能解锁
}
坑三:数据库事务与锁的“死亡拥抱”
这是最隐蔽、也是最容易被资深开发忽视的坑。
那是2023年初,我们在重构支付模块。代码逻辑看起来无懈可击:
@Transactional
public void updateOrder() {
RLock lock = redisson.getLock("order_123");
lock.lock();
try {
// 查询订单状态,防止并发修改
Order order = orderMapper.selectById(123);
if (order.getStatus() == PAID) return;
order.setStatus(PAID);
orderMapper.updateById(order);
} finally {
lock.unlock();
}
}
即便加了锁,依然出现了少量的“订单状态覆盖”问题。为什么?
事故现场:
问题出在 @Transactional 和 lock 的嵌套顺序上。
- 线程 A 获得锁,执行更新,释放锁。
- 注意: 此时 Spring 的事务还没有提交(事务提交是在方法结束后)。
- 线程 B 获得锁,读取数据库。
- 由于数据库的隔离级别(通常是 Read Committed 或 Repeated Read),线程 B 读到的依然是旧数据(因为线程 A 的事务还没提交)。
- 线程 B 再次执行更新逻辑。
深度反思: 分布式锁必须包裹在数据库事务的外层,而不是里层。锁的生命周期必须大于事务的生命周期,才能保证看到的数据是最终一致的。
落地方法: 我给团队定下的规矩是:Service 层严禁在事务方法内部加锁。
我们通常采用“门面模式”或手动控制事务粒度来解决:
// 正确做法:锁在事务之外
public void payOrderFacade(Long orderId) {
RLock lock = redisson.getLock("order_" + orderId);
lock.lock();
try {
// 调用事务方法
orderService.updateOrderStatusInTransaction(orderId);
} finally {
lock.unlock();
}
}
这种改动虽然繁琐,需要拆分方法,但它从根源上杜绝了并发脏读的可能性。
结语
从 setnx 的盲目自信,到 Redisson 的拿来主义,再到对事务边界的精细控制,这也是我作为一个架构师的成长路径。
分布式锁从来不是一个简单的“工具使用”问题,而是对原子性、一致性、隔离性的综合考量。在中小团队,我们不需要 Google 级别的基础设施,但我们需要对每一行代码背后的代价心知肚明。
我现在的办公桌上贴着一张便利贴,每次评审代码时我都会问三个问题:
- 这把锁会死锁吗?(原子性)
- 业务跑久了锁会失效吗?(自动续期)
- 锁释放了,数据真的落库了吗?(事务边界)
最后,想听听你的看法:
在你的项目中,为了解决并发一致性,你更倾向于使用 A. 悲观锁(如 Redis 分布式锁) 还是 B. 乐观锁(如数据库版本号 Version)?
欢迎在评论区留下你的选择(A 或 B)及理由。
给读者的3个行动建议:
- 全量扫描: 搜索代码中的
setnx关键字,检查是否存在“加锁”与“设置过期”非原子操作的风险。 - 引入看门狗: 对于执行时间波动较大的定时任务,尝试引入 Redisson 或自行实现续期机制,替换掉硬编码的过期时间。
- 检查边界: 重点 Review 带有
@Transactional注解的方法,确认分布式锁是否被错误地包裹在事务内部。