07 - 事务与脚本
事务与脚本
7.1 Redis 事务概述
Redis 的事务与关系型数据库的 ACID 事务不同,它提供了一种将多个命令打包执行的机制,但不支持回滚。
| ACID 特性 | Redis 事务 | 关系型数据库 |
|---|---|---|
| 原子性(Atomicity) | ✅ 命令要么全部执行,要么全部不执行(但执行错误不回滚) | ✅ 支持回滚 |
| 一致性(Consistency) | ✅ 入队时检查语法 | ✅ 全面约束 |
| 隔离性(Isolation) | ✅ 单线程串行执行 | ✅ MVCC 等 |
| 持久性(Durability) | 取决于持久化配置 | ✅ WAL |
7.2 MULTI/EXEC 事务
基本用法
# 开始事务
MULTI
# OK
# 命令入队(不会立即执行)
SET account:A 900
QUEUED
SET account:B 1100
QUEUED
# 执行事务
EXEC
# 1) OK
# 2) OK
事务执行流程
客户端 Redis 服务端
│ │
│ MULTI │
│─────────────────────→│ 事务开始
│ OK │
│←─────────────────────│
│ │
│ SET key1 val1 │
│─────────────────────→│ 命令入队(不执行)
│ QUEUED │
│←─────────────────────│
│ │
│ SET key2 val2 │
│─────────────────────→│ 命令入队(不执行)
│ QUEUED │
│←─────────────────────│
│ │
│ EXEC │
│─────────────────────→│ 执行所有入队命令
│ 1) OK │
│ 2) OK │
│←─────────────────────│
乐观锁(WATCH)
WATCH 命令实现了 CAS(Check-And-Set)机制,在事务执行前监控 Key 是否被修改:
# 监控 Key
WATCH account:A
# OK
# 读取当前值
GET account:A
# "1000"
# 开始事务
MULTI
# 转账操作
DECRBY account:A 100
INCRBY account:B 100
# 执行
EXEC
# 如果 account:A 在 WATCH 后、EXEC 前被其他客户端修改
# EXEC 返回 nil(事务被取消)
# 否则正常执行,返回两个 OK
转账示例(完整)
# 转账函数(伪代码,使用 redis-cli 演示)
# 从账户 A 向账户 B 转账 100 元
# 初始化
SET account:A 1000
SET account:B 500
# 第一次尝试(正常情况)
WATCH account:A account:B
MULTI
DECRBY account:A 100
INCRBY account:B 100
EXEC
# 1) OK
# 2) OK
# 转账成功
# 第二次尝试(并发冲突)
# 客户端 1:
WATCH account:A
GET account:A # "900"
# 此时客户端 2 修改了 account:A:
# SET account:A 800
# 客户端 1 继续:
MULTI
DECRBY account:A 100
EXEC
# (nil) ← 事务被取消,因为 account:A 被修改了
# 客户端 1 需要重试
WATCH 的注意事项
# 取消所有 WATCH 监控
UNWATCH
# WATCH 在 EXEC 后自动取消
# WATCH 在 DISCARD 后自动取消
# 连接断开后,所有 WATCH 自动取消
⚠️ 注意:WATCH 是乐观锁,适用于冲突较少的场景。如果冲突频繁,大部分事务会被取消并重试,反而降低性能。
7.3 事务中的错误处理
命令入队时的错误(语法错误)
MULTI
SET key1 "value1"
SET key2 # ❌ 语法错误(缺少参数)
# (error) ERR wrong number of arguments for 'set' command
SET key3 "value3"
EXEC
# (error) EXECABORT Transaction discarded because of previous errors.
# 所有命令都不会执行
命令执行时的错误(运行时错误)
MULTI
SET key1 "hello"
LPUSH key1 "item" # ❌ 类型错误(key1 是 String,不能 LPUSH)
SET key2 "world"
EXEC
# 1) OK ← 成功
# 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
# 3) OK ← 也成功了!
# Redis 不会回滚已成功的命令
💡 技巧:Redis 的"事务"实际上是"批量命令执行"。如果需要真正的回滚,使用 Lua 脚本。
7.4 Lua 脚本
Lua 脚本是 Redis 最强大的特性之一,它允许在服务端执行复杂的逻辑,保证原子性。
EVAL 命令
# 基本语法
EVAL <script> <numkeys> <key1> <key2> ... <arg1> <arg2> ...
# 简单示例
EVAL "return 'Hello Redis'" 0
# "Hello Redis"
# 获取参数
EVAL "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 2 key1 key2 arg1 arg2
# 1) "key1"
# 2) "key2"
# 3) "arg1"
# 4) "arg2"
# 在 Lua 中调用 Redis 命令
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey "myvalue"
# OK
EVAL "return redis.call('GET', KEYS[1])" 1 mykey
# "myvalue"
redis.call() vs redis.pcall()
# redis.call() - 错误时直接抛出异常
EVAL "return redis.call('SET', 'key1', 'val1', 'extra_arg')" 0
# (error) ERR wrong number of arguments for 'set' command
# redis.pcall() - 错误时返回错误对象(不中断脚本)
EVAL "
local result = redis.pcall('SET', 'key1', 'val1', 'extra_arg')
if result['err'] then
return 'Error: ' .. result['err']
end
return result
" 0
# "Error: ERR wrong number of arguments for 'set' command"
实用 Lua 脚本示例
1. 原子性的 GET + SET
# 如果旧值等于期望值,则设置新值(CAS)
EVAL "
local current = redis.call('GET', KEYS[1])
if current == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
end
return 0
" 1 mykey "old_value" "new_value"
2. 分布式锁(安全释放)
# 加锁
SET lock:resource "owner-uuid" NX EX 30
# 安全解锁(只有持锁者才能解锁)
EVAL "
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
" 1 lock:resource "owner-uuid"
3. 限流器(滑动窗口)
-- 参数:KEYS[1] = 限流 Key,ARGV[1] = 窗口大小(秒),ARGV[2] = 最大请求数,ARGV[3] = 当前时间戳
EVAL "
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 移除窗口外的请求
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count < limit then
-- 未超限,添加请求
redis.call('ZADD', key, now, now .. math.random())
redis.call('EXPIRE', key, window)
return 1 -- 允许
else
return 0 -- 拒绝
end
" 1 rate:user:1001 60 100 1715318400
4. 原子计数器(带上限检查)
-- 如果当前值 < max,则自增;否则返回 -1
EVAL "
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local max = tonumber(ARGV[1])
if current < max then
return redis.call('INCR', KEYS[1])
else
return -1
end
" 1 counter:api:1001 1000
5. 批量条件删除
-- 删除所有匹配的 Key(类似 KEYS + DEL,但原子执行)
EVAL "
local keys = redis.call('SCAN', ARGV[1], 'MATCH', ARGV[2], 'COUNT', 100)
local cursor = keys[1]
local found = keys[2]
for i, key in ipairs(found) do
redis.call('DEL', key)
end
return {cursor, #found}
" 0 0 "temp:*"
SCRIPT 管理命令
# 加载脚本(返回 SHA1 值)
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# "a42059b356c875f0717db19a50f6a46b6b07e5c3"
# 使用 SHA1 执行脚本(避免重复传输脚本内容)
EVALSHA "a42059b356c875f0717db19a50f6a46b6b07e5c3" 1 mykey
# 检查脚本是否已缓存
SCRIPT EXISTS "a42059b356c875f0717db19a50f6a46b6b07e5c3"
# 1) (integer) 1
# 清空脚本缓存
SCRIPT FLUSH
# 终止正在运行的脚本(Redis 2.8+)
SCRIPT KILL
EVALSHA 优化模式
在生产环境中,推荐使用 EVALSHA 替代 EVAL,避免重复传输脚本内容:
import redis
r = redis.Redis()
SCRIPT = """
local current = redis.call('GET', KEYS[1]) or '0'
local max = tonumber(ARGV[1])
if tonumber(current) < max then
return redis.call('INCR', KEYS[1])
else
return -1
end
"""
# 首次加载
sha = r.script_load(SCRIPT)
# 后续使用 SHA1 执行
try:
result = r.evalsha(sha, 1, "counter:api", "1000")
except redis.exceptions.NoScriptError:
# 脚本缓存被清除,重新加载
sha = r.script_load(SCRIPT)
result = r.evalsha(sha, 1, "counter:api", "1000")
7.5 Lua 脚本 vs MULTI/EXEC
| 特性 | MULTI/EXEC | Lua 脚本 |
|---|---|---|
| 原子性 | ✅ | ✅ |
| 条件逻辑 | ❌(需配合 WATCH) | ✅(if/else/while) |
| 错误回滚 | ❌ | ✅(脚本内可控制) |
| 减少网络往返 | ✅ | ✅(且更灵活) |
| 复杂逻辑 | ❌ | ✅ |
| 性能 | 略高(无脚本编译开销) | 略低(脚本编译),但 EVALSHA 缓存后无差异 |
| 可读性 | 好 | 需了解 Lua 语法 |
💡 技巧:绝大多数场景下,推荐使用 Lua 脚本 替代 MULTI/EXEC,它更灵活、更强大。
7.6 Lua 脚本的注意事项
脚本执行时间限制
# 默认最大执行时间 5 秒
CONFIG SET lua-time-limit 5000
# 超时后新命令返回 BUSY 错误
# 可以使用 SCRIPT KILL 终止脚本
# 但如果脚本已经执行了写命令,则 SCRIPT KILL 无效,只能 SHUTDOWN NOSAVE
⚠️ 注意:Lua 脚本执行期间会阻塞所有其他客户端请求。脚本必须尽量简短,避免超过 5 秒。
脚本中不能使用不确定性的命令
# ❌ 错误:脚本中不能使用随机命令
EVAL "return redis.call('RANDOMKEY')" 0
# (error) ERR Error running script (user_script:1): @user_script:1: Script attempted to access a global variable 'redis'
# ❌ 错误:脚本中不能使用时间命令(除非用 ARGV 传入)
EVAL "return redis.call('TIME')" 0
# 虽然可以执行,但可能导致主从不一致
💡 技巧:如果脚本需要当前时间,通过 ARGV 参数传入:
EVAL "
local now = tonumber(ARGV[1])
-- 使用 now 进行时间相关操作
redis.call('ZADD', 'queue', now, ARGV[2])
" 0 1715318400 "task1"
📌 业务场景
场景一:电商秒杀(原子库存扣减)
-- 原子扣减库存,超卖检查
EVAL "
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock > 0 then
redis.call('DECR', KEYS[1])
return 1 -- 扣减成功
end
return 0 -- 库存不足
" 1 stock:sku:001
场景二:分布式限流(令牌桶)
-- 令牌桶算法
EVAL "
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- 每秒产生的令牌数
local capacity = tonumber(ARGV[2]) -- 桶容量
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4]) -- 请求的令牌数
local data = redis.call('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(data[1]) or capacity
local last_time = tonumber(data[2]) or now
-- 计算新增令牌
local elapsed = now - last_time
local new_tokens = math.min(capacity, tokens + elapsed * rate)
if new_tokens >= requested then
new_tokens = new_tokens - requested
redis.call('HMSET', key, 'tokens', new_tokens, 'last_time', now)
redis.call('EXPIRE', key, 3600)
return 1 -- 允许
else
redis.call('HMSET', key, 'tokens', new_tokens, 'last_time', now)
redis.call('EXPIRE', key, 3600)
return 0 -- 限流
end
" 1 bucket:user:1001 10 100 1715318400 1
场景三:分布式锁续期(看门狗机制)
-- 续期锁(如果还持有锁)
EVAL "
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
end
return 0
" 1 lock:order:123 "owner-uuid" 30000