作者:没有四次元口袋的蓝胖
日期:2026-10-14
标签:Redis, 事务, 发布订阅, WATCH
Redis知识梳理(4)
写在前面
Redis 是单线程模型,但业务中我们经常要执行"多个操作"。怎么保证这几个操作的原子性 和一致性?这就是 Redis 事务要解决的问题。
另外,Redis 还提供了**发布/订阅(Pub/Sub)**机制,让多个客户端之间进行消息广播。虽然不是专业 MQ,但在很多场景下也能派上用场。
本篇把这两块讲透:
- Redis 事务(MULTI/EXEC)是什么、怎么用、有什么坑
- WATCH 乐观锁机制
- 发布/订阅模型
一、Redis 事务
1.1 什么是 Redis 事务
Redis 事务是一种批量执行多个命令的机制:
- 把多个命令打包
- 按顺序一次性执行
- 执行过程中不会被其他客户端打断
但是,Redis 事务 ≠ 传统数据库事务。这是最大的坑,必须分清。
1.2 核心命令
bash
# 1. 开启事务
MULTI
# OK
# 2. 在事务中排队命令
SET account:a 100
# QUEUED
SET account:b 200
# QUEUED
INCR account:a
# QUEUED
GET account:a
# QUEUED
# 3. 执行事务
EXEC
# 1) OK
# 2) OK
# 3) (integer) 101
# 4) "101"
# 取消事务(放弃所有排队的命令)
DISCARD
事务执行的三个阶段:
1. MULTI → 开启事务,之后命令进入"排队"状态
2. 多个命令 → 全部排队,不立即执行,返回 QUEUED
3. EXEC → 一次性执行所有命令(顺序执行,不可打断)
1.3 事务的特性 vs 传统事务
| 特性 | 传统数据库事务 | Redis 事务 |
|---|---|---|
| 原子性 (Atomicity) | 全部成功或全部回滚 | ❌ 不支持回滚(见下文) |
| 一致性 (Consistency) | 操作前后数据库状态一致 | 部分一致(出错不回滚) |
| 隔离性 (Isolation) | 有锁/MVCC 隔离 | ✅ 单线程执行,天然隔离 |
| 持久性 (Durability) | 写入磁盘持久化 | 取决于持久化配置 |
Redis 事务的"原子性"真相:
bash
# 假设 account 不存在
MULTI
SET account a b # 语法错误,这条会被拒绝
INCR account # 语法正确
EXEC
# 整个事务都会被拒绝,因为命令语法错误
bash
# 假设 account 存的是字符串
MULTI
SET account "abc"
INCR account # 运行时错误(INCR 需要整数)
SET account "def"
EXEC
# 1) OK ← 这条执行了
# (error) ERR value is not an integer ← 这条报错
# 3) OK ← 这条继续执行(不回滚!)
结论:Redis 事务遇到运行时错误不会回滚,只会跳过出错的命令,继续执行后面的。
这是 Redis 事务的"不完全原子性",和 MySQL 的 ACID 完全不同。
🎯 面试高频:Redis 事务能保证 ACID 吗?
答:Redis 事务只有隔离性和一致性 (单线程执行不会被其他客户端打断),但不支持原子性 (失败不回滚)和持久性(依赖持久化策略)。这是面试必考题。
1.4 事务的错误处理
Redis 事务分两种错误:
1. 语法错误(入队时就能检测到的错误):
- 整个事务被拒绝,EXEC 时直接报错
bash
MULTI
SET key value # 正确
GET key # 正确
XXXCMD # 命令不存在 → 整个事务被拒绝
EXEC
(error) EXECABORT Transaction discarded because of previous errors.
2. 运行时错误(入队时检测不到,执行时才报错):
- 出错的那条命令返回错误,其他命令继续执行
- 这就是"事务无法回滚"的根源
1.5 事务与 Lua 脚本
当需要"原子地执行多个操作,并且根据结果决定后续步骤"时,Lua 脚本是更好的选择:
bash
EVAL "
local current = redis.call('GET', KEYS[1])
if tonumber(current) > tonumber(ARGV[1]) then
redis.call('INCRBY', KEYS[1], -ARGV[2])
return 1
end
return 0
" 1 account:balance 100 50
Lua 脚本在 Redis 中整体作为一个命令执行,具有真正的原子性。
| 方式 | 原子性 | 条件判断 | 适用场景 |
|---|---|---|---|
| 事务 | 部分原子(不支持回滚) | 不支持 | 简单批量命令 |
| Lua 脚本 | 完全原子 | 支持 | 复杂业务逻辑 |
二、WATCH 乐观锁
2.1 为什么需要 WATCH?
Redis 事务本身不能解决并发问题。考虑这个场景:
场景:转账
账户 A 余额:100
账户 B 余额:0
客户端1(A转50给B):
1. 读 A 余额 → 100
2. A 余额 -= 50 → 50
3. B 余额 += 50 → 50
客户端2(同时,A转30给B):
1. 读 A 余额 → 100(还没看到客户端1的修改)
2. A 余额 -= 30 → 70
3. B 余额 += 30 → 30
结果:A 只剩 70(应该是20),B 有 30(应该是80),钱凭空消失了!
问题根源: Redis 事务没有"读-写"之间的隔离,客户端在读取余额到修改余额之间,其他客户端可能已经改了数据。
2.2 WATCH 的工作机制
WATCH 是一种乐观锁(CAS,Compare And Swap):
bash
# 客户端1:
WATCH account:a # 监视 account:a
balance = GET account:a # 读取余额(假设=100)
MULTI # 开启事务
DECRBY account:a 50 # 队列化修改
INCRBY account:b 50
EXEC # 执行事务
# 如果 WATCH 的键在 EXEC 之前被其他客户端修改过
# EXEC 会返回 nil,事务执行失败
# 客户端需要重新尝试
WATCH 的流程:
1. WATCH key → 标记监视该键
2. 读取键值 → 记住当前值
3. MULTI → 开始事务
4. 队列化修改命令 → 准备修改
5. EXEC → 提交事务
├─ 监视的键未被其他客户端修改 → 事务成功执行
└─ 监视的键被其他客户端修改 → 事务失败,返回 nil
2.3 完整转账示例
bash
# 转账函数(伪代码)
function transfer(from, to, amount):
while true:
WATCH from
balance = GET from
if balance < amount:
UNWATCH
return "余额不足"
MULTI
DECRBY from amount
INCRBY to amount
result = EXEC
if result is not null:
return "转账成功"
# else:事务失败(因为 from 被其他客户端修改了),重试
Java 客户端示例(Jedis):
java
public boolean transfer(String from, String to, long amount) {
Jedis jedis = jedisPool.getResource();
while (true) {
// 1. 监视转出账户
jedis.watch(from);
// 2. 读取余额
long balance = Long.parseLong(jedis.get(from));
if (balance < amount) {
jedis.unwatch();
return false;
}
// 3. 开启事务
Transaction tx = jedis.multi();
tx.decrBy(from, amount);
tx.incrBy(to, amount);
// 4. 执行事务
List<Object> result = tx.exec();
if (result != null) {
return true; // 成功
}
// 失败:继续重试
}
}
2.4 WATCH 特性总结
| 特性 | 说明 |
|---|---|
| 类型 | 乐观锁(CAS) |
| 监视对象 | 一个或多个 key |
| 触发时机 | EXEC 时检查 key 是否被修改 |
| 修改了怎么办 | EXEC 返回 nil,事务失败 |
| 取消监视 | UNWATCH 或 EXEC 执行后自动解除 |
注意:
- WATCH 在 MULTI 之前执行
- 一旦 EXEC 执行,所有 WATCH 自动解除
- 也可以用 UNWATCH 主动取消所有监视
2.5 WATCH 与分布式锁对比
| 对比 | WATCH 乐观锁 | 分布式锁 |
|---|---|---|
| 思路 | 乐观尝试,失败重试 | 悲观锁定,独占访问 |
| 适用场景 | 并发不激烈 | 高并发、冲突频繁 |
| 性能 | 冲突少时效率高 | 开销较大但可控 |
| 死锁风险 | 无 | 需注意释放 |
三、发布订阅(Pub/Sub)
3.1 什么是发布订阅
Redis 提供了发布/订阅(Publish/Subscribe)消息模型:
- 发布者(Publisher):向某个频道发送消息
- 订阅者(Subscriber):订阅一个或多个频道,接收消息
- 频道(Channel):消息的传输通道
类比:微信群聊------一个人发消息(发布),群里其他人都能收到(订阅)。
3.2 基本命令
bash
# 订阅频道(阻塞等待消息)
SUBSCRIBE channel:news
# 订阅多个频道
SUBSCRIBE channel:news channel:sports channel:tech
# 按模式订阅(通配符)
PSUBSCRIBE channel:*
# 发布消息到频道
PUBLISH channel:news "Redis 7.0 发布了!"
PUBLISH channel:sports "国足赢了!"
# 查看订阅某频道的客户端数量
PUBSUB NUMSUB channel:news
订阅者终端输出:
127.0.0.1:6379> SUBSCRIBE channel:news
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "channel:news"
3) (integer) 1 # 当前订阅的频道数
1) "message" # 消息类型
2) "channel:news" # 频道
3) "Redis 7.0 发布了!" # 消息内容
3.3 Pub/Sub 工作流
Publisher(发布者)
│
│ PUBLISH channel:news "xxx"
▼
┌────────────────────────┐
│ Redis Pub/Sub │
│ 频道:channel:news │
└────┬────┬────┬────────┘
│ │ │
│ │ │ 广播给所有订阅者
▼ ▼ ▼
Sub1 Sub2 Sub3(订阅者)
关键点:
- 消息不持久化,发完就没了
- 订阅者离线期间的消息收不到
- 适合实时性要求高、允许丢消息的场景
3.4 Pub/Sub 适用场景
| 场景 | 说明 |
|---|---|
| 实时通知 | 订单状态变更通知所有相关服务 |
| 配置广播 | 主服务广播配置变更,从服务立即应用 |
| 聊天系统 | 频道聊天、群聊消息 |
| 缓存同步 | 多实例缓存失效通知 |
| 事件驱动架构 | 微服务间事件发布/订阅 |
3.5 Pub/Sub 的局限
致命缺陷:消息不持久化,离线丢失
客户端A 订阅了 channel:news
客户端B 在 10:00 发布消息 "xxx"
客户端A 此时离线
客户端A 在 10:01 重新上线
结果:A 收不到 "xxx" 这条消息
因此,Pub/Sub 不适用于:
- 需要可靠传递的场景(订单、支付)
- 需要回溯历史消息的场景
3.6 Redis Stream(替代方案)
Redis 5.0 引入了 Stream 类型,专门用于解决 Pub/Sub 的局限:
| 特性 | Pub/Sub | Stream |
|---|---|---|
| 消息持久化 | ❌ | ✅ |
| 离线消息 | ❌ | ✅ |
| 消息回溯 | ❌ | ✅ |
| 消费者组 | ❌ | ✅ |
| ACK 机制 | ❌ | ✅ |
Stream 是 Redis 实现的专业消息队列,功能强大,但篇幅较长,后面单独讲。
3.7 Pub/Sub vs 专业 MQ 对比
| 对比 | Redis Pub/Sub | RabbitMQ / Kafka |
|---|---|---|
| 性能 | 快(内存) | 高 |
| 持久化 | ❌ | ✅ |
| 消息回溯 | ❌ | ✅ |
| 消息确认 | ❌ | ✅ |
| 复杂路由 | ❌ | ✅ |
| 适用场景 | 轻量级实时广播 | 企业级消息中间件 |
四、事务、WATCH 与 Pub/Sub 的选择
需要多个命令原子执行?
│
├── 不需要条件判断 → MULTI/EXEC 事务
│
├── 需要条件判断(如检查余额)→ Lua 脚本
│
└── 需要并发保护 → WATCH 乐观锁 + 重试
或 分布式锁
需要多个客户端消息广播?
│
├── 允许消息丢失 → Pub/Sub
│
└── 需要可靠传递 → Stream / RabbitMQ / Kafka
五、常见坑点与面试题
5.1 坑点总结
| 坑 | 描述 | 解决方案 |
|---|---|---|
| 事务不回滚 | 运行时错误不会触发回滚 | 用 Lua 脚本代替 |
| WATCH 重试风暴 | 高并发下事务频繁失败 | 用分布式锁替代 |
| Pub/Sub 丢消息 | 离线订阅者收不到消息 | 用 Stream 或 MQ |
| 事务中的阻塞 | 事务内执行慢命令会阻塞其他客户端 | 控制事务命令数量 |
5.2 面试高频题
Q1:Redis 事务支持 ACID 吗?
答:Redis 事务部分支持:
- ✅ 隔离性:单线程执行,事务期间其他命令排队
- ✅ 一致性:事务前后数据状态一致
- ❌ 原子性:运行时错误不会回滚
- ❌ 持久性:取决于持久化配置
Q2:WATCH 是乐观锁还是悲观锁?
答:乐观锁(CAS)。先不加锁,等到提交时再检查冲突,有冲突就重试。
Q3:Pub/Sub 和 Stream 的区别?
答:Pub/Sub 消息不持久化、不保证送达;Stream 持久化、支持消费者组、消息确认,是专业消息队列。
Q4:如何用 Redis 实现转账?
答:用 WATCH + MULTI/EXEC 乐观锁方案,或者用 Lua 脚本保证原子性。
思维导图速览
事务与发布订阅
│
├── Redis 事务(MULTI/EXEC)
│ ├── 命令排队 + 一次性执行
│ ├── ❌ 不支持回滚(运行时错误继续执行)
│ ├── ✅ 隔离性(单线程)
│ ├── 适用:批量无依赖操作
│ └── 不适合:需要条件判断的场景
│
├── WATCH 乐观锁(CAS)
│ ├── 监视键 → 读值 → MULTI → EXEC
│ ├── 键被修改 → 事务失败(返回 nil)
│ ├── 需要客户端重试
│ ├── 适合:并发不激烈的场景
│ └── 与分布式锁互补
│
├── Lua 脚本
│ ├── 整个脚本原子执行
│ ├── 支持条件判断
│ └── 替代复杂事务
│
└── 发布订阅(Pub/Sub)
├── SUBSCRIBE/PUBLISH
├── 频道订阅 + 实时广播
├── ❌ 消息不持久化,离线丢失
├── 适用:实时通知、配置广播
└── 替代方案:Stream / RabbitMQ
写在最后
Redis 事务、WATCH、Pub/Sub 是三个独立的概念,但都围绕"如何正确处理多客户端并发"这个主题。
三个关键认知:
- Redis 事务 ≠ MySQL 事务------它不支持回滚,运行时错误会跳过继续
- WATCH 是乐观锁------冲突时重试,高并发下可能风暴
- Pub/Sub 不可靠------适合实时广播,需要可靠性用 Stream
这三个在面试中出场率都很高,尤其是"Redis 事务支持 ACID 吗"这个问题,几乎每场面试都会问。