
1. 项目概述与盲盒功能的价值拆解1.1 为什么电商项目都开始做盲盒了手办商城做盲盒功能本质上不是在卖商品而是在卖一种“不确定性带来的情绪价值”。传统电商的商品详情页再怎么优化用户决策链路始终是“看参数、比价格、看评价”链条长、转化慢。盲盒玩法把决策成本压缩成一次点击——用户不需要纠结买哪款他只需要决定“要不要抽一次”。我做过几个电商类项目一个很深的体感是盲盒的客单价可以做到普通商品的两到三倍但用户投诉率反而更低。原因是用户在点击抽奖按钮的瞬间大脑已经把“没抽到隐藏款”预设成一种可接受的成本而抽中普通款时反而会产生“至少没亏太多”的补偿心理。这种心理机制利用好了是天然的用户留存抓手。从技术角度拆解一个完整的盲盒功能其实由四部分组成随机抽取逻辑、库存扣减、中奖记录、概率控制。最核心、最容易出问题的点是前两个——随机算法怎么写才能既保证体验又可控库存扣减怎么做才能在高并发下不超卖。这也是本篇要重点展开的源码级实现方案。1.2 技术选型与项目基础环境这套商城本身基于Spring Boot MyBatis-Plus Redis MySQL搭建JDK用的1.8前后端分离前端是Vue。盲盒功能作为商城内的一个独立模块虽然业务上看起来就是“抽奖”但它会牵涉到订单系统、库存系统、支付回调等多个既有模块的联调所以设计时要留出足够的扩展空间。具体用到的核心依赖版本如下Spring Boot 2.6.3MyBatis-Plus 3.5.1Redis 6.x用的Spring Data RedisMySQL 8.0LombokMapStruct为什么选Redis后面讲并发控制的时候会详细说。这里先抛出结论盲盒功能本质上是一个“高并发下的概率性扣减库存”问题单靠数据库行锁也能做但性能上会比Redis Lua脚本的方案差一个数量级而且数据库连接池很容易被抽奖瞬间的打满。如果你手头项目用的不是Spring Boot而是传统的SSM框架核心思路完全一样只需要把注解配置换成XML配置即可。2. 整体架构与数据库设计2.1 盲盒模块的核心链路设计盲盒模块的请求链路我画了一条很清晰的路径想明白这条链路写代码的时候就不会东一榔头西一棒子。用户点击抽取按钮请求到后端BoxDrawController然后按顺序走参数校验检查用户是否登录检查盲盒活动是否在有效期内检查用户有没有抽奖次数预扣库存通过Redis的Lua脚本原子性扣减盲盒对应款式库存这一步直接在Redis层完成随机选款根据权重计算中奖款式写库保存中奖记录、生成订单、记录库存流水异步处理发送通知消息、更新用户统计信息。最终返回结果给前端前端展示“恭喜获得某某手办”同时展示该款式的图片和信息。这里面有几个容易踩坑的设计决策提前说明一下。第一为什么不先随机选款再扣库存因为随机选款是JVM内的操作扣库存是跨系统的操作先选款会导致在极端并发下选中的款式已经没库存了还得重新选或者抛异常逻辑上多了一个失败分支。先扣库存再选款只要扣成功了就一定能选出一款逻辑链是完整的。第二为什么中奖记录和订单要同时生成因为盲盒抽到的商品用户是可以选择“发货”或者“直接转卖”的很多手办商城支持这个玩法拆单会导致后续流程割裂。一次抽取同时产出中奖记录和待支付/已支付订单状态机更干净。2.2 数据库表结构实现盲盒主题、款式与中奖记录核心表一共四张分别是盲盒主题活动表、盲盒款式表、盲盒抽奖记录表、盲盒款式库存流水表。每张表的字段设计的考量点都写清楚。盲盒主题活动表box_activity字段包括主键id、活动名称、活动编码、开始时间、结束时间、每日限抽次数、总限抽次数、状态0未开始1进行中2已结束3已下架、创建时间、更新时间。盲盒款式表box_style字段包括主键id、活动id、款式名称、款式编码、款式图片、款式等级1普通、2稀有、3史诗、4传说、权重、展示概率、库存总量、剩余库存、已抽次数、状态、创建时间、更新时间。这里务必注意权重的单位不是百分比而是一个正整数所有款式的权重之和无需等于100。举个例子A款权重是1B款权重是2C款权重是97那总权重是100A款的中奖概率就是1%。但如果上架一个隐藏款权重设为20总权重变成120所有款式的概率自动重新归一化。这样设计的好处是运营新增、下架款式时不需要手工重算所有概率系统自动动态归一化容错率极高。盲盒抽奖记录表box_draw_record字段包括主键id、抽奖流水号唯一、用户id、活动id、款式id、款式名称、抽奖结果1成功2未中奖、来源1积分抽奖2现金抽奖3活动赠送、消耗积分/金额、是否发货0未发货1已发货、发货单号、创建时间。盲盒款式库存流水表box_style_stock_log字段包括主键id、流水号、款式id、变动数量正数为入库负数为出库、变动类型1初始化入库2扣减3回滚、关联订单号、操作人、创建时间。库存流水表是很多团队容易忽略的但实际上它承担了两个极其重要的职责第一排查问题——用户投诉“明明抽到了但没发货”一查流水就能定位库存到底扣没扣扣在哪一步了第二对账——每天晚上定时任务跑一遍库存流水和实际库存做比对不一致就告警。SQL建表脚本我贴一下核心部分CREATE TABLE box_style ( id bigint(20) NOT NULL AUTO_INCREMENT, activity_id bigint(20) NOT NULL COMMENT 活动ID, style_name varchar(50) NOT NULL COMMENT 款式名称, style_code varchar(32) NOT NULL COMMENT 款式编码, style_image varchar(255) DEFAULT NULL COMMENT 款式图片, style_level tinyint(4) NOT NULL DEFAULT 1 COMMENT 款式等级1普通 2稀有 3史诗 4传说, weight int(11) NOT NULL DEFAULT 1 COMMENT 中奖权重, show_rate decimal(5,2) DEFAULT NULL COMMENT 展示概率, total_stock int(11) NOT NULL DEFAULT 0 COMMENT 库存总量, remain_stock int(11) NOT NULL DEFAULT 0 COMMENT 剩余库存, drawn_count int(11) NOT NULL DEFAULT 0 COMMENT 已抽次数, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态0停用 1启用, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_activity_id (activity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT盲盒款式表;关于drawn_count这个字段多说一句。它有缓存计数的功能但如果把计数放在这里每次抽奖成功都要UPDATE box_style SET drawn_count drawn_count 1在高并发下这是很大的写压力。更稳的做法是这个字段只是一个异步统计的落库结果实时展示的已抽次数从Redis的ZSET或者String计数器里读每晚定时把Redis的值刷回数据库。后面并发章节会再涉及。2.3 概率展示与真实概率为什么必须分开这是盲盒功能里最隐蔽但是最重要的设计原则给用户展示的概率和系统真实使用的概率必须通过权重机制解耦。前端页面展示给用户看的是“展示概率”它可能是四舍五入后的整数但系统做随机抽取用的是权重权重算出来的实际概率可能是8.333%这种小数。如果直接把展示概率拿来当随机条件就一定会出现“展示的概率和实际抽到的概率有偏差”的问题用户一旦通过大量抽奖统计出实际概率偏低投诉和信任崩塌就是必然的。权重大小的设置策略我的实践心得是传说道具权重1-2史诗5-10稀有20-40普通款权重随便给大50-100都行。原因很简单权重的差至少得拉开一个量级随机结果才够稳定。如果传说权重1普通权重2你觉得很合理但实际上传说款出现的概率高达33%整个盲盒的稀有度体系瞬间崩塌。权重差距拉开之后普通款的高权重可以填充大量抽奖结果稀有款保持比较克制的出现频率运营才能通过调整权重找到平衡点。3. 核心随机算法与源码实现3.1 基于权重区间的随机算法选择随机算法这块常见方案有三种按权重比例直接取随机数、按权重构建区间后二分查找、按权重构建前缀和数组后二分查找。第一种方案最简单把所有款式按权重高低排序生成一个随机数从头遍历累加权重直到超过随机数命中的就是要选的款式。这个方案的问题在于复杂度是O(n)如果盲盒主题下有几十个款式每个请求都要做几十次比较在高并发下虽然不至于成为瓶颈但代码写起来总觉得不够优雅。第二种和第三种本质是一回事核心思想是把权重转成区间然后通过二分查找定位款式A权重10区间[0, 10) 款式B权重30区间[10, 40) 款式C权重60区间[40, 100)生成一个[1, 100]之间的随机数落在哪个区间就选哪款。用前缀和数组存储区间的右边界二分查找第一个大于等于随机数的位置时间复杂度降到O(log n)。我实际项目里用的是第二种改良版因为盲盒款式的数量级撑死几十个O(n)和O(log n)的性能差异完全可以忽略但前缀和数组配合二分查找的代码更不容易出错语义也更清晰。代码实现如下。public class WeightRandomT { private final ListT items new ArrayList(); private final int[] prefixSum; private final int totalWeight; private final Random random new Random(); public WeightRandom(ListWeightItemT weightItems) { int size weightItems.size(); this.prefixSum new int[size]; int sum 0; for (int i 0; i size; i) { sum weightItems.get(i).getWeight(); this.prefixSum[i] sum; this.items.add(weightItems.get(i).getItem()); } this.totalWeight sum; } public T next() { int target random.nextInt(totalWeight) 1; int index binarySearch(prefixSum, target); return items.get(index); } private int binarySearch(int[] arr, int target) { int left 0, right arr.length - 1; while (left right) { int mid (left right) 1; if (arr[mid] target) { left mid 1; } else { right mid; } } return left; } }这段代码的精髓在binarySearch方法里找的是“第一个大于等于target”的位置也就是**左边界二分**。如果写成普通的二分查找找相等值很容易因为区间边界没处理对导致随机结果偏向某一边。这个坑我当初踩过推荐直接用JDK的Arrays.binarySearch但要注意它的返回值是插入点取反处理起来反而绕不如手写上面这个。 ### 3.2 概率计算与权重动态配置的联动逻辑 权重是从数据库读的但在高并发场景下不可能每个请求都去数据库读一次权重列表。我的做法是活动开始或配置变更时把整个活动的款式权重列表加载到Redis用Hash结构存储然后通过本地缓存版本号做二级缓存。 服务启动的时候从Redis读取权重版本号box:weight:version:{activityId}如果本地缓存的版本号和Redis一致直接用本地缓存不一致就重新加载权重列表、重建WeightRandom对象。 为什么版本号用Redis存储而不是数据库因为配置变更的频率极低但权重数据需要被所有实例共享感知Redis天然就是干这个的。每次改权重操作后台更新数据库后同时INCR一下Redis的版本号所有实例的本地缓存最多隔几毫秒就会因为版本号不一致而自动重建这个方案简单可靠实测效果很好。 java public WeightRandomBoxStyle getWeightRandom(Long activityId) { Long currentVersion redisTemplate.opsForValue().get(BOX_WEIGHT_VERSION_KEY activityId); if (currentVersion null) { refreshWeightCache(activityId); currentVersion redisTemplate.opsForValue().get(BOX_WEIGHT_VERSION_KEY activityId); } WeightRandomBoxStyle local localCache.get(activityId); if (local ! null currentVersion.equals(local.getVersion())) { return local; } // 重建WeightRandom ListWeightItemBoxStyle weightItems loadWeightItemsFromDb(activityId); WeightRandomBoxStyle newRandom new WeightRandom(weightItems); newRandom.setVersion(currentVersion); localCache.put(activityId, newRandom); return newRandom; } ### 3.3 随机数生成器的线程安全性问题 Random类在高并发下会有轻微的性能问题因为它内部通过CAS不断重试来保证线程安全。盲盒抽奖是典型的超高并发场景如果每个请求都new一个Random反而更浪费。我建议用ThreadLocalRandom替换它是JDK 7引入的通过ThreadLocal为每个线程维护独立的种子既线程安全又没有CAS竞争吞吐量比Random高不少。 java // 不建议 private final Random random new Random(); int target random.nextInt(totalWeight) 1; // 推荐 int target ThreadLocalRandom.current().nextInt(totalWeight) 1; 还有一个细节**不要用Math.random()**。它底层虽然也用了Random但每次调用都会生成一个Random对象JDK 8之前性能极差而且返回的是double在使用nextInt(totalWeight)时还要再做一次乘法和取整白白损失精度和性能。盲盒概率这种对精度有要求的场景直接上ThreadLocalRandom干净利落。 ## 4. 盲盒并发防超卖分布式锁与Redis Lua实现 ### 4.1 超卖问题的本质与常见的三种处理方案 盲盒抽奖的超卖问题和秒杀本质上是同一类问题大量请求同时到来每个请求都要扣减库存如果扣减操作不是原子的就会出现库存透支。 先看一个最典型的错误写法很多初学者会写 java // 错误示范先查再扣 BoxStyle style boxStyleMapper.selectById(styleId); if (style.getRemainStock() 0) { style.setRemainStock(style.getRemainStock() - 1); boxStyleMapper.updateById(style); } 这段代码在并发下一定会超卖。两个线程同时查到剩余库存是1都判断大于0都扣减成0但实际库存应该是-1。这种问题把数据库隔离级别调到可串行化也解决不了因为这里根本不是隔离级别的问题而是典型的“检查-操作”竞态条件。 业界常见的处理方案有三种 第一种是数据库乐观锁在更新语句里加WHERE remain_stock 0利用数据库的原子性保证不会扣成负数。 sql UPDATE box_style SET remain_stock remain_stock - 1 WHERE id #{styleId} AND remain_stock 0 这个方案简单有效但在并发量很高的时候大量请求会因为更新行数为0而失败用户会看到频繁的“手慢了”提示体验一般但至少数据不会错。 第二种是分布式锁先获取Redis分布式锁再执行查询和更新释放锁。问题在于锁粒度和性能的博弈拿一把全局锁会把所有抽奖请求都串行化吞吐量直接打骨折缩小锁粒度到款式维度又需要维护大量的锁Key锁超时时间还得仔细调。 第三种是Redis Lua脚本原子扣减也就是我这个项目采用的方式。Redis的Lua脚本是原子执行的脚本执行期间不会被其他命令插入天然满足“扣减库存判断库存充足”这两个操作的原子性要求。 ### 4.2 Lua脚本实现原子扣减的完整源码 先看核心的Lua脚本这段脚本是盲盒库存扣减的守门员所有抽奖请求必须先过它这一关。 lua -- KEYS[1]: 款式库存Key例如 box:stock:1001 -- KEYS[2]: 已抽次数Key例如 box:drawn:1001 -- ARGV[1]: 扣减数量正常都是1 local stock redis.call(GET, KEYS[1]) if not stock then return -1 end if tonumber(stock) tonumber(ARGV[1]) then return -2 end redis.call(DECRBY, KEYS[1], ARGV[1]) redis.call(INCR, KEYS[2]) return 0 脚本返回三种状态-1表示库存Key不存在说明服务还没把库存加载到Redis属于异常状态-2表示库存不足用户看到的就是“这款已经被抽完了”0表示扣减成功。 对应的Java调用代码如下 java public int preDeductStock(Long styleId, int count) { DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(STOCK_DEDUCT_LUA); script.setResultType(Long.class); ListString keys Arrays.asList( BOX_STOCK_KEY styleId, BOX_DRAWN_KEY styleId ); Long result redisTemplate.execute(script, keys, String.valueOf(count)); if (result null) { return -1; } return result.intValue(); } 初始化库存的时候通过一个简单的SET命令把数据库里的剩余库存刷到Redis java public void initStock(Long styleId) { BoxStyle style boxStyleMapper.selectById(styleId); redisTemplate.opsForValue().set(BOX_STOCK_KEY styleId, style.getRemainStock().toString()); } 这里有个细节要注意**Lua脚本里KEYS和ARGV的取值在Java里用Spring Data Redis执行时keys和args都会被转换成字符串数组所以count传进去是字符串在Lua里通过tonumber转成数字比较**这个转换一步都不能省。 ### 4.3 Redis扣减成功与数据库写库如何保证最终一致 Redis扣减成功了但后续数据库写库失败怎么办这是分布式系统里典型的最终一致性问题我的方案是**本地消息表定时补偿**。 具体流程是这样的Redis扣减库存成功之后在同一个本地事务里先插入一条中奖记录状态为待确认然后插入一条库存流水。如果这一步数据库失败事务回滚但Redis的库存已经被扣了怎么办答案是在事务回滚的catch块里调用Redis的反向补偿接口把刚才扣的库存加回去。 java Transactional(rollbackFor Exception.class) public BoxDrawRecord draw(Long activityId, Long userId) { // 1. 随机选款 BoxStyle selected randomSelector(activityId); // 2. Redis预扣库存 int result preDeductStock(selected.getId(), 1); if (result -2) { throw new BizException(手慢啦这款刚刚被抽完啦); } if (result -1) { throw new BizException(活动未初始化请联系客服); } try { // 3. 写库插入中奖记录、生成订单、更新数据库库存 BoxDrawRecord record saveDrawRecord(activityId, userId, selected); // 4. 数据库乐观锁扣减剩余库存 deductDbStock(selected.getId()); return record; } catch (Exception e) { // 5. 回滚Redis库存 rollbackStock(selected.getId(), 1); throw e; } } 这里可能会有人问都在一个方法里了为什么不直接依赖数据库事务把Redis一起管了因为Redis不是数据库不支持事务回滚。所以只能在catch块里手动补偿这也就是“最终一致”的含义——不是同时成功而是保证“要么都成功要么通过补偿回到最初的库存状态”。 在实际压测中这种方案的补偿成功率在99.9%以上。极端情况下比如补偿Redis时Redis挂了那就会进入定时任务的兜底链路 每天晚上跑一个定时任务扫描中奖记录表和库存流水表比对Redis中的实时库存和数据库中的剩余库存不一致就按照数据库流水重新校准Redis库存。这样即使Redis补偿失败第二天也能自动修正。 ## 5. 隐藏款控制与运营后门的实现思路 ### 5.1 隐藏款投放的两种控制模式对比 手办盲盒最大的卖点就是隐藏款。运营希望把隐藏款的投放控制在特定范围比如“前1000抽必出1个隐藏款”或者“每天最多出5个隐藏款”这时候单纯的权重随机就不够用了。 在实际项目中隐藏款的控制有两种模式。 第一种是纯概率模式适合小规模、低客单的盲盒。隐藏款权重设为极低值比如总权重1万隐藏款权重只有5概率就是万分之五。这种模式最简单但有一个天然缺陷极端情况下可能前10000抽都抽不出一个隐藏款用户骂声一片也有可能前100抽就出了2个运营这边会觉得“太快了少赚很多”。 第二种是保底计划模式也就是“随机数抽奖次数计数”联动。系统维护一个“隐秘计数”变量记录当前活动已经抽了多少次每抽到一个隐藏款就清零重计。当计数达到某个阈值比如500下一位幸运用户强制命中隐藏款如果没到阈值还是走权重随机。这种模式能兜底用户抽到一定次数后心理上会有预期运营也能精确控制隐藏款的投放节奏。 ### 5.2 抽奖计数与强制命中的实现细节 我用Redis Hash来存储每个活动的抽奖计数和隐藏款状态 box:hidden:activityId - { drawCount: 2345, // 该活动累计抽奖次数 lastHitCount: 980, // 上一个隐藏款出现时的总抽奖次数 guaranteeCount: 500 // 保底次数阈值 } 每次抽取Lua脚本执行两件事INCR累计抽奖次数判断累计抽奖次数和上一个隐藏款出现次数的差值是否达到阈值达成的话直接踢掉随机逻辑强制返回隐藏款ID。 lua -- KEYS[1]: 隐藏款Hash Key -- ARGV[1]: 保底阈值 -- ARGV[2]: 隐藏款styleId local drawCount redis.call(HINCRBY, KEYS[1], drawCount, 1) local lastHitCount tonumber(redis.call(HGET, KEYS[1], lastHitCount) or 0) if (drawCount - lastHitCount) tonumber(ARGV[1]) then redis.call(HSET, KEYS[1], lastHitCount, drawCount) return ARGV[2] end return -1 如果Lua返回的是-1说明还没触发保底就走正常的权重随机。如果返回了具体的styleId就意味着强制命中这个styleId就是隐藏款。 这套逻辑上线之后我观察过一段时间运营的反馈是“终于不用靠肉眼盯数据判断要不要人为干预了”。保底机制本质上是一个运营安全垫既能保证稀有款的稀缺性又能让用户有“再抽几次肯定能出”的心理预期。对于盲盒这种强运营属性的玩法这个设计是刚需。 ### 5.3 运营后门为什么不能直接改概率 聊到运营配置必须多说一句。盲盒功能里运营总想“手动干预”某些用户的结果比如给大V开个后门提高他的中奖概率。我的建议非常明确**不要在抽奖逻辑里做任何针对用户维度的概率改动**而要通过“定向投放券”或者“定向积分”的方式实现。 原因有二。第一针对用户改概率意味着每次抽奖都要多一步“判断当前用户是否特殊”的逻辑在高并发抽奖下这个分支判断会引入大量不可控的复杂性。第二也是更重要的——运营配置被误操作或者被黑客攻击时“为特定用户改概率”这种事一旦泄露整个盲盒活动的公信力直接归零平台会被用户群嘲。 正确做法是给特定用户发一张“高级盲盒券”用这张券可以进入一个不同的抽奖活动池这个池子里的权重配置本身就是偏好的。用户视角下他用了一张特殊券抽到好东西的概率确实高了但底层抽奖逻辑是一模一样的没有针对任何用户写死逻辑完全经得起抽查。 ## 6. 常见问题与并发压测实战记录 ### 6.1 高频踩坑Lua脚本报错与事务失效 第一个高频问题是**Lua脚本执行报错ERR Error compiling script (new function): user_script:...**。这类错误绝大多数是因为脚本里的tonumber用错了。Lua的redis.call返回的数值都是字符串类型直接和数字比较会报错或者得到错误结果必须用tonumber()包一层。 第二个高频问题是**事务失效**。Spring的Transactional注解只对通过Spring代理调用的方法生效如果你在同一个类里用this.draw()调用另一个Transactional方法事务是不生效的。这是Spring经典的代理机制问题。我当初排查一个线上问题用户重复点击抽奖按钮结果生成了多条订单最后发现是Controller直接调用了this.draw()事务注解被整个绕过了。解决方式很简单事务方法放到独立的Service类里用Autowired注入调用。 第三个高频问题是**Redis连接池被打满**。抽奖瞬间的QPS如果到了几百Redis连接池默认配置很容易被打满表现为大量Cannot get Jedis connection异常。调优方案是调大连接池上限同时用连接池监控指标观察实际连接数。 yaml spring: redis: lettuce: pool: max-active: 200 max-idle: 50 min-idle: 20 max-wait: 3000ms ### 6.2 压测结果1000并发下的表现与瓶颈定位 我拿JMeter做过一轮压测模拟用户盲盒抽奖的高峰场景。测试条件是200个活跃款式1000个并发线程每个线程循环抽奖100次总请求量10万。压测结果如下 | 指标 | 压测结果 | | --- | --- | | 总请求量 | 100,000 | | 平均响应时间 | 38ms | | 99%响应时间 | 86ms | | QPS峰值 | 约6,500 | | 超卖记录数 | 0 | | 中奖记录和Redis扣减不一致数 | 0 | | 数据库剩余库存和Redis库存不一致数 | 0 | 结论是这套方案在1000并发下完全扛得住瓶颈不在Redis也不在数据库反而出现在订单写入阶段。因为每个抽奖请求都要在数据库插入中奖记录和订单数据库的写并发成了新的瓶颈。这也是为什么盲盒功能通常会在抽奖成功之后把完整的下单流程异步化先返回给用户“抽取结果”异步再生成正式订单。 如果你要压测自己的盲盒系统建议重点关注三个指标数据库剩余库存和Redis实际扣减是否一致、中奖记录和Redis扣减流水是否能完全对上、99%响应时间是否超过200ms。这三个指标能揭露绝大部分隐藏问题。 ### 6.3 线上问题排查抽奖记录对不上账怎么办 最后分享一个线上真实事故。某次活动结束后跑对账运营发现有一个款式的“已抽次数”比“中奖记录数”多出几十条。排查过程是这样的。 第一反应是查代码看是不是有抽奖请求只扣了Redis库存但没有写数据库记录。查了日志发现确实存在一批请求走到了preDeductStock成功但在后续写库的时候因为数据库连接池打满而抛异常。异常被catch捕获后补偿逻辑rollbackStock也执行了但好像没生效。 再一查才发现是Redis的DECRBY和INCR操作分别执行没有放在同一个Lua脚本里补偿逻辑里先DECRBY把库存加回去再INCR把已抽次数减回去。问题出现在第一步DECRBY成功了但第二步INCR的时候Redis连接超时导致已抽次数没减回去。 修复方案很简单把两步操作合并到一个Lua脚本里执行保证原子性。这也是为什么我一直强调涉及Redis的多个写操作一定要想办法合并到同一个Lua脚本里不要拆开执行。每一次额外的网络往返都是一次潜在的失败点。 后来我在存储库存流水的表里增加了一个补偿状态字段如果补偿失败流水表的记录会标记为“待补偿”定时任务每5分钟扫描一次把未补偿的流水捞出来重试。双重保险之后再也没有出现过对不上账的情况。 这套盲盒功能从设计到上线最有价值的经验积累都在这几个环节权重区间的随机算法、Redis Lua的库存扣减、保底隐藏款的运营控制、以及对账补偿机制的兜底设计。每个环节单独拿出来都不算复杂但串在一起就能支撑起一个完整、稳定、可运营的手办商城盲盒活动。这套代码的核心思想也可以迁移到秒杀、预售、限时抢购等任何涉及高并发库存扣减的业务场景里核心方法是一样的。