Redis分布式锁必须用原子命令SET key value EX seconds NX,C#中调用StringSet(..., When.NotExists);释放锁须用Lua脚本校验value后删除;需结合DB乐观锁防超卖;锁续期是刚需。

Redis分布式锁必须用 SET 命令加 NX + EX 参数
直接用 SET key value 再 EXPIRE 是错的——这两步不原子,中间崩了就锁残留。C# 里用 StackExchange.Redis 时,必须走 Database.StringSet(key, value, expiry, When.NotExists),它底层对应 SET key value EX seconds NX。
常见错误现象:Lock acquired but never released 或者多个服务同时写库存;本质是锁没设过期时间,或设了但没和值绑定,导致误删别人锁。
-
value必须是唯一标识(比如Guid.NewGuid().ToString()),不能用固定字符串,否则释放锁时无法校验所有权 -
expiry别拍脑袋设 10 秒——得大于业务最长执行时间,但也不能太长(比如 5 分钟),否则故障时锁堆积 - 别用
Database.LockTake:它底层不是原子 SET,且已标记为 obsolete
释放锁必须用 Lua 脚本校验 value 再删
先 GET 再 DEL 是经典竞态:A 拿到锁,超时前执行完,去 GET 发现还是自己的 value,正要 DEL,B 的锁刚好过期被自动清理,C 立刻抢到锁,这时 A 的 DEL 就删掉了 C 的锁。
正确做法是用一段 Lua 脚本,由 Redis 保证原子性:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
endC# 中调用:Database.ScriptEvaluate(luaScript, new RedisKey[] { lockKey }, new RedisValue[] { lockValue })
- 脚本里用
KEYS[1]和ARGV[1],别硬编码 key 名或 value - 返回值是
long类型,1表示删除成功,0表示锁已失效或不属于你 - 别在 finally 里无条件
DEL——那是裸奔
超卖防护不能只靠锁,得结合数据库乐观并发控制
锁只解决「同一时刻只有一个线程改库存」,但挡不住:A 扣减前查到库存 100,B 同时也查到 100,A 扣 1 后写回 99,B 还按 100-1=99 写,结果变成 99 而不是 98。
所以 Redis 锁之后,DB 层必须用 where 条件兜底:
UPDATE products SET stock = stock - 1 WHERE id = @id AND stock >= 1
然后检查 RowsAffected 是否为 1。
- 如果
RowsAffected == 0,说明库存不足或已被扣完,此时要主动释放 Redis 锁(避免锁空占) - 别把库存扣减逻辑全放 Redis 里做
DECR——没法和订单、日志等事务联动 - 高并发下,
SELECT ... FOR UPDATE在 MySQL 里容易引发间隙锁争用,不如 where + affect rows 简单可靠
锁续期(watchdog)不是可选功能,而是防误删的刚需
业务执行时间波动大,比如下单链路有时快有时慢,如果锁过期时间设死,就可能在执行中锁自动消失,被别人抢走——这比没锁还危险。
StackExchange.Redis 自带 LockTake / LockRelease 不支持续期,得自己实现后台心跳:
- 拿到锁后,立刻起一个
Task.Run定时器,每隔expiry / 3时间(比如 expiry=30s,就每 10s 刷一次)执行 Lua 续期脚本 - 续期脚本类似释放逻辑,先校验 value,再用
EXPIRE延长 TTL:if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) else return 0 end - 任务必须在锁释放时显式取消,否则可能对已失效的锁反复续期,污染 Redis
这个点最容易被跳过——大家觉得“我业务很快,设个 10 秒够了”,但网络抖动、GC 暂停、磁盘 IO 高峰都可能让 10 秒变 15 秒。


















