
直接说结论返利优惠券机器人这种高并发场景下“先扣Redis库存再扣数据库库存”是标配思路但真正把它做对、做稳不产生超卖和账目误差关键就在于扣减动作的原子性。标题里的方案我完整落地过一版这里把设计思路、核心代码、踩坑记录都整理出来给正在做类似优惠券、秒杀、库存系统的朋友一个可参考的工程样例。返利优惠券机器人的业务形态其实不复杂运营配置一批优惠券设置总库存用户或机器人任务在活动开始时批量领取。难点在于瞬时流量特别大同一个券的库存可能被几十个线程同时扣减。如果扣减逻辑写得不严谨最常见的后果就是超卖——数据库里剩余库存已经为负用户却还能领到券最后对账的时候怎么都平不上。我给模拟项目X设计的是双重保障Redis加Lua脚本做前置扣减承担高并发流量保证同一个券在Redis层不会扣超数据库加乐观锁做最终兜底保证数据持久层的准确性和可对账性。整个方案的核心理念就一句话用Redis挡流量用数据库保底两层各司其职互不干扰又互相校验。下面把每一层的细节拆开说。1. 业务背景与并发困境为什么库存扣减必须原子1.1 返利优惠券机器人的典型扣减链路先把这个业务链路画清楚。用户在App里点“领取优惠券”请求到达后端接口接口要做的事情包括校验用户资格比如是否新用户、是否已经领过、校验优惠券状态是否在有效期内、是否已抢光、扣减库存、生成用户券记录、推送消息。这里的第三件事扣减库存是最容易出问题的环节。正常写法是不是这样// 错误示例千万别这么写 int stock couponService.getStock(couponId); if (stock 0) { stock stock - 1; couponService.updateStock(couponId, stock); // 发放优惠券 }这段代码的问题在几乎人人皆知的地方并发环境下线程A读取到stock1线程B也读取到stock1两个线程都判断“有库存”然后都执行updateStock最终库存变成0但优惠券发出去两张。这就是典型的先读后写导致的竞态条件。更隐蔽的是即使你给这个方法加了synchronized也只能保证单机内同步一旦服务部署了多实例锁就失效了。1.2 不原子会导致什么超卖、错账、资损库存扣减不原子的后果不只是技术上的数据不一致业务上会直接演化成资损。超卖的直接结果就是这个优惠券实际发出的数量大于配置的库存。如果优惠券本身是虚拟权益还好最多是运营被领导问话如果优惠券对应的是现金返利、实物兑换资格超卖就意味着需要额外采购、额外支出这个钱算谁的最后往往是技术背锅。更麻烦的是错账。返利系统的账务一般按“领取记录”和“核销记录”两条线走如果领取记录比库存多了一笔核销阶段就会对不上财务系统天天报差异。我见过一个真实案例某个电商平台的内部复盘因为扣库存没加锁活动结束后多了几万张券最后是运营手动在后台删掉多余的绑定关系才勉强收场整个对账过程花了三天。所以库存扣减原子性是这个系统的底线。所谓原子就是扣减操作要么完整成功要么完全不发生中间不允许其他操作插入。理解了这条底线你就知道为什么下面这套方案要设计成双层结构。2. 第一道防线Redis Lua脚本原子扣减2.1 核心思路把扣减逻辑塞进Redis服务端执行Redis的Lua脚本能解决原子性问题核心原因是Redis自身是单线程执行命令的。一个Lua脚本在Redis里执行时整个脚本会被当做一个整体期间不会有其他命令插入执行。也就是说脚本里面“读取库存→判断库存→扣减库存→返回结果”这几步在Redis看来就像是只执行了一条命令天然不会被并发干扰。听起来很简单对吧但这里有个关键前提你要记住Lua脚本的原子性仅限于单个Redis实例。如果你用了Redis Cluster并且库存key做了分片Lua脚本操作涉及多个key时会有限制必须用hash tag把key放到同一个槽位。我做的方案里库存key按券ID维度拆分本身就在一个key上操作所以不受影响。为什么不用Redis原生的MULTI/EXEC事务MULTI/EXEC只能保证批量执行不被插入但它不能像Lua脚本那样在事务里做条件判断。你没法“先查库存够不够不够就不扣”因为MULTI模式下的命令是执行前就入队好的你没法在事务中间根据查询结果决定要不要执行某条命令。WATCH倒是能做乐观锁但WATCH一旦发现key被修改整个事务就取消需要客户端重试在高并发下重试率很高性能并不理想。Lua脚本是更优雅的解法。2.2 完整Lua脚本与Java调用示例这是我的库存扣减脚本实际项目里可以直接抄-- KEYS[1]: 库存key例如 coupon:stock:1001 -- ARGV[1]: 扣减数量一般传1 -- ARGV[2]: 检查用的阈值一般传0 local stock redis.call(get, KEYS[1]) -- 库存key不存在时返回特殊值方便外层感知并触发预热补偿 if not stock then return -2 end stock tonumber(stock) if stock tonumber(ARGV[2]) then return -1 end -- 执行扣减 redis.call(decrby, KEYS[1], ARGV[1]) return stock - tonumber(ARGV[1])脚本返回值含义返回值含义-2库存key不存在需要触发库存预热-1库存不足扣减失败非负数扣减成功返回剩余库存Java侧用Spring Data Redis里的DefaultRedisScript来调用注意泛型用LongComponent public class CouponStockDeductionService { private static final String DEDUCT_SCRIPT local stock redis.call(get, KEYS[1]) if not stock then return -2 end stock tonumber(stock) if stock tonumber(ARGV[2]) then return -1 end redis.call(decrby, KEYS[1], ARGV[1]) return stock - tonumber(ARGV[1]); private final RedisTemplateString, String redisTemplate; private final DefaultRedisScriptLong deductScript; public CouponStockDeductionService(RedisTemplateString, String redisTemplate) { this.redisTemplate redisTemplate; this.deductScript new DefaultRedisScript(); this.deductScript.setScriptText(DEDUCT_SCRIPT); this.deductScript.setResultType(Long.class); } /** * 扣减库存 * return true-成功 false-失败 */ public boolean tryDeduct(String couponId, int delta) { String stockKey buildStockKey(couponId); Long result redisTemplate.execute( deductScript, List.of(stockKey), String.valueOf(delta), 0 ); // 返回 -2 时触发主动预热逻辑见下文 if (result ! null result -2L) { initStockIfAbsent(couponId); return tryDeduct(couponId, delta); } return result ! null result 0; } }为什么脚本里传入“阈值”而不直接写死0因为有些业务场景是“剩余库存小于某个数就拒绝”比如虚拟券每轮放量只放出200张阈值就可以动态传。这个设计用得上最好用不上也不碍事。2.3 脚本并发测试与Redis单线程的边界给这个脚本做并发测试时我在一台模拟环境里开了200个线程同时扣减同一个券的库存初始库存设为200。最终跑完Redis里的剩余库存是0没有出现负数。这就是Lua脚本的效果因为每个线程的脚本调用是排队执行的200次扣减在Redis里被串行化无论如何都不会扣超。但这套方案有个边界你要清楚Redis扣减只是“挡在数据库前面的第一道闸门”它本身不能保证最终账目正确。原因有两个。第一Redis不是可靠持久化存储。如果Redis是仅开启RDB的模式默认的持久化策略下当Redis的宕机会丢失最近一段时间内的数据变更。假设扣减成功的操作在Redis宕机后丢了用户领了券但Redis库存没扣重启后库存又变回原值等于变相超发。第二Redis扣减成功不代表后续业务一定成功。你扣了库存之后还要写数据库、发消息、生成券码这些步骤中任何一个失败Redis库存都少了但业务没完成导致“幽灵库存”。所以必须上第二道防线用数据库的乐观锁保证业务层的最终正确性。3. 第二道防线数据库乐观锁兜底3.1 库存表设计与版本号机制数据库这层做的是最终兜底。我把库存表设计成这样的结构CREATE TABLE coupon_stock ( id BIGINT AUTO_INCREMENT PRIMARY KEY, coupon_id BIGINT NOT NULL COMMENT 优惠券ID, total_stock INT NOT NULL COMMENT 总库存, remain_stock INT NOT NULL COMMENT 剩余库存, version BIGINT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_coupon_id (coupon_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT优惠券库存表;乐观锁的机制你可以理解成每次扣减库存之前先读一次当前版本号扣减的时候不仅要求剩余库存足够还要求版本号和我之前读到的一致。UPDATE语句里带上version#{oldVersion}这个条件如果期间有其他人改过这条记录version就变了这条UPDATE会受影响行数为0也就是更新失败说明有并发冲突。3.2 乐观锁扣减SQL与Java实现核心SQL长这样UPDATE coupon_stock SET remain_stock remain_stock - #{delta}, version version 1 WHERE coupon_id #{couponId} AND remain_stock #{delta} AND version #{version}注意这里的条件有两个remain_stock delta是业务校验version #{version}是并发校验。二者同时满足才会更新成功。MyBatis里对应这样写public interface CouponStockMapper { Update(UPDATE coupon_stock SET remain_stock remain_stock - #{delta}, version version 1 WHERE coupon_id #{couponId} AND remain_stock #{delta} AND version #{version}) int deductStock(Param(couponId) Long couponId, Param(delta) int delta, Param(version) Long version); }配套的Service方法Transactional(rollbackFor Exception.class) public boolean dbDeductStock(Long couponId, int delta) { CouponStock stock stockMapper.selectByCouponIdForUpdate(couponId); if (stock null) { // 理论上预热时已初始化这里做兜底 stockMapper.initStock(couponId, delta); } for (int retry 0; retry 3; retry) { int rows stockMapper.deductStock(couponId, delta, stock.getVersion()); if (rows 1) { return true; } // 更新失败重新读取最新版本号重试 stock stockMapper.selectByCouponId(couponId); if (stock null || stock.getRemainStock() delta) { return false; } } return false; }看到这里的疑问可能是加锁查一遍再用乐观锁更新是不是有点重复我解释一下。selectByCouponIdForUpdate加的是行锁可以避免多个线程同时读同一行的相同版本号然后各自UPDATE时互相阻塞。不过在库存扣减这个场景里我实测用不用ForUpdate影响不大因为乐观锁更新本来就允许一定比例的失败重试。保留ForUpdate的作用是让重试逻辑更顺畅不至于版本号连续被覆盖。3.3 数据库扣减失败的重试策略与阈值设定乐观锁在高并发下的失败率会随着冲突增多而上升。我的做法是重试3次每次重新读取版本号。3次是基于实际压测选的这个模拟项目X的峰值QPS在800左右单券并发大概200乐观锁第一次成功率达到70%重试一次后超过95%3次基本能到99.9%。再往上加重试次数收益就很小了反而会占用数据库连接和事务时间。还有个细节值得说不要用“剩余库存自减后判断是否大于0”的写法。很多人图省事先UPDATE把库存减1然后SELECT看是否小于0小于0再回滚。这种写法在并发不高时没问题但高并发下会产生大量的无效回滚和死锁数据库压力翻倍。直接在UPDATE的WHERE条件里带上remain_stock delta让数据库自己判断更新失败就说明库存不足或版本冲突干脆利落。4. 双层协同一次完整领取流程的实操实现4.1 “预热 Lua扣减 DB落账 异步对账”的完整时序把两层串起来的完整流程我建议设计成下面这样第一步活动开始前做库存预热。把数据库里的剩余库存同步到Redis同时设置一个足够长的过期时间避免活动中途key失效导致所有扣减都失败。第二步用户请求进来先走Lua脚本扣减Redis库存。这一步承受绝大部分流量毫秒级返回。扣减失败就直接返回“已抢光”或“领取失败”不再往后走。第三步Redis扣减成功之后发送一条“库存扣减成功”的MQ消息异步触发数据库扣减。为什么不直接在请求线程里同步扣数据库因为同步扣库会把数据库的TPS拉到Redis的水平数据库根本扛不住而且会成倍拉长接口响应时间。用MQ削峰数据库按照自己的节奏消费每秒处理几百笔事务完全没问题。第四步数据库乐观锁扣减成功生成用户券记录。如果数据库扣减失败比如版本冲突次数耗尽或库存被兜底逻辑纠正就回补Redis库存同时记录一条异常流水方便对账。打个比方Redis是站在门口快速数人头的保安数据库是最终签字确认的财务保安放进去的人财务如果发现没资格还得把人请出去再把进门计数减回去。这个计数回补就是第四步的意义。4.2 库存预热与Redis Key设置预热逻辑我封装成一个独立服务在活动开始前10分钟由定时任务触发Component public class CouponStockPreheatService { private final RedisTemplateString, String redisTemplate; private final CouponStockMapper stockMapper; public void preheat(Long couponId) { Long remainStock stockMapper.selectRemainStock(couponId); String stockKey coupon:stock: couponId; redisTemplate.opsForValue().set(stockKey, String.valueOf(remainStock), 30, TimeUnit.MINUTES); } }这个Key我习惯带上“coupon:stock:”前缀方便按活动维度清理。过期时间设为30分钟覆盖整个活动周期。需要注意的一点是如果活动周期很长比如持续两天不要用固定过期时间而是做一个后台任务每隔20分钟刷新一次过期时间防止中途丢Key。4.3 Redis扣减成功之后数据库扣减失败的回补逻辑先看代码再解释为什么必须这样回补public void handleDbDeductFail(Long couponId, int delta) { // 1. 回补Redis库存 String stockKey coupon:stock: couponId; redisTemplate.opsForValue().increment(stockKey, delta); // 2. 记录异常流水 stockDeductLogDao.insert(new StockDeductFailLog( couponId, delta, DB_DEDUCT_FAIL, LocalDateTime.now() )); // 3. 如果回补后Redis库存大于活动初始值做一次校准 String val redisTemplate.opsForValue().get(stockKey); if (val ! null Integer.parseInt(val) initStockMap.get(couponId)) { redisTemplate.opsForValue().set(stockKey, String.valueOf(initStockMap.get(couponId))); } }为什么强调回补而不是放任不管如果Redis扣了库存但数据库没扣成功而你又不去回补Redis这一张券的库存就永久蒸发了用户会看到“还有库存但总是领取失败”体验极差。回补Redis之后这个券额可以被下一个用户正常领取。这里还有一个容易踩的坑MQ消息消费失败时一定不要直接丢弃消息。我用的是本地消息表加定时任务扫描的方案扣减Redis成功时先写一条本地消息表记录状态为待处理发MQ的同时落库。消费端处理完数据库扣减后回调更新消息表状态。定时任务每隔5分钟扫一次待处理的消息把超时未确认的重新投递。这个设计确保了数据库扣减不会因为MQ丢失而漏执行。4.4 压测数据与参数选择参考整套方案我在模拟环境里做过压测先说明下测试环境4核8G的Java服务节点单实例RedisMySQL 5.7并发量用JMeter模拟500线程连续请求3分钟。初始库存设置1万最终结果Redis库存扣到0数据库剩余库存0没有超卖接口平均响应时间126msP99响应时间310msRedis单节点CPU峰值大约在60%左右。有几个参数值得记录供大家参考Redis连接池最大连接数建议设置为50到100太高会造成内存浪费太低在突发流量下会等待。我用的配置是maxTotal50maxIdle20实测够用。Lua脚本的timeout设置在Redis客户端侧要稍微大一点因为脚本排队执行时如果并发特别高脚本可能等待几十毫秒甚至上百毫秒。Spring Data Redis默认的timeout是2秒我调到了3秒。数据库连接池最大连接数设为20因为扣减操作都是单条UPDATE执行很快20个连接足够支撑每小时几万笔的扣减事务。需要提醒的是压测结果只能作为参考实际性能和你Redis实例规格、网络带宽、脚本复杂度都有关系。但有一点是确定的这套方案把流量挡在Redis层数据库的压力非常可控。5. 常见问题与排查技巧实录5.1 问题速查表把我在这一套方案上线后遇到的典型问题整理成表格方便大家直接对照排查现象可能原因解决办法用户明明看到还有库存点击领取却提示失败Redis库存Key过期脚本读到nil脚本里处理nil并触发预热用定时任务刷新Key过期时间接口偶发报错Could not return a non-OK responseLua脚本返回值类型映射不对DefaultRedisScript的setResultType必须设成Long.class数据库库存没有扣减但Redis库存变少了MQ消息丢失或消费失败引入本地消息表定时任务确保数据库扣减最终执行活动结束后对账发现Redis库存和数据库库存差几张回补逻辑没记录流水或补偿时重复回补每笔扣减和回补都写流水日志通过流水做两边差额核对高并发下数据库死锁扣减顺序不一致或事务里有多条更新语句单个扣减事务尽量只包含一条UPDATE多个Key扣减按固定顺序排序某个券的Redis库存突然清零预热时Key值设置错误或脚本里用了错误的Key命名检查Key前缀确保预热和扣减用的是同一个Key5.2 最容易踩坑的四个细节第一个坑Lua脚本返回nil。很多人的Lua脚本只写了扣减逻辑没写key不存在时的分支结果Redis返回nilJava侧拿到后类型转换直接NPE或者反序列化报错。我自己的Lua脚本返回了-2这类自定义值调用方根据具体值做不同处理可以从根上规避这个问题。第二个坑预热时不能把数据库剩余库存直接覆盖到Redis。如果活动已经进行到一半Redis和数据库的库存可能因异步消费存在短暂差异直接覆盖会把数据库还没扣成功的部分也变成“已扣减”。所以预热只能在活动开始前或者Redis库存为0时做千万别在运行中随意重置。第三个坑乐观锁的version字段一定要在UPDATE里做自增逻辑而不是先读出来加1再传进去。如果你在Java里对version做加法再传入SQL会有一批线程传入相同的旧version值导致其中一个成功、其余全部失败重试逻辑会变得异常频繁。第四个坑MQ场景下一定不要“发了就不管”。从Redis扣减到数据库扣减中间的链路是最容易丢失数据的环节。我见过一个案例某团队直接用RabbitMQ的autoAck模式消费结果消费线程在处理库存更新前被kill -9消息还没来得及处理就被确认了最后数据库库存比Redis多出几百活动结束怎么都排查不出来。用本地消息表可以有效避免这类问题。5.3 从一次线上事故看对账的重要性这套系统上线后的第一个月就出过一次对账告警。某个优惠券的Redis库存显示剩余35张数据库剩余却是40张差了5张。排查下来竟然是压测时的遗留数据压测脚本里创建的本地消息表记录没有清除定时任务把这批遗留消息又投递了一遍导致数据库重复扣减。但库存为什么反而多了因为重复扣减时乐观锁的版本号冲突重试3次后放弃并回补了Redis库存就把Redis回补多了。这个案例让我意识到单纯靠代码里的回补逻辑是不够的你还需要一个独立的对账任务。我后面加了一个定时对账单每10分钟统计一次Redis库存总量和数据库库存总量发现差额超过阈值就触发告警并把差额明细写入日志表。对于返利系统这种直接涉及资金的场景把对账作为常态化机制远比“出了问题再补”可靠。结尾这套双重保障方案我落地之后最大的体会是高并发场景下的库存准确性从来不是靠某一个单一机制达成的。Redis的Lua脚本用极致性能挡住了99%的并发数据库的乐观锁用可靠性兜住了最后1%的意外中间的MQ、本地消息表、对账任务则像胶水一样把两层粘成一个整体。少了任何一环系统都会暴露出不同形态的坑。最后分享一个小技巧给这套方案做上线后的观察期我习惯在前两周每天拉一次库存差异明细逐笔比对Redis扣减流水和数据库扣减流水。虽然批量对账任务已经在跑但人工抽查几笔关键券的流水能让你对系统的行为摸得更透后面遇到问题时的排查速度会快很多。库存扣减这个场景说到底拼的是对细节的把控把每条异常路径都想清楚就不会被“看起来很小”的并发问题打得措手不及。