Administrator
发布于 2026-04-14 / 12 阅读
0
0

抽奖算法

从零实现一个高可用的加权抽奖算法(附完整Java代码)

最近在负责的「津彩免疫之旅」微信H5项目中,需要实现一个活动抽奖功能,要求支持灵活的权重配置、整体中奖率控制、库存校验、高并发安全等特性,于是自己封装了一套通用的加权抽奖工具类,分享给大家,也做个技术沉淀。

一、需求背景

在活动抽奖场景中,我们通常会遇到这些核心需求:

  1. 不同奖品有差异化的中奖概率(比如大奖权重低、小奖权重高)
  2. 支持全局整体中奖率控制(比如活动期内整体20%中奖率,可灵活调整)
  3. 奖品有库存限制,库存为0时自动不参与抽奖,彻底避免超发
  4. 高并发场景下安全、无精度误差
  5. 代码可复用、易维护,适配不同项目

基于这些需求,我设计了这套加权抽奖算法,完全满足项目需求,也可以直接复用在其他抽奖场景中。

二、核心算法设计思路

1. 核心逻辑

  • 加权随机:根据奖品的权重分配中奖概率,权重越高,中奖概率越大
  • 整体中奖率控制:通过totalWinRate参数统一控制全局中奖率,无需逐个调整奖品权重
  • 库存校验:过滤掉库存为0、权重为0的无效奖品,避免超发
  • 逐段排除算法:替代传统的「累加区间」算法,空间复杂度O(1),性能更优,适合高并发
  • 高精度计算:使用BigDecimal处理概率计算,避免浮点数精度误差
  • 高并发安全:使用ThreadLocalRandom替代Random,多线程下无竞争,性能更好

2. 算法流程

  1. 校验过滤:过滤掉库存不足、权重为0的无效奖品
  2. 计算总权重:统计所有有效奖品的总权重
  3. 分配概率:根据权重和整体中奖率,计算每个奖品的实际中奖概率(×10000转整数,避免精度问题)
  4. 谢谢参与权重:计算「谢谢参与」的权重(10000 - 整体中奖率×10000)
  5. 执行抽奖:通过逐段排除算法,生成随机数,命中对应奖品区间

三、完整代码实现

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. 项目中使用

  1. 从数据库查询所有有效奖品列表(prizeList
  2. 调用LotteryUtil.draw(prizeList, totalWinRate)方法,获取中奖结果
  3. 如果中奖,扣减对应奖品的库存;如果未中,返回「谢谢参与」
  4. 记录抽奖日志,方便后续排查问题

六、总结

这套加权抽奖算法,完全满足了项目中活动抽奖的所有需求,并且具备高可用、高并发、高精度、易维护的特点,可以直接复用在任何抽奖场景中。

作为个人开发者,在项目中遇到需求时,尽量自己实现通用工具类,不仅能加深对技术的理解,也能提升自己的代码能力,同时也方便后续项目的复用。

如果你有更好的抽奖算法实现,或者有任何问题,欢迎在评论区交流~


⚠️ 本文仅为个人技术学习分享,代码仅供学习交流使用,请勿用于商业用途。



评论