分布式锁是解决多服务环境下资源争抢问题的“钥匙”,而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(相当于“没拿到锁”)。
- 抢锁成功时,Redis返回字符串
通俗解释:
客户端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. 完整流程示例
- 客户端A发送
SET ... NX EX 30命令,成功获得锁(返回OK)。 - 执行业务逻辑(如扣减库存)。
- 执行完成后,通过Lua脚本安全释放锁。
- 如果客户端A崩溃,30秒后锁自动释放,避免系统死锁。
三、为什么Redis的操作能保证原子性?
Redis的原子性能力,核心在于它的单线程模型。
你可以把Redis想象成一个超市里只有一个收银员:
- 所有顾客(客户端)必须排队结账(执行命令)。
- 收银员一次只处理一个顾客的请求,处理完才会轮到下一个。
- 因此,
SETNX命令和Lua脚本的执行不会被其他操作打断。
具体原理
- SETNX和SET命令的原子性:
SET key value NX EX 30是一个单条命令,Redis单线程会一次性执行完毕,不会被其他客户端插入操作。 - Lua脚本的原子性:
Redis执行Lua脚本时,会把它当作一个整体任务,执行期间不会处理其他命令。
例如,解锁脚本中的GET和DEL操作被打包成“一个动作”,要么全部成功,要么全部失败。
四、一个隐藏的“大坑”:锁过期了怎么办?
假设客户端A抢到锁后,业务代码执行了40秒(比如网络卡顿),但锁30秒就过期了。这时会发生什么?
问题1:锁提前释放,导致数据混乱
- 客户端A还在处理业务,锁已过期释放。
- 客户端B抢到锁,开始操作同一资源。
- 结果:A和B同时修改数据,导致库存扣减错误。
问题2:误删别人的锁
- A处理完成后,尝试释放锁。
- 如果未检查锁的持有者,可能删除客户端B的锁。
- 结果:B以为自己还持有锁,但实际上锁已被删,引发更多问题。
五、针对性解决方案
针对问题1“锁提前释放,导致数据混乱”的解决方案
如何“续命”?
-
合理设置超时时间
- 预估业务逻辑最大耗时(如25秒),设置锁超时时间略大于该值(如30秒)。
- 缺点:难以精准预估耗时,网络波动可能导致超时失效。
-
自动续期(看门狗机制)
- 原理:抢锁成功后,启动后台线程定期(如每10秒)检查锁是否存在。
- 若锁仍属于当前客户端,则重置锁的过期时间(如续期到30秒)。
- 若锁已丢失(如Redis故障),则停止续期。
- 工具推荐:使用成熟客户端库(如Java的Redisson),内置看门狗功能。
RLock lock = redisson.getLock("product_001_lock"); lock.lock(); // 自动启动看门狗 try { // 执行业务(锁会自动续期) } finally { lock.unlock(); } - 原理:抢锁成功后,启动后台线程定期(如每10秒)检查锁是否存在。
针对问题2“误删别人的锁”的解决方案
如何彻底避免?
必须同时满足以下两个条件:
-
严格验证锁的持有者
- 删除锁前必须检查锁的标识(如UUID)是否匹配。
- 错误做法:仅通过锁名称直接删除(
DEL product_001_lock),完全不验证归属。
-
使用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辅助生成。