1. 我们为什么需要关注这个问题?
在互联网应用的战场中,秒杀系统就像"春运抢票"般令人窒息。某次大促中,我们使用Redis+Lua实现的库存扣减系统,在10万QPS的冲击下出现了超卖事故——库存显示-500件的诡异场景。事后排查发现,某个Lua脚本中遗漏了库存校验,多个请求同时修改了同一个库存键值。这个惨痛教训让我们意识到:Lua脚本的便捷背后,藏着共享资源管理的暗礁。
2. 典型问题场景重现
(Redis技术栈示例)
2.1 竞态条件:超卖陷阱
-- 错误示例:直接扣减库存
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) > 0 then
redis.call('DECR', KEYS[1])
return 1 -- 扣减成功
end
return 0 -- 库存不足
这段看似合理的代码在高并发下会翻车:当两个请求同时读取到stock=1时,都会执行DECR导致库存变为-1。这就好比超市最后一件商品被两个顾客同时放入购物车。
修复方案:
-- 正确姿势:原子操作一步到位
if redis.call('DECR', KEYS[1]) >= 0 then
return 1
else
redis.call('INCR', KEYS[1]) -- 回滚操作
return 0
end
2.2 重复操作:幂等性缺失
-- 错误示例:重复发放优惠券
local used = redis.call('GET', KEYS[1])
if used == '0' then
redis.call('SET', KEYS[1], '1')
return '发放成功'
end
return '已领取'
在网络抖动时,客户端可能重试成功请求,导致SET操作重复执行。就像快递员反复确认你是否在家,结果把包裹投递了多次。
修复方案:
-- 正确姿势:使用SETNX原子命令
if redis.call('SETNX', KEYS[1], '1') == 1 then
redis.call('EXPIRE', KEYS[1], 86400)
return '发放成功'
end
return '已领取'
2.3 性能黑洞:循环中的资源泄漏
-- 危险操作:未限制的循环查询
local i = 0
while true do
local data = redis.call('LRANGE', 'task_queue', i, i+10)
if #data == 0 then break end
process_data(data)
i = i + 10
end
这个看似高效的批量处理,在队列瞬间涌入百万级任务时,会导致Redis连接被长时间占用,就像高速公路收费站被大货车完全堵死。
修复方案:
-- 正确姿势:分片处理+超时控制
local start_time = redis.call('TIME')[1]
for i=0,1000,10 do -- 限制最大处理量
if redis.call('TIME')[1] - start_time > 5 then -- 超时跳出
break
end
local data = redis.call('LRANGE', 'task_queue', i, i+9)
if #data == 0 then break end
process_data(data)
end
3. 关键技术
3.1 Redis原子操作全家福
INCR/DECR
:原子计数器SETNX
:分布式锁的基石EVAL
:脚本原子执行的保障WATCH/MULTI
:事务监控的哨兵
3.2 Lua脚本优化技巧
- 参数化所有变量:避免硬编码导致的脚本膨胀
-- 错误写法
local user = redis.call('GET', 'current_user')
-- 正确写法
local user = redis.call('GET', KEYS[1])
- 资源预热策略:提前加载热点脚本的SHA1
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# 后续调用
EVALSHA a1b2c3d4 1 user_key
- 熔断机制:在脚本中嵌入安全阀
local begin = redis.call('TIME')[1]
-- 业务逻辑处理
if redis.call('TIME')[1] - begin > 2 then -- 超时2秒自动终止
error('Execution timeout')
end
4. 性能优化实战:秒杀系统改造记
原始方案:
-- 旧版秒杀脚本
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock <= 0 then
return 0
end
redis.call('DECR', KEYS[1])
return 1
压测结果:5000QPS时出现超卖
优化方案:
-- 新版秒杀脚本(带库存预热和分段锁)
-- KEYS[1]: 库存键
-- ARGV[1]: 用户ID
-- ARGV[2]: 当前时间戳
-- 校验请求时效性
if tonumber(ARGV[2]) < redis.call('GET', 'start_time') then
return {-1, '活动未开始'} -- 错误码+消息
end
-- 分段锁设计
local segment = tonumber(ARGV[1]) % 100 -- 将用户分散到100个分片
local lock_key = 'lock:'..segment
if redis.call('SET', lock_key, '1', 'NX', 'EX', 1) then
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) > 0 then
redis.call('DECR', KEYS[1])
redis.call('DEL', lock_key)
return {1, '成功'} -- 结构化返回
end
redis.call('DEL', lock_key)
end
return {0, '库存不足'}
优化效果:成功支撑5万QPS,错误率从3%降至0.01%
5. 避坑指南:老司机经验谈
- 脚本长度警戒线:超过50行的Lua脚本就该考虑拆分
- 死循环检测:所有循环必须包含退出条件
for i=1,100 do -- 明确循环上限
-- 业务逻辑
end
资源释放三原则:
- 用完的连接立即归还
- 获取的锁必须设置过期时间
- 事务操作要有回滚预案
监控指标四件套:
# 脚本执行时长 redis-cli info stats | grep "lua_" # 内存使用情况 redis-cli info memory # 慢查询日志 redis-cli slowlog get # 键空间统计 redis-cli info keyspace
6. 技术选型对比表
方案 | 适用场景 | QPS支撑 | 实现复杂度 | 数据一致性 |
---|---|---|---|---|
纯Lua脚本 | 简单原子操作 | 5万+ | ★☆☆☆☆ | 强一致 |
Lua+分布式锁 | 复杂事务 | 1万-3万 | ★★★☆☆ | 最终一致 |
Redis事务 | 批量操作 | 2万-5万 | ★★☆☆☆ | 弱一致 |
多阶段提交 | 跨节点事务 | 500-1千 | ★★★★★ | 强一致 |
7. 总结与展望
通过三个月的系统优化,我们最终将库存系统的错误率从3.2%降到了0.005%。在这个过程中,我们深刻体会到:Lua脚本就像瑞士军刀——用好了事半功倍,用错了可能伤到自己。未来我们将探索以下方向:
- 脚本自动化分析工具开发
- 基于AI的异常模式识别
- 动态资源配额管理