尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Redis事务与Lua脚本:原子操作原理、选型对比及生产实践
1. 为什么单线程的 Redis 还需要“事务”很多人第一次接触 Redis 事务时都会有一个疑问Redis 不是单线程处理命令的吗一条命令执行完才会执行下一条天然就不存在并发穿插的问题那为什么还要搞一套事务机制出来这个问题我当年也纠结过后来在实际项目里踩了几次坑才真正想明白。单线程保证的是“单条命令”的原子性也就是说一条命令要么执行成功要么执行失败不会执行到一半被别的命令插进来。但现实业务里我们经常需要“一组命令”整体按顺序执行中间不希望被其他客户端的命令打断。比如经典的转账场景A 账户扣 100B 账户加 100这两步必须绑在一起中间如果插进来一个查询请求读到“A 已扣、B 未加”的中间状态数据就脏了。Redis 事务解决的正是这种“批量命令打包执行”的需求。需要先明确一个关键认知Redis 事务和关系型数据库的事务不是一回事。MySQL 的事务有 ACID 四大特性支持回滚而 Redis 事务只保证“命令打包顺序执行、执行期间不被其他命令打断”它不支持回滚。这一点是无数新手踩坑的重灾区后面我会专门用一节来讲清楚为什么它不回滚、以及不回滚会带来什么后果。这篇文章我会把 Redis 事务的完整命令链路MULTI、QUEUE、EXEC、DISCARD、WATCH拆开讲透再延伸到 Lua 脚本这个“进阶版事务”把两者的适用边界、性能差异、实战坑点都过一遍。适合已经会用 Redis 基本命令、想搞清楚事务和脚本怎么选、怎么写才不出错的开发者。如果你还在纠结“为什么我的事务没生效”那这篇大概率能帮你把问题定位出来。2. Redis 事务的核心机制拆解2.1 事务的五个关键命令与执行流程Redis 事务围绕五个命令展开我先把它们的作用和关系理一遍后面再逐个深入。命令作用使用阶段MULTI开启事务之后的命令进入队列事务开始普通命令入队但不立即执行返回 QUEUED事务排队中EXEC执行队列中的所有命令事务提交DISCARD放弃事务清空队列事务取消WATCH监视 key被改动则事务失败事务开始前整个流程可以这样理解客户端发送MULTI后Redis 进入“事务状态”后续所有命令不会立即执行而是被放进一个队列里每入队一条就返回一个QUEUED。直到客户端发送EXECRedis 才把队列里的命令一次性、按顺序、连续地执行完。执行期间不会插入其他客户端的命令这就是它“隔离性”的来源。我用一段实际会敲的命令演示一下127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET account:A 100 QUEUED 127.0.0.1:6379 SET account:B 200 QUEUED 127.0.0.1:6379 INCRBY account:A -100 QUEUED 127.0.0.1:6379 INCRBY account:B 100 QUEUED 127.0.0.1:6379 EXEC 1) OK 2) OK 3) (integer) 0 4) (integer) 300注意看入队阶段返回的全是QUEUED只有EXEC之后才返回真正的执行结果而且结果是一个数组顺序和入队顺序一一对应。这个返回结构很重要很多客户端库就是靠解析这个数组来判断每条命令的执行结果的。2.2 入队错误与执行错误两种失败要分清Redis 事务的“错误处理”是它最反直觉的地方必须把两种错误分开看。第一种入队时的错误语法错误、命令不存在。比如你写了个不存在的命令或者参数个数不对Redis 在入队阶段就能检测出来会直接返回一个错误并且标记这个事务为“脏”。一旦事务被标记为脏后续EXEC会直接失败整个队列一条都不执行。127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET key1 value1 QUEUED 127.0.0.1:6379 INVALIDCMD key2 (error) ERR unknown command INVALIDCMD 127.0.0.1:6379 EXEC (error) EXECABORT Transaction discarded because of previous errors.第二种执行时的错误类型错误、对字符串做 INCR 等。这类错误在入队阶段检测不出来因为 Redis 不检查值的类型只有真正执行时才知道。关键点来了执行时某条命令报错其他命令照样执行事务不会回滚。127.0.0.1:6379 SET str hello OK 127.0.0.1:6379 MULTI OK 127.0.0.1:6379 INCR str QUEUED 127.0.0.1:6379 SET num 10 QUEUED 127.0.0.1:6379 EXEC 1) (error) ERR value is not an integer or out of range 2) OK看到没第一条INCR str报错了但第二条SET num 10依然执行成功。这就是 Redis 事务“不支持回滚”的直接体现。为什么这么设计官方的解释是执行时错误通常是编程错误比如类型用错应该在开发阶段就发现而不是靠运行时回滚来兜底而且支持回滚会让 Redis 的实现复杂很多性能也会受影响。理解了这一点你就知道不能把 Redis 事务当成 MySQL 事务来用业务上的“要么全成功要么全失败”得靠别的手段保证。2.3 WATCH乐观锁实现 CAS 操作光有 MULTI/EXEC 还不够因为它只能保证命令连续执行但没法保证“我读到的值在我写之前没被别人改过”。这时候就要请出WATCH。WATCH的用法是在MULTI之前监视一个或多个 key如果在EXEC执行之前这些 key 被任何客户端包括自己修改过那么整个事务就会失败EXEC返回 nil。# 客户端 A 127.0.0.1:6379 WATCH balance OK 127.0.0.1:6379 GET balance 100 127.0.0.1:6379 MULTI OK 127.0.0.1:6379 INCRBY balance -50 QUEUED # 此时客户端 B 抢先改了 balance 127.0.0.1:6379 INCRBY balance 30 (integer) 130 # 客户端 A 再执行 EXEC 127.0.0.1:6379 EXEC (nil)EXEC返回 nil说明事务因为balance被改动而放弃执行。这就是典型的CASCheck-And-Set乐观锁思路先监视操作前检查有没有被别人动过动过就重试。它不阻塞其他客户端性能比悲观锁好代价是冲突时需要业务层自己重试。这里有个细节要注意WATCH的监视效果是一次性的EXEC或DISCARD之后监视自动解除。另外WATCH必须在MULTI之前执行放在MULTI里面是无效的。还有一点如果事务执行成功WATCH也会自动取消不需要手动UNWATCH。3. Lua 脚本Redis 事务的进阶形态3.1 为什么 Lua 脚本能替代事务事务用起来其实挺别扭的命令要一条条入队逻辑判断比如“如果余额够就扣款”没法在事务里做因为入队阶段拿不到值。这时候 Lua 脚本就派上用场了。Redis 从 2.6 版本开始内置 Lua 解释器允许客户端发送一段 Lua 脚本在服务端原子性地执行。所谓“原子性”和事务一样指的是脚本执行期间不会被其他命令打断。但 Lua 脚本比事务强的地方在于可以在服务端做逻辑判断脚本里能读值、比较、分支根据结果决定执行哪些写操作这是事务做不到的。减少网络往返多条命令打包成一次脚本调用客户端只发一次请求网络开销大幅降低。复用性强脚本可以缓存到服务端后续用 SHA1 摘要调用不用重复传输脚本内容。我个人的经验是能用 Lua 脚本搞定的原子操作优先用 Lua事务更多是历史遗留场景或者简单批量场景才用。原因就是 Lua 的表达能力强太多而且性能通常更好。3.2 EVAL、EVALSHA 与脚本缓存机制Lua 脚本的执行命令主要有两个EVAL和EVALSHA。EVAL的语法是EVAL script numkeys key1 key2 ... arg1 arg2 ...。其中numkeys是后面跟的 key 的数量Redis 靠这个来区分哪些参数是 key、哪些是普通参数。这个区分很重要因为集群模式下 Redis 需要根据 key 来路由如果 key 没标对脚本在集群里会报错。EVAL return redis.call(SET, KEYS[1], ARGV[1]) 1 mykey myvalue这里1表示有 1 个 keymykey是 KEYS[1]myvalue是 ARGV[1]。脚本里通过KEYS和ARGV两个全局数组访问这些参数。EVAL每次都要把整段脚本传过去脚本长了网络开销就大。所以 Redis 提供了脚本缓存执行过的脚本会以 SHA1 摘要为 key 缓存起来之后可以用EVALSHA只传摘要来调用。127.0.0.1:6379 SCRIPT LOAD return redis.call(GET, KEYS[1]) 4e6d8fc8bb01276962cce5371fa795a7763657ae 127.0.0.1:6379 EVALSHA 4e6d8fc8bb01276962cce5371fa795a7763657ae 1 mykey myvalue实际项目里成熟的客户端库比如 Jedis、Lettuce都会自动处理这套逻辑先尝试EVALSHA如果报NOSCRIPT错误脚本没缓存再回退到EVAL并缓存。你手动写的时候也可以照这个思路来。3.3 脚本里的 redis.call 与 redis.pcall在 Lua 脚本里调用 Redis 命令有两个函数redis.call和redis.pcall。它们的区别在于错误处理方式。redis.call遇到错误会直接中断脚本把错误返回给客户端redis.pcall则会把错误捕获成一个 Lua table 返回脚本可以继续执行。看个对比-- 用 redis.call遇到类型错误直接中断 redis.call(SET, KEYS[1], hello) redis.call(INCR, KEYS[1]) -- 这里报错脚本终止 redis.call(SET, KEYS[2], never) -- 不会执行 -- 用 redis.pcall可以捕获错误继续 local ok redis.pcall(INCR, KEYS[1]) if ok.err then -- 处理错误 end redis.call(SET, KEYS[2], executed) -- 会执行这里有个非常重要的坑Lua 脚本执行到一半报错前面已经执行的写命令不会回滚。这和事务是一样的。所以脚本里如果有多步写操作要么保证前面的操作不会失败要么用pcall自己做好错误处理。我见过有同学在脚本里先扣库存再写订单结果扣完库存写订单时报错库存白扣了这就是没考虑脚本的“部分执行”问题。4. 实战用 Lua 脚本实现一个安全的库存扣减4.1 业务场景与原子性需求假设我们有个秒杀场景库存存在 Redis 里key 是stock:1001值是剩余库存。要求扣减库存时先判断够不够够才扣不够返回失败。这个逻辑用事务写会很别扭因为事务里没法根据读到的值做判断。用 Lua 脚本就非常自然。先想清楚为什么必须原子如果分成“GET 判断”和“DECR 扣减”两步两个客户端可能同时读到库存为 1都判断够然后都扣库存变成 -1超卖了。Lua 脚本把判断和扣减放在一次原子执行里就杜绝了这个问题。4.2 脚本编写与参数设计脚本内容如下-- KEYS[1]: 库存 key -- ARGV[1]: 本次扣减数量 local stock tonumber(redis.call(GET, KEYS[1])) if stock nil then return -1 -- 库存不存在 end local num tonumber(ARGV[1]) if stock num then return 0 -- 库存不足 end redis.call(DECRBY, KEYS[1], num) return 1 -- 扣减成功返回值设计上我用了三个状态-1 表示 key 不存在0 表示库存不足1 表示成功。这样客户端能精确区分失败原因而不是笼统地知道“失败了”。参数设计上key 通过KEYS传扣减数量通过ARGV传符合 Redis 的规范集群模式下也能正确路由。调用方式EVAL local stock tonumber(redis.call(GET, KEYS[1])) if stock nil then return -1 end local num tonumber(ARGV[1]) if stock num then return 0 end redis.call(DECRBY, KEYS[1], num) return 1 1 stock:1001 14.3 参数计算与边界处理这里有几个边界要处理到位。第一GET返回的是字符串必须用tonumber转成数字才能比较否则 Lua 里字符串和数字比较会报错。第二如果 key 不存在GET返回 falseLua 里对应 niltonumber(nil)会报错所以要先判断 nil。第三扣减数量也要转数字防止客户端传了非数字字符串。还有一个容易被忽略的点Lua 里的数字都是 double 类型。库存这种整数在 double 范围内2^53 以内是精确的但如果你的数值超过这个范围就会出现精度问题。库存一般不会这么大但如果是计数器类的场景得留意这个限制。另外脚本里我用了DECRBY而不是先GET再SET因为DECRBY本身是原子操作而且能避免“读-改-写”的竞态。虽然整个脚本已经是原子的但用DECRBY语义更清晰也更高效。4.4 脚本缓存与 EVALSHA 调用优化生产环境里每次都传整段脚本太浪费带宽。正确做法是启动时用SCRIPT LOAD把脚本加载进去拿到 SHA1之后都用EVALSHA调用。127.0.0.1:6379 SCRIPT LOAD local stock tonumber(redis.call(GET, KEYS[1])) if stock nil then return -1 end local num tonumber(ARGV[1]) if stock num then return 0 end redis.call(DECRBY, KEYS[1], num) return 1 a3f5c8e9b1d2... 127.0.0.1:6379 EVALSHA a3f5c8e9b1d2... 1 stock:1001 1 (integer) 1但要注意Redis 重启或者执行SCRIPT FLUSH后缓存会清空EVALSHA会报NOSCRIPT。所以客户端必须实现“先 EVALSHA失败则 EVAL 并重新缓存”的降级逻辑。这个逻辑在 Jedis 里是eval方法自动处理的用原生命令的话得自己写。5. 事务与 Lua 脚本的选型对比与避坑指南5.1 什么时候用事务什么时候用脚本我把两者的适用场景整理成一张表方便对照维度MULTI/EXEC 事务Lua 脚本逻辑判断不支持入队时拿不到值支持可读值、分支原子性命令连续执行不被打断脚本整体原子执行回滚不支持不支持网络开销每条命令一次往返入队阶段一次往返可复用每次都要重发命令可缓存EVALSHA 复用集群支持需同 slot需同 slotkey 要标对调试难度低较高需熟悉 Lua我的选型原则很简单需要根据读到的值做判断的一律用 Lua纯粹批量执行、不需要判断的事务够用。比如批量删除多个 key、批量设置多个值事务就行而库存扣减、限流计数、分布式锁续期这类需要“读-判断-写”的必须用 Lua。5.2 集群模式下的 key 分布陷阱这是个大坑。Redis 集群把数据分片到不同节点事务和 Lua 脚本都要求所有涉及的 key 在同一个 slot否则直接报错CROSSSLOT Keys in request dont hash to the same slot。解决办法是用hash tag在 key 里用{}包住一部分Redis 只对{}里的内容算哈希。比如order:{1001}:stock和order:{1001}:price因为{1001}相同会被分到同一个 slot。# 这两个 key 会落到同一个 slot SET order:{1001}:stock 100 SET order:{1001}:price 99设计 key 的时候就要提前规划好哪些 key 需要一起操作用统一的 hash tag 包起来。这个如果等到上线后才发现改造成本很高因为要迁移数据。5.3 常见报错与排查速查表我把实际运维中遇到的高频问题整理成表报错信息原因解决思路EXECABORT入队阶段有语法错误检查命令拼写、参数个数(nil) from EXECWATCH 的 key 被改过业务层重试整个事务NOSCRIPT脚本未缓存回退到 EVAL 并重新缓存CROSSSLOTkey 不在同一 slot用 hash tag 统一 slotERR value is not an integer对非数字做 INCR检查数据类型脚本执行超时脚本里有死循环或耗时操作优化脚本避免大循环关于脚本超时还有个细节Redis 有个lua-time-limit配置默认 5 秒脚本执行超过这个时间Redis 会开始接受其他客户端的SCRIPT KILL命令但不会自动中断脚本。如果脚本已经执行过写操作SCRIPT KILL会失败只能用SHUTDOWN NOSAVE强制关闭这会导致数据丢失。所以脚本里绝对不能写可能长时间运行的逻辑比如遍历大集合、复杂计算。5.4 我踩过的几个真实坑第一个坑是在事务里用 WATCH 但忘了重试。WATCH 失败返回 nil 后如果业务代码不重试这次操作就静默失败了用户以为扣款成功其实没扣。正确做法是包一个循环失败就重新 WATCH、重新读值、重新提交一般重试 3 到 5 次。第二个坑是Lua 脚本里用了随机函数。早期 Redis 要求脚本必须是“纯函数”同样的输入必须产生同样的输出所以math.random这类会被禁用或需要特殊处理。虽然新版本放宽了限制但为了脚本能被正确复制到从节点最好还是避免在脚本里用随机数和时间函数需要的话通过参数传进去。第三个坑是脚本里 KEYS 和 ARGV 用混了。有次我把 key 当成 ARGV 传本地单机测试没问题一上集群就报 CROSSSLOT排查了半天才发现是参数位置传错了。记住所有 key 必须通过 KEYS 传其他参数通过 ARGV 传这是铁律。第四个坑是误以为事务能回滚。前面反复强调了但还是要再说一遍Redis 事务执行时出错不回滚Lua 脚本执行到一半出错也不回滚。如果你的业务真的需要“全成功或全失败”得在应用层做补偿比如记录操作日志、失败后手动回滚或者用支持事务回滚的其他存储方案。6. 性能优化与生产环境实践建议6.1 脚本复杂度控制与拆分策略Lua 脚本虽然强大但不要写成“万能脚本”。我见过有人把整个订单流程塞进一个几百行的 Lua 脚本结果调试困难、执行慢、还容易超时。合理的做法是按原子性边界拆分只有必须原子执行的部分才放进脚本其他逻辑放在应用层。比如下单流程里“扣库存”和“写订单”如果必须原子就放一个脚本但“发通知”“记日志”这些不需要原子的放脚本外面。脚本越短执行越快出问题的概率也越低。另外脚本里要避免KEYS *、SMEMBERS大集合、LRANGE大范围这类操作它们的时间复杂度是 O(N)N 大了直接拖垮 Redis。如果确实需要遍历考虑用SCAN分批在应用层做。6.2 监控脚本执行耗时与慢查询生产环境一定要监控脚本执行时间。Redis 的SLOWLOG会记录执行超过阈值的命令Lua 脚本也在其中。配置slowlog-log-slower-than单位微秒比如设成 10000 就是 10 毫秒超过就记下来。127.0.0.1:6379 CONFIG SET slowlog-log-slower-than 10000 OK 127.0.0.1:6379 SLOWLOG GET 10通过慢查询日志能快速定位是哪个脚本、哪条命令拖慢了 Redis。我一般会定期看SLOWLOG尤其是大促前把潜在的性能问题提前解决掉。还有个指标是INFO commandstats能看到每个命令的调用次数和平均耗时脚本对应的命令是eval和evalsha可以对比它们的耗时来判断缓存是否生效。6.3 主从复制与脚本传播机制Lua 脚本在主从复制里的传播方式经历过变化。早期 Redis 是把脚本执行产生的写命令传播给从节点这样从节点重放命令即可保证主从一致。但这种方式在脚本里有随机性时会有问题所以后来改成了直接传播脚本本身或者 EVALSHA从节点重新执行脚本。理解这个机制的意义在于脚本必须是确定性的同样的输入在主从两边要产生同样的结果否则主从数据会不一致。所以前面说的避免随机数、避免依赖系统时间就是为了这个。如果你确实需要随机性用redis.replicate_commands()显式开启命令传播模式但这样又回到了老问题得自己保证命令的确定性。6.4 生产环境脚本管理规范最后分享几条我在团队里推行的脚本管理规范都是血泪教训换来的脚本统一版本管理所有 Lua 脚本放在代码仓库里有版本号改动走 review不能随手在客户端敲。启动时预加载应用启动时用SCRIPT LOAD把所有脚本加载一遍拿到 SHA1 缓存到本地避免运行时才发现脚本没缓存。降级逻辑必备EVALSHA失败要能自动回退到EVAL并且处理NOSCRIPT错误。超时保护脚本里避免任何可能长时间运行的逻辑必要时在应用层加超时控制。灰度验证新脚本上线前先在预发环境压测确认耗时和正确性再上生产。这套规范执行下来我们线上基本没再出过脚本相关的故障。Redis 的事务和 Lua 脚本本身不复杂复杂的是边界情况和生产环境的各种意外把这些提前想到用起来就很稳了。
RELATED

相关推荐

易语言多线程COM初始化指南:解决大漠插件崩溃问题

易语言多线程COM初始化指南:解决大漠插件崩溃问题

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/10/14 1:24:56
Qt画图板实战:从QWidget到QGraphicsView的绘图架构选型与优化

Qt画图板实战:从QWidget到QGraphicsView的绘图架构选型与优化

简介:这是一份面向Qt初学者与GUI开发入门者的绘图应用实践项目,完整实现了具备画笔选择、颜色填充、图像打开/保存等核心功能的跨平台画图板,帮助开发者快速掌握Qt图形绘制与事件处理机制。资源包共44个文件,包含6个CPP源文件与5个…

📅 2026/10/14 1:24:56
Cocos Creator 中国象棋开发:从规则建模到 AI 对战

Cocos Creator 中国象棋开发:从规则建模到 AI 对战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/10/14 1:19:55
MORE NEWS

更多资讯

📰

airllm逐层加载实战:4GB显存如何跑70B大模型

1. 当显存告急时,我们还能怎么跑大模型显存不够这件事,几乎每个在本地折腾过大模型的人都遇到过。你兴冲冲下载了一个 70B 参数的模型权重,打开推理脚本,结果第一行就给你甩回来一个CUDA out of memory。显卡就那么大,…

📰

YOLO26算法城市街道背包手提包纸袋行人目标检测+训练好的模型+9130张数据集+pyqt可视化界面

YOLO26算法城市街道背包手提包纸袋行人目标检测训练好的模型9130张数据集pyqt可视化界面 这套数据共 9130 张图。用 YOLO26 训了 100 轮,mAP50 0.9158,mAP50-95 0.7920。Precision 0.9433、Recall 0.8620。下面按数据构成 → 训练曲线 → 预测效果的顺序…

📰

动态多Agent工作流公测体验:架构拆解与压测实战

最近只要在搞AI应用,多Agent编排就绕不开。单Agent能力不够用,静态编排又总在各种场景里卡壳,所以动态多Agent工作流这个概念一出来,我第一时间申请了公测。前前后后跑了两周多,压过几轮高负载任务,也塞了几…

📰

线束工厂100问|端子采购需要核对哪些关键参数,避免来料不匹配?

1. 引言端子是线束连接的核心元件,来料不匹配往往会导致压接不良、拉脱力不足甚至批量返工。结合上海红同电子有限公司(www.hongtongsh.com)近30年电子高低压线束的生产经验,本文梳理端子采购时需要重点核对的关键参数&#xff0c…

📰

小说一键成片:开源AI工具如何用大模型重构短剧生产链

要说最近技术圈里什么方向最热,AI 视频生成绝对算一个。不过比起那种输入一句话生成几秒特效片段的花活,我更关注的是另一类工具——直接能把一本百万字小说变成一部可以看的短剧。这个细分赛道里有个开源项目最近表现很猛,GitHub 上狂揽了 1…

📰

NSGAII多目标优化在综合能源系统调度中的Matlab实现与调参实践

做综合能源优化调度的人,多半都遇到过这种尴尬:目标函数一多,单目标优化那套权重法就不好使了——权重拍脑袋定,结果跑出来要么成本低得离谱但碳排放高得吓人,要么反过来,折腾半天压根看不到整体权衡。所以…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬