规则引擎之抽奖系统设计落地实践
1、需求描述
业务方在直播过程中需要一些课后互动,比如抽奖来发送一些奖品,还有一些大促活动也可能需要通过下单获得抽奖次数来抽取大奖提升下单率。一些成熟的抽奖系统在某些程度上可以接管这些需求,但都是一些通用或者常用的功能,对于一些定制化需求比较乏力,由于内部已经接入规则引擎,针对复杂、定制业务处理更灵活高效,只需要设计一套抽奖架子,即可完成复杂的抽奖业务。
表达式规则引擎开源地址:
- https://github.com/liukaixiong/expression-mind-map-engine
- https://gitee.com/liukaixiong/expression-mind-map-engine
欢迎体验,有问题随时留言。
🎯 抽奖系统核心难点与解决方案 | 规则引擎深度赋能
难点维度 | 难点描述 | 规则引擎应用 🔧 | 解决方案 ✨(需自行实现) |
---|---|---|---|
人群精准触达 | 仅允许白名单/特殊身份用户参与抽奖 | 动态校验表达式: `(白名单校验 | |
奖品定向发放 | VIP用户需绑定专属大奖 普通用户限制抽奖范围 | 环境变量注入:fn_env_put_value('vipUser',校验VIP身份) && fn_env_add_list('奖品池',专属奖品) | 🔒 奖品池动态隔离 🎯 支持奖品优先级分层控制 |
动态概率调控 | 1W人抽空奖品库 首次必中+后续兜底 | 权重区间算法:奖品权重2/4/4 → 区间1-2/3-6/7-10 首次抽奖强制命中逻辑 | 🎰 权重实时计算 📉 库存耗尽自动熔断 🍀 新手福利保障机制 |
高并发防超卖 | 5W+/秒请求下精准控制库存 | Redis原子操作:if(Redis预扣库存+1 ≤总库存)→发奖 else→移除奖品 | ⚡ 库存双重校验(缓存+DB) |
多活动并行 | 定制化转盘(专属奖品)与通用转盘独立运行 | 活动码隔离策略:活动编码前缀匹配规则集 独立奖品池+独立概率配置 | 🎪 活动资源完全隔离 ⏱️ 10分钟快速上线新活动 |
任务联动体系 | 邀请3人得抽奖机会 观看直播解锁隐藏奖品 | 任务规则链:完成任务→触发次数增减事件 环境变量注入新奖品 | 🔗 任务与抽奖深度耦合 🚀 实时奖励反馈机制 |
💡 技术赋能价值点
- 配置化代替编码:通过规则表达式实现
零代码迭代
业务逻辑 - 动态熔断机制:库存耗尽时自动触发
奖品熔断策略
,保障系统稳定性 - 毫秒级响应:规则引擎+Redis原子操作,抽奖核心链路平均响应<50ms
- 可视化监控:实时追踪
奖品消耗曲线
/人群命中比例
/规则执行效能
2、架构设计(简配版本)
2.1 表结构设计
公共字段就不列举了,就列举一些关键字段。
1️⃣ 转盘活动基础配置表
字段名 | 说明 |
---|---|
抽奖活动编码 | 活动唯一标识(如:LIVE_2023) |
开始时间 | 活动生效时间(精确到秒) |
结束时间 | 活动失效时间(精确到秒) |
活动是否有效 | 活动启用/停用开关(0-关闭,1-开启) |
2️⃣ 转盘奖品配置表
字段名 | 说明 |
---|---|
转盘编号 | 关联转盘活动编码 |
转盘编码 🔄 | 冗余字段,用于快速查询 |
奖项处理类型 | 奖品发放处理器(积分发放/商品兑换/仅记录日志) |
奖品类型 | 奖品分类标识(积分/课程/实物商品) |
奖品标题 | 奖品展示名称(如:“VIP课程礼包”) |
奖品数值 | 具体奖品值(积分数量/商品编码) |
奖品图标 | 前端展示图标的URL地址 |
奖品权重 | 抽中概率计算依据(权重越高概率越大) |
奖品排序 | 转盘上奖品的展示顺序 |
奖品库存 | 奖品最大可发放次数 |
奖品消耗数量 | 已发放次数(与库存比对防超卖) |
奖品优先级 🔄 | 冗余字段,用于贵重奖品排序(如:特等奖>一等奖) |
3️⃣ 用户抽奖日志表
字段名 | 说明 |
---|---|
转盘编号 | 关联抽奖活动编码 |
转盘编码 🔄 | 冗余字段,避免联表查询 |
奖品编号 🔄 | 冗余奖品唯一标识 |
奖品编码 🔄 | 冗余奖品业务编码(如:ITEM_1001) |
奖品类型 🔄 | 冗余奖品分类标识 |
奖品标题 🔄 | 冗余奖品展示名称 |
奖品数值 🔄 | 冗余奖品具体数值 |
奖品图标 🔄 | 冗余奖品图标URL |
用户编号 | 抽奖用户唯一标识 |
4️⃣ 用户抽奖次数表
字段名 | 说明 |
---|---|
转盘编号 | 关联活动规则 |
用户编号 | 用户身份标识 |
剩余转盘机会 | 可参与抽奖次数(通过任务动态增减) |
5、任务表【任务编码和转盘编码保持一致即可,我们是有一个通用的任务表代替了】
…
接口设计
1、抽奖配置下发接口
该接口只需要验证该用户对应的身份信息以及转盘的配置信息
- 活动信息: 活动时间校验、活动状态校验
- 是否有资格: 【规则引擎处理】
- 剩余抽奖次数:【规则引擎处理】
- 转盘奖项集合
- 关联任务
- 当前用户抽奖记录
- 全局用户抽中记录【top 20】
以上步骤其实都可以将有些配置开关去规则引擎中配置。
2、 用户抽奖接口
当用户点击抽奖的时候,验证能否抽奖、然后计算概率、返回奖项信息
- 活动是否有效
- 是否有抽奖次数
- 是否有资格
- 概率计算,得到奖项
- 发放奖项
- 记录用户的抽奖日志
- 扣减用户抽奖次数
规则引擎的处理方式
注意下面的函数都是业务函数,你可以参考规则引擎的函数以及动态变量实现,去做自己业务的通用能力,本质上都是能力整合了。
活动1 : 先判断活动码,确定落在哪个活动规则分支。
1、转盘是否有资格,人群可见 【可见性表达式设计】
业务要求是:1、内测用户【白名单】 2、特殊用户【白名单】 3、有资格用户【业务身份校验】
表达式: (black_white_list(lotteryCode,nil,0,userId) || is_course_user(userId, courseId))
2、非首次抽奖,直接指定奖项
该表达式执行成功过一次,直接返回默认奖项,并且消耗一次该奖项的次数
表达式: !expression_repeatable() && fn_record_result_context(‘lotteryItemId’,defaultItemId) && lottery_item_consumer(defaultItemId,1)
3、设置用户可抽选奖项
1、是否存在iPad白名单,并且还有库存。将奖项添加到可抽选集合中。
put_env(‘ixxxUser’,family_black_white_list(lotteryCode,‘ipad’,0,userId)) && get_env(‘ixxxUser’)
2、邀请过的用户金额下单金额大于特定值,新增特定的奖项
…
3、如果大奖还存在库存,添加一个大奖资格,
lottery_item_remaining_quantity(6) && add_env_list(‘optionalItemIdList’,6)
4、概率计算
根据第3步获取到的可抽选奖项,进行概率计算
lottery_item_random(转盘编码,兜底奖项,可抽选奖项集合)
这里的概率计算的话逻辑是:
根据可选奖项的权重累加,得到一个总数。并且每个奖项的权重会在这个总数范围内中得到一个连续的区间,再根据随机数判断落在哪个奖项的权重区间,来知道你抽中哪个奖项。
比如,你有3个奖项,分别是2、4、4,总和是10,第一个奖项就是【12】,第二个就是【36】,第三个就是【7~10】。随机数是8 就是第3个奖品。
5、任务完成,奖励次数
归类到任务系统,加入一个规则,即满足参与要求的,新增一次次数。
规则配置释义
上面还有一些回调,就不做过多介绍了,基于引擎设计业务的函数,通过配置的方式,将业务组合起来,即可轻松的玩转复杂的业务。
概率计算代码
/**
* 根据权重获取相应概率的对象
*
* @param itemInfoList 选项集合
* @param itemUnionIdFunction 唯一标识字段
* @param weightFunction 权重配置
* @param debugStepList 调试步骤
* @param <T>
* @return
*/
public static <T> T getWeightRandom(List<T> itemInfoList, Function<T, String> itemUnionIdFunction, Function<T, Integer> weightFunction, List<String> debugStepList) {
// 构建权重映射
if (CollectionUtils.isEmpty(itemInfoList)) {
return null;
}
if (itemInfoList.size() == 1) {
return itemInfoList.get(0);
} else {
int initWeight = 0;
TreeMap<Integer, T> weightMap = new TreeMap<>();
for (T itemInfo : itemInfoList) {
weightMap.put(initWeight += weightFunction.apply(itemInfo), itemInfo);
}
if (debugStepList != null) {
debugStepList.add("初始总权重:" + initWeight);
for (T itemInfo : itemInfoList) {
final Integer weight = weightFunction.apply(itemInfo);
final String itemObject = itemUnionIdFunction.apply(itemInfo);
Double percentage = Double.valueOf(weight) / initWeight * 100;
debugStepList.add(String.format("奖品[%s]-权重[%s]-概率[%s]", itemObject, weight, String.format("%.2f", percentage) + "%"));
}
}
int weightNumber = RandomUtil.randomInt(1, initWeight);
return weightMap.ceilingEntry(weightNumber).getValue();
}
}
// getWeightRandom(itemIdList, XXXX::getItemTitle, XXXX::getItemWeight, debugStepList);
防止超卖
本质上还是基于Redis的原子性去获取库存和实际消耗的数量进行比较,在库存剩余的情况下,通过redis原子性先自增一个,如果还是小于库存,说明申请成功了。大于的话,说明有并发,且库存已经消耗完了,直接返回false,上一层再删掉该奖项,继续概率计算。
public boolean consumerNumberProcessor(Long itemId, Integer number) {
// 消耗奖项信息
final ActivityLotteryItem item = getById(itemId);
final boolean isConsumer = item.getItemNumber() > item.getConsumeNumber();
// 是否能消耗,先确定奖项库存是否充足
if (isConsumer) {
// 尝试通过redis进行库存消耗
final String cacheKey = CacheKeyEnum.LOTTERY_ITEM_CONSUMER.toKey(item.getLotteryCode());
final RMap<Object, Integer> lotteryItemMap = RedisUtils.getClient().getMap(cacheKey);
final Integer resourceNumber = lotteryItemMap.addAndGet(item.getId(), number);
// 如果消耗之后小于库存数
final boolean result = item.getItemNumber() >= resourceNumber;
if (result) {
// 如果实际消耗和缓存消耗一致,则更新到数据库中,这里主要是判断是否存在并发消耗的情况。
final Integer concurrentNumber = SafeUtils.getValue(() -> lotteryItemMap.get(item.getId()), -1);
if (Objects.equals(concurrentNumber, resourceNumber)) {
log.info("[触发奖项消耗] : 库存: {} -> 实际消耗: {} ", item.getItemNumber(), resourceNumber);
SafeUtils.invoker(() -> updateConsumeNumber(itemId, resourceNumber));
} else {
log.info("[触发奖项消耗 - 存在并发交给下一个线程去更新] : 库存: {} -> 实际消耗: {} -> {} ", item.getItemNumber(), resourceNumber, concurrentNumber);
}
return true;
} else {
log.warn("奖项[{}]可能存在超支 : 库存: {} -> 实际消耗: {} ", itemId, item.getItemNumber(), resourceNumber);
// 被消耗完成的话,直接将消耗的数量设置为库存数量,并更新到数据库中
lotteryItemMap.put(item.getId(), item.getItemNumber());
SafeUtils.invoker(() -> updateConsumeNumber(itemId, item.getItemNumber()));
}
} else {
log.info("奖项不足 :{} -> {} ", item.getItemNumber(), item.getConsumeNumber());
}
return false;
}
另外基于库存的设计,当奖项库存被消耗完毕之后,该奖项不再参与后续的抽奖计算中,也就是说你如果希望1W人抽完所有奖项,只需要针对库存做一定的数量均摊,即可达到这样的效果。
多转盘体系配置
通过设计转盘码,来隔离规则配置,一些通用的活动根据活动码的前缀,来适配某一批活动的规则,落到对应的规则配置中。
如果需要做产品化的话,比如交给运营或者其他人使用的话,可以做一些前端渲染效果,构建一个json配置,规则引擎负责读取json配置,去完成对应的规则功能即可。
接口1: 转盘数据渲染接口
接口2: 通用的抽奖接口规则
3、关于表达式规则引擎
以上截图,均是通过规则引擎配置而成,并非简单的思维导图,借助思维导图直观、清晰的能力,非常适合复杂业务的配置和维护。
如果你受够了复杂业务,各种设计模式穿插,维护困难,时间长了复杂度高了,你也说不清里面的逻辑的时候,那么你可以考虑它,至少把复杂的事情简单化,就是它的初衷。
表达式规则引擎开源地址:
- https://github.com/liukaixiong/expression-mind-map-engine
- https://gitee.com/liukaixiong/expression-mind-map-engine
欢迎体验,有问题随时留言。