高并发系统避不开的坑:5年实战总结,用Redis实现分布式锁的真正最佳实践
说实话,提到用Redis实现分布式锁,很多开发者(包括曾经的我)第一反应就是:这不就是用SETNX加个过期时间嘛,有啥难的?
直到你在凌晨三点被线上告警叫醒,发现因为锁没释放,整个支付系统卡死了半小时;或者因为“幽灵锁”导致库存被重复扣减,损失惨重——你才会明白,这绝对是一个看似简单、实则布满暗礁的技术领域。
今天,我不想和你复述那些随处可见的教科书式步骤。我想和你分享的,是我在过去五年,经历了大大小小数十个高并发项目后,总结出的、真正能在生产环境扛住压力的最佳实践,以及那些教科书里不会告诉你的“避坑指南”。
一、 为什么你的Redis分布式锁总在关键时刻掉链子?
我们先来诊断几个典型症状:
- 锁失效了,但业务还没执行完:设置的10秒过期时间,结果一个GC停顿或者慢查询就让业务执行了15秒,锁自动释放,另一个进程进来重复执行。
- 锁被其他人释放了:进程A加的锁,被进程B误删了,导致A还在执行业务逻辑时,锁已失效,C又进来了。
- 锁压根没加上,但业务以为加上了:网络问题导致
SETNX命令成功但响应丢失,客户端误判,业务逻辑在没有锁保护下执行。
这些问题,根源往往在于我们把分布式锁想得太“简单”了。分布式环境下的“正确性”,和我们单机多线程下的“正确性”,完全是两个维度的挑战。
二、 从SETNX到Redlock:别再盲目选择了
1. 单节点Redis锁:基础但必须加固
对于绝大多数非金融核心场景(比如活动抢购、内容发布审核),单Redis实例的锁已经足够。但实现方式有讲究。
错误示范(网上90%的教程):
SETNX lock_key 1
EXPIRE lock_key 10这两条命令不是原子的!如果SETNX成功后进程崩溃,EXPIRE没执行,这个锁就永远不会释放,成为“死锁”。
正确姿势(Redis 2.6.12+):
SET lock_key unique_value NX PX 30000一条原子命令解决问题:NX代表不存在才设置,PX 30000表示30秒后自动过期。
关键技巧一:value必须全局唯一unique_value必须是每个客户端、每个请求唯一的标识(比如UUID+线程ID),这是后续安全释放锁的唯一凭证。
2. 安全释放锁:比加锁更重要
释放锁时,必须验证value是否匹配。伪代码逻辑:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end为什么必须用Lua脚本?因为GET和DEL必须是原子操作,否则在你GET之后、DEL之前,锁可能刚好过期并被其他客户端获取,导致你误删了别人的锁。
3. Redlock:当你真的需要它时再用
Redis官方推荐的Redlock算法,通过多个独立的Redis主节点来共同决策,确实能提供更高的可靠性(容忍少数节点故障)。
但是,请先问自己三个问题:
- 你的业务真的需要这么强的锁一致性保证吗?(多数业务不需要)
- 你愿意为维护5个独立的Redis实例(官方推荐N=5)付出额外的成本和复杂度吗?
- 你仔细研读过Martin Kleppmann和Antirez关于Redlock的那场著名辩论吗?
我的经验是:对于99%的场景,一个正确实现的、基于单个Redis主从架构的锁(配合合理的过期时间、唯一value和Lua脚本)已经完全够用,且更简单、更可控。Redlock适用于对正确性要求极端苛刻,且能接受其复杂性和性能损耗的场景。
三、 最佳实践:让你的锁既坚固又高效
1. 锁过期时间:设置多长才合适?
这是最头疼的问题之一。设太短,业务没跑完锁就没了;设太长,一旦持有锁的客户端崩溃,恢复时间又太长。
我的策略是:动态续期(Watch Dog)。
不要设置一个固定的、很长的过期时间。而是设置一个相对较短的初始过期时间(比如10秒),然后由一个后台线程(看门狗)在业务执行期间,定期(比如每过期时间的1/3时间)去检查锁是否还在持有,如果在,就延长过期时间。
这样,正常情况下锁会随着业务执行自动续期;一旦客户端崩溃,锁也会在较短的初始过期时间后自动释放,避免长时间阻塞。很多客户端(如Redisson)已经内置了这个机制。
2. 获取锁失败怎么办?立即返回还是重试?
直接返回失败对用户不友好,无限重试又会拖垮系统。
推荐做法:带退避策略的有限重试。
int maxRetries = 3;
long baseDelay = 100; // 毫秒
for (int i = 0; i < maxRetries; i++) {
if (tryLock()) {
return true;
}
// 指数退避,避免多个客户端同时重试导致羊群效应
Thread.sleep(baseDelay * (1 << i) + randomSmallDelay());
}
return false;同时,一定要在业务层面做好锁获取失败的降级处理(比如返回“系统繁忙,请稍后重试”)。
3. 锁的粒度:锁住整个世界,还是只锁需要的?
我曾经见过一个系统,对“用户下单”这个操作直接用一个全局锁order_lock。结果并发量一上去,所有人都在排队。
锁的粒度应该尽可能细。 比如,改成order_lock:{user_id},只锁同一个用户的下单操作,不同用户之间互不干扰。或者针对库存扣减,用stock_lock:{sku_id},而不是锁整个库存表。
4. 监控与告警:没有监控的锁就是定时炸弹
- 监控锁的等待时间:如果平均等待时间持续增长,说明锁竞争激烈,可能是粒度太粗或系统瓶颈。
- 监控锁的持有时间:如果持有时间经常接近或超过过期时间,说明业务逻辑可能太慢,或者需要调整过期时间/实现看门狗机制。
- 监控获取锁失败率:失败率突然升高是系统压力的重要信号。
把这些指标接入你的监控大盘,设置合理的告警阈值。
四、 高级话题与常见陷阱
1. Redis主从切换与锁丢失
这是单Redis实例方案最被诟病的一点:如果主节点加锁后,数据还没同步到从节点主就挂了,从节点升主后,锁“丢失”了。
解决方案取决于你的容忍度:
- 如果可以接受极低概率的锁失效(比如非核心场景),可以接受这个风险,并配合业务层的幂等性设计来兜底。
- 如果无法接受,那么你需要考虑Redlock,或者使用ZooKeeper、etcd等强一致性协调服务(当然,它们又有性能和复杂度上的 trade-off)。
2. 时钟跳跃问题
Redis的过期依赖服务器时间。如果服务器时钟发生大幅度向前跳跃(比如NTP同步),会导致锁提前过期。这是所有基于时间的分布式锁的共性问题。
缓解措施: 确保你的服务器时钟同步(NTP)配置正确,并使用ntpdate -q定期检查时钟偏移。对于物理机或VM,可以考虑禁用激进的时钟同步。在云环境(如K8s)下,这个问题相对较少。
3. “锁”不是万能的
最后,也是最重要的一点:不要试图用分布式锁去解决所有并发问题。
分布式锁是“悲观”的,它假设冲突会发生,所以先获取许可。在很多高并发场景,无锁设计或乐观锁(如CAS)往往是更好的选择。比如库存扣减,可以用INCRBY或DECRBY直接原子操作,而不是先锁住、再查、再计算、再更新。
五、 总结:给你的行动清单
- 评估需求:你的业务到底需要什么样的一致性级别?别过度设计。
- 正确实现单节点锁:使用原子命令
SET ... NX PX,value全局唯一。 - 安全释放:用Lua脚本原子化地验证value并删除。
- 实现动态续期:避免过期时间设置的两难,提升系统健壮性。
- 细化锁粒度:用
resource:identifier的模式,减少不必要的竞争。 - 做好降级与监控:获取锁失败要有优雅处理,运行时要有关键指标监控。
- 考虑业务幂等:作为最后防线,即使锁真的失效了,业务逻辑的幂等性也能避免重复执行造成破坏。
分布式锁没有银弹。理解每种方案的权衡取舍,结合你的具体业务场景、团队技术栈和运维能力,做出最合适的选择,这才是真正的“最佳实践”。
希望这些来自真实项目的经验总结,能帮你避开我曾经踩过的坑。如果你在实践中遇到其他棘手问题,欢迎随时交流。