文章目录
- Redis 事务:原子性与脚本执行机制
- 一、前言
- 二、Redis 事务的基本概念
- 2.1 为什么需要 Redis 事务
- 2.2 Redis 事务的三个命令
- 三、事务的完整流程
- 3.1 基本使用示例
- 3.2 放弃事务
- 四、事务中的错误处理
- 4.1 第一类错误:命令语法错误(编译期错误)
- 4.2 第二类错误:命令运行时错误(执行期错误)
- 4.3 Redis 事务的"弱原子性"
- 五、WATCH 命令与乐观锁
- 5.1 事务存在的竞态问题
- 5.2 WATCH 命令
- 5.3 基于 WATCH 实现乐观锁的完整示例
- 5.4 UNWATCH 命令
- 六、Lua 脚本
- 6.1 Redis 事务的局限性
- 6.2 Lua 脚本的核心优势
- 6.3 EVAL 命令语法
- 6.4 使用示例
- 6.5 SCRIPT LOAD 与 EVALSHA
- 6.6 Lua 脚本的注意事项
- 七、事务 vs Lua 脚本
- 八、总结
Redis 事务:原子性与脚本执行机制
一、前言
💬这一篇讲什么:Redis 的事务机制与 Lua 脚本
🚀核心内容:
- Redis 事务的基本流程:MULTI / EXEC / DISCARD
- 事务中出现错误怎么处理?Redis 事务的"弱原子性"是什么意思?
- WATCH 命令如何实现乐观锁?
- Redis 事务的局限性是什么?
- 为什么 Lua 脚本能弥补事务的不足?Lua 脚本的使用方式
上一篇讲完了 Redis 持久化,这一篇来看 Redis 的事务机制。很多同学从 MySQL 事务的认知出发去理解 Redis 事务,会踩不少坑——Redis 事务和 MySQL 事务差别很大,它并不支持回滚。搞清楚这些差异,才能正确地在业务中使用 Redis 事务。
二、Redis 事务的基本概念
2.1 为什么需要 Redis 事务
Redis 是单线程处理命令的,单条命令的执行是原子的。但很多业务场景需要把多条命令组合在一起原子地执行,中间不能被其他客户端的命令打断,这就是 Redis 事务要解决的问题。
经典例子:转账操作需要同时减少 A 的余额、增加 B 的余额,这两步要么都成功,要么都不执行。
2.2 Redis 事务的三个命令
Redis 事务用三个命令控制:
| 命令 | 作用 |
|---|---|
MULTI | 开启事务,进入事务模式 |
EXEC | 提交事务,顺序执行事务中排队的所有命令 |
DISCARD | 丢弃事务,清空命令队列,退出事务模式(相当于"回滚") |
三、事务的完整流程
3.1 基本使用示例
127.0.0.1:6379>MULTI# 开启事务OK127.0.0.1:6379>SET k1 v1# 命令进入队列(不立即执行)QUEUED127.0.0.1:6379>SET k2 v2 QUEUED127.0.0.1:6379>INCR counter QUEUED127.0.0.1:6379>EXEC# 提交事务,顺序执行所有命令1)OK2)OK3)(integer)1执行MULTI后,Redis 会返回QUEUED而不是立即执行命令,所有命令都放入一个命令队列中。调用EXEC后,Redis 顺序执行队列中的所有命令并返回结果数组。
3.2 放弃事务
执行DISCARD可以放弃当前事务,清空命令队列:
127.0.0.1:6379>MULTI OK127.0.0.1:6379>SET k1 v1 QUEUED127.0.0.1:6379>SET k2 v2 QUEUED127.0.0.1:6379>DISCARD# 放弃事务OK# 事务中的命令全部取消,k1 和 k2 均未被修改四、事务中的错误处理
这是理解 Redis 事务最重要的部分。Redis 事务对错误的处理方式与 MySQL 完全不同,且两类错误的处理方式也不一样。
4.1 第一类错误:命令语法错误(编译期错误)
如果在 MULTI 和 EXEC 之间输入了一条语法错误的命令(比如命令名写错了),Redis 会立即返回错误,并在执行 EXEC 时放弃整个事务:
127.0.0.1:6379>MULTI OK127.0.0.1:6379>SET k1 v1 QUEUED127.0.0.1:6379>SETXXX k2 v2# 不存在的命令,语法错误(error)ERR unknowncommand'SETXXX'127.0.0.1:6379>SET k3 v3 QUEUED127.0.0.1:6379>EXEC(error)EXECABORT Transaction discarded because of previous errors.# 整个事务被放弃,k1 和 k3 均未被修改这类错误,整个事务不执行。表现和我们预期的"事务回滚"接近。
4.2 第二类错误:命令运行时错误(执行期错误)
如果命令语法正确,但执行时因为数据类型不匹配等原因失败,Redis 会继续执行其他命令,不会回滚:
127.0.0.1:6379>SET mystr"hello"# mystr 是字符串类型OK127.0.0.1:6379>MULTI OK127.0.0.1:6379>SET k1 v1 QUEUED127.0.0.1:6379>INCR mystr# 对字符串执行自增,运行时才会报错QUEUED127.0.0.1:6379>SET k2 v2 QUEUED127.0.0.1:6379>EXEC1)OK# SET k1 v1 执行成功2)(error)ERR value...# INCR mystr 执行失败,但不影响其他命令3)OK# SET k2 v2 执行成功这类错误,只有出错的那条命令失败,其他命令继续正常执行。
4.3 Redis 事务的"弱原子性"
从上面两种错误处理可以看出:Redis 事务不支持回滚(Rollback)。
在 MySQL 中,只要事务中任意一步出错,整个事务都会回滚,保证"要么全成功,要么全失败"。Redis 的事务做不到这一点:一旦 EXEC 开始执行,即使中途某条命令失败,其他命令也会继续执行,已经成功的命令不会被撤销。
因此,Redis 事务被称为**“弱原子性”**:
- ✅ 保证了事务中的命令不会被其他客户端的命令穿插打断(隔离性)
- ✅ 保证了命令一定会被顺序执行(有序性)
- ❌ 不保证全部成功或全部失败(不支持回滚)
为什么 Redis 不支持回滚?Redis 官方给出的理由是:运行时错误通常是编程错误(比如把字符串当数字用),正确编写的程序不应该出现这类错误。不支持回滚使得 Redis 的实现更简单,性能更好。
五、WATCH 命令与乐观锁
5.1 事务存在的竞态问题
假设要实现一个功能:只有当账户余额balance >= 100时,才执行扣款操作(减去 100)。用事务写出来可能是:
GET balance# 读取余额,假设返回 200MULTI DECR balance100EXEC问题在于:GET balance和MULTI...EXEC之间存在时间窗口,另一个客户端可能在这个窗口内也读到了 balance=200,也发起了扣款,两个客户端都成功执行,最终 balance 变成了 0,而不是预期的 100。
这就是**Check-Then-Act(先检查后操作)**的竞态条件,普通事务无法解决。
5.2 WATCH 命令
WATCH命令为 Redis 事务提供了乐观锁机制:
WATCH key[key...]WATCH监视一个或多个 key。在 MULTI 之前执行 WATCH,如果在 MULTI 到 EXEC 期间,被监视的 key 被任何其他客户端修改,执行 EXEC 时整个事务会自动失败,返回 nil(而不是命令结果数组)。
# 客户端 A127.0.0.1:6379>WATCH balance# 监视 balanceOK127.0.0.1:6379>GET balance# 读取余额 = 200"200"127.0.0.1:6379>MULTI OK127.0.0.1:6379>DECRBY balance100QUEUED# === 此时客户端 B 修改了 balance ===# 客户端 B: SET balance 50127.0.0.1:6379>EXEC# 回到客户端 A 提交事务(nil)# 返回 nil,事务失败,balance 未被修改5.3 基于 WATCH 实现乐观锁的完整示例
实际业务中,配合重试逻辑使用 WATCH:
importredis r=redis.Redis()whileTrue:withr.pipeline()aspipe:try:pipe.watch('balance')# 监视 balancebalance=int(pipe.get('balance'))ifbalance<100:print("余额不足,取消操作")pipe.unwatch()# 取消监视breakpipe.multi()# 开启事务pipe.decrby('balance',100)pipe.execute()# 提交事务print("扣款成功")breakexceptredis.WatchError:# 监视的 key 被其他客户端修改,重试print("数据被修改,重试中...")continueWATCH 的工作原理:乐观锁,假设并发冲突不常见。不上锁,只在提交时检查是否有冲突;有冲突就放弃并重试,没冲突就直接提交。性能比悲观锁(每次操作都加锁)好得多,适合冲突概率低的场景。
5.4 UNWATCH 命令
UNWATCH取消当前客户端对所有 key 的监视。执行 EXEC 或 DISCARD 后,WATCH 监视会自动取消,无需手动 UNWATCH。
六、Lua 脚本
6.1 Redis 事务的局限性
虽然 WATCH + 事务可以实现乐观锁,但 Redis 原生事务有一个根本的局限性:无法在事务执行期间根据命令结果来决定下一步操作。
比如:如果 key 不存在就 SET,如果已存在就 INCR。用事务写不了,因为 MULTI 进入队列阶段无法读取命令结果,无法做条件判断。
Lua 脚本完美解决了这个问题。
6.2 Lua 脚本的核心优势
Redis 内置了 Lua 解释器,通过EVAL命令可以执行 Lua 脚本。Lua 脚本在 Redis 中具有以下关键特性:
原子性:Lua 脚本在 Redis 中是原子执行的。执行期间,其他客户端的命令不会被执行,不存在并发问题。这是比原生事务更强的保证。
支持条件判断:Lua 脚本是完整的编程语言,可以在脚本中读取 Redis 数据,然后根据数据决定执行什么操作,原生事务做不到这一点。
减少网络往返:多条命令打包在一个 Lua 脚本中,只需要一次网络请求,比多次 COMMAND 调用效率更高。
6.3 EVAL 命令语法
EVAL script numkeys key[key...]arg[arg...]参数说明:
script:Lua 脚本内容numkeys:传入的 key 数量key [key ...]:传给脚本的 key 名称,在脚本中通过KEYS[1]、KEYS[2]访问(下标从 1 开始)arg [arg ...]:传给脚本的额外参数,在脚本中通过ARGV[1]、ARGV[2]访问
在 Lua 脚本中调用 Redis 命令的方式:
redis.call('命令名',参数1,参数2,...)redis.pcall('命令名',参数1,参数2,...)-- 出错时不抛异常,返回错误信息6.4 使用示例
示例一:基本使用
# 设置一个 key,值为传入的参数127.0.0.1:6379>EVAL"return redis.call('SET', KEYS[1], ARGV[1])"1mykey hello OK127.0.0.1:6379>GET mykey"hello"示例二:条件判断(事务做不到的事)
# 如果 key 不存在则设置值,如果已存在则自增# 这在原生事务中无法实现,但 Lua 脚本可以轻松做到127.0.0.1:6379>EVAL" if redis.call('EXISTS', KEYS[1]) == 0 then return redis.call('SET', KEYS[1], ARGV[1]) else return redis.call('INCR', KEYS[1]) end "1counter100OK127.0.0.1:6379>EVAL" if redis.call('EXISTS', KEYS[1]) == 0 then return redis.call('SET', KEYS[1], ARGV[1]) else return redis.call('INCR', KEYS[1]) end "1counter100(integer)101示例三:用 Lua 实现原子性的库存扣减
127.0.0.1:6379>EVAL" local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil or stock <= 0 then return 0 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1 "1product:1001:stock1(integer)1-- 返回1表示扣减成功,返回0表示库存不足6.5 SCRIPT LOAD 与 EVALSHA
如果一个 Lua 脚本需要频繁执行,每次都传输完整的脚本内容会浪费带宽。可以先加载脚本,得到一个 SHA1 摘要,后续用摘要来执行:
# 加载脚本到 Redis,返回 SHA1 摘要127.0.0.1:6379>SCRIPT LOAD"return redis.call('GET', KEYS[1])""e0e1f9fabfa9d353e4f3df77bf9d21d32dbb72b5"# 用 SHA1 执行脚本(传输数据量更小)127.0.0.1:6379>EVALSHA e0e1f9fabfa9d353e4f3df77bf9d21d32dbb72b51mykey"hello"# 检查某个脚本是否已加载127.0.0.1:6379>SCRIPT EXISTS e0e1f9fabfa9d353e4f3df77bf9d21d32dbb72b51)(integer)1# 清空所有已缓存的脚本127.0.0.1:6379>SCRIPT FLUSH OK6.6 Lua 脚本的注意事项
不要在 Lua 脚本中执行耗时操作。Lua 脚本执行期间,Redis 主线程被阻塞,其他所有客户端命令都要等待。如果脚本执行了耗时的循环计算,会严重影响 Redis 的响应时间。
Redis 会超时强制终止 Lua 脚本。通过lua-time-limit配置(默认 5000ms),超过此时间还未完成的脚本会被强制停止。
Lua 脚本在 Redis 集群中有限制。脚本中涉及的所有 key 必须在同一个 slot(哈希槽)中,否则会执行失败。在集群模式下,建议使用 Hash Tags 把相关 key 固定到同一个 slot。
七、事务 vs Lua 脚本
| 对比维度 | Redis 事务 | Lua 脚本 |
|---|---|---|
| 原子性 | 弱(不支持回滚) | 强(执行期间完全阻塞其他命令) |
| 支持条件判断 | ❌ 不支持 | ✅ 支持完整的编程逻辑 |
| 错误处理 | 运行时错误不回滚 | 可用 pcall 捕获错误 |
| 并发安全 | 需要配合 WATCH 实现乐观锁 | 天然原子,无需额外处理 |
| 网络开销 | 多次命令 + MULTI/EXEC | 一次请求 |
| 适用场景 | 简单的多命令原子批量执行 | 复杂的需要条件判断的原子操作 |
结论:
- 简单的多命令批量执行,且不需要条件判断 →事务(MULTI/EXEC)
- 需要在 Redis 端做条件判断、逻辑运算 →Lua 脚本
- 需要乐观锁保护共享数据 →WATCH + 事务,或直接用Lua 脚本
八、总结
✅事务三件套:MULTI开启 → 命令入队列 →EXEC提交 /DISCARD放弃
✅两类错误不同处理:语法错误 → 整个事务放弃;运行时错误 → 仅该命令失败,其他命令继续执行
✅弱原子性:Redis 事务不支持回滚,这是与 MySQL 事务最大的区别
✅WATCH 乐观锁:监视 key,如果在 MULTI~EXEC 期间被其他客户端修改,EXEC 自动失败
✅Lua 脚本:原子执行,支持条件判断,弥补了原生事务的最大局限
✅EVALSHA:预加载脚本 + SHA1 摘要执行,减少重复传输开销