用 Redis 原生的原子能力,把多条命令编织成不可分割的操作单元
前五篇积累了大量单条命令的用法。但真实场景中,一个业务操作往往需要多条命令配合完成——分布式锁要先 GET 再 DEL(05 篇的释放锁)、扣库存要先 GET 再 SET、限流要先 INCR 再判断。如果这些命令之间被其他客户端的命令插入,就会出现竞态条件(Race Condition)。
Redis 提供了两条路来保证多命令的原子执行:
| 方式 | 核心机制 | 能做什么 | 不能做什么 |
|---|---|---|---|
| 事务(MULTI/EXEC) | 命令入队,EXEC 时一次性串行执行 | 保证"不被打断" | 没有回滚,不能在命令间做逻辑判断 |
| Lua 脚本 | 整个脚本作为一个原子操作执行 | 原子性 + 逻辑判断(if/else)+ 计算 + 减少网络往返 | 不能执行非确定性操作(如随机写),不能长时间运行 |
事务的三个关键命令:
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 没加"不会发生
事务的关键特性:
这可能是从关系数据库(MySQL/Postgres)转过来的开发者最大的困惑。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 脚本在应用层控制。
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 的限制:① 只能在 MULTI 之前使用,不能在事务内部 WATCH;② 它监控的是整个 key 的值变化,不是某个 field 的变化;③ 高并发下重试次数可能很高,此时更适合用 Lua 脚本。
| 场景 | 事务合适吗? | 原因 |
|---|---|---|
| 多条命令需要不被打断 | ✅ | MULTI/EXEC 的核心能力 |
| 需要"全成功或全失败" | ❌ | Redis 事务无回滚,中途失败后面继续执行 |
| 需要根据上一条命令的结果做判断 | ❌ | 事务中命令先入队再执行,无法在命令间传递变量 |
| 高并发 CAS(如抢库存) | 勉强 | WATCH 在高竞争下重试率高,Lua 脚本更优 |
| 需要循环、计算、复杂逻辑 | ❌ | Lua 脚本 |
Redis 选择嵌入 Lua 解释器,解决了一个核心矛盾:原子性 vs 灵活性。事务能保证原子性但太僵硬(不能做判断),而一次发多条命令又无法保证原子性。Lua 脚本在 Redis 服务端执行,把"逻辑判断 + 数据操作"打包成一个原子单元。
| 优势 | 说明 | 对比 |
|---|---|---|
| 原子性 | 整个脚本作为一个原子操作,执行期间其他命令必须等待 | vs 分多次命令发送 = 无原子性 |
| 减少网络往返 | N 条命令 = 1 次网络请求 | vs 事务 = 2 次(MULTI + 命令一起发,EXEC) |
| 服务端逻辑 | 可以在服务端做 if/else、计算、返回值处理 | vs 事务中命令间不能传递变量 |
| 脚本复用 | SCRIPT LOAD 缓存后,用 SHA1 引用,省带宽 | vs 事务每次都传完整命令 |
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 是两个独立的数组。
很多初学者的第一个 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 (过期秒数,运行参数)
在 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 错误。
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 后跨 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(⚠️ 可能热点)
并不是所有多 key 操作都必须在同一 slot。以下情况自然兼容:
| 问题 | 说明 | 对策 |
|---|---|---|
| 数据倾斜(热点) | 如果 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 脚本逻辑不动。
两者都在 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
-- 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 件
-- 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
-- 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 -- 放行
-- 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
每次 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 并缓存。
| 维度 | 事务(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 —— 它不仅是事务的超集,而且更高效(减少往返)。
| 陷阱 | 后果 | 对策 |
|---|---|---|
| 脚本运行时间过长 | 单线程模型下,脚本执行期间所有其他客户端被阻塞。如果脚本跑了 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 解决) |
DEBUG SLEEP 2 阻塞 EXEC 测试)?