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/EXEC | Lua 脚本 | 推荐 |
|---|---|---|---|
| 简单批量命令 | ✅ 最优 | 额外解析开销 | 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 本地测试 |
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。