ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

一文搞懂 Redis 事务与 Lua 脚本:把它想成一家超市收银台

2026/8/4 22:19:32 拓冰建站 浏览量
一文搞懂 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 事务与分布式锁不要互相冒充十四、十个高频误区误区 1Redis 事务和 MySQL 事务一样误区 2单线程就没有竞态条件误区 3MULTI 后命令已经执行误区 4事务中可以读取上一条结果再写下一条误区 5WATCH 会锁住 Key误区 6EXEC 失败应该无限重试误区 7Lua 报错会撤销之前的写入误区 8Lua 越大越能减少网络请求误区 9EVALSHA 永远不会失败误区 10Cluster 中 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可能出现这样的时序客户端 AGET - 1 客户端 BGET - 1 客户端 ADECR - 0 客户端 BDECR - -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 经常和事务一起被提到因为它们都能一次发送多条命令但目标完全不同。对比项PipelineMULTI / EXEC核心目标减少网络往返保证一组命令连续执行是否阻止其他客户端插入不保证EXEC阶段保证是否返回每条命令结果是是是否能提高吞吐通常可以不一定是否等于业务原子性否只覆盖队列执行阶段Pipeline 像顾客一次把十件商品放上传送带减少来回递商品的次数事务像按下“本单连续处理”按钮。很多客户端支持“事务 Pipeline”但要清楚自己要解决的是网络往返还是并发一致性。七、Lua把结账规则直接交给 Redis 收银员7.1 一个完整的原子结账脚本下面的脚本先检查库存再扣减库存、生成订单并设置过期时间localstock_keyKEYS[1]localorder_keyKEYS[2]localskuARGV[1]localquantitytonumber(ARGV[2])localamountARGV[3]ifnotquantityorquantity0thenreturnredis.error_reply(INVALID_QUANTITY)endlocalstocktonumber(redis.call(HGET,stock_key,sku)or-1)ifstock0thenreturnredis.error_reply(SKU_NOT_FOUND)endifstockquantitythenreturn{0,stock}endlocalremainingredis.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.pcallredis.call(...)遇到错误会终止脚本并把错误返回客户端redis.pcall(...)把错误作为 Lua 值返回脚本可以自行判断和处理。localresultredis.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显式传入普通参数放进ARGVredis.call(HINCRBY,KEYS[1],ARGV[1],-tonumber(ARGV[2]))不要在脚本里动态拼出一个未声明的 Key-- 不推荐localkeyorder:..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 LOADreturn 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不连接本机常用的6379chmodx 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_resultaborted, stock9 Lua atomic checkout 1 3 0 3 stock_after_success_and_failure3 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、P99RedisSLOWLOG、命令调用次数、CPU 和延迟热点 Key、单次脚本涉及的元素数量客户端超时后通过幂等查询确认成功的比例。如果WATCH冲突率持续升高说明“乐观地认为大家不会撞车”已经不成立应考虑单条原子命令、Lua、分片或业务队列。如果 Lua P99 明显上升则要检查脚本复杂度、大 Key 和输入规模而不是简单把客户端超时调大。13.7 事务与分布式锁不要互相冒充WATCH不是分布式锁MULTI / EXEC也不会在业务处理期间替你占住资源。反过来拿到分布式锁也不代表锁内每个 Redis 命令会自动回滚。如果业务真的需要“某段跨 Redis、数据库和外部调用的流程同一时间只允许一个执行者”才考虑分布式锁并处理唯一令牌、续期、安全解锁和故障恢复。仅仅为了防止库存检查与扣减被插队短小的 Lua 往往比“加锁—网络往返—解锁”更直接。关于锁的完整边界可以继续看《一文搞懂 Redis 分布式锁》。十四、十个高频误区误区 1Redis 事务和 MySQL 事务一样Redis 保证队列连续执行但不提供传统事务的自动回滚语义。误区 2单线程就没有竞态条件单条命令不会被拆开不代表多条命令之间不会插入别人的操作。误区 3MULTI 后命令已经执行绝大多数命令只是返回QUEUED真正执行发生在EXEC。误区 4事务中可以读取上一条结果再写下一条组装队列时拿不到真实结果。需要WATCH或 Lua。误区 5WATCH 会锁住 Key它只观察变化不阻止别人写冲突时由当前事务放弃并重试。误区 6EXEC 失败应该无限重试热点冲突下无限重试会制造流量风暴。必须限制次数并退避。误区 7Lua 报错会撤销之前的写入不会。应先验证再写入减少脚本中途出错的机会。误区 8Lua 越大越能减少网络请求大脚本会长时间阻塞 Redis。脚本应只承载靠近数据的短逻辑。误区 9EVALSHA 永远不会失败脚本缓存可能丢失客户端必须能处理NOSCRIPT。误区 10Cluster 中 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 ReferenceRedis Functions 官方文档本文实验基于 Redis 8.6.1。Redis 事务和 Lua 的核心语义较稳定但命令选项、Functions 与集群能力会随版本演进生产使用前请核对目标版本官方文档。个人小游戏