强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

07 - 事务协议

事务协议

7.1 Redis 事务概述

Redis 事务是一组命令的打包执行。与关系数据库事务不同,Redis 事务不支持回滚——它提供的是"原子性"(所有命令要么全部执行,要么全部不执行),而非"事务性"(ACID)。

事务生命周期

普通模式 → MULTI → 命令排队 → EXEC/DISCARD → 普通模式
阶段行为
MULTI进入事务模式,后续命令排队而非立即执行
命令排队命令被缓存,返回 QUEUED
EXEC执行所有排队的命令,返回结果数组
DISCARD取消事务,丢弃所有排队的命令
WATCH在事务开始前监视 key,实现乐观锁

7.2 MULTI 命令

命令格式

*1\r\n$5\r\nMULTI\r\n

响应

+QUEUED\r\n

MULTI 之后,服务器进入事务模式。后续命令不再立即执行,而是排队等待。

事务模式下的命令处理

$ telnet 127.0.0.1 6379

*1\r\n$5\r\nMULTI\r\n
+QUEUED

*3\r\n$3\r\nSET\r\n$4\r\nkey1\r\n$6\r\nvalue1\r\n
+QUEUED

*3\r\n$3\r\nSET\r\n$4\r\nkey2\r\n$6\r\nvalue2\r\n
+QUEUED

*2\r\n$3\r\nGET\r\n$4\r\nkey1\r\n
+QUEUED

每个命令返回 +QUEUED,表示已排队。


7.3 EXEC 命令

命令格式

*1\r\n$4\r\nEXEC\r\n

响应格式

EXEC 返回一个数组,包含所有排队命令的执行结果:

*3\r\n
+OK\r\n           ← SET key1 value1 的结果
+OK\r\n           ← SET key2 value2 的结果
$6\r\nvalue1\r\n  ← GET key1 的结果

完整事务示例

→ MULTI
← +OK

→ SET account:a 100
← +QUEUED

→ SET account:b 200
← +QUEUED

→ DECRBY account:a 30
← +QUEUED

→ INCRBY account:b 30
← +QUEUED

→ EXEC
← *4
← +OK
← +OK
← :70
← :230

7.4 DISCARD 命令

命令格式

*1\r\n$7\r\nDISCARD\r\n

响应

+OK\r\n

DISCARD 取消当前事务,丢弃所有排队的命令:

→ MULTI
← +OK

→ SET key1 value1
← +QUEUED

→ SET key2 value2
← +QUEUED

→ DISCARD
← +OK

→ GET key1
← ... (key1 未被修改)

7.5 事务中的错误处理

Redis 事务中有两种类型的错误:

类型一:命令排队阶段的错误

在 MULTI 和 EXEC 之间,如果命令格式错误或不存在,Redis 会在排队阶段就报错:

→ MULTI
← +OK

→ SET key1 value1
← +QUEUED

→ INVALID_COMMAND
← -ERR unknown command 'INVALID_COMMAND'

→ SET key2 value2
← +QUEUED

→ EXEC
← -EXECABORT Transaction discarded because of previous errors.

行为:EXEC 返回错误,整个事务被丢弃(不执行任何命令)。

类型二:命令执行阶段的错误

如果命令在排队阶段成功,但在执行阶段失败(如类型错误),其他命令仍然会执行:

→ MULTI
← +OK

→ SET mykey "hello"
← +QUEUED

→ LLEN mykey          ← 排队成功,但执行时 mykey 不是列表
← +QUEUED

→ SET anotherkey "world"
← +QUEUED

→ EXEC
← *3
← +OK                                 ← SET 成功
← -WRONGTYPE Operation against a key  ← LLEN 失败
← +OK                                 ← SET 成功(仍然执行!)

行为:其他命令不受影响,继续执行。Redis 事务不支持回滚。

为什么不支持回滚?

Redis 设计者认为:

  1. 回滚增加了复杂性,但 Redis 命令很少失败
  2. 命令失败通常是编程错误(如类型不匹配),而非运行时错误
  3. 不支持回滚让 Redis 更简单、更快

7.6 WATCH 命令(乐观锁)

什么是乐观锁

乐观锁假设"冲突很少发生",在提交事务时检查数据是否被修改:

┌─────────────────────────────────────────────┐
│ 1. WATCH key                                │ ← 监视 key
│ 2. 读取 key 的值                            │ ← 获取当前状态
│ 3. 计算新值                                  │ ← 本地计算
│ 4. MULTI                                     │ ← 开始事务
│ 5. SET key new_value                         │ ← 排队写入
│ 6. EXEC                                      │ ← 执行事务
│    └─ 如果 key 在 WATCH 后被修改 → 返回 NULL │ ← 检查失败
│    └─ 如果 key 未被修改 → 正常执行           │ ← 检查成功
└─────────────────────────────────────────────┘

WATCH 命令格式

*2\r\n$5\r\nWATCH\r\n$3\r\nkey\r\n

响应

+OK\r\n

EXEC 的特殊返回值

当 WATCH 检测到 key 被修改时,EXEC 返回 NULL 而非数组:

# 正常执行
→ EXEC
← *2        ← 数组,包含两个结果
← +OK
← +OK

# 被取消(WATCH 检测到冲突)
→ EXEC
*-1         ← NULL,事务未执行

7.7 WATCH 实战:实现转账

import redis

def transfer(r: redis.Redis, from_key: str, to_key: str, amount: int):
    """
    使用 WATCH 实现乐观锁转账

    原子性保证:如果在事务执行前有任何 key 被修改,事务自动重试
    """
    max_retries = 100

    for attempt in range(max_retries):
        try:
            # 1. 监视相关 key
            r.watch(from_key, to_key)

            # 2. 读取当前余额
            from_balance = int(r.get(from_key) or 0)
            to_balance = int(r.get(to_key) or 0)

            # 3. 检查余额是否充足
            if from_balance < amount:
                r.unwatch()
                raise ValueError(f"Insufficient balance: {from_balance} < {amount}")

            # 4. 开始事务
            pipe = r.pipeline()
            pipe.multi()
            pipe.decrby(from_key, amount)
            pipe.incrby(to_key, amount)

            # 5. 执行事务
            results = pipe.execute()

            # 如果 execute() 没有抛出 WatchError,说明事务成功
            print(f"Transfer successful: {amount} from {from_key} to {to_key}")
            return True

        except redis.exceptions.WatchError:
            # key 被其他客户端修改,重试
            print(f"Attempt {attempt + 1}: Conflict detected, retrying...")
            continue

    raise RuntimeError(f"Transfer failed after {max_retries} attempts")


# 使用
r = redis.Redis(host="127.0.0.1", port=6379)
r.set("account:a", 1000)
r.set("account:b", 500)

transfer(r, "account:a", "account:b", 100)

print(f"account:a = {r.get('account:a')}")  # 900
print(f"account:b = {r.get('account:b')}")  # 600

7.8 WATCH 的协议细节

WATCH 多个 key

*3\r\n$5\r\nWATCH\r\n$5\r\nkey1\r\n$5\r\nkey2\r\n

WATCH 可以监视多个 key,任意一个 key 被修改都会导致 EXEC 失败。

UNWATCH

*1\r\n$7\r\nUNWATCH\r\n

UNWATCH 取消所有 WATCH 监视。DISCARD 也会自动取消监视。

WATCH 的生命周期

事件WATCH 状态
WATCH key开始监视
EXEC 成功自动取消监视
EXEC 失败(冲突)自动取消监视
DISCARD自动取消监视
UNWATCH手动取消监视
连接断开自动取消监视

7.9 事务中的命令限制

在 MULTI 和 EXEC 之间,以下命令有特殊行为:

不允许的命令

命令原因
MULTI不能嵌套事务
WATCHWATCH 必须在 MULTI 之前

会触发展开的命令

某些命令在事务中会立即执行(不会排队):

命令行为
EXEC执行事务并退出事务模式
DISCARD取消事务并退出事务模式
AUTH立即认证(Redis 6.0+)
HELLO立即执行
RESETRedis 7.0+,重置连接

会进入 Pub/Sub 的命令

在事务中执行 SUBSCRIBE 会导致连接进入 Pub/Sub 模式,这通常不是期望的行为。


7.10 完整事务协议流程

Client                              Server
  │                                    │
  │──── WATCH mykey ─────────────────→│  注册监视
  │←─── +OK ─────────────────────────│
  │                                    │
  │──── GET mykey ──────────────────→│  读取当前值
  │←─── $5\r\nhello ────────────────│
  │                                    │
  │──── MULTI ──────────────────────→│  进入事务
  │←─── +OK ─────────────────────────│
  │                                    │
  │──── SET mykey world ────────────→│  排队
  │←─── +QUEUED ─────────────────────│
  │                                    │
  │──── EXEC ───────────────────────→│  执行
  │←─── *1\r\n+OK ─────────────────│  成功(mykey 未被修改)

  ────── 或者(mykey 被其他客户端修改)──────

  │──── EXEC ───────────────────────→│  执行
  │←─── *-1\r\n ────────────────────│  失败(WATCH 冲突)

7.11 WATCH 冲突检测的实现原理

Redis 使用一种高效的机制来检测 WATCH 冲突:

标志位机制

// Redis 源码中的简化逻辑
typedef struct redisDb {
    dict *dict;           // key-value 存储
    dict *watched_keys;   // key → client 列表的映射
    // ...
} redisDb;
  1. WATCH key 时:将当前连接加入 watched_keys[key] 的列表
  2. 任何修改 key 的命令(SET、DEL 等)执行后:检查 watched_keys[key],标记相关客户端的 CLIENT_DIRTY_CAS 标志
  3. EXEC 时:检查 CLIENT_DIRTY_CAS 标志,如果被设置则返回 NULL

性能特点

操作时间复杂度
WATCHO(1) per key
冲突检测(写入时)O(n) per key(n 为监视该 key 的客户端数)
EXEC 检查O(1)(检查标志位)

7.12 事务 vs Pipeline

特性事务 (MULTI/EXEC)Pipeline
原子性✅ 保证❌ 不保证
排队阶段命令排队,返回 QUEUED命令直接发送
错误处理排队错误中止整个事务逐个处理
性能略低(需要排队)更高
WATCH 支持
适用场景需要原子保证批量操作

组合使用

Pipeline + 事务可以减少 RTT:

pipe = r.pipeline(transaction=True)  # 默认就使用事务

# 这实际上会发送:MULTI, 命令1, 命令2, ..., EXEC
# 但在一个 Pipeline 批次中完成
pipe.set("key1", "value1")
pipe.set("key2", "value2")
results = pipe.execute()  # 发送 EXEC

7.13 注意事项

⚠️ 事务不支持回滚 命令执行失败不会回滚已成功的命令。如果需要更强的事务保证,考虑使用 Lua 脚本。

⚠️ WATCH 的重试 在高并发场景下,WATCH 冲突可能频繁发生。建议实现指数退避重试策略。

⚠️ 事务中的命令是顺序执行的 事务中的命令按顺序执行,但不保证不被其他客户端的命令穿插(除非使用 WATCH)。

⚠️ 集群中的事务 事务中的所有 key 必须在同一个哈希槽(hash slot)中,否则事务无法在集群模式下执行。


7.14 扩展阅读

资源说明
Redis Transactions 文档官方文档
WATCH 命令WATCH 详细说明
Redis 事务 vs Lua比较

上一章:发布订阅协议 | 下一章:Lua 脚本协议