← [ BACK_TO_NOTES ]

BodySense Redis 第 4 课:原子性、Race Condition、MULTI、WATCH 与 Lua

从 GET-check-SET 的竞态出发,学习 Redis 单命令原子性、MULTI/EXEC、WATCH 与 Lua,并映射到 BodySense Session、Rate Limit 与 Refresh Rotation。

#course #growing #backend #redis #bodysense

[!info] related notes

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
...

问题有两个:

  1. 多次 network round trip;
  2. 命令之间可能被其他客户端穿插。

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 代码后判断:

  1. 单条命令是否已经满足原子性;
  2. 是否存在 read-check-write race;
  3. MULTI/EXEC 和数据库事务区别;
  4. WATCH 何时适合;
  5. Lua 何时值得引入、何时属于过度设计。
创建于 2026/9/18 更新于 2026/9/18
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本