幸运营销汇-开发日志-第二阶段

需求分析

img_5.png 在本阶段,主要实现在抽奖中增加每天进入,免费赠送N次抽奖。N次免费后,则引导用户使用积分兑换抽奖。 这个体现方式也可以是点击登录的方式获取抽奖次数

功能流程 img_6.pngimg_7.png

我们可以把用户参与抽奖理解为商城的一次下单,下单后才具备参与抽奖的资格。抽奖策略是具体的商品。而下单的过程中,则需要过滤活动的相关信息以及库存数据。

所有的判断流程做完后开始写入库中,库中则是用户一个互动的次数账户记录。记录着用户可以参与的抽奖次数。同时需要把参与活动的记录写一条订单。

此外为了扩展用户在一些场景中,首次【签到/登录】可以赠送一个抽奖次数外,还可以通过购买、做任务、兑换等方式获得新的抽奖次数。这样用户就可以不断地消耗自己的积分兑换抽奖次数来抽奖了。

库表设计

img_8.png

  • 抽奖活动表,配置了用户参与一个活动的时候,需要进行的必要信息判断。时间、库存、状态等。
  • 参与次数表,单独分离出来。这样更方便后续基于不同的次数编号,做扩展。比如兑换一个新的抽奖次数。
  • 活动下单记录表,用户参与活动,则需要先创建一笔订单记录。如果用户抽奖中有失败流程(网络抖动,超时,服务重启,新上线的逻辑异常等),也可以基于订单的状态,用户重新发起抽奖,也不会额外占用库存记录。
  • 活动次数账户表,记录着一个用户在一个活动的可参与次数数据,也就是个人活动账户。
  • 账户次数流水表,每一笔对账户变动的记录,无论是任何的方式的变动,都要有一条流水。

分库分表设计

为用户的行为数据,使用路由组件将数据散列到分库分表中。在本项目的系统设计中,有一个配置库(big_market)和两个分库(big_market_01、big_market_02),我们需要对两个分库进行配置路由操作。达到分库分表的目的,而配置库则是一个单库单表存储活动等配置类信息。分库分表调用流程。 img_9.png

  • 路由计算的处理,是以配置了 @DBRouter注解的 DAO 方法进行路由切面开始。通过获取用户ID(userId)值进行哈希索引计算。 哈希值 & 2从n次幂数量的库表 - 1 得到一个值,在根据这个值计算应该分配到哪个库表上去。比如这个是6,分库分表是2库4表,共计8个,那么6就分配到了1库4+2库2个等于6,也就得到了2库2表。
  • 对于计算得到的分库分表值,存入到 ThreadLocal 中,这个东西的目的是可以在一个线程的调用中,可以随时获取值,而不需要通过方法传递。
  • Spring 在执行数据库操作前,会获取路由。而路由组件则实现了动态路由,从 ThreadLocal 中获取。

在分库分表系统中,用户数据可能分布在不同数据库实例中。通过 dbRouter.doRouter(userId)根据用户 ID 切换到对应的数据库实例,确保后续的数据库操作(如查询、插入)落在同一实例,避免跨库事务问题。

抽奖活动订单流程设计

完成用户参与抽奖活动的流程设计,并可以支持后续满足用户通过不同行为来增加自己的抽奖次数。 我们可以把抽奖的行为理解为一个下单过程,用户参与抽奖,也等价于商品下单。只不过这个商品的 sku 是活动信息。 img_10.png

  • 用户的触达行为是后续需要扩展的部分,当我们把大营销结合给其他系统的时候,就可以让支付后的消息推送过来,给用户领取一次抽奖次数。并参与抽奖。类似于云服务器购买操作后的参与抽奖的过程。

如果把活动的可参与库存、用户库存都配置到活动本身。那么在一个活动上给用户分配不同的抽奖次数就不好配置了。因为可以把活动、活动库存和个人参与的次数,从活动配置中解耦出来,并通过 sku 商品表的方式配置出这样一组商品信息。

库表解耦

img_11.png

  • 首先,去掉活动表中的关联操作,并新增加活动 sku 表来做关联。这样就可以把活动和参与次数当成一种物料,之后 sku 来定义库存或者将来想扩展价格或者积分兑换也是可以的。
  • 之后,去掉原来的次数流水表,把流水的用途合并到订单表中。想获得更多的抽奖次数,就直接对 sku 下单即可。无论是通过赠送、签到、打卡、积分兑换等任何方式,都是可以的。这样也就增强了营销活动的扩展性。 img_12.png
  • 活动策略 -》活动配置-》sku,后者可以利用前者,将其视为一种可复用的实体,sku对应的大概是同一种活动在不同场景下的使用,用于细分抽奖活动表和参与次数表的对应关系。 活动的复用仅仅只需要更改次数编号表就可以了,不需要对于活动配置本身进行修改。 其实仔细想想,取消掉原先的关联实际上就意味着活动配置记录和次数编号不再是一一对应的关系,也需要一个新的id。
  • 举例:假设有一个抽奖活动: 调整前:活动库存=1000次,用户签到或下单都从这1000次扣减,无法区分行为。 调整后: SKU1(签到):库存=500次,赠送3次。 SKU2(下单):库存=1000次,赠送5次。 用户签到时,从SKU1扣减库存并记录3次;下单时,从SKU2扣减库存并记录5次。

sku库存标识符:我理解的就是跟 id 一样的属性,标识唯一,一般用于管理库存

为什么spring推荐构造注入呢

构造器注入的方式啊,能够保证注入的组件不可变,并且确保需要的依赖不为空。此外,构造器注入的依赖总是能够在返回客户端(组件)代码的时候保证完全初始化的状态。避免了循环依赖,提升了代码的可复用性 field注入对于IOC容器以外的环境,除了使用反射来提供它需要的依赖之外,无法复用该实现类。还可能会导致循环依赖,即A里面注入B,B里面又注入A。setter的方式能用让类在之后重新配置或者重新注入。 当有一个依赖有多个实现的使用,推荐使用field注入或者setter注入的方式来指定注入的类型。

抽奖活动流水入库

在基于活动、次数所组合的活动 sku,用户参与活动就相当于,下单sku给自己的活动账户充值可参与的额度次数。所以本节把活动的下单的过程落入到数据库中。写库会涉及到分库分表组件切分和开启事务的操作。

用户参与抽奖活动就会有一个活动额度账户,而本节则让来实现参与活动对自己的活动额度账户充值的过程。 img_13.png

  • 先实现出领取活动的框架结构代码,并对数据进行落库操作。(落库的过程会有分库分表下事务的操作)
  • 活动日期、活动状态、sku库存校验和扣减,这些都是固定的流程。无论创建多少个活动都会走这样的统一流程,所以这里适合添加一个责任链模式的结构。
  • 因为是分库分表设计,所以库表数据的写入需要确定切分键,并在同一个连接下执行 commit 这样才能把用户的活动账户和订单流程,一起写库。(也就是一个事务的特性)
  • 即以用户id(user_id)作为切分键,设置路由。具体实现:由于userId是一致的,所以在执行cn.bugstack.middleware.db.router.strategy.impl.DBRouterStrategyHashCode#doRouter这个方法进行路由的时候,计算得到的 idx 是相同的,在设置到DBContextHolder的ThreadLocal的时候对应的db和tb的值在同一个线程的时候是相同的(单线程操作)

**业务放重ID保证幂等 **:给用户增加的账户充值下单动作,需要外部透传对应的业务唯一ID 这样才能保证幂等。允许外部用同一个单号请求多次,但结果相同。out_business_no是唯一索引字段。

引入MQ处理活动SKU库存一致性

完成活动责任链判断,包括;活动的校验【日期、状态、sku库存】,之后是sku 库存的扣减操作。 之后这里会涉及一个缓存库存和数据库库存一致性问题,是本节要处理的重点。一个手段是之前在策略实现阶段采用的延迟队列做趋势更新,另外一个是本节要引入 RabbitMQ 在库存消耗空以后发送 MQ 消息,直接更新清空最终库存,保持缓存与数据库的一致性。

注意,可能此时缓存消耗库存阶段有失败丢失,不完全等于数据库真实库存。不过没关系,先保证一个统一库存即可。后续可以扫描统计订单数量,来校准库存。 img_14.png

  • 第一步;完成责任链的活动校验,时间、状态、库存。
  • 第二步;对库存的扣减,使用 decr + lock 锁的方式(兜底setNx锁)进行处理。高并发场景下的 Redis 的原子操作 DECR 和分布式锁作为兜底方案。lock 是一个兜底的设计,类似于【账户和流水】一笔消费,对应一个锁的记录。 这样的兜底,可以确保极端情况下,如运营误操作的时候,恢复库存为错误的情况下,有流水的锁记录,也不会超卖。锁的到期时间为设置为到活动结束时间 + 延迟1天。
@Override
public boolean subtractionActivitySkuStock(Long sku, String cacheKey, Date endDateTime) {
    long surplus = redisService.decr(cacheKey);
    if (surplus == 0) {
        // 库存消耗没了以后,发送MQ消息,更新数据库库存
        eventPublisher.publish(activitySkuStockZeroMessageEvent.topic(), activitySkuStockZeroMessageEvent.buildEventMessage(sku));
        return false;
    } else if (surplus < 0) {
        // 库存小于0,恢复为0个
        redisService.setAtomicLong(cacheKey, 0);
        return false;
    }
    // 1. 按照cacheKey decr 后的值,如 99、98、97 和 key 组成为库存锁的key进行使用。
    // 2. 加锁为了兜底,如果后续有恢复库存,手动处理等【运营是人来操作,会有这种情况发放,系统要做防护】,也不会超卖。因为所有的可用库存key,都被加锁了。
    // 3. 设置加锁时间为活动到期 + 延迟1天
    String lockKey = cacheKey + Constants.UNDERLINE + surplus;
    long expireMillis = endDateTime.getTime() - System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1);
    Boolean lock = redisService.setNx(lockKey, expireMillis, TimeUnit.MILLISECONDS);
    if (!lock) {
        log.info("活动sku库存加锁失败 {}", lockKey);
    }
    return lock;
}
  • 第三步;做完库存扣减后,发送延迟队列,由任务调度更新趋势库存,满足最终一致。
  • 第四步;库存消耗为0后,发送MQ消息,驱动变更数据库库存为0.ActivitySkuStockZeroCustomer类监听消费。
// 更新库存
skuStock.clearActivitySkuStock(sku);
// 清空队列 「此时就不需要延迟更新数据库记录了」
skuStock.clearQueueValue()

ActivityArmory:预热活动的信息和活动sku的库存,写入到 Redis 队列中。

延迟队列

@Override
public void activitySkuStockConsumeSendQueue(ActivitySkuStockKeyVO activitySkuStockKeyVO) {
    String cacheKey = Constants.RedisKey.ACTIVITY_SKU_COUNT_QUERY_KEY;
    RBlockingQueue<ActivitySkuStockKeyVO> blockingQueue = redisService.getBlockingQueue(cacheKey);
    RDelayedQueue<ActivitySkuStockKeyVO> delayedQueue = redisService.getDelayedQueue(blockingQueue);
    delayedQueue.offer(activitySkuStockKeyVO, 3, TimeUnit.SECONDS);
}

将活动 SKU 库存相关的数据放入延迟队列中,延迟 3 秒后供消费者处理,以此来减缓消费。(这里是一个双重减缓,一个是延迟队列,一个是定时的任务调度)。 作用:将库存扣减的实时操作与后续的数据库更新、状态同步等耗时操作分离,避免高并发下直接操作数据库引发性能瓶颈; 在分布式系统中,若库存扣减成功但后续操作(如订单创建)失败,通过延迟队列异步触发库存回滚,避免超卖; 保障最终一致性。改进:死信队列兜底为延迟队列配置死信队列(DLQ),处理长时间未消费的消息,避免消息丢失。

  1. decr操作的是账户,lock加锁流水,保证账户扣减额度与流水在同一事物下,因为不是lua脚本,多个原子操作只能利用decr值作为key进行加锁;保证库存不超卖;
  2. 一旦库存成为竞争状态,只要发生等待就会有释放不了连接的情况发生,用户越看不到结果就越疯狂点击,整个数据库被拖垮,发生慢查询 →设置延迟队列更新库存值,让数据库保持一个大概接近的库存值,缓解频繁写库带来的压力 ;
  3. 另一方面MQ在关键节点一锤定音,确保库存归零,避免误差扩散;

用kafka延时队列库存扣减,跟分段锁思路很像,本质上就是把整体的大延迟、大任务拆分成更小粒度的段落,每段独立负责自己的一部分延时或等待。只不过分段锁是空间上的,kafka是时间上的;前者哈希取模,后者用数学函数分级,二进制分解。

问题1:会不会出现缓存扣减成功,应用宕了,mq没发 导致少卖的情况

极端的情况下有可能。可以通过先存redis扣减记录到db, mq操作成功后再更新状态。 搞一个二阶段的协议模式。 最后中间状态没达到预期 就去验证redis数据然后做补偿。少卖没关系,核心是超卖。

问题2:那sku下单记录和活动账户变化不通过延迟队列和任务扣减原因

抽奖次数、账户流水是高实时需求,如果用延迟队列处理会有明显延迟,下单记录延迟数秒/分钟落库,用户无法实时查看订单;活动次数延迟更新,用户扣减次数抽奖就会读到旧值,可能在次数未扣减时重复参与抽奖,体验差。MQ的即时投递机制确保了用户操作后能立即看到订单状态和账户变化。

问题3;使用延迟队列➕定时任务去消耗队列,再加MQ当库存为0时清空延迟队列,和直接都是用MQ完成异步扣减有什么区别

  1. 如果扣减完mq直接更新库,和直接操作库扣减库存不用缓存是一样的了。数据库是扛不住大量的数据同时更新一条记录的,所有的请求会进入等待前面的处理释放行级锁。那么其他查询的操作,也没法获取到数据库连接,直至拖垮数据库。
  2. 所以要做异步的,延迟的,缓慢的更新,降低集中操作数据库的处理。

问题4:“趋势更新mysql库存”和“最后mysql库存清0”,分别用“Redis延时阻塞队列”和“RabbitMQ”。

延时阻塞队列加定时任务主要是为了缓冲数据库压力,比如几千个同时下单sku,如果用mq就和直接打到数据库没太大区别,用延时阻塞队列保证上一次数据库连接被释放后才会进行下一次的数据库操作 库存清零的话就是一次操作,所以用mq的及时性比较高。 异步持久化数据库要降低数据库压力,所以要”慢“,使用延迟队列;清空库存要尽可能在延迟队列消费之前清空队列,所以要”快“,使用MQ。

问题5;清空库存为 0 时,万一定时任务中也正在执行,那岂不是会出现数据库扣减为负数的情况?

定时任务里面扣减数据库库存的sql语句是当剩余库存大于0的时候才减一,所以当MQ清空库存为0的时候定时任务也正好执行了sql是不会继续扣减库存的。

多个 sku 共用延迟队列,库存丢失问题? 如果同一个队列混着多个 sku 的扣减任务, 一个 sku 清零时清理整个队列,别的 sku 的任务就丢了。 解决: 延迟队列按 sku 隔离,查询时传入 sku,在 IStockService 里带上 sku 来精准处理。

用户领取活动库表设计

设计用于用户参与活动所需的库表。从用户参与活动,扣减个人活动账户次数,创建活动订单。抽奖完成获得奖品ID后,写入中奖记录和task任务表(发mq补偿使用)。之后发送MQ消息,更新中奖记录。 img_15.png

  • 首先,用户抽奖开始,需要领取活动,扣减个人账户额度。生成一个抽奖订单。每个用户都有一个活动账户额度,里面包含了;总可参与次数、月可参与次数、日可参与次数。 这样的设计是为了应对复杂的业务需求。那么有这样的表,就不能只是在一个表里扣减额度,因为每天都要扣减额度,但只在一个账户中扣减,日的次数第一天扣减完,第二天相当于恢复为原始库存继续扣减。 所以这里要生成一个每日活动账户,当前则在自己的日账户中扣减。而总库存的日,是一种镜像记录,方便查询统计的。
  • 账户在扣减额度和用户的订单,要在一个事务内完成。但不能和后续的抽奖结果继续做事务,因为抽奖的过程还有很多的操作,包括缓存的处理,而他们都不能做事务。所以这部分是分开的。
  • 之后,抽奖策略结果计算完毕后,把奖品ID写入中奖记录表中,同时写一个 task 任务表。任务表是发 MQ 消息的,异步进行奖品的发放。 在写入完成奖品订单后,则直接发送一个 MQ 消息【发送后更新 task 表状态】,如果发送失败则还有 task 任务表,由 job 任务扫描的方式处理。这样可以尽快的发送 MQ 消息。
  • 最后,接收发送的 MQ 开始发放奖品。

img_16.png

  • 活动账户(月、日),分别记录每日和每月的参与次数。每天一条记录和每月一条记录。
  • 用户抽奖订单表,则是每个用户参与抽奖的时候产生的订单。
  • 用户中奖记录表,则是参与抽奖后获得具体奖品的记录表。
  • 任务表,用于发送MQ消息。通过任务扫描发送,是一种兜底设计。异步进行奖品的发放。

用户抽奖订单表和上几节的抽奖活动订单表这有啥区别

  1. 活动订单,走活动订单,给用户的活动账户充值。让用户有参与抽奖次数。也可以叫活动充值订单或者权益订单。
  2. 抽奖订单,是消费活动账户,扣减次数完成抽奖产生的订单。

领取活动扣减账户额度

活动领域是由活动部署、活动账户充值、用户参与活动等多个核心子领域组成。因此我们需要设计不同的子领域来承接这些功能。 本节就要在添加扣减额度的需求上,对前面实现的活动sku充值,划分下领域。

用户抽奖的业务流程分为;给自己的活动账户添加额度(购买、兑换、打卡),领取活动(扣减活动账户额度)、执行抽奖策略、抽奖结果落库。本节实现到领取活动部分。

先给原有实现额度充值的对象,新增加quota额度子领域文件夹,迁移进去以及调整类名。 img_17.png 本节主要实现参与活动的领域,本部分主要涉及了接口和抽象类定义流程,以及向库表写入数据。库表的写入是一个聚合对象下的事务操作,涵盖了3个账户表(总、月、日)和一个订单表。 在用户参与活动中,需要扣减;总额度、月额度、日额度,以及写入一笔参与活动的订单流水。

领取活动的订单的执行流程

活动的校验->查询未消费订单->账户过滤和构建对象->创建订单->组装聚合对象和保存订单。

账户更新流程

开始

提取参数

切换数据库路由

开启事务

更新总账户额度 (raffleActivityAccountDao.updateActivityAccountSubtractionQuota)

总账户更新成功? ──否──→ 事务回滚 → 抛异常 ACCOUNT_QUOTA_ERROR → 结束



  月账户是否存在?
    ├─ 是 → 更新月账户额度 (updateActivityAccountMonthSubtractionQuota),month_count_surplus = month_count_surplus - 1
    │         ↓
    │     更新成功? ──否──→ 事务回滚 → 结束
    │         │
    │         是 → 更新总账月镜像额度,month_count_surplus = month_count_surplus - 1

    └─ 否 → 创建月账户 → 更新总账月镜像额度,month_count_surplus = #{monthCountSurplus} - 1

  日账户是否存在?
    ├─ 是 → 更新日账户额度 (updateActivityAccountDaySubtractionQuota)
    │         ↓
    │     更新成功? ──否──→ 事务回滚 → 结束
    │         │
    │         是 → 更新总账日镜像额度

    └─ 否 → 创建日账户 → 更新总账日镜像额度

插入参与活动订单 (userRaffleOrderDao.insert)

事务提交

清理数据库路由

结束

问题1:saveCreatePartakeOrderAggregate方法中,先调用了raffleActivityAccountDao.updateActivityAccountSubtractionQuota方法,已经把总账表中的月镜像额度、日镜像额度减1了。为什么代码后面在新创建月账户、日账户时,还要再更新总账表中月镜像额度、日镜像额度呢?

更新月、日账户额度中的镜像额度更新,是保持和本月的月、日额度一致。和新建时的镜像额度并不是一个,是两个完全不同的额度。但新建时,镜像额度初始化的是标准额度(比如规定每月30),此时已经进行一次抽奖了,因为也是需要更新的

写入中奖记录和任务补偿发送MQ

本节实现奖品记录写入和异步消息发送。在写入奖品记录的时候,写入一条task消息发送任务,作为补偿使用,当mq发送失败时,由任务扫描task消息发送。

从用户中奖到发奖,通常来说我们会做异步解耦,因为一些奖品的方法并不都在抽奖系统中,二十通过RPC/HTTP接口来发放, 这些接口有时会发生超时,需要重试处理。因此需要数据库写入一条记录,之后记录一个状态,等奖品真正发完以后,再更新这个状态。

这样可以让用户快速知道自己已中奖,之后可以在详情或者奖品列表查看中奖结果。

那么这里的写入记录和发送 MQ 消息,不能用事务解决,事务主要是数据库事务,但 MQ 消息不是数据库事务。所以需要写入一个 task 表,通过任务补偿的方式进行处理。 img_19.png 先把中奖消息写入task表,再异步发送mq消息通知奖品分发系统,无论发送成功或失败都会更新任务状态(成功是completed,失败是fail)。定时任务通过查询数据库中发送失败的task【因为我们是分库分表的,所以需要通过 dbRouter.setDBKey(finalDbIdx); 设定扫描哪个库表】,进行重试。然后通过rabbitmq监听消息队列中的消息,并分发奖品。

  • 事务隔离与原子性:通过spring的transactionTemplate确保数据库操作的原子性。
  • 幂等性:通过数据库唯一索引和duplicateKeyException处理。
  • 异步解耦:使用mq实现异步通信,扫描库表和消息发送都使用异步线程的方式进行处理。将奖品记录和实际分发解耦。提高系统吞吐量;降低服务间依赖;支持灵活扩展
  • 最终一致性:采用事务+消息补偿的机制:通过定时任务扫描未发送成功的消息进行补偿重试
  • 失败处理机制:mq发送失败:记录状态,后续通过定时任务重试;发放奖品失败:在mq消费端实现重试或人工干预;数据库操作失败(DuplicateKeyException e),通过事务回滚

问题1:mq因为怕几率失败的话不是有重试机制吗,为什么还要写补偿机制

rabbitmq的重试机制太占用资源了,特别是在一写高访问量或者并发的环境下,就会导致队列的消息来不及被消费重而导致mq OOM了,一般就是用补偿发的方式或者用死信队列的方式来统一集中处理失败的消息

RocketMQ事务消息在订单创建和库存扣减的使用open in new window

抽奖活动流程串联

串联各个模块提供抽奖API接口。 我们在过往的开发中,我们提供了活动装配、策略装配,所以要单独为装配提供好一个接口来使用。 之后,串联抽奖所需的过程模块,从参数校验、参与活动、抽奖策略、存放结果、更新订单,直至最终返回抽奖结果。 img_20.png

  • 串联整个抽奖流程,提供 API
  • 提供以活动为主导的,预热装配动作。装配是活动ID发起的,所以需要把活动ID对应的sku记录一起查询出来进行装配。 策略装配,也需要以活动为发起。活动与策略是1:1绑定使用的。不会一个策略配置到多个活动上。核心目的是确保业务逻辑的独立性、数据隔离性和维护便捷性。
  • 抽奖流程包括;参数校验、参与活动 - 创建参与记录订单、抽奖策略 - 执行抽奖、存放结果 - 写入中奖记录,最后返回抽奖结果即可。抽奖策略模块中,校验账户额度。【之前的一个策略规则,需要根据已经抽奖次数进行解锁】
  • 存放抽奖结果后,更新用户参与活动时的抽奖单状态为已使用。

活动信息API迭代和功能完善

扩展查询奖品信息接口【queryRaffleAwardList】,以用户活动为视角进行查询增加返回信息。同时修复Redisson序列化、加锁过期时间问题。 img_21.png

  • 提供带有活动和用户属性信息判断的抽奖列表数据,满足后续前端展示抽奖列表时可以渲染出奖品是否被加锁并提示用户还需要抽奖几次才能解锁奖品。
  • 在以活动为开始的抽奖,对抽奖策略加锁的key设置过期时间为活动结束时间。 RaffleActivityController#draw 抽奖操作,把活动的有效期透传到抽奖策略操作中,这样就可以记录上每个抽奖产生的加锁key的有效期为活动过期时间了。
  • 完善 Redis 序列化操作,Redisson 添加序列化设置。
  • 抽奖sku消耗完毕的队列清空,暂时先去掉。因为目前使用的一套队列,避免都清空喽。这部分不会影响到库存的更新.

延迟队列消费更新数据库做优化,如果数据库库存已经清空,则不更新,减少数据库操作。给三个redis延迟队列的相关方法加了sku参数用来区分。 实现流程

  1. 当某个 SKU 库存耗尽时, ActivitySkuStockZeroCustomer 监听器接收到消息
  2. 调用 clearActivitySkuStock(sku) 更新数据库库存状态
  3. 调用 clearQueueValue(sku) 只清空该 SKU 相关的队列元素
  4. 在 clearQueueValue(Long sku) 方法中:
  • 获取原队列
  • 创建临时队列
  • 过滤出非目标 SKU 的元素并存入临时队列
  • 将临时队列中的元素移回原队列
  • 最终实现只清空指定 SKU 元素的目的

用户行为奖励需求设计

设计出一个用户行为返利需求,也就是用户在完成一个行为动作后,奖励一种东西。这个东西可以是我们前面定义出来的 sku,一个 sku 有配置对应的用户可使用的抽奖次数额度。也可以是积分等。目前我们主要做 sku 这部分,但库表设计会预留出扩展点。 img_23.png

  • 行为返利分为2步,一个是入账,另外一个是结算。中奖用MQ消息进行衔接。
  • 以用户签到日历举例,每天可签到一次,用户和日历时间组合唯一ID,记录一笔返利记录。记录后写入task表,同时推送MQ消息。MQ 消息会有补偿,避免发送失败。
  • 在结算的时候,接收MQ消息(消息里有唯一值),来给用户的账户充值或者发放用户积分。

设计日常行为返利的配置表和记录用户行为返利流水订单两张表。 img_49.png

  • 配置表如果我们设计的比较固定,那么就是 rebate_sku 但我们不只是想返利 sku 【前面章节中 sku 可以给用户添加上对应的获得抽奖次数】,而是可以扩展其他各类奖励。所以设计了,rebate_type + rebate_config两个字段的组合。
  • 订单表是一个分库分表的记录,以用户 user_id 为切分键进行路由。这里只负责记录流水和同时写 task 表来推送 MQ 消息,异步记录奖励即可。

用户行为返利入账

按照用户行为返利的需求设计,创建相应的库表,开发 rebate 返利领域,提供返利订单创建接口。并在写入订单后发送 MQ 消息。后续则处理奖励入账。 img_24.png

  • 一个用户行为可能会给多种奖励,所以在接收到用户信息后,会根据配置组装聚合对象。【聚合的目的就是为了做一个统一的事务】
  • 一个聚合对象中包含了返利的订单实体对象,写入task的实体对象。它们是一个事务入库。
  • 另外是发送MQ消息,在完成入口动作后,会直接发送MQ消息,并且如果发送失败,会有任务兜底。【这样是面试中经常问到的点,如果MQ消息发送失败了,你是怎么处理的。】 成功和失败都会更新task任务表中的状态,通过定时扫描task任务表,重试发送来做任务补偿。

添加两个表

  • big_market 配置库的 daily_behavior_rebate 日常行为返利 img_50.png
  • big_market_01、big_market_02 两个库的分表 user_behavior_rebate_order_000 ~ 004 img_51.png

这里每日签到的防重ID biz_id是user_id和日期拼接,如果是支付的话,就是外部传过来的,不会重复。

这里创建的MQ消息,是没有使用到交换机的,就是生产者 ->队列 -> 消费者 简单的工作队列。

TransactionTemplate 是 Spring 框架中通过模板模式实现用于简化编程式事务管理的工具类,它封装了事务的创建、提交、回滚等操作,让开发者可以更简洁地编写带有事务控制的代码,无需手动处理复杂的事务边界(如开启、提交、回滚事务)。 TransactionTemplate 的核心是 execute() 方法,它接收一个 TransactionCallback 接口(或其简化版 TransactionCallbackWithoutResult),业务逻辑放在接口的回调方法中。

用户行为返利结算

接收MQ消息开始结算返利。也就是给用户的活动账户充值。并提供一个日历签到返利的接口,用于后续对接到前端UI使用。 img_25.png

  • 增加接收MQ消息流程,调用活动账户额度入账接口,增加用户的可抽奖次数。这里包括;总、月、日,账户额度更新。
  • 注意 MQ 消息会有过滤,目前只处理返利是 sku 类型的返利。
  • 最后在提供一个日历签到的接口,便于后续对接到前端页面使用。

对接外部服务就需要由外部系统传一个用来幂等性控制,同时也方便进行对账

规则完善和应用接口实现

完善权重规则的校验,用户的总抽奖次数作为规则权重的比对值,一个活动下抽奖总N次后,可获得必中范围奖品。之后提供出,活动账户额度、签到&查询、权重范围接口,用于前端UI渲染使用。 img_26.png

  • 剩余抽奖次数接口,后端会返回;总、日、月,总额度和剩余额度,这样可以满足前端的各类渲染诉求。
  • 点击签到接口和签到结果查询接口,如果今日已签到,则展示灰色并不可签到。
  • 权重规则范围展示接口,告诉用户总计抽奖多少次后,可以必中指定范围奖品。

IRaffleActivityService新增三个接口:签到 - calendarSignRebate、是否签到过 - sCalendarSignRebate、查询账户额度 - queryUserActivityAccount

IRaffleStrategyService新增1个接口:查询权重配置

问题1:这里查询账户额度,为什么不直接使用总账户额度中的日、月额度呢,而是再查询一遍日账户和月账户

总账户额度中的日、月额度是总库存,如果当天或当月的额度用完了的话,此时直接使用总账户数据会导致逻辑错误。并且当新的一天或一月时,需要通过查询去判断日、月额度账户是否存在。

前端页面

注意: 前端的抽奖数据显示字段必须要和后端传输的字段保持一致,即后端传输的为awardUnlock、waitUnlockCount。那么前端的也应该是如此,而不能是waitUnLockCount和isAwardUnlock。

最近更新 2025/8/8 21:24:49