引言
这篇文章主要讲解的是通用营销抽奖模块的设计实现的第一部分,抽奖部分的实现。
流程设计(流程图)
数据库设计
奖品表
create table award
(
id int unsigned auto_increment comment '自增ID'
primary key,
award_id int not null comment '抽奖奖品ID - 内部流转使用',
award_key varchar(32) not null comment '奖品对接标识 - 每一个都是一个对应的发奖策略',
award_config varchar(32) not null comment '奖品配置信息',
award_desc varchar(128) not null comment '奖品内容描述',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
comment '奖品表';
策略表
create table strategy
(
id bigint unsigned auto_increment comment '自增ID'
primary key,
strategy_id bigint not null comment '抽奖策略ID',
strategy_desc varchar(128) not null comment '抽奖策略描述',
rule_models varchar(256) null comment '规则模型,rule配置的模型同步到此表,便于使用',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
comment '抽奖策略';
create index idx_strategy_id
on strategy (strategy_id);
create table strategy_award
(
id bigint unsigned auto_increment comment '自增ID'
primary key,
strategy_id bigint not null comment '抽奖策略ID',
award_id int not null comment '抽奖奖品ID - 内部流转使用',
award_title varchar(128) not null comment '抽奖奖品标题',
award_subtitle varchar(128) null comment '抽奖奖品副标题',
award_count int default 0 not null comment '奖品库存总量',
award_count_surplus int default 0 not null comment '奖品库存剩余',
award_rate decimal(6, 4) not null comment '奖品中奖概率',
rule_models varchar(256) null comment '规则模型,rule配置的模型同步到此表,便于使用',
sort int default 0 not null comment '排序',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '修改时间'
)
comment '抽奖策略奖品概率';
create index idx_strategy_id_award_id
on strategy_award (strategy_id, award_id);
create table strategy_rule
(
id bigint unsigned auto_increment comment '自增ID'
primary key,
strategy_id int not null comment '抽奖策略ID',
award_id int null comment '抽奖奖品ID【规则类型为策略,则不需要奖品ID】',
rule_type tinyint(1) default 0 not null comment '抽象规则类型;1-策略规则、2-奖品规则',
rule_model varchar(16) not null comment '抽奖规则类型【rule_random - 随机值计算、rule_lock - 抽奖几次后解锁、rule_luck_award - 幸运奖(兜底奖品)】',
rule_value varchar(256) not null comment '抽奖规则比值',
rule_desc varchar(128) not null comment '抽奖规则描述',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
constraint uq_strategy_id_rule_model
unique (strategy_id, rule_model)
)
comment '抽奖策略规则';
create index idx_strategy_id_award_id
on strategy_rule (strategy_id, award_id);
create table rule_tree
(
id bigint unsigned auto_increment comment '自增ID'
primary key,
tree_id varchar(32) not null comment '规则树ID',
tree_name varchar(64) not null comment '规则树名称',
tree_desc varchar(128) null comment '规则树描述',
tree_node_rule_key varchar(32) not null comment '规则树根入口规则',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
constraint uq_tree_id
unique (tree_id)
)
comment '规则表-树';
create table rule_tree_node
(
id bigint unsigned auto_increment comment '自增ID'
primary key,
tree_id varchar(32) not null comment '规则树ID',
rule_key varchar(32) not null comment '规则Key',
rule_desc varchar(64) not null comment '规则描述',
rule_value varchar(128) null comment '规则比值',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
comment '规则表-树节点';
create table rule_tree_node_line
(
id bigint unsigned auto_increment comment '自增ID'
primary key,
tree_id varchar(32) not null comment '规则树ID',
rule_node_from varchar(32) not null comment '规则Key节点 From',
rule_node_to varchar(32) not null comment '规则Key节点 To',
rule_limit_type varchar(8) not null comment '限定类型;1:=;2:>;3:<;4:>=;5<=;6:enum[枚举范围];',
rule_limit_value varchar(32) not null comment '限定值(到下个节点)',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
comment '规则表-树节点连线';
缓存设计
1.抽奖策略
2.抽奖策略范围map
3.抽奖策略到一定次数的范围map
4.抽奖随机值范围
4.策略奖品配置
5.对应策略规则树缓存
6.对应策略奖品库存缓存
strategy_award_count_key_100006_103
ttl: 对应活动的过期时间
type:String
详细设计
1.策略概率装配处理
功能诉求
我们在抽奖之前,首先要做的就是把我们的抽奖策略进行一个装配。
关于抽奖算法的实现,一种是空间换时间,另外一种是时间换空间。具体来说呢,空间换时间就是提前计算好抽奖的概率分布,用本地内存Guava Cache、Caffeine、Encache 等都可以,或者使用Redis 进行存储,最后我们抽奖的时候生成一个随机值,在空间之中定位出他的位置,复杂度自然就是O(1)了。我在抽奖中使用的就是这种算法,但是这种方案也会有一定的缺陷,可以在后文中看到。
相比于Redis ,本地内存自然是更快地,但 Redis 可以直接解决分布式存储问题,本地内存需要让多台分布式机器都保持数据的同步更新,所以我就使用了Redis 进行存储。
详细设计
在redis中呢,抽奖策略范围值我们就使用hash进行存储就行了,在上面的缓存设计中我们不难看到。
查询策略奖品配置我们从redis中就可以拿到,获取最小概率值 我们可以查到的奖品中获取出来 之后再计算概率的总和,计算概率的范围,之后我们就可以在一个list添加我们的奖品id 之后打乱顺序 添加到map中 存储到redis中。在我们缓存完抽奖策略之后 我们还要缓存
权重策略配置 -适用于 rule_weight 权重规则配置
因为在我们的设计中有 类似抽到40次必中什么什么 所以这个我们也要缓存上,当用户的抽奖范围到达一定数量就要执行其中的抽奖 这个也要缓存到。
2.抽奖策略
前置抽奖规则过滤
rule_blacklist 黑名单过滤 ,rule_weight 抽到xx次比中xx奖品过滤
首先 这两个规则是互斥的,两者只能取其一
对应到策略规则表中
在这里 60:102,103,104,105 就是 60次抽奖对应触发抽到 102 103 104 的奖品id
101:user001,user002,user003 就是 用户为 user001 的用户 是黑名单用户 只能抽到101的奖品id
功能诉求
实现抽奖前的前置抽奖规则过滤
目前有的功能就是黑名单 和权重过滤
详细设计
责任链设计模式+工厂设计模式
1.创建责任链接口和抽象类
2.创建责任链实体
目前创建了三个责任链 ,分别是黑名单责任链 权重范围责任链 以及默认抽奖责任链
3.创建责任链构造工厂
工厂类,负责根据策略ID构建责任链,并缓存已构建的责任链。
工厂类的流程图:
使用
抽奖中规则过滤
功能诉求
在策略规则表中
我们要过滤的就是抽奖次数,如果当前用户抽奖 抽到了 id 为107的奖品,但次数没有到达100次,那么只会给他兜底的奖品,也就是rule_value 的奖品id
抽奖后规则过滤
功能诉求
我们在这里过滤的就是对应奖品id的库存,如果奖品的库存都不足了,我们只能给他发送一个兜底的奖励。
抽奖中与后规则过滤的功能实现
这里有一个矛盾点需要解决。对于抽奖策略的前置规则过滤是顺序一条链的,有一个成功就可以返回。比如;黑名单抽奖、权重范围抽奖、默认抽奖,总之它只能有一种情况,所以这样的流程是适合责任链的。
那么对于抽奖中到抽奖后的规则,它是一个非多分支情况的规则过滤。单独的责任链是不能满足的,如果是拆分开抽奖中规则和抽奖后规则分阶段处理,中间单独写逻辑处理库存操作。那么是可以实现的。但这样的方式始终不够优雅,配置化的内容较低,后续的规则开发仍需要在代码上改造。所以这里使用组合模式的决策树模型。
规则树模型
RuleTreeVO 决策树的树根信息,标记出最开始从哪个节点执行「treeRootRuleNode」。
RuleTreeNodeVO 决策树的节点,这些节点可以组合出任意需要的规则树。
RuleTreeNodeLineVO 决策树节点连线,用于标识出怎么从一个节点到下一个节点。
规则节点
执行引擎
执行引擎呢,就是负责构建二叉树的调用关系的
执行引擎构建工厂
由决策树工厂管理规则节点的注入和决策树引擎的创建。在使用的时候,可以通过 openLogicTree 方法获取执行器。执行器提供了规则的执行操作。
串联总抽奖流程
我们实现了抽奖中的所有过滤,开始执行串联。在这里使用了模板设计模式进行串联。
定义AbstractRaffleStrategy模板类和performRaffle 模板方法
定义raffleLogicChain 和 raffleLogicTree 抽象方法,由子类实现具体的逻辑
子类实现其中的方法
raffleLogicChain实现
这个就直接从责任链构建工厂中获取即可
ILogicChain logicChain = defaultChainFactory.openLogicChain(strategyId);
return logicChain.logic(userId, strategyId);
raffleLogicTree 实现
这个方法呢 需要先构建抽奖策略-规则树,我们可以把构建完的规则树放到缓存中
就是从这三张表里面构建出一个map集合,用于我们构建整个规则树。
构建完规则树调用我们的抽中与后规则过滤的方法即可。
3.不超卖库存规则实现(在规则树的库存校验)
功能诉求
当我们通过抽奖策略拿到用户可获得的奖品ID之后,我们接下来就要对这一条记录进行库存的扣减操作了,档之后扣减成功之后,才可以拿到奖品ID对应的奖品,否则就走兜底的奖品。
详细设计
首先对于库存集中扣减类的业务流程,是不能直接用数据库表抗的。
比如数据库表有一条记录是库存,如果是通过锁这一条表记录更新库存为10、9、8的话,就会出现大量的用户在应用用获得数据库的连接后,等待前一个用户更新完库表记录后释放锁,让下一个用户进入在扣减。
这样随着用户参与量的增加,就有非常多的用户处于等待状态,而等待的用户是持久数据库的连接的,这个连接资源非常宝贵,你占用了应用中其他的请求就进不来,最终导致一个请求要几分钟才能得到响应。【前台的用户越着急,越疯狂点击,直至越来越卡到崩溃】
所以,对于这样的秒杀场景,我们一般都是使用 redis 缓存来处理库存,它只要不超卖就可以。但也确保一点,不要用一条key加锁和等待释放的方式来处理,这样的效率依然是很低的。
对于库存节点的操作,开发 decr 方式扣减库存。decr 是原子操作,效率非常高。这样要注意,setnx 加锁是一种兜底手段,避免后续有库存的恢复,导致库存从1消耗后又回到了2重复消费。所以对于每个key加锁,98、97、96... 即使有恢复库存也不会导致超卖。
库存消耗完以后,还需要更新库表的数据量。但这会也不能随着用户消耗奖品库存的速率,对数据库表执行扣减操作。所以这里可以通过 Redisson 延迟队列的 + 定时任务的凡是,缓慢消耗队列数据来更新库表数据变化。
装配库存数据
我们从数据库中查询策略下奖品的库存,再放入到redis之中。
扣减库存
完成抽奖树的库存扣减节点
在我们库存扣减成功之后,我们还要考虑把缓存中的数据同步到mysql之中。
之后我们再创建一个定时任务去处理库存扣减
/**
* 本地化任务注解;@Scheduled(cron = "0/5 * * * * ?")
* 分布式任务注解; @XxlJob("updateAwardStockJob")
*/
@XxlJob("updateAwardStockJob")
public void exec() {
// 为什么加锁?分布式应用N台机器部署互备,任务调度会有N个同时执行,那么这里需要增加抢占机制,谁抢占到谁就执行。完毕后,下一轮继续抢占。
RLock lock = redissonClient.getLock("big-market-updateAwardStockJob");
try {
boolean isLocked = lock.tryLock(3, 0, TimeUnit.SECONDS);
if (!isLocked) return;
List<StrategyAwardStockKeyVO> strategyAwardStockKeyVOS = raffleAward.queryOpenActivityStrategyAwardList();
if (null == strategyAwardStockKeyVOS) return;
for (StrategyAwardStockKeyVO strategyAwardStockKeyVO : strategyAwardStockKeyVOS) {
executor.execute(() -> {
try {
StrategyAwardStockKeyVO queueStrategyAwardStockKeyVO = raffleStock.getRaffleStock(strategyAwardStockKeyVO.getStrategyId(), strategyAwardStockKeyVO.getAwardId());
if (null == queueStrategyAwardStockKeyVO) return;
log.info("定时任务,更新奖品消耗库存 strategyId:{} awardId:{}", queueStrategyAwardStockKeyVO.getStrategyId(), queueStrategyAwardStockKeyVO.getAwardId());
raffleStock.updateStrategyAwardStock(queueStrategyAwardStockKeyVO.getStrategyId(), queueStrategyAwardStockKeyVO.getAwardId());
} catch (InterruptedException e) {
log.error("定时任务,更新奖品消耗库存失败 strategyId:{} awardId:{}", strategyAwardStockKeyVO.getStrategyId(), strategyAwardStockKeyVO.getAwardId());
}
});
}
} catch (Exception e) {
log.error("定时任务,更新奖品消耗库存失败", e);
} finally {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
这样就可以保证不让更新库存的压力打到数据库。