强曰为道

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

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/EXECLua 脚本
原子性
条件逻辑❌(需配合 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

🔗 扩展阅读