Redis 事务
事务是指将一组命令作为一个原子操作执行。Redis 通过 MULTI、EXEC、DISCARD 和 WATCH 命令提供事务支持。
事务概述
什么是事务?
Redis 事务允许将多个命令打包,然后按顺序执行。事务执行期间,不会被其他客户端的命令打断,保证了操作的原子性。
┌─────────────────────────────────────────────────────────────┐
│ Redis 事务流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 客户端 │
│ │ │
│ │ MULTI ─────────────────────> 开始事务 │
│ │ 命令1 ────────────────────> 命令入队 │
│ │ 命令2 ────────────────────> 命令入队 │
│ │ 命令3 ────────────────────> 命令入队 │
│ │ EXEC ─────────────────────> 执行所有命令 │
│ │ │
│ 服务端 │
│ │ │
│ │ [命令1, 命令2, 命令3] ──────> 顺序执行 │
│ │ │
│ │
└─────────────────────────────────────────────────────────────┘
Redis 事务的特点
- 原子性:事务中的命令要么全部执行,要么全部不执行
- 隔离性:事务执行期间不会被其他客户端打断
- 无回滚: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
小结
本章我们学习了:
- 事务基础:MULTI、EXEC、DISCARD 命令
- 乐观锁:WATCH 实现条件更新
- 错误处理:语法错误和运行时错误的区别
- 应用场景:批量操作、条件更新、秒杀
- 最佳实践:处理错误、避免长事务
练习
- 实现一个原子性计数器
- 使用 WATCH 实现一个简单的分布式锁
- 编写转账程序,确保原子性
- 对比事务和 Lua 脚本的优劣