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

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:nameuser: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 在思路上的根本区别是什么?