抽卡系统后端设计:概率权重、保底机制与并发扣减完整实现
很多同学看到“抽卡”“皮肤”这类词第一反应是前台展示和美术资源但真正让抽卡体验稳定、可控、不崩服的往往是后台那一整套概率配置、库存扣减、保底结算和日志追踪机制。最近在复盘一个类似“残虹200抽”的皮肤抽奖活动时我把整个抽卡系统的核心链路从接口设计到数据落库重新梳理了一遍踩了不少细节坑也沉淀了一套可以直接落地的实现方案。本文将围绕抽卡/抽奖系统的后端设计展开包含完整的表结构、核心代码、概率配置、保底逻辑、并发扣减方案和常见问题排查无论是新手入门还是项目改造都能直接参考。1. 背景与核心概念1.1 什么是抽卡抽奖系统抽卡系统本质上是“按配置概率发放虚拟奖励”的业务系统。用户通过消耗道具如钻石、抽卡券、积分获得一次抽取机会系统根据预先配置的概率表随机决定用户获得哪一类道具或皮肤。在很多业务场景里抽卡系统并不只是“随机发奖”这么简单它往往包含以下核心规则不同档位奖励拥有不同权重概率。累计抽取达到一定次数后触发保底机制。部分稀有道具需要限量投放。活动期间概率可能动态调整。并发请求下不能超发、不能重复扣减。1.2 抽卡系统解决什么问题从产品角度看抽卡是提升用户活跃度和付费转化的重要手段从开发角度看抽卡系统解决的核心问题有三个随机发奖按照配置概率公平地返回奖励。状态控制记录用户的抽取次数、保底进度、库存消耗。数据可追溯每一抽都有记录方便对账、排查和风控。1.3 常见应用场景游戏内皮肤/角色抽取。电商平台的积分盲盒。营销活动的转盘抽奖。内容平台的随机卡牌收集。本文以一个皮肤抽奖活动为例核心场景是用户消耗抽卡券从奖池中随机获取皮肤碎片、完整皮肤、道具等奖励并且累计抽取200次时有额外保底奖励。2. 环境准备与版本说明本文示例采用以下技术环境实际项目请按自身情况调整操作系统Windows / macOS / Linux 均可。开发语言Java 8。框架Spring Boot 2.x。数据库MySQL 5.7 / 8.0。缓存Redis 5.x。构建工具Maven 3.6。IDEIntelliJ IDEA。版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路和代码逻辑。项目结构如下lottery-system/ ├── pom.xml ├── src/main/java/com/example/lottery/ │ ├── LotteryApplication.java │ ├── controller/ │ │ └── DrawController.java │ ├── service/ │ │ ├── DrawService.java │ │ └── impl/DrawServiceImpl.java │ ├── mapper/ │ │ ├── PrizeMapper.java │ │ ├── DrawRecordMapper.java │ │ └── UserStockMapper.java │ ├── entity/ │ │ ├── Prize.java │ │ ├── DrawRecord.java │ │ └── UserStock.java │ └── config/ │ └── RedisConfig.java └── src/main/resources/ ├── application.yml ├── mapper/ │ ├── PrizeMapper.xml │ ├── DrawRecordMapper.xml │ └── UserStockMapper.xml └── db/schema.sql3. 核心表结构设计抽卡系统的表结构需要围绕“奖池配置、用户库存、抽取记录”三条主线展开。3.1 奖品表prize奖品表存储奖池内所有可抽取的奖励项。CREATE TABLE prize ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, pool_id bigint(20) NOT NULL COMMENT 奖池ID, prize_name varchar(64) NOT NULL COMMENT 奖品名称, prize_type tinyint(4) NOT NULL COMMENT 奖品类型1-皮肤2-碎片3-道具, weight int(11) NOT NULL DEFAULT 0 COMMENT 概率权重, total_stock int(11) NOT NULL DEFAULT -1 COMMENT 总库存-1表示不限量, remain_stock int(11) NOT NULL DEFAULT -1 COMMENT 剩余库存, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_pool_id (pool_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT奖品配置表;3.2 用户库存表user_stock用户库存表记录用户当前持有的抽卡券或积分数量。CREATE TABLE user_stock ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 用户ID, stock_type tinyint(4) NOT NULL COMMENT 库存类型1-抽卡券2-积分, balance int(11) NOT NULL DEFAULT 0 COMMENT 余额, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_stock_type (user_id, stock_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户库存表;3.3 抽取记录表draw_record抽取记录表记录每一次抽卡行为是后续对账和排查的核心依据。CREATE TABLE draw_record ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, pool_id bigint(20) NOT NULL COMMENT 奖池ID, prize_id bigint(20) NOT NULL COMMENT 抽中奖品ID, prize_name varchar(64) DEFAULT NULL, consume_type tinyint(4) NOT NULL COMMENT 消耗类型1-抽卡券2-积分, consume_count int(11) NOT NULL DEFAULT 1 COMMENT 消耗数量, draw_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 抽取时间, request_id varchar(64) NOT NULL COMMENT 幂等请求ID, PRIMARY KEY (id), UNIQUE KEY uk_request_id (request_id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT抽取记录表;3.4 保底进度表guarantee_progress保底进度表记录用户在当前奖池中累计抽取的次数以及保底奖励领取状态。CREATE TABLE guarantee_progress ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, pool_id bigint(20) NOT NULL, total_draw_count int(11) NOT NULL DEFAULT 0 COMMENT 当前累计抽取次数, guarantee_received tinyint(4) NOT NULL DEFAULT 0 COMMENT 保底奖励是否已领取0-未领取1-已领取, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_pool (user_id, pool_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT保底进度表;3.5 表关系说明一个奖池pool_id对应多个奖品。一个用户在一个奖池中只有一条保底进度。每一次抽取都会写入一条 draw_record。user_stock 中的库存变更通过乐观锁保证并发安全。4. 概率抽奖算法实现4.1 权重概率原理常见的抽奖概率实现方式是“权重区间法”。假设有四个奖品权重分别为 10、20、30、40那么总权重为 100。生成一个 [1, 100] 之间的随机数根据随机数落在哪个区间来决定命中哪个奖品。奖品权重区间皮肤A5[1, 5]碎片B15[6, 20]道具C30[21, 50]谢谢参与50[51, 100]4.2 核心代码实现import java.util.List; import java.util.concurrent.ThreadLocalRandom; public class WeightRandom { public static Prize drawPrize(ListPrize prizeList) { int totalWeight 0; for (Prize prize : prizeList) { totalWeight prize.getWeight(); } if (totalWeight 0) { throw new IllegalArgumentException(总权重必须大于0); } int randomNum ThreadLocalRandom.current().nextInt(1, totalWeight 1); int current 0; for (Prize prize : prizeList) { current prize.getWeight(); if (randomNum current) { return prize; } } return null; } }这段代码的核心思想是将随机数通过累加权重映射到具体奖品上。ThreadLocalRandom在高并发场景下比Math.random()性能更好且线程安全。4.3 限量库存判断当奖品设置了限量库存时需要先判断剩余库存是否足够。public Prize drawWithStockCheck(ListPrize prizeList) { ListPrize availablePrizes prizeList.stream() .filter(prize - prize.getRemainStock() ! 0) .collect(Collectors.toList()); if (availablePrizes.isEmpty()) { // 所有奖品库存不足返回默认道具或提示 return getDefaultPrize(prizeList); } return drawPrize(availablePrizes); }需要注意的是这种判断只是“预检查”真正保证库存不超发还需要依赖数据库更新时的条件语句UPDATE prize SET remain_stock remain_stock - 1 WHERE id #{prizeId} AND remain_stock 0只有更新影响行数为 1 时才算占库存成功。5. 用户扣库存与并发控制5.1 并发问题分析抽卡场景是典型的高并发写场景。假设同一个用户同时发起多次抽卡请求如果不做控制可能出现库存扣成负数。抽卡券被重复扣减。保底进度统计错误。5.2 乐观锁方案user_stock 表增加了 version 字段更新时使用乐观锁Update(UPDATE user_stock SET balance balance - #{count}, version version 1 WHERE user_id #{userId} AND stock_type #{stockType} AND balance #{count} AND version #{version}) int deductStock(Param(userId) Long userId, Param(stockType) Integer stockType, Param(count) Integer count, Param(version) Integer version);如果返回值为 0说明余额不足或版本冲突外部需要重试。5.3 Redis 分布式锁兜底对于单机场景乐观锁已经足够但在分布式环境下多个服务实例同时操作同一用户的库存时建议加上 Redis 分布式锁public boolean tryLock(String key, String requestId, long expireTime) { return redisTemplate.opsForValue() .setIfAbsent(key, requestId, Duration.ofSeconds(expireTime)); }锁的 key 设计为lottery:user_lock:{userId}:{poolId}拿到锁后执行扣减、抽奖、记录写入最后释放锁。锁的过期时间不宜太短否则业务未执行完锁就释放了。5.4 使用事务保证一致性整个抽卡流程涉及用户扣库存、写入抽取记录、扣减奖品库存必须放在同一个事务中Transactional(rollbackFor Exception.class) public DrawResult draw(Long userId, Long poolId, String requestId) { // 1. 幂等判断如果 requestId 已存在直接返回原结果 // 2. 扣减用户抽卡券 // 3. 根据权重随机抽取奖品 // 4. 扣减奖品库存 // 5. 写入抽取记录 // 6. 更新保底进度 }这里需要特别说明事务的边界锁的获取和释放不能放在事务内部否则事务提交前锁就被释放了仍然可能产生并发问题。6. 200抽保底机制实现6.1 保底规则说明本文场景中的保底规则是用户在当前奖池累计抽取 200 次时额外赠送一个限定皮肤。如果用户在未达到 200 次之前已经抽到该限定皮肤则保底进度不重置仍然继续累计直到 200 次直接发放。6.2 实现逻辑每次抽取完成后更新 guarantee_progress 表的 total_draw_count。public void updateGuaranteeProgress(Long userId, Long poolId) { GuaranteeProgress progress guaranteeProgressMapper.selectByUserIdAndPoolId(userId, poolId); if (progress null) { GuaranteeProgress newProgress new GuaranteeProgress(); newProgress.setUserId(userId); newProgress.setPoolId(poolId); newProgress.setTotalDrawCount(1); newProgress.setGuaranteeReceived(0); guaranteeProgressMapper.insert(newProgress); } else { int newCount progress.getTotalDrawCount() 1; guaranteeProgressMapper.updateCount(userId, poolId, newCount); // 达到200次且未领取保底奖励时直接发放 if (newCount 200 progress.getGuaranteeReceived() 0) { grantGuaranteePrize(userId, poolId); guaranteeProgressMapper.markGuaranteeReceived(userId, poolId); } } }6.3 保底进度查询接口用户前端的“剩余多少抽保底”就是查这张表的 total_draw_count。GetMapping(/guarantee/progress) public ResultGuaranteeProgressVO getGuaranteeProgress(Long userId, Long poolId) { GuaranteeProgress progress guaranteeProgressMapper.selectByUserIdAndPoolId(userId, poolId); int totalDrawCount progress null ? 0 : progress.getTotalDrawCount(); int remainCount Math.max(200 - totalDrawCount, 0); return Result.success(new GuaranteeProgressVO(totalDrawCount, remainCount)); }7. 完整抽卡流程串联7.1 接口定义RestController RequestMapping(/lottery) public class DrawController { Autowired private DrawService drawService; PostMapping(/draw) public ResultDrawResultVO draw(RequestBody DrawRequest request) { return Result.success(drawService.draw(request.getUserId(), request.getPoolId(), request.getRequestId())); } }7.2 请求对象Data public class DrawRequest { private Long userId; private Long poolId; private String requestId; // 前端生成的唯一ID用于幂等 }7.3 Service 核心实现Service public class DrawServiceImpl implements DrawService { Autowired private UserStockMapper userStockMapper; Autowired private PrizeMapper prizeMapper; Autowired private DrawRecordMapper drawRecordMapper; Autowired private GuaranteeProgressMapper guaranteeProgressMapper; Autowired private StringRedisTemplate redisTemplate; Override Transactional(rollbackFor Exception.class) public DrawResultVO draw(Long userId, Long poolId, String requestId) { String lockKey lottery:user_lock: userId : poolId; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(5)); if (!Boolean.TRUE.equals(locked)) { throw new BizException(操作过于频繁请稍后再试); } try { // 幂等判断 DrawRecord existingRecord drawRecordMapper.selectByRequestId(requestId); if (existingRecord ! null) { return buildResultFromRecord(existingRecord); } // 1. 扣减用户抽卡券 UserStock userStock userStockMapper.selectByUserIdAndType(userId, 1); if (userStock null || userStock.getBalance() 1) { throw new BizException(抽卡券不足); } int rows userStockMapper.deductStock(userId, 1, 1, userStock.getVersion()); if (rows 0) { throw new BizException(库存扣减失败请重试); } // 2. 查询奖池奖品并执行权重抽取 ListPrize prizeList prizeMapper.selectByPoolId(poolId); Prize prize WeightRandom.drawPrize(prizeList); // 3. 扣减奖品库存 if (prize.getRemainStock() ! -1) { int stockRows prizeMapper.deductStock(prize.getId()); if (stockRows 0) { // 库存不足降级返回默认奖品 prize getDefaultPrize(prizeList); } } // 4. 写入抽取记录 DrawRecord record buildDrawRecord(userId, poolId, prize, requestId); drawRecordMapper.insert(record); // 5. 更新保底进度 updateGuaranteeProgress(userId, poolId); return buildSuccessResult(prize); } finally { redisTemplate.delete(lockKey); } } }7.4 运行验证启动 Spring Boot 项目后使用 curl 调用接口curl -X POST http://localhost:8080/lottery/draw \ -H Content-Type: application/json \ -d {userId: 1001, poolId: 1, requestId: REQ_20241201_001}正常情况下返回{ code: 200, message: success, data: { prizeId: 3, prizeName: 传说皮肤碎片, prizeType: 2 } }8. 常见问题与排查思路8.1 抽卡券扣了但没返回奖品可能原因抽奖接口抛出异常后事务回滚但 Redis 锁已释放前端显示失败。幂等 ID 未生效用户重复提交。排查思路查看 draw_record 表中是否有该 request_id 的记录。查看 user_stock 表余额是否被扣减。查看应用日志中异常堆栈。解决方案每次请求必须携带唯一 request_id并在事务内先做幂等查询。接口返回异常时前端提示用户“稍后查看抽取记录”不要立即重试。8.2 奖品库存超发可能原因扣减库存的 SQL 没有加remain_stock 0条件。高并发下多个请求同时读到剩余库存为 1然后同时扣减。排查思路检查扣库存 SQL 是否包含条件。查看奖品表剩余库存是否为负数。解决方案UPDATE prize SET remain_stock remain_stock - 1 WHERE id #{prizeId} AND remain_stock 0通过影响行数判断是否扣减成功不成功则降级文案提示。8.3 保底进度不准确可能原因保底进度更新和抽取记录写入不在同一事务。guarantee_progress 表没有唯一索引插入重复数据。解决方案确保保底进度更新在同一个事务内完成。为 user_id pool_id 增加唯一索引。8.4 重复请求导致发放两次奖励问题现象常见原因解决思路同一请求重复发放奖励缺少幂等控制draw_record 表的 request_id 加唯一索引插入前先查询用户余额被多次扣减接口超时重试使用乐观锁 事务回滚保底奖励重复领取guarantee_received 标志位未正确更新更新时加guarantee_received 0条件9. 最佳实践与工程建议9.1 配置管理奖池和奖品概率不应写死在代码中建议放配置中心或数据库管理方便运营实时调整。如果走数据库建议增加缓存// 查询奖池奖品并缓存5分钟 ListPrize prizeList prizeCache.get(poolId); if (prizeList null) { prizeList prizeMapper.selectByPoolId(poolId); prizeCache.put(poolId, prizeList, Duration.ofMinutes(5)); }9.2 日志记录每次抽卡的完整链路需要记录以下日志请求参数userId、poolId、requestId。抽取结果prizeId、prizeName。耗时接口整体耗时。库存扣减结果影响行数。推荐使用 MDC 将 requestId 贯穿整个链路方便日志检索。9.3 降级与兜底当奖池奖品库存不足、用户余额异常、Redis 不可用时接口需要降级策略奖品库存不足返回固定安慰奖。Redis 不可用退化为数据库乐观锁控制。接口异常返回明确错误码不吞异常。9.4 安全边界接口需要考虑防刷通过用户登录态校验。概率配置不能暴露给前端抽奖结果必须由后端计算。涉及充值、扣费操作时必须记录操作流水。高价值奖品建议增加人工复核流程。9.5 性能优化抽奖接口中用到的奖池奖品列表尽量缓存到本地或 Redis。保底进度查询使用索引避免全表扫描。高并发场景下可以将用户的抽奖请求放入消息队列异步处理但需要处理好异步结果通知。10. 总结与下一步学习通过本文的梳理我们完整实现了一个皮肤抽卡系统的核心后端链路包括权重概率算法、用户库存扣减、奖品限量库存控制、200 抽保底机制、幂等处理和并发安全方案。这套设计不仅适用于“残虹200抽”这类皮肤活动也完全可以复用到盲盒、转盘、积分抽奖等常见业务场景。下一步可以继续深入的方向包括抽奖活动的限时配置与动态概率切换。多奖池之间的用户保底进度迁移。基于消息队列的异步抽奖与结果通知。接入监控大盘对抽奖接口的 QPS、失败率、库存消耗速率进行实时观测。在实际项目中优先关注的是幂等、库存和保底数据的一致性。建议先在小流量活动中验证代码稳定性再逐步放开全量用户。如果你正在设计抽奖类活动希望本文的表结构和代码能帮你少走一些弯路。