Redis 原子性边界:单命令、事务、Lua 与分布式锁各保证什么
Redis 同一时刻只执行一条命令,这只能说明一条命令或一段服务器端脚本不会被别的命令插入。它不自动提供回滚、跨系统的事务,也不能保证锁过期之后旧的持有者停手。
用一个活动名额的例子开头:100 个名额,50 个线程各抢 10 次。先读剩余名额、判断大于 0、再写回减一后的值:
客户端认为抢到:500 次
中奖名单: 500 人
剩余名额: 34每一条 GET、SET 都是原子的,结果却超卖了 400 个。原因是「读、判断、写」是三次往返,中间可以插入任意多个其他客户端的读和写。本文用 Redis 8.10.1 的实测,把「原子」拆成几层不同的保证,再看锁在租约过期时为什么需要下游配合。
一、先说结论
- 把判断和修改放进同一次服务器端执行。 名额扣减的四种正确写法实测都恰好卖出 100 个:
DECR后补偿、WATCH 事务、Lua、Function;其中 Lua 和 Function 用时 4—5ms,WATCH 冲突重试了 3,003 次、用时 115ms。 - MULTI/EXEC 不是 SQL 事务。 入队时的语法错误让整个事务被拒绝;执行时的错误(比如对字符串执行
LPUSH)只让那一条失败,前后的命令照常生效,没有回滚。 - EVAL 的脚本缓存不会持久化,Function 会。 实测重启后
EVALSHA返回NOSCRIPT,FCALL照常执行。 - 锁的正确释放只是起点。 不校验 token 的
DEL会删掉别人的锁;校验 token 后仍然挡不住「租约过期后旧持有者继续写」,实测旧持有者覆盖了新持有者的结果。 - fencing token 需要下游参与。 每次拿锁时领一个递增的 token,下游只接受不小于已见最大值的写入,实测旧持有者的写入被拒绝。
二、五层保证不要混在一起
- 单条命令:
INCR、DECR、SET NX、HINCRBY等在执行中不会被打断。它能表达的逻辑有限,比如DECR不能「先判断大于 0 再减」。 - MULTI/EXEC:队列里的命令连续执行,中间不会插入其他客户端的命令。但所有命令都在
EXEC时才执行,事务里读不到中间结果,所以不能「先读后判断」。 - WATCH:在
MULTI之前监视 key,如果EXEC前 key 被别人改过,整个事务放弃执行,客户端重试。这是乐观锁,冲突越多重试越多。 - Lua / Function:整段脚本在服务器上执行,期间不会插入其他命令,可以读、判断、写。代价是脚本执行时整个实例都在等它。
- 分布式锁:在 Redis 之外的资源上建立互斥,只能靠租约;它能减少并发冲突,保护外部资源时还需要下游配合。
三、名额扣减的五种写法
| 写法 | 卖出 / 名单 | 耗时 | 说明 |
|---|---|---|---|
GET 判断后 SET | 500 / 500 | — | 读和写之间被插入,超卖 400 个 |
DECR,小于 0 再 INCR 补回 | 100 / 100 | 9ms | 正确;期间计数器会短暂为负,读到它的请求会看到负数 |
WATCH + MULTI 冲突重试 | 100 / 100 | 115ms | 正确;50 个并发下冲突重试 3,003 次 |
Lua 条件扣减(EVALSHA) | 100 / 100 | 5ms | 正确;一次往返 |
Function 条件扣减(FCALL) | 100 / 100 | 4ms | 正确;与 Lua 相同,另有持久化与命名 |
Lua 版本:
-- KEYS[1]=剩余名额 KEYS[2]=中奖名单 ARGV[1]=用户
local left = tonumber(redis.call('GET', KEYS[1]))
if left > 0 then
redis.call('DECR', KEYS[1])
redis.call('RPUSH', KEYS[2], ARGV[1])
return 1
end
return 0WATCH 的重试次数说明了它的适用范围:冲突少时它不需要服务器端代码,冲突多时大量请求在白白往返。热点名额、库存这类高冲突场景,用 Lua 或 Function 更合适。更完整的秒杀链路见 容量评估与秒杀设计。
四、事务不是 SQL 事务
实测三种情况:
| 情况 | EXEC 的结果 | 数据 |
|---|---|---|
入队时命令有语法错误(INCRBY 少参数) | EXECABORT,整个事务被拒绝 | 队列里的 SET 也没有执行 |
执行时出错(对字符串 LPUSH) | [OK, WRONGTYPE…, OK] | 出错命令前后的 SET 都已生效,没有回滚 |
WATCH 的 key 被其他连接修改 | 空回复 | 整个事务不执行 |
所以不要用「支持 / 不支持 ACID」一个标签概括。Redis 事务保证的是:队列里的命令连续执行、不被插入(隔离);WATCH 提供条件提交;执行时出错不回滚(没有原子性意义上的全部或全不);持久性取决于 AOF 配置,见 Redis 持久化与恢复。
五、Lua 与 Functions 怎么选
- 只通过
KEYS和ARGV访问 key。 脚本里用到的 key 都要作为KEYS传入,Cluster 模式下它们必须在同一个 slot,见 Redis Cluster 的应用契约。 - 脚本会阻塞整个实例。 长脚本的影响和慢命令一样,实测一个 300 万次的空循环让其他请求等了 350ms,见 Redis 为什么突然变慢。
- EVAL 的脚本缓存属于运行时状态。
SCRIPT LOAD加载的脚本在重启、切换、SCRIPT FLUSH后都会消失,客户端要能在收到NOSCRIPT时用EVAL重新加载。实测重启后EVALSHA返回NOSCRIPT。 - Function(Redis 7.0 起)是数据库的一部分。 用
FUNCTION LOAD加载一个带名字的库,随 RDB/AOF 持久化、随复制传播,客户端用FCALL按名字调用。实测重启后FCALL照常执行。适合多个服务共用、需要版本管理的服务器端逻辑。
六、锁:正确释放只是起点
用 SET key token NX PX ttl 加锁、比较 token 后再删除,是最基本的要求。实测:
A:SET lock token-a NX PX 300 → OK
B:SET lock token-b NX PX 300 → 空(A 持有)
300ms 后 A 的锁过期
B:SET lock token-b NX PX 5000 → OK
A:DEL lock → 1,删掉的是 B 的锁改用比较 token 的 Lua 释放后,A 释放返回 0,B 的锁还在。这部分在 分布式锁的三种实现 中已有详细说明。
更难的问题是:锁过期之后,旧的持有者可能还在运行。 进程停顿(GC、长时间 I/O、CPU 被抢占)时,它自己不知道时间已经过去了。
6ms A 拿到锁,租约 500ms,token 1
716ms B 拿到锁,token 2,写入下游
1510ms A 从停顿中恢复,此时锁的持有者是 B
1510ms A 写入下游,成功 → 最终值是 A 写的自动续期(watchdog)能减少这种情况,但挡不住它:续期线程和业务线程一起停顿时,续期也不会发生。异步复制还会让锁在主从切换时丢失,见 Redis Sentinel 故障切换。
七、fencing token 让下游拒绝过期的持有者
每次获取锁时,用 INCR 领一个单调递增的 token,随写入一起带给下游;下游记住见过的最大 token,只接受不小于它的写入。在数据库里就是一个条件更新:
UPDATE activity SET quota = ?, fence = ?
WHERE id = ? AND fence <= ?实验里用一段 Lua 模拟下游的这个判断:
1ms A 拿到锁,token 3
706ms B 拿到锁,token 4,写入下游,下游记下 4
1505ms A 恢复
1506ms A 带着 token 3 写入下游 → 被拒绝,最终值是 B 写的fencing token 的正确性来自下游的校验,而不是 Redis 客户端的技巧:下游如果不认 token,这个方案就不成立。
八、什么时候不用锁
先看约束能不能直接表达在数据上:
- 唯一约束、条件更新(
WHERE stock >= ?)、版本号,能在数据库里原子地判断并修改; - 幂等键让重复请求只生效一次;
- 按业务键把请求路由到同一个队列分区,串行处理;
- 状态机的条件转换(
WHERE status = 'PAID')。
锁适合的是:资源本身没法表达并发约束,操作时间短、可以重试,而且被保护的操作能容忍偶尔的重复执行,或者下游能校验 fencing token。更多写法见 分布式锁与并发控制实战。
九、常见误区
- 「Redis 是单线程的,所以我的操作是原子的」:每条命令是原子的,多条命令之间可以被插入,实测超卖 400 个。
- 「MULTI 能回滚」:执行时出错的命令不影响其他命令,已执行的不会撤销。
- 「EVALSHA 加载一次就一直能用」:重启和切换后脚本缓存会丢,要处理
NOSCRIPT。 - 「校验 token 再删锁就安全了」:它只防止误删,挡不住租约过期后旧持有者继续写。
- 「有了自动续期就不会过期」:续期线程也会停顿,主从切换也会丢锁。
- 「Lua 能替代跨 Redis 和数据库的事务」:Lua 只在 Redis 内部原子,Redis 成功、数据库失败时仍要补偿。
小结
先问清楚要保护的是什么:Redis 里的一组 key,就用单命令、Lua 或 Function 把判断和修改放在一起;需要乐观并发时用 WATCH,但冲突多时它会大量重试;事务不回滚,要自己处理部分失败。要保护 Redis 之外的资源,锁只能提供带租约的互斥,正确性最终靠下游:唯一约束、条件更新或 fencing token。
具体的分布式信号量实现见 基于 Redis 的分布式信号量。
配套实验
- codesphere-labs/cache/redis-atomicity-and-locks:名额扣减的五种写法、MULTI 的两类错误与 WATCH 冲突、重启前后的 EVALSHA 与 FCALL、锁的 token 释放、租约过期后旧持有者的写入与 fencing token(验证记录)
参考资料