文章目录
-
- [Redis 事务:原子性与脚本执行机制](#Redis 事务:原子性与脚本执行机制)
- 一、前言
- [二、Redis 事务的基本概念](#二、Redis 事务的基本概念)
-
- [2.1 为什么需要 Redis 事务](#2.1 为什么需要 Redis 事务)
- [2.2 Redis 事务的三个命令](#2.2 Redis 事务的三个命令)
- 三、事务的完整流程
-
- [3.1 基本使用示例](#3.1 基本使用示例)
- [3.2 放弃事务](#3.2 放弃事务)
- 四、事务中的错误处理
-
- [4.1 第一类错误:命令语法错误(编译期错误)](#4.1 第一类错误:命令语法错误(编译期错误))
- [4.2 第二类错误:命令运行时错误(执行期错误)](#4.2 第二类错误:命令运行时错误(执行期错误))
- [4.3 Redis 事务的"弱原子性"](#4.3 Redis 事务的"弱原子性")
- [五、WATCH 命令与乐观锁](#五、WATCH 命令与乐观锁)
-
- [5.1 事务存在的竞态问题](#5.1 事务存在的竞态问题)
- [5.2 WATCH 命令](#5.2 WATCH 命令)
- [5.3 基于 WATCH 实现乐观锁的完整示例](#5.3 基于 WATCH 实现乐观锁的完整示例)
- [5.4 UNWATCH 命令](#5.4 UNWATCH 命令)
- [六、Lua 脚本](#六、Lua 脚本)
-
- [6.1 Redis 事务的局限性](#6.1 Redis 事务的局限性)
- [6.2 Lua 脚本的核心优势](#6.2 Lua 脚本的核心优势)
- [6.3 EVAL 命令语法](#6.3 EVAL 命令语法)
- [6.4 使用示例](#6.4 使用示例)
- [6.5 SCRIPT LOAD 与 EVALSHA](#6.5 SCRIPT LOAD 与 EVALSHA)
- [6.6 Lua 脚本的注意事项](#6.6 Lua 脚本的注意事项)
- [七、事务 vs 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 基本使用示例
bash
127.0.0.1:6379> MULTI # 开启事务
OK
127.0.0.1:6379> SET k1 v1 # 命令进入队列(不立即执行)
QUEUED
127.0.0.1:6379> SET k2 v2
QUEUED
127.0.0.1:6379> INCR counter
QUEUED
127.0.0.1:6379> EXEC # 提交事务,顺序执行所有命令
1) OK
2) OK
3) (integer) 1
执行 MULTI 后,Redis 会返回 QUEUED 而不是立即执行命令,所有命令都放入一个命令队列中。调用 EXEC 后,Redis 顺序执行队列中的所有命令并返回结果数组。
3.2 放弃事务
执行 DISCARD 可以放弃当前事务,清空命令队列:
bash
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 v1
QUEUED
127.0.0.1:6379> SET k2 v2
QUEUED
127.0.0.1:6379> DISCARD # 放弃事务
OK
# 事务中的命令全部取消,k1 和 k2 均未被修改
四、事务中的错误处理
这是理解 Redis 事务最重要的部分。Redis 事务对错误的处理方式与 MySQL 完全不同,且两类错误的处理方式也不一样。
4.1 第一类错误:命令语法错误(编译期错误)
如果在 MULTI 和 EXEC 之间输入了一条语法错误 的命令(比如命令名写错了),Redis 会立即返回错误,并在执行 EXEC 时放弃整个事务:
bash
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 v1
QUEUED
127.0.0.1:6379> SETXXX k2 v2 # 不存在的命令,语法错误
(error) ERR unknown command 'SETXXX'
127.0.0.1:6379> SET k3 v3
QUEUED
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
# 整个事务被放弃,k1 和 k3 均未被修改
这类错误,整个事务不执行。 表现和我们预期的"事务回滚"接近。
4.2 第二类错误:命令运行时错误(执行期错误)
如果命令语法正确,但执行时 因为数据类型不匹配等原因失败,Redis 会继续执行其他命令,不会回滚:
bash
127.0.0.1:6379> SET mystr "hello" # mystr 是字符串类型
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 v1
QUEUED
127.0.0.1:6379> INCR mystr # 对字符串执行自增,运行时才会报错
QUEUED
127.0.0.1:6379> SET k2 v2
QUEUED
127.0.0.1:6379> EXEC
1) 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)。用事务写出来可能是:
bash
GET balance # 读取余额,假设返回 200
MULTI
DECR balance 100
EXEC
问题在于:GET balance 和 MULTI...EXEC 之间存在时间窗口,另一个客户端可能在这个窗口内也读到了 balance=200,也发起了扣款,两个客户端都成功执行,最终 balance 变成了 0,而不是预期的 100。
这就是**Check-Then-Act(先检查后操作)**的竞态条件,普通事务无法解决。
5.2 WATCH 命令
WATCH 命令为 Redis 事务提供了乐观锁机制:
bash
WATCH key [key ...]
WATCH 监视一个或多个 key。在 MULTI 之前执行 WATCH,如果在 MULTI 到 EXEC 期间,被监视的 key 被任何其他客户端修改,执行 EXEC 时整个事务会自动失败,返回 nil(而不是命令结果数组)。
bash
# 客户端 A
127.0.0.1:6379> WATCH balance # 监视 balance
OK
127.0.0.1:6379> GET balance # 读取余额 = 200
"200"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECRBY balance 100
QUEUED
# === 此时客户端 B 修改了 balance ===
# 客户端 B: SET balance 50
127.0.0.1:6379> EXEC # 回到客户端 A 提交事务
(nil) # 返回 nil,事务失败,balance 未被修改
5.3 基于 WATCH 实现乐观锁的完整示例
实际业务中,配合重试逻辑使用 WATCH:
python
import redis
r = redis.Redis()
while True:
with r.pipeline() as pipe:
try:
pipe.watch('balance') # 监视 balance
balance = int(pipe.get('balance'))
if balance < 100:
print("余额不足,取消操作")
pipe.unwatch() # 取消监视
break
pipe.multi() # 开启事务
pipe.decrby('balance', 100)
pipe.execute() # 提交事务
print("扣款成功")
break
except redis.WatchError:
# 监视的 key 被其他客户端修改,重试
print("数据被修改,重试中...")
continue
WATCH 的工作原理:乐观锁,假设并发冲突不常见。不上锁,只在提交时检查是否有冲突;有冲突就放弃并重试,没冲突就直接提交。性能比悲观锁(每次操作都加锁)好得多,适合冲突概率低的场景。
5.4 UNWATCH 命令
bash
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 命令语法
bash
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 命令的方式:
lua
redis.call('命令名', 参数1, 参数2, ...)
redis.pcall('命令名', 参数1, 参数2, ...) -- 出错时不抛异常,返回错误信息
6.4 使用示例
示例一:基本使用
bash
# 设置一个 key,值为传入的参数
127.0.0.1:6379> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey hello
OK
127.0.0.1:6379> GET mykey
"hello"
示例二:条件判断(事务做不到的事)
bash
# 如果 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
" 1 counter 100
OK
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
" 1 counter 100
(integer) 101
示例三:用 Lua 实现原子性的库存扣减
bash
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
" 1 product:1001:stock 1
(integer) 1 -- 返回 1 表示扣减成功,返回 0 表示库存不足
6.5 SCRIPT LOAD 与 EVALSHA
如果一个 Lua 脚本需要频繁执行,每次都传输完整的脚本内容会浪费带宽。可以先加载脚本,得到一个 SHA1 摘要,后续用摘要来执行:
bash
# 加载脚本到 Redis,返回 SHA1 摘要
127.0.0.1:6379> SCRIPT LOAD "return redis.call('GET', KEYS[1])"
"e0e1f9fabfa9d353e4f3df77bf9d21d32dbb72b5"
# 用 SHA1 执行脚本(传输数据量更小)
127.0.0.1:6379> EVALSHA e0e1f9fabfa9d353e4f3df77bf9d21d32dbb72b5 1 mykey
"hello"
# 检查某个脚本是否已加载
127.0.0.1:6379> SCRIPT EXISTS e0e1f9fabfa9d353e4f3df77bf9d21d32dbb72b5
1) (integer) 1
# 清空所有已缓存的脚本
127.0.0.1:6379> SCRIPT FLUSH
OK
6.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 摘要执行,减少重复传输开销