“托管活动三送一,再抽一张月卡送给老用户”——这种促销需求在产品群里发出来只需一分钟,但落到后端,就是一场不折不扣的线上活动系统设计题。
如果你只看到“买三送一”的数学规则,那这个需求确实简单;真正难的是下面三个问题:并发下单时怎么保证不会超送;抽奖环节怎么保证用户只能抽一次且奖品不超发;活动结束后如果出了资损问题,你怎么从数据里把线索捞出来。这三个问题处理不好,活动上线当天就可能出现“中奖用户拿不到月卡”“满三单却送了五单”的事故。
这篇文章就用 Spring Boot + MySQL + Redis 来实现一个完整的“托管活动三送一 + 月卡抽奖”系统。我会从促销规则抽象、数据表设计、核心代码、验证方法和排查思路一步步讲清楚,让你在真实项目里能直接参考这套结构,而不是停留在“能跑就行”的 demo 层面。
1. 这篇文章真正要解决的问题
先明确一下我们到底在做什么业务:一个游戏社区/服务平台推出回馈活动,用户购买托管类增值服务满 3 单,系统自动赠送 1 单;同时,每个满足条件的用户还能额外参加一次月卡抽奖,中奖后自动发放月卡。
这类活动在很多产品里都叫“买赠 + 抽奖”,看起来是运营玩法,但后端要解决的是业务一致性问题。
1.1 这类活动真正可怕的地方不是规则复杂
买三送一的规则一句话就能说清:每 3 单送 1 单。但线上活动一旦并发起来,就会出现“同一用户同时提交 3 个订单,每个请求都以为自己刚好满足条件,结果各送一单”的情况。如果抽奖接口被连续点击,就可能出现一次活动抽走多张月卡。
所以我会反复强调三个关键词:
- 防超送:赠送数量必须基于“已支付且满足条件”的订单,而不是“正在下单”的请求。
- 防超抽:一个用户在一轮活动中只能抽一次,而且奖池库存不能变负数。
- 防重发:月卡发放要做幂等,不能因为接口重试就发出去两张。
1.2 什么场景下你需要这套设计
如果你只需要在后台手动给三五个用户补发奖品,那确实不需要读这篇文章。但只要有下面任何一种情况,这套设计就有参考价值:
- 活动入口在 App/H5 上,用户会自己点;
- 活动会持续多天,跨天并发;
- 奖品是真实资产,比如会员卡、代金券;
- 运营需要活动结束后导出发放明细;
- 活动规则未来可能从“三送一”改成“五送二”,最好不重写代码。
这篇文章更适合后端开发、系统架构师和正在设计活动运营平台的团队阅读。前端部分我们不展开,重点讲清楚“下单确认—赠送计算—抽奖—发奖”这条最核心的后端链路。
2. 活动系统核心概念与设计思路
在写代码之前,先把几个关键概念弄清楚。很多活动系统的 bug 都出在概念没有抽象好。
2.1 什么是“买三送一”规则
“买三送一”从数学上看是:用户已支付的合格订单数达到 3 的倍数时,赠送对应数量的权益。
但实际业务里有几个细节要确认:
- 是否要求订单全部是同一 SKU?
- 赠送的是“下一单免单”还是“直接发放权益卡”?
- 一个用户是否可以多次参与,还是限制一次?
- 活动的统计口径是按“创建订单”还是“支付成功”?
从设计安全角度,我建议一律按“支付成功”来统计。因为用户可能下单后取消支付,如果按创建订单算,很容易出现“买了三单但只付了两单”的资损争议。下面的代码也按这个口径实现。
2.2 把活动拆成四个核心模块
按我的习惯,这类活动系统会拆成下面四个模块:
| 模块 | 职责 | 关键点 |
|---|---|---|
| 活动配置模块 | 维护活动时间、规则参数、奖池 | 配置版本化 |
| 资格计算模块 | 统计用户已支付订单,计算应送数量 | 幂等、可重算 |
| 抽奖模块 | 读取奖池,按权重抽取奖品,扣减库存 | 防超抽、防超发 |
| 发放模块 | 调用发货服务发放月卡权益 | 异步、幂等、可追溯 |
这四个模块不是一定要拆成四个微服务,即使是单体项目,也要在代码结构上把它们分开。否则活动规则一变,你就要在原下单逻辑里到处打补丁。
2.3 为什么不能用“if 订单数 >= 3”这样写
最直观的写法是:
if (userPaidOrderCount >= 3) { sendGift(); }这段代码在单机、低并发、人工操作时没问题。但在线上并发场景里存在两个问题:
- 两个请求同时读到
paidCount = 2,都认为再加自己这单就满足 3 单,然后都执行sendGift。 - 赠送动作本身没有幂等,前端重试一次就多发一次。
所以我们需要“统计口径 + 锁 + 幂等”三件套来保证正确性,这也是后面代码的核心。
3. 环境准备与项目基础配置
虽然活动规则可以做得复杂,但基础技术栈我建议保持常见、易维护的组合。本文使用:
- JDK 17
- Spring Boot 3.x
- MyBatis-Plus 3.5.x(负责数据库访问)
- MySQL 8.x
- Redis 7.x
- Maven 3.8+
如果你们的项目是 JDK 8 + Spring Boot 2.x,代码思路完全通用,只需要注意锁注解和依赖版本的差异,不要直接复制版本号。
3.1 创建 Maven 工程并添加依赖
新建一个 Spring Boot 工程,pom.xml至少包含下面的依赖:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这里需要注意:MyBatis-Plus 的版本要与 Spring Boot 3.x 匹配,直接用 3.5.x 系列比较稳妥。如果你遇到启动时ClassNotFoundException,优先检查依赖版本冲突。
3.2 配置 application.yml
在src/main/resources/application.yml中写入基础配置:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/activity_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver data: redis: host: localhost port: 6379 password: database: 0 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case开启后,数据库字段gift_count可以自动映射到 Java 属性giftCount,避免手写太多 ResultMap。
3.3 初始化数据库表结构
下面是核心表结构。为了控制篇幅,我只列出本次活动必须的几张表。
CREATE DATABASE IF NOT EXISTS activity_db DEFAULT CHARACTER SET utf8mb4; USE activity_db; -- 活动配置表 CREATE TABLE activity_config ( id BIGINT PRIMARY KEY AUTO_INCREMENT, activity_code VARCHAR(64) NOT NULL COMMENT '活动编码,例如 RUZ_2025_0718', activity_name VARCHAR(128) NOT NULL COMMENT '活动名称', rule_type VARCHAR(32) NOT NULL COMMENT '规则类型:BUY_THREE_GET_ONE / CUSTOM', buy_threshold INT NOT NULL DEFAULT 3 COMMENT '购买多少单触发赠送', gift_count_per_round INT NOT NULL DEFAULT 1 COMMENT '每满 threshold 赠送数量', max_gift_per_user INT NOT NULL DEFAULT 99 COMMENT '单个用户最大赠送数量', start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_activity_code (activity_code) ) ENGINE=InnoDB COMMENT='活动配置表'; -- 用户购买订单表(这里只保留活动需要的核心字段) CREATE TABLE user_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT '业务订单号', user_id BIGINT NOT NULL COMMENT '用户ID', product_id BIGINT NOT NULL, order_status TINYINT NOT NULL COMMENT '10待支付 20已支付 30已取消', pay_time DATETIME DEFAULT NULL COMMENT '支付时间', activity_code VARCHAR(64) DEFAULT NULL COMMENT '参与的活动编码', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_activity (user_id, activity_code, order_status) ) ENGINE=InnoDB COMMENT='用户订单表'; -- 赠送记录表 CREATE TABLE gift_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(128) NOT NULL COMMENT '幂等键:user_id + activity_code + batch_no', user_id BIGINT NOT NULL, activity_code VARCHAR(64) NOT NULL, order_no VARCHAR(64) NOT NULL COMMENT '触发赠送的订单号', batch_no INT NOT NULL COMMENT '第几轮赠送,比如第3单触发第1轮', gift_count INT NOT NULL COMMENT '本次赠送数量', gift_status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发放 1已发放 2已作废', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_id (biz_id) ) ENGINE=InnoDB COMMENT='赠送记录表'; -- 月卡抽奖记录表 CREATE TABLE lottery_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(128) NOT NULL COMMENT '幂等键:user_id + activity_code', user_id BIGINT NOT NULL, activity_code VARCHAR(64) NOT NULL, prize_code VARCHAR(64) NOT NULL COMMENT '奖品编码,例如 MONTH_CARD', prize_name VARCHAR(64) NOT NULL, lottery_status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发放 1已发放 2已作废', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_id (biz_id), KEY idx_user_activity (user_id, activity_code) ) ENGINE=InnoDB COMMENT='月卡抽奖记录表';这几张表的核心设计原则是:所有敏感操作都留有幂等键和审计字段。活动一旦出问题,你能根据biz_id精确判断某条记录是否已经处理过。
4. 核心流程拆解
现在我们看一下整个活动从用户下单到发奖的完整链路。这个顺序非常重要,因为它决定了哪些操作先加锁、哪些操作后落库。
4.1 下单支付成功回调
用户在前端下单并支付后,支付渠道会回调后端。在这个步骤里,我们要做三件事:
- 更新订单状态为已支付,记录支付时间。
- 调用活动服务,检查该用户是否满足“买三送一”条件。
- 如果满足,生成赠送记录,并把月卡抽奖机会记录下来。
注意:支付回调可能因为网络问题被发送多次,所以第一步必须做幂等处理,例如根据order_no做唯一约束,重复更新时直接返回。
4.2 计算是否触发赠送
我把计算逻辑写成一个独立类,这样可以单元测试。
输入参数是:
- 用户 ID
- 活动编码
- 当前支付成功的订单号
输出结果是:
- 是否触发赠送
- 触发的是第几轮赠送
- 本轮应送多少张
用“用户已支付订单总数”除以buy_threshold,得到当前应赠送的总轮数。再用“总轮数 - 已赠送轮数”得到本轮是否触发赠送。
例如:
- 用户已支付 5 单,阈值是 3,总轮数 = 5 / 3 = 1,已赠送 0 轮,所以第 5 单支付完成后应触发第 1 轮赠送。
- 用户已支付 6 单,总轮数 = 6 / 3 = 2,已赠送 1 轮,所以第 6 单应触发第 2 轮赠送。
这里要小心:如果不做“总轮数 - 已赠送轮数”的计算,而是简单判断“用户订单数等于 3”,那用户一次买 6 单时只会触发一轮,就漏送了。
4.3 加锁防并发超送
在计算赠送的同时,必须对“用户 + 活动”维度加分布式锁。Redis 锁的 key 建议设计为:
activity:gift:lock:{activityCode}:{userId}锁的作用不是防止计算(计算本身很快),而是防止两个支付回调并发进入赠送生成逻辑,导致重复生成赠送记录。
4.4 抽奖与发奖
赠送记录生成后,用户获得一次抽奖机会。抽奖接口要校验:
- 活动是否在进行中。
- 用户是否已经抽过。
- 奖池是否还有库存。
抽完之后不直接在接口内发奖,而是把发放任务写入数据库或消息队列,由异步任务去调用月卡发放服务。这样用户响应更快,发奖失败也能重试。
5. 完整代码实现
下面进入代码部分。我尽量给出可以落地的代码,但请根据实际项目的包名和工具类做调整。
5.1 买三送一规则计算器
创建一个独立的规则计算类,避免把业务规则散落在 Service 里。
package com.example.activity.rule; import org.springframework.stereotype.Component; @Component public class BuyThreeGetOneRule { /** * 计算当前用户累计应赠送总轮数 */ public int calculateTotalRounds(int paidOrderCount, int threshold) { if (paidOrderCount < threshold) { return 0; } return paidOrderCount / threshold; } /** * 判断本次支付是否触发赠送。 * * @param paidOrderCount 当前已支付订单总数(包含本次) * @param alreadyGiftRounds 已经赠送的轮数 * @param threshold 购买阈值,比如 3 * @return true 表示需要生成赠送记录 */ public boolean isTriggerGift(int paidOrderCount, int alreadyGiftRounds, int threshold) { int totalRounds = calculateTotalRounds(paidOrderCount, threshold); return totalRounds > alreadyGiftRounds; } /** * 计算本次需要赠送的数量,这里简单按每轮赠送固定数量处理。 */ public int calculateCurrentGiftCount(int paidOrderCount, int alreadyGiftRounds, int threshold, int giftCountPerRound) { int totalRounds = calculateTotalRounds(paidOrderCount, threshold); int currentRounds = totalRounds - alreadyGiftRounds; return Math.max(currentRounds, 0) * giftCountPerRound; } }这个类的好处是:即使以后改成“满 5 送 2”,只需要新增一个规则类,不需要改动下单主流程。
5.2 抽奖工具类:加权随机
月卡抽奖虽然奖品目前似乎只有一张月卡,但实际项目里往往是多个奖品,所以我把加权随机抽奖单独抽出来。这样可以避免以后加奖品时重写抽奖逻辑。
package com.example.activity.lottery; import java.util.NavigableMap; import java.util.TreeMap; import java.util.concurrent.ThreadLocalRandom; public class WeightedRandomSelector<T> { private final NavigableMap<Double, T> weightMap = new TreeMap<>(); private double totalWeight = 0.0; public void add(T item, double weight) { if (weight <= 0) { return; } totalWeight += weight; weightMap.put(totalWeight, item); } public T next() { double value = ThreadLocalRandom.current().nextDouble() * totalWeight; return weightMap.ceilingEntry(value).getValue(); } }假设月卡中奖概率是 10%,未中奖概率是 90%,在构造奖池时这样加入权重:
WeightedRandomSelector<String> selector = new WeightedRandomSelector<>(); selector.add("MONTH_CARD", 10); selector.add("NO_PRIZE", 90);这里需要解释一下:加权随机不是把奖品放数组里随机选,而是把权重映射到一条“概率轴”上,然后用随机数去落点。正确率更容易验证。
5.3 活动主服务:支付成功后处理赠送与抽奖资格
这是核心代码,它同时处理了“支付状态更新、赠送记录生成、抽奖资格创建”三个动作。在生产环境里,我建议把三件事拆成不同事务或由消息队列驱动,这里为了演示完整链路,把它们放在一个事务方法里。
package com.example.activity.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.activity.entity.GiftRecord; import com.example.activity.entity.LotteryRecord; import com.example.activity.entity.UserOrder; import com.example.activity.mapper.GiftRecordMapper; import com.example.activity.mapper.LotteryRecordMapper; import com.example.activity.mapper.UserOrderMapper; import com.example.activity.rule.BuyThreeGetOneRule; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.LocalDateTime; import java.util.UUID; import java.util.concurrent.TimeUnit; @Slf4j @Service @RequiredArgsConstructor public class ActivityService { private final UserOrderMapper userOrderMapper; private final GiftRecordMapper giftRecordMapper; private final LotteryRecordMapper lotteryRecordMapper; private final BuyThreeGetOneRule rule; private final StringRedisTemplate redisTemplate; /** * 支付成功回调入口。 * orderNo 是业务订单号,activityCode 是活动编码。 */ @Transactional(rollbackFor = Exception.class) public void handlePaidOrder(String orderNo, String activityCode, Long userId) { // 这里假设调用方已经确认订单支付成功。 // 1. 先校验活动是否进行中 checkActivityRunning(activityCode); // 2. 更新订单支付状态 updateOrderPaid(orderNo, activityCode, userId); // 3. 加分布式锁,防止并发触发赠送 String lockKey = "activity:gift:lock:" + activityCode + ":" + userId; String requestId = UUID.randomUUID().toString(); boolean locked = tryLock(lockKey, requestId, 5, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException("当前用户正在处理赠送,请勿重复提交"); } try { // 4. 查询已支付订单总数 Long paidCount = userOrderMapper.selectCount( new LambdaQueryWrapper<UserOrder>() .eq(UserOrder::getUserId, userId) .eq(UserOrder::getActivityCode, activityCode) .eq(UserOrder::getOrderStatus, 20) ); // 5. 查询已赠送轮数 Integer alreadyGiftRounds = giftRecordMapper.selectCount( new LambdaQueryWrapper<GiftRecord>() .eq(GiftRecord::getUserId, userId) .eq(GiftRecord::getActivityCode, activityCode) .eq(GiftRecord::getGiftStatus, 1) ); boolean trigger = rule.isTriggerGift(paidCount.intValue(), alreadyGiftRounds, 3); if (trigger) { int giftCount = rule.calculateCurrentGiftCount( paidCount.intValue(), alreadyGiftRounds, 3, 1); GiftRecord record = new GiftRecord(); String bizId = userId + ":" + activityCode + ":" + (alreadyGiftRounds + 1); record.setBizId(bizId); record.setUserId(userId); record.setActivityCode(activityCode); record.setOrderNo(orderNo); record.setBatchNo(alreadyGiftRounds + 1); record.setGiftCount(giftCount); record.setGiftStatus(0); giftRecordMapper.insert(record); // 6. 给用户创建一次抽奖资格(幂等) createLotteryRecord(userId, activityCode); } } finally { releaseLock(lockKey, requestId); } } private void createLotteryRecord(Long userId, String activityCode) { String bizId = userId + ":" + activityCode; LotteryRecord existing = lotteryRecordMapper.selectOne( new LambdaQueryWrapper<LotteryRecord>() .eq(LotteryRecord::getBizId, bizId) ); if (existing != null) { log.info("用户 {} 在活动 {} 已有抽奖资格", userId, activityCode); return; } LotteryRecord record = new LotteryRecord(); record.setBizId(bizId); record.setUserId(userId); record.setActivityCode(activityCode); record.setPrizeCode("MONTH_CARD"); record.setPrizeName("月卡"); record.setLotteryStatus(0); lotteryRecordMapper.insert(record); } private void updateOrderPaid(String orderNo, String activityCode, Long userId) { UserOrder order = userOrderMapper.selectOne( new LambdaQueryWrapper<UserOrder>() .eq(UserOrder::getOrderNo, orderNo) ); if (order == null) { throw new RuntimeException("订单不存在"); } if (order.getOrderStatus() == 10) { order.setOrderStatus(20); order.setPayTime(LocalDateTime.now()); order.setActivityCode(activityCode); userOrderMapper.updateById(order); } else { log.info("订单 {} 当前状态 {},无需重复处理", orderNo, order.getOrderStatus()); } } private void checkActivityRunning(String activityCode) { // 实际开发中应查 activity_config 表,并校验当前时间在 start_time 和 end_time 之间。 // 这里为了演示简洁,省略数据库查询。 } private boolean tryLock(String key, String requestId, long timeout, TimeUnit unit) { return Boolean.TRUE.equals( redisTemplate.opsForValue().setIfAbsent(key, requestId, Duration.ofSeconds(unit.toSeconds(timeout))) ); } private void releaseLock(String key, String requestId) { String value = redisTemplate.opsForValue().get(key); if (requestId.equals(value)) { redisTemplate.delete(key); } } }这段代码里有几个关键工程细节:
releaseLock不是直接删除 key,而是先检查 value 是否等于当前请求的requestId,避免误删别的请求持有的锁。- 赠送记录和抽奖资格都使用拼接好的
bizId做唯一约束,数据库层面如果再建唯一索引,双保险。 - 事务方法内做 Redis 锁存在锁粒度和事务边界问题,严格来说锁应该放在事务外层,但作为示例已经能说明思路。生产项目建议把锁放到
handlePaidOrder的上层切面或独立方法中。
5.4 抽奖接口
用户满足条件后,大概率会去前端点击“立即抽奖”。接口代码如下:
@RestController @RequestMapping("/activity") @RequiredArgsConstructor public class ActivityController { private final LotteryService lotteryService; /** * 首次进入活动页时,可调用此接口查询是否拥有抽奖资格。 */ @GetMapping("/lottery/status") public Result lotteryStatus(@RequestParam Long userId, @RequestParam String activityCode) { return Result.ok(lotteryService.checkStatus(userId, activityCode)); } /** * 执行抽奖。 */ @PostMapping("/lottery/draw") public Result draw(@RequestParam Long userId, @RequestParam String activityCode) { return Result.ok(lotteryService.draw(userId, activityCode)); } }Service 层实现:
package com.example.activity.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.activity.entity.LotteryRecord; import com.example.activity.lottery.WeightedRandomSelector; import com.example.activity.mapper.LotteryRecordMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @Service @RequiredArgsConstructor public class LotteryService { private final LotteryRecordMapper lotteryRecordMapper; public LotteryRecord draw(Long userId, String activityCode) { String bizId = userId + ":" + activityCode; LotteryRecord record = lotteryRecordMapper.selectOne( new LambdaQueryWrapper<LotteryRecord>() .eq(LotteryRecord::getBizId, bizId) ); if (record == null) { throw new RuntimeException("当前用户没有抽奖资格"); } if (record.getLotteryStatus() != 0) { throw new RuntimeException("当前用户已经抽过奖"); } WeightedRandomSelector<String> selector = new WeightedRandomSelector<>(); selector.add("MONTH_CARD", 10); selector.add("NO_PRIZE", 90); String prize = selector.next(); if (!"NO_PRIZE".equals(prize)) { record.setPrizeCode(prize); record.setPrizeName("月卡"); record.setLotteryStatus(1); record.setCreateTime(LocalDateTime.now()); lotteryRecordMapper.updateById(record); } else { record.setLotteryStatus(2); lotteryRecordMapper.updateById(record); } return record; } }抽奖接口这里简化了“库存预扣”逻辑。如果奖品数量有限,应该先查奖池剩余库存,并在 Redis 上用DECR原子扣减,扣减失败说明奖品已被抽完。这一点我会在第 8 节再次强调。
6. 运行结果与效果验证
代码写完以后,不能只在本地启动一下就算完。我们要用数据来验证“三送一”和“抽奖”是否符合预期。
6.1 准备测试数据
先初始化一张活动配置:
INSERT INTO activity_config (activity_code, activity_name, rule_type, buy_threshold, gift_count_per_round, max_gift_per_user, start_time, end_time, status) VALUES ('ACT_TEST_2025', '托管活动三送一', 'BUY_THREE_GET_ONE', 3, 1, 99, '2025-01-01 00:00:00', '2025-12-31 23:59:59', 1);再插入两条待处理的订单:
INSERT INTO user_order (order_no, user_id, product_id, order_status, activity_code) VALUES ('ORDER_001', 1001, 5001, 10, 'ACT_TEST_2025'), ('ORDER_002', 1001, 5001, 10, 'ACT_TEST_2025');然后模拟第三次支付回调。在测试环境里,可以直接用 curl 调用一个手动触发的临时接口,或者直接写单元测试调用ActivityService.handlePaidOrder。
6.2 预期的正确结果
用户 1001 在第三次支付成功后:
- user_order 中 ORDER_001、ORDER_002、ORDER_003 的状态都变成 20。
- gift_record 中新增一条记录:user_id=1001, batch_no=1, gift_count=1。
- lottery_record 中新增一条记录:biz_id=1001:ACT_TEST_2025。
用 SQL 验证:
SELECT * FROM gift_record WHERE user_id = 1001; SELECT * FROM lottery_record WHERE user_id = 1001;如果第 4 笔订单也支付成功,此时 paidOrderCount=4,总轮数 = 4/3 = 1,已赠送轮数 = 1,所以不会再新增赠送记录,也不会新增抽奖资格。这是“买三送一”在累计消费场景下的正确表现:第六笔订单支付时,才会触发第二轮赠送。
6.3 并发验证
建议用 JMeter 或 ab 模拟 20 个并发请求同时提交同一用户的支付回调。验证标准是:
- gift_record 表里只有一条 batch_no=1 的记录。
- lottery_record 表里只有一条该用户在该活动的记录。
- 日志中没有出现“当前用户正在处理赠送,请勿重复提交”大量报错。
如果出现了重复赠送记录,优先检查 Redis 锁里requestId的释放逻辑和数据库唯一索引是否生效。
7. 常见问题与排查思路
活动系统上线后,问题往往不是“跑不起来”,而是“数据不对”。我把最常见的五类问题整理成一张表。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 用户支付 3 单没有赠送 | 活动配置未启用,或未按已支付口径统计 | 查看 activity_config.status 和 user_order.order_status | 确认配置状态、确认支付回调已更新订单状态 |
| 重复生成赠送记录 | 并发回调没有加锁,或幂等键未生效 | 查询 gift_record.biz_id 是否有重复值 | 加 Redis 锁,并给 biz_id 建唯一索引 |
| 用户抽奖后奖品未发放 | 发放逻辑在异步任务中执行,异步任务失败未重试 | 查看 lottery_record.lottery_status 是否为 1 | 建立发放补偿任务,定期扫描待发放记录 |
| 奖品库存变成负数 | 抽奖时没有原子扣减库存 | 查看 Redis 库存 key 的值 | 改用 DECR 或 Lua 脚本扣减,DB 库存做兜底 |
| 活动期间修改规则导致数据错乱 | 规则参数直接写死在代码里 | 检查代码中是否有魔法数字 3、1 | 规则配置化,按版本生效,不要改历史配置 |
7.1 幂等键重复的处理思路
如果你使用的是 MySQL,建议把gift_record.biz_id和lottery_record.biz_id都建成唯一索引:
ALTER TABLE gift_record ADD UNIQUE KEY uk_biz_id (biz_id); ALTER TABLE lottery_record ADD UNIQUE KEY uk_biz_id (biz_id);这样即使代码里漏了判断,数据库也会拦住重复插入。唯一索引抛出的DuplicateKeyException在事务里很容易被误认为系统异常,建议在 catch 里单独判断:
try { giftRecordMapper.insert(record); } catch (DuplicateKeyException e) { log.info("赠送记录已存在,直接跳过,bizId={}", bizId); }7.2 分布式锁超时问题
Redis 锁如果设置了 5 秒过期,但业务执行超过 5 秒,锁会被自动释放,另一个请求就能再次进入。比较稳妥的做法是:
- 锁过期时间加大到业务耗时的 3 到 5 倍。
- 核心写入依赖数据库唯一索引兜底,不能只靠锁。
- 考虑引入 Redisson 看门狗机制自动续期,但这会增加依赖,小项目按需选择。
8. 最佳实践与工程建议
写到这里,读者应该已经能跑通一个最小活动系统。但如果要上生产,以下建议必须认真对待。
8.1 规则配置不要写死在代码里
我在前面代码中多次使用了3和1这两个魔法数字。这在 demo 里没问题,但生产环境建议把阈值、赠送数量、活动时间抽到activity_config表或配置中心统一维护。以后活动改成“五送二”,只需要新增一条配置,不需要发版。
8.2 发放动作与抽奖动作解耦
抽奖接口里不要直接调用月卡发放服务。正确的流程是:
- 抽奖接口只负责写入中奖记录,状态为“待发放”。
- 异步任务扫描待发放记录,调用发放服务。
- 发放成功后更新状态为“已发放”。
- 如果发放失败,保留错误日志并定期重试。
这样即使用户量暴增,也不会因为发奖服务变慢导致抽奖接口全链路超时。
8.3 库存要做到“Redis 预扣 + DB 兜底”
月卡奖品数量有限时,抽奖前要先检查库存。我推荐下面的原子操作:
# 在 Redis 中初始化库存 SET activity:stock:MONTH_CARD 100 # 抽奖时原子扣减,返回值大于0表示扣减成功 DECR activity:stock:MONTH_CARD但 Redis 不是强持久化存储,所以最终要以数据库库存表和发放记录为准。活动结束后做一次对账:中奖记录数 = 发放成功记录数 + 合理作废记录数,不一致立即告警。
8.4 操作权限与安全边界
活动系统涉及发奖,本质上是“发钱”的系统。后台管理接口必须遵循:
- 最小权限原则:运营人员只能查看数据,不能直接修改发放状态。
- 操作审计:谁在什么时间改了什么配置,要留日志。
- 测试环境隔离:不要在测试环境连生产 Redis,避免误扣库存。
- 数据库备份:活动上线前对
gift_record、lottery_record相关表做备份,以便资损追溯。
8.5 对账与监控
活动上线期间,至少监控三个指标:
- 每分钟抽奖接口 QPS。
gift_record新增数量和user_order已支付订单数量的比例是否符合预期。- 发放失败率,超过阈值立即告警。
对账最好做成一个定时任务,每天凌晨跑一次,把赠送记录、抽奖记录和实际发放记录做比对,输出差异明细给开发群。
9. 总结与后续学习方向
这次我们把一个“托管活动三送一 + 月卡抽奖”的需求,从业务规则拆解到数据库表设计、核心代码、验证方法、问题排查,完整走了一遍。我的核心判断是:这类系统看起来只是几条 if 判断,但真正的复杂度全部集中在“并发一致性”上。只要把赠送和抽奖的幂等键设计好、把 Redis 锁和数据库唯一索引结合起来、把发奖动作从请求链路中拆出去,这套系统就能支撑住大多数线上活动场景。
下一步你可以做三件事:
第一,把代码里的魔法数字改成活动配置表动态读取,跑通一个“规则可配置”的版本。第二,给抽奖模块增加真正的限时奖池库存扣减,用 JMeter 压测一下并发抽奖的正确性。第三,引入消息队列,把赠送、抽奖、发放三个动作改成异步事件驱动,进一步提高系统的吞吐能力和容错能力。
如果你正在设计类似的活动运营平台,建议先从小流量的单次活动开始验证这套方案,再逐步沉淀成公共活动组件。毕竟,活动系统的成败不在于功能多花哨,而在于活动结束时账能不能对平。