首页 / 资讯中心 / 文章详情

Redis 分布式锁讲透:从 SETNX 到 Redlock 的所有坑

Redis 分布式锁讲透:从 SETNX 到 Redlock 的所有坑 ★ FEATURED ARTICLE
个人主页 for_ever_love__ 欢迎各位大佬莅临其他栏目: 大模型开发从0到1 其他栏目: iOS项目总结大全 其他栏目: 我想学python了 其他栏目: iOS UI 文章目录Redis 分布式锁讲透从 SETNX 到 Redlock 的所有坑一、分布式锁的最低要求二、演进五个版本的写法v1SETNX DEL❌ 会死锁v2SETNX EXPIRE❌ 非原子v3SET NX EX✅ 可用但仍有问题v4Lua 脚本原子释放✅ 生产可用v5自动续期看门狗三、用现成的库Redisson四、主从切换导致的锁失效解决方案Redlock⚠️ Redlock 有争议谨慎使用五、分布式锁的替代方案5.1 数据库乐观锁推荐5.2 数据库悲观锁5.3 Lua 脚本原子操作选型建议六、八个必须知道的坑七、一份可以直接用的实现八、小结Redis 分布式锁讲透从 SETNX 到 Redlock 的所有坑分布式锁看起来简单——SETNX加锁DEL释放。但真到生产环境你要处理进程崩溃锁不释放、删了别人的锁、业务没执行完锁就过期、主从切换导致锁失效……本篇把这些坑一个个列出来给出能用的解法最后说清楚 Redlock 到底该不该用。一、分布式锁的最低要求一个可用的分布式锁至少要满足互斥性任意时刻只有一个客户端能持有不会死锁持有者崩溃后锁能自动释放加锁和解锁是同一个客户端不能删别人的锁容错性部分 Redis 节点宕机时仍能工作二、演进五个版本的写法v1SETNX DEL❌ 会死锁r.setnx(lock,1)try:do_something()finally:r.delete(lock)问题如果do_something()时进程崩溃finally不会执行锁永远不释放死锁。v2SETNX EXPIRE❌ 非原子r.setnx(lock,1)r.expire(lock,30)# 加过期时间问题这两条命令不是原子的。如果setnx成功后进程崩溃还没来得及expire又是死锁。v3SET NX EX✅ 可用但仍有问题r.set(lock,token,nxTrue,ex30)Redis 2.6.12 支持SET key value NX EX seconds加锁和设过期时间是原子的。这是正确的基础写法。但仍有问题——会删掉别人的锁# 客户端 A 加锁超时 30 秒# A 的业务执行了 35 秒超过 30 秒锁已自动释放# 客户端 B 拿到锁# A 执行完DEL 删除锁 ← 把 B 的锁删了v4Lua 脚本原子释放✅ 生产可用释放锁时先比对 value 再删除用 Lua 保证原子性importuuidimportredis rredis.Redis()defacquire(lock_key,ttl30):tokenstr(uuid.uuid4())# 唯一标识okr.set(lock_key,token,nxTrue,exttl)returntokenifokelseNonedefrelease(lock_key,token):# ⚠️ 必须是 LuaGET 和 DEL 要原子执行script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end returnr.eval(script,1,lock_key,token)# 使用tokenacquire(lock:order:123,ttl30)iftoken:try:do_something()finally:release(lock:order:123,token)else:print(没拿到锁)为什么必须用 Lua# ❌ 错误两步操作非原子ifr.get(lock_key)token:# 判断时是我的锁# 恰好这一刻锁过期了B 拿到了锁r.delete(lock_key)# 删掉了 B 的锁GET和DEL之间有时间窗口必须合并成一个原子操作。v5自动续期看门狗问题业务执行时间不确定30 秒可能不够。解法看门狗Watchdog——加锁成功后起一个后台线程每隔 TTL/3 时间检查锁还在不在在就延长。importthreading,timeclassRedisLock:def__init__(self,r,key,ttl30):self.r,self.key,self.ttlr,key,ttl self.tokenNoneself._timerNonedef__enter__(self):self.tokenstr(uuid.uuid4())ifnotself.r.set(self.key,self.token,nxTrue,exself.ttl):self.tokenNoneraiseRuntimeError(获取锁失败)self._start_watchdog()returnselfdef__exit__(self,*exc):self._stop_watchdog()self._release()def_start_watchdog(self):每 ttl/3 秒续期一次defrenew():ifself.token:# 只有锁还是我的才续期self.r.eval( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(pexpire, KEYS[1], ARGV[2]) end return 0 ,1,self.key,self.token,self.ttl*1000)self._timerthreading.Timer(self.ttl/3,renew)self._timer.daemonTrueself._timer.start()self._timerthreading.Timer(self.ttl/3,renew)self._timer.daemonTrueself._timer.start()def_stop_watchdog(self):ifself._timer:self._timer.cancel()self._timerNonedef_release(self):ifself.token:self.r.eval( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) end return 0 ,1,self.key,self.token)self.tokenNone# 使用withRedisLock(r,lock:order:123):do_something()# 执行多久都不怕看门狗会自动续期这就是Redisson 的watchdog机制的原理。三、用现成的库RedissonJava 项目直接用 Redisson上面所有功能它都实现好了RLocklockredissonClient.getLock(lock:order:123);// 自动续期默认 30 秒 TTL每 10 秒续一次lock.lock();try{doSomething();}finally{lock.unlock();}// 或者指定超时时间不续期booleanacquiredlock.tryLock(10,30,TimeUnit.SECONDS);Redisson 额外提供的能力可重入锁同一线程可多次加锁内部用 Hash 记重入次数公平锁按请求顺序获取读写锁RReadWriteLock联锁 / 红锁Python 对应的是redis-py的Lock但功能弱很多没有看门狗。生产建议自己封装或用redlock-py。四、主从切换导致的锁失效这是单 Redis 实例方案的根本缺陷1. 客户端 A 在 master 上加锁成功 2. master 还没把锁同步给 slave就宕机了 3. slave 被提升为新 master 4. 客户端 B 在新 master 上加锁 ← 也成功了 5. A 和 B 同时持有锁 → 互斥性被破坏解决方案RedlockRedlock 的思路向 N 个独立的 Redis 实例通常 5 个依次加锁超过半数N/2 1成功才算加锁成功。客户端 ├─→ Redis 1 ✓ ├─→ Redis 2 ✓ ├─→ Redis 3 ✓ ← 3/5 成功加锁成功 ├─→ Redis 4 ✗ └─→ Redis 5 ✗关键约束N 个实例互相独立不是同一个集群的分片也不是主从加锁有超时时间超时就跳过这个节点总耗时必须小于锁的 TTL否则认为失败释放时向所有实例发删除请求fromredlockimportRedlock dlmRedlock([{host:redis1,port:6379},{host:redis2,port:6379},{host:redis3,port:6379},{host:redis4,port:6379},{host:redis5,port:6379},])lockdlm.lock(lock:order,1000)# TTL 1000 毫秒iflock:try:do_something()finally:dlm.unlock(lock)⚠️ Redlock 有争议谨慎使用Redis 作者 antirez 和分布式系统专家 Martin Kleppmann曾就此激烈争论。主要的质疑依赖时钟Redlock 假设各节点时钟大致同步。如果发生时钟跳跃NTP 调整、运维改时间锁的 TTL 计算会出错GC 停顿客户端 GC 停顿期间锁可能过期但客户端以为自己还持有复杂度高收益存疑为了极小概率的主从切换场景引入 5 个实例和可观的延迟现实建议场景建议一般业务防止重复扣款、防止并发写单实例 Lua 释放 看门狗够用对正确性要求极高金融别用 Redis 锁用数据库的乐观锁 / ZooKeeper / etcd必须用 Redlock确保有fencing token递增序号做最终兜底fencing token 是什么每次加锁返回一个单调递增的序号操作数据时带上这个序号存储层拒绝处理序号更小的请求。这样即使锁失效了GC 停顿导致旧的请求也会被存储层挡住。这才是真正的兜底。五、分布式锁的替代方案很多时候根本不需要分布式锁。5.1 数据库乐观锁推荐UPDATEproductsSETstockstock-1,versionversion1WHEREid1001ANDstock1ANDversion5;-- 影响行数 0 说明被别人改过重试用CAS 版本号不需要 Redis且天然和数据库事务一致。扣库存这类场景首选这个。5.2 数据库悲观锁BEGIN;SELECT*FROMproductsWHEREid1001FORUPDATE;-- 行锁UPDATEproductsSETstockstock-1WHEREid1001;COMMIT;简单可靠但要注意锁的范围和事务时长见 MySQL 锁那篇。5.3 Lua 脚本原子操作很多时候锁要解决的是多个操作的原子性那不如直接把多个操作写成一个 Lua 脚本# 扣库存判断 扣减一步完成不需要锁script local stock tonumber(redis.call(get, KEYS[1])) if stock 0 then return -1 end redis.call(decr, KEYS[1]) return stock - 1 r.eval(script,1,stock:1001)Lua 脚本在 Redis 里是原子执行的这是最高效的锁替代方案。选型建议场景方案单纯防止重复执行数据库唯一索引 / 乐观锁扣库存、改余额Lua 脚本或 DB 乐观锁需要跨多个资源的原子操作分布式锁定时任务只在一台机器跑分布式锁或 K8s Lease金融级强一致别用 Redis用 etcd / ZK六、八个必须知道的坑坑 1忘了设过期时间→ 死锁。用SET NX EX一步到位。坑 2DEL 之前不比对 token→ 删别人的锁。用 Lua。坑 3TTL 太短业务没跑完锁就没了→ 用看门狗续期。坑 4把锁的粒度设得太大withlock(global_lock):# ❌ 全局锁所有请求串行...withlock(forder:{order_id}):# ✅ 按资源加锁...坑 5锁重入defa():withlock:b()# 外层加了锁defb():withlock:...# ❌ 不可重入的话这里死锁需要可重入就用 Redisson或者自己用 Hash 记重入次数。坑 6锁的 value 用了固定值必须用 UUID 等唯一标识否则无法区分持有者。坑 7Redis 单实例宕机→ 锁服务不可用。要么接受多数场景可接受要么上 Redlock要么换 etcd。坑 8主从切换丢锁前面详述→ 这是单实例方案的固有缺陷。七、一份可以直接用的实现importuuid,threading,timeimportredisclassDistLock:生产可用的 Redis 分布式锁单实例版_RELEASE if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) end return 0 _RENEW if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(pexpire, KEYS[1], ARGV[2]) end return 0 def__init__(self,r,key,ttl30,auto_renewTrue,retry0,retry_delay0.1):self.r,self.key,self.ttlr,key,ttl self.auto_renewauto_renew self.retry,self.retry_delayretry,retry_delay self.tokenNoneself._timerNonedefacquire(self):self.tokenstr(uuid.uuid4())foriinrange(self.retry1):ifself.r.set(self.key,self.token,nxTrue,exself.ttl):ifself.auto_renew:self._schedule_renew()returnTrueifiself.retry:time.sleep(self.retry_delay)self.tokenNonereturnFalsedefrelease(self):ifnotself.token:returnifself._timer:self._timer.cancel()self._timerNoneself.r.eval(self._RELEASE,1,self.key,self.token)self.tokenNonedef_schedule_renew(self):defrenew():ifself.token:try:self.r.eval(self._RENEW,1,self.key,self.token,int(self.ttl*1000))exceptException:passself._timerthreading.Timer(self.ttl/3,renew)self._timer.daemonTrueself._timer.start()self._timerthreading.Timer(self.ttl/3,renew)self._timer.daemonTrueself._timer.start()def__enter__(self):ifnotself.acquire():raiseRuntimeError(f获取锁失败:{self.key})returnselfdef__exit__(self,*exc):self.release()# 使用withDistLock(r,lock:order:123,ttl30,retry3):do_something()八、小结最低可用写法SET key token NX EX 30Lua 比对 token 释放必须加过期时间且加锁和设 TTL 要原子NX EX一起用释放锁必须 Lua否则GET和DEL之间的窗口会删掉别人的锁业务耗时不确定 →看门狗自动续期每 TTL/3 续一次单实例方案的主从切换会丢锁这是固有缺陷Redlock 有争议谨慎使用真要强一致请上etcd / ZooKeeper很多场景根本不需要锁扣库存用 Lua 或 DB 乐观锁更好锁的粒度要按资源分别用全局锁下一篇讲过期与淘汰策略——锁解决并发控制淘汰策略解决内存满了怎么办两者都是 Redis 运维的必修课。
阅读完成 · 觉得有帮助?
咨询建站