通用营销抽奖模块-设计实现(第一部分:抽奖策略)

通用营销抽奖模块-设计实现(第一部分:抽奖策略)

引言

这篇文章主要讲解的是通用营销抽奖模块的设计实现的第一部分,抽奖部分的实现。

流程设计(流程图)


数据库设计

奖品表
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();
            }
        }
    }

这样就可以保证不让更新库存的压力打到数据库。

LICENSED UNDER CC BY-NC-SA 4.0