抽奖功能设计与实现

8,277 阅读4分钟
原文链接: blog.liuhongnan.com

需求点

近期开发抽奖功能,涉及到的问题以及相应的需求点列举如下:

  1. 能够根据奖品初始设置的抽中概率进行分配

  2. 防止前端并发操作产生的超中情况

  3. 避免频繁抽中单一奖品,导致抽中奖项种类不均匀

  4. 抽中的概率能够根据实时库存进行相应变化,便于灵活的增加减少奖品数量

  5. 奖品分为几等奖,每等奖中奖品数量不为一,并且同一等奖的抽中概率相同

  6. 保证每次抽奖均抽中

方案设计

将抽中奖品的逻辑实现放在后台,前端只负责相应的交互与结果展示,这样可以降低前端逻辑,更加可控

mock数据

首先模拟奖品数据,包括奖品id,奖品名称,奖品分类,奖品抽中概率,奖品库存

id 名称 分类 抽中概率(0-100) 库存
1 奖品1 一等奖 1 10
2 奖品2 二等奖 5 50
3 奖品3 三等奖 34 100
4 奖品4 三等奖 34 100
5 奖品5 三等奖 34 150
6 奖品6 四等奖 60 300
7 奖品7 四等奖 60 300
8 奖品8 四等奖 60 350
9 奖品9 四等奖 60 500

mock的数据满足以上列举的需求,即可以设置初始抽中概率值,库存可以灵活的去设置修改,每等奖中奖品数量不为一且相同等奖的抽中概率相同

实现设计

引入区间的概念,即每个奖品的区间距离为抽中概率乘以库存数量,并且单个奖品的区间左值为前面奖品区间距离之和,如上mock的数据为例:

id 名称 分类 抽中概率(0-100) 库存 区间
1 奖品1 一等奖 1 10 [0,10)
2 奖品2 二等奖 5 50 [10,260)
3 奖品3 三等奖 34 100 [260,3660)
4 奖品4 三等奖 34 100 [3660,7060)
5 奖品5 三等奖 34 150 [7060,12160)
6 奖品6 四等奖 60 300 [12160,30160)
7 奖品7 四等奖 60 300 [30160,48160)
8 奖品8 四等奖 60 350 [48160,69160)
9 奖品9 四等奖 60 500 [69160,99160)

附:若存在未中奖的情况,则需要添加未中奖区间,该区间可以为[区间最右值,区间最右值 乘n-区间最右值),n为抽几次中一次奖的数量

接着在区间范围内取随机值,落在哪个区间就获得相应的奖品

int randNum = new Random().nextInt(区间最右值);

对于防止奖品超中,分两方面进行控制,即查询奖品信息时,过滤掉库存为0的奖品

select id,ratio,num,type from prize where num > 0

抽中奖品进行写库时,加上CAS乐观锁机制,防止并发操作导致奖品超中

update prize set num = num -1 where id = #{prizeId} and num > 0

即保证只有num大于0的情况下才会减库成功

设计总结

  1. 奖品区间大小为库存*抽中概率,则抽中概率越大,区间会越大,满足需求1

  2. 在读取奖品信息时过滤库存为0,并且写库使用乐观锁控制,可以防止超中,满足需求2

  3. 当抽中奖品后,导致库存减少,相应的区间距离变小,所以随着抽中次数增多,抽中的概率会变小,会避免单一奖品频繁被抽中,满足需求3

  4. 可以灵活的进行改库修改库存数量,相应的区间距离会变化,抽中概率也会变化,满足需求4

逻辑实现

代码如下:Java实现

class IntervalBean {
private int front;
private int end;
private int type;

//构造函数与Get Set 略
}

public Map<String, Object> getWinInfo() {
try {
// 从数据库读取奖品信息列表
List<WinCalDO> l = luckDrawDAO.getCalList();
int totalWeight = 0;
Map<Long, IntervalBean> interval = new HashMap<>();
int prev = 0;
// 计算最右区间值以及奖品对应的区间
for (WinCalDO item : l) {
int spacing = item.getNum() * item.getRatio();
totalWeight += spacing;
interval.put(item.getPrizeId(), new IntervalBean(prev, prev + spacing, item.getType()));
prev = prev + spacing;
}
// 产生随机数
int randNum = new Random().nextInt(totalWeight);
long prizeId = -1;
int type = -1;
for (Map.Entry<Long, IntervalBean> m : interval.entrySet()) {
int front = m.getValue().getFront();
int end = m.getValue().getEnd();
if (randNum >= front && randNum < end) {
prizeId = m.getKey();
type = m.getValue().getType();
break;
}
}
Map<String, Object> res = new HashMap<>();
res.put("prizeId", prizeId);
res.put("type", type);
return res;
} catch (Exception e) {
LOGGER.error("计算获奖出错");
}
}

待完善点

  1. 因为计算奖品逻辑会动态根据库存等信息变化,所以每次计算都会读取数据库,获取所有奖品列表信息,后续考虑同步到缓存中进行,提高计算速度

  2. 若考虑到未中奖的情况,奖品全部抽完的次数不确定,可以加入保证抽几次必中一次的逻辑,确保奖品可以都抽完