BodySense Redis 第 4 课:原子性、Race Condition、MULTI、WATCH 与 Lua
从 GET-check-SET 的竞态出发,学习 Redis 单命令原子性、MULTI/EXEC、WATCH 与 Lua,并映射到 BodySense Session、Rate Limit 与 Refresh Rotation。
[!info] related notes
- 课程 MOC:BodySense Redis 从零到一课程 MOC
- 前置课程:第 3 课
- 对应知识笔记:Redis 原子性、事务与 Lua、Refresh Token
- 实践:Redis 在 Go 后端的应用
BodySense Redis 第 4 课:原子性、Race Condition、MULTI、WATCH 与 Lua
[!abstract] 学习进度 第 4 / 12 课,当前进行中。核心问题:多条 Redis 命令各自原子,不代表应用的一段 Redis 代码原子。
先从问题出发:为什么这一课需要“原子性”
上一课我们已经有了多种 Redis Key:
session:<sid>
user_sessions:<uid>
refresh_token:<digest>
login_failed:<dimension>
如果一次业务动作只需要一条 Redis 命令,问题通常比较简单。例如:
INCR login_failed:ip
但真实业务经常需要:
先读取当前状态
→ 判断是否允许
→ 修改多个 Key
→ 设置 TTL
→ 更新辅助索引
一旦出现这种“多步状态转换”,就必须回答:
如果两个请求同时执行,或者应用刚执行到一半就崩溃,会不会留下一个业务上不可能出现的中间状态?
这就是本课的核心。
1. 什么叫原子操作 Atomic Operation
Atomic 可以先按字面理解成“不可再拆的整体”。
在并发系统里,一个操作具有原子性,意味着其他参与者不会看到它执行到一半的中间状态:
before
→ atomic operation
→ after
外部只看到:
旧状态
或
新状态
而不是:
步骤 1 已完成
步骤 2 还没完成
步骤 3 正准备开始
最简单例子:INCR
假设:
counter = 4
两个客户端同时:
INCR counter
Redis 会形成类似:
A: 4 → 5
B: 5 → 6
最终是 6。
而不会发生两个客户端都“读到 4、算出 5、再写回 5”的 lost update。
2. Race Condition:为什么“每条命令都正确”还会出错
Race Condition(竞态条件)指结果取决于多个并发执行之间不可控的先后顺序。
最典型形状:
Read
→ Check
→ Write
库存例子:
GET stock
if stock > 0:
SET stock stock-1
初始:
stock = 1
并发时可能:
A GET → 1
B GET → 1
A check → 可以卖
B check → 可以卖
A SET → 0
B SET → 0
最终库存数字看起来还是 0,但业务实际上卖出了两件。
关键点:
GET自己没错;SET自己也没错;- 错的是业务上要求连在一起的几步并没有成为一个整体。
3. “Redis 单线程”为什么不能替你解决竞态
常见误解:
Redis 是单线程,所以 Redis 代码天然没有并发问题。
这不对。
更准确地说,在经典 Redis 命令执行模型中,一条命令执行时不会被另一个普通命令插到命令内部。
所以:
A 的一个 INCR
不会执行到一半突然变成:
A IN...
B GET
...CR
但是多条命令之间完全可以被别的客户端穿插:
A GET
B GET
A SET
B SET
所以应该记成:
单条 Redis 命令可以具有原子执行语义,但“客户端发出的多条命令序列”不会自动变成一个原子业务操作。
4. 第一优先:先找 Redis 已经提供的单条原子命令
在学习事务和 Lua 前,一个非常重要的工程习惯是:
能用一个已经存在的原子命令表达,就不要自己拼 Read → Check → Write。
Counter
不要:
GET counter
+1
SET counter
用:
INCR counter
Only-if-absent
不要:
GET lock
if missing:
SET lock owner
因为 GET 和 SET 中间存在竞争窗口。
Redis 的 SET 本身支持条件:
SET lock:job123 worker-a NX EX 30
含义:
NX → 只有 Key 不存在才 SET
EX 30 → 同时设置 30 秒 TTL
因此“检查不存在 + 写入 + TTL”可以收敛进一条命令。
[!important] 原则 先找原生命令 → 再考虑 MULTI/WATCH/Lua。
5. MULTI / EXEC:Redis 的事务队列是什么
MULTI / EXEC 是 Redis 的 transaction 机制。
最小例子:
MULTI
SET a 1
SET b 2
INCR c
EXEC
真实执行过程不是:
MULTI
→ SET a 立刻执行
→ SET b 立刻执行
→ INCR c 立刻执行
而是:
MULTI
↓
后续命令先进入队列
↓
EXEC
↓
Redis 连续执行整个队列
在执行这个队列期间,其他客户端的普通命令不会插进这些命令中间。
MULTI/EXEC 能解决什么
适合:
我已经知道要执行哪些命令
并且希望它们连续执行
不允许别的客户端插进中间
例如:
MULTI
SET x 1
SET y 2
EXEC
MULTI/EXEC 不能直接解决什么
假设业务是:
读取 balance
如果 >= 100
才扣 100
你真正需要的是:
Read
→ 根据 Read 的结果做判断
→ 决定后续写什么
普通 MULTI 只会把后续命令排队;它本身不是“在事务内部让客户端先读结果再分支”的通用编程语言。
这就是为什么还会有 WATCH 和 Lua。
Redis Transaction 和 PostgreSQL Transaction 不一样
这是一个很重要的边界。
PostgreSQL transaction 常让人形成:
BEGIN
step 1
step 2
step 3 出错
→ ROLLBACK 前面的修改
Redis MULTI/EXEC 没有这种通用 rollback 模型。
如果一个命令在 EXEC 执行阶段发生类型错误,其他已经排队的命令仍可能继续执行。
所以:
Redis transaction 的核心语义是“排队 + 连续执行 + 不被其他客户端命令插入”,而不是关系数据库式任意错误自动回滚。
6. WATCH:给 Read → Check → Write 增加乐观并发控制
WATCH 可以理解成:
我准备基于这个 Key 当前的值做决定;如果在我提交前别人改过它,就不要执行我的事务。
示意:
WATCH balance
GET balance
应用读到:
balance = 150
应用判断可以扣款,然后:
MULTI
DECRBY balance 100
EXEC
如果从 WATCH 到 EXEC 之间没有别人修改 balance:
EXEC success
150 → 50
如果另一个客户端已经修改:
WATCH 发现 watched key changed
→ EXEC 返回 abort/null
→ 客户端重新读、重新判断、重试
这种模式叫 optimistic concurrency control / optimistic locking:
先假设冲突不多
→ 正常读和计算
→ 提交时检查版本是否仍然成立
→ 冲突才重试
WATCH 的代价
如果一个热点 Key 竞争很激烈:
read
→ conflict
→ retry
→ conflict
→ retry
会出现很多无效往返。
这时把判断搬到 Redis 内一次完成的 Lua/Function 可能更自然。
7. Lua 到底是什么?
现在才进入你指出的那个突兀概念。
7.1 Lua 是一门编程语言,不是 Redis 自创语法
Lua 是一门体积小、嵌入式场景常见的脚本语言。
它和 JavaScript、Python 一样,具备:
变量
if / else
循环
函数
table 数据结构
Redis 内部嵌入了一个 Lua 解释器,所以客户端可以把一小段 Lua 程序发送给 Redis Server,让这段程序在 Redis 服务器内部执行。当前 Redis 官方 Lua API 文档说明 Redis 内嵌 Lua 5.1 解释器。
这点非常关键:
普通 Go 逻辑
Go Process
↓ network
Redis command
Redis Lua
Go Process
↓ 把 script + 参数发送一次
Redis Server 内部
↓
Lua 执行多次 Redis command
也就是说,Lua 不是在 Go 应用里帮你把几条命令拼起来;逻辑真的被搬到了 Redis Server 里面。
7.2 Redis 为什么要允许 Server-side Script
假设你需要:
读取 Redis 值
→ if 判断
→ 修改 Key A
→ 修改 Key B
→ 根据结果返回状态码
如果全放在 Go 里:
Go → Redis GET
Redis → Go
Go 做判断
Go → Redis SET
Go → Redis SADD
...
问题有两个:
- 多次 network round trip;
- 命令之间可能被其他客户端穿插。
Lua 可以变成:
Go
↓ 一次发送
Redis Lua Script
├─ GET
├─ if
├─ SET
├─ SADD
└─ return
↓
一次返回
所以 Lua 的主要价值是:
Data locality
+
多命令条件逻辑
+
原子执行
+
减少网络往返
8. 第一段真正的 Redis Lua:EVAL
最直接的执行命令是:
EVAL "return 'hello'" 0
这里:
EVAL
第 1 个参数 → Lua script source
第 2 个参数 → 后面有几个参数属于 Redis Key
返回:
hello
再看一个带 Key 和参数的版本:
EVAL "return {KEYS[1], ARGV[1]}" 1 login_failed:ip 600
脚本内部:
KEYS[1] = "login_failed:ip"
ARGV[1] = "600"
为什么分 KEYS 和 ARGV
KEYS 专门声明脚本会访问哪些 Redis Key。
ARGV 是普通参数,例如:
TTL 秒数
user id
expected value
new value
不要把动态 Key 偷偷放进 ARGV 再拼接作为主要访问路径。显式声明 Key 对 Redis Cluster 路由、分析脚本访问范围等很重要。
9. Lua 怎么调用 Redis 命令:redis.call
在脚本里不能直接写:
INCR key
真正形式类似:
local current = redis.call('INCR', KEYS[1])
再例如:
redis.call('EXPIRE', KEYS[1], ARGV[1])
于是你可以把 Redis command 的返回值放进 Lua 变量,再做条件判断。
10. 逐行理解 Rate Limit Lua
BodySense 当前真实 apps/api/internal/auth/rate_limit.go 中使用 redis.NewScript(...) 定义 fixed-window limiter。
核心脚本:
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {current, ttl}
逐行看。
第 1 行:INCR
local current = redis.call('INCR', KEYS[1])
如果 Key 不存在,计数从 0 开始:
missing
→ INCR
→ 1
如果已是 4:
4
→ INCR
→ 5
current 得到增加后的值。
第 2~4 行:只在第一次请求设置 TTL
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
为什么只在 current == 1 设置?
因为 fixed window 想要:
第一次请求
→ 创建一个窗口,例如 10 分钟
后续请求
→ 只增加计数
→ 不把窗口不断向后续期
如果每次请求都重新 EXPIRE 600:
第 1 次 12:00 → 到 12:10
第 2 次 12:05 → 又延到 12:15
第 3 次 12:14 → 又延到 12:24
那就不再是这个 fixed-window 语义了。
为什么不能在 Go 里分两条命令
如果:
INCR key
↓
应用进程崩溃 / 网络断开
↓
EXPIRE key 600 根本没执行
会留下:
counter exists
TTL = none
一个原本应该 10 分钟后消失的 rate-limit Key 可能永久存在。
Lua 将:
INCR
→ 判断 current
→ EXPIRE
→ TTL
放在 Redis 内一次执行,消除了客户端两条命令之间的失败/穿插窗口。
11. 为什么 Redis Lua 是原子的
Redis 官方对脚本给出的核心保证是:脚本在 Server 端执行时具有原子执行语义;脚本运行期间,其他服务器活动不会插进脚本的执行中。
因此其他客户端看到的不是:
Lua 执行 INCR
→ B 客户端 GET
→ Lua 执行 EXPIRE
而更接近:
script starts
→ INCR
→ if
→ EXPIRE
→ TTL
→ return
script ends
→ next client command
这就是 Lua 能表达一个原子状态转换的原因。
12. 原子执行 ≠ 关系数据库式 Rollback
这是学习 Lua 时必须立刻建立的边界。
“原子”很容易让人误以为:
脚本执行到后面报错
→ Redis 自动撤销脚本前面所有写入
不要这样理解。
Redis 的脚本/transaction 模型重点是执行时不被其他命令穿插,不是 PostgreSQL 那种任意错误都自动回滚前面所有修改的通用事务。
所以生产脚本应该:
- 尽量在写之前完成可预见的参数/类型检查;
- 保持逻辑小而清晰;
- 不把复杂业务流程全部塞进 Lua。
13. 为什么长 Lua 脚本危险
刚才说:
脚本执行期间不会让其他普通命令插入。
这既是优点,也是风险。
假设一个脚本循环处理几十万个成员:
Lua running 2 seconds
↓
其他客户端命令也要等
↓
Redis p95/p99 latency suddenly rises
所以 Lua 的典型定位是:
短
确定
围绕 Redis 状态
少量 Key / 少量命令
完成一个小状态转换
而不是把一整个业务 Service 搬进 Redis。
14. EVAL、EVALSHA、go-redis NewScript 与 Redis Functions
EVAL
每次把完整 script source 发给 Redis。
EVALSHA
Redis 可以缓存 script,并通过 SHA1 digest 执行,减少每次发送完整源码的成本。
go-redis 的 redis.NewScript
BodySense 当前使用:
redis.NewScript(`...`)
然后:
script.Run(ctx, client, keys, args...)
客户端库会负责脚本执行细节,因此业务代码不需要手写一整套 SCRIPT LOAD / EVALSHA fallback。
Redis Functions
Redis 7+ 还提供 Redis Functions:把 server-side logic 作为服务器管理的 function library 注册,再通过 FCALL 调用。
可以先把关系理解为:
EVAL / Lua Script
→ application-owned ephemeral server-side script
Redis Functions
→ server-managed programmable function
Redis 官方现在把 Functions 作为 Redis 7+ 更正式的 server-side programmability 方案。
但这不意味着你现在必须把 BodySense 所有 redis.NewScript 立即改写成 Functions。课程这里的目标是先理解为什么需要 server-side atomic logic。
15. Lua 与 MULTI / WATCH 到底怎么选
只有一条原子命令就够
INCR
SET ... NX EX
SADD
→ 用原生命令。
已经知道要执行哪些命令,只要求连续执行
SET A
SET B
INCR C
→ 可以考虑 MULTI/EXEC。
判断逻辑必须留在客户端
WATCH
→ GET
→ Go 里计算
→ MULTI/EXEC
→ 冲突则重试
→ WATCH 是自然选择。
判断可以很小地搬进 Redis
GET / EXISTS
→ if
→ SET / DEL / SADD / EXPIRE
→ return code
→ Lua / Redis Functions 往往更直接。
16. BodySense 真实案例一:Session 创建
当前 apps/api/internal/cache/user_session.go 不是概念示例,而是真实使用 Lua:
if redis.call('EXISTS', KEYS[3]) == 1 then
return 0
end
redis.call('SET', KEYS[1], '1', 'EX', ARGV[2])
redis.call('SADD', KEYS[2], ARGV[1])
redis.call('EXPIRE', KEYS[2], ARGV[2])
return 1
这里三个 Key 大致是:
KEYS[1] → session:<sid>
KEYS[2] → user_sessions:<uid>
KEYS[3] → user_auth_revoked:<uid>
目标不是单纯“同时 SET + SADD”,还包含一个业务判断:
如果这个 user 已经存在 revocation tombstone
→ 不允许重新创建 Session
所以它非常适合 server-side conditional atomic transition。
17. BodySense 真实案例二:Refresh Token Rotation
当前 auth_service.go 里的 rotateRefreshScript 做的是:
GET old refresh state
↓
是否仍等于 expected?
├─ yes
│ ├─ DEL old token
│ ├─ 写 replay tombstone
│ ├─ SET new token
│ ├─ 更新 refresh family Set
│ └─ return 1
│
└─ no
├─ 检查是否命中 replay tombstone
└─ return 2 / 0
为什么这必须 single-winner?
假设 R1 被浏览器和攻击者同时提交:
A sees R1 active
B sees R1 active
如果判断在 Go 里、写入不是一个原子转换:
R1
/ \
R2 R3
就出现两个合法后代。
Lua 把“检查旧状态 + 消耗旧 token + 创建新 token + 更新 family”收敛成一个 Redis 内部状态转换。
18. Lua 解决不了什么
TTL 自动过期不是“调用你的 Lua”
上一课提过:
session:s1 TTL expires
Redis 不会自动执行:
你的 deleteSessionScript
因此 user_sessions:<uid> 仍可能暂时残留 stale sid。
Lua 不能给 PostgreSQL + Redis 做一个 ACID 事务
例如:
PostgreSQL UPDATE
+
Redis Lua
并不会因为用了 Lua 就变成跨系统原子事务。
Lua 不能替代业务建模
如果状态 ownership、本该持久化在哪里、失败语义都没想清楚,把更多逻辑塞进 Lua 只会让系统更难理解。
19. 本课选择顺序
1. 已有单条原子命令?
→ 优先原生命令
2. 只需要命令连续执行?
→ MULTI / EXEC
3. 需要客户端 read-check-write,且冲突不高?
→ WATCH + MULTI / EXEC
4. 需要 Redis 内部判断 + 多命令原子状态转换?
→ Lua / Redis Functions
Prediction
[!question] 先自己回答
A. counter=4,两个请求同时执行 GET → +1 → SET,最终可能是多少?为什么?
B. 两个请求都直接 INCR counter,最终是多少?
C. 两个请求同时使用 active R1,应用代码为 GET → if active → SET consumed → create new token,最大问题是什么?
D. Rate Limit 的 INCR 和 EXPIRE 单独都正确,为什么组合仍有问题?
E. 只是执行 SADD user_sessions:u1 s1,是否还需要为了“原子性”专门写 Lua?
[!success]- Answer / 解析 A → 最终可能只有 5。 两个请求都可能先读到 4,各自在客户端算出 5,再分别写 5;两次业务增加丢了一次,这就是 lost update。问题不是 GET/SET 单条命令不原子,而是 GET → 计算 → SET 这个整体没有原子边界。
B → 最终是 6。
INCR把读取、加一和写回都封装在 Redis Server 的一条命令里,两个请求会形成 4→5→6。C → R1 可能分叉出多个合法后代。 A/B 都看到 R1 active 后分别创建 R2/R3,会破坏 Refresh Rotation 的 single-winner 和 replay detection。需要 WATCH/CAS 或更直接的 Redis-side Lua/Function 原子状态转换。
D → 两条命令之间存在 failure window。
INCR成功后,如果应用崩溃或连接断开,EXPIRE可能永远没有执行,于是一个本应只活 10 分钟的 counter 变成永久 Key。E → 不需要。
SADD自身已经是原子命令。Lua 的目标是解决“多命令 + 条件判断”的原子状态转换,不是为了让所有 Redis 操作看起来更高级。
Lab:亲手观察三种原子模型
Lab A · Lost Update
开两个终端,先人为模拟:
Terminal A: GET demo:counter
Terminal B: GET demo:counter
两边都读到 4
然后两边都 SET 5
确认最终只得到 5。
Lab B · INCR
把两端都改成:
INCR demo:counter
观察最终得到 6。
Lab C · WATCH Conflict
用两个连接:
A WATCH demo:balance
A GET demo:balance
B SET demo:balance 200
A MULTI
A DECRBY demo:balance 100
A EXEC
观察 A 的事务因为 watched key 被改变而 abort,然后解释为什么客户端必须重新读取和重新判断。
Lab D · EVAL
在隔离 Redis 中执行一个最小脚本:
EVAL "local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]) end; return {n,redis.call('TTL',KEYS[1])}" 1 demo:rate 60
重复执行几次,观察:
count 增加
TTL 递减
后续请求不会把 TTL 重置回 60
Explain-back / L4 Gate
能够看到一段 Redis 代码后判断:
- 单条命令是否已经满足原子性;
- 是否存在 read-check-write race;
- MULTI/EXEC 和数据库事务区别;
- WATCH 何时适合;
- Lua 何时值得引入、何时属于过度设计。