Redis 事务、Lua 脚本与原子操作

深入 Redis 事务机制(MULTI/EXEC/DISCARD/WATCH)、Lua 脚本原子执行、EVAL 与 EVALSHA 命令,以及使用 Lua 实现原子扣库存、限流器等实战模式。

1. Redis 事务(MULTI/EXEC/DISCARD)

1.1 事务模型

Redis 事务与关系型数据库事务不同:没有回滚机制,命令在 EXEC 时批量顺序执行,中间某命令失败不影响后续命令。

# 基本事务流程
MULTI           # 开启事务,后续命令入队
SET balance:alice 100
DECRBY balance:alice 30
INCRBY balance:bob 30
EXEC            # 执行所有入队命令
# 返回: [OK, 85, 30]

# 取消事务
MULTI
SET key1 value1
DISCARD         # 取消事务,清空队列,不回滚已执行命令(还没执行)

1.2 WATCH 乐观锁

# 场景:转账时检查余额是否变化
WATCH balance:alice           # 监控 key
GET balance:alice             # 当前余额 100
MULTI
DECRBY balance:alice 30
INCRBY balance:bob 30
EXEC
# 如果在 WATCH 和 EXEC 之间,alice 的 balance 被修改 → EXEC 返回 nil(事务失败)
# 应用层应重试

1.3 事务的限制

限制说明
无回滚命令失败后继续执行后续命令
语法错误入队时检查语法错误,EXEC 拒绝执行
运行时错误如对 String 执行 LPOP,命令失败但继续执行
不隔离其他客户端可在 MULTI 和 EXEC 间读取中间状态

2. Lua 脚本(推荐)

Lua 脚本在 Redis 服务端原子执行,天然解决事务回滚、原子性和复杂逻辑问题。

2.1 EVAL 命令

# 语法: EVAL "脚本" key数量 [key...] [arg...]

# 示例:原子扣减库存(防超卖)
EVAL "
    local stock = tonumber(redis.call('GET', KEYS[1]))
    local qty = tonumber(ARGV[1])
    if stock == nil then
        return -1
    end
    if stock >= qty then
        redis.call('DECRBY', KEYS[1], qty)
        return stock - qty
    else
        return -2
    end
" 1 stock:sku:001 5
# 返回: 剩余库存 或 -1(key不存在) 或 -2(库存不足)

2.2 EVALSHA(缓存脚本)

# 第一次:EVAL,Redis 缓存脚本 SHA1
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# 返回: "a5260dd7e3c0a8f6f3a5d9e1b0c2d3e4f5a6b7c8"

# 后续:用 EVALSHA 执行(减少带宽和解析开销)
EVALSHA a5260dd7e3c0a8f6f3a5d9e1b0c2d3e4f5a6b7c8 1 mykey

2.3 Lua 脚本实战模式

模式 1:分布式锁释放

-- KEYS[1]: 锁的 key
-- ARGV[1]: 持有者标识
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

模式 2:滑动窗口限流

-- KEYS[1]: 限流 key(如 rate:user:1001:api:create)
-- ARGV[1]: 窗口大小(ms)
-- ARGV[2]: 最大请求数
-- ARGV[3]: 当前时间戳(ms)
local window = tonumber(ARGV[1])
local max = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local key = KEYS[1]

-- 清理过期记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 统计当前窗口内的请求数
local count = redis.call('ZCARD', key)

if count < max then
    redis.call('ZADD', key, now, now .. ':' .. redis.call('INCR', key .. ':seq'))
    redis.call('PEXPIRE', key, window)
    return 1  -- 允许通过
else
    return 0  -- 拒绝
end

模式 3:原子读取并删除

-- 获取并删除 key(用于消息队列消费确认)
local value = redis.call('GET', KEYS[1])
if value ~= nil then
    redis.call('DEL', KEYS[1])
end
return value

2.4 Lua 脚本注意事项

注意点说明
执行时间限制lua-time-limit(默认 5s),超时后脚本可被 SCRIPT KILL
禁止随机命令RANDOMKEY、SRANDMEMBER 等在副本同步时有问题
禁止全局变量防止并发问题
脚本原子性执行期间阻塞其他命令,脚本应尽量短小

3. 事务 vs Lua 脚本选择

场景推荐方案原因
批量命令执行MULTI/EXEC简单、无需脚本
条件判断+原子操作Lua事务无回滚和条件判断
分布式锁Lua保证"判断+删除"原子性
复杂聚合逻辑Lua服务端计算减少网络往返
限流/计数Lua原子性+条件判断

4. Redis 函数(Redis Functions,Redis 7.0+)

Redis 7.0 引入了 Redis Functions,作为 Lua 脚本的增强替代:

# 加载函数库
FUNCTION LOAD "#!lua name=mylib\n\
redis.register_function('deduct_stock', function(keys, args)\n\
  local stock = tonumber(redis.call('GET', keys[1]))\n\
  local qty = tonumber(args[1])\n\
  if not stock then return {-1} end\n\
  if stock >= qty then\n\
    redis.call('DECRBY', keys[1], qty)\n\
    return {stock - qty}\n\
  else\n\
    return {-2}\n\
  end\n\
end)"

# 调用函数(无需每次传递脚本体)
FCALL deduct_stock 1 stock:sku:001 5
特性Lua 脚本Redis Functions
持久化仅缓存于内存写入 RDB/AOF 持久化
加载方式客户端每次发送服务端一次性注册
可用性主从复制,主节点丢失后需重新加载跟随数据自动复制
适用版本Redis 2.6+Redis 7.0+

建议:Redis 7.0+ 环境优先使用 Redis Functions,生产环境更可靠。

5. 事务与 Lua 的性能对比

场景MULTI/EXECLua 脚本推荐
简单批量命令✅ 最优额外解析开销MULTI/EXEC
条件判断❌ 不支持✅ 原生支持Lua
复杂计算❌ 不支持✅ 服务端计算Lua
高频调用(百万级/秒)✅ 性能更好有解析开销MULTI/EXEC
需要原子性+逻辑❌ 无回滚✅ 原子执行Lua

性能优化要点

# 1. 使用 EVALSHA 减少带宽
SCRIPT LOAD "..."   # 加载一次
EVALSHA sha1 ...    # 后续全部使用 SHA

# 2. 脚本尽量短小(< 100 行)
# 长脚本阻塞 Redis 主线程,影响整体性能

# 3. KEYS 和 ARGV 的正确使用
# KEYS 用于 Redis 键名(参与集群分片计算)
# ARGV 用于参数值(不参与分片)
EVAL "redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myvalue

# 4. 避免在 Lua 中调用阻塞命令(BLPOP、BRPOP)
# 会导致整个 Redis 实例阻塞

6. 实战:完整秒杀系统原子扣库存

-- 秒杀扣库存 + 防重 + 限流(完整版)
-- KEYS[1]: 库存 key(stock:sku:001)
-- KEYS[2]: 用户已购集合(purchased:sku:001)
-- KEYS[3]: 限流计数器(rate:sku:001)
-- ARGV[1]: 用户 ID
-- ARGV[2]: 购买数量
-- ARGV[3]: 当前时间戳(秒)
-- ARGV[4]: 限流窗口(秒)
-- ARGV[5]: 限流阈值

local stock_key = KEYS[1]
local purchased_key = KEYS[2]
local rate_key = KEYS[3]
local user_id = ARGV[1]
local qty = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local window = tonumber(ARGV[4])
local rate_limit = tonumber(ARGV[5])

-- 1. 检查是否已购买(防重)
if redis.call('SISMEMBER', purchased_key, user_id) == 1 then
    return {-3, '已购买过'}  -- 重复购买
end

-- 2. 限流检查
redis.call('ZREMRANGEBYSCORE', rate_key, 0, now - window)
local current_rate = redis.call('ZCARD', rate_key)
if current_rate >= rate_limit then
    return {-2, '活动太火爆,请稍后重试'}  -- 触发限流
end

-- 3. 扣减库存
local stock = tonumber(redis.call('GET', stock_key) or 0)
if stock < qty then
    return {-1, '库存不足'}  -- 库存不足
end

redis.call('DECRBY', stock_key, qty)
redis.call('SADD', purchased_key, user_id)
redis.call('ZADD', rate_key, now, user_id .. ':' .. now)
redis.call('EXPIRE', rate_key, window)

return {stock - qty, '抢购成功'}
# 调用
EVAL "$(cat seckill.lua)" 3 stock:iphone15 purchased:iphone15 rate:iphone15 \
  user_1001 1 1705315200 60 10000
# 返回值: [498, "抢购成功"] 或 [-1, "库存不足"]

7. 常见问题与陷阱

问题原因解决方案
SCRIPT KILL 杀不掉脚本已执行写命令只能 SHUTDOWN NOSAVE
脚本执行超时lua-time-limit 默认 5s优化脚本逻辑或调整阈值
集群环境下脚本报错KEYS 未覆盖所有涉及的 slot使用 Hash Tag:{user}:1001
脚本内容不一致不同节点缓存不同 SHA统一 SCRIPT LOAD或使用 Redis Functions
调试困难服务端执行,无法打断点先在 redis-cli –eval 本地测试

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「redis」更多文章

  1. 多租户隔离与资源配额:共享 Redis 的边界设计
  2. Key 设计与命名规范:Redis 里唯一的结构约束
  3. 代理与路由方案:客户端直连之外的另一种选择