总览: 原子性的两条路
前五篇积累了大量单条命令的用法. 但真实场景中, 一个业务操作往往需要多条命令配合完成--分布式锁要先 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 之前使用.
|
事务的关键特性:
- 串行执行: EXEC 时命令一次性顺序执行, 不会被打断
- 没有回滚: 即使中间某条命令执行失败, 后面的命令继续执行. 前面的命令不会撤销.
- 入队时只做语法检查: MULTI 期间命令入队时只检查语法错误(参数数量不对等), 执行期错误(如对 String 做 LPUSH)不影响其他命令
1.2 为什么 Redis 事务没有回滚?
这可能是从关系数据库(MySQL/Postgres)转过来的开发者最大的困惑. Redis 官方对此的解释是:
- Redis 命令的设计哲学: 命令本身设计得足够简单, 语法错误应该在开发阶段就被发现, 不应该到生产环境才暴露.
- 性能优先: 回滚机制需要维护 undo log 等额外结构, 与 Redis 的极简高性能定位冲突.
- 实际收益低: Redis 命令的执行期错误大多是编程错误(如类型不匹配), 这类错误无法通过回滚修复--需要的是修代码, 而不是撤销操作.
|
关键结论
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 取消所有监控.
|
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 数组从 1 开始(不是 0), 且 KEYS 和 ARGV 是两个独立的数组.
2.3 KEYS[] 与 ARGV[] -- 为什么必须区分?
很多初学者的第一个 Lua 脚本是把所有参数都塞进 ARGV:
|
在 Redis Cluster 中, 每个 key 根据 hash slot 分布在不同的节点上. 如果脚本操作的 key 没有通过 KEYS[] 正确声明, Redis 就不知道应该把脚本路由到哪个节点, 也不知道脚本是否合法(集群要求一个脚本操作的所有 key 必须在同一个 slot).
规则
脚本中所有会被读写的 Redis key 都必须通过 KEYS[] 数组传入. 运行时参数(阈值, TTL, token 等)通过 ARGV[] 传入. numkeys 必须准确填写.
|
2.4 Hash Tag: 让多个 key 落到同一个 Slot
在 Redis Cluster 中, key 通过 CRC16(key) % 16384 计算出 hash slot, 数据分布在不同节点上. Lua 脚本的硬性要求是: 脚本操作的所有 key 必须在同一个 slot, 否则执行时报错:
|
user:1:name 和 user:1:age 虽然是同一个用户的数据, 但它们的 key 字符串不同, hash 后大概率落在不同的 slot--这就是跨 slot 错误.
Hash Tag 的语法
Redis Cluster 提供了一种特殊规则: 如果 key 中包含 {...}(一对花括号), 那么只有花括号内部的内容参与 hash 计算, 花括号外部的部分被忽略.
|
Hash Tag = 快递分拣中的"按邮政编码分配仓库" -- 你可以在包裹上贴个性化贴纸(花括号外的部分随意), 但仓库只看邮政编码(花括号内的部分). 只要两个包裹的邮政编码一样, 它们就一定被送到同一个仓库, 不管贴纸有多不同.
在 Lua 脚本中使用 Hash Tag
沿用上面的例子, 加上 Hash Tag 后跨 slot 问题就解决了:
|
实际项目中的 key 命名惯例:
|
什么时候可以不用 Hash Tag?
并不是所有多 key 操作都必须在同一 slot. 以下情况自然兼容:
- 单实例 / 主从模式: 没有 Cluster 的分片概念, 所有 key 都在同一个节点上, Hash Tag 完全不需要.
- 脚本只操作一个 key: 单个 key 总在同一 slot, 天然满足要求.
- 多 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.call | redis.pcall | |
|---|---|---|
| 命令执行出错时 | 脚本立即终止, 错误向上抛出 | 捕获错误, 返回一个 error 对象 |
| 适用 | 错误不可接受, 希望脚本失败 | 需要自己判断错误并做分支处理 |
| 典型用法 | redis.call('INCR', KEYS[1]) |
local ok, err = redis.pcall('SET', ...) |
|
2.6 实战脚本示例
示例 1: 原子扣库存(解决超卖)
|
|
示例 2: 安全释放分布式锁
|
示例 3: 滑动窗口限流(05 篇的精简版)
|
示例 4: CAS 更新("版本号匹配才写")
|
2.7 脚本缓存机制
每次 EVAL 都把完整脚本传到服务端, 脚本越长带宽浪费越大. Redis 提供了脚本缓存机制:
| 命令 | 作用 |
|---|---|
SCRIPT LOAD script |
将脚本加载到服务端缓存, 返回 SHA1 摘要 |
EVALSHA sha1 ... |
用 SHA1 执行已缓存的脚本 |
SCRIPT EXISTS sha1 [sha1 ...] |
检查脚本是否已在缓存中 |
| `SCRIPT FLUSH [ASYNC | SYNC]` |
SCRIPT KILL |
终止当前正在执行的脚本(只对未执行写操作的脚本有效) |
|
大多数 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 |
用 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 解决) |
动手练习
- 事务基础: 用 MULTI/EXEC 对两个 key 分别 SET 不同值, 在 MULTI 之后 EXEC 之前用另一个 redis-cli 执行 GET, 验证其他客户端在事务期间是否被阻塞.
- WATCH 乐观锁: 用 WATCH 保护一个计数器 key, 在 WATCH→MULTI→EXEC 之间用另一个客户端修改它, 验证 EXEC 返回 nil.
- 安全释放锁: 写一个 Lua 脚本实现"比对 token → DEL"的分布式锁释放, 用 EVAL 传入不同的 token 验证: token 匹配时删除成功, token 不匹配时返回 0.
- 原子扣库存: 写 Lua 脚本实现库存扣减(含库存不足检查), 连续扣减直到返回 -1, 验证不会超卖.
- 滑动窗口限流: 用 Lua 脚本实现 ZSet 滑动窗口限流(见 2.6 示例 3), 模拟正常请求和超限请求.
- 脚本缓存: 用 SCRIPT LOAD 加载一个脚本, 用 EVALSHA 执行, 再 SCRIPT FLUSH 后验证 EVALSHA 报错.
- 思考题: Redis 事务没有回滚机制, 假设在做一个"转账"功能, 怎样用 Lua 脚本来保证 A 扣了 B 没加的情况一定不会发生? 这与传统数据库的 BEGIN→COMMIT→ROLLBACK 在思路上的根本区别是什么?