上一篇处理了流量和容量集中,本篇转向并发执行集中:多个进程都认为自己应该修改同一资源。Redis 锁只是一份带期限的协调记录,不是数据库事务。可靠边界由原子获取、随机所有者令牌、比较后释放,以及由最终资源验证的 fencing token 共同组成。
一、边界:先判断是否真的需要锁
库存扣减若能写成数据库UPDATE ... WHERE stock > 0,唯一约束若能拒绝重复订单,原子条件写通常比外部锁更可靠。锁适合协调定时任务、缓存重建或只能串行调用的外部资源。把锁加在“先读再写”的外围,却不在最终存储层验证版本,会让过期持有者仍能覆盖新结果。
最小正确获取是SET resource token NX PX 10000:NX 防止覆盖已有锁,PX 设置租约,随机 token 标识所有权。SETNX后再EXPIRE有崩溃窗口,会留下死锁。释放不能直接DEL:客户端 A 停顿至锁过期,B 获取新锁,A 恢复后裸删就会删除 B 的锁。比较 token 与删除必须在同一个 Lua 脚本中原子完成。
二、原理:租约解决活性,不保证旧持有者失效
租约必须长于正常临界区,但进程暂停、网络抖动和下游慢请求都可能超时。自动续期只能减少过期概率:若续期线程同样暂停,锁仍会丢;若客户端与 Redis 失联,它也不知道自己是否还持有锁。因此业务动作要幂等,并使用 fencing token 防止陈旧写。
fencing token 是锁服务每次成功授予时递增的编号。受保护存储只接受大于已见编号的写入:即使编号 41 的旧客户端晚到,也会被已经处理编号 42 的存储拒绝。Redis 自增可产生编号,但必须把校验落在真正资源侧;如果下游 API 无法比较 token,就无法获得这种防护。
单实例在主节点确认写后、复制到副本前崩溃,故障转移可能丢失锁,新主又授予一次。能否接受取决于风险模型。缓存重建通常能接受偶发并行;金融扣款不能把 Redis 锁当唯一正确性屏障。多节点 Redlock 也有时钟、网络和暂停假设,使用前应明确安全目标,而不是因节点更多就宣称绝对安全。
三、实现:所有权检查与 fencing token
下面程序模拟两个持有者乱序完成。资源记录最后 token,并拒绝过期持有者;这是需要由数据库条件更新或下游服务实现的关键契约。
classFencedResource:def__init__(self):self.last_token=0self.value=Nonedefwrite(self,token,value):iftoken<=self.last_token:returnFalseself.last_token=token self.value=valuereturnTrueresource=FencedResource()holder_a=41holder_b=42accepted_b=resource.write(holder_b,"new-result")accepted_a=resource.write(holder_a,"stale-result")assertaccepted_bisTrueassertaccepted_aisFalseassertresource.value=="new-result"print(f"holder_b_accepted={accepted_b}")print(f"holder_a_accepted={accepted_a}")print(f"stored_token={resource.last_token}")print(f"stored_value={resource.value}")运行输出:
holder_b_accepted=True holder_a_accepted=False stored_token=42 stored_value=new-result下面脚本完整演示获取、生成 fencing token、续期和安全释放。两个 Lua 脚本都验证 token;示例临界区很短,生产租约应根据执行时间分布和故障预算决定。
#!/usr/bin/env bashset-euopipefailredis_url="${REDIS_URL:-redis://127.0.0.1:6379/0}"lock='demo:lock:report'sequence='demo:lock:sequence'token="owner-$$-$(date+%s%N)"redis-cli-u"$redis_url"DEL"$lock""$sequence">/dev/nullresult="$(redis-cli-u"$redis_url"--rawSET"$lock""$token"NX PX5000)"if[["$result"!=OK]];thenprintf'lock busy\n'>&2exit1fifence="$(redis-cli-u"$redis_url"--rawINCR"$sequence")"renew='if redis.call("get",KEYS[1])==ARGV[1] then return redis.call("pexpire",KEYS[1],ARGV[2]) else return 0 end'renewed="$(redis-cli-u"$redis_url"--rawEVAL"$renew"1"$lock""$token"5000)"test"$renewed"=1printf'fencing_token=%s\n'"$fence"printf'critical_section=completed\n'release='if redis.call("get",KEYS[1])==ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end'released="$(redis-cli-u"$redis_url"--rawEVAL"$release"1"$lock""$token")"printf'released=%s\n'"$released"redis-cli-u"$redis_url"DEL"$sequence">/dev/null四、工程化:等待、续期和可观测性
争锁失败不要固定间隔自旋,否则所有客户端会同步轰击 Redis。采用带随机抖动的指数退避,设置总等待上限,并让调用方能取消。等待时间超过业务截止时间就应失败,而不是拿到锁后已无时间完成工作。临界区不要包含不必要的网络请求;把准备工作移到锁外,并再次校验锁内条件。
续期只允许当前 token 执行,最大持有时间必须有限,防止逻辑错误永久占锁。进程收到终止信号时可尽力释放,但正确性不能依赖优雅退出。指标至少包括获取成功率、冲突率、等待分布、持有时长、续期失败、租约过期和业务重复执行次数;日志记录资源类别与 token 摘要,不记录敏感完整 key。
锁 key 的 Cluster 哈希槽会影响 Lua 涉及的多 key 操作。若要在脚本中同时操作锁与序列,二者需用相同哈希标签,例如lock:{report}与seq:{report};本文为兼容单实例演示分开调用,因此 fencing 编号与获取并非一个原子授予动作。严格系统应将授予流程放在同槽脚本或专门协调服务中。
五、验证:主动制造暂停与失联
单元测试不足以证明分布式行为。集成测试应让 A 获取锁后暂停超过 TTL,让 B 获取并写入,再恢复 A,确认下游拒绝旧 fencing token;还要模拟 Redis 命令超时但服务端实际成功,确认重试不会形成两个业务动作。释放脚本要测试 token 不匹配时返回 0 且不删除。
评审时逐项询问:租约多长、最大等待多久、谁续期、如何取消、进程崩溃如何恢复、主从切换是否允许重复、业务是否幂等、下游能否 fencing。没有答案的锁只是把竞态隐藏在罕见时序里。
还要区分锁粒度。全局锁实现简单,却把无关订单串行化;按资源 ID 加锁并发更高,但 key 数、监控与死锁排查更复杂。一次操作若必须获取多把锁,应固定全局顺序,并在拿不到后释放已持有锁,否则会形成循环等待。更稳妥的做法往往是重新划分事务边界,让一次业务动作只拥有一个聚合根。
安全测试应记录线性时间线,而不只是最终断言:客户端何时发送获取、Redis 何时确认、租约何时到期、业务何时提交、释放返回什么。这样才能区分“重复执行但被幂等吸收”和“锁本身从未重叠”。若告警只统计获取失败,会漏掉最危险的租约内执行超时;应单独统计临界区时长超过租约比例,并对持续上升立即降载。
锁解决互斥,不适合承载工作历史、重试与消费者进度。下一篇转向 Redis 的 List、Pub/Sub 与 Stream,分别建立简单队列、瞬时广播和可恢复消费模型。
参考来源
- Redis 官方文档:分布式锁
- Redis 官方文档:SET
- Redis 官方文档:EVAL
👍 觉得有用就点个赞 + 收藏,方便回头查阅;有疑问直接在评论区留言,我看到都会回。
🚀 本文属于《Redis 应用实战》系列,持续更新,关注不迷路。
📌 文章里的代码都能直接跑。想要可直接 clone 的完整工程 + 配套部署脚本 / 踩坑清单?评论一声或发邮件到cj2664@qq.com,我免费发你。
如果你正好在做类似系统、或有工程化难题想找人做,也欢迎邮件聊一句——我按实际情况评估,能落地的就接单或出方案。评论和邮件都能直接找到我,不用跳别的平台。