项目已上线:http://81.70.76.245:3030/?userId=liergou01&activityId=100301

nano ~/.zshrc
 1.8.0_472 (arm64) "Amazon" - "Amazon Corretto 8" /Users/xinduan/Library/Java/JavaVirtualMachines/corretto-1.8.0_472/Contents/Home
source ~/.zshrc

0.架构梳理

8.讲讲抽奖流程? 9.讲讲抽奖后的的流程?

1.项目搭建

项目由start.spring.io 脚手架创建,使用了统一 maven-archetype-plugin 插件,自定义了一套 DDD 工程骨架脚手架。

DDD的分层结构

  1. api 类似菜单。外部服务(如订单系统)调用抽奖系统上,引入 api 的 jar 包后
  2. 代码里写着IRaffleService.draw(),他知道接口长什么样了,入参和返回值是什么
  3. 当服务 A 运行.draw()时,RPC 框架如 dubbo 和 freign,会拦截这个调用,框架去问注册中心 nacos 或 zookeeper,谁实现了IRaffleService 接口?
  4. 注册中心回答 ip。。。。端口号。。。在 big-maket-trigger 那个服务里,之后 RPC 框架将数据发送过去

2.项目中都遇到了什么问题?

在开始项目时,引入脚手架之外的组件时,因为 jar 包版本不同,出现编译通过,但是在代码运行时,调用的方法不存在,通过 mavenHelper 插件,找到其他组件中多引入了相同的 jar 包,另外在项目的规则树逻辑判断上出现问题,通过抛出自定义的异常,debug 来解决。还有一些空指针异常也是,加入额外的判断。

3.DDD模式讲一讲一你的理解

一种软件设计方式,就像在这个项目中,我负责实现的抽奖中的策略,就是一个独立的领域模型。在这个领域中我需要提供策略的装载随机数算法计算抽奖模板调用(含责任链和规则树) 功能,这样一个领域就像划分好的一个独立个体,它拥有属于它的对象信息(实体、值对象、聚合),当需要使用数据库资源、缓存资源,以及外部接口资源的时候,都通过依赖倒置进行调用。也就是说,我的领域不做其他模块的引入,而是领域只负责业务功能实现,所需的所有数据,则有外部接口通过依赖倒置提供。

1.DDD架构设计 分为 6 层架构

  1. api,为 RPC 提供接口,为其他服务提供联系
  2. app 层,主要是项目的启动类,切面,日志等
  3. domain层,核心的业务层
    1. 值对象 valobj 没有唯一标识
    2. 实体对象 entity,多一个唯一标识
    3. 聚合对象,包含多个实体对象,比如订单号与商品名称数量,与业务强相关 🚗 一个超级通俗的例子:
  4. infrastructure 层,mq,redis,mysql 的具体逻辑,类似于 mapper 层,实现 domain 层的数据持久化
  5. trigger 层,通过 mq,RPC,http 等触发 ,实现 api 层的接口。接受外部请求,校验参数,联系 domain 层,返回结果
  6. type 层,通用的返回对象,枚举类,常量等,被公共使用

4. 因为你的项目是前后端分离的,接口跨域怎么做的?

  • 本质:跨域问题是浏览器执行的同源策略导致的,为了防止恶意网站窃取数据。

  • 现状:我的项目是前后端分离的,前端(Vue/React)和后端(Spring Boot)运行在不同的端口(或域名)上,属于“不同源”,所以默认无法交互。

  • 解决:我使用了 Spring Boot 的 @CrossOrigin 注解。

    • 它会在响应头中添加 Access-Control-Allow-Origin 字段。

    • 开发时,为了方便调试,我配置为 *,允许所有访问。

    • 上线后,为了安全,我会将它限制为前端部署的具体域名(如 gaga.plus),防止第三方站点恶意调用我的接口。

5. 你的抽奖流程中,哪些被定义为值对象,哪些被定义为实体对象

描述对象属性的值,不具备 唯一id

  • 值对象 (VO):像项目中的规则树 (RuleTreeVO) 或枚举,它们只用于描述属性或结构,在抽奖过程中是只读的,不需要唯一 ID 进行追踪,所以定义为 VO。
  • 实体 (Entity):像抽奖单 (PartakeEntity) 或奖品 (AwardEntity),它们必须有唯一 ID(如订单号、奖品 ID),因为它们在业务流程中会有状态流转(如从“未支付”变“已支付”,库存从 10 变 9),最终需要持久化到数据库,所以必须定义为 Entity。

对于实体,我们使用充血模型,将状态变更的逻辑(如扣减库存、修改状态)封装在实体内部,保证了数据的一致性和安全性。 例如

  1. 在 strategyEntity 中的getRuleWeight(),内部有一个判断取出的实体内是否有数据库中的ruleModels字段是否有值,还有一个取出ruleModels内的各种字段后判断其中有没有rule_weight字段,然后返回rule_weight字段,来代表此策略有权重规则(还有一种策略是黑名单策略)
  2. 在 strategyRuleEntity 中的getRuleWeightValues(),(也就是上面的 strategy 要看具体的规则时,查看 strategy_Rule 内的数据表内的两种规则具体的配置),需要解析出具体的配置,项目中是是一个字符串4000:102,103,104,105 5000:102,103,104,105,106,107 6000:102,103,104,105,106,107,108,109,需要解析出后,返回一个 map 结构的结果。这里直接在实体定义充血方法,这样不用将繁杂重复的逻辑放在具体的业务代码里。即使以后业务需求有更改,换成 json 字符串或用别的符号分割,直接在实体内修改即可。

6. 关于访问数据层的依赖倒置,是怎么使用的,有什么好处,你可以描述下吗

MVC 的贫血模式中,数据库持久化对象,一般会被当做业务对象来使用,后期非常难维护,但在 DDD 中,是以领域实现为核心,一个领域内的所需外部服务,都有领域层做接口,由数据层做具体的实现。数据库持久化操作,定义的 PO 对象,就被这样的方式被限定在基础层了,外部是没法引入使用的,也就天然的防止了数据库持久化对象进入业务中。 DDD 把依赖关系箭头掉了个头,领域层当老板,定义接口让打工人来实现数据持久化,防止数据库 PO 对象泄露

7.把抽奖划分为抽奖前、中、后,三个动作。请具体结合场景讲解下,为什么这样设计

对于需求中的各类功能点;黑名单抽奖、权重抽奖、默认抽奖、抽奖N次解锁、兜底抽奖等等情况,是可以拆解为抽奖前、中、后,3个行为动作的,基于这样的考虑后,就可以设计出非常容易扩展的松耦合结构。

8.讲讲抽奖流程?

  1. 首先就是,奖品的概率装配,在装配时,我们使用空间换时间的方法,根据概率值来直接将奖品放入 list 列表中打乱,抽奖时直接选取其中一个即可。另一种是生成概率值后 for 循环与范围值对比,来看在哪个区间。

    1. 具体来说:先查询具体策略下的每个奖品,比如有 8 个奖品,奖品之间的被抽出的概率不同,比如一个是 0.0001 一个是 0.1.我们查数据库时,找出最小的概率作为“精度”,计算出需要的格子数(1 除于小数)。之后创建一个这个格子数的数组(Map),将奖品铺开,打乱,存入 redis
    2. 需要注意点是,分布式系统下,redis 能解决分布式问题
    3. 存入 redis 时,使用 map 结构:strategy_rate_table_10001 中,里面value 和 key,在抽奖时,根据生成的随机数,比如 5,发送一个strategy_rate_table_10001 5 就能返回 102 编号的奖品
  2. 第二种情况:如果有策略权重的装配,当该策略为权重装配时(rule-model字段有rule_weight时)查询 strategy_rule 数据表查询具体的权重配比4000:102,103,104,105 5000:102,103,104,105,106,107 6000:102,103,104,105,106,107,108,109。即,消耗多少积分,必得几号几号奖品

    1. 从规则表读取出序列后,解析,遍历这些分组(key=3000,Key=5000)对于每个分组,都执行一遍策略装配(与概率装配平行)
    2. 当用户触发了权重规则(在抽奖前时可以校验用户 redis 中储存的已消耗的积分来判断是否触发保底次数,如果触发了,直接抽奖后清空保底保底次数,不然的话,还是走默认抽奖也就是上面的全概率装配的奖池)
Redis Key说明谁来用?
strategy_rate_10001默认奖池积分不足 4000 的普通用户
strategy_rate_10001_40004000分 VIP 奖池消耗积分 >= 4000 的用户
strategy_rate_10001_50005000分 VIP 奖池消耗积分 >= 5000 的用户
  1. 以上是两种装配方式,接下来我使用了模板模式来串联抽奖前抽奖中的流程
    1. 在AbstractRaffleStrategy中定义骨架模板,由DefaultRaffleStrategy 来实现抽象模版中的可变的那个过滤方法
      1. 参数校验
      2. 查询策略
      3. 抽奖前规则过滤 ← 子类实现
      4. 默认抽奖逻辑
  2. 其中,抽奖前的规则过滤使用到了策略模式,来根据具体情况
    1. 利用工厂模式(从刚开始工厂根据扫描的注解放入 map 的那个 map 里取出策略。)获取rule_blacklist策略,来判断是否接管。如果不是黑名单,则继续用权重过滤
                   +-------------------------------+
                   |   DefaultRaffleStrategy       |
                   | doCheckRaffleBeforeLogic()    |
                   +-------------------------------+
                                 |
                +----------------+-----------------+
                |                                  |
        黑名单规则优先执行                     其它规则顺序执行
                |                                  |
    logicFilter["rule_blacklist"]           logicFilter["rule_weight"]
                |                                  |
        +-------+-------+                    +------+-------+
        |               |                    |              |
   TAKE_OVER         ALLOW               TAKE_OVER        ALLOW
        |               |                    |              |
 返回固定奖品ID      进入下一规则        限定范围抽奖    进入默认抽奖
  1. 抽奖时(特殊判断当前奖品是否有次数锁) 1.需要加入doCheckRaffleCenterLogic()的子类方法,来根据用户用户的抽奖次数是否到达当前已经抽到的奖品是否匹配(这里指的是该奖品有次数锁的情况 ),如果匹配(即大于等于)放行即可,如果不匹配,拦截走兜底奖励,也就是说,是先抽奖再判断是否这个奖品匹配。后面会转换为决策树。在树节点中配置具体几次的次数

  2. 之后我们对整个抽奖前的阶段进行了责任链的重构,虽然我们是用来模板模式与策略模式,但所有的规则判断逻辑,如黑名单,权重,其实还是堆在DefaultRaffleStrategy里的,随着规则变多,这个类还是会很挤,所以之后我们又对规则判断与业务逻辑彻底分开,使用责任链来实现

    1. 在之前的doCheckRaffleBeforeLogic中,需要一堆 if-else 或循序来遍历所有规则,如果要增加一个规则,既需要写规则类,又需要在 ifelse 中添加,不符合开闭原则
    2. 于是我们将黑名单,权重抽奖,默认抽奖封装成独立节点首尾相连,从链头往下走,处理不了就下一个节点处理 testt a.所有节点(黑名单,权重,默认)都需要实现AbstractLogicChain ILogicChain接口7.责任链模式处理抽奖规则 1.logic():核心逻辑,判断我是不是该拦截这个用户 2.next():传递棒,我不拦截,交个下个人 3.appendNext():组装链。在初始化时,将所有节点串起来 b.DefaultChainFactory此工厂类将这些节点初始化时收起来,如果配置里有 如@Component(“rule_blacklist”)会被扫描后,启动时扫描所有实现了ILogicChan 接口的类,自动装配(使用的是构造器注入不断学习 这里没有用到自动装配,而是懒加载 getBean 指定后添加链接链接点(通过 appendNext),第五节的还是策略模式的时候用的是 map 自动装配,但是逻辑还得实时更改。 c.在抽奖时,新传入策略 id 到工厂类中,在工厂类中读取数据库中配置的该策略是否有黑名单和权重抽奖 1.如果没有,默认装配一个默认抽康责任链,如果有的话就挨个装配。并返回责任链链头 2.开始核心的 logic 挨个判断。来决定是否接管与传递下一位 :return next().logic(userId,strategy)
  3. 之后我又对抽奖中和抽奖后进行了规则树的重构,抽奖中和抽奖后因为逻辑比较复杂,不像抽奖前的阶段那么线性,需要判断奖品是否有库存,二叉分裂后,有库存,判断是否有次数锁。没库存,直接发保底奖品。只能写成 ifelse,所以为了引入了组合模式来实现一个决策树引擎

    1. 为了将这种流程配置化在数据库层面里,而不是写死在业务代码中,我实现了 3 个核心对象
      1. 树根RuleTreeVO 整颗树的入口,类似于责任链的链头
      2. 树节点RuleTreeNodeVO 具体的判断节点(库存判断?次数判断?),类似于责任链的 logic 方法
      3. 树连线RuleTreeNodeLineVO 定义当前节点的跳转逻辑,类似于责任链的 next,如果接管,去左边的路,如果通行,走右边的路
    2. 之后实现一个规则树引擎DecisionTreeEngine,让上面的静态模型跑起来
      1. start:从 TreeRoot 拿到第一个节点 (比如次数节点)
      2. Execute:执行当前节点的业务逻辑(库存够不够?抽奖次数够不够?)
      3. Result:节点返回结果(接管还是允许通行?)
      4. Next:引擎拿到结果后遍历当前节点伸出去的line 连线。找到匹配的那条线顺着线找打下一个节点如(库存节点)
      5. Loop:重复步骤,知道走到叶子节点(如直接发奖),流程结束
    3. 如果后面新增风控拦截,或者 VIP 特权等逻辑分支,只需要重新匹配一下数据库中的节点与连线即可,无需修改核心代码
    4. 具体需要定义的数据结构有
      1. 树根,核心字段是 treeRootRuleNode,标记决策引擎的初始入口,同时还维护一个 treeNodeMap,所有的子节点都扁平化的存储在这里,方便引擎通过 key 快速找到节点,不需要深度递归查找
      2. 树节点,每个树节点都代表一个业务规则,包含 rulekey,对应 spring 容器中具体的组件 Bean,还包含 ruleValue,存储该节点的所需的规则解锁阈值(如,需解锁 4 次),还有规则连线List,相当于 next
      3. 树连线,路由逻辑,定义了一个ruleLimitType:如大于小于等于,目前只有等于。ruleLimitValue:判断(枚举)值,如 allow,takeover。具体的逻辑是,如果当前节点的执行结果,匹配到某条连线的值,引擎就会顺着这个线走到ruleNodeTo指向的下个节点。
      4. 总体逻辑看 上面的2 小节
      5. 14.你提到的规则树(Rule Tree),为什么要用树结构?传统的 if-else 责任链不行吗?
    5. 工厂类:这里也是需要工厂模式进行自动装配,像策略模式那一集一样,当 spring 启动并初始化DefaultTreeFactory 这个 Bean时,看到构造函数里有一个Map<String, ILogicTreeNode>参数,所以会在容器中找到所有实现了ILogicTreeNode接口的 Bean,将各种树节点收集起来,key 做 component 的指定的名字,value 就是对应的 Bean 实例对象,这时候logicTreeNodeGroup就是一个节点工具箱,谁用谁取,由引擎看着RuleTreeVO(地图)。走到rule_lock 节点时,取出 Map 中的单例 Bean,执行逻辑,在顺着连线继续在工具箱找 Bean。
    6. 规则树引擎类:在传入工具箱 Map 到DecisionTreeEngine的引擎时,这里是 new 出一个 DecisionTreeEngine(logicTreeNodeGroup,ruleTreeVo) 因为 树节点 Node是无状态工具类(单例),工厂也是无状态(单例)。但是引擎是有状态的执行者(多例),注意看代码里的 private final RuleTreeVO ruleTreeVO; 每个引擎对象都绑定一个具体的规则树配置 (VO),比如张三用的是规则树 A,李四用的是规则树 B,不能让张三李四共用同一个DecisionTreeEngine单例 Bean,否则李四的数据会被张三覆盖掉。 因为他们三个的生命周期是不一样的,不能直接把DecisionTreeEngine 作为一个 Bean 交给 Spring 管理,要搞个工厂手动 new
  4. 到最后,我将整个抽奖流程串联,用模版模式框架住整个抽奖前,中,后的逻辑判断流程

    1. 之前也是用的模板模式来定义骨架AbstractRaffleStrategy 但是,使用抽奖方法protected abstract void doCheckRaffleBeforeLogic(...)来让继承的子类来实现抽奖前,中后的具体逻辑,在这些具体逻辑内,遍历策略列表,调用一个个 filter(黑名单,权重)
    2. 重构之后,依旧是用模板模式定义骨架,但是不需要定义抽象子类,而是直接调用责任链方法传入用户 id 等信息(由父文件夹中的 repository 查询数据库支撑),然后调用规则树得到最终结果
    3. 依旧保留骨架“校验用户参数”,“一些经过处理(责任链,规则树)后的到逻辑判断”,“返回最终结果”等。

9.讲讲抽奖后的的流程?

  1. 首先考虑到的是,抽奖之后,得到的奖品如何实现不超卖的问题

    1. 因为不能访问数据库然后根据数据库行锁来解决,这样会导致压力很大,所以我用到了 redis 来实现库存扣减这部分的操作。
    2. 为保证又快又安全,我使用”redis原子操作+最终一致性”的方案,将核心逻辑封装在规则树的库存节点中。
      1. 先是用 decr 的原子性来扣减库存,根据返回结果来判断是否可以扣减库存
      2. 之后进行第二道防线,来兜底,为了防止并发下的数据混乱网络抖动超卖,使用了 setnx 锁(setnx 在 redisson 是用 trySet 实现)来锁住具体的‘库存令牌’如 stock_token_1001_99),确保每个具体的商品库存 id˙ 只能被消费一次。如果后续有回复库存,手动处理,也不会进行超卖
        1. 如果以上都成功,写入延迟队列,延迟消费更新数据库记录(在 trigger 层的 job:updateAwardStockJob
        2. 如果都不成功,进入规则树的兜底节点:在数据库配置的 rule_value中取出解析,返回兜底奖品
      3. 在延迟队列同步到数据库中时,并没有由 MQ 来完成,而是是用来 Redisson 的 RDelayedQueue 来 offer,也就是推入消息。他底层是自动的维护了 ZSet(sorted Set,将执行时间戳为 Scroce ,消息内容为 Member) 结构 启动后台线程来监听队列。因为只需要同步库存,所以轻量化就可以保证。由定时任务,每 5 秒来使用 redis 队列取出相应的数据同步到库存。
    1. IStrategyDispatch 接口新增加了 subtractionAwardStock 接口,最终都是到 StrategyArmoryDispatch 类实现。这样做是为了让功能内聚,既然你提供了库存的装配,策略的装配,也要内聚库存的扣减。
  2. 之后我们进行抽奖 API 接口的抽取,将抽奖过程完全顺一遍,开门迎客

    1. 我通过 Trigger 层,实现对外 API 的暴露,提供三个核心能力
      1. 策略装配接口:用于对奖品及其策略的预热,将概率计算加载到 redis 中
      2. 奖品查询接口:用于前端展示抽奖奖品页面,加入 sort字段来控住展示顺序
      3. 抽奖执行接口:他是核心接口,用于接收用户的请求,在内部串联调用责任链与规则树,最终返回抽奖结果
    2. 具体就是在 api 层定义一个接口 IRaffleService 接口,有上面的 Trigger 层 RaffleController 类实现接口,然后调用 Domain 领域层,执行具体逻辑
  3. 对接好抽奖接口后,我开始将抽奖引擎转化为面向用户的业务系统,关注凭什么用户能抽奖,核心逻辑:将一次抽奖机会看作一次商品,用户参与抽奖的过程,本质就是一个“下单购买商品”的过程

    1. 以前的逻辑:用户来了,抽奖,得奖品
    2. 现在在用户抽奖前,引入资格验证
      1. 验资:用户 A,活动里还有“余额”吗
      2. 额度限制:不仅有总次数,还有月日次数,比如活动总共能玩 10 次,但是每天只能玩一次。
      3. 扣费:抽一次,总账户-1,日账户-1
      4. 下单:系统生成抽奖单,证明用户参与过此活动
    3. 为了实现步骤 2,我设计了 5 张表
      1. 活动配置类
        1. activity 表:定义活动的“壳”,活动 id,名称,抽奖策略等
        2. activity_count 表:定义活动的“规则”配置,总次数,月,日次数
      2. 用户资产类
        1. activity_account:用户的钱包,总剩余,月日剩余高并发更新的热点表
        2. activity_order:用户的发票,每次抽奖前,都需生成订单发票,作用:幂等性,如果中间网络抖动,用户重试时,发现订单已存在,就不会重复扣减账户余额,里面有个 state 字段,来表示当前订单是否已完成
        3. activity_account_flow:银行流水,对账审计
  4. 定义好一些必要的关于奖品入库操作需要的表后,我引入了一个 DB—router 组件,来实现分库分表,来应对这种数据量非常大的情况

    1. 核心扰动函数算法原理:HashMap 的扰动函数散列算法 i. 公式:idx = (size - 1) & (hashCode ^ (hashCode >>> 16))。先算出总格子数,在扰动,在根据总格子数取余 1. 这里直接复用了 JDK 的 HashMap 的源码实现 2. 单纯的 hashCode 可能不够散列(特别是低位重复时),所以让高 16 位也参与运算,增加随机性 3. 最后通过& (size - 1)等价于取模,但效率更高 (前提是库表总数必须是 2 的 N 次幂) 4. - 算出总索引 idx 后,再拆分为: - dbIdx (库索引) = idx / tbCount + 1。 - tbIdx (表索引) = idx - tbCount * (dbIdx - 1)。 - 这里想象成两个库,一个库 4 个表,一共 0~7 张表,库索引取整,表索引取余找出库中的表的相对位置
    2. 整个过程对开发者透明,流程如下
      1. 切入点:定义一个@DBRouter 注解,AOP 拦截所有带有该注解的 DAO 方法。
      2. 路由算法,见 3
      3. 上下文传递:计算出的库表索引被存入 TreadLocal 中private static final ThreadLocal<String> dbKey = new ThreadLocal<String>();
        1. 库:DynamicDataSource 重写它的 determineCurrentLookupKey() 读取 ThreadLocal 中的库索引,动态切换 JDBC 连接。
        2. 表:~~MyBatis 通过动态 SQL(${tbIdx})或者插件读取 ThreadLocal 中的表索引,替换真实的表名。~~实际上是通过拦截器模式,拦截器直接找到 TreadLocal 要表索引,然后在 sql 语句通过正则表达式匹配到关键语句,通过反射修改 sql 语句替换表索引
      4. 最后clearTreadLocal
  5. 完成基础的必要的表的建立后和分库组件后,我开始重构用户抽奖下单的这个动作:因为用户获取抽奖次数来源是不同的,是签到?还是 VIP?还是积分兑换?来源不同的库存和规则很难写在同一张活动表上,所以我们进行库存架构的重构解耦,将抽奖次数包装为 SKU库存量单位 ,下单必须购买一个 SKU,SKU 可以来源不同

    1. 新增一个 activity_SKU表,不在总活动表中添加库存了,也不在总活动表添加每天每月的配置了,而是讲这些单独放在 SKU 表中,用活动 id与次数id 与其他两个表相连
    2. 将流水表合并到订单表
    3. 完成表的重构后,定一个一个下单模板抽象类createOrder,(参数校验,查询基础信息,规则过滤,构建聚合对象,保存订单)
    4. 具体的下单流程:用户触发行为,如点击抽奖,或支付成功回调,根据用户的行为拿到 SKU,然后查询两个表参数校验,然后就是上面的后面。
      1. 参数校验,使用责任链(活动的:有效期、状态)(商品库存的:有效期、状态、库存(sku))
      2. 落地实现:使用IactionChain接口,通过 ActionChainFactory 组装校验链,因为固定,所以直接构造时确定顺序
    5. 之后就是构建聚合对象(扣减额度,写入订单表需要的数据)
    6. 下单(重要)
      1. 使用 db-router 组件:调用 dbRouter.doRouter(userId),根据用户 id 算出他属于哪个库,存入当前的线程中,然后自动知晓库和表
      2. 之后开启编程性事务:transactionTemplate.excute(。。。),更细粒度的控制路由清理和异常处理,而放弃事务注解
      3. 写入两张表:订单表和额度表
      4. 使用 db-router 组件的 clear 方法,实际就是清理 TreadLocal
    7. 为了这个流程有重复下单的情况,我们在数据库建立了唯一索引字段,上游必须传入一个唯一单号,根据报错与否来保证幂等性
  6. 在确定下单流程与写入数据库的流程后,我之后解决了写入数据库的问题,因为使用到 redis,要保证 SKU 库存一致性,必须引入 MQ,核心思想:redis 抗压(原子扣减)+延迟队列做同步(最终一致性)+MQ 处理售罄(最后的通知)

    1. redis 和延迟队列(redission)复用当时扣减奖品库存那一段
    2. 多出一个库存耗尽的”熔断”处理
      1. 当redis decr 处理时,返回值小于 0时,发送一个 MQ 消息,消费者ActivitySkuStockZeroCustomer。目的是通知数据库立马把库存设置为 0,之所以这里不同延迟队列,为了拦截后续可能穿过缓存层的查询请求,并用于展示前端已抢光
      2. 具体工程落地:在Trigger 层新增一个 MQ 监听器(Listener),当收到库存归零后,调用数据库操作清零
      3. redisson 的延迟队列的任务是减负,即使丢到几个中间状态也没事,只要最终一致性就行,这里的 MQ 用于状态通知,利用率解耦和广播能力
  7. 最后,我完成写入中奖记录和 task 任务表(用于 mq补发)

    1. 首先设计六张表
      1. 总用户活动账户,月,日用户活动表
      2. 用户抽奖订单表(这里与活动订单表区分开,那个是抽奖前(SKU 的下单)
      3. 用户中奖记录表与任务补发表
    2. 创建完表后,总结并细分一下活动领域
      1. 装配 Armory
        1. 预热活动 SKU ,活动信息,信息到 redis
      2. 充值 Quota(creatOrder1)
        1. 就是上面的下单 SKU 写入库存
      3. 参与 Partake(creatOrder2)
        1. 本节重点,证明用户这一次抽奖是合法的,与上面购买单区分开
        2. 流程 :检验用户在这个活动下有没有账户,总,月,日额度是否充足
        3. 之后创建订单方法内,参考购买单(dbrouter,事务 execute,db-clear()),事务写库,总账户变更(直接 update),日月账户(insert或 update(懒加载,不用每天维护每日限额表))。写入订单表(以上四个表是在同一个事务中)。期间捕捉异常 status.setRollbackOnly();
  8. 最后的最后。使用 MQ 扫描 Task 表补发奖品。(写入用户中奖记录的同时写入 task 任务表,并初始化状态字段为待发放)这里没用 redisson 延迟队列,因为场景和一致性目标完全不同。这里不同查询数据库更改数据库,而是追加写入,无竞争。这里的一致性是指“中奖记录(DB)与发奖动作(MQ)的一致性”

    1. 场景:用户中奖后,通常需要调用外部接口(物流?发券系统?),如果在同一个数据库事务里调用 RPC 接口,一旦出现网络问题,数据库事务回滚,用户看到界面显示抽到 Iphone,但中奖记录没有(或者发货系统没有)
    2. 解决方案:将“中奖记录”与“实际发货”分开,先 DB,再 MQ 异步执行(MQ 的发送在写入中奖记录的事务后)
    3. 所以这里 MQ 的作用是对接未来的发货系统或者别的微服务,让他们来监听这个 MQ,监听到后,返回给 MQ ACK,这时 MQ 改写 TASK 任务表对应的数据状态为✅
    4. 保底:用定时任务每五秒扫描发送失败的中奖记录
    5. 工程实现:细化 domain 领域的 award 模块(中奖的核心业务,调用 task 模块创建任务)与 task 模块(只负责存任务,发 MQ,扫表重传)。在 trigger 层,添加一个未来别的服务的消费者 Listener,在添加一个定时扫描的任务类在 job 包内(任务类:dbrouter 读取库的数量,每个库都有一张表。for 循环查出待发送消息,executor.execute(() { 发送 MQ,改状态}))CallerRunsPolicy 让提交任务的线程自己跑,避免任务丢失,报错,降速。3.有线程池参数设置的经验吗?36.讲一下项目中的基于分片的多线程同步组件

10.整体怎么运作的?

20.抽奖活动串联

11.一致性问题

  • 分布式系统中,redis 挂了,mq 丢消息怎么办,如何保证用户抽奖扣的次数且能一定发奖?

关于一致性,在这个抽奖系统中,我重点解决的是库存扣减发奖的一致性。

  1. 库存侧: 我没有直接依靠DB事务抗高并发,而是用 Redis decr 做原子扣减,同时用 setnx 加锁做兜底,防止超卖。库存的同步是异步的,通过 Redisson 延迟队列更新数据库,保证最终一致性

  2. 发奖侧(重难点): 针对发奖可能涉及第三方接口(网络抖动)的问题,我采用了**‘本地任务表 + MQ’**的方案(文档第19节)。

  • 我在事务中同时写入‘中奖记录’和‘Task任务表’(状态为待发送)。

  • 事务提交后,再发送MQ。

  • 如果MQ发送失败,我有定时任务(Xxl-job)扫描Task表进行重试补偿。

  • 这保证了即使MQ挂了,用户中奖的数据也不会丢,做到了至少投递一次(At-least-once)。”

12.高并发问题

  • QPS一高,你的系统哪里先挂?数据库分库分表是怎么做的?(分治思想+缓存前置 )

“高并发场景下,数据库通常是瓶颈。

  1. 读写分离与分库分表: 我自研了一个基于 ThreadLocal 和 AOP 的轻量级 DB-Router 组件(文档第13节)。通过对 UserID 进行哈希扰动(参考了 HashMap 源码 (size-1) & hash),将数据分散到 2库 4表(举例)中,大幅降低了单表压力。

  2. 无锁化设计: 在扣减库存时,我避免了数据库行锁,而是全量依赖 Redis。特别是在库存耗尽时(文档第16节),我设计了一个 ‘库存熔断’ 机制:当 Redis 返回 -1 时,直接触发 MQ 消息通知数据库同步清零,并拦截后续所有请求,防止流量打穿到 DB。”

13.AI提效

  • 场景一(代码重构): “在做规则树引擎(Decision Tree)时,逻辑比较复杂。我利用 Cursor/GitHub Copilot 帮我生成了基于组合模式的代码骨架,并让 AI 帮我设计了 RuleTreeNode 的数据结构,这让我的开发效率提升了 30%。”

  • 场景二(单元测试): “C端业务对稳定性要求高。我利用 AI 快速生成了全链路的单元测试用例,特别是针对 Redis Lua 脚本的边缘测试(比如库存为 1 时的并发扣减),AI 帮我构造了很多我没想到的边界条件。”

14.你提到的规则树(Rule Tree),为什么要用树结构?传统的 if-else 责任链不行吗?

public DefaultStrategyAwardVO process(String userId, Long strategyId, Integer initialScore) {
    // 1. 找到根节点 (rootNode)
    String nextNodeKey = ruleTreeVO.getTreeRootRuleNode();
    Map<String, RuleTreeNodeVO> treeNodeMap = ruleTreeVO.getTreeNodeMap();
 
    // 2. 拿到根节点对象
    RuleTreeNodeVO currentNode = treeNodeMap.get(nextNodeKey);
 
    // 3. 循环遍历(只要还有节点就一直走)
    while (null != currentNode) {
        // A. 执行当前节点的逻辑(调用 Spring 注入的 LogicFilter)
        // 比如:判断库存、判断次数
        ILogicTreeNode logicTreeNode = logicTreeNodeGroup.get(currentNode.getRuleKey());
        String logicValue = logicTreeNode.logic(userId, strategyId, currentNode.getRuleValue());
 
        // B. 核心:根据逻辑返回的值(ALLOW/TAKE_OVER),找连线(Line)
        String nextKey = nextNode(logicValue, currentNode.getTreeNodeLineVOList());
        
        // C. 如果找到了连线指向的下一个节点 Key,就更新 currentNode 继续循环
        // D. 如果没找到,说明到头了(或者被拦截了),返回当前节点的处理结果
        currentNode = treeNodeMap.get(nextKey);
    }
    return ...;
}
 
责任链的组装与调用
理由: 责任链的精髓不在于 next() 方法,而在于链条是如何串起来的。 参考: 文档第7节。
 
口述/手写重点:
 
如何组装: “我在 DefaultChainFactory 中,从 Map 中拿到所有的 Bean(黑名单、权重、默认),然后手动用 appendNext() 把它们串成一个链表:BlackList -> Weight -> Default。”
 
如何调用: “我在 performRaffle 抽奖主入口,直接调用链头的 logic() 方法。它会自动向下传递,直到有人接管(TAKE_OVER)或走完默认流程。”
 
    • 痛点: 责任链(Chain)适合线性流程(如:黑名单 权重 默认)。但抽奖中/后期的逻辑是分叉的(例如:库存充足走A路,库存不足走B路;次数解锁满足走C路,不满足走D路)。

      • 方案: if-else 会导致代码极其臃肿难以维护(“面条代码”)。

      • 实现: 我参考了组合模式,设计了 TreeRootTreeNodeTreeLine。这就把业务逻辑的编排代码层面上移到了数据层面(数据库配置)。运营如果想调整规则顺序,我不需要改代码发版,只需要改数据库配置即可。

15.你说你设计了 DB-Router 分库分表组件,为什么不用 Sharding-JDBC?

  • “我知道 Sharding-JDBC 功能很强大,但它比较重,且作为一个实习生项目,我想深入理解分库分表的底层原理

  • 我的 DB-Router 更加轻量级。我通过自定义注解 @DBRouter + AOP 切面,配合 ThreadLocal 传递路由 Key(UserID),在 AbstractRoutingDataSource 层动态切换数据源。

  • 虽然功能不如 Sharding-JDBC 全面,但对于目前的业务场景(基于 UserID 的哈希分片)已经足够高效,且让我彻底搞懂了 Spring 事务管理和数据源切换的机制。”

16. Redis与 Mysql 长期不一致

“关于 Redis 库存扣减,如果 Redis 扣减成功了,但是 MQ 发送失败了(延迟同步数据库失败),导致 Redis 和 DB 数据长期不一致,怎么解决?”

  • 这就是在考你兜底机制

  • 回答: “首先,Redis 是主数据,C端展示以 Redis 为准,短期不一致是可以接受的。其次,为了防止长期不一致,我有一个定时任务(Xxl-job)。它会定期扫描数据库中的库存水位,并与 Redis 进行核对(或者全量同步),如果发现偏差超过阈值,会进行报警人工介入,或者以数据库的库存为基准进行回滚/修正。”

17.redis 挂了怎么办

“你的库存全在 Redis 里,Redis 崩了,流量瞬间打到 MySQL,数据库必死。你的系统怎么活?”

第一层:架构级高可用(基建)

“首先,在基础设施层面,生产环境通常采用 Redis Sentinel(哨兵)Redis Cluster(集群) 模式。 如果主节点挂了,哨兵会自动进行主从切换(Failover),应用层感知到新主节点后继续工作。这能解决大部分单点故障。”

第二层:应用级降级(重点!体现你的业务思考)

“但是,作为开发,我必须考虑极端情况(比如整个 Redis 集群不可用,或者网络抖动)。 针对抽奖这种高并发场景,绝对不能直接回源查数据库,否则 MySQL 会瞬间被打挂,导致整个站点雪崩。

我的方案是 ‘熔断 + 本地缓存兜底’

  1. 熔断(Circuit Breaker): 引入 Sentinel 或 Resilience4j。当检测到 Redis 异常率飙升时,触发熔断,直接拦截后续请求,返回‘当前排队人数过多’,保护数据库

  2. 本地缓存(Local Cache): 对于活动配置、白名单等读多写少的数据,我在应用层加了 Guava/Caffeine 本地缓存。即使 Redis 挂了,核心配置还能读到,保证基本页面能打开,只是不能抽奖。

  3. 有损服务: 如果必须保证服务可用,可以开启‘降级模式’,只允许小流量通过,或者只允许白名单用户抽奖,直接走数据库,放弃高并发能力,保住核心业务不宕机。”

第三层:事后恢复(数据一致性)

“Redis 恢复后,因为它是异步写库的,可能存在 Redis 数据丢失(AOF/RDB没来得及落盘)。 这时候需要运行我的 ‘库存校对任务’(你文档里的 Xxl-job),以 MySQL 的记录为准,将剩余库存重新预热加载回 Redis,使系统恢复正常。”

18.Lua 脚本的“原子性”陷阱

“你用了 Lua 脚本扣减库存。Redis 的 Lua 脚本执行是原子性的,但如果脚本逻辑很复杂,执行时间过长(比如超过 100ms),会发生什么?

“Redis 是单线程模型。如果 Lua 脚本执行太慢,会阻塞后续所有命令的执行(Get/Set 都进不来),导致 Redis 假死。 我的优化:

  1. 严格控制 Lua 脚本的逻辑复杂度,只做最简单的比较和扣减,不写循环。

  2. 在脚本中只传递必要的 Key,避免在大 Key(如包含几万个元素的 Hash/Set)上操作。

  3. (进阶)如果必须处理复杂逻辑,我会拆分为多次请求,或者在应用层做预校验。”

19.关于分库分表的“扩容”难题

  • “你现在是 2库4表。如果业务发展太快,不够用了,要变成 4库8表,怎么做数据迁移?怎么保证不停机?

“这涉及在线平滑扩容,通常分四步走:

  1. 双写阶段: 修改代码,所有新数据同时写入旧库和新库(以旧库为准)。

  2. 存量迁移: 跑一个脚本,后台把旧库的历史数据搬运到新库(遇到双写更新的数据,以最新时间戳为准)。

  3. 校验阶段: 抽样对比新旧库数据,直到数据完全一致。

  4. 切换阶段: 将读流量切到新库,观察稳定后,停止双写,下线旧库。”

20.关于“幂等性”的极限拉扯

  • “用户手抖,1秒钟点了 10 次抽奖。你如何防止他抽 10 次?如果他用了连点器(脚本),你的前端防抖没用,后端怎么防?”

  • 基于 Redis 的分布式锁: setnx lock_key_uid_activityId

  • 数据库唯一索引:user_strategy_export 表中,user_id + activity_id + order_id 建立唯一索引。即使 Redis 锁失效,数据库也会报 DuplicateKeyException,保证最后一道防线不被击穿。

21.日志组件是怎么做的

“我实现的实时日志组件利用了 Logback 的扩展机制。通过继承 AppenderBase 并重写 append 方法,我将自定义组件挂载到了 Logback 的事件分发链上。

这种设计的优势在于完全的非侵入性:它利用了框架的观察者模式,在日志框架进行事件分发时异步捕获 ILoggingEvent。同时,我配合 logback-spring.xml 中的 Root 级别配置和异步 Appender 包装,确保了日志采集过程不会阻塞主业务线程,实现了业务逻辑与监控逻辑的彻底解耦。”

  1. 什么是 WebSocket?(通俗理解)
  • 普通 HTTP(外卖小哥): 你(客户端)打个电话问:“我的外卖到了吗?”小哥(服务端)说:“还没。”然后挂了。你想知道进度,得不停地打电话问(轮询)。

  • WebSocket(专属对讲机): 你和小哥建立了一个持久的通话频道。小哥一边骑车一边对着对讲机喊:“我出发了”、“我过红绿灯了”、“我到门口了”。服务端可以主动推消息给你,不用你问。


  1. 你的项目是怎么做这个日志功能的?

在你的“大营销平台”项目中,我们并没有用它做业务提醒,而是做了一个**“实时性能/监控中心”**。它的链路是这样的:

  1. 后端拦截(Logback Appender):我们写了一个钩子,每当 Java 代码打印一条日志时,Logback 会分身出一份数据给我们的 WebSocketAppender

  2. 实时发射(WebSocket Server):后端通过 WebSocket 管道,把这行日志像射箭一样射向前端。

  3. 前端缓冲(React Buffer Queue):前端收到后,为了不让页面闪瞎眼,先存进一个“小仓库(队列)”,然后按你刚才要求的频率(比如 150ms)一行行优雅地吐出来。

这就是把“死”的服务器文件日志,变成了“活”的可视化流数据。

💡 面试官可能会追问:

  • 问: “为什么不用 HTTP 轮询看日志?”

  • 你: “HTTP 轮询会有大量无效请求,且实时性差。而日志是流式数据,WebSocket 的长连接特性不仅能节省服务器开销,还能达到‘所见即所得’的实时效果,这对于监控高并发系统的瞬时状态至关重要。”

  • 根目录 pom.xml:在 <dependencyManagement> 中声明版本(如果你有版本管理习惯)。

  • big-market-app 模块的 pom.xml这是最重要的。 app 模块是整个 Spring Boot 的运行入口,它负责把所有模块组合起来并启动 Web 容器。

操作: 打开 big-market-app/pom.xml,添加以下内容:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

这个日志实时监控组件,本质上是为你原本“静止”的后端系统安装了一个**“直播摄像头”**。

在传统的开发中,后端日志是写在服务器的文件里的(比如 app.log),你想看日志得去连服务器执行 tail -f。而这个组件的作用,就是把这些本该写入文件的日志,实时地通过网络“发射”到你的浏览器控制台上。

为了让你在面试时能讲得透彻,我们把它拆解为三个核心角色:


1. 核心角色:谁在干活?

我们可以把这个过程比喻成一个**“快递配送系统”**:

  • WebSocketAppender(情报员)

    它潜伏在 Java 的日志框架(Logback)里。每当你的代码执行 log.info("用户开始抽奖") 时,它就会第一时间跳出来,把这条消息复制一份,交给配送中心。

  • LogWebSocketHandler(配送中心)

    它负责管理所有的连接。当你的前端页面打开时,它会维持一个“长连接”管道。收到情报员的消息后,它会顺着管道把消息推送到前端。

  • LogConsole(收件人)

    这是你前端写的 React 组件。它像一个等温箱,实时接收消息,并一行行地显示在屏幕上,还负责自动滚动。


2. 数据的“生命周期”:日志是怎么飞过去的?

  1. 产生:后端执行业务逻辑,调用 log.info()

  2. 拦截WebSocketAppender 拦截到这条日志事件。

  3. 加工:它把时间、日志级别(INFO/ERROR)、内容封装成一个字符串。

  4. 传送:调用 LogWebSocketHandler.sendLog(),通过 WebSocket 协议,将数据从云服务器的 Docker 容器中传出。

  5. 渲染:前端 onMessage 接收到字符串,更新 React 的 state,页面实时刷新。

好的,我们现在深入到 Java 后端,看看这套“日志直播系统”在服务端是如何运转的。

在 DDD(领域驱动设计)架构中,这套功能的实现体现了典型的基础设施适配解耦思想。我们按日志流动的顺序,分三块来讲解:


1. 拦截器:WebSocketAppender.java (基础设施层)

它的角色: 潜伏在日志框架里的“间谍”。

public class WebSocketAppender extends AppenderBase<ILoggingEvent> {
    @Override
    protected void append(ILoggingEvent eventObject) {
        if (eventObject != null) {
            // 1. 格式化日志:从事件对象中提取时间戳、级别、线程名、消息内容
            String log = String.format("%d [%s] %s - %s",
                    eventObject.getTimeStamp(),
                    eventObject.getLevel().levelStr,
                    eventObject.getThreadName(),
                    eventObject.getFormattedMessage());
            
            // 2. 关键动作:把加工好的字符串交给“广播员”发送出去
            LogWebSocketHandler.sendLog(log);
        }
    }
}
  • 面试点: 它是继承自 Logback 的 AppenderBase。这意味着它不需要你在业务代码里写任何一行代码,只要配置了 XML,系统所有的 log.info 都会自动流经这里。这叫非侵入式设计

2. 广播员:LogWebSocketHandler.java (触发器/适配器层)

它的角色: 负责管理对讲机频道(连接)并喊话。

public class LogWebSocketHandler extends TextWebSocketHandler {
    // 🌟 线程安全的 Set:保存所有当前连接着网页的“听众”
    private static final Set<WebSocketSession> sessions = new CopyOnWriteArraySet<>();
 
    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        // 网页打开,握手成功,把这个连接存起来
        sessions.add(session);
    }
 
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        // 网页关闭,把连接移除,防止内存泄漏
        sessions.remove(session);
    }
 
    // 🌟 核心方法:由 Appender 调用,群发日志
    public static void sendLog(String message) {
        for (WebSocketSession session : sessions) {
            if (session.isOpen()) {
                try {
                    // 顺着管道把字符串射向前端
                    session.sendMessage(new TextMessage(message));
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
  • 面试点: 为什么用 CopyOnWriteArraySet?因为日志推送非常频繁,而用户的连接/断开是随机的。这个类保证了在多线程环境下,遍历发送日志和增加/删除连接不会发生冲突(并发安全)。

3. 总机:WebSocketConfig.java (配置层)

它的角色: 告诉 Spring 哪个电话号码(URL)对应哪个服务。

@Configuration
@EnableWebSocket // 开启 WebSocket 功能
public class WebSocketConfig implements WebSocketConfigurer {
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // 映射路径:前端通过 ws://IP:端口/ws/log 就能连上
        // setAllowedOrigins("*"):解决跨域问题,允许前端跨服务器访问
        registry.addHandler(new LogWebSocketHandler(), "/ws/log").setAllowedOrigins("*");
    }
}

4. 激活开关:logback-spring.xml

它的角色: 启动“间谍”的指令。

<appender name="WEBSOCKET" class="cn.bugstack.infrastructure.adapter.WebSocketAppender" />
 
<root level="info">
    <appender-ref ref="CONSOLE" />   <appender-ref ref="WEBSOCKET" /> </root>

💡 总结这个 Java 后端设计的亮点(面试必杀技)

当你面对面试官讲解这段 Java 代码时,一定要提到以下三个关键词:

  1. 观察者模式 (Observer Pattern)

    • Logback 框架是主体,我们的 WebSocketAppender 是观察者。每当有日志产生,主体就会通知所有观察者。这种设计让日志推送功能与业务逻辑完全解耦
  2. 异步化处理思想

    • 如果在 XML 里配置了 AsyncAppender 包裹我们的 WEBSOCKET,那么日志推送就会在独立的线程池中运行。即便 WebSocket 推送网络卡顿,也绝对不会影响主业务(比如抽奖)的执行速度。
  3. 单例与静态通信

    • 通过静态方法 LogWebSocketHandler.sendLog 实现 Appender(底层工具)与 Handler(网络接口)的通信,这在日志这种全局单例的场景下非常高效。

这套后端代码配合你前面优化的“匀速滴落”前端逻辑,构成了一个完整的“高并发日志观测方案”。

你现在回头看你的 pom.xml 和目录结构,是不是觉得这些代码放的位置(Infrastructure 和 App/Trigger 层)非常有道理了?


3. 为什么这个东西在简历上“很值钱”?

作为一个找实习的学生,面试官看中的不是你用了什么框架,而是你解决问题的工程思维。你可以从以下几个维度去“吹”:

维度你的技术亮点
性能意识你会提到:日志打印非常频繁,为了不阻塞主业务线程,我会考虑异步推送,不影响接口响应速度。
全栈能力你打通了 Java WebSocket Nginx/Docker React 的全链路,证明你不是只会写 CRUD。
架构思维你知道把配置放在 app 层,把实现放在 infrastructure 层,这体现了对 DDD(领域驱动设计) 的深刻理解。
运维友好你可以说:这是为了方便开发调试,不用频繁登录服务器看日志,提高了研发效能。

4. 重点突破:面试官可能会问的问题

问:如果日志量特别大,前端会不会卡死?

你的绝杀回答:

“确实存在这个风险。所以在实现时,我在前端设置了日志缓冲区限制,只保留最新的 100 条数据(slice(-100))。同时在后端,我可以通过 Logback 的配置,只过滤 INFO 以上级别的关键业务日志,避免心跳包等垃圾日志占满带宽。”


💡 给你的“数字花园”加点料

在你的笔记里,我建议你画一张简单的数据流转图。不需要很复杂,用文本块画就行:

[Java 业务代码] -> [Logback 框架] -> [WebSocketAppender] 
                                          | (实时推送)
[浏览器 React 组件] <- [WebSocket 协议] <- [Handler 处理器]

现在你对这个组件的“来龙去脉”是不是清晰多了?如果你准备好了,我们可以聊聊如何把后端代码打包到 Docker 中,并让前端正确连上那个 ws:// 地址。

“日志打印是非常频繁的操作,你直接在 Appender 里同步调用 WebSocket 发送,会不会卡死业务线程?”

你可以这样回答(显得你思考过性能):

“目前是初步实现。为了不影响主业务性能,后续可以考虑在 WebSocketAppender 内部引入一个阻塞队列(BlockingQueue),采用‘异步生产消费’模型,让日志异步推送,确保业务链路的零侵入和高性能。”

22.QPS,DB-router

  • 关于“2C4G 支撑 2000 QPS” :

    • 面试官必问:“你这个 2000 QPS 是怎么测出来的?”

    • 标准答案:“我是用 JMeter 在本地模拟并发请求,直接压测的云服务器公网 IP。因为云服务器 峰值带宽只有 3 Mbps,加上 Tomcat 线程池的瓶颈,我观察到 CPU 在 2000 QPS 时负载达到 90%,响应时间开始变慢,所以测出了这个物理极限。”(这样回答极其真实)。

  • 关于“DB-Router” :

    • 面试官必问:“你这个路由组件是怎么实现的?”

    • 标准答案:“核心是利用 Spring 的 AbstractRoutingDataSource。我在 ThreadLocal 里存了当前请求的路由 Key(比如 UserID),然后重写了 determineCurrentLookupKey() 方法,动态决定用哪个 DataSource。这其实就是简易版的 Sharding-JDBC 实现。”

23.防刷组件

1.DDD架构设计

MVC与DDD

https://bugstack.cn/md/road-map/ddd.html

DDD 是一种软件设计方法,Domain-driven design (DDD) is a major software design approach. MVC简单易懂,但较复杂的场景需要维护时,代码迭代成本会变高

  • DDD 是把复杂业务拆成“领域”,每个领域像一个小公司一样自我管理,代码按业务逻辑而不是按技术堆放,从而让系统可维护、可扩展、不乱套。

    MVC 最容易写成:

    • controller 里面写逻辑
    • service 调 DAO
    • service 越堆越大
    • VO、DTO、PO 被乱用
    • 最后一个功能要改,需要了解全系统不同地方的逻辑 → 巨麻烦

DDD的分层结构

如下是 DDD 架构的一种分层结构,也可以有其他种方式,核心的重点在于适合你所在场景的业务开发。

xfg-frame-api → 对外的接口(公司的前台/宣传册) “我们的服务是怎么调用的?RPC 格式是什么?”,RPC = Remote Procedure Call(远程过程调用) ② xfg-frame-app → 应用程序启动层(公司的行政/运营部门) “负责把公司运转起来,但不参与业务逻辑。”

例如:
- SpringBoot 启动类
- AOP、限流、日志
- 配置文件
- 打包成 docker 镜像
这里**不写业务逻辑,只负责应用运行**。

xfg-frame-domain → 领域层(公司的核心业务部门) DDD 的灵魂!业务逻辑都在这里。

 每个领域像“一个小公司”:
- 模型 model(员工)
- 仓储接口 repository(资源获取)
- 业务 service(业务规则、流程)
例如“订单领域”就是一个独立团队:
`订单模型(OrderEntity) 订单值对象(OrderIdVO) 订单仓库接口(IOrderRepository) 订单服务(OrderService)`
领域层就是你系统的**真实业务能力所在**。

xfg-frame-infrastructure → 基础设施层(公司的后勤部门) “数据库、Redis、MQ 的真实实现都在这里。”

领域层只定义接口:
`IOrderRepository`
基础设施层来实现:
`OrderRepository implements IOrderRepository`
这样做的好处:
- 业务层不依赖 MyBatis 代码
- 想从 MySQL 换成 MongoDB,只改基础设施,不改业务逻辑
- 业务变动不会炸掉 DAO

xfg-frame-trigger → 触发器层(公司的对外接口部门) “别人通过 HTTP / RPC / MQ / 定时任务 调用你”

也叫 Adapter 层,负责接入各种触发方式:
- HTTP Controller
- Dubbo RPC 实现类
- MQ 消费者
- 定时任务
触发器做的事是:
1. 接收外部请求
2. 参数校验
3. 调用 domain 业务逻辑
4. 返回处理后的结果
触发器不写业务!

xfg-frame-types → 通用工具层(公司的共享工具库) “常量、通用的响应对象、枚举等公共类型。”

这里没有业务逻辑,只放工具类,例如:
- Response
- Constants
- 枚举类型
所有层都能引用

领域分层

 model 里还分为;valobj - 值对象、entity 实体对象、aggregates 聚合对象

概念有没有 ID能不能单独存在?是什么?
值对象 VO❌ 没有❌ 不能小数据值
实体 Entity✔ 有✔ 能业务中的“东西”
聚合根 Aggregate✔ 有✔ 必须有整个业务逻辑的大 Boss

🚗 一个超级通俗的例子:

我们开一家「电商系统」来解释 DDD 的三个核心概念 假设业务里有:

  • 商品(name、price、description…)
  • 订单(orderId、收件人、订单项…)
  • 用户(userId、昵称、手机号…) DDD 中,针对数据和对象会这样拆分:

值对象 VO(valobj) ≈ 不可变、没有唯一 ID 的“小数据片段” 关键特征:

  • 没有唯一标识(没有 ID)
  • 只关心内容,而不是它是谁
  • 通常是不可变对象(immutable)
  • 代表纯粹的“值” 比如:
  • 商品名 ProductNameVO
  • 商品描述 ProductDescriptionVO
  • 年龄 AgeVO
  • 地址 AddressVO
  • Money(金额) 这些东西变化时,不是“修改”,而是“重新生成一个新对象”。

“iPhone 16 Pro Max(蓝色)价格 8999” ​ 这里「名称」「描述」「价格」都只是,不会单独存数据库,不会有自己的 ID。 你不会给“商品名称”单独建一张表,对吧?

实体对象 Entity ≈ 有一个唯一 ID 的可变化的业务对象 关键特征:

  • 有唯一标识(ID)
  • 即使属性变化,它还是同一个对象
  • 通常和数据库 PO 强相关,但不是完全等价 比如:
  • 用户(userId)
  • 订单(orderId)
  • 商品(productId) 这些东西会单独存表,因为它们是业务的主要对象。

OrderItemEntity(id=1, productName="可乐", price=3) 即使你把可乐的价格从 3 改成 3.5,它仍然是 OrderItem id=1。

聚合根 Aggregate ≈ 一组实体的“老板”,负责保持业务一致性 这是 DDD 最不好理解、但最重要的概念。 聚合(Aggregate)理解成:

一组强相关实体的组织结构,它们必须成为一个业务整体,由一个“聚合根”负责。 聚合根的作用:

  • 对外的唯一入口
  • 保证整体数据的一致性
  • 业务规则在这里封装

一个订单聚合可以包含:

结构示意:

OrderAggregate(聚合根)
 ├── OrderEntity(订单本体)
 ├── List<OrderItemEntity>(订单项)
 ├── AddressVO
 └── PriceVO

【聚合规则】

  • 订单创建时,订单项不能为空(聚合根校验)
  • 订单总价必须 = 各订单项相加(聚合根确保一致性)
  • 添加订单项、删除订单项,都必须通过 OrderAggregate 来做 📌 聚合根 = 决策者 📌 实体 = 成员对象 📌 值对象 = 基础值信息

本项目业务流程

本项目战略设计

https://bugstack.cn/md/project/big-market/ddd.html

1.用例图

  • 根据业务需求画系统用例图(use case diagram:是用户与系统交互的最简表示形式,展现了用户和与他相关的用例之间的关系)

2.事件定义风暴

3.寻找领域事件(最核心的部分)

4.识别领域角色和对象

5.划分领域边界

  • 圈出领域

  • 领域边界

6.研发详细细节

  • 实体对象

对每个领域对象进行字段的详细细节

  • 流程设计

2.数据库

3.策略概率装配处理

feature/231223-xfg-strategy-armory #无边记 无边记第三节

项目架构图

本节先实现最核心的部分

策略的选择

这里使用空间换时间的做法

  • 提前算好抽奖的概率分布,用redis存储,抽奖时生成随机值,在空间中定位
  • 另一种是生成随机值后与概率范围做for循环比较,如果总概率超过100万,可以用此方法,与实际诉求来依赖

记忆点 为什么用redis不用本地内存

  • redis可以解决分布式问题,本地内存需要让多台机器都保持数据的同步更新,需要引入配置中心及定时检测的手段来处理启动前/后,对活动变更/新增做本地内存做数据加载处理

策略装配库(兵工厂),负责初始化策略计算; ​

  1. 查询策略配置
  2. 获取最小概率值
  3. 获取概率值总和
  4. 用 1 % 0.0001 获得概率范围,百分位、千分位、万分位
  5. 生成策略奖品概率查找表「这里指需要在list集合中,存放上对应的奖品占位即可,占位越多等于概率越高」
  6. 对存储的奖品进行乱序操作。避免顺序生成的随机数前面是固定的奖品。
  7. 期间存储奖品table时,也要存储table的长度,在抽奖时,先根据策略在redis查对应的长度,在根据长度生成随机数获取相应奖品
  8. 生成出Map集合,key值,对应的就是后续的概率值。通过概率来获得对应的奖品ID
  9. 存放到 Redis

注意,这里调用的 IStrategyRepository 由仓储层进行实现。 ​

4.策略权重的装配

231231-xfg-strategy-armory-rule-weight-note #无边记 见无边记第四节

  • 本章对上一章进行重构,满足对策略权重的装配处理(如果存在)
  • 策略权重
    • 如果用户抽奖N积分后,可以升级中奖返回(更高阶高价值的奖品)

  • 在装填策略后在装填权重策略(如果有)
    1. 查询是否策略id是否在策略表内存在权重策略
    2. 在查询策略规则表看是否存在具体策略组[1,4,7]
    3. 当存在时,在策略规则实体内使用get具体策略方法,返回一个map,key是权重名字,val是具体的权重组
    4. 循环key取出各自的权重组,拷贝后过滤不存在该组的奖品,装填带策略权重的奖品池

5.抽奖前置规则过滤

实现抽奖前置权重和黑名单规则,在用户抽奖前进行规则过滤。本节会使用到模板模式、策略模式、工厂模式,来实现功能;

一.模版模式

定义一个算法的固定流程框架,并允许子类在不改变整体流程的情况下,重写其中的某些步骤

📌 核心点

  • 父类:定义固定流程(模板)。
  • 子类:负责实现具体步骤。
  • 流程就像一个“骨架/模板”,不能改变顺序。

📌 示例(当前项目)

AbstractRaffleStrategy 就是模板模式:

performRaffle() {
    1. 参数校验
    2. 查询策略
    3. 抽奖前规则过滤   ← 子类实现
    4. 默认抽奖逻辑
}

你只需要在子类 DefaultRaffleStrategy 中实现: doCheckRaffleBeforeLogic() 整个抽奖流程不会变,这就是模板模式。

📌 适用场景

  • 流程固定,但某些步骤会变化(多态扩展)
  • 典型:抽奖流程、处理表单流程、爬虫流程、支付流程…

二.策略模式

1.策略模式

一系列可替换的算法封装成独立策略对象,在运行时动态选择其中一个。

📌 核心点

  • 策略之间是 并列、可互换 的。
  • 不同规则是不同的策略类。

📌 示例(你的项目)

抽奖前规则:黑名单 & 权重

@LogicStrategy(logicMode = RULE_BLACKLIST) class RuleBlackListLogicFilter implements ILogicFilter {}  @LogicStrategy(logicMode = RULE_WEIGHT) class RuleWeightLogicFilter implements ILogicFilter {}

“ 两者都是策略,都实现了:

public interface ILogicFilter {
    RuleActionEntity filter(RuleMatterEntity entity);
}

当抽奖时,会根据规则选择不同策略执行。

📌 使用场景

  • 多种可替换算法:排序、压缩、加密
  • 多种计费模式、风控规则、抽奖规则
  • if-else 太多、要从代码中“解耦逻辑”

三.工厂模式

使用工厂对象统一创建实例,避免你自己 new,让创建逻辑可控、可扩展。

简单工厂模式核心:

一个工厂类,负责创建一组相关的对象,根据传入的“标识”选择返回哪个对象。

DefaultLogicFactory 是一个带自动注册的简单工厂。 它不是工厂方法,也不是抽象工厂,而是一个策略池工厂(Registry Simple Factory)。

📌 项目里的例子

DefaultLogicFactory 会自动扫描所有 ILogicFilter 策略:

logicFilterMap.put(strategy.logicMode().getCode(), logic);

在抽奖时通过工厂获取具体策略: logicFilterGroup = logicFactory.openLogicFilter(); 你无需知道策略类叫什么,不需要 new,只需要: 根据 code 找对象

📌 使用场景

  • 对象创建复杂
  • 需要根据类型动态创建实例
  • 想隐藏创建细节

⭐ 三者的关系(超级重要)

在你的抽奖系统中:

模板模式

规定整个抽奖流程(父类骨架)

策略模式

用于“抽奖前规则过滤”(黑名单、权重等)

工厂模式

用于组装策略对象,让程序动态选择用哪个策略

流程设计

记忆点

  • 正规规则来说,分为抽奖前、抽奖中、抽奖后,三个阶段执行。本节我们先来处理抽奖前的规则。

用户在执行抽奖前,需先判断是否为黑名单用户,返回固定积分,在判断是否已超过N积分,来决定奖池

项目结构

  1. rule 下面是实现的整个规则部分的处理,后续可以更好的扩展添加其他规则。
  2. raffle 是抽奖功能的实现,抽象类是模板模式,定义出标准的抽奖流程

代码结构

  1. AbstractRaffleStrategy

定义抽奖流程的固定步骤,把其中可变步骤(例如规则过滤)抽象出去,让子类实现 就像做一碗拉面,流程固定:

  1. 准备食材
  2. 检查用户规则(是否黑名单、积分是否符合范围)
  3. 执行抽奖
  4. 返回结果

这就是模板方法模式的经典使用场景

固定流程:

✔ 参数校验 ✔ 查策略 ✔ 执行规则过滤 ✔ 抽奖(或使用规则结果) ✔ 返回奖品 可变步骤只有一个: 👉 规则过滤 doCheckRaffleBeforeLogic()

doCheckRaffleBeforeLogic() 的实现在DefaultRaffleStrategy中

                   +-------------------------------+
                   |   DefaultRaffleStrategy       |
                   | doCheckRaffleBeforeLogic()    |
                   +-------------------------------+
                                 |
                +----------------+-----------------+
                |                                  |
        黑名单规则优先执行                     其它规则顺序执行
                |                                  |
    logicFilter["rule_blacklist"]           logicFilter["rule_weight"]
                |                                  |
        +-------+-------+                    +------+-------+
        |               |                    |              |
   TAKE_OVER         ALLOW               TAKE_OVER        ALLOW
        |               |                    |              |
 返回固定奖品ID      进入下一规则        限定范围抽奖    进入默认抽奖

DefaultRaffleStrategy 的 doCheckRaffleBeforeLogic() 是前置规则引擎的执行入口。它会使用工厂提供的规则池,先过滤黑名单,再按顺序执行其它规则,只要任何规则返回 TAKE_OVER 就接管抽奖流程,否则执行默认抽奖。

                    +-----------------------------------+
                    |        抽奖模板(Template)        |
                    |   AbstractRaffleStrategy           |
                    |-----------------------------------|
                    | 1 参数校验                         |
                    | 2 查询策略                         |
                    | 3 调用 doCheckRaffleBeforeLogic   | <─── 子类处理规则逻辑
                    | 4 如果 TAKE_OVER → 按规则返回结果   |
                    | 5 默认抽奖流程                    |
                    +-----------------------------------+
                                    |
                                    v
        +------------------------------------------------------------+
        |      DefaultRaffleStrategy(规则执行者)                   |
        |------------------------------------------------------------|
        | 从工厂获取所有规则:logicFactory.openLogicFilter()         |
        | 优先执行黑名单 → 判断 ALLOW / TAKE_OVER                    |
        | 顺序执行剩余规则 → 判断 ALLOW / TAKE_OVER                  |
        | 返回第一个 TAKE_OVER 的规则结果                           |
        +------------------------------------------------------------+
                                    |
                                    v
                 +--------------------------------+
                 |        规则工厂(Factory)      |启动时就开始收集
                 |    DefaultLogicFactory          |
                 |--------------------------------|
                 | 自动扫描所有 @LogicStrategy     |
                 | 建立规则池 Map<code, filter>   |
                 | 提供 openLogicFilter() 返回池  |
                 +--------------------------------+
                                    |
                                    v
       -----------------------------------------------------------
       |                  规则策略(Strategy)                    |
       |----------------------------------------------------------|
       | RuleBackListLogicFilter     → 黑名单规则(TAKE_OVER)    |
       | RuleWeightLogicFilter       → 权重规则(TAKE_OVER)      |
       | …未来可扩展更多规则…                                     |
       | 每个规则都实现 ILogicFilter 接口                         |
       -----------------------------------------------------------

6.中奖中置规则过滤

240113-xfg-raffle-rule-center-note'

  • 本章诉求 为刺激用户消耗手中的积分,为后面一些奖品增加条件:需要抽奖过几次后才能解锁(rule_lock字段) 故在抽奖中这个阶段,添加次数过滤(也算是一种黑名单或者权重策略)

7.责任链模式处理抽奖规则

240120-xfg-raffle-chain-note

  • 本章诉求

在前面的章节,我们用模版,策略,工厂三种设计模式定义抽奖前中后的规则过滤,但前置规则(只判断,不抽奖)的校验和抽奖逻辑混在一起,显得臃肿,指责过多,这节,通过责任链把“”规则判断“和”抽奖行为“分开,让其每一节点只干一件事情清晰可扩展

流程设计

抽奖的前置规则可以抽象为一种策略行为,比如黑白名单策略,权重策略,而这些策略规则是互斥的,所以责任链很适合

2.1 定义责任链接口(ILogicChain)

接口:

public interface ILogicChain {
    ILogicChain next();
    ILogicChain appendNext(ILogicChain next);
    Integer logic(String userId, Long strategyId);
}
  • appendNext():构建链
  • next():执行链的下一个节点
  • logic():每个节点自己的核心逻辑 这是本节最基础的定义。

2.2 黑名单节点(BackListLogicChain)

特点:

  • @Component(“rule_blacklist”)
  • 逻辑:命中黑名单 → 接管抽奖直接返回 awardId,否则 next() 这一段结构示例: 黑名单节点 ↓(放行) 权重节点

2.3 权重节点(RuleWeightLogicChain)

特点:

  • @Component(“rule_weight”)
  • 逻辑:根据积分范围选择某个奖品 → 接管,否则 next()

2.4 默认节点(DefaultLogicChain)

特点:

  • @Component(“default”)
  • 逻辑:抽奖的最终兜底逻辑(一定会返回奖品) 责任链结构就像这样:

黑名单
↓ 权重
↓ 默认抽奖

                strategy
                     │
                     │ ① 抽奖入口 performRaffle
                     │
                     ▼
      ┌──────────────────────────────┐
      │    DefaultChainFactory       │
      │    ← Map<String,ILogicChain> │
      └──────────────────────────────┘
                     │
                     ▼
    构建责任链(基于 rule_models 配置)
        链头
          ↓
 ┌────────────────────────┐
 │ BackListLogicChain     │  rule_blacklist
 └────────────────────────┘
          ↓ (next)
 ┌────────────────────────┐
 │ RuleWeightLogicChain   │  rule_weight
 └────────────────────────┘
          ↓ (next)
 ┌────────────────────────┐
 │ DefaultLogicChain      │  default
 └────────────────────────┘
(责任链执行逻辑)

8.抽奖规则树模型结构设计

240127-xfg-rule-tree-note

  • 本章诉求 解决先阶段中抽奖策略规则的中、后两部分执行问题。.引入组合模式的规则引擎,让过滤节点可以满足一颗二叉树的结构,自由的组合和多分支莲路的方式完成流程的处理

“抽奖中/抽奖后”的规则完全不同! 比如:

  • 抽到某个奖品后,还要判断库存
  • 如果库存不足,要判断是否给兜底奖
  • 抽奖次数与某些奖品的解锁条件关联
  • 抽奖后还可能触发一些后置规则

① 设计规则树结构(组合模式) ② 编写树执行引擎(决策树引擎) ③ 提供工厂装配树结构(工厂模式)

流程设计

  • 工程结构

规则树模型

  1. RuleTreeVO 决策树的树根信息,标记出最开始从哪个节点执行「treeRootRuleNode」。
  2. RuleTreeNodeVO 决策树的节点,这些节点可以组合出任意需要的规则树。
  3. RuleTreeNodeLineVO 决策树节点连线,用于标识出怎么从一个节点到下一个节点。
rule_lock(抽奖次数是否满足解锁条件)
   │
   ├── TAKE_OVER → rule_luck_award(直接给奖励)
   └── ALLOW     → rule_stock (检查库存)
                         │
                         └── TAKE_OVER → rule_luck_award (库存不足给兜底奖)

9.模版模式串联抽奖规则

240203-xfg-raffle-rule-flow-note

责任链进行抽奖计算,基于抽奖计算结果对基础抽奖在进行规则树的过滤,最终返回抽奖结果。串联上两节课程

流程设计

功能实现

  • 库表设计

  • 工程结构

  1. rule 规则部分,保留;责任链、规则树,去掉之前的 filter 过滤器。
  2. 在 AbstractRaffleStrategy 抽象类,串联调用流程。先是责任链,后是规则树。责任链处理的是不同的抽奖【黑名单、权重、默认】,处理完的抽奖结果,如果是默认抽奖则需要进行库存、次数等校验,并给出最终发奖结果。
  3. 本节还包括了根据上一节实现的规则树模型,设计的库表结构。并实现出仓储数据查询的操作。

10.不超卖库存规则实现

  • 本章诉求 当通过抽奖策略计算完用户可获得的奖品ID后,接下来进行这一奖品的库存扣减操作,只有扣减成功才能获得,否则走兜底奖励

流程设计

如果只对数据库进行操作,则对数据库的访问压力很大,需要用户排队,这里使用redis缓存作库存,只要做到不超卖就行 但不能用一条key加锁和等待释放的方式来处理,这样效率依旧很低

  • 故在对上面所实现的规则树中,对于库存节点的操作,使用decr方式扣减库存.
    • decr是原子操作,效率非常高
  • 还需setnx加锁是一种兜底手段,避免后续库存的恢复
  • 库存消耗完后,需要更新库表的数据量,这里通过Redisson延迟队列+定时任务,缓慢消耗队列数据来更新库表数据变化

redissonClient.getAtomicLong(key).decrementAndGet()

  • 底层命令: 对应 Redis 的 DECR 命令。

redissonClient.getBucket(key).trySet("lock")

  • 底层命令: 对应 Redis 的 SETNX(或带参数的 SET ... NX)。

场景模拟:加上 setnx 之后(文中的方案)

假设当前库存是 98。

  1. 用户 A 进来了,执行 decr,拿到 98
  2. 系统立即执行 setnx(key="stock_token_98", value=user_a_id)
    • 因为这个 Key 之前不存在,返回 True (成功)
    • 用户 A 成功锁定第 98 号库存。
  3. 用户 A 后续业务失败了,甚至总库存被错误地回滚回了 99。
  4. 用户 B 进来了,执行 decr,再次拿到了 98
  5. 系统尝试执行 setnx(key="stock_token_98", value=user_b_id)
    • 关键点来了:因为用户 A 之前已经写入了 stock_token_98,Redis 发现该 Key 已存在。
    • 返回 False (失败)
  6. 系统判断:虽然 decr 分配了名额,但锁失败了。说明这个“第 98 号”名额已经是脏数据(被消耗过),系统拒绝用户 B 的请求,或者让用户 B 重试(去抢第 97 号)。

在这里,setnx 的作用可以归纳为:

  • 幂等性保证(Idempotency):确保每一个具体的库存 ID(如第 98 号、第 97 号)在全生命周期内只能被消费一次
  • 防止超卖的最后一道防线(兜底):即使最外层的计数器 (count) 出现了计算错误、并发回滚错误,只要具体的 token (stock_token_N) 被占用了,就不会发生两个用户抢到同一个具体商品的情况。

工程结构

  1. 承接上一节规则树的使用,本节完善规则树节点的逻辑。包括;次数锁、兜底和奖品库存的处理。奖品库存的处理是大头。
  2. 奖品库存处理,就会涉及从 redis 缓存读取数据做 decr 扣减操作。而这部分缓存的数据,要放到装配处理里,事先做好数据的装配操作。
  3. 最后一步就是新增 IRaffleStock 接口,处理扣减库存结束后,写到到 redis 队列中的库存消耗数据,再由 trigger 中定时任务扫描获取 redis 队列数据,从而缓慢更新库表数据。

11.抽奖API接口实现

240215-xfg-raffle-controller

结合着前面两节(13、14) WEB UI 的开发,以及接口的 Mock 让前端调用。我们可以知道服务端应该提供的接口标准。

流程设计

在大营销的系统架构设计中,有一个 trigger 模块,专门用于提供触发操作。这里我们把 HTTP 调用、RPC(Dubbo)调用、定时任务、MQ监听 等动作,都称为触发操作。触发表示通过一种调用方式,调用到领域的服务上。

RPC 让你在写分布式代码时,感觉像是在写单机代码,通过共享一个“接口 Jar 包”作为契约,既保证了类型安全(编译时就能发现错误),又隐藏了复杂的网络通信细节.

工程结构

  1. 定义 IRaffleService 接口,由 trigger 模块下的 http 层 RaffleController 实现接口。
  2. 接口层的实现,直接调用到 domain 领域层。也就是我们前面所实现的抽奖策略领域服务。【本节会对抽奖策略领域服务新增接口,做到单一职责的设计】
抽奖策略

  1. 新增加 IRaffleAward 策略奖品接口,查询奖品信息。让 DefaultRaffleStrategy 子类实现。【注意奖品查询会用到之前的接口,并做了新的字段的增加查询】
  2. 调整 IRaffleStock 库存的处理接口,由子类实现。因为这两个接口,都不需要做抽象类的处理。
  3. DefaultRaffleStrategy 子类继承了一个抽象类,并实现2个接口。这样的结构会更加清晰,知道子类在做什么,也更好维护。
  • 定义查询奖品列表接口,用于大转盘展示奖品使用。

    • 注意调用到 StrategyRepository#queryStrategyAwardList 方法的时候,缓存的 Key 调整 STRATEGY_AWARD_LIST_KEY 这个是查询 List 的结果。在查询字段上,增加了 额外的内容,如 sort。
  • 抽奖策略接口返回的结果 RaffleAwardEntity 需要调整下字段。在我们前面章节实现的前端 UI 中知道,抽奖优先根据奖品列表的顺序ID进行抽奖,正好这个字段是我们数据库设计的 sort 排序字段。所以我们在 RaffleAwardEntity 中新增加这个字段来使用。

对外接口

在API中定义3个接口;策略装配接口、查询抽奖奖品列表配置、随机抽奖接口。

在trigger层,实现上面的接口,写入具体逻辑(头上顶RequestMapping注解),同时封装对应的错误码

  1. 策略装配,将策略信息装配到缓存中;/api/v1/raffle/strategy_armory
  2. 查询奖品列表;/api/v1/raffle/query_raffle_award_list
  3. 随机抽奖接口;/api/v1/raffle/random_raffle ​
其他调整

4.1 规则树节点判断 ​ DecisionTreeEngine#nextNode 决策树引擎的判断下一个阶段方法,在找不到下一个阶段的时候返回 null 不需要抛异常。null 结束即可。 ​ 4.2 缓存获取值判断* ​ 在通过缓存获取抽奖范围值时,如果忘记初始化策略到缓存中会抛异常。所以新增加了判断代码,增强健壮性。 ​

12.用户参与抽奖活动库表设计

240302-xfg-table-activity

在一个营销场景中,抽奖的流程分为;参与的有效期、整体的预算库存、个人的可参与次数、之后是抽奖策略的计算处理,返回抽奖结果。 ​ 大营销第1阶段已经完成了抽奖策略的领域模块实现,接下来则需要设计一个外壳,把抽奖策略包装进去。这个外壳就是一个抽奖活动的配置,在活动上有相关的库存、时间、状态,个人在总、日、月分别可进行的参数次数判断。 ​

业务流程

以用户参与活动为视角,来理解整个业务流程

  1. 我们可以把用户参与抽奖理解为pdd的一次下单,下单后才具备参与抽奖的资格。而下单的过程中,则需要过滤活动的相关信息以及库存数据。
  2. 所有的判断流程做完后开始写入库中,库中则是用户一个互动的次数账户记录。记录着用户可以参与的抽奖次数。同时需要把参与活动的记录写一条订单。
  3. 此外为了扩展用户在一些场景中,首次【签到/登录】可以赠送一个抽奖次数外,还可以通过购买、做任务、兑换等方式获得新的抽奖次数。这样用户就可以不断地消耗自己的积分兑换抽奖次数来抽奖了。 ​

库表设计

整个设计分为5张表;两个活动配置表(抽奖活动表、参与次数表)、三个用户领取表(活动下单记录、活动次数账户、账户次数流水)

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

13.引入分库分表路由组件

分库分表在分布式架构中是一种非常常用且成熟的数据存储方案,如果早期设计为单库单表,后期要扩展为分库分表时,迁移成本和工程改造成本非常大

假设你有 物理机A物理机B。为了省钱且安全,可以这样部署:

  • 物理机 A 运行两个虚拟机
    • VM1:跑 库1的主节点
    • VM2:跑 库2的备节点
  • 物理机 B 运行两个虚拟机
    • VM3:跑 库2的主节点
    • VM4:跑 库1的备节点 效果:
  1. 资源利用率:两台机器都跑满了,没浪费。
  2. 安全性:如果物理机 A 停电了,物理机 B 上有“库1的备节点”,可以立刻顶上,数据不丢失,服务不断。
  3. 分库分表:逻辑上你已经拥有了“库1”和“库2”两个库。

当需要扩充数据库时,只需要再买物理机,讲其中2个虚拟机合并到之前分库分表的配置即可

功能流程

在大营销的系统设计中,有一个配置库(big_market)和两个分库(big_market_01、big_market_02),我们需要对两个分库进行配置路由操作。达到分库分表的目的,而配置库则是一个单库单表存储活动等配置类信息。分库分表调用流程【如图】

  • 这里使用的开源的数据库分库分表路由组件(DB-Router),而非sharding-jdbc
  1. 以用户对数据库的操作为视角,发生用户类的行为操作时【账户、下单、流水】,则会根据用户ID(userId)进行路由,把数据分配到x库y表中。
  2. 路由计算的处理,是以配置了 @DBRouter注解的 DAO 方法进行路由切面开始。通过获取用户ID(userId)值进行哈希索引计算。哈希值 & 2从n次幂数量的库表 - 1 得到一个值,在根据这个值计算应该分配到哪个库表上去。比如这个是6,分库分表是2库4表,共计8个,那么6就分配到了1库4+2库2个等于6,也就得到了2库2表。
  3. 对于计算得到的分库分表值,存入到 ThreaLocal 中,这个东西的目的是可以在一个线程的调用中,可以随时获取值,而不需要通过方法传递。
  4. 最后 Spring 在执行数据库操作前,会获取路由。而路由组件则实现了动态路由,从 ThreadLocal 中获取。此外注意,因为还有分表的操作,比如 table 需要为 table_01 这个动作是由 MyBatis Plugin 插件开发实现的(拦截器)。

数据源配置

(和JDBC好像)

  1. dbCount 分几个库,tbCount 分几个表,两个数的乘积为2的次幂。
  2. default 为默认不走分库分表时候路由到哪个库,这里是我们需要的配置库。
  3. routerKey 默认走的路由 Key,一个数据路由,是需要有一个键的,这里选择的是用户ID作为路由计算键。
  4. list: db01,db02 表示分库分表,走那套库。
  5. db0、db1、db2 就是配置的数据库信息了。这里给每个数据库都配置了对应的连接池信息。

库表使用

在 big-market-infrastructure 基础层,配置路由操作。

就是在sql语句时插入注解

14.抽奖活动订单流程设计

240316-xfg-activity-order-design

本节我们要设计出,用户参与抽奖活动的流程设计,并可以支持后续满足用户通过不同行为来增加自己的抽奖次数。 ​ 那么站在本节的诉求上,小傅哥将会带着读者对前面所设计的活动库表做一个解耦操作。来满足后续流程中个人可参与抽奖活动次数的变化处理。

功能流程

我们可以把抽奖的行为理解为一个下单过程,用户参与抽奖,也等价于商品下单。只不过这个商品的 sku 是活动信息。

  1. 用户的触达行为是后续需要扩展的部分,当我们把大营销结合给其他系统的时候,就可以让支付后的消息推送过来,给用户领取一次抽奖次数。并参与抽奖。【还记得你在商城,或者一些云服务购买后,可以参与抽奖的过程吗?】
  2. 在上一节,小傅哥是把活动的可参与库存、用户的库存,都配置到活动本身。那这样就有一个问题,比如不同的场景,所需要在一个活动上给用户分配的抽奖次数不同,那么就不好配置了。所以我们要抽象一下,把活动和个人参与的次数,从活动配置中解耦出来,并通过 sku 商品表的方式配置出这样一组商品信息。
  3. 另外,在活动信息表中,还有活动的库存。这里我们把活动的库存也提取出来,放到 sku 上。一个商品的 sku 能下单多少次,由 sku 管理就行了。

库表调整

按照我们的功能流程设计,新增加 sku 表,并去掉分库分表中的 flow 流水表,而是直接由 order 订单表提供。

  1. 首先,去掉活动表中的关联操作,并新增加活动 sku 表来做关联。这样就可以把活动和参与次数当成一种物料,之后 sku 来定义库存或者将来想扩展价格或者积分兑换也是可以的。
  2. 之后,去掉原来的次数流水表,把流水的用途合并到订单表中。想获得更多的抽奖次数,就直接对 sku 下单即可。无论是通过赠送、签到、打卡、积分兑换等任何方式,都是可以的。这样也就增强了营销活动的扩展性。
  3. 注意:调整的库表信息,已经放到了导出sql语句,放到本节分支下 docs/dev-ops/mysql/sql 下。

工程结构

  • 在 domain 领域下新增加活动领域模块。在这个模块下会陆续提供出活动的下单、配置等服务。学习中可以以此为入口,查看到对应的基础层、启动层(app)的变化。
  1. 本节先初步定义出领域模块和下单抽奖的抽象类,抽象类的作用是定义一个执行下单的标准流程。后续将逐步完成这块的功能。
  2. 其他所涉及的对象可以参考工程或者视频来看。在 DDD 规范中,通常会把具有唯一ID标识,影响数据库数据变动的操作,定义为实体对象。而用于描述对象属性的值,如枚举值、没有生命周期对象,可以被定义为 VO 对象。

15.抽奖活动流水入库

在基于活动、次数所组合的活动 sku,用户参与活动就相当于,下单sku给自己的活动账户充值可参与的额度次数。所以本节把活动的下单的过程落入到数据库中。

本节写库会涉及到分库分表组件切分和开启事务的操作。

功能流程

按照上一节我们对活动流程的设计,用户参与抽奖活动就会有一个活动额度账户,而本节则让来实现参与活动对自己的活动额度账户充值的过程。

  1. 本节会先实现出领取活动的框架结构代码,并对数据进行落库操作。(落库的过程会有分库分表下事务的操作)
  2. 活动日期、活动状态、sku库存校验和扣减,这些都是固定的流程。无论创建多少个活动都会走这样的统一流程,所以这里适合添加一个责任链模式的结构。
  3. 因为是分库分表设计,所以库表数据的写入需要确定切分键,并在同一个连接下执行 commit 这样才能把用户的活动账户和订单流程,一起写库。(也就是一个事务的特性)

库表调整

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

  • out_business_no 是一个唯一索引字段,这样也就确保了重复的插入会有唯一索引冲突。通过冲突的异常来告诉调用者,这个业务ID的业务已经处理了。

工程结构

以 domain 下的 activity 领域模块进行功能实现; ​

  1. IRaffleOrder 是抽奖下单的入口,也就是给用户在当前的这个活动,个人的账户上充值。比如这次是允许抽奖1次。
  2. AbstractRaffleActivity 是抽象类,定义出抽奖下单的流程。
  3. RaffleActivitySupport 是支撑类,类似 Spring 源码中也会有 XxxSupport 来提供数据支撑。这样可以简化抽象类(AbstractRaffleActivity)里的代码量。
  4. RaffleActivityService 是抽象类定义的抽象接口由此类实现。
  5. rule 模块下是责任链的规则实现部分。
  • 抽象类定义出了整个抽奖活动的充值过程;参数校验、查询基础信息(由支撑类提供)、活动动作规则校验(这部分是责任链的处理,暂时先实现结构)、构建订单聚合对象、保存订单、返回单号。

  1. 这一部分的重点主要是到写库操作这块。

  2. dbRouter.doRouter(createOrderAggregate.getUserId()); 是在确定路由的结果,也就是让 Spring 知道应该链接到哪个库上去。

  3. transactionTemplate 的操作是编程式事务,事务中操作了2个表。只有这2个表在同一个库,同一个连接上,才能确保一次 commit 提交下的事务。

  4. 整个过程完成后执行 dbRouter.clear; 也就清理掉了路由组件中 ThreadLocal 中的值。

  5. 责任链需要一个规则接口,一个组装规则的接口,一个抽象类来填充责任链。

  6. 之后是实现责任链接口的各个具体要处理的规则操作,比如;活动基础信息校验,活动库存处理。

  7. 此外还有一个责任链的处理工厂,负责将各部分责任链对象注入进来后加工组装出一个责任链的链条⛓。就像我们讲的,这个责任链是一个固定结构的链接,所以在工厂中提供个统一的链就可以了。

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

完成上一节内的责任链内部的逻辑,包括日期,状态,sku库存

  • sku库存的扣减操作
  • 判断缓存库存和数据库存一致性问题
    • 先在策略阶段采用延迟队列作趋势更新
    • 引入MQ,在库存消耗空后发送MQ消息,直接更新清空库存

功能流程

  1. 第一步;完成责任链的活动校验,时间、状态、库存。
  2. 第二步;对库存的扣减,使用 decr + lock 锁的方式(兜底)进行处理。
  3. 第三步;做完库存扣减后,发送延迟队列,由任务调度更新趋势库存,满足最终一致。
  4. 第四步;库存消耗为0后,发送MQ消息,驱动变更数据库库存为0

工程结构

  1. domain 领域层是本节要实现的核心主功能,以完善开发上一节的活动责任链功能和扩展出 ISkuStock 接口的能力。
  2. trigger 是触发器层,用于接收mq消息、执行任务,和http请求的处理。本节会在 listener 中消费 mq
  • 活动预热的代码书写assembleActivitySku(与抽奖预热相同)

  • 责任链的内部链条逻辑填充粉红框内(包括1️⃣基础的校验,2️⃣库存的扣减)

    • 消息队列代码activitySkuStockConsumeSendQueue与最终一致性判断subtractionActivitySkuStock(库存清空),这俩在2️⃣库存扣减逻辑内的具体实现
  • 监听消费代码ActivitySkuStockZeroCustomer

17.用户领取活动库表设计

设计用于,用户参与活动所需的库表。从用户参与活动,扣减个人活动账户次数创建活动订单。抽奖完成获得奖品ID后,写入中奖记录task任务表(发mq补偿使用)。之后发送MQ消息,更新中奖记录。 ​ 这一套流程,需要本节增加6张表。—— 小傅哥给大家的这些设计,都是来自于真实的场景中,而不是随便一张库表就只写 CRUD 。

业务流程

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

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

18.领取活动扣减账户额度

240405-xfg-activity-partake

活动领域包含三个核心子领域:

  • 活动部署
  • 活动账户充值
  • 用户参与活动

类似的,上面的策略领域只有一个核心子领域:

  • 策略装配
    • 具体规则:树,责任链
    • 抽奖动作

业务流程

用户抽奖的业务流程分为;给自己的活动账户添加额度(购买、兑换、打卡),领取活动(扣减互动账户额度)、执行抽奖策略、抽奖结果落库。本节实现到领取活动部分。 ​ 在本节实现中先给原有实现额度充值的对象,新增加 quota 额度子领域文件夹,迁移类进去以及调整类名。这样一个活动类下就有 quota、armory 两个子领域了,之后本节在增加一个 partake 参与活动的领域。

  1. 创建活动订单创建接口IRaffleActivityPartakeService:createOrder…
  2. 抽象类实现接口AbstractRaffleActivityPartake

  1. 复杂点在于这里的事务操作,月账户、日账户,是随着用户每次参与活动自动创建的。所以根据是否有已经有账户选择更新和创建操作。如果这里出现创建的时候已经存在了数据,则会抛出一个主键冲突异常。也就是一个日账户的用户ID、活动ID、和当日,组装了一个唯一索引,来仿重。
  2. 账户的流程处理完毕后,则是写入订单,这些过程是一个事务下进行的。在过程中如果更新数量不为1则抛出异常。

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

串一下抽奖流程 活动账户额度充值活动参与抽奖执行(策略)中奖记录写入及后续发奖(本节内容)

本节用到task任务表,在写入奖品记录时,同时写入task消息发送任务,当mq发送发送失败时,由任务扫描task消息进行发送

业务流程

  1. 因为抽奖到发奖时,有些奖品不是在抽奖系统,而是各种http接口或者RPC接口来发放,有时会发生网络抖动问题,因此需要作异步解耦:在数据库写入记录时,记录一个状态,等奖品发送完毕后,更新这个状态.
  2. 经过以上步骤后,让用户知道是否中奖,之后点击详情或奖品列表来查看自己中奖结果
  3. 具体操作就是文章开头所说,这里不能用事务解决,事务主要时数据库事务,这里MQ不是数据库事务,用task表,通过异步补偿来进行

功能实现

  1. 在domain 领域层添加奖品领域、任务领域,两个模块。一个处理奖品写入记录,另外处理任务的扫描补偿。
  2. trigger 层一个是任务扫描,另外一个是监听奖品记录后发送的 MQ 消息。本节先接收 MQ 消息,后续再做奖品发放。

奖品领域

  • AwardService 实现IAwardService,用来同时save数据库与saveTASK消息,其中具体操作在仓储层saveUserAwardRecord
    • saveUserAwardRecord中,从聚合对象中取出奖品记录及TASK消息实体,存储后,自动发送一个MQ(在事务外)
    • 其中,task的处理为

任务领域

  • 定义TaskService 实现 ITaskService接口,供上面奖品领域内的发送MQ来使用
    • 定义未完成MQ发送的任务表,之后是发送消息的操作,以及2个更新发送状态的操作。
  • 定义SendMessageTaskJob任务,来定时扫描task表
    • 这里需要对分库分表的情况下,扫描每个库和表下的task表(用dbRouter内的set)使用线程异步处理扫描库表消息发送两个动作

消息监听

  • 这一节只是监听和打印消息,还没有发送奖品的操作,后续会处理奖品。

20.抽奖活动串联

串联各个模块,提供抽奖API接口

业务流程

  1. 开发内容1;串联整个抽奖流程,提供 API
  2. 开发内容2;提供以活动为主导的,预热装配动作
  3. 开发内容3;抽奖策略模块中,校验账户额度。【之前的一个策略规则,需要根据已经抽奖次数进行解锁】
  4. 开发内容4;存放抽奖结果后,更新用户参与活动时的抽奖单状态为已使用。

工程结构

  1. 以api模块开始,定义一个接口类 IRaffleActivityService,实现装配和抽奖接口。另外对之前的 IRaffleService 接口调整名称为 IRaffleStrategyService,这样更好区分。
  2. 进入 RaffleActivityController 会看到对抽奖参与、抽奖策略、奖品服务、装配操作的领域模块调用。

总流程

  • RaffleActivityController 实现IRaffleActivityService内的总装配抽奖
    • 传入活动ID,先进行活动装配,在进行策略装配,活动与策略是1:1绑定使用的。不会一个策略配置到多个活动上。
      • 冲突: 如果活动 A 和活动 B 共用同一个策略 ID。当活动 A 的用户太热情把奖品抽光了,活动 B 的用户进来就会发现也没库存了。这通常不符合业务需求(每个活动应该有自己独立的预算和库存)。
      • 解决: 1:1 绑定保证了库存池是隔离的。
  • 在进行抽奖(不同于刚开始几节的抽奖,里面有19节的写入记录部分)
    • 流程包括;参数校验 参与活动 -(创建参与记录订单) 抽奖策略 (执行抽奖) 存放结果(写入中奖记录) 最后返回抽奖结果即可

1-20阶段抽奖流程总结

无边记

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

纯curd,但需要有业务的理解

在查询活动奖品信息接口加入功能

  • 返回奖品的待解锁次数,由此需求我们需要改
  1. 将原来只传入策略 Id 改为传入的是用户 id 与活动 id(因为需要判断用户抽奖了几次,和每个奖品的次数锁配置信息)
  2. 因为需要展示给前端,在 VO 结构中加入:1️⃣是否解锁 ,2️⃣剩余解锁次数,3️⃣奖品的次数锁配置
  3. 首先,根据活动 id 查询奖品信息表(一个 list 的奖品实体)
  4. 处理得到后的奖品实体列表,过滤出有 rulelock 的 treeids
  5. 根据 treeids,批量查询规则树配置表得出具体的次数
  6. 在根据用户id 查询用户每日抽奖次数表
  7. 最后填充新加的三个数据返回

在抽奖接口中加入功能

  • 需要加入当前时间,为了在 redis 锁库存时判断是否超时
  • 从抽奖接口加入 endTime 字段,传给规则树,再到 logic 的规则树节点,进入redis 扣减逻辑,判断传入是否有时间,如果时间不为 null,则加入过期时间,这样的话,具体库存的 SetNX 锁会自带有过期时间

24.防刷组件

方案 B 已落地,核心能力都加上了,并且已编译通过(mvn -q -DskipTests compile)。

已完成

  1. 接口层防刷 AOP(防连点 + 用户/IP 限流)
  1. 动态黑白灰名单(DB + Redis 热缓存)
  1. 责任链扩展 rule_risk
  1. 风控标签管理 API(运行时可改)
  • 控制器:RiskControlController.java
  • POST /api/v1/risk/control/upsert_user_tag
  • POST /api/v1/risk/control/remove_user_tag?userId=xxx
  • GET /api/v1/risk/control/query_user_tag?userId=xxx
  1. 配置与基础能力补充

数据库变更

如果你是已有库,需要执行同等迁移(重点):

  1. 新建 risk_user_tag 表。
  2. strategy.rule_models 增加 rule_risk(如 rule_blacklist,rule_risk,rule_weight)。
  3. strategy_rule 增加 rule_risk 配置(格式 奖品ID:频率阈值,例 101:3)。

联调注意

  1. draw 请求建议带 X-Device-Id,否则命中 rule_risk 会被降级奖品。
  2. 策略改完后要重新装配:/api/v1/raffle/activity/armory?activityId=...
  3. 这次只做了编译校验,未跑集成测试。

如果你同意,我下一步可以直接补一组压测脚本(连续点击、并发 50)把拦截率和吞吐指标跑出来,正好用于简历量化。