Redis基础:4. 事务

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 在调用 EXECDISCARD自动失效
  • 也可以手动 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 命令几乎不会失败。

官方理由

  1. 性能优先:回滚需要记录命令执行前的状态,开销太大
  2. 设计简单:Redis 追求简单高效,回滚机制会让代码复杂 10 倍
  3. 错误可预见:大部分错误(如类型不匹配)是编程 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+ 还支持函数)

八、 最佳实践与避坑指南

✅ 推荐做法

  1. 用 Lua 脚本代替事务:如果你的 Redis 版本 ≥ 2.6,优先考虑 Lua 脚本

  2. WATCH 时一定要处理失败重试

    python 复制代码
    while True:
        redis.watch(key)
        # 读取数据、做判断
        pipe = redis.pipeline()
        pipe.multi()
        pipe.commands...
        if pipe.execute() is not None:
            break
        # 否则继续循环
  3. 把事务写短:不要在事务里放太多命令,否则长时间阻塞 Redis

  4. 对一致性要求高的场景,不要用 Redis:用关系型数据库或分布式事务框架

❌ 避免踩坑

  1. 不要指望回滚:某个命令失败后,其他命令还会执行
  2. 不要在事务中使用 WATCH 不存在的 keyWATCH 一个不存在的 key 不会报错,但也没啥用
  3. 不要在事务中执行 KEYSFLUSHALL:会阻塞整个 Redis
  4. 不要把 DISCARD 当作回滚:它只是清空命令队列,不能撤销已执行的命令

九、 面试高频题

Q1:Redis 事务支持回滚吗?

A:不支持。事务中某条命令执行失败时,其他命令依然会继续执行。Redis 的设计哲学是:大多数命令失败都是编程错误,应该在开发阶段发现,而不是依赖回滚机制。

Q2:WATCH 的实现原理是什么?

A:WATCH 会在 Redis 服务器端标记被监视的 key。当执行 EXEC 时,Redis 会检查这些 key 在 WATCH 之后是否被修改过。如果被修改,事务失败返回 (nil)。这本质上是一种乐观锁机制。

Q3:MULTIPipeline 有什么区别?

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 永不宕机。

事务有风险,回滚不存在。且用且珍惜,下期见!

相关推荐
zzz_23681 小时前
【Redis】缓存策略与三大经典问题
数据库·redis·缓存
zzz_23681 小时前
【Redis】Redis 数据结构与 Spring Boot 集成
数据结构·spring boot·redis
菠萝猫yena1 小时前
【数据库软件】beekeeper-studio安装方式(Mac)
数据库
Dovis(誓平步青云)1 小时前
《指标中转站:Pushgateway 如何把监控覆盖到这些原本看不见的角落》
数据库·生成对抗网络·oracle·内网穿透·飞牛nas
yurenpai(27届找实习中)1 小时前
Elasticsearch 核心总结 + 面试题实战(黑马点评项目)
redis·es
YJlio1 小时前
OpenClaw v2026.5.26-beta.1 / beta.2 预发布解读:Gateway 加速、transcript 路径统一、多通道修复、语音增强与安装更新链路加固
人工智能·windows·python·ui·缓存·gateway·outlook
IT龟苓膏10 小时前
Redis 数据类型底层原理:SDS、quicklist、intset、skiplist、Bitmap、HyperLogLog 一篇讲清
数据库·redis·skiplist
流星白龙11 小时前
【MySQL高阶】19.变更缓冲区,自适应哈希索引,日志缓冲区
数据库·windows·mysql
晴天¥11 小时前
Oracle中的监听配置与管理(动态、静态监听配置对比以及listener.ora和tnsnames.ora)
数据库·oracle