一文搞懂 Redis 事务与 Lua 脚本:把它想成一家超市收银台
文章目录
- 一、先把超市和 Redis 对上号
- 二、没有事务时,问题到底出在哪里
- 三、MULTI 与 EXEC:先把商品全部扫码,再统一结账
- 3.1 最基础的事务
- 3.2 DISCARD:顾客说“这单不要了”
- 3.3 为什么事务里拿不到上一条命令的结果
- 四、Redis 事务不会自动回滚
- 4.1 入队阶段就发现错误:整个事务拒绝执行
- 4.2 执行阶段才出现错误:其他命令照常执行
- 五、WATCH:结账前再看一眼商品价签
- 5.1 WATCH 是乐观锁,不是把 Key 锁住
- 5.2 正确的重试轮廓
- 5.3 WATCH 的代价
- 六、Pipeline 和事务不是一回事
- 七、Lua:把结账规则直接交给 Redis 收银员
- 7.1 一个完整的原子结账脚本
- 7.2 Lua 为什么能解决竞态
- 7.3 redis.call 与 redis.pcall
- 八、KEYS 与 ARGV:商品编号别偷偷藏在操作手册里
- 九、EVAL、SCRIPT LOAD 与 EVALSHA
- 十、Lua 最大的风险:一位收银员把整家店堵住
- 十一、事务、WATCH、Lua 和普通命令怎么选
- 场景一:Redis 已经有一条原子命令
- 场景二:多条命令只需要连续执行,不依赖中间结果
- 场景三:需要先读取,在客户端计算,冲突概率较低
- 场景四:需要读取、判断并修改多个 Key,追求一次原子完成
- 场景五:只是想减少网络往返
- 十二、完整 redis-cli 实验
- 十三、放进真实系统前,还要补上六块拼图
- 13.1 原子性不能代替幂等性
- 13.2 客户端超时不等于服务端没有执行
- 13.3 Lua 只保证 Redis 内部原子,管不到 MySQL 和消息队列
- 13.4 脚本权限要跟着账号走
- 13.5 故障转移后要准备重新加载脚本
- 13.6 把冲突率和脚本耗时变成可观察指标
- 13.7 事务与分布式锁不要互相冒充
- 十四、十个高频误区
- 误区 1:Redis 事务和 MySQL 事务一样
- 误区 2:单线程就没有竞态条件
- 误区 3:MULTI 后命令已经执行
- 误区 4:事务中可以读取上一条结果再写下一条
- 误区 5:WATCH 会锁住 Key
- 误区 6:EXEC 失败应该无限重试
- 误区 7:Lua 报错会撤销之前的写入
- 误区 8:Lua 越大越能减少网络请求
- 误区 9:EVALSHA 永远不会失败
- 误区 10:Cluster 中 Lua 可以随便操作多个 Key
- 十五、总结
- 参考资料
周末晚上,超市收银台排起了长队。小林的购物篮里有牛奶、面包和一张满减券。收银员必须依次完成核对库存、扣减库存、使用优惠券、增加会员积分和生成小票。
要是处理到一半,另一名收银员突然插进来改库存;或者牛奶扣掉了,小票却没生成,这笔账就会变得很难看。
这正是很多 Redis 业务会遇到的问题:我们不是只想执行一条命令,而是希望一组操作按照约定的顺序完成,并且中间不要被其他客户端插队。
Redis 为此准备了两套常用工具:
MULTI / EXEC / WATCH,像把几项收银操作装进一个待执行清单;- Lua 脚本,像把完整结账规则交给一位熟练收银员,让判断和修改在 Redis 服务器里一次完成。
不过,Redis 事务和 MySQL 事务并不是同一种东西。Redis 没有传统意义上的自动回滚;Lua 虽然原子,却会阻塞服务器处理其他活动。工具用对了是收银台,用错了就可能变成堵住整个超市出口的购物车。
本文基于 Redis 8.6.1 实验,把它们的语义、差异和高频陷阱一次讲清。
一、先把超市和 Redis 对上号
| 超市里的角色 | Redis 概念 | 负责什么 |
|---|---|---|
| 收银台 | Redis 服务端 | 按顺序处理客户端命令 |
| 顾客 | Redis 客户端连接 | 提交命令或事务 |
| 待结账清单 | MULTI后的命令队列 | 暂存准备执行的命令 |
| “开始结账”按钮 | EXEC | 连续执行队列中的命令 |
| 取消本单 | DISCARD | 清空队列并退出事务 |
| 盯住商品价签 | WATCH | 检查关键数据是否被别人改过 |
| 收银操作手册 | Lua 脚本 | 在服务端完成判断与修改 |
先记住全文最重要的区别:
MULTI / EXEC擅长“把已有命令连续执行”;Lua 擅长“读取以后做判断,再决定执行哪些命令”。
如果只是同时给积分和订单数加一,事务清单就够用;如果要先检查库存是否充足,再扣库存并生成订单,Lua 往往更自然。
二、没有事务时,问题到底出在哪里
假设牛奶库存为 1,两名顾客几乎同时购买。应用采用最直白的三步:
GET stock:milk 判断库存大于 0 DECR stock:milk可能出现这样的时序:
客户端 A:GET -> 1 客户端 B:GET -> 1 客户端 A:DECR -> 0 客户端 B:DECR -> -1每一条 Redis 命令自身都是原子执行的,但“三条命令组成的业务逻辑”不是原子的。就像每次扫码都不会扫半件商品,却不代表两位收银员读取到的库存一定一致。
不少人会说:“Redis 不是单线程执行命令吗,怎么还会有并发问题?”
单线程保证的是:同一时刻不会把两条命令各执行一半。可客户端 A 的GET和DECR之间,完全可能插入客户端 B 的命令:
A.GET -> B.GET -> A.DECR -> B.DECR因此需要原子性的是业务动作,而不只是单条命令。
三、MULTI 与 EXEC:先把商品全部扫码,再统一结账
3.1 最基础的事务
MULTI SET checkout:receipt:1001 PAID INCR member:1001:points EXEC交互结果类似:
OK QUEUED QUEUED 1) OK 2) (integer) 1执行MULTI后,后续命令通常不会立即操作数据,而是返回QUEUED,表示已经放进当前连接的事务队列。直到EXEC到达,Redis 才依次执行它们,并按命令入队顺序返回结果数组。
这套机制有两个重要保证:
EXEC执行队列期间,其他客户端的命令不会插入事务中间;- 如果客户端在发送
EXEC以前断开,队列里的命令不会执行。
注意,事务属于当前连接。不能在连接 A 上MULTI,然后换连接 B 去EXEC。使用连接池时,必须确保整个过程绑定同一条连接。
3.2 DISCARD:顾客说“这单不要了”
MULTI SET checkout:receipt:1002 PAID INCR member:1002:points DISCARDDISCARD会清空已入队的命令并退出事务,两个写操作都不会发生。它不是“执行失败后的回滚”,而是在 EXEC 之前主动放弃队列。
3.3 为什么事务里拿不到上一条命令的结果
下面这个想法看起来合理:
MULTI GET stock:milk 根据 GET 结果决定是否 DECR EXEC问题在于GET只会返回QUEUED,真实库存要等EXEC时才读取。应用在组装队列时拿不到这个结果,自然无法根据它决定下一条命令。
MULTI / EXEC不是存储过程,也不是一段带if的程序。需要“先读、判断、再写”时,可以用WATCH做乐观锁,或者直接用 Lua 把判断搬到服务端。
四、Redis 事务不会自动回滚
这是 Redis 事务和数据库事务最容易混淆的地方。
4.1 入队阶段就发现错误:整个事务拒绝执行
比如命令参数数量不对:
MULTI SET only-key INCR checkout:count EXECSET only-key在排队时就能发现语法或参数错误。此时事务会被标记为无效,EXEC返回EXECABORT,队列中的命令都不执行。
这像收银员在扫码阶段就发现“这张操作单连商品编号都没写”,于是整单不结。
4.2 执行阶段才出现错误:其他命令照常执行
SET checkout:points hello MULTI SET checkout:receipt PAID INCR checkout:points SET checkout:noticedoneEXECINCR只有真正执行时才知道 Value 不是整数。它会返回错误,但前后的SET仍然执行。Redis 不会把已经成功的命令撤销。
就像小票打印到第二行时发现会员积分格式坏了:Redis 会报告这一项失败,却不会倒带撕掉前面已经打印的内容。
Redis 官方明确说明事务不支持回滚。这样做让实现更简单、执行更快,也要求开发者:
- 提前校验输入;
- 让事务中的命令类型和 Key 结构保持可预测;
- 不要把“部分命令可能运行时失败”的流程当成数据库 ACID 事务。
五、WATCH:结账前再看一眼商品价签
5.1 WATCH 是乐观锁,不是把 Key 锁住
库存需要先读取再计算时,可以这样做:
WATCH stock:milk GET stock:milk # 客户端计算新库存 MULTI SET stock:milk 8 EXEC从WATCH到EXEC之间,如果被监视的 Key 发生修改,EXEC会返回空结果,整组事务不执行。应用需要重新读取、重新计算并重试。
它像收银员先记下价签是 10 元,准备结账时发现另一位员工把价签换成 12 元,于是放弃本次结算,重新核价。
WATCH并不会阻止别人修改 Key,所以它是乐观锁:先假设冲突不常发生,真冲突了再重试。
5.2 正确的重试轮廓
for 有限次数: WATCH key 读取数据并校验 如果业务条件不成立: UNWATCH 返回业务失败 MULTI 写入新值 result = EXEC 如果 result 成功: 返回成功 随机退避后重试需要注意:
EXEC成功、失败或连接断开后,监视状态都会结束;- 不继续事务时应执行
UNWATCH,让连接尽快回归普通状态; - 重试必须有上限和退避,热点 Key 冲突严重时无限自旋只会加重拥堵;
- Key 的过期和淘汰也可能被视为修改,从而让事务放弃;
- Redis 6.0.9 之前,过期 Key 对
WATCH的行为有历史差异。
Redis 8.4 起,字符串SET增加了IFEQ、IFNE等比较后写入选项,简单的字符串 CAS 可以用一条命令完成。但涉及多个数据结构或复杂判断时,WATCH和 Lua 仍然有价值。使用新选项前要确认生产版本和客户端支持情况。
5.3 WATCH 的代价
低冲突时,乐观锁很轻巧;高冲突时,大量客户端会反复经历“读取—计算—EXEC 失败—重试”。如果所有人都在抢最后一盒牛奶,Lua 通常比让一百名收银员反复核价更合适。
六、Pipeline 和事务不是一回事
Pipeline 经常和事务一起被提到,因为它们都能一次发送多条命令,但目标完全不同。
| 对比项 | Pipeline | MULTI / EXEC |
|---|---|---|
| 核心目标 | 减少网络往返 | 保证一组命令连续执行 |
| 是否阻止其他客户端插入 | 不保证 | EXEC阶段保证 |
| 是否返回每条命令结果 | 是 | 是 |
| 是否能提高吞吐 | 通常可以 | 不一定 |
| 是否等于业务原子性 | 否 | 只覆盖队列执行阶段 |
Pipeline 像顾客一次把十件商品放上传送带,减少来回递商品的次数;事务像按下“本单连续处理”按钮。很多客户端支持“事务 + Pipeline”,但要清楚自己要解决的是网络往返,还是并发一致性。
七、Lua:把结账规则直接交给 Redis 收银员
7.1 一个完整的原子结账脚本
下面的脚本先检查库存,再扣减库存、生成订单并设置过期时间:
localstock_key=KEYS[1]localorder_key=KEYS[2]localsku=ARGV[1]localquantity=tonumber(ARGV[2])localamount=ARGV[3]ifnotquantityorquantity<=0thenreturnredis.error_reply("INVALID_QUANTITY")endlocalstock=tonumber(redis.call("HGET",stock_key,sku)or"-1")ifstock<0thenreturnredis.error_reply("SKU_NOT_FOUND")endifstock<quantitythenreturn{0,stock}endlocalremaining=redis.call("HINCRBY",stock_key,sku,-quantity)redis.call("HSET",order_key,"sku",sku,"quantity",quantity,"amount",amount,"status","PAID")redis.call("EXPIRE",order_key,3600)return{1,remaining}执行:
redis-cli--evalcheckout.lua\"{store}:stock""{store}:order:1001",\milk218.80逗号左边是KEYS,右边是ARGV。脚本返回:
1) (integer) 1 2) (integer) 3第一个数字表示购买成功,第二个是剩余库存。如果库存不足,脚本在任何写入发生前返回{0, 当前库存},订单不会创建,库存也不会扣减。
7.2 Lua 为什么能解决竞态
Redis 保证脚本原子执行。脚本运行期间,其他客户端不会在脚本调用的多条 Redis 命令之间插入操作。
以前的流程是:
客户端读取 -> 网络返回 -> 客户端判断 -> 网络发送 -> Redis 修改Lua 以后变成:
客户端提交脚本 -> Redis 内部读取、判断、修改 -> 返回结果判断靠近数据,既减少网络往返,又让检查和写入形成一个不可插队的整体。
但“原子”不等于“自动回滚”。如果脚本先完成写操作,后来某条redis.call报错,前面的写入不会自动撤销。因此脚本也应该先完成参数、类型和业务条件校验,再进入写入阶段。
7.3 redis.call 与 redis.pcall
redis.call(...)遇到错误会终止脚本,并把错误返回客户端;redis.pcall(...)把错误作为 Lua 值返回,脚本可以自行判断和处理。
localresult=redis.pcall("INCR",KEYS[1])ifresult.errthenreturn{0,result.err}endreturn{1,result}不要为了“脚本不报错”而到处使用pcall。真正无法恢复的类型错误,直接失败通常更容易暴露数据污染。
八、KEYS 与 ARGV:商品编号别偷偷藏在操作手册里
EVAL的格式是:
EVAL script numkeys key [key ...] arg [arg ...]所有 Redis Key 都应该通过KEYS显式传入,普通参数放进ARGV:
redis.call("HINCRBY",KEYS[1],ARGV[1],-tonumber(ARGV[2]))不要在脚本里动态拼出一个未声明的 Key:
-- 不推荐localkey="order:"..ARGV[1]redis.call("HSET",key,"status","PAID")显式声明 Key 有三个好处:
- Redis 和客户端更容易分析脚本会访问哪些数据;
- 同一份脚本可以通过参数复用,避免生成大量不同脚本;
- Redis Cluster 能检查这些 Key 是否位于兼容的 Hash Slot。
Cluster 中,多 Key 脚本通常要求 Key 在同一个槽。可以使用 Hash Tag:
{store}:stock {store}:order:1001花括号中的store相同,两者会映射到同一个槽。它不是为了让 Key 更好看,而是数据分片设计的一部分。把所有业务 Key 都塞进同一个 Hash Tag 又会制造热点,所以应按业务聚合边界设计,而不是“一把梭”。
九、EVAL、SCRIPT LOAD 与 EVALSHA
每次EVAL都发送完整脚本,简单但会增加网络传输。生产中常用:
SCRIPT LOAD"return redis.call('GET', KEYS[1])"Redis 返回脚本内容的 SHA1 摘要:
4e6d8fc8bb01276962cce5371fa795a7763657ae之后使用:
EVALSHA 4e6d8fc8bb01276962cce5371fa795a7763657ae1stock:milk脚本缓存不是数据库数据,不会可靠持久化。重启、故障转移或SCRIPT FLUSH后,EVALSHA可能返回:
NOSCRIPT No matching script. Please use EVAL.客户端的正确策略是:优先EVALSHA;遇到NOSCRIPT时加载脚本,再重试。许多 Redis 客户端已经封装了这个过程。
千万不要把商品 ID、用户 ID 直接拼进脚本文本,每次生成一份不同脚本。Redis 会按 SHA1 缓存它们,动态脚本可能让脚本缓存不断增长。正确做法是脚本固定,变化值走KEYS和ARGV。
Redis 7 起还提供 Redis Functions:代码以函数库形式加载、命名并由服务器管理,适合希望把可复用逻辑作为 Redis 部署的一部分。EVAL脚本更像客户端携带的临时操作手册;Functions 更像正式登记在超市后台的标准流程。本文以基础场景常见的 Lua Eval 为主。
十、Lua 最大的风险:一位收银员把整家店堵住
Redis 脚本原子执行的另一面,是脚本运行期间会阻塞服务器处理其他活动。下面这些操作非常危险:
- 在 Lua 中遍历海量 Key 或巨大集合;
- 写一个次数不可控的循环;
- 把复杂报表统计塞进脚本;
- 一次操作大量大 Key;
- 在线上临时执行未经压测的脚本。
脚本应该“小、快、边界明确”。复杂度取决于脚本内部调用的命令,EVAL不会把 O(N) 魔法变成 O(1)。
排查时可关注:
SLOWLOG GET10INFO commandstats INFO memory还要监控 Redis 延迟、CPU、阻塞客户端数和脚本执行错误。
SCRIPT KILL也不是万能急停按钮:对于尚未执行写命令的脚本,Redis 可以终止;如果脚本已经写入数据,为避免留下不可理解的半状态,处理方式会更受限制。真正的安全策略是上线前限定输入规模、做基准测试,并设置合理的客户端超时和 Redis Lua 时间限制。
十一、事务、WATCH、Lua 和普通命令怎么选
场景一:Redis 已经有一条原子命令
直接用命令:
INCR counter HINCRBY cart:1001 sku:milk1SET lock:value token NX PX30000不要为了“显得高级”套事务或 Lua。
场景二:多条命令只需要连续执行,不依赖中间结果
使用MULTI / EXEC。例如同时写订单状态、增加积分和记录统计,而且这些命令的类型错误可以提前避免。
场景三:需要先读取,在客户端计算,冲突概率较低
使用WATCH + MULTI / EXEC。要有有限重试和退避,并记录冲突率。
场景四:需要读取、判断并修改多个 Key,追求一次原子完成
使用 Lua。脚本必须短小,Key 边界明确,并考虑 Cluster 同槽。
场景五:只是想减少网络往返
使用 Pipeline。别把吞吐优化误认为事务语义。
十二、完整 redis-cli 实验
实验使用独立端口6395,关闭 RDB 和 AOF,不连接本机常用的6379:
chmod+x run-lab.sh ./run-lab.sh脚本会验证:
MULTI / EXEC的入队与执行结果;DISCARD后 Key 不存在;- 两个客户端竞争时
WATCH事务放弃; - Lua 结账成功后库存正确扣减;
- 库存不足时订单不创建、库存不变化;
SCRIPT LOAD / EVALSHA正常执行;SCRIPT FLUSH后出现NOSCRIPT。
实验中的库存和订单 Key 统一使用{store}Hash Tag;由于临时实例是非 Cluster 模式,脚本不执行槽位命令,Cluster 部署时应在目标集群用CLUSTER KEYSLOT再次核对。
关键输出类似:
== WATCH abort with two clients == OK 10 OK QUEUED watch_result=aborted, stock=9 == Lua atomic checkout == 1 3 0 3 stock_after_success_and_failure=3 == SCRIPT LOAD / EVALSHA / NOSCRIPT == sha=... 1 NOSCRIPT No matching script. Please use EVAL. ALL ASSERTIONS PASSED临时实例结束后会执行SHUTDOWN NOSAVE。如果6395已被其他进程占用,实验会直接退出,绝不会停止未知进程。
十三、放进真实系统前,还要补上六块拼图
13.1 原子性不能代替幂等性
假设 Lua 已经成功扣库存并创建订单,但网络在返回结果前断开。客户端不知道这单到底成功没有,如果直接原样重试,可能再次扣库存。
因此脚本应该携带业务幂等号,例如订单 ID,并在开头检查订单是否已经存在:
ifredis.call("EXISTS",KEYS[2])==1thenreturn{2,redis.call("HGET",KEYS[2],"status")}end这里的2可以表示“此前已经处理过”。客户端收到超时后,使用同一个订单 ID 重试,脚本会返回旧结果,而不是重复结账。
要注意,幂等记录的保存时间必须覆盖业务可能重试的时间窗口。如果订单 Key 五秒就过期,十秒后到达的重试还是会被当成新订单。
13.2 客户端超时不等于服务端没有执行
客户端等待 100 毫秒后超时,只能说明“没有按时收到响应”,不能说明 Redis 没处理命令。网络抖动、连接复用、慢脚本和客户端 GC 都可能造成超时。
所以涉及资金、库存、券核销时,不要写出这样的逻辑:
请求超时 -> 当作失败 -> 换一个订单号重试更安全的做法是使用固定幂等号查询最终状态,区分“明确失败”和“结果未知”。原子性保护的是 Redis 内部操作边界,幂等性保护的是客户端重试边界,两者缺一不可。
13.3 Lua 只保证 Redis 内部原子,管不到 MySQL 和消息队列
如果结账流程还包含:
Redis 扣库存 MySQL 写订单 Kafka 发消息 第三方支付一段 Redis Lua 不可能让四套系统一起提交或回滚。脚本成功后 MySQL 仍可能失败,支付成功后消息也可能暂时发送不出去。
这类跨系统一致性需要状态机、事务消息、Outbox、补偿任务或对账机制。不要因为 Lua 脚本里有“原子”两个字,就把它升级成分布式事务。
超市里的收银员只能保证本台收银机的动作连续,管不了银行清算中心和仓库运输车同时成功。
13.4 脚本权限要跟着账号走
生产环境通常通过 ACL 限制应用账号。使用 Lua 时,不仅要允许EVAL或EVALSHA,还要确保脚本内部调用的 Redis 命令也在账号权限范围内。
权限不应该大到“为了脚本方便直接给+@all”。更稳妥的是根据脚本真实使用的命令和 Key 前缀配置最小权限,并在预发布环境用与生产一致的账号验证。
脚本也不能访问 Redis 主机的文件系统、网络或任意系统调用。Lua 运行在受限沙箱里,它是数据旁边的小程序,不是让你远程登录服务器的后门。
13.5 故障转移后要准备重新加载脚本
EVAL脚本缓存是易失的。主节点重启,或者 Sentinel/Cluster 发生主从切换后,新主节点未必拥有客户端记住的脚本 SHA1。
因此部署过程最好做到:
- 应用启动时加载核心脚本,但不要假设预加载永远有效;
- 正常调用优先使用
EVALSHA; - 捕获
NOSCRIPT后,在当前目标节点重新SCRIPT LOAD; - 加载完成后只重试一次,并继续使用原幂等号;
- 对
NOSCRIPT数量和故障转移事件建立监控。
如果采用 Redis Functions,还要把函数库部署纳入 Redis 实例的版本与变更管理,不能只更新应用代码却忘了服务端函数。
13.6 把冲突率和脚本耗时变成可观察指标
仅看 Redis QPS,很难判断事务是不是健康。建议至少记录:
WATCH的EXEC放弃次数和重试次数;- Lua 成功、库存不足、参数错误、
NOSCRIPT的数量; - 脚本端到端耗时及 P95、P99;
- Redis
SLOWLOG、命令调用次数、CPU 和延迟; - 热点 Key、单次脚本涉及的元素数量;
- 客户端超时后通过幂等查询确认成功的比例。
如果WATCH冲突率持续升高,说明“乐观地认为大家不会撞车”已经不成立,应考虑单条原子命令、Lua、分片或业务队列。如果 Lua P99 明显上升,则要检查脚本复杂度、大 Key 和输入规模,而不是简单把客户端超时调大。
13.7 事务与分布式锁不要互相冒充
WATCH不是分布式锁,MULTI / EXEC也不会在业务处理期间替你占住资源。反过来,拿到分布式锁也不代表锁内每个 Redis 命令会自动回滚。
如果业务真的需要“某段跨 Redis、数据库和外部调用的流程同一时间只允许一个执行者”,才考虑分布式锁,并处理唯一令牌、续期、安全解锁和故障恢复。仅仅为了防止库存检查与扣减被插队,短小的 Lua 往往比“加锁—网络往返—解锁”更直接。
关于锁的完整边界,可以继续看《一文搞懂 Redis 分布式锁》。
十四、十个高频误区
误区 1:Redis 事务和 MySQL 事务一样
Redis 保证队列连续执行,但不提供传统事务的自动回滚语义。
误区 2:单线程就没有竞态条件
单条命令不会被拆开,不代表多条命令之间不会插入别人的操作。
误区 3:MULTI 后命令已经执行
绝大多数命令只是返回QUEUED,真正执行发生在EXEC。
误区 4:事务中可以读取上一条结果再写下一条
组装队列时拿不到真实结果。需要WATCH或 Lua。
误区 5:WATCH 会锁住 Key
它只观察变化,不阻止别人写;冲突时由当前事务放弃并重试。
误区 6:EXEC 失败应该无限重试
热点冲突下无限重试会制造流量风暴。必须限制次数并退避。
误区 7:Lua 报错会撤销之前的写入
不会。应先验证,再写入,减少脚本中途出错的机会。
误区 8:Lua 越大越能减少网络请求
大脚本会长时间阻塞 Redis。脚本应只承载靠近数据的短逻辑。
误区 9:EVALSHA 永远不会失败
脚本缓存可能丢失,客户端必须能处理NOSCRIPT。
误区 10:Cluster 中 Lua 可以随便操作多个 Key
多 Key 脚本要考虑 Hash Slot。Hash Tag 能解决同槽问题,也可能带来热点。
十五、总结
把 Redis 想成一家超市后,这几种工具就很好区分:
- 普通原子命令,是收银员一次完成一个标准动作;
- Pipeline,是把一篮子商品一次送上输送带,减少来回跑;
MULTI / EXEC,是先列清单,再连续执行本单操作;DISCARD,是在结账前取消整张清单;WATCH,是结账前检查价签是否被别人换过,冲突就重来;- Lua,是把读取、判断和修改写成一份服务端操作手册;
- 原子执行不等于自动回滚,脚本也不例外;
- Lua 越长,其他顾客等待越久;
- Cluster 中还要考虑 Key 是否同槽。
真正成熟的做法,不是所有业务都套 Lua,而是先寻找 Redis 已有的原子命令;解决不了,再根据是否依赖读取结果、冲突概率、脚本复杂度和集群结构选择事务、WATCH 或 Lua。
一句话收尾:
事务负责“不让别人插队”,WATCH 负责“发现价签变了”,Lua 负责“把核价和结账一次做完”。
参考资料
- Redis Transactions 官方文档
- Scripting with Lua 官方文档
- EVAL 命令文档
- Redis Lua API Reference
- Redis Functions 官方文档
本文实验基于 Redis 8.6.1。Redis 事务和 Lua 的核心语义较稳定,但命令选项、Functions 与集群能力会随版本演进,生产使用前请核对目标版本官方文档。
- 个人小游戏
