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脚本优化技巧
  1. 参数化所有变量:避免硬编码导致的脚本膨胀
-- 错误写法
local user = redis.call('GET', 'current_user')

-- 正确写法
local user = redis.call('GET', KEYS[1])
  1. 资源预热策略:提前加载热点脚本的SHA1
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# 后续调用
EVALSHA a1b2c3d4 1 user_key
  1. 熔断机制:在脚本中嵌入安全阀
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. 避坑指南:老司机经验谈

  1. 脚本长度警戒线:超过50行的Lua脚本就该考虑拆分
  2. 死循环检测:所有循环必须包含退出条件
for i=1,100 do  -- 明确循环上限
    -- 业务逻辑
end
  1. 资源释放三原则

    • 用完的连接立即归还
    • 获取的锁必须设置过期时间
    • 事务操作要有回滚预案
  2. 监控指标四件套

    # 脚本执行时长
    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脚本就像瑞士军刀——用好了事半功倍,用错了可能伤到自己。未来我们将探索以下方向:

  1. 脚本自动化分析工具开发
  2. 基于AI的异常模式识别
  3. 动态资源配额管理