抽奖类营销活动的技术实现,说到底是三件事的平衡:预算有限、概率可查、并发不可控。最近在梳理一套“抽奖免首单”的玩法落地时,反复卡在超发、重复中奖、免单券被拆单套利这几个环节,网上资料大多停留在业务描述层面,缺少可以直接复用的代码骨架。下面把整套方案拆开写一遍,从概率模型、库存扣减、幂等到风控和对账,代码尽量给到能直接跑的程度。刚接触营销系统的新手可以顺着读,做过订单或券系统的同学可以直接跳到第 4、5 章看并发与幂等部分。
1. 抽奖免首单模式到底是什么
1.1 业务形态拆解
“抽奖免首单”通常指用户在一个活动周期内完成指定动作(注册、浏览、分享、领券)后获得一次抽奖机会,奖品池中包含“首单免单资格”这一项。用户一旦抽中,系统会给他的账号发放一张免单资格凭证,用户在有效期内完成第一笔符合条件的订单,结算时按规则返还或直接抵扣订单金额(通常设有封顶金额)。
从技术角度看,它由四个互相独立的子系统拼起来:
- 抽奖引擎:负责概率计算、奖池扣减、结果落库。
- 凭证系统:负责把“中奖结果”翻译成一张可核销的券或资格记录。
- 订单系统:负责在结算环节识别资格、计算抵扣、记录核销关系。
- 风控与对账:负责识别异常用户、控制预算、保证资金侧数据能对上。
很多团队栽跟头,不是因为抽奖算法写不出来,而是因为这四块各自实现、缺乏统一的幂等键和状态机,导致“抽中了但没发券”“发了券但订单没核销”“退款了券没退回”这类跨系统不一致的问题。所以后面每一节我都会围绕同一个主线:用一个全局唯一的业务流水号把四个环节串起来。
1.2 技术视角下的核心难点
把这些难点列出来,后面章节会逐一展开:
| 难点 | 表现形式 | 技术手段 |
|---|---|---|
| 奖池超发 | 库存 100 份,最终发出 130 份 | Redis 预扣减 + DB 乐观锁兜底 |
| 重复中奖 | 用户狂点、脚本重放导致一次机会抽出多次 | 唯一索引幂等 + 一次性令牌 |
| 概率失真 | 保底逻辑和权重逻辑互相干扰 | 分层奖池 + 显式状态标记 |
| 免单套利 | 拆单、凑单、退款保留资格 | 订单金额校验 + 逆向流程收回 |
| 预算失控 | 期望成本远超活动预算 | 实时消耗监控 + 自动降级开关 |
其中“超发”和“套利”是最容易出生产事故的两项。超发是技术问题,套利是业务规则漏洞,两者都需要在编码阶段就设计好防线,而不是等运营发现成本异常再补。
1.3 与其他营销玩法的边界区分
这个区分很重要,因为它直接决定数据结构。三种常见玩法的差别如下:
- 优惠券:发放即确定面额,用户在结算时主动选择使用,系统不强制校验订单金额以外的东西。
- 抽奖:结果不确定,核心在于概率和库存,中奖结果本身没有资金属性。
- 免首单:结果确定但金额不确定,核心在于“资格 + 订单金额”绑定,必须校验首单属性。
所以“抽奖免首单”是抽奖和免单两种模型的叠加,不能简单复用优惠券表结构。我的建议是单独建一张资格表,用状态机管理生命周期,优惠券表只作为可选的下游表达形式。
2. 概率模型设计的数学基础
2.1 权重法与别名法
奖池里每一项奖品的概率不一样,最直观的做法是给每项配置权重,然后按权重随机。最小实现是前缀和 + 二分查找,够用、好调试、易排查。
// 文件路径:src/main/java/com/example/lottery/core/WeightRandom.java package com.example.lottery.core; import java.util.List; import java.util.concurrent.ThreadLocalRandom; /** * 权重随机工具类 * 适用于奖品数量在几十到几百量级的场景 */ public class WeightRandom<T> { private final List<T> items; private final int[] prefixSum; private final int totalWeight; public WeightRandom(List<T> items, List<Integer> weights) { if (items.size() != weights.size() || items.isEmpty()) { throw new IllegalArgumentException("奖品与权重数量必须一致且不能为空"); } this.items = items; this.prefixSum = new int[weights.size()]; int sum = 0; for (int i = 0; i < weights.size(); i++) { int w = weights.get(i); if (w <= 0) { throw new IllegalArgumentException("权重必须为正数,索引:" + i); } sum += w; prefixSum[i] = sum; } this.totalWeight = sum; } /** * 抽取一个奖品 * 使用 ThreadLocalRandom 避免多线程竞争 Random 的 CAS */ public T next() { int r = ThreadLocalRandom.current().nextInt(totalWeight); // 二分查找第一个大于 r 的前缀和下标 int low = 0, high = prefixSum.length - 1; while (low < high) { int mid = (low + high) >>> 1; if (prefixSum[mid] > r) { high = mid; } else { low = mid + 1; } } return items.get(low); } }这段代码有两个容易被忽略的细节。第一是nextInt(totalWeight)取的是[0, totalWeight),配合“第一个大于 r”的判定,实际落在第 i 项的概率正好是weights[i] / totalWeight,不会出现边界少算一个的问题。第二是用了ThreadLocalRandom而不是new Random(),后者在多线程下共享同一个种子序列,高并发时存在竞争,虽然结果依然随机,但吞吐会下降。
当奖品数量上千、QPS 又很高时,二分查找的 O(log n) 也会成为热点,这时可以换别名法(Alias Method),预处理 O(n) 之后单次采样 O(1)。不过对绝大多数活动来说,几十个奖品用前缀和完全足够,过早优化反而增加维护成本。
2.2 保底与伪随机的取舍
纯随机在业务上会挨骂。用户连抽 20 次全是“谢谢参与”,体验极差,运营也会收到投诉。所以实际项目里几乎都会加保底,常见两种做法:
第一种是次数保底:配置“第 N 次必中”,实现上用一张用户进度表记录已抽次数,命中阈值时直接返回保底奖品。这种方式确定性强、合规风险低,用户也能看到明确规则。
第二种是概率补偿:每次未中就累加一个隐藏的修正系数,让下一次中奖概率递增。这种方式体验平滑,但问题在于概率不再是静态的,如果对外公示的概率是静态值,就会产生偏差争议。
我的建议是优先用次数保底,并且在活动规则页明确写清“连续 N 次未中奖后必得某奖品”。概率补偿如果一定要做,务必把修正逻辑做在服务端并留好日志,保证任意一次抽奖结果都能被解释和回溯。
保底还有一个隐藏坑:保底奖品往往和普通奖品共用同一个库存 key。如果保底逻辑直接绕过库存校验扣减,就会出现库存负数。正确的做法是保底奖品使用独立的库存池,或者保底路径也必须走同一套扣减流程。
2.3 期望成本与预算控制
奖池设计不只看概率,还要看期望成本。假设一次抽奖的奖品配置如下:
| 奖品 | 权重 | 单价(元) | 期望成本贡献 |
|---|---|---|---|
| 免首单资格 | 1 | 30(封顶) | 0.03 |
| 5 元券 | 50 | 5 | 2.50 |
| 积分 10 分 | 200 | 0.01 | 0.02 |
| 谢谢参与 | 749 | 0 | 0 |
总权重 1000,单次抽奖期望成本约 2.55 元。如果活动预算 1 万元,那么理论可承受约 3900 次抽奖。这个数字要在活动上线前算清楚,并且做成系统里的硬性上限:当累计消耗达到预算的某个比例(比如 90%)时,自动把免首单权重的奖池降级,只保留小额奖品。
预算控制不能依赖人工盯盘。下面是一个简单的降级开关配置,用配置中心下发,改完实时生效:
# 配置项:lottery.cost-control lottery: cost-control: total-budget: 10000.00 # 活动总预算(元) warning-ratio: 0.8 # 预警比例 degrade-ratio: 0.9 # 降级比例,超过后只发小额奖品 stop-ratio: 1.0 # 停止发奖比例,超过后全部返回谢谢参与 check-interval-seconds: 30 # 消耗统计刷新间隔这里要注意,预算统计必须基于已核销的真实成本,而不是发放时的预估成本。因为免单资格可能不被使用,也可能被使用但金额低于封顶值。所以监控里要区分“发放预估成本”和“实际核销成本”两个指标,前者用于预防,后者用于对账。
3. 环境准备与技术选型
3.1 版本与依赖
下面的示例基于 Java 生态,版本请按你所在项目的实际情况调整,本文重点演示配置思路和代码结构。示例使用的环境如下:
- JDK 17
- Spring Boot 3.x
- MySQL 8.x
- Redis 6.x 及以上(需要支持 Lua 脚本)
- Maven 3.8+
pom.xml中的关键依赖:
<!-- 文件路径: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>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>如果你的项目已经在用 MyBatis-Plus 或 JPA,把spring-boot-starter-jdbc换掉即可,本文的 SQL 都是标准写法,不依赖特定 ORM。
3.2 项目结构
建议按领域拆分包结构,避免所有逻辑堆在一个 Service 里:
lottery-demo/ ├── src/main/java/com/example/lottery/ │ ├── LotteryApplication.java │ ├── api/ # 对外接口层 │ │ └── LotteryController.java │ ├── core/ # 抽奖核心算法 │ │ ├── WeightRandom.java │ │ └── PrizePool.java │ ├── service/ # 业务编排 │ │ ├── LotteryService.java │ │ └── CouponService.java │ ├── repository/ # 数据访问 │ │ ├── LotteryRecordRepository.java │ │ └── PrizeStockRepository.java │ └── config/ │ └── RedisConfig.java └── src/main/resources/ ├── application.yml └── lua/ └── deduct_stock.lua把 Lua 脚本单独放文件而不是写在字符串里,好处是可以用 Redis 客户端工具单独调试,也便于代码评审时看清逻辑。
3.3 表结构设计
核心表有四张。先看活动与奖池:
-- 文件路径:src/main/resources/sql/schema.sql CREATE TABLE lottery_activity ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', activity_code VARCHAR(64) NOT NULL COMMENT '活动编码', name VARCHAR(128) NOT NULL COMMENT '活动名称', start_time DATETIME NOT NULL COMMENT '开始时间', end_time DATETIME NOT NULL COMMENT '结束时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2已结束', budget_amount DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '活动预算', PRIMARY KEY (id), UNIQUE KEY uk_activity_code (activity_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖活动表'; CREATE TABLE lottery_prize ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', activity_code VARCHAR(64) NOT NULL COMMENT '活动编码', prize_code VARCHAR(64) NOT NULL COMMENT '奖品编码', prize_type TINYINT NOT NULL COMMENT '1免单资格 2优惠券 3积分 4谢谢参与', weight INT NOT NULL DEFAULT 0 COMMENT '权重', total_stock INT NOT NULL DEFAULT 0 COMMENT '总库存', used_stock INT NOT NULL DEFAULT 0 COMMENT '已使用库存', unit_cost DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '单份预估成本', PRIMARY KEY (id), UNIQUE KEY uk_activity_prize (activity_code, prize_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='奖池配置表';再是抽奖记录表,这张表是幂等的关键:
CREATE TABLE lottery_record ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', request_id VARCHAR(64) NOT NULL COMMENT '客户端请求号,幂等键', user_id BIGINT NOT NULL COMMENT '用户ID', activity_code VARCHAR(64) NOT NULL COMMENT '活动编码', prize_code VARCHAR(64) NOT NULL COMMENT '中奖奖品编码', prize_type TINYINT NOT NULL COMMENT '奖品类型', result_status TINYINT NOT NULL DEFAULT 0 COMMENT '0已中奖待发 1已发奖 2发放失败', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_request_id (request_id), UNIQUE KEY uk_user_activity (user_id, activity_code), KEY idx_activity_created (activity_code, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖记录表';uk_request_id保证同一个请求不会写入两次,uk_user_activity保证一个用户在一个活动里只有一条记录——这正是“首单免单只能中一次”的数据库层面约束。两个唯一索引同时存在时要注意插入顺序:如果业务允许一个用户在一个活动里多次抽奖但不重复中免单,那么uk_user_activity就不能加,应改成(user_id, activity_code, prize_type)的部分唯一约束,或者用业务层 + 状态字段控制。这一点必须在设计阶段和产品确认清楚,否则上线后改动索引代价很大。
免单资格表和核销记录表:
CREATE TABLE free_order_qualification ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', qual_no VARCHAR(64) NOT NULL COMMENT '资格编号', user_id BIGINT NOT NULL COMMENT '用户ID', activity_code VARCHAR(64) NOT NULL COMMENT '来源活动', max_amount DECIMAL(10,2) NOT NULL COMMENT '免单封顶金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0未使用 1已锁定 2已使用 3已过期 4已收回', expire_time DATETIME NOT NULL COMMENT '过期时间', order_no VARCHAR(64) DEFAULT NULL COMMENT '核销订单号', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_qual_no (qual_no), UNIQUE KEY uk_user_activity_qual (user_id, activity_code), KEY idx_status_expire (status, expire_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='免单资格表';version字段是后面乐观锁核销的基础,uk_user_activity_qual是防重复发放的最后一道防线。即使上游逻辑出 bug 重复调用发奖接口,数据库也会直接拒绝。
4. 核心抽奖算法的代码实现
4.1 奖池加载与缓存
奖池配置变化不频繁,适合放进本地缓存,但活动期间运营可能调整权重,所以要留一个刷新入口。简单实现如下:
// 文件路径:src/main/java/com/example/lottery/core/PrizePool.java package com.example.lottery.core; import java.util.ArrayList; import java.util.List; public class PrizePool { /** 奖品定义 */ public static class PrizeItem { private final String prizeCode; private final int prizeType; private final int weight; private final int totalStock; public PrizeItem(String prizeCode, int prizeType, int weight, int totalStock) { this.prizeCode = prizeCode; this.prizeType = prizeType; this.weight = weight; this.totalStock = totalStock; } public String getPrizeCode() { return prizeCode; } public int getPrizeType() { return prizeType; } public int getWeight() { return weight; } public int getTotalStock() { return totalStock; } } private final WeightRandom<PrizeItem> random; public PrizePool(List<PrizeItem> items) { List<Integer> weights = new ArrayList<>(items.size()); for (PrizeItem item : items) { weights.add(item.getWeight()); } this.random = new WeightRandom<>(items, weights); } public PrizeItem draw() { return random.next(); } }这里故意没有把库存判断放进draw(),因为库存是高频变化的数据,放在内存里会不一致,必须交给 Redis 和数据库处理。奖品对象的职责就是“决定抽到谁”,库存的职责是“决定这次能不能发出去”,两者分离,逻辑才清晰。
4.2 抽奖主流程编排
主流程要按固定顺序执行,每一步失败都有明确的处理方式:
// 文件路径:src/main/java/com/example/lottery/service/LotteryService.java package com.example.lottery.service; import com.example.lottery.core.PrizePool; import org.springframework.stereotype.Service; @Service public class LotteryService { private final PrizeStockService stockService; private final LotteryRecordRepository recordRepository; private final CouponService couponService; public LotteryService(PrizeStockService stockService, LotteryRecordRepository recordRepository, CouponService couponService) { this.stockService = stockService; this.recordRepository = recordRepository; this.couponService = couponService; } /** * 抽奖入口 * @param userId 用户ID * @param activityCode 活动编码 * @param requestId 客户端生成的请求号,用于幂等 */ public LotteryResult draw(Long userId, String activityCode, String requestId) { // 1. 幂等前置查询:同一请求号直接返回历史结果 LotteryRecord exist = recordRepository.findByRequestId(requestId); if (exist != null) { return LotteryResult.of(exist); } // 2. 校验抽奖资格(次数、活动状态、用户状态) checkDrawPermission(userId, activityCode); // 3. 概率抽取 PrizePool pool = PrizePoolHolder.get(activityCode); PrizePool.PrizeItem item = pool.draw(); // 4. 库存预扣减,失败则降级为谢谢参与 boolean deducted = stockService.tryDeduct(activityCode, item.getPrizeCode()); if (!deducted) { item = PrizePoolHolder.thanksItem(activityCode); } // 5. 落库,唯一索引兜底防重 LotteryRecord record = recordRepository.insert(userId, activityCode, item, requestId); // 6. 发奖(异步或同步,按业务要求) if (item.getPrizeType() != 4) { couponService.issue(record); } return LotteryResult.of(record); } private void checkDrawPermission(Long userId, String activityCode) { // 活动时间、用户可抽次数、黑名单校验,省略具体实现 } }这段编排里,第 4 步的降级策略值得展开说。库存扣减失败时,不要直接抛异常让用户看到报错,而是把结果降级成“谢谢参与”,同时打日志和监控埋点。原因很简单:库存不足是系统侧的问题,让用户承担错误提示体验很差,而且会引发客诉。降级之后,抽奖记录依然要落库,方便后续统计真实的中奖意愿和库存缺口。
4.3 幂等键的生成与传递
幂等键必须由客户端生成并在重试时保持不变。常见错误是用服务端生成的 UUID,那样每次重试都是新键,幂等完全失效。
前端生成的方式示例:
// 文件路径:web/lottery.js function genRequestId() { // 时间戳 + 随机串,保证同一用户同一时刻也不会冲突 return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); } async function draw(activityCode) { const requestId = genRequestId(); const resp = await fetch('/api/lottery/draw', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ activityCode, requestId }) }); return resp.json(); }这里要注意一个细节:幂等键的生成必须在“用户点击”那一刻完成,而不是在fetch内部每次调用时生成。如果把它写在被重试包裹的函数里,超时重试就会拿到新键。稳妥的做法是在事件处理函数里先生成一次,然后把requestId传给网络层。
5. 高并发下的库存与幂等控制
5.1 Redis 预扣减 + Lua 脚本
库存扣减必须是原子的,用GET再DECR会出现竞态。正确做法是用 Lua 脚本把“判断 + 扣减”合并成一次原子操作:
-- 文件路径:src/main/resources/lua/deduct_stock.lua -- KEYS[1] 库存键,例如 lottery:stock:ACT2024:FIRST_FREE -- ARGV[1] 本次扣减数量 -- 返回值:>=0 表示扣减后剩余库存;-1 表示库存不足;-2 表示键不存在 local stockKey = KEYS[1] local num = tonumber(ARGV[1]) local current = redis.call('GET', stockKey) if not current then return -2 end local remain = tonumber(current) if remain < num then return -1 end return redis.call('DECRBY', stockKey, num)Java 侧调用:
// 文件路径:src/main/java/com/example/lottery/service/PrizeStockService.java package com.example.lottery.service; import org.springframework.core.io.ClassPathResource; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; import java.util.Collections; @Service public class PrizeStockService { private final StringRedisTemplate redisTemplate; private final DefaultRedisScript<Long> deductScript; public PrizeStockService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; this.deductScript = new DefaultRedisScript<>(); this.deductScript.setLocation(new ClassPathResource("lua/deduct_stock.lua")); this.deductScript.setResultType(Long.class); } public boolean tryDeduct(String activityCode, String prizeCode) { String key = "lottery:stock:" + activityCode + ":" + prizeCode; Long result = redisTemplate.execute( deductScript, Collections.singletonList(key), "1" ); return result != null && result >= 0; } }注意DefaultRedisScript要复用同一个实例,不要在方法里每次 new,否则每次调用都会重新读取并解析脚本文件,白白消耗 IO。Spring 的DefaultRedisScript内部基于脚本 SHA1 缓存,复用时第二次开始走EVALSHA,性能差别很明显。
5.2 数据库乐观锁兜底
Redis 扣减成功不代表数据库库存一定够,因为可能存在缓存与库不一致、或者 Redis 重启丢数据的情况。所以在落库阶段再加一层乐观锁:
-- 数据库库存扣减,affected rows = 0 表示扣减失败 UPDATE lottery_prize SET used_stock = used_stock + 1 WHERE activity_code = #{activityCode} AND prize_code = #{prizeCode} AND used_stock + 1 <= total_stock;这条 SQL 的巧妙之处在于把“判断库存是否充足”和“扣减”合成了一条语句,依赖 InnoDB 的行锁保证原子性,不需要显式加SELECT ... FOR UPDATE,也不会有版本号重试的麻烦。当affected rows为 0 时,说明库存已被抢完,此时要回滚 Redis 的扣减(把之前 DECRBY 的值加回去),否则 Redis 和数据库的库存会长期偏差。
回滚逻辑要放在try-catch里,并且回滚本身失败时只记录日志、不再抛出异常,避免异常嵌套导致主流程失控:
try { int affected = prizeMapper.deductStock(activityCode, prizeCode); if (affected == 0) { // 数据库库存不足,回补 Redis redisTemplate.opsForValue().increment(stockKey, 1); return false; } } catch (Exception e) { // 数据库异常时同样需要回补,避免 Redis 库存被白白消耗 safeIncrement(stockKey); throw e; }5.3 分布式锁的使用边界
很多同学一遇到并发就先加分布式锁,其实抽奖场景大部分不需要。分析一下:
- 库存扣减:已经由 Redis Lua 和数据库行锁保证了原子性,不需要额外加锁。
- 幂等控制:由唯一索引保证,不需要锁。
- 用户维度的次数控制:如果只是“每人一次”,唯一索引足够;如果需要“每人每天 N 次”,用 Redis 的
INCR+ 过期时间更轻量。
真正需要分布式锁的场景是“跨多个资源的复合操作”,比如“扣库存 + 扣用户次数 + 写记录”要求整体原子。这时用锁能简化逻辑,但代价是吞吐下降,而且锁超时、锁误删的问题都要处理。我的建议是:能用唯一索引和原子操作解决的,坚决不用锁;实在要用,锁的粒度要小到具体用户,而不是整个活动。
6. 免单资格发放与订单核销
6.1 资格模型与状态机
免单资格的生命周期是明确的,用状态机管理最稳妥:
0 未使用 ──锁定──> 1 已锁定 ──支付成功──> 2 已使用 │ │ │ └──订单取消/超时──> 0 未使用 │ └──过期──> 3 已过期 异常场景:已使用 ──售后退款──> 4 已收回(不再退回,或按规则退回到 0)状态流转必须用带条件的UPDATE实现,而不是先查后改:
-- 锁定资格:只有未使用状态才能锁定 UPDATE free_order_qualification SET status = 1, order_no = #{orderNo}, version = version + 1 WHERE qual_no = #{qualNo} AND user_id = #{userId} AND status = 0 AND expire_time > NOW(); -- 确认使用:只有已锁定且订单号一致才能置为已使用 UPDATE free_order_qualification SET status = 2, version = version + 1 WHERE qual_no = #{qualNo} AND order_no = #{orderNo} AND status = 1;每条 SQL 的WHERE都带上了状态条件,这就是所谓的“状态机式更新”。它的价值在于:即使两个请求同时到达,也只有一个能把状态从 0 改成 1,另一个affected rows为 0,直接返回“资格已被占用”。不需要任何锁。
expire_time > NOW()这个条件同样重要,它把过期判断放在数据库层,避免应用服务器时钟不一致导致过期资格被使用。
6.2 核销流程
核销发生在订单结算环节,顺序不能乱:
- 用户下单,前端展示可用免单资格。
- 用户勾选,后端校验资格归属、状态、有效期、订单金额是否满足门槛。
- 锁定资格(状态 0 → 1),同时把订单号写入资格记录。
- 计算抵扣金额:
min(订单实付金额, 封顶金额)。 - 订单支付成功后,确认使用(状态 1 → 2)。
- 支付失败或订单超时取消,释放资格(状态 1 → 0)。
第 4 步的min是关键。如果直接按订单金额全额抵扣,用户下一笔 9999 元的订单就能把活动预算击穿。封顶金额必须在发券时就固化到资格记录里,而不是在核销时读活动配置——因为活动配置可能在期间被运营改小,会导致规则前后不一致。
第 6 步的释放逻辑要绑定订单超时任务。很多系统的问题是订单取消后资格没释放,用户发现券“消失”了,客诉量很大。建议在订单表加一个qual_no字段,取消订单时根据这个字段反查并释放,或者在资格表上定时扫描“锁定超过 30 分钟且订单已关闭”的记录做补偿。
6.3 防拆单套利
这是免单模式里最容易被薅的环节。用户抽中免单资格后,可能下单一个正好等于封顶金额的订单,用完资格后退款,或者拆成多单试图多次使用。
技术上要做三件事:
第一,资格表上的uk_user_activity_qual唯一索引保证一个用户在一个活动里只能有一张资格,从根上杜绝“多次中奖”。
第二,核销时校验订单是否为新用户首单。判断依据建议用“该用户历史已支付订单数 = 0”,而不是简单看注册时间。这个校验要与下单流程在同一个事务边界内,或者用订单表的唯一约束配合。
第三,退款逆向流程必须与资格状态联动。用户全额退款时,资格要么收回(状态 4),要么按规则退回(状态 0),具体取决于活动条款。无论哪种,都要在退款回调里做幂等处理,因为退款回调可能重复推送。
// 文件路径:src/main/java/com/example/lottery/service/RefundCallbackService.java public void onOrderRefunded(String orderNo) { FreeOrderQualification qual = qualRepository.findByOrderNo(orderNo); if (qual == null) { return; } // 只有已使用状态才需要处理,重复回调直接返回 if (qual.getStatus() != 2) { return; } int affected = qualRepository.revoke(qual.getQualNo(), orderNo); if (affected == 0) { log.warn("资格收回失败,可能已被并发处理,qualNo={}", qual.getQualNo()); } }7. 风控与防刷设计
7.1 用户与设备维度限流
抽奖接口是最容易被脚本刷的接口之一。基础防线是三层限流:
| 维度 | 限制方式 | 说明 |
|---|---|---|
| 用户维度 | 每日抽奖次数上限 | 存在用户进度表,原子累加 |
| 接口维度 | 单用户单 IP 每分钟请求数 | Redis 计数器 + 过期时间 |
| 设备维度 | 同设备指纹关联账号数上限 | 超出后进入人工审核队列 |
用户维度的次数控制用 Redis 实现最轻量:
// 文件路径:src/main/java/com/example/lottery/service/DrawLimitService.java public boolean acquireDrawChance(Long userId, String activityCode, int dailyLimit) { String key = "lottery:limit:" + activityCode + ":" + userId; Long count = redisTemplate.opsForValue().increment(key); if (count != null && count == 1L) { // 第一次设置时同时设置过期时间,避免每次调用都刷过期 redisTemplate.expire(key, Duration.ofDays(1)); } return count != null && count <= dailyLimit; }这里有个易错点:increment和expire不是原子的,如果服务在两步之间重启,key 就会永不过期,导致用户当天次数被永久占用。更严谨的做法是把这两步也写进 Lua 脚本,或者接受这个小概率问题但在 key 上带日期后缀(如20240501),让 key 天然按天隔离。后者实现简单,我一般优先选它。
7.2 异常行为识别
限流只能挡住粗暴的刷接口,挡不住“养号 + 慢速刷”。需要的信号包括:
- 同一设备在短时间内切换多个账号抽奖。
- 抽奖时间间隔高度规律(比如严格每 3 秒一次)。
- 收货地址、手机号段高度集中。
- 中奖后立刻下单、下单后立刻申请退款。
这些信号不需要上复杂的模型,用规则引擎或者简单的统计任务就能覆盖大部分。重点是产出“可疑用户列表”后要有处置手段:轻则限制参与、重则冻结资格。处置动作必须可追溯,保留操作人、时间、原因,方便申诉时核查。
7.3 规则公示与合规边界
这一块虽然偏业务,但技术上必须支持。至少要做到:
- 活动规则页可配置,且保存历史版本,出争议时能查到当时的规则。
- 中奖概率对外展示的数值,必须与后端奖池配置保持一致,建议由后台直接读取配置生成,避免运营手写数字。
- 抽奖记录对用户可查,包含时间、奖品、状态。
- 对参与人群有明确限制(例如仅限成年用户、仅限新注册用户),并在接口层做校验,不能只靠前端提示。
概率公示还有一个技术细节:如果用了保底,公示的概率应该是“单次名义概率”还是“含保底的综合概率”?建议在规则页把两者都说明清楚,后端日志里记录每次抽奖使用的实际权重,确保任何一次结果都能复现。
8. 常见问题与排查思路
下面这些是我在联调和压测阶段最常碰到的问题,按现象整理成表格,方便对照排查。
| 问题现象 | 常见原因 | 排查与解决 |
|---|---|---|
| 库存出现负数 | 保底路径绕过库存校验;Redis 扣减后 DB 扣减失败未回补 | 检查所有发奖路径是否统一走扣减;核对 Redis 与 DB 库存差值,加定时对账 |
| 同一用户重复中奖 | 缺少用户维度唯一索引;幂等键由服务端生成 | 补(user_id, activity_code, prize_type)唯一约束;改为客户端生成 requestId |
| 抽奖接口超时但记录已写入 | 同步发券耗时过长;未做超时降级 | 把发券改成异步消息,接口只保证记录落库;前端用 requestId 查询结果 |
| 中奖率高得离谱 | 权重配置全为 0 或负数;奖池未按活动隔离 | 加权重校验,启动时校验总和大于 0;奖池 key 必须带活动编码 |
| 免单资格无法核销 | 状态仍为已锁定;订单金额未达门槛;资格已过期 | 查资格表状态与 expire_time;核对订单实付金额与封顶金额 |
| 退款后资格未收回 | 退款回调未实现或未幂等 | 补退款回调逻辑;用订单号做幂等;加定时任务扫描异常状态 |
| Redis 重启后库存翻倍 | 库存只存在 Redis,未从 DB 重建 | 启动时从 DB 计算剩余库存并回写 Redis;关键活动加持久化配置 |
| 活动结束仍有抽奖请求成功 | 只校验了前端时间,未校验服务端时间 | 接口入口强制校验活动 start_time/end_time,且以数据库时间为准 |
排查时有一条通用原则:先看请求号,再看状态。任何一个异常工单,都应该能通过request_id找到抽奖记录,再通过记录里的资格编号找到资格表,最后通过order_no找到订单。如果这条链路中间有断点,说明可观测性不足,需要补日志字段。
还有一个容易被忽略的场景:压测环境和生产环境的奖池配置不一致。压测时为了省成本把免单权重调成 0,上线时忘了改回来,结果活动第一天一个人都没中。建议把奖池配置纳入发布检查清单,或者在应用启动时打印当前生效的奖池摘要,方便上线后快速确认。
9. 最佳实践与工程建议
9.1 预算监控与自动降级
预算监控要区分三个指标:发放预估成本、实际核销成本、退款冲回成本。前两个前面提过,第三个经常被漏掉。用户退款后,如果资格被收回且未再次使用,那么这笔成本应该从“实际核销成本”里扣减。如果不做冲回,活动结束后算账会发现实际花费远低于统计值,运营会误判活动效果。
降级动作要分级,不要一刀切。建议四级:正常 → 预警(通知到人)→ 降级(关闭高价值奖品)→ 熔断(全部返回谢谢参与)。每一级都要有手动开关,运营能在后台立即触发,不要只依赖自动判断。
9.2 灰度与开关设计
抽奖这种带资金属性的功能,上线必须能灰度。至少准备四个开关:
# 抽奖功能总开关,关闭后接口直接返回活动未开始 lottery.enabled=true # 是否允许发放免单资格,关闭后奖池中该项降级为优惠券 lottery.free-order.enabled=true # 单用户每日抽奖次数上限 lottery.daily-limit=3 # 是否开启异常用户强制审核 lottery.risk.audit-enabled=false这些开关放在配置中心,支持秒级生效。灰度策略可以按用户 ID 取模,先放 1% 流量验证完整链路(抽奖 → 发资格 → 下单核销 → 退款收回),确认无问题后再逐步放量。小流量阶段重点看的是“跨系统一致性”而不是“中奖概率”,因为概率问题在样本量小的时候看不出来。
9.3 数据一致性与对账
跨系统场景必须做对账,这是最后一道防线。建议每天跑一次三方对账:
- 抽奖记录中“已中奖待发”超过 10 分钟仍未变成“已发奖”的记录,说明发奖环节失败,需要补偿。
- 资格表中“已锁定”超过 1 小时但对应订单已关闭的记录,说明释放逻辑有遗漏。
- 资格表“已使用”记录数与订单表实际抵扣订单数不一致,说明核销链路有问题。
对账任务的输出不应该是日志,而应该是一张差异表 + 告警。因为日志容易被淹没,差异表可以直接给运营做人工处理。
-- 差异排查示例:找出锁定超时但订单已关闭的资格 SELECT q.qual_no, q.user_id, q.order_no, q.version FROM free_order_qualification q LEFT JOIN orders o ON o.order_no = q.order_no WHERE q.status = 1 AND q.version >= 0 AND (o.order_no IS NULL OR o.order_status IN ('CLOSED', 'CANCELLED')) AND q.order_no IS NOT NULL;注意q.version >= 0这类写法只是为了兼容某些 ORM 对空条件的处理,实际使用时应根据索引情况调整,避免全表扫描。生产环境跑这类 SQL 前,务必先在从库或测试环境验证执行计划。
9.4 可维护性方面的几个约定
命名上,所有与活动相关的 Redis key 统一用lottery:业务:活动编码:标识的格式,方便用SCAN批量清理和排查。日志上,抽奖链路的每条日志都必须带requestId和userId,否则串联不起来。异常处理上,发奖失败不要吞异常,要写入失败表并纳入补偿队列,避免“用户中奖了但没收到”这种最难解释的问题。
最后一点关于代码分层:抽奖算法、库存管理、发奖、核销这四块要严格分开,不要为了省事在一个 Service 里写 800 行。分开之后,每一块都能单独压测、单独替换实现(比如从 Redis 换成数据库原子操作),也能在新活动复用时直接拿来用。
10. 学习路线与落地建议
想把这套东西真正落地,建议按下面的顺序推进,不要一上来就追求大而全:
第一步,先用单库单表把完整链路跑通,包括抽奖、发资格、下单核销、退款收回四段,重点验证状态机是否正确。这个阶段不需要高并发方案,UPDATE ... WHERE status = x的写法足够。
第二步,补齐幂等。把所有写操作都加上唯一键,把客户端请求号打通到每一层。这一步做完,大部分线上数据错乱问题会消失。
第三步,再考虑并发性能。把库存预热到 Redis,用 Lua 保证原子扣减,用数据库做兜底。这个阶段要做压测,重点关注 Redis 与数据库的库存偏差。
第四步,做风控和对账。这一步最容易被省略,但它决定了活动能不能安全收尾。没有对账的营销系统,等于没有刹车的车。
后续可以继续深入的方向包括:用状态机引擎(如 Spring StateMachine)管理资格流转,用消息队列解耦发奖与抽奖主流程,用分库分表应对超大活动量,以及用实时计算框架做消耗监控。但这些都属于锦上添花,前三步没做扎实之前,不建议引入。
代码部分建议你自己动手改一遍:把权重抽奖的奖品换成你熟悉的业务对象,把库存扣减的 Lua 脚本在本地 Redis 里用EVAL手动跑几次,观察返回值的变化。很多并发问题只有自己构造出两个线程同时扣减的场景,才能真正理解为什么GET加DECR不行。