Redis 事务:不是你想的那种"事务"
别被名字骗了,Redis 的事务和 MySQL 的事务,差了十万八千里
你好,欢迎回来!
如果你刚从关系型数据库转过来,听到 Redis 也有"事务"功能,第一反应可能是:终于可以保证原子性了!
然后你兴致勃勃地写了一段代码:
bash
MULTI
DECRBY wallet:1001 100
INCRBY wallet:1002 100
EXEC
一切顺利,你觉得万事大吉。
直到有一天,wallet:1001 余额不足,但 wallet:1002 却神奇地多了 100 块钱。
你打开 Redis 文档,愣住了:Redis 事务不支持回滚。
今天我们就来彻底搞懂:Redis 的事务到底是什么?能做什么?不能做什么?以及在实际项目中到底该怎么用。
一、 先泼冷水:Redis 事务 ≠ 数据库事务
让我们先建立一个正确的认知:
| 特性 | MySQL 事务 | Redis 事务 |
|---|---|---|
| 原子性 | ✅ 要么全成功,要么全失败(回滚) | ❌ 不支持回滚,某条失败其他继续执行 |
| 一致性 | ✅ 约束检查 | ⚠️ 部分保证(保证命令顺序执行) |
| 隔离性 | ✅ MVCC/锁 | ⚠️ 单线程天然隔离 |
| 持久性 | ✅ WAL 日志 | ⚠️ 依赖持久化配置 |
一句话总结 :Redis 的"事务"只是一组命令的批量执行,中间不会被其他客户端的命令打断,但不保证要么全做、要么全不做。
经典翻车案例
bash
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET name "Tom"
QUEUED
127.0.0.1:6379> INCR name # 这里故意出错,对字符串做自增
QUEUED
127.0.0.1:6379> SET age 18
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) ERR value is not an integer or out of range # 这条失败了
3) OK
结果:name 被设成了 "Tom",age 被设成了 18。中间那条错误的命令只是报错,不会导致回滚。
这能叫事务吗? 严格来说,不能。它更像是一个"命令打包工具"。
二、 事务的核心命令
Redis 事务涉及三个命令,非常简单:
| 命令 | 作用 | 返回值 |
|---|---|---|
MULTI |
开启事务,标记事务块开始 | OK |
EXEC |
执行事务块内的所有命令 | 数组,包含每条命令的返回值 |
DISCARD |
取消事务,清空命令队列 | OK |
还有一个辅助命令:
| WATCH | 乐观锁,监视一个或多个 key | OK |

基本用法
bash
# 1. 开启事务
127.0.0.1:6379> MULTI
OK
# 2. 输入命令(不会立即执行,只是排队)
127.0.0.1:6379> SET user:1 "Tom"
QUEUED
127.0.0.1:6379> SET user:2 "Jerry"
QUEUED
127.0.0.1:6379> INCR counter
QUEUED
# 3. 执行事务
127.0.0.1:6379> EXEC
1) OK
2) OK
3) (integer) 1
取消事务
bash
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET name "Tom"
QUEUED
127.0.0.1:6379> DISCARD # 放弃,什么都不做
OK
127.0.0.1:6379> GET name # name 没有被设置
(nil)
三、 Redis 事务的特性详解
3.1 原子性?No,是"隔离性"
Redis 事务保证了命令的隔离执行------即事务中的命令会按顺序执行,中间不会插入其他客户端的命令。
原因 :Redis 是单线程 的。当执行 EXEC 时,Redis 会锁住自己,一口气执行完队列里的所有命令,然后才处理下一个请求。
这就意味着:事务中的命令是串行且连续的,不会被别的客户端打断。
但是:如果事务中某条命令执行失败(比如语法错误或类型错误),其他命令依然会执行完毕。Redis 不会回滚。
3.2 两种失败情况
| 失败类型 | 发生时机 | 后果 | 示例 |
|---|---|---|---|
| 语法错误 | QUEUED 阶段 |
整个事务被拒绝,EXEC 失败 |
SET name(缺少参数) |
| 运行时错误 | EXEC 执行阶段 |
只报错那条,其他继续执行 | INCR name(name 是字符串) |
bash
# 语法错误(在 QUEUED 时就能发现)
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET name
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379> SET age 18
QUEUED
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
# 整个事务被丢弃,age 也没有被设置
bash
# 运行时错误(只有 EXEC 时才发现)
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET name "Tom"
QUEUED
127.0.0.1:6379> INCR name
QUEUED
127.0.0.1:6379> SET age 18
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) ERR value is not an integer or out of range # 只有这条失败
3) OK
# name 和 age 都被设置了!
所以:千万别指望 Redis 事务能像数据库事务那样"全部成功或全部失败"。
四、 乐观锁 WATCH:Redis 真正的并发控制
既然 Redis 事务不支持回滚,那怎么解决并发修改的问题?
比如:两个人同时抢最后一个库存。
场景:
bash
# 用户 A 和用户 B 同时执行:
# 1. 检查库存
stock = GET product:stock # 假设是 1
# 2. 如果 stock > 0,扣减
DECR product:stock
如果两人同时检查到库存为 1,都执行了 DECR,库存就变成了 -1。这明显不对。
解决方案:WATCH + MULTI + EXEC
WATCH 命令实现了乐观锁 。它会监视一个或多个 key,如果在执行 EXEC 时发现这些 key 被其他客户端修改了,事务就会失败。
bash
# 正确的秒杀代码
127.0.0.1:6379> WATCH product:stock # 监视库存
OK
127.0.0.1:6379> GET product:stock
"1"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECR product:stock
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 0 # 成功了,扣减到 0
如果另一个客户端在 WATCH 之后、EXEC 之前修改了 product:stock:
bash
# 用户 A 的视角
127.0.0.1:6379> WATCH product:stock
OK
127.0.0.1:6379> GET product:stock
"1"
# 就在这个时候,用户 B 执行了 DECR product:stock,库存变成了 0
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECR product:stock
QUEUED
127.0.0.1:6379> EXEC
(nil) # 返回 nil,表示事务失败!因为 WATCH 的 key 被改过了
核心逻辑:
EXEC返回(nil)表示事务执行失败,需要重试(重新 WATCH、重新 GET、重新判断)- 这就像乐观锁的版本号机制,只不过 Redis 帮你自动检查了
WATCH 的生命周期
WATCH在调用EXEC或DISCARD后自动失效- 也可以手动
UNWATCH取消监视
bash
127.0.0.1:6379> WATCH key1 key2
OK
127.0.0.1:6379> UNWATCH # 取消对所有 key 的监视
OK
五、 实际应用场景
5.1 秒杀/抢购(WATCH 实现乐观锁)
python
def seckill(user_id, product_id):
while True:
redis.watch(f"stock:{product_id}")
stock = redis.get(f"stock:{product_id}")
if stock <= 0:
redis.unwatch()
return "已售罄"
# 开始事务
pipeline = redis.pipeline()
pipeline.multi()
pipeline.decr(f"stock:{product_id}")
pipeline.sadd(f"buyers:{product_id}", user_id)
# 执行事务
result = pipeline.execute()
# 如果返回 None,说明有并发修改,重试
if result is not None:
return "抢购成功"
# 否则继续循环重试
5.2 转账(注意:Redis 事务不能回滚!)
bash
# 危险的转账代码
WATCH account:1001 account:1002
balance1 = GET account:1001
if balance1 < 100:
UNWATCH
return "余额不足"
MULTI
DECRBY account:1001 100
INCRBY account:1002 100
EXEC
# 问题:如果 DECRBY 成功但 INCRBY 失败(比如 key 类型错误),
# 100 块钱就凭空蒸发了!
结论 :涉及金额的强一致性场景,不要用 Redis 事务,老老实实用数据库。Redis 事务适合那些可以容忍重试、逻辑简单的场景。
5.3 批量执行,保证中间不被插入
bash
# 场景:需要同时更新多个 key,且中间不能让其他请求读取到中间状态
MULTI
SET config:rate 1000
SET config:limit 5000
SET config:window 60
EXEC
# 保证这三个设置要么都没生效,要么全部生效(在同一个瞬间完成)
虽然没有回滚,但至少保证了这些更新对外可见是原子的------其他客户端要么看到旧的三个值,要么看到新的三个值,不会看到新旧混合的状态。
六、 为什么不支持回滚?
这是 Redis 官方文档里明确写的设计决策:
Redis 命令只会因为语法错误 或数据类型错误而失败,而这些错误应该在开发阶段就被发现。在生产环境中,Redis 命令几乎不会失败。
官方理由:
- 性能优先:回滚需要记录命令执行前的状态,开销太大
- 设计简单:Redis 追求简单高效,回滚机制会让代码复杂 10 倍
- 错误可预见:大部分错误(如类型不匹配)是编程 bug,不应该用事务机制来掩盖
个人观点:这个设计是合理的。如果你需要强 ACID 事务,Redis 不是正确答案,PostgreSQL 才是。
七、 Redis 事务 vs Lua 脚本
Redis 从 2.6 版本开始支持 Lua 脚本。Lua 脚本可以替代事务,而且更强大。
| 特性 | MULTI/EXEC | Lua 脚本 |
|---|---|---|
| 原子性 | 隔离执行,不回滚 | 整个脚本原子执行,不回滚 |
| 条件判断 | 需要 WATCH + 重试 | 脚本内直接写 if/else |
| 网络开销 | 多次往返(MULTI, 命令, EXEC) | 一次发送,一次返回 |
| 复杂度 | 适合简单排队 | 适合复杂逻辑 |
| 返回值 | 数组 | 脚本自定义 |
Lua 脚本示例
lua
-- 秒杀脚本:检查库存 + 扣减 + 记录用户
local stock = redis.call('get', KEYS[1])
if tonumber(stock) <= 0 then
return 0
end
redis.call('decr', KEYS[1])
redis.call('sadd', KEYS[2], ARGV[1])
return 1
bash
# 调用脚本
EVAL "local stock = redis.call('get', KEYS[1]); if tonumber(stock) <= 0 then return 0 end; redis.call('decr', KEYS[1]); redis.call('sadd', KEYS[2], ARGV[1]); return 1" 2 stock:1001 buyers:1001 "user123"
结论:
- 简单的命令排队:用
MULTI/EXEC - 复杂的条件逻辑:用 Lua 脚本(Redis 7.0+ 还支持函数)
八、 最佳实践与避坑指南
✅ 推荐做法
-
用 Lua 脚本代替事务:如果你的 Redis 版本 ≥ 2.6,优先考虑 Lua 脚本
-
WATCH 时一定要处理失败重试 :
pythonwhile True: redis.watch(key) # 读取数据、做判断 pipe = redis.pipeline() pipe.multi() pipe.commands... if pipe.execute() is not None: break # 否则继续循环 -
把事务写短:不要在事务里放太多命令,否则长时间阻塞 Redis
-
对一致性要求高的场景,不要用 Redis:用关系型数据库或分布式事务框架
❌ 避免踩坑
- 不要指望回滚:某个命令失败后,其他命令还会执行
- 不要在事务中使用
WATCH不存在的 key :WATCH一个不存在的 key 不会报错,但也没啥用 - 不要在事务中执行
KEYS、FLUSHALL:会阻塞整个 Redis - 不要把
DISCARD当作回滚:它只是清空命令队列,不能撤销已执行的命令
九、 面试高频题
Q1:Redis 事务支持回滚吗?
A:不支持。事务中某条命令执行失败时,其他命令依然会继续执行。Redis 的设计哲学是:大多数命令失败都是编程错误,应该在开发阶段发现,而不是依赖回滚机制。
Q2:WATCH 的实现原理是什么?
A:WATCH 会在 Redis 服务器端标记被监视的 key。当执行 EXEC 时,Redis 会检查这些 key 在 WATCH 之后是否被修改过。如果被修改,事务失败返回 (nil)。这本质上是一种乐观锁机制。
Q3:MULTI 和 Pipeline 有什么区别?
A:
MULTI+EXEC保证命令的原子执行(不被其他命令打断)Pipeline只是批量发送命令,减少网络开销,但命令之间可能被其他客户端的命令插入- 两者可以结合使用:
Pipeline发送MULTI、命令、EXEC
Q4:如何用 Redis 实现秒杀?
A:用 WATCH + MULTI + EXEC 实现乐观锁,或者用 Lua 脚本实现。关键在于:检查库存和扣减库存必须是原子操作,且要处理并发冲突(重试)。
十、 总结
Redis 的事务是一把刻度不准的尺子------你要理解它的刻度,才能在合适的场合使用它。
| 你的需求 | 推荐方案 |
|---|---|
| 保证一组命令连续执行,不被插入 | MULTI/EXEC |
| 解决并发修改冲突(如秒杀) | WATCH + MULTI + 重试 |
| 复杂的条件逻辑(如 if 判断) | Lua 脚本 |
| 强一致性、需要回滚 | 别用 Redis,用 MySQL |
| 减少网络往返,不关心隔离 | Pipeline |
最后的忠告:
- 如果你习惯 MySQL 的事务,忘掉它,Redis 的事务是另一个物种
- 如果你真的需要强 ACID,选错工具比用错方法更致命
- 在 80% 的场景下,Lua 脚本比原生事务更合适
下一期预告:Redis 主从复制与哨兵模式------从单机到高可用。我们来聊聊怎么让 Redis 永不宕机。
事务有风险,回滚不存在。且用且珍惜,下期见!