📚 文章概述
Redis事务和Lua脚本是Redis提供的两种原子性操作机制。事务允许将多个命令打包执行,Lua脚本则提供了更强大的编程能力,可以在服务器端执行复杂的业务逻辑。本文将深入解析Redis事务的特性、WATCH机制、Lua脚本的执行原理、原子性保证机制,并通过实例和练习帮助读者掌握这两种机制的使用场景和最佳实践。
一、理论部分
1.1 Redis事务概述
1.1.1 什么是Redis事务?
Redis事务是一组命令的集合,这些命令会被序列化并按顺序执行。事务执行期间,不会被其他客户端的命令打断。
事务的特点:
- 原子性:事务中的所有命令要么全部执行,要么全部不执行
- 隔离性:事务执行期间不会被其他客户端命令打断
- 顺序性:命令按顺序执行
- 无回滚:Redis事务不支持回滚(rollback)
1.1.2 事务命令
基本命令:
bash
# 开始事务
MULTI
# 执行事务
EXEC
# 取消事务
DISCARD
# 监视键(乐观锁)
WATCH key [key ...]
# 取消监视
UNWATCH
事务执行流程:
EXEC DISCARD 客户端 MULTI 命令1 命令2 命令3 执行? 服务器执行所有命令 取消事务 返回所有命令结果 事务取消
1.2 事务执行原理
1.2.1 事务执行过程
阶段1:命令入队
Client Server MULTI OK SET key1 value1 QUEUED SET key2 value2 QUEUED GET key1 QUEUED 命令被加入队列 但不执行 Client Server
阶段2:命令执行
Client Server EXEC 执行队列中的所有命令 按顺序执行 [OK, OK, "value1"] 所有命令原子性执行 Client Server
1.2.2 事务队列
Redis为每个客户端维护一个事务队列:
客户端连接 事务队列 命令1 命令2 命令3 命令N EXEC 执行队列 结果1 结果2 结果3 结果N
队列特点:
- FIFO(先进先出)
- 每个客户端独立队列
- 命令在队列中不执行
- EXEC时才批量执行
1.3 事务的ACID特性
1.3.1 原子性(Atomicity)
Redis事务的原子性:
是 否 事务开始 命令1: SET a 1 命令2: SET b 2 命令3: INCR c 执行EXEC? 所有命令执行 所有命令不执行 原子性保证
原子性保证:
- ✅ 所有命令要么全部执行,要么全部不执行
- ✅ 执行过程中不会被打断
- ❌ 不支持回滚(执行失败的命令不会回滚)
示例:
bash
MULTI
SET key1 value1
SET key2 value2
INCR key3 # 如果key3不是数字,这个命令会失败
EXEC
# 结果:
# 1) OK
# 2) OK
# 3) (error) ERR value is not an integer or out of range
# 注意:前两个命令已经执行,不会回滚
1.3.2 一致性(Consistency)
一致性保证:
- 事务执行前后,数据库的完整性约束保持一致
- Redis是单线程执行,天然保证一致性
1.3.3 隔离性(Isolation)
隔离性保证:
客户端1事务 命令队列 客户端2命令 立即执行 客户端3事务 命令队列 EXEC时执行 EXEC时执行 不等待事务
隔离级别:
- Redis事务是串行化隔离级别
- 事务执行期间,其他客户端的命令不会插入执行
- 但事务中的命令不会立即执行,只在EXEC时执行
1.3.4 持久性(Durability)
持久性保证:
- 取决于Redis的持久化配置(RDB/AOF)
- 如果开启了持久化,事务执行后的数据会持久化
- 如果未开启持久化,数据只存在内存中
1.4 WATCH机制(乐观锁)
1.4.1 WATCH原理
WATCH是Redis提供的乐观锁机制,用于在事务执行前检测键是否被修改。
WATCH工作流程:
Client1 Client2 Redis WATCH key OK MULTI GET key SET key new_value 同时,Client2修改了key SET key other_value OK EXEC (nil) 因为key被修改 事务被取消 Client1 Client2 Redis
WATCH机制:
未变化 已变化 WATCH key 记录键的版本号 开始事务 命令入队 EXEC 检查键版本号 执行事务 取消事务
1.4.2 WATCH使用场景
场景1:账户余额更新
Client Redis WATCH account:1000 GET account:1000 1000 计算新余额: 1000 - 100 = 900 MULTI SET account:1000 900 EXEC OK (nil) alt [账户未被修改] [账户被修改] Client Redis
场景2:库存扣减
bash
# 防止超卖
WATCH inventory:item1
GET inventory:item1
# 如果库存>0,则扣减
MULTI
DECR inventory:item1
EXEC
1.4.3 WATCH注意事项
1. WATCH的作用范围
- WATCH在EXEC或DISCARD后自动取消
- UNWATCH可以手动取消所有WATCH
2. WATCH的性能影响
- WATCH会增加内存开销(记录键的版本)
- 大量WATCH可能影响性能
3. WATCH的限制
- 只能检测键的修改,不能检测值的变化
- 如果键被删除,WATCH也会触发
1.5 事务的限制
1.5.1 不支持回滚
问题:
事务开始 命令1: 成功 命令2: 成功 命令3: 失败 命令4: 未执行 事务结束 命令1和2已执行
无法回滚
原因:
- Redis追求简单和性能
- 回滚需要记录操作日志,影响性能
- 大多数命令失败是编程错误,应该在开发时避免
1.5.2 命令错误处理
命令错误类型:
-
入队错误:命令语法错误,事务不会执行
bashMULTI SET key value INVALID_COMMAND # 语法错误 EXEC # 结果:事务被取消,所有命令都不执行 -
执行错误:命令执行时出错,其他命令继续执行
bashMULTI SET key1 value1 INCR key2 # key2不是数字,执行失败 SET key3 value3 EXEC # 结果: # 1) OK # 2) (error) ERR value is not an integer # 3) OK
1.5.3 事务中的命令限制
不支持的命令:
- 某些阻塞命令(如BLPOP)
- 某些需要立即返回结果的命令
原因:
- 事务中的命令在EXEC时才执行
- 阻塞命令会阻塞整个事务
1.6 Lua脚本概述
1.6.1 为什么需要Lua脚本?
事务的局限性:
- 无法在事务中根据条件执行不同命令
- 无法在事务中执行循环和分支逻辑
- 错误处理能力有限
Lua脚本的优势:
- 原子性:整个脚本原子性执行
- 灵活性:支持复杂的业务逻辑
- 性能:减少网络往返
- 可编程:支持变量、循环、条件判断等
1.6.2 Lua脚本执行
基本命令:
bash
# 执行Lua脚本
EVAL script numkeys key [key ...] arg [arg ...]
# 执行缓存的Lua脚本
EVALSHA sha1 numkeys key [key ...] arg [arg ...]
# 加载脚本到缓存
SCRIPT LOAD script
# 检查脚本是否存在
SCRIPT EXISTS sha1 [sha1 ...]
# 刷新脚本缓存
SCRIPT FLUSH
# 杀死正在执行的脚本
SCRIPT KILL
EVAL命令格式:
EVAL script: Lua脚本代码 numkeys: 键的数量 key1, key2, ...: 键名 arg1, arg2, ...: 参数 在Redis服务器执行
1.7 Lua脚本执行原理
1.7.1 脚本执行流程
Client Redis EVAL "return redis.call('GET', KEYS[1])" 1 mykey 解析脚本 检查脚本缓存 编译Lua脚本 执行脚本 调用Redis命令 返回结果 Client Redis
1.7.2 脚本缓存机制
SHA1缓存:
是 否 Lua脚本 计算SHA1 缓存中存在? 使用缓存的脚本 编译并缓存 执行脚本
缓存优势:
- 避免重复编译脚本
- 提高执行效率
- 减少网络传输
使用EVALSHA:
bash
# 1. 加载脚本
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# 返回: abc123... (SHA1值)
# 2. 使用SHA1执行
EVALSHA abc123... 1 mykey
1.7.3 脚本原子性
原子性保证:
脚本开始执行 执行Lua代码 调用Redis命令1 调用Redis命令2 调用Redis命令3 脚本执行完成 所有操作原子性完成 其他客户端命令 等待脚本执行完成 才能执行
原子性特点:
- 脚本执行期间,Redis不会执行其他命令
- 脚本中的所有Redis命令原子性执行
- 脚本执行是单线程的
1.8 Lua脚本中的Redis命令
1.8.1 redis.call() 和 redis.pcall()
redis.call():
- 命令执行失败会抛出错误
- 脚本会中断执行
redis.pcall():
- 命令执行失败返回错误对象
- 脚本继续执行
对比:
是 否 是 否 redis.call 命令成功? 返回结果 抛出错误
脚本中断 redis.pcall 命令成功? 返回结果 返回错误对象
脚本继续
使用建议:
- 大多数情况使用
redis.call() - 需要错误处理时使用
redis.pcall()
1.8.2 键和参数访问
KEYS数组:
- 通过
KEYS[1],KEYS[2]访问键 - 必须提前声明键的数量
ARGV数组:
- 通过
ARGV[1],ARGV[2]访问参数 - 参数都是字符串类型
示例:
lua
-- KEYS[1] = "user:1000"
-- ARGV[1] = "100"
-- ARGV[2] = "200"
local balance = redis.call('GET', KEYS[1])
local amount = tonumber(ARGV[1])
local new_balance = tonumber(ARGV[2])
return redis.call('SET', KEYS[1], new_balance)
1.9 脚本执行限制
1.9.1 执行时间限制
默认配置:
conf
# 脚本最大执行时间(秒)
lua-time-limit 5
超时处理:
是 否 是 否 是 否 脚本开始执行 执行时间 < 5秒? 继续执行 标记为慢脚本 有其他命令等待? 允许脚本继续执行 继续执行 SCRIPT KILL 脚本可中断? 中断脚本 无法中断
需要SHUTDOWN
1.9.2 脚本阻塞
阻塞场景:
- 脚本执行时间过长
- 脚本中有阻塞命令(如BLPOP)
解决方案:
- 优化脚本逻辑,减少执行时间
- 避免在脚本中使用阻塞命令
- 使用
SCRIPT KILL中断脚本(如果可中断)
1.9.3 脚本复制
主从复制:
- 脚本会在主从节点间复制
- 从节点执行相同的脚本
集群模式:
- 脚本中的键必须在同一个节点
- 使用键标签确保键在同一槽
1.10 事务 vs Lua脚本
1.10.1 功能对比
| 特性 | 事务 | Lua脚本 |
|---|---|---|
| 原子性 | ✅ | ✅ |
| 条件判断 | ❌ | ✅ |
| 循环 | ❌ | ✅ |
| 错误处理 | 有限 | 完整 |
| 性能 | 高 | 中(需要编译) |
| 灵活性 | 低 | 高 |
| 回滚 | ❌ | ❌ |
1.10.2 使用场景
事务适用场景:
- 简单的批量操作
- 不需要条件判断
- 性能要求高
Lua脚本适用场景:
- 复杂的业务逻辑
- 需要条件判断和循环
- 需要原子性的复合操作
- 减少网络往返
1.10.3 选择建议
是 否 是 否 是 否 需要原子性操作 需要条件判断? 使用Lua脚本 需要循环? 操作简单? 使用事务
二、实例部分
2.1 环境准备
由于事务和Lua脚本可以在单机Redis上演示,我们可以使用本地Redis进行测试。
连接Redis:
python
import redis
# 连接Redis(本地配置:localhost:6379,无密码)
r = redis.Redis(
host='localhost',
port=6379,
db=0,
decode_responses=True
)
2.2 实例1:基本事务操作
Python示例:
python
def demo_basic_transaction():
"""演示基本事务操作"""
print("=" * 70)
print("实例1:基本事务操作")
print("=" * 70)
# 清理测试数据
r.delete('key1', 'key2', 'key3')
# 开始事务
pipe = r.pipeline()
pipe.multi()
# 添加命令到事务
pipe.set('key1', 'value1')
pipe.set('key2', 'value2')
pipe.set('key3', 'value3')
# 执行事务
results = pipe.execute()
print(f"\n事务执行结果: {results}")
# 验证数据
print(f"key1: {r.get('key1')}")
print(f"key2: {r.get('key2')}")
print(f"key3: {r.get('key3')}")
Redis命令示例:
bash
# 在redis-cli中执行
MULTI
SET key1 value1
SET key2 value2
GET key1
EXEC
# 结果:
# 1) OK
# 2) OK
# 3) "value1"
2.3 实例2:WATCH实现乐观锁
Python示例:
python
def demo_watch_optimistic_lock():
"""演示WATCH实现乐观锁"""
print("\n" + "=" * 70)
print("实例2:WATCH实现乐观锁")
print("=" * 70)
# 设置初始值
r.set('balance', '1000')
def update_balance(amount):
"""更新余额(带乐观锁)"""
while True:
try:
# 监视键
r.watch('balance')
# 获取当前余额
current = int(r.get('balance'))
new_balance = current + amount
# 开始事务
pipe = r.pipeline()
pipe.multi()
pipe.set('balance', new_balance)
# 执行事务
result = pipe.execute()
if result:
print(f"余额更新成功: {current} -> {new_balance}")
return True
else:
print("余额被其他客户端修改,重试...")
continue
except redis.WatchError:
print("WATCH检测到键被修改,重试...")
continue
# 测试
update_balance(100) # 增加100
print(f"最终余额: {r.get('balance')}")
2.4 实例3:Lua脚本基本使用
Python示例:
python
def demo_lua_basic():
"""演示Lua脚本基本使用"""
print("\n" + "=" * 70)
print("实例3:Lua脚本基本使用")
print("=" * 70)
# 设置测试数据
r.set('counter', '10')
# 定义Lua脚本
script = """
local current = redis.call('GET', KEYS[1])
local increment = tonumber(ARGV[1])
local new_value = tonumber(current) + increment
redis.call('SET', KEYS[1], new_value)
return new_value
"""
# 执行脚本
result = r.eval(script, 1, 'counter', '5')
print(f"脚本执行结果: {result}")
print(f"counter当前值: {r.get('counter')}")
# 使用EVALSHA
sha = r.script_load(script)
print(f"脚本SHA1: {sha}")
result2 = r.evalsha(sha, 1, 'counter', '3')
print(f"EVALSHA执行结果: {result2}")
print(f"counter当前值: {r.get('counter')}")
2.5 实例4:Lua脚本实现原子性操作
Python示例:
python
def demo_lua_atomic_operation():
"""演示Lua脚本实现原子性操作"""
print("\n" + "=" * 70)
print("实例4:Lua脚本实现原子性操作")
print("=" * 70)
# 设置测试数据
r.set('user:1000:balance', '1000')
r.set('user:1000:points', '100')
# 定义Lua脚本:转账操作
script = """
local balance_key = KEYS[1]
local points_key = KEYS[2]
local amount = tonumber(ARGV[1])
local points_cost = tonumber(ARGV[2])
-- 获取当前余额和积分
local balance = tonumber(redis.call('GET', balance_key) or 0)
local points = tonumber(redis.call('GET', points_key) or 0)
-- 检查余额和积分是否足够
if balance < amount or points < points_cost then
return {err = "余额或积分不足"}
end
-- 执行转账
local new_balance = balance - amount
local new_points = points - points_cost
redis.call('SET', balance_key, new_balance)
redis.call('SET', points_key, new_points)
return {ok = true, balance = new_balance, points = new_points}
"""
# 执行脚本
result = r.eval(script, 2, 'user:1000:balance', 'user:1000:points', '100', '10')
print(f"转账结果: {result}")
print(f"余额: {r.get('user:1000:balance')}")
print(f"积分: {r.get('user:1000:points')}")
2.6 实例5:Lua脚本实现分布式锁
Python示例:
python
def demo_lua_distributed_lock():
"""演示Lua脚本实现分布式锁"""
print("\n" + "=" * 70)
print("实例5:Lua脚本实现分布式锁")
print("=" * 70)
# 获取锁的脚本
acquire_script = """
local lock_key = KEYS[1]
local lock_value = ARGV[1]
local expire_time = tonumber(ARGV[2])
-- 尝试获取锁
local result = redis.call('SETNX', lock_key, lock_value)
if result == 1 then
redis.call('EXPIRE', lock_key, expire_time)
return 1
else
return 0
end
"""
# 释放锁的脚本
release_script = """
local lock_key = KEYS[1]
local lock_value = ARGV[1]
-- 检查锁的值是否匹配
local current_value = redis.call('GET', lock_key)
if current_value == lock_value then
redis.call('DEL', lock_key)
return 1
else
return 0
end
"""
lock_key = 'mylock'
lock_value = 'client123'
expire_time = 10
# 获取锁
result = r.eval(acquire_script, 1, lock_key, lock_value, expire_time)
if result == 1:
print("锁获取成功")
# 执行业务逻辑
print("执行业务逻辑...")
# 释放锁
release_result = r.eval(release_script, 1, lock_key, lock_value)
if release_result == 1:
print("锁释放成功")
else:
print("锁释放失败(可能已过期或被其他客户端获取)")
else:
print("锁获取失败(已被其他客户端持有)")
2.7 实例6:性能对比测试
Python示例:
python
import time
def demo_performance_comparison():
"""演示事务和Lua脚本的性能对比"""
print("\n" + "=" * 70)
print("实例6:性能对比测试")
print("=" * 70)
iterations = 10000
# 测试事务性能
r.flushdb()
start_time = time.time()
for i in range(iterations):
pipe = r.pipeline()
pipe.multi()
pipe.set(f'key{i}', f'value{i}')
pipe.execute()
transaction_time = time.time() - start_time
transaction_ops = iterations / transaction_time
# 测试Lua脚本性能
r.flushdb()
script = """
redis.call('SET', KEYS[1], ARGV[1])
return 'OK'
"""
sha = r.script_load(script)
start_time = time.time()
for i in range(iterations):
r.evalsha(sha, 1, f'key{i}', f'value{i}')
lua_time = time.time() - start_time
lua_ops = iterations / lua_time
# 测试普通命令性能
r.flushdb()
start_time = time.time()
for i in range(iterations):
r.set(f'key{i}', f'value{i}')
normal_time = time.time() - start_time
normal_ops = iterations / normal_time
print(f"\n性能对比({iterations}次操作):")
print(f"普通命令: {normal_time:.4f}s, {normal_ops:,.0f} ops/sec")
print(f"事务: {transaction_time:.4f}s, {transaction_ops:,.0f} ops/sec")
print(f"Lua脚本: {lua_time:.4f}s, {lua_ops:,.0f} ops/sec")
三、练习内容
练习1:实现账户转账系统
需求:
使用Redis事务和WATCH实现一个账户转账系统,要求:
- 支持从账户A转账到账户B
- 检查账户余额是否足够
- 使用WATCH防止并发问题
- 实现重试机制
参考代码框架:
python
def transfer_account(from_account, to_account, amount):
"""
账户转账
Args:
from_account: 转出账户
to_account: 转入账户
amount: 转账金额
Returns:
bool: 转账是否成功
"""
# TODO: 实现转账逻辑
# 1. WATCH两个账户
# 2. 检查转出账户余额
# 3. 执行转账事务
# 4. 处理WATCH错误,实现重试
pass
练习2:使用Lua脚本实现库存扣减
需求:
使用Lua脚本实现库存扣减功能,要求:
- 检查库存是否充足
- 扣减库存
- 记录扣减日志
- 保证原子性
参考代码框架:
python
def decrease_inventory(item_id, quantity):
"""
扣减库存
Args:
item_id: 商品ID
quantity: 扣减数量
Returns:
dict: 操作结果
"""
script = """
-- TODO: 实现库存扣减逻辑
-- 1. 获取当前库存
-- 2. 检查库存是否充足
-- 3. 扣减库存
-- 4. 记录日志
-- 5. 返回结果
"""
# TODO: 执行脚本
pass
练习3:实现分布式限流器
需求:
使用Lua脚本实现一个分布式限流器,要求:
- 支持固定窗口限流
- 支持滑动窗口限流
- 支持令牌桶算法
- 保证原子性
参考代码框架:
python
def rate_limit(key, limit, window):
"""
限流检查
Args:
key: 限流键
limit: 限制次数
window: 时间窗口(秒)
Returns:
bool: 是否允许通过
"""
script = """
-- TODO: 实现限流逻辑
-- 1. 获取当前计数
-- 2. 检查是否超过限制
-- 3. 更新计数
-- 4. 返回结果
"""
# TODO: 执行脚本
pass
四、可视化图表
4.1 事务执行流程图
EXEC DISCARD 客户端 MULTI 命令1: QUEUED 命令2: QUEUED 命令3: QUEUED 执行? 服务器执行所有命令 取消事务 返回所有结果 事务取消
4.2 WATCH机制时序图
Client1 Client2 Redis WATCH balance OK GET balance 1000 同时修改 SET balance 1500 OK MULTI SET balance 900 EXEC (nil) 因为balance被修改 事务被取消 Client1 Client2 Redis
4.3 Lua脚本执行流程图
是 否 客户端发送EVAL 服务器接收脚本 计算SHA1 脚本已缓存? 使用缓存的脚本 编译Lua脚本 缓存脚本 执行脚本 调用Redis命令 返回结果
4.4 原子性保证示意图
Lua脚本开始 执行Lua代码 调用redis.call1 调用redis.call2 调用redis.call3 脚本完成 其他客户端命令 等待脚本完成 脚本完成后执行
4.5 事务 vs Lua脚本对比
是 否 是 否 是 否 原子性操作需求 需要条件判断? Lua脚本 需要循环? 操作简单? 事务 灵活性高
功能强大 性能高
简单直接
五、总结
5.1 关键知识点回顾
-
Redis事务
- 原子性执行多个命令
- 不支持回滚
- 使用WATCH实现乐观锁
- 适合简单的批量操作
-
WATCH机制
- 乐观锁实现
- 检测键是否被修改
- 键被修改时事务失败
- 需要实现重试机制
-
Lua脚本
- 服务器端执行脚本
- 完整的原子性保证
- 支持复杂业务逻辑
- 使用SHA1缓存提高性能
-
原子性保证
- 事务:命令队列原子性执行
- Lua脚本:整个脚本原子性执行
- 执行期间不会被其他命令打断
5.2 使用场景
事务适用场景:
- 简单的批量操作
- 不需要条件判断
- 性能要求高
- 操作相对固定
Lua脚本适用场景:
- 复杂的业务逻辑
- 需要条件判断和循环
- 需要原子性的复合操作
- 减少网络往返
- 实现分布式锁、限流等功能
5.3 最佳实践
-
事务使用建议
- 避免在事务中使用可能失败的命令
- 使用WATCH时实现重试机制
- 注意事务不支持回滚
-
Lua脚本使用建议
- 使用SCRIPT LOAD和EVALSHA提高性能
- 避免脚本执行时间过长
- 避免在脚本中使用阻塞命令
- 合理使用redis.call和redis.pcall
-
性能优化
- 事务性能高于Lua脚本
- Lua脚本可以减少网络往返
- 使用脚本缓存提高效率
5.4 注意事项
-
事务限制
- 不支持回滚
- 命令错误不会回滚已执行的命令
- 某些命令不能在事务中使用
-
Lua脚本限制
- 脚本执行时间有限制
- 脚本阻塞会影响其他客户端
- 集群模式下键必须在同一节点
-
并发控制
- 使用WATCH实现乐观锁
- 使用Lua脚本实现原子操作
- 注意死锁和性能问题
下一篇预告: 第6篇将深入讲解Redis发布订阅与Stream,包括Pub/Sub机制、Stream数据结构、消费者组和消息队列应用。