Redis分布式锁:小白也能搞懂的实现与避坑指南

104 阅读7分钟

分布式锁是解决多服务环境下资源争抢问题的“钥匙”,而Redis是实现这把“钥匙”的常用工具。但如果不小心用错,这把“钥匙”可能会让系统陷入混乱。本文用最直白的语言,带你一步步搞懂Redis分布式锁的原理、隐患和正确用法。


一、为什么需要分布式锁?

想象一个场景:双十一秒杀,100台服务器同时处理用户请求。如果某件商品只剩1件库存,如何保证不被重复售卖?
答案就是分布式锁——只有一台服务器能“抢到锁”,获得操作库存的权限,其他服务器必须等待。

核心目标:在分布式系统中,同一时间只允许一个客户端操作共享资源


二、Redis分布式锁的实现步骤(小白版)

1. 上锁:设置一把“带有效期”的锁

SET product_001_lock "客户端A" NX EX 30
  • product_001_lock:锁的名字(比如对应商品ID为001的锁)
  • 客户端A:锁的持有者标识(必须唯一,防止误删)
  • NX(Not eXists):只有锁不存在时才能设置成功(类似“抢锁”)
  • EX(Expire Time):锁30秒后自动释放(防止死锁)
  • 返回值
    • 抢锁成功时,Redis返回字符串 "OK"
    • 抢锁失败时(锁已被其他客户端持有),返回 nil(相当于“没拿到锁”)。

通俗解释
客户端A大喊:“我要锁住商品001!如果没人锁,我就设置一个30秒后过期的锁,钥匙上写着我的名字!”
如果成功抢到锁,Redis会回应:“OK!锁是你的了!”;如果已被别人锁住,Redis会沉默不语(返回nil),表示抢锁失败。


2. 解锁:安全归还钥匙

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
end
return 0
  • 用Lua脚本保证原子性:先检查锁是不是自己的,再删除
  • KEYS[1]:锁的名字(如product_001_lock
  • ARGV[1]:客户端标识(如"客户端A"
  • 返回值说明
    • redis.call("DEL", KEYS[1])会返回被删除的锁的数量
      如果锁存在且被删除,返回1;如果锁不存在,返回0
    • 整个Lua脚本的返回值:
      • 如果锁存在且属于当前客户端,返回1(删除成功)。
      • 如果锁不存在或不属于当前客户端,返回0(无需操作)。

如何在Redis CLI中执行Lua脚本?
可以直接使用EVAL命令执行脚本。例如:

EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 product_001_lock "客户端A"
  • 参数说明
    • EVAL:执行Lua脚本的命令。
    • 脚本内容:用双引号包裹的Lua代码。
    • 1:表示后面跟随1个键(KEYS[1])。
    • product_001_lock:锁的键名(对应KEYS[1])。
    • "客户端A":客户端标识(对应ARGV[1])。

3. 完整流程示例

  1. 客户端A发送SET ... NX EX 30命令,成功获得锁(返回OK)。
  2. 执行业务逻辑(如扣减库存)。
  3. 执行完成后,通过Lua脚本安全释放锁。
  4. 如果客户端A崩溃,30秒后锁自动释放,避免系统死锁。

三、为什么Redis的操作能保证原子性?

Redis的原子性能力,核心在于它的单线程模型
你可以把Redis想象成一个超市里只有一个收银员

  1. 所有顾客(客户端)必须排队结账(执行命令)。
  2. 收银员一次只处理一个顾客的请求,处理完才会轮到下一个。
  3. 因此,SETNX命令和Lua脚本的执行不会被其他操作打断

具体原理

  • SETNX和SET命令的原子性
    SET key value NX EX 30是一个单条命令,Redis单线程会一次性执行完毕,不会被其他客户端插入操作。
  • Lua脚本的原子性
    Redis执行Lua脚本时,会把它当作一个整体任务,执行期间不会处理其他命令
    例如,解锁脚本中的GETDEL操作被打包成“一个动作”,要么全部成功,要么全部失败。

四、一个隐藏的“大坑”:锁过期了怎么办?

假设客户端A抢到锁后,业务代码执行了40秒(比如网络卡顿),但锁30秒就过期了。这时会发生什么?

问题1:锁提前释放,导致数据混乱

  • 客户端A还在处理业务,锁已过期释放。
  • 客户端B抢到锁,开始操作同一资源。
  • 结果:A和B同时修改数据,导致库存扣减错误。

问题2:误删别人的锁

  • A处理完成后,尝试释放锁。
  • 如果未检查锁的持有者,可能删除客户端B的锁。
  • 结果:B以为自己还持有锁,但实际上锁已被删,引发更多问题。

五、针对性解决方案


针对问题1“锁提前释放,导致数据混乱”的解决方案

如何“续命”?

  1. 合理设置超时时间

    • 预估业务逻辑最大耗时(如25秒),设置锁超时时间略大于该值(如30秒)。
    • 缺点:难以精准预估耗时,网络波动可能导致超时失效。
  2. 自动续期(看门狗机制)

    • 原理:抢锁成功后,启动后台线程定期(如每10秒)检查锁是否存在。
      • 若锁仍属于当前客户端,则重置锁的过期时间(如续期到30秒)。
      • 若锁已丢失(如Redis故障),则停止续期。
    • 工具推荐:使用成熟客户端库(如Java的Redisson),内置看门狗功能。
    RLock lock = redisson.getLock("product_001_lock");
    lock.lock(); // 自动启动看门狗
    try {
        // 执行业务(锁会自动续期)
    } finally {
        lock.unlock();
    }
    

针对问题2“误删别人的锁”的解决方案

如何彻底避免?

必须同时满足以下两个条件:

  1. 严格验证锁的持有者

    • 删除锁前必须检查锁的标识(如UUID)是否匹配。
    • 错误做法:仅通过锁名称直接删除(DEL product_001_lock),完全不验证归属。
  2. 使用Lua脚本保证原子性

    • 原因:验证锁归属(GET)和删除锁(DEL)必须原子性执行,否则可能被其他客户端插入操作。
    • 正确脚本
      if redis.call("GET", KEYS[1]) == ARGV[1] then
          return redis.call("DEL", KEYS[1])
      else
          return 0
      end
      

总结:解决方案的关联性

  • 锁续期(合理设置超时时间或自动续期)和 防误删(验证持有者 + Lua脚本)必须同时实施。
  • 若只续期不验证锁归属,可能误删他人锁;若只验证不续期,仍可能因锁过期导致数据混乱。

六、其他注意事项

1. 主从切换的风险

  • 问题:如果Redis主节点宕机,锁可能未同步到从节点,导致多个客户端抢到锁。
  • 解决方案:对一致性要求高的场景,使用RedLock算法(需部署多个独立Redis节点)。

2. 网络延迟的影响

  • 客户端与Redis的通信可能有延迟,导致锁实际有效期比预期短。
  • 建议:在业务层预留缓冲时间(如锁超时时间 = 业务最大时间 + 5秒)。

七、总结

正确实现Redis分布式锁的要点

步骤正确做法错误做法
上锁SET + NX + EX + 唯一标识只用SETNX不设超时
解锁Lua脚本验证持有者 + 删除直接DEL不检查持有者
防超时看门狗自动续期 + 合理超时设置盲目依赖固定超时

一句话口诀

“一锁一身份,过期要续命,删前先确认,高可用多备份。”


备注:本文由AI辅助生成。