从零实现一个高可用的加权抽奖算法(附完整Java代码)
最近在负责的「津彩免疫之旅」微信H5项目中,需要实现一个活动抽奖功能,要求支持灵活的权重配置、整体中奖率控制、库存校验、高并发安全等特性,于是自己封装了一套通用的加权抽奖工具类,分享给大家,也做个技术沉淀。
一、需求背景
在活动抽奖场景中,我们通常会遇到这些核心需求:
- 不同奖品有差异化的中奖概率(比如大奖权重低、小奖权重高)
- 支持全局整体中奖率控制(比如活动期内整体20%中奖率,可灵活调整)
- 奖品有库存限制,库存为0时自动不参与抽奖,彻底避免超发
- 高并发场景下安全、无精度误差
- 代码可复用、易维护,适配不同项目
基于这些需求,我设计了这套加权抽奖算法,完全满足项目需求,也可以直接复用在其他抽奖场景中。
二、核心算法设计思路
1. 核心逻辑
- 加权随机:根据奖品的权重分配中奖概率,权重越高,中奖概率越大
- 整体中奖率控制:通过
totalWinRate参数统一控制全局中奖率,无需逐个调整奖品权重 - 库存校验:过滤掉库存为0、权重为0的无效奖品,避免超发
- 逐段排除算法:替代传统的「累加区间」算法,空间复杂度O(1),性能更优,适合高并发
- 高精度计算:使用
BigDecimal处理概率计算,避免浮点数精度误差 - 高并发安全:使用
ThreadLocalRandom替代Random,多线程下无竞争,性能更好
2. 算法流程
- 校验过滤:过滤掉库存不足、权重为0的无效奖品
- 计算总权重:统计所有有效奖品的总权重
- 分配概率:根据权重和整体中奖率,计算每个奖品的实际中奖概率(×10000转整数,避免精度问题)
- 谢谢参与权重:计算「谢谢参与」的权重(10000 - 整体中奖率×10000)
- 执行抽奖:通过逐段排除算法,生成随机数,命中对应奖品区间
三、完整代码实现
1. 抽奖工具类 LotteryUtil.java
package com.ozo.boot.immunization.utility;
import com.ozo.boot.immunization.entity.ImmunizationPrize;
import lombok.extern.slf4j.Slf4j;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
/**
* 加权抽奖算法
* 支持:整体中奖率控制、权重分配、库存校验、高并发安全
*/
@Slf4j
public class LotteryUtil {
private final static BigDecimal TEN_THOUSAND = new BigDecimal("10000");
/**
* @param prizeList 数据库查询的奖品列表
* @param totalWinRate 整体中奖率 0.2=20% 1.0=100%必中奖品
* @return 中奖奖品 / null=谢谢参与
*/
public static ImmunizationPrize draw(List<ImmunizationPrize> prizeList, BigDecimal totalWinRate) {
// 1. 仅保留 有剩余库存、权重>0 的奖品
List<ImmunizationPrize> available = prizeList.stream()
.filter(p -> p.getRemainStock() != null && p.getRemainStock() > 0)
.filter(p -> p.getPrizeWeight() != null && p.getPrizeWeight() > 0)
.toList();
// 无可用奖品 → 谢谢参与
if (available.isEmpty()) {
return null;
}
// 2. 计算总权重
int totalWeight = available.stream()
.mapToInt(ImmunizationPrize::getPrizeWeight)
.sum();
// 3. 按权重分配奖品概率(×10000转整数,避免浮点数误差)
List<Long> prizeWeights = available.stream()
.map(prize -> {
// 单个奖品概率 = 整体中奖率 × (自身权重 ÷ 所有奖品总权重)
BigDecimal ratio = new BigDecimal(prize.getPrizeWeight())
.divide(new BigDecimal(totalWeight), 8, RoundingMode.HALF_UP);
BigDecimal realProb = totalWinRate.multiply(ratio);
return realProb.multiply(TEN_THOUSAND).longValue();
})
.collect(Collectors.toList());
// 4. 谢谢参与的权重
long thanksWeight = TEN_THOUSAND
.subtract(totalWinRate.multiply(TEN_THOUSAND))
.longValue();
prizeWeights.add(thanksWeight);
// 5. 执行抽奖(逐段排除算法)
int winIndex = doLottery(prizeWeights);
// 中奖返回奖品,未中返回null
if (winIndex >= 0 && winIndex < available.size()) {
return available.get(winIndex);
}
return null;
}
/**
* 抽奖核心方法:逐段排除算法
*
* @param weightList 奖品权重列表 (如:[10,20,30,40])
* @return 中奖索引 (0=A,1=B,2=C,3=D);-1=参数异常
*/
public static int doLottery(List<Long> weightList) {
// 1. 基础校验:空集合直接返回异常
if (weightList == null || weightList.isEmpty()) {
return -1;
}
// 2. 计算总权重
long totalWeight = weightList.stream().mapToLong(Long::longValue).sum();
// 总权重为0,无有效奖品
if (totalWeight <= 0) {
return -1;
}
// 3. 逐段排除
var currentTotal = totalWeight;
for (int i = 0; i < weightList.size(); i++) {
long currentWeight = weightList.get(i);
// 权重为0直接跳过(不参与抽奖)
if (currentWeight <= 0) {
currentTotal -= currentWeight;
continue;
}
long randomNum = ThreadLocalRandom.current().nextLong(currentTotal);
// 命中当前区间 → 返回中奖索引
if (randomNum < currentWeight) {
return i;
}
// 未命中 → 砍掉当前权重,缩小区间
currentTotal -= currentWeight;
}
// 理论永不执行(兜底)
return -1;
}
public static void main(String[] args) {
// 模拟数据库奖品:每日库存10,剩余库存10, 权重1:1:1:1
List<ImmunizationPrize> prizes = List.of(
ImmunizationPrize.builder()
.id(1L)
.prizeWeight(1)
.prizeName("印章")
.remainStock(10)
.build(),
ImmunizationPrize.builder()
.id(2L)
.prizeWeight(1)
.prizeName("手办")
.remainStock(10)
.build(),
ImmunizationPrize.builder()
.id(3L)
.prizeWeight(1)
.prizeName("冰箱贴")
.remainStock(10)
.build(),
ImmunizationPrize.builder()
.id(4L)
.prizeWeight(1)
.prizeName("贴纸")
.remainStock(10)
.build()
);
for (int i = 0; i < Integer.MAX_VALUE; i++) {
System.out.println("第" + i + "次抽奖");
// 整体中奖率20%(想调难度只改这一个数)
ImmunizationPrize result = draw(prizes, new BigDecimal("1"));
System.out.println(result == null ? "谢谢参与" : "中奖:" + result.getPrizeName());
if (result != null) {
break;
}
}
}
}
2. 奖品实体类 ImmunizationPrize.java
package com.ozo.boot.immunization.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Builder;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 抽奖奖品表实体(immunization_prize)
*/
@Data
@Builder
@TableName("immunization_prize")
public class ImmunizationPrize implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 奖品名称(四季印章/冰箱贴/津彩手办/造景贴纸/谢谢参与)
*/
private String prizeName;
/**
* 奖品等级(一等奖/二等奖/谢谢参与)
*/
private String prizeLevel;
/**
* 奖品图片(对应抽奖页展示)
*/
private String prizeImg;
/**
* 中奖概率(0.0000-1.0000)
*/
private BigDecimal winProbability;
/**
* 概率权重
*/
private Integer prizeWeight;
/**
* 每日发放库存
*/
private Integer dailyStock;
/**
* 活动总库存
*/
private Integer totalStock;
/**
* 当日剩余库存
*/
private Integer remainStock;
/**
* 排序号
*/
private Integer sort;
/**
* 状态:1-启用 0-禁用
*/
private Integer status;
/**
* 逻辑删除:0-未删 1-已删
*/
private Integer delFlag;
/**
* 创建人
*/
private Long createUser;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新人
*/
private Long updateUser;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/**
* 备注
*/
private String remark;
}
四、核心设计亮点拆解
1. 逐段排除算法 vs 传统累加区间算法
传统的加权随机算法,通常是先累加所有权重,生成一个区间数组,然后生成随机数,遍历区间数组命中对应奖品。这种方式的问题是:
- 空间复杂度O(n),奖品数量多的时候会占用额外内存
- 遍历区间数组的时间复杂度O(n),性能一般
而逐段排除算法的优势:
- 空间复杂度O(1),不需要生成区间数组
- 平均时间复杂度O(n),但实际命中概率高的奖品会提前返回,性能更优
- 代码更简洁,易维护
2. 高精度计算:BigDecimal的使用
抽奖概率计算中,浮点数的精度误差是常见的坑(比如0.1+0.2≠0.3),因此我们使用BigDecimal进行所有概率计算,并且通过×10000转整数的方式,彻底避免精度问题,保证概率的准确性。
3. 高并发安全:ThreadLocalRandom的使用
在多线程场景下,Random是线程安全的,但会存在多线程竞争,导致性能下降。而ThreadLocalRandom是Java 7+提供的线程局部随机数生成器,每个线程有自己的随机数种子,无竞争,性能提升明显,非常适合高并发的抽奖场景。
4. 灵活的整体中奖率控制
通过totalWinRate参数,我们可以统一控制全局中奖率,比如活动初期设置0.2(20%),活动末期提升到0.5(50%),无需逐个调整每个奖品的权重,运营配置非常灵活。
5. 库存校验:避免超发
在抽奖前,我们会过滤掉remainStock <= 0的奖品,确保库存不足的奖品不会被抽中,彻底避免超发问题,这是实际项目中必须的校验逻辑。
五、使用说明与测试
1. 测试方法
工具类中提供了main方法,可以直接运行测试:
- 模拟了4个奖品,权重1:1:1:1,库存充足
- 可以通过修改
totalWinRate参数调整整体中奖率(比如new BigDecimal("0.2")就是20%中奖率) - 运行后会打印抽奖结果,直到中奖为止
2. 项目中使用
- 从数据库查询所有有效奖品列表(
prizeList) - 调用
LotteryUtil.draw(prizeList, totalWinRate)方法,获取中奖结果 - 如果中奖,扣减对应奖品的库存;如果未中,返回「谢谢参与」
- 记录抽奖日志,方便后续排查问题
六、总结
这套加权抽奖算法,完全满足了项目中活动抽奖的所有需求,并且具备高可用、高并发、高精度、易维护的特点,可以直接复用在任何抽奖场景中。
作为个人开发者,在项目中遇到需求时,尽量自己实现通用工具类,不仅能加深对技术的理解,也能提升自己的代码能力,同时也方便后续项目的复用。
如果你有更好的抽奖算法实现,或者有任何问题,欢迎在评论区交流~
⚠️ 本文仅为个人技术学习分享,代码仅供学习交流使用,请勿用于商业用途。