news 2026/11/5 22:02:12

Redis事务与Lua脚本:原子操作原理、选型对比及生产实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis事务与Lua脚本:原子操作原理、选型对比及生产实践

1. 为什么单线程的 Redis 还需要“事务”

很多人第一次接触 Redis 事务时都会有一个疑问:Redis 不是单线程处理命令的吗,一条命令执行完才会执行下一条,天然就不存在并发穿插的问题,那为什么还要搞一套事务机制出来?这个问题我当年也纠结过,后来在实际项目里踩了几次坑才真正想明白。

单线程保证的是“单条命令”的原子性,也就是说一条命令要么执行成功要么执行失败,不会执行到一半被别的命令插进来。但现实业务里,我们经常需要“一组命令”整体按顺序执行,中间不希望被其他客户端的命令打断。比如经典的转账场景:A 账户扣 100,B 账户加 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。直到客户端发送EXEC,Redis 才把队列里的命令一次性、按顺序、连续地执行完。执行期间不会插入其他客户端的命令,这就是它“隔离性”的来源。

我用一段实际会敲的命令演示一下:

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被改动而放弃执行。这就是典型的CAS(Check-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 个 key,mykey是 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 1

4.3 参数计算与边界处理

这里有几个边界要处理到位。第一,GET返回的是字符串,必须用tonumber转成数字才能比较,否则 Lua 里字符串和数字比较会报错。第二,如果 key 不存在,GET返回 false(Lua 里对应 nil),tonumber(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需同 slot,key 要标对
调试难度低较高,需熟悉 Lua

我的选型原则很简单:需要根据读到的值做判断的,一律用 Lua;纯粹批量执行、不需要判断的,事务够用。比如批量删除多个 key、批量设置多个值,事务就行;而库存扣减、限流计数、分布式锁续期这类需要“读-判断-写”的,必须用 Lua。

5.2 集群模式下的 key 分布陷阱

这是个大坑。Redis 集群把数据分片到不同节点,事务和 Lua 脚本都要求所有涉及的 key 在同一个 slot,否则直接报错CROSSSLOT Keys in request don't 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 统一 slot
ERR 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 脚本本身不复杂,复杂的是边界情况和生产环境的各种意外,把这些提前想到,用起来就很稳了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 3:24:48

ESP32上做应用商店:动态加载与沙箱隔离实战

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

作者头像 李华
网站建设 2026/10/22 23:32:17

ADC0809详解:从逐次逼近原理到51单片机驱动代码与避坑指南

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

作者头像 李华
网站建设 2026/11/4 0:34:35

嵌入式数据库SQLite移植与使用:交叉编译、裁剪与性能优化实战

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

作者头像 李华
网站建设 2026/11/5 1:09:50

LED降压恒流驱动芯片选型指南:从原理到实战

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

作者头像 李华
网站建设 2026/11/4 16:55:58

Kotlin协程与Jetpack架构组件深度整合实战指南

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

作者头像 李华
网站建设 2026/11/4 13:28:33

905nm激光测距模块UART协议解析与Python上位机调试实战

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

作者头像 李华