
Redis 分布式锁的正确实现SET NX PX Lua 释放别再写原子性有 bug 的锁了这篇文章解决什么问题在多个服务实例共享同一份数据时用 Redis 做分布式锁是最常见的方案。但「把锁加出来」和「把锁写对」是两回事加锁和设过期时间如果不是一个原子操作进程崩溃就会留下死锁释放时如果不校验持有者就可能误删别人的锁。本文从最朴素的写法开始一步步指出 bug 并给出正确实现读完能直接用于生产。适合谁读需要在后端做并发控制、防止重复扣款/重复下单等场景的开发者。为什么需要分布式锁单机环境下synchronized、Lock、Mutex就能做互斥因为所有线程共享同一个 JVM/进程内存。但服务一旦部署成多实例或多台机器内存锁就失效了——两个实例各自加锁互不知情共享资源照样被并发修改。这时需要一个所有实例都能访问的「公共仲裁者」Redis 因其高性能和原子命令天然适合这个角色。典型的应用场景防止同一订单被并发重复支付防止定时任务在多实例下被重复执行防止库存超卖配合乐观锁或库存扣减逻辑写法一先 SET 再 EXPIRE有 bugimportredisimportuuid rredis.Redis(hostlocalhost,port6379,decode_responsesTrue)defacquire_bad(key):# 1. 先尝试加锁ifr.set(key,1,nxTrue):# nxTrue 表示不存在才设置# 2. 再设置过期时间r.expire(key,30)returnTruereturnFalse这段代码的问题在于set和expire是两个独立命令中间没有原子性保证。如果进程在set成功之后、expire执行之前崩溃了这个 key 就永远不会过期——锁被永久占用其他实例永远拿不到形成死锁。即使不崩溃网络抖一下也可能让expire丢失。写法二不校验持有者直接 DEL还是有 bug有人为了省事释放锁时无条件删除defrelease_bad(key):r.delete(key)看起来没问题看这个时序实例 A 拿到锁expire设了 30 秒。实例 A 执行业务因为 GC 或网络原因卡了 35 秒此时锁已自动过期。实例 B 趁锁过期拿到锁正在操作共享资源。实例 A 恢复运行执行release_baddelete掉的是 B 当前持有的锁。此时锁消失实例 C 又拿到了锁——B、C 同时在操作共享资源互斥被打破。这就是「误删别人的锁」。要解决它必须让每个锁带上持有者标识释放前校验「这个锁是不是我加的」。正确实现加锁用 SET NX PX释放用 Lua 原子校验加锁一条命令同时完成「加锁 过期」importredisimportuuid rredis.Redis(hostlocalhost,port6379,decode_responsesTrue)defacquire(key,expire_seconds30):tokenuuid.uuid4().hex# 每个实例每次加锁生成唯一标识# SET key token NX PX 毫秒原子地「不存在则设置且带过期时间」okr.set(key,token,nxTrue,pxexpire_seconds*1000)returntokenifokelseNone# 返回 token 用于后续释放校验redis-py的set(name, value, nxTrue, px...)底层就是一条SET key value NX PX ms命令天然原子。NX保证只在 key 不存在时设置PX参数以毫秒为单位设置过期时间。加锁和过期绑定在同一条命令里前面写法一「崩溃留死锁」的问题就消除了。这里用uuid4生成随机 token 而不是固定值「1」是为了让每个持有者可被区分。释放Lua 脚本里「先校验、再删除」释放锁必须保证「校验 token 是否匹配」和「删除 key」两个动作原子执行否则校验通过后、删除之前锁可能刚好过期被他人拿走。Redis 的 Lua 脚本在服务端原子执行正好满足ifredis.call(get,KEYS[1])ARGV[1]thenreturnredis.call(del,KEYS[1])elsereturn0end用 redis-py 执行RELEASE_SCRIPT if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end defrelease(key,token):resultr.eval(RELEASE_SCRIPT,1,key,token)returnresult1# 1 表示成功删除锁确实是我的0 表示不是我的或已失效只有当 key 的当前值等于传入的 token 时才删除这样就避免了写法二的「误删他人的锁」。完整使用示例带失败重试importtimedefdo_with_lock(r,key,expire_seconds30,retry_times3):tokenacquire(key,expire_seconds)iftokenisNone:print(获取锁失败)returnFalsetry:# 这里放你的临界区业务逻辑print(拿到锁执行业务 ...)time.sleep(1)returnTruefinally:# 无论如何都要释放锁release(key,token)需要注意finally里释放的前提是「锁还属于自己」。如果业务耗时超过了过期时间锁已经自动过期且可能被他人拿走此时release会返回 0不误删这是正确行为。因此关键原则是锁的过期时间要大于业务最坏执行时间或者在业务内做续期。续期业务跑得久怎么办如果业务执行时间可能超过锁的过期时间可以用「看门狗」机制持锁期间定期检查若即将过期且自己仍持有就把过期时间往后延。这里给出一个简化的续期 Lua 脚本ifredis.call(get,KEYS[1])ARGV[1]thenreturnredis.call(pexpire,KEYS[1],ARGV[2])elsereturn0end调用时传入新的毫秒数即可。续期同样通过「校验 token 是否匹配」保证只给自己的锁续期。生产环境中RedissonJava已经内置了 watchdog 自动续期避免重复造轮子。与其他方案对比方案优点缺点数据库唯一索引/行锁实现简单事务保证强一致性能差易产生锁等待连接资源占用Zookeeper 临时顺序节点强一致可靠客户端崩溃自动释放性能较低部署维护成本高Redis SET NX PX Lua高性能实现简洁依赖 Redis 单点/主从切换时可能丢失锁非强一致Redis 方案在「极端情况下」不是绝对安全的如果使用了主从架构主节点刚写入锁还未同步到从节点就宕机发生主从切换后锁可能丢失。如果你的业务对一致性要求极高如金融资金操作应评估 RedLock 或 Zookeeper 等更强一致性的方案一般业务场景下单实例 Redis 的 SET NX PX Lua 已经足够可靠。小结加锁必须用SET key value NX PX一条命令原子完成「加锁 过期」别拆成setexpire。释放必须校验持有者 token并用 Lua 脚本让「校验 删除」原子执行别无条件DEL。token 用随机唯一值如 UUID区分每次加锁。锁的过期时间要大于业务最坏执行时间长时间任务考虑 watchdog 续期。拿本文代码在本地起一个 Redis 用两个进程/线程并发测试观察 acquire 只有一个返回非 None、release 不会误删是理解这些细节最直接的方式。延伸阅读Redis 官方文档中 SET 命令 与 EVAL/Lua 脚本、Martin Kleppmann 关于分布式锁的经典讨论文章。