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

Lua 脚本与事务

用 Redis 原生的原子能力,把多条命令编织成不可分割的操作单元

Redis 学习笔记 · 第 06 篇 · 大纲 · 前置:第 05 篇

总览:原子性的两条路

前五篇积累了大量单条命令的用法。但真实场景中,一个业务操作往往需要多条命令配合完成——分布式锁要先 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

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

EXEC

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

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 没加"不会发生

事务的关键特性:

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

监控指定 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 的限制:① 只能在 MULTI 之前使用,不能在事务内部 WATCH;② 它监控的是整个 key 的值变化,不是某个 field 的变化;③ 高并发下重试次数可能很高,此时更适合用 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 设计原则

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

2.5 redis.call vs redis.pcall

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

redis.callredis.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              -- key 不存在
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 等需要逻辑判断的场景

选型原则

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

4. 常见陷阱

陷阱后果对策
脚本运行时间过长 单线程模型下,脚本执行期间所有其他客户端被阻塞。如果脚本跑了 1 秒,这 1 秒内 Redis 对外完全无响应。 ① 脚本逻辑尽量简单,避免 O(N) 全量扫描 ② 用 SCRIPT KILL 终止未写入的脚本 ③ 大数据量操作拆分成多次小批次
集群模式下 key 分布在不同节点 Lua 脚本操作的所有 key 必须在同一个 slot 上,否则执行时报错 CROSSSLOT Keys in request don't hash to the same slot ① 用 Hash Tag(如 {user1}:name{user1}:age 路由到同一 slot)② 单节点/主从模式下无此限制
脚本中的随机性 / 不确定性 脚本中用 math.random / os.time 会导致主从复制不一致(主节点和从节点执行结果不同) ① Redis 7.0+ 支持 redis.setresp 等确定性 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 的场景下意味着每秒有 1000ms / 100ms = 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,验证其他客户端在事务期间被阻塞了吗(提示:用 DEBUG SLEEP 2 阻塞 EXEC 测试)?
  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.5 示例 3),模拟正常请求和超限请求。
  6. 脚本缓存:用 SCRIPT LOAD 加载一个脚本,用 EVALSHA 执行,再 SCRIPT FLUSH 后验证 EVALSHA 报错。
  7. (思考题)Redis 事务没有回滚机制,假设你在做一个"转账"功能,怎样用 Lua 脚本来保证 A 扣了 B 没加的情况一定不会发生?这与传统数据库的 BEGIN→COMMIT→ROLLBACK 在思路上的根本区别是什么?