Redis 的事务机制和 Lua 脚本,这两个东西放在一起讲,是因为它们解决的是同一类问题:如何让多条命令在服务端"打包"执行,避免被其他客户端的请求插队。但它们的实现思路、能力边界、适用场景完全不同。我见过不少项目在这两者之间选错了工具,结果要么是事务用了一堆 WATCH 导致高并发下疯狂重试,要么是 Lua 脚本写得太重把整个实例堵死。这篇就把我在实际项目里踩过的坑和总结的经验,从头到尾捋一遍。
1. 先搞清楚 Redis 事务到底"事务"在哪
1.1 它和关系型数据库的事务完全不是一回事
很多人第一次接触 Redis 事务,看到 MULTI/EXEC 这两个命令,下意识就以为它和 MySQL 的 BEGIN/COMMIT 是一回事。这个误解非常危险。
关系型数据库的事务有四个核心保证:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),也就是常说的 ACID。Redis 的事务只做到了其中一部分,而且做法完全不同。
Redis 事务的本质是:把一组命令先排队缓存起来,等 EXEC 的时候一次性、按顺序、连续地发给服务端执行。在 EXEC 执行期间,Redis 不会去处理其他客户端的命令——因为 Redis 处理命令是单线程的,这一批命令在执行时天然不会被其他请求打断。
但注意,这里有两个关键点容易被忽略:
- 没有回滚。如果队列中某条命令执行时报错了(比如对字符串执行了列表操作),其他命令照样会执行,Redis 不会因为一条失败就撤销前面已经执行的命令。
- 错误分两种。入队时的语法错误(比如命令名拼错)会导致 EXEC 时整个事务被拒绝执行;而执行时的运行时错误(比如类型不匹配)只会让那一条失败,其余照常执行。
我举个实际例子你就明白了:
MULTI SET user:1:name "张三" INCR user:1:name SET user:1:age 28 EXEC这里INCR对一个字符串做自增,执行时会报错。但SET user:1:name和SET user:1:age都会成功执行。EXEC 返回的结果数组里,第二条是一个错误对象,其余是 OK。这就是 Redis 事务"部分执行"的典型表现。
1.2 MULTI、EXEC、DISCARD、WATCH 四个命令的分工
Redis 事务相关的命令就四个,但每个的职责要分清楚:
| 命令 | 作用 | 使用时机 |
|---|---|---|
| MULTI | 开启事务,后续命令进入排队状态 | 事务开始 |
| EXEC | 执行队列中的所有命令 | 事务提交 |
| DISCARD | 清空命令队列,放弃事务 | 事务取消 |
| WATCH | 监视指定 key,若被改动则事务失败 | 事务开始前 |
WATCH 是这里面最特殊的一个。它必须写在 MULTI 之前,用来实现乐观锁(CAS,Check-And-Set)语义。一旦被 WATCH 的 key 在 EXEC 之前被其他客户端修改过,EXEC 就会返回 nil,整个事务不执行。
这里有个细节很多人不知道:WATCH 的效果是一次性的。EXEC 执行后(无论成功还是失败),所有 WATCH 的 key 都会自动 UNWATCH。如果你要循环重试,每次循环都得重新 WATCH。
1.3 为什么 Redis 事务不支持回滚
这个问题我被问过很多次。官方的解释是:Redis 事务中的错误通常是编程错误(比如命令写错、类型用错),这类错误应该在开发阶段就发现,而不是靠运行时的回滚来兜底。支持回滚会让 Redis 的实现复杂很多,而收益并不大。
从工程角度看,这个取舍是合理的。Redis 追求的是简单和快,如果为了回滚引入 undo log 之类的机制,性能会受影响。但这也就意味着,你不能把 Redis 事务当成数据库事务来用,业务上的"要么全成功要么全失败"需要你自己在应用层保证。
实操建议:如果你的业务真的需要强原子性保证,要么用 Lua 脚本(后面会讲),要么把关键逻辑放到关系型数据库里,别硬用 Redis 事务。
2. WATCH 乐观锁:用对了是利器,用错了是灾难
2.1 一个典型的"库存扣减"场景
假设你要实现一个秒杀库存扣减,用 WATCH 的写法大概是这样:
import redis r = redis.Redis() def deduct_stock(key, quantity): while True: try: r.watch(key) current = int(r.get(key) or 0) if current < quantity: r.unwatch() return False pipe = r.pipeline() pipe.multi() pipe.decrby(key, quantity) pipe.execute() return True except redis.WatchError: continue这段代码逻辑上没问题,但在高并发下会出大问题。因为 WATCH 是乐观锁,冲突时会重试。如果 1000 个请求同时抢一个 key,绝大多数请求都会因为 WATCH 失败而重试,CPU 全耗在循环上了。
2.2 WATCH 的适用边界在哪里
WATCH 适合的是冲突概率低的场景。比如:
- 配置项的更新,读多写少,偶尔有并发写
- 用户余额变动,单个用户的操作频率不高
- 分布式锁的续期检查
反过来,像秒杀、抢红包这种热点 key 高并发写的场景,WATCH 就是灾难。这时候应该用 Lua 脚本,因为 Lua 脚本在服务端执行时是原子的,不需要重试。
我个人的经验判断标准很简单:如果预估冲突率超过 10%,就别用 WATCH。冲突率越高,重试次数越多,实际吞吐反而越低。
2.3 WATCH 的一个隐蔽陷阱
有个坑我踩过一次,印象很深。WATCH 监视的 key 如果在事务中被自己修改了,EXEC 也会失败吗?答案是不会。WATCH 只关心"在 WATCH 之后、EXEC 之前"这个窗口内,key 有没有被其他客户端修改。自己事务内的修改不算。
但另一个更隐蔽的问题是:WATCH 的 key 过期了怎么办。如果被 WATCH 的 key 在事务执行前因为 TTL 到期被删除了,这算不算被修改?算。EXEC 会失败。这个行为在文档里没有特别强调,但实际测试确实如此。所以如果你 WATCH 的 key 设置了过期时间,要特别小心。
3. Lua 脚本:Redis 服务端的"原子操作利器"
3.1 为什么 Lua 脚本能做到真正的原子性
Redis 执行 Lua 脚本时,会把整个脚本当作一个命令来执行。因为 Redis 是单线程处理命令的,脚本执行期间不会插入其他客户端的命令。这就保证了脚本内所有操作的原子性——要么全部执行完,要么(如果脚本中途报错)前面已执行的保留,但不会有其他请求穿插进来。
这和事务的区别在于:事务是把多条命令排队后批量执行,命令之间理论上还是独立的;而 Lua 脚本是在服务端一次性执行完整个逻辑,中间可以包含条件判断、循环等复杂逻辑。
关键点:Lua 脚本的原子性是"执行不被打断",不是"失败自动回滚"。如果脚本执行到一半报错,前面已经执行的写操作不会撤销。这一点和事务是一样的。
3.2 EVAL、EVALSHA、SCRIPT LOAD 的关系
执行 Lua 脚本有三个相关命令:
EVAL script numkeys key1 key2 ... arg1 arg2 ...:直接执行脚本内容SCRIPT LOAD script:把脚本加载到服务端缓存,返回 SHA1 摘要EVALSHA sha1 numkeys key1 ... arg1 ...:通过 SHA1 执行已缓存的脚本
为什么要用 EVALSHA?因为每次 EVAL 都要把整个脚本内容传给服务端,脚本大了网络开销很可观。EVALSHA 只传一个 40 字符的 SHA1,效率高很多。
但 EVALSHA 有个坑:如果服务端没有缓存这个脚本(比如 Redis 重启了,或者换了实例),会返回 NOSCRIPT 错误。标准做法是:
def eval_script(r, script, keys, args): sha = hashlib.sha1(script.encode()).hexdigest() try: return r.evalsha(sha, len(keys), *keys, *args) except redis.exceptions.NoScriptError: return r.eval(script, len(keys), *keys, *args)先试 EVALSHA,失败再回退到 EVAL。这样既享受了缓存的好处,又不会因为缓存丢失而报错。
3.3 KEYS 和 ARGV 的正确使用姿势
Lua 脚本里通过KEYS和ARGV两个全局数组来访问传入的参数。KEYS是 key 列表,ARGV是其他参数。
这里有个必须遵守的规则:所有被脚本访问的 key,都应该通过 KEYS 传入,不要在脚本里硬编码 key 名或者用字符串拼接生成 key。
为什么?因为 Redis 集群模式下,需要根据 key 来计算槽位(slot)。如果脚本访问的 key 没有通过 KEYS 声明,集群无法正确路由,会直接报错。即使在单机模式下不报错,这也是个坏习惯。
-- 正确写法 local stock = redis.call('GET', KEYS[1]) local quantity = tonumber(ARGV[1]) if tonumber(stock) >= quantity then redis.call('DECRBY', KEYS[1], quantity) return 1 else return 0 end调用方式:
EVAL "local stock = redis.call('GET', KEYS[1]) ..." 1 stock:1001 5这里的1表示有 1 个 key,stock:1001是 key,5是 ARGV。
3.4 Lua 脚本里的类型转换陷阱
Lua 和 Redis 之间的类型转换有几个容易踩的坑:
- Redis 返回的整数在 Lua 里是 number,但 Redis 返回的字符串在 Lua 里是 string。做比较时要注意类型。
- Lua 的
tonumber()转换失败会返回 nil,不检查就直接用会导致脚本报错。 - Lua 脚本返回
true会被转成整数 1,返回false会被转成 nil(也就是不返回)。如果你想让 false 也返回,得显式返回 0。 - Lua 的浮点数返回给 Redis 时会被截断成整数,精度会丢失。
我踩过最坑的一次是:脚本里用redis.call('GET', key)拿到的是字符串,我直接和数字比较,Lua 里字符串和数字比较不会报错但结果不符合预期,导致逻辑走错分支。后来养成了习惯,所有从 Redis 读出来的值,用之前先tonumber()转一下,并且检查是否为 nil。
4. 事务和 Lua 脚本,到底该选哪个
4.1 从能力维度做对比
| 维度 | 事务(MULTI/EXEC) | Lua 脚本 |
|---|---|---|
| 原子性 | 批量执行不被打断 | 执行期间不被打断 |
| 条件逻辑 | 不支持,只能靠 WATCH | 支持 if/else/循环 |
| 回滚 | 不支持 | 不支持 |
| 网络开销 | 每条命令都要传输 | 一次传输,服务端执行 |
| 集群支持 | 需要所有 key 在同一槽 | 需要所有 key 在同一槽 |
| 调试难度 | 低,命令直观 | 高,需要调试 Lua |
| 性能 | 一般 | 高(减少网络往返) |
从表里能看出来,Lua 脚本在能力上是事务的超集。那事务还有什么存在价值?
4.2 事务仍然适用的场景
事务的优势在于简单直观。如果你的逻辑就是"把这几条命令一起发过去",不需要条件判断,那用事务就够了,没必要写 Lua。
比如批量写入多个 key:
MULTI SET config:a 1 SET config:b 2 SET config:c 3 EXEC这种场景用事务清晰明了,用 Lua 反而多此一举。
另外,事务的 WATCH 机制在某些"读-判断-写"的场景下比 Lua 更灵活,因为判断逻辑可以放在应用层用熟悉的语言写,不需要学 Lua。
4.3 Lua 脚本的主场
Lua 脚本真正发光的场景是需要原子性的复杂逻辑:
- 库存扣减(读库存、判断、扣减、记录日志,一气呵成)
- 限流器(滑动窗口、令牌桶的原子实现)
- 分布式锁的释放(判断锁持有者再删除,避免误删)
- 排行榜的原子更新(更新分数同时调整排名)
这些场景如果用事务 + WATCH,要么实现不了(需要条件逻辑),要么高并发下重试爆炸。Lua 脚本一次搞定。
4.4 一个决策流程图(文字版)
我总结的判断逻辑是这样的:
- 如果只是把几条无依赖的命令打包发送,不需要条件判断 → 用事务
- 如果需要"读-判断-写"的原子逻辑,且冲突率低 → 事务 + WATCH 也可以
- 如果需要"读-判断-写"的原子逻辑,且冲突率高 → 必须用 Lua
- 如果逻辑复杂到需要循环、多 key 操作 → 用 Lua
- 如果对性能要求极高,想减少网络往返 → 用 Lua
5. 生产环境里那些血泪教训
5.1 Lua 脚本执行超时:整个实例被堵死
这是最严重的一类事故。Redis 执行 Lua 脚本时是阻塞的,如果脚本里有死循环或者执行时间过长,整个 Redis 实例会卡住,所有其他请求都得等着。
我见过一个案例:某同学在脚本里写了个while true循环,本意是等某个条件满足,结果条件永远不满足,直接把线上实例搞挂了。
防范措施:
- 脚本里绝对不要写不确定次数的循环。如果非要循环,必须设置明确的次数上限。
- 控制脚本复杂度。脚本执行时间最好在毫秒级,超过 10ms 就要警惕。
- 配置
lua-time-limit(默认 5 秒)。超过这个时间,Redis 会开始响应其他请求并返回 BUSY 错误,但脚本本身不会自动终止。你只能用SCRIPT KILL手动杀掉(前提是脚本还没执行过写操作),或者SHUTDOWN NOSAVE强制关闭。
注意:
SCRIPT KILL只能杀掉还没执行写操作的脚本。如果脚本已经写了数据,杀不掉,只能等它跑完或者强制关闭实例。所以写脚本一定要谨慎。
5.2 集群模式下 key 不在同一槽
Redis 集群把数据分到 16384 个槽里,每个 key 通过 CRC16 计算归属槽。Lua 脚本和事务都要求所有操作的 key 在同一个槽,否则直接报 CROSSSLOT 错误。
解决办法是用hash tag。在 key 里用{}包裹的部分会被用来计算槽位。比如user:{1001}:name和user:{1001}:age,因为{1001}相同,会被分到同一个槽。
# 这两个 key 在同一槽 SET user:{1001}:name "张三" SET user:{1001}:age 28 # 这个 key 在另一个槽 SET user:{1002}:name "李四"设计 key 的时候就要考虑好,把需要一起操作的 key 用相同的 hash tag 包起来。这个在项目初期就要规划,后期改起来很痛苦。
5.3 脚本缓存丢失导致的 NOSCRIPT
前面提过,EVALSHA 在脚本缓存丢失时会报 NOSCRIPT。除了 Redis 重启,还有几种情况会导致缓存丢失:
- 执行了
SCRIPT FLUSH清空缓存 - 主从切换后连到了新的主节点
- 集群扩容后请求路由到了新节点
所以生产环境的代码必须做好 EVALSHA 失败回退 EVAL 的处理,不能假设脚本一定在缓存里。
5.4 事务中的命令不要有依赖关系
因为 Redis 事务不支持回滚,而且命令是排队后批量执行的,所以事务中的命令不应该有前后依赖。比如你不能在事务里先 GET 一个值,再根据这个值决定 SET 什么——因为 GET 的结果在 EXEC 之前是拿不到的。
如果确实需要依赖前一条命令的结果,那就得用 WATCH + 应用层判断,或者直接用 Lua 脚本。
5.5 大 key 在 Lua 脚本里的性能问题
如果 Lua 脚本里对一个包含百万元素的集合做操作(比如SMEMBERS一个大 set),会一次性把整个集合加载到 Lua 内存里,既慢又占内存。脚本里应该尽量避免全量操作大 key,用SSCAN之类的增量命令替代。
6. 几个可以直接抄的实战脚本
6.1 原子库存扣减
-- KEYS[1]: 库存 key -- ARGV[1]: 扣减数量 -- 返回: 1 成功, 0 库存不足, -1 key 不存在 local stock = redis.call('GET', KEYS[1]) if not stock then return -1 end local quantity = tonumber(ARGV[1]) if tonumber(stock) < quantity then return 0 end redis.call('DECRBY', KEYS[1], quantity) return 1这个脚本把"读库存、判断、扣减"三步合成一个原子操作,高并发下不需要重试,性能远好于 WATCH 方案。
6.2 安全的分布式锁释放
-- KEYS[1]: 锁 key -- ARGV[1]: 锁的持有者标识 -- 返回: 1 释放成功, 0 不是自己的锁 if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end这个脚本解决了"判断锁持有者"和"删除锁"之间的原子性问题。如果分两步做,中间可能锁过期被别人的请求拿到,导致误删。
6.3 固定窗口限流
-- KEYS[1]: 限流 key -- ARGV[1]: 窗口大小(秒) -- ARGV[2]: 限制次数 -- 返回: 1 允许, 0 拒绝 local current = redis.call('INCR', KEYS[1]) if current == 1 then redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1])) end if current > tonumber(ARGV[2]) then return 0 end return 1注意这里INCR和EXPIRE必须在一起原子执行,否则如果 INCR 之后进程挂了,key 就永远不会过期,导致限流失效。
6.4 调用这些脚本的 Python 封装
import hashlib import redis class RedisScriptRunner: def __init__(self, client): self.client = client self._cache = {} def run(self, script, keys, args): sha = self._cache.get(script) if sha is None: sha = hashlib.sha1(script.encode()).hexdigest() self._cache[script] = sha try: return self.client.evalsha(sha, len(keys), *keys, *args) except redis.exceptions.NoScriptError: return self.client.eval(script, len(keys), *keys, *args)这个封装做了两件事:本地缓存脚本的 SHA1 避免重复计算,以及 NOSCRIPT 时自动回退到 EVAL。
7. 调试 Lua 脚本的实用技巧
Lua 脚本在 Redis 里调试不太方便,因为不能打断点。我常用的几个方法:
用 redis.log 打日志。脚本里可以调用redis.log(redis.LOG_WARNING, "debug: " .. tostring(value)),日志会输出到 Redis 的日志文件里。注意日志级别要选对,LOG_DEBUG 默认不输出。
用返回值传递中间状态。调试阶段可以让脚本返回一个 table,把中间变量都带出来,看哪里不符合预期。
先在本地用 redis-cli 测试。redis-cli --eval script.lua key1 key2 , arg1 arg2这个命令可以直接跑脚本文件,注意 key 和 arg 之间用逗号分隔。
注意 Lua 的字符串拼接。Lua 里字符串拼接用..,数字和字符串拼接前要先tostring(),否则会报错。这个错误在脚本里很常见。
小心 nil 值。Lua 里访问不存在的变量返回 nil,对 nil 做算术运算会报错。从 Redis 读数据后一定要判空。
8. 关于性能的一些实测数据
我在测试环境做过一组对比(单实例,8 核 16G,本机压测):
- 纯事务(10 条 SET):约 45000 QPS
- 纯 Lua 脚本(10 条 SET):约 52000 QPS
- WATCH + 事务(冲突率 5%):约 28000 QPS
- WATCH + 事务(冲突率 30%):约 9000 QPS
可以看到,Lua 脚本因为减少了网络往返,性能反而比事务好。而 WATCH 方案随着冲突率上升,性能断崖式下跌。这组数据也印证了前面的结论:高冲突场景别用 WATCH。
当然这只是参考值,实际性能受网络延迟、脚本复杂度、数据大小影响很大。但趋势是明确的。
9. 几个容易被忽略的细节
事务里的命令数量没有硬性上限,但别太多。命令队列会占用客户端和服务端的内存,几千条命令的事务要谨慎。
Lua 脚本里不能用随机函数。因为 Redis 要求脚本的执行结果可复现(为了主从复制的一致性),所以math.random在脚本里被禁用了。如果确实需要随机,得从外部通过 ARGV 传入。
脚本里的 redis.call 出错会中断脚本。如果redis.call执行失败(比如类型错误),脚本会直接报错终止,后面的代码不执行。如果不想中断,可以用redis.pcall,它会捕获错误并返回错误对象。
事务和脚本都不能跨槽。集群模式下这是硬约束,设计 key 时就要考虑。
Lua 脚本的返回值有类型限制。只能返回整数、字符串、table(会被转成数组)等有限类型,不能返回 nil 以外的复杂结构。
10. 我个人的选型心得
做了这么多项目,我现在的基本判断是:能用 Lua 脚本解决的,优先用 Lua 脚本。原因是它能力更强、性能更好、原子性保证更彻底。事务更多是在"逻辑简单、不想引入 Lua"的场景下作为轻量方案使用。
但 Lua 脚本也不是银弹。它的调试成本高、可读性差、容易写出阻塞实例的代码。所以我的做法是:
- 脚本保持短小精悍,一个脚本只做一件事
- 脚本必须经过充分的测试,特别是边界条件
- 脚本里绝对不写不确定次数的循环
- 所有脚本统一管理,做好版本控制和文档
WATCH 方案我现在基本只在"读多写少、冲突极低"的场景用,比如配置更新。高并发的核心链路,一律上 Lua。
最后说一个我踩过的坑:别在 Lua 脚本里做太重的计算。有一次我在脚本里对一个几千元素的列表做了排序,结果单次执行要几十毫秒,高峰期直接把实例的响应时间拉高了。脚本应该只做必要的原子操作,重计算放到应用层做。