Skip to content

Redis 原子性边界:单命令、事务、Lua 与分布式锁各保证什么 ​

Redis 同一时刻只执行一条命令,这只能说明一条命令或一段服务器端脚本不会被别的命令插入。它不自动提供回滚、跨系统的事务,也不能保证锁过期之后旧的持有者停手。

用一个活动名额的例子开头:100 个名额,50 个线程各抢 10 次。先读剩余名额、判断大于 0、再写回减一后的值:

text
客户端认为抢到: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,下游只接受不小于已见最大值的写入,实测旧持有者的写入被拒绝。

二、五层保证不要混在一起 ​

单条命令MULTI / EXECWATCH 事务Lua / Function分布式锁执行中不被其他命令插入是是是是否能先读后判断再写否否冲突即放弃是是出错时回滚已执行的部分单命令无此问题不回滚不回滚不回滚不回滚保护 Redis 之外的资源否否否否需 fencing
图 1 · 同样叫「原子」,保证的内容并不相同:MULTI 只保证连续执行,不能先读后判断,也不回滚;Lua 和 Function 能在服务器上完成判断与修改;锁要保护 Redis 之外的资源时,还需要下游配合校验 fencing token
  1. 单条命令:INCR、DECR、SET NX、HINCRBY 等在执行中不会被打断。它能表达的逻辑有限,比如 DECR 不能「先判断大于 0 再减」。
  2. MULTI/EXEC:队列里的命令连续执行,中间不会插入其他客户端的命令。但所有命令都在 EXEC 时才执行,事务里读不到中间结果,所以不能「先读后判断」。
  3. WATCH:在 MULTI 之前监视 key,如果 EXEC 前 key 被别人改过,整个事务放弃执行,客户端重试。这是乐观锁,冲突越多重试越多。
  4. Lua / Function:整段脚本在服务器上执行,期间不会插入其他命令,可以读、判断、写。代价是脚本执行时整个实例都在等它。
  5. 分布式锁:在 Redis 之外的资源上建立互斥,只能靠租约;它能减少并发冲突,保护外部资源时还需要下游配合。

三、名额扣减的五种写法 ​

写法卖出 / 名单耗时说明
GET 判断后 SET500 / 500—读和写之间被插入,超卖 400 个
DECR,小于 0 再 INCR 补回100 / 1009ms正确;期间计数器会短暂为负,读到它的请求会看到负数
WATCH + MULTI 冲突重试100 / 100115ms正确;50 个并发下冲突重试 3,003 次
Lua 条件扣减(EVALSHA)100 / 1005ms正确;一次往返
Function 条件扣减(FCALL)100 / 1004ms正确;与 Lua 相同,另有持久化与命名

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 0

WATCH 的重试次数说明了它的适用范围:冲突少时它不需要服务器端代码,冲突多时大量请求在白白往返。热点名额、库存这类高冲突场景,用 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 后再删除,是最基本的要求。实测:

text
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 被抢占)时,它自己不知道时间已经过去了。

没有 fencing有 fencing0ms500ms716ms1510msA 持有锁(500ms 租约)A 停顿中,锁已过期B 获取锁并写入A 恢复,写入成功AB下游A:token 3A 停顿中B:token 4,下游记下 4A 写入被拒绝(3 < 4)AB下游
图 2 · A 拿到 500ms 的锁后停顿 1.5 秒,B 在锁过期后拿到锁并写入;A 恢复时并不知道锁已易主。没有 fencing 时 A 覆盖了 B 的结果;有 fencing 时下游发现 A 的 token 3 小于已见的 4,拒绝写入
text
6ms      A 拿到锁,租约 500ms,token 1
716ms    B 拿到锁,token 2,写入下游
1510ms   A 从停顿中恢复,此时锁的持有者是 B
1510ms   A 写入下游,成功 → 最终值是 A 写的

自动续期(watchdog)能减少这种情况,但挡不住它:续期线程和业务线程一起停顿时,续期也不会发生。异步复制还会让锁在主从切换时丢失,见 Redis Sentinel 故障切换。

七、fencing token 让下游拒绝过期的持有者 ​

每次获取锁时,用 INCR 领一个单调递增的 token,随写入一起带给下游;下游记住见过的最大 token,只接受不小于它的写入。在数据库里就是一个条件更新:

sql
UPDATE activity SET quota = ?, fence = ?
WHERE id = ? AND fence <= ?

实验里用一段 Lua 模拟下游的这个判断:

text
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 的分布式信号量。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权