跳到主要内容

Redis 事务

事务是指将一组命令作为一个原子操作执行。Redis 通过 MULTI、EXEC、DISCARD 和 WATCH 命令提供事务支持。

事务概述

什么是事务?

Redis 事务允许将多个命令打包,然后按顺序执行。事务执行期间,不会被其他客户端的命令打断,保证了操作的原子性。

┌─────────────────────────────────────────────────────────────┐
│ Redis 事务流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 客户端 │
│ │ │
│ │ MULTI ─────────────────────> 开始事务 │
│ │ 命令1 ────────────────────> 命令入队 │
│ │ 命令2 ────────────────────> 命令入队 │
│ │ 命令3 ────────────────────> 命令入队 │
│ │ EXEC ─────────────────────> 执行所有命令 │
│ │ │
│ 服务端 │
│ │ │
│ │ [命令1, 命令2, 命令3] ──────> 顺序执行 │
│ │ │
│ │
└─────────────────────────────────────────────────────────────┘

Redis 事务的特点

  1. 原子性:事务中的命令要么全部执行,要么全部不执行
  2. 隔离性:事务执行期间不会被其他客户端打断
  3. 无回滚:Redis 不支持事务回滚(设计决策)

Redis 事务与关系型数据库事务的区别

特性Redis 事务MySQL 事务
原子性部分支持完全支持
回滚不支持支持
隔离级别串行化多种级别
持久性依赖持久化配置完全支持
复杂度简单复杂

为什么不支持回滚?

Redis 作者认为:

  • Redis 命令出错通常是编程错误,不应在生产环境出现
  • 支持回滚会增加复杂性,影响性能
  • 简单的设计更可靠

基本命令

MULTI 开始事务

MULTI 命令标记事务开始,之后的命令不会立即执行,而是进入队列:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key1 "value1"
QUEUED
127.0.0.1:6379> SET key2 "value2"
QUEUED
127.0.0.1:6379> GET key1
QUEUED

注意命令返回 QUEUED,表示命令已入队等待执行。

EXEC 执行事务

EXEC 命令执行事务队列中的所有命令:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET counter 0
QUEUED
127.0.0.1:6379> INCR counter
QUEUED
127.0.0.1:6379> INCR counter
QUEUED
127.0.0.1:6379> GET counter
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (integer) 1
3) (integer) 2
4) "2"

EXEC 返回数组,包含每个命令的执行结果,顺序与命令入队顺序一致。

DISCARD 取消事务

DISCARD 取消事务,清空命令队列:

127.0.0.1:6379> SET key "original"
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key "modified"
QUEUED
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> GET key
"original" # 值未被修改

WATCH 乐观锁

基本概念

WATCH 命令用于实现乐观锁(Optimistic Locking)。它会监视一个或多个键,如果在事务执行前这些键被修改,事务将失败。

工作原理

┌─────────────────────────────────────────────────────────────┐
│ WATCH 工作流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. WATCH key ─────────────> 标记 key 被监视 │
│ │
│ 2. GET key ────────────────> 获取当前值 │
│ │
│ 3. 计算新值... │
│ │
│ 4. MULTI ──────────────────> 开始事务 │
│ SET key new_value ──────> 命令入队 │
│ EXEC ───────────────────> 检查 key 是否被修改 │
│ │
│ 情况 A:key 未被修改 │
│ └──> 事务执行成功 │
│ │
│ 情况 B:key 已被其他客户端修改 │
│ └──> EXEC 返回 nil,事务失败 │
│ │
└─────────────────────────────────────────────────────────────┘

使用示例

场景:原子性递增(假设没有 INCR 命令)

# 客户端 A
127.0.0.1:6379> GET counter
"10"
127.0.0.1:6379> WATCH counter
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET counter 11
QUEUED
127.0.0.1:6379> EXEC
1) OK # 成功

# 如果在 EXEC 之前,客户端 B 修改了 counter
# 客户端 A 的 EXEC 会返回 nil,事务失败

Python 客户端示例

import redis

def atomic_increment(conn, key):
"""原子性递增"""
with conn.pipeline() as pipe:
while True:
try:
# 监视键
pipe.watch(key)
# 获取当前值
current = pipe.get(key)
value = int(current) if current else 0
# 开始事务
pipe.multi()
pipe.set(key, value + 1)
# 执行事务
pipe.execute()
return value + 1
except redis.WatchError:
# 键被修改,重试
continue

# 使用
r = redis.Redis()
result = atomic_increment(r, 'counter')
print(result)

UNWATCH 取消监视

# 取消所有监视
127.0.0.1:6379> UNWATCH
OK

EXEC 执行后(无论成功或失败),WATCH 会自动取消。

错误处理

语法错误(命令入队失败)

如果命令有语法错误,Redis 会拒绝入队,EXEC 会返回错误:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key "value"
QUEUED
127.0.0.1:6379> INCR key value extra # 语法错误
(error) ERR wrong number of arguments for 'incr' command
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.

从 Redis 2.6.5 开始,如果入队时有错误,EXEC 会拒绝执行整个事务。

运行时错误(类型错误)

如果命令执行时出错(如对字符串执行列表操作),只有该命令失败,其他命令继续执行:

127.0.0.1:6379> SET key "string_value"
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> LPUSH key "item" # 对字符串执行列表操作
QUEUED
127.0.0.1:6379> SET another_key "value"
QUEUED
127.0.0.1:6379> EXEC
1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
2) OK # 第二个命令成功执行

注意:这种情况下,其他命令不会回滚!需要在应用层处理。

事务应用场景

场景一:批量操作

需要一次性执行多个命令,减少网络往返:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET user:1:name "张三"
QUEUED
127.0.0.1:6379> SET user:1:age "25"
QUEUED
127.0.0.1:6379> SET user:1:email "[email protected]"
QUEUED
127.0.0.1:6379> EXEC

场景二:条件更新

使用 WATCH 实现条件更新:

def transfer_funds(conn, from_key, to_key, amount):
"""转账操作"""
with conn.pipeline() as pipe:
while True:
try:
# 监视两个账户
pipe.watch(from_key, to_key)
# 获取余额
from_balance = int(pipe.get(from_key) or 0)
to_balance = int(pipe.get(to_key) or 0)

# 检查余额是否充足
if from_balance < amount:
pipe.unwatch()
return False

# 执行转账
pipe.multi()
pipe.decrby(from_key, amount)
pipe.incrby(to_key, amount)
pipe.execute()
return True
except redis.WatchError:
# 余额被修改,重试
continue

场景三:商品秒杀

def seckill(conn, item_id, user_id):
"""秒杀逻辑"""
item_key = f"item:{item_id}"
user_key = f"user:{user_id}:items"

with conn.pipeline() as pipe:
while True:
try:
pipe.watch(item_key)
# 获取库存
stock = int(pipe.get(item_key) or 0)

if stock <= 0:
pipe.unwatch()
return False # 库存不足

# 减库存,记录用户购买
pipe.multi()
pipe.decr(item_key)
pipe.sadd(user_key, item_id)
pipe.execute()
return True
except redis.WatchError:
continue

事务与管道

区别

特性事务管道
原子性
隔离性
网络往返一次一次
命令执行服务端队列服务端顺序执行

结合使用

在 Redis 客户端中,事务和管道通常结合使用:

import redis

r = redis.Redis()

# 使用管道 + 事务
with r.pipeline() as pipe:
pipe.multi() # 开始事务
pipe.set('key1', 'value1')
pipe.set('key2', 'value2')
pipe.get('key1')
result = pipe.execute() # 一次网络往返

print(result) # [True, True, 'value1']

事务的限制

不支持回滚

127.0.0.1:6379> SET key "original"
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key "modified"
QUEUED
127.0.0.1:6379> INCR key # 这会失败,因为 key 是字符串
QUEUED
127.0.0.1:6379> EXEC
1) OK # SET 成功
2) (error) ... # INCR 失败,但 SET 不会回滚
127.0.0.1:6379> GET key
"modified" # 值已被修改

不支持嵌套事务

Redis 不支持嵌套事务:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key "value"
QUEUED
127.0.0.1:6379> MULTI
(error) ERR MULTI calls can not be nested

WATCH 在集群中的限制

在 Redis Cluster 中,WATCH 的键必须在同一个哈希槽:

# 在集群模式下,以下操作会失败
WATCH key1 key2
# 如果 key1 和 key2 不在同一个槽位

解决方案:使用哈希标签 {tag} 确保键在同一槽位:

WATCH user:{123}:name user:{123}:age

最佳实践

1. 处理 WatchError

def safe_transaction(conn, key):
"""安全的事务操作"""
max_retries = 3
for _ in range(max_retries):
try:
with conn.pipeline() as pipe:
pipe.watch(key)
value = pipe.get(key)
# 业务逻辑...
pipe.multi()
pipe.set(key, new_value)
pipe.execute()
return True
except redis.WatchError:
continue
return False

2. 避免长时间事务

# 不好的做法:事务中有耗时操作
pipe.multi()
pipe.set('key1', 'value1')
time.sleep(1) # 阻塞其他客户端
pipe.set('key2', 'value2')
pipe.execute()

# 好的做法:事务只包含 Redis 命令
pipe.multi()
pipe.set('key1', 'value1')
pipe.set('key2', 'value2')
pipe.execute()

3. 使用 Lua 脚本替代复杂事务

对于复杂逻辑,Lua 脚本更合适:

# 使用 Lua 脚本实现原子性操作
EVAL "
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) > 0 then
redis.call('DECR', KEYS[1])
return 1
end
return 0
" 1 item:stock

小结

本章我们学习了:

  1. 事务基础:MULTI、EXEC、DISCARD 命令
  2. 乐观锁:WATCH 实现条件更新
  3. 错误处理:语法错误和运行时错误的区别
  4. 应用场景:批量操作、条件更新、秒杀
  5. 最佳实践:处理错误、避免长事务

练习

  1. 实现一个原子性计数器
  2. 使用 WATCH 实现一个简单的分布式锁
  3. 编写转账程序,确保原子性
  4. 对比事务和 Lua 脚本的优劣

参考资源