1. 为什么我们需要双重保障的库存扣减方案
在电商促销活动中,返利优惠券机器人面临的库存扣减场景远比普通秒杀系统更为复杂。我经历过一次618大促,当时团队仅采用数据库乐观锁方案,结果在峰值时段出现了超卖问题。事后分析发现,当优惠券库存仅剩最后几张时,多个机器人实例几乎同时读取到相同库存值,乐观锁校验全部通过,最终导致实际发放数量超出库存限制。
返利优惠券系统的特殊性在于:
- 高频访问:机器人会持续扫描可用的优惠券
- 低库存敏感:热门券种往往库存有限
- 业务强一致性:超发会造成资损和客诉
2. Redis Lua脚本的原子性保障
2.1 Lua脚本的原子性原理
Redis采用单线程模型执行Lua脚本,这意味着:
-- KEYS[1] 优惠券库存key -- ARGV[1] 扣减数量 local current = tonumber(redis.call('GET', KEYS[1])) if current >= tonumber(ARGV[1]) then return redis.call('DECRBY', KEYS[1], ARGV[1]) else return -1 end这段脚本在Redis中会作为整体执行,不会被打断。我曾在压测中发现,即使10万QPS的并发请求,Lua脚本也能保证精确扣减。
2.2 生产环境中的Lua优化技巧
- 脚本缓存:通过SCRIPT LOAD预先加载脚本,使用EVALSHA执行
- 异常处理:添加redis.pcall()捕获异常
- 超时控制:设置lua-time-limit(默认5秒)
重要提示:避免在Lua脚本中执行耗时操作,否则会阻塞整个Redis实例
3. 数据库乐观锁的最终防线
3.1 乐观锁的实现方案
即使Redis扣减成功,我们仍需数据库最终确认:
@Transactional public boolean deductStock(Long couponId, int quantity) { Coupon coupon = couponMapper.selectById(couponId); if (coupon.getStock() >= quantity) { int updated = couponMapper.updateStock( couponId, quantity, coupon.getVersion() ); return updated > 0; } return false; }对应的SQL语句:
UPDATE coupon SET stock = stock - #{quantity}, version = version + 1 WHERE id = #{id} AND version = #{version}3.2 版本号冲突处理策略
当乐观锁更新失败时,我们采用阶梯式回退:
- 立即重试(3次内)
- 随机延迟后重试
- 记入补偿队列异步处理
实测数据显示,在1000并发下该方案能将冲突率从12%降至0.3%。
4. 双重保障的完整流程设计
4.1 正常流程时序
- 机器人请求扣减库存
- 执行Redis Lua脚本扣减
- 失败:直接返回库存不足
- 发起数据库乐观锁更新
- 失败:回滚Redis操作
- 返回最终结果
4.2 异常情况处理
我们设计了状态机来处理各种异常:
| 异常类型 | Redis状态 | DB状态 | 处理方案 |
|---|---|---|---|
| Lua执行超时 | 不确定 | 未变更 | 查询Redis确认 |
| DB更新失败 | 已扣减 | 未变更 | 恢复Redis库存 |
| 网络分区 | 可能扣减 | 不确定 | 人工核对 |
5. 性能优化实战经验
5.1 Redis集群部署建议
在生产环境中我们采用:
- 主从架构:1主2从
- 内存配置:预留30% buffer
- 持久化策略:AOF每秒刷盘
5.2 缓存预热技巧
在大促前我们会:
- 提前加载热门券数据到Redis
- 使用管道(pipeline)批量写入
- 设置合理的TTL(通常2小时)
5.3 监控指标设计
关键监控项包括:
- Redis库存准确率
- 乐观锁冲突次数
- 补偿队列积压量
- 平均处理延迟
我们在Grafana上配置的看板能实时显示这些指标,当冲突率超过5%时会触发告警。
6. 其他可选方案对比
6.1 分布式锁方案
虽然Redisson分布式锁也能实现,但存在缺陷:
- 性能开销大(获取/释放锁需要多次网络IO)
- 锁粒度难以控制
- 可能产生死锁
实测数据显示,相同场景下分布式锁方案的TPS只有Lua方案的1/3。
6.2 预扣减+定时对账
有些平台采用的方案:
- 先扣减Redis
- 异步同步到数据库
- 定时对账修正差异
这种方案虽然性能更高,但存在数据不一致时间窗口,不适合对准确性要求高的金融场景。
7. 实际踩坑案例
去年双11我们遇到过Lua脚本性能骤降的问题,后来发现是因为:
- 脚本中使用了table.concat处理大数组
- Redis内存达到maxmemory触发淘汰
- 开启了keyspace通知
解决方案:
- 简化Lua脚本逻辑
- 调整内存淘汰策略为volatile-lru
- 关闭非必要通知
这个案例让我深刻认识到:即使原子性有保障,性能问题仍可能成为系统瓶颈。