阶段二 · 进阶数据结构与应用场景

Lua 脚本与事务

总览: 原子性的两条路

前五篇积累了大量单条命令的用法. 但真实场景中, 一个业务操作往往需要多条命令配合完成--分布式锁要先 GET 再 DEL(05 篇的释放锁), 扣库存要先 GET 再 SET, 限流要先 INCR 再判断. 如果这些命令之间被其他客户端的命令插入, 就会出现竞态条件(Race Condition).

Redis 提供了两条路来保证多命令的原子执行:

方式 核心机制 能做什么 不能做什么
事务(MULTI/EXEC) 命令入队, EXEC 时一次性串行执行 保证"不被打断" 没有回滚, 不能在命令间做逻辑判断
Lua 脚本 整个脚本作为一个原子操作执行 原子性 + 逻辑判断(if/else) + 计算 + 减少网络往返 不能执行非确定性操作(如随机写), 不能长时间运行

事务 = 把要做的几件事写在纸条上, 交给柜台一次性办理 -- 中间不会插队, 但如果某一步出错了, 前面的步骤也不会撤销.
Lua 脚本 = 写了一段程序让柜台运行 -- 可以写 if/else 判断, 循环, 计算, 整段程序要么跑完要么不跑.

1. Redis 事务(Transaction)

1.1 MULTI / EXEC 基础

事务的三个关键命令:

MULTI

MULTI

开启事务. 之后的所有命令不会被立即执行, 而是进入命令队列.

EXEC

EXEC

执行事务队列中的所有命令. 返回一个数组, 每个元素对应一条命令的结果.

DISCARD

DISCARD

清空命令队列, 放弃执行. MULTI 之后, EXEC 之前使用.

# 场景: 转账 -- A 减 100, B 加 100
MULTI
DECRBY account:A 100
INCRBY account:B 100
EXEC
→ 1) (integer) 900 ← A 的结果
→ 2) (integer) 1100 ← B 的结果

# 在 MULTI 和 EXEC 之间, 其他客户端无法插入命令
# 这保证了"A 扣了但 B 没加"不会发生

事务的关键特性:

  • 串行执行: EXEC 时命令一次性顺序执行, 不会被打断
  • 没有回滚: 即使中间某条命令执行失败, 后面的命令继续执行. 前面的命令不会撤销.
  • 入队时只做语法检查: MULTI 期间命令入队时只检查语法错误(参数数量不对等), 执行期错误(如对 String 做 LPUSH)不影响其他命令

1.2 为什么 Redis 事务没有回滚?

这可能是从关系数据库(MySQL/Postgres)转过来的开发者最大的困惑. Redis 官方对此的解释是:

  1. Redis 命令的设计哲学: 命令本身设计得足够简单, 语法错误应该在开发阶段就被发现, 不应该到生产环境才暴露.
  2. 性能优先: 回滚机制需要维护 undo log 等额外结构, 与 Redis 的极简高性能定位冲突.
  3. 实际收益低: Redis 命令的执行期错误大多是编程错误(如类型不匹配), 这类错误无法通过回滚修复--需要的是修代码, 而不是撤销操作.
# 演示: 事务中的执行期错误不会回滚
SET k1 "hello"
MULTI
INCR k1 ← 入队不报错(语法 OK)
SET k2 "world"
EXEC
→ 1) (error) ERR value is not an integer ← INCR 对字符串执行失败
→ 2) OK ← 但 SET 仍然执行了!

关键结论

Redis 事务保证的是隔离性(不被其他客户端打断), 不保证原子性(失败不回滚). 如果业务逻辑需要"全成功或全失败", 必须用 Lua 脚本在应用层控制.

1.3 WATCH: 乐观锁(Optimistic Locking)

WATCH 是实现**CAS(Compare-And-Swap)**的关键. 它监控一个或多个 key, 在执行 EXEC 之前, 如果这些 key 被其他客户端修改了, EXEC 会返回 (nil), 整个事务被取消.

WATCH / UNWATCH

WATCH key [key ...] / UNWATCH

监控指定 key 的修改. 如果在 WATCH 之后, EXEC 之前该 key 被修改, EXEC 放弃执行. UNWATCH 取消所有监控.

# 场景: 抢最后一个库存(乐观锁版本)

# --- 客户端 A ---
WATCH stock:item_1001 ← 监控库存 key
GET stock:item_1001 → "1"(还剩 1 件)
MULTI
DECR stock:item_1001 ← 扣库存
# 此时在 A 的 EXEC 之前, 客户端 B 抢先:
# DECR stock:item_1001 → 0

EXEC ← A 的 EXEC
→ (nil) ← 事务被取消! 因为 stock 已被 B 修改

# A 的处理: 重试
UNWATCH
WATCH stock:item_1001
GET stock:item_1001 → "0"(库存为 0, 不再下单)

WATCH = 在图书馆的书上放一张便签"我在读这本". 去借书之前, 管理员检查便签还在不在--如果在, 说明没人动过, 借给你; 如果被撕了, 说明有人捷足先登, 你得重新排队.

WATCH 的限制:

  1. 只能在 MULTI 之前使用, 不能在事务内部 WATCH
  2. 它监控的是整个 key 的值变化, 不是某个 field 的变化
  3. 高并发下重试次数可能很高, 此时更适合用 Lua 脚本

1.4 事务的适用边界

场景 事务合适吗? 原因
多条命令需要不被打断 ✅ MULTI/EXEC 的核心能力
需要"全成功或全失败" ❌ Redis 事务无回滚, 中途失败后面继续执行
需要根据上一条命令的结果做判断 ❌ 事务中命令先入队再执行, 无法在命令间传递变量
高并发 CAS(如抢库存) 勉强 WATCH 在高竞争下重试率高, Lua 脚本更优
需要循环, 计算, 复杂逻辑 ❌ Lua 脚本

2. Lua 脚本

2.1 为什么需要 Lua?

Redis 选择嵌入 Lua 解释器, 解决了一个核心矛盾: 原子性 vs 灵活性. 事务能保证原子性但太僵硬(不能做判断), 而一次发多条命令又无法保证原子性. Lua 脚本在 Redis 服务端执行, 把"逻辑判断 + 数据操作"打包成一个原子单元.

优势 说明 对比
原子性 整个脚本作为一个原子操作, 执行期间其他命令必须等待 vs 分多次命令发送 = 无原子性
减少网络往返 N 条命令 = 1 次网络请求 vs 事务 = 2 次(MULTI + 命令一起发, EXEC)
服务端逻辑 可以在服务端做 if/else, 计算, 返回值处理 vs 事务中命令间不能传递变量
脚本复用 SCRIPT LOAD 缓存后, 用 SHA1 引用, 省带宽 vs 事务每次都传完整命令

不用 Lua = 给柜台发五次微信, 每次都等回复再发下一条--来回五趟, 中间可能被插队.
用 Lua = 把一段程序发给柜台, 柜台自己执行完告诉你结果--一趟往返, 中间没人插队.

2.2 EVAL / EVALSHA

EVAL

EVAL script numkeys key [key ...] arg [arg ...]

执行一段 Lua 脚本. numkeys 表示后面有几个 key(用于集群模式计算 slot), key 是 KEYS 数组, arg 是 ARGV 数组.

EVALSHA

EVALSHA sha1 numkeys key [key ...] arg [arg ...]

执行已缓存的脚本(通过 SHA1 摘要引用). 脚本已在服务端时省去传输开销.

# 最简单的 Lua 脚本: 返回 "hello"
EVAL "return 'hello'" 0
→ "hello"

# 带参数: 返回第 2 个 ARGV 参数
EVAL "return ARGV[2]" 0 arg1 arg2 arg3
→ "arg2"

# 操作 key: 对指定 key 做 INCR
EVAL "return redis.call('INCR', KEYS[1])" 1 counter:test
→ (integer) 1

Lua 数组从 1 开始(不是 0), 且 KEYS 和 ARGV 是两个独立的数组.

2.3 KEYS[] 与 ARGV[] -- 为什么必须区分?

很多初学者的第一个 Lua 脚本是把所有参数都塞进 ARGV:

-- ❌ 错误示范: key 当 arg 传
EVAL "return redis.call('GET', ARGV[1])" 0 mykey
-- 能跑, 但集群模式下会出问题

在 Redis Cluster 中, 每个 key 根据 hash slot 分布在不同的节点上. 如果脚本操作的 key 没有通过 KEYS[] 正确声明, Redis 就不知道应该把脚本路由到哪个节点, 也不知道脚本是否合法(集群要求一个脚本操作的所有 key 必须在同一个 slot).

规则

脚本中所有会被读写的 Redis key 都必须通过 KEYS[] 数组传入. 运行时参数(阈值, TTL, token 等)通过 ARGV[] 传入. numkeys 必须准确填写.

# ✅ 正确示范: key 和 arg 分开
EVAL "return redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])" 1 mykey "hello" 60

# KEYS[1] = mykey (key, 要被集群路由)
# ARGV[1] = hello (value, 运行参数)
# ARGV[2] = 60 (过期秒数, 运行参数)

2.4 Hash Tag: 让多个 key 落到同一个 Slot

在 Redis Cluster 中, key 通过 CRC16(key) % 16384 计算出 hash slot, 数据分布在不同节点上. Lua 脚本的硬性要求是: 脚本操作的所有 key 必须在同一个 slot, 否则执行时报错:

EVAL "return redis.call('MGET', KEYS[1], KEYS[2])" 2 user:1:name user:1:age
→ (error) CROSSSLOT Keys in request don't hash to the same slot

user:1:name 和 user:1:age 虽然是同一个用户的数据, 但它们的 key 字符串不同, hash 后大概率落在不同的 slot--这就是跨 slot 错误.

Hash Tag 的语法

Redis Cluster 提供了一种特殊规则: 如果 key 中包含 {...}(一对花括号), 那么只有花括号内部的内容参与 hash 计算, 花括号外部的部分被忽略.

-- 没有 Hash Tag: 整个 key 参与 hash
CRC16("user:1:name") % 16384 → slot A
CRC16("user:1:age") % 16384 → slot B ← 不同 slot!

-- 有 Hash Tag: 只 hash 花括号内的部分
CRC16("{user:1}:name") % 16384 → slot X
CRC16("{user:1}:age") % 16384 → slot X ← 相同 slot! 因为都只 hash "user:1"

Hash Tag = 快递分拣中的"按邮政编码分配仓库" -- 你可以在包裹上贴个性化贴纸(花括号外的部分随意), 但仓库只看邮政编码(花括号内的部分). 只要两个包裹的邮政编码一样, 它们就一定被送到同一个仓库, 不管贴纸有多不同.

在 Lua 脚本中使用 Hash Tag

沿用上面的例子, 加上 Hash Tag 后跨 slot 问题就解决了:

# ✅ 用 Hash Tag 把同一用户的 key 绑定到同一 slot
EVAL "return redis.call('MGET', KEYS[1], KEYS[2])" 2 "{user:1}:name" "{user:1}:age"
→ 1) "张三"
→ 2) "25"

实际项目中的 key 命名惯例:

# 方案 A: 把业务主体放在花括号内
{user:1001}:profile
{user:1001}:followers
{user:1001}:settings
→ 所有 user:1001 的数据落在同一 slot

# 方案 B: 用固定前缀做 Tag(按业务域聚合)
{shop}:orders:2024-06-01
{shop}:products:featured
{shop}:inventory:sku_1001
→ 所有 shop 域的数据落在同一 slot(可能热点)

什么时候可以不用 Hash Tag?

并不是所有多 key 操作都必须在同一 slot. 以下情况自然兼容:

  1. 单实例 / 主从模式: 没有 Cluster 的分片概念, 所有 key 都在同一个节点上, Hash Tag 完全不需要.
  2. 脚本只操作一个 key: 单个 key 总在同一 slot, 天然满足要求.
  3. 多 key 但恰好 hash 到了同一 slot: 概率极小但理论上可能存在--不能依赖这一点.

Hash Tag 的代价与注意事项

问题 说明 对策
数据倾斜(热点) 如果 Tag 粒度过粗(如整个业务共用一个 Tag), 所有数据挤在一个 slot → 一个节点, 失去集群分片的意义 Tag 粒度控制在"自然业务单元"级别(如单个用户, 单个订单), 而非全局
Tag 不可嵌套 如果 key 中有多对 {}, 只有第一对完整的花括号生效. 如 {a}{b}:key 的 Tag 是 a, 后面的 {b} 被当做普通字符 确保 key 中最多一对有意义的 {}
空花括号无效 {} 不构成 Hash Tag, 整个 key 照常 hash 花括号内必须有内容
可读性 key 中出现花括号让命名变得不直观 团队约定统一命名规范, 如 {entity}:{type}:{detail}

Hash Tag 设计原则

  1. Tag 的内容应该是需要原子操作的业务关联键--最常见的例子是用户 ID: {user:1001}:* 系列 key
  2. 不要让 Tag 过粗(全局一根筋)或过细(每个 key 独有的 Tag = 没加一样)
  3. 在开发单机 Redis 时就养成正确使用 KEYS[] 的习惯, 迁移到集群时只需在 key 命名上加 {} 即可, Lua 脚本逻辑不动

2.5 redis.call vs redis.pcall

两者都在 Lua 中执行 Redis 命令, 区别在于错误处理:

redis.call redis.pcall
命令执行出错时 脚本立即终止, 错误向上抛出 捕获错误, 返回一个 error 对象
适用 错误不可接受, 希望脚本失败 需要自己判断错误并做分支处理
典型用法 redis.call('INCR', KEYS[1]) local ok, err = redis.pcall('SET', ...)
-- redis.call: 错误即终止
local val = redis.call("INCR", KEYS[1]) -- 如果 KEYS[1] 是 String 类型, 脚本报错退出

-- redis.pcall: 错误可捕获
local result = redis.pcall("INCR", KEYS[1])
if type(result) == "table" and result.err then
return "ERR: " .. result.err
end
return result

2.6 实战脚本示例

示例 1: 原子扣库存(解决超卖)

-- KEYS[1]: 库存 key
-- ARGV[1]: 扣减数量
-- 返回: 剩余库存(负值表示库存不足)

local stock = redis.call("GET", KEYS[1])
if not stock then
return -1
end
stock = tonumber(stock)
local deduct = tonumber(ARGV[1])
if stock < deduct then
return -1
end
redis.call("DECRBY", KEYS[1], deduct)
return stock - deduct
# 执行
SET stock:item_1001 10
EVAL "..." 1 stock:item_1001 3
→ (integer) 7 ← 剩余 7 件

示例 2: 安全释放分布式锁

-- KEYS[1]: 锁 key
-- ARGV[1]: 客户端 token

if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end

示例 3: 滑动窗口限流(05 篇的精简版)

-- KEYS[1]: ZSet key
-- ARGV[1]: 窗口大小(毫秒)
-- ARGV[2]: 限额
-- ARGV[3]: 当前时间戳(毫秒)
-- ARGV[4]: 唯一标识(时间戳 + 随机数)

local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local member = ARGV[4]

redis.call("ZREMRANGEBYSCORE", KEYS[1], 0, now - window)
local count = redis.call("ZCARD", KEYS[1])

if count >= limit then
return 0 -- 限流
end

redis.call("ZADD", KEYS[1], now, member)
redis.call("EXPIRE", KEYS[1], math.ceil(window / 1000) + 1)
return 1 -- 放行

示例 4: CAS 更新("版本号匹配才写")

-- KEYS[1]: 数据 key
-- ARGV[1]: 期望的版本号
-- ARGV[2]: 新值
-- 返回: 1=更新成功, 0=版本冲突

local data = redis.call("GET", KEYS[1])
if not data then
redis.call("SET", KEYS[1], ARGV[2])
return 1
end

-- 假设 value 格式: "version:payload"(如 "3:hello world")
local parts = {}
local i = 0
for part in string.gmatch(data, "([^:]+)") do
i = i + 1
parts[i] = part
end

local current_version = tonumber(parts[1])
local expected = tonumber(ARGV[1])

if current_version == expected then
local new_value = (current_version + 1) .. ":" .. ARGV[2]
redis.call("SET", KEYS[1], new_value)
return 1
else
return 0
end

2.7 脚本缓存机制

每次 EVAL 都把完整脚本传到服务端, 脚本越长带宽浪费越大. Redis 提供了脚本缓存机制:

命令 作用
SCRIPT LOAD script 将脚本加载到服务端缓存, 返回 SHA1 摘要
EVALSHA sha1 ... 用 SHA1 执行已缓存的脚本
SCRIPT EXISTS sha1 [sha1 ...] 检查脚本是否已在缓存中
`SCRIPT FLUSH [ASYNC SYNC]`
SCRIPT KILL 终止当前正在执行的脚本(只对未执行写操作的脚本有效)
# 经典用法: 先尝试 EVALSHA, 失败则 EVAL 并缓存
SCRIPT LOAD "return redis.call('INCR', KEYS[1])"
→ "e0b0f5a4b4e3c7c3a3a3b3c3d3e3f3a3b3c3d3e3" ← SHA1 摘要

EVALSHA e0b0f5a4b4e3c7c3a3a3b3c3d3e3f3a3b3c3d3e3 1 counter:test
→ (integer) 1

大多数 Redis 客户端(Jedis, Lettuce, go-redis 等)已封装了 EVALSHA + fallback EVAL 逻辑: 先尝试 EVALSHA, 收到 NOSCRIPT 错误时自动 EVAL 并缓存.

3. 事务 vs Lua 选型对比

维度 事务(MULTI/EXEC) Lua 脚本
原子性 隔离性(不被打断), 无回滚 原子性(脚本要么全执行要么全不执行)
逻辑能力 不能做 if/else, 循环, 变量传递 完整的 Lua 语言能力
网络往返 2 次(MULTI + EXEC, 命令批量发送) 1 次(EVAL/EVALSHA)
学习成本 低 中(需要学 Lua 基础语法 + Redis Lua API)
调试难度 低 较高(服务端执行, 无法断点)
脚本复用 无 SCRIPT LOAD 缓存, SHA1 复用
集群兼容 自然兼容(每条命令独立路由) 需所有 key 在同一 hash slot(或用 hash tag)
适合场景 简单批处理, 不需要判断逻辑 分布式锁, 库存扣减, 限流, CAS 等需要逻辑判断的场景

选型原则

  1. 如果只是想"一次性写入/读取多个 key 不被插队" → 事务就够
  2. 如果需要"先看值再决定怎么写" → 必须用 Lua
  3. 现代 Redis 客户端大多推荐优先使用 Lua--它不仅是事务的超集, 而且更高效(减少往返)

4. 常见陷阱

陷阱 后果 对策
脚本运行时间过长 单线程模型下, 脚本执行期间所有其他客户端被阻塞. 如果脚本跑了 1 秒, 这 1 秒内 Redis 对外完全无响应. 脚本逻辑尽量简单, 避免 O(N) 全量扫描; 用 SCRIPT KILL 终止未写入的脚本; 大数据量操作拆分成多次小批次
集群模式下 key 分布在不同节点 Lua 脚本操作的所有 key 必须在同一个 slot 上, 否则执行时报错 CROSSSLOT 用 Hash Tag(如 {user1}:name 和 {user1}:age 路由到同一 slot); 单节点/主从模式下无此限制
脚本中的随机性 / 不确定性 脚本中用 math.random / os.time 会导致主从复制不一致(主节点和从节点执行结果不同) Redis 7.0+ 支持确定性 API; 需要随机值时通过 ARGV 传入(由客户端生成); redis.replicate_commands() 开启命令级复制(Redis 3.2+)
KEYS 和 ARGV 不分 集群模式下脚本路由到错误的节点, 或在主从切换后数据不一致 所有要读写的 key 必须放 KEYS[], 运行时参数放 ARGV[]
脚本中写日志/debug 困难 Lua 在服务端执行, print() 输出到 Redis 日志而非客户端 用 redis.log(loglevel, message) 输出到 Redis 日志; 或把中间结果拼到返回值里带回客户端
Lua 类型转换 Lua 的 number 是 double, Redis 返回的整数可能丢失精度(超过 2^53 的大数) 大数值用 string 传递, Lua 中用 tonumber() 时注意范围

最重要的原则

Lua 脚本在 Redis 中是独占执行的. 一个 100 毫秒的脚本在 QPS 10000 的场景下意味着每秒有 10 个"时间窗口"被阻塞, 每个窗口期间积压 1000 个请求.

设计脚本时时刻记住: 让它够快, 快到对并发用户来说只是多等了一个指令周期.

快速回顾

模块 核心命令/概念 记忆要点
事务 MULTI → 命令入队 → EXEC 保证隔离性(不被打断), 无回滚
WATCH key 乐观锁: key 被改则 EXEC 返回 nil
DISCARD 放弃事务, 清空命令队列
Lua EVAL script numkeys key... arg... 原子执行整个脚本
EVALSHA sha1 ... 用 SHA1 执行已缓存脚本, 省带宽
KEYS[] vs ARGV[] 操作 key 必须走 KEYS(集群路由 + 确定性), 运行时参数走 ARGV
redis.call vs pcall call 错误即停, pcall 可捕获
选型 事务够用的场景 简单批处理, 不需要 if/else 判断
Lua 更合适的场景 需要判断, 计算, 条件执行的任何原子操作
集群注意 Lua 脚本所有 key 必须在同一 slot(用 hash tag 解决)

动手练习

  1. 事务基础: 用 MULTI/EXEC 对两个 key 分别 SET 不同值, 在 MULTI 之后 EXEC 之前用另一个 redis-cli 执行 GET, 验证其他客户端在事务期间是否被阻塞.
  2. WATCH 乐观锁: 用 WATCH 保护一个计数器 key, 在 WATCH→MULTI→EXEC 之间用另一个客户端修改它, 验证 EXEC 返回 nil.
  3. 安全释放锁: 写一个 Lua 脚本实现"比对 token → DEL"的分布式锁释放, 用 EVAL 传入不同的 token 验证: token 匹配时删除成功, token 不匹配时返回 0.
  4. 原子扣库存: 写 Lua 脚本实现库存扣减(含库存不足检查), 连续扣减直到返回 -1, 验证不会超卖.
  5. 滑动窗口限流: 用 Lua 脚本实现 ZSet 滑动窗口限流(见 2.6 示例 3), 模拟正常请求和超限请求.
  6. 脚本缓存: 用 SCRIPT LOAD 加载一个脚本, 用 EVALSHA 执行, 再 SCRIPT FLUSH 后验证 EVALSHA 报错.
  7. 思考题: Redis 事务没有回滚机制, 假设在做一个"转账"功能, 怎样用 Lua 脚本来保证 A 扣了 B 没加的情况一定不会发生? 这与传统数据库的 BEGIN→COMMIT→ROLLBACK 在思路上的根本区别是什么?