幸运营销汇-问题梳理
思考
因为你的项目是前后端分离的,接口跨域怎么做的?
Web跨域(Cross-Origin Resource Sharing,CORS)是一种安全机制,用于限制一个域下的文档或脚本如何与另一个源的资源进行交互。 这是一个由浏览器强制执行的安全特性,旨在防止恶意网站读取或修改另一个网站的数据,这种攻击通常被称为“跨站点脚本”(Cross-Site Scripting,XSS)。 所以在我的前后端分离项目中,通过配置 @CrossOrigin 注解来解决跨域问题。 开发阶段 Access-Control-Allow-Origin: *(允许所有域访问)、上线阶段 Access-Control-Allow-Origin: 云服务器地址(仅允许指定域名访问后端接口)。
使⽤ DDD 做这个项⽬时,都运⽤了 DDD 哪些知识(领域模型和DDD架构的理解)
DDD 是⼀种软件设计⽅法,软件设计⽅法涵盖了范式、模型、框架、⽅法论等内容,⽽ DDD 的很多规范约定,都是为了提⾼⼯程交付质量。 如⼏个很重要的知识点;框架分层结构、领域、实体、聚合、值对象、依赖倒置等。它所有的⼿段,都希望以⼀个功能逻辑的实现为聚合, 将功能所需的对象、接⼝、逻辑,按照领域划分到⾃⼰的领域内。
像本项目中实现的抽奖的策略,就是⼀个独⽴的领域模型。在这个领域中我需要提供策略的装载、随机数算法计算、抽奖规则调⽤(含责任链和规则树)功能, 这样⼀个领域就像划分好的⼀个独⽴个体,它拥有属于它的对象信息(实体、值对象、聚合),当需要使⽤数据库资源、缓存资源,以及外部接⼝资源的时候,都通过依赖倒置进⾏调⽤。 也就是说,我的领域不做其他模块的引⼊,⽽是领域只负责业务功能实现,所需的所有数据,则有外部接⼝通过依赖倒置提供。
抽奖流程中,哪些被定义为值对象,哪些被定义为实体对象(战术设计)
在 DDD 的规范定义中,值对象通常⽤于描述对象属性的值,不具备唯⼀ID,不影响数据变化。 如;数据库中字段的枚举值、业务流程中属性对象。如抽奖流程中,RuleLimitTypeVO 规则限定⽅式的枚举值对象、还有 RuleTreeVO 规则树对象、RuleTreeNodeVO规则数节点对象等。 ⽽那些实体对象,则具备唯⼀ID,会影响到最后的写库动作。如:TaskEntity 任务实体对象、AwardEntity策略结果实体对象。并且我们可以把⼀些和实体对象相关的功能聚合到对象内,这样的通⽤性会更好,避免所有调⽤⽅都需要⾃⼰编写逻辑。
访问数据层的依赖倒置 是怎样设计的
DDD 中的依赖倒置是⼀个⾮常好的设计,尤其是与 MVC 结构对⽐的时候,MVC 的贫⾎模型结构设计,数据库持久化对象,很容易被当做业务对象使⽤,这样后期⾮常难维护。 但在 DDD 的分层结构⽤,是以 domain 领域实现为核⼼,⼀个 domain 领域下所需的外部服务,都由领域层定义接⼝,让基础层做具体实现。 ⽽数据库持久化操作,定义的 PO 对象,就以这样的⽅式被限定在基础层了,外部是没法引⼊使⽤的,也就天然的防⽌了数据库持久化对象进⼊业务中。
聚合根的设计原则?
以活动⼊⼝的规则引擎举例,事件⻛暴出来的实体对象:树根、⼦叶、连线,⽽聚合根是⼀个整个决策树的描述。对于这块的内容,如果是理论,还有⼀些规则:
- 聚合设计的尽量⼩:如果聚合设计的过⼤,内部还有⼤量的实体和值对象,管理会⽐较复杂,⾼频操作会有并发和数据库锁冲突的问题,导致系统可⽤性降低; 聚合设计的⾜够⼩,也就降低了复杂度,可复⽤性也更⾼,降低了后期重构复杂聚合的成本;
- 聚合应该⾼内聚:封装的是真正的不变的领域对象,内部的实体和值对象按照固定的规则运⾏,实现数据的⼀致性,边界外的任何东⻄都于该聚合⽆关。
- 使⽤最终⼀致性:聚合内部数据强⼀致性,聚合之间数据最终⼀致性,在⼀次事务中最多只修改⼀个聚合的数据状态,如果在⼀次事务中涉及修改多个聚合的状态, 应该使⽤领域事件的⽅式来异步的实现最终⼀致性,实现聚合之间的解耦;
- 在应⽤层实现跨聚合的调⽤(领域编排):实现微服务内部聚合之间的解耦,为了未来以聚合为单位的拆分和组合,应该避免跨聚合的的领域服务调⽤和数据表关联;
你的DDD是怎么分层的? 2.核⼼域的主要职责有哪些? 3.核⼼域这边有哪些实体,哪些聚合? 4.领域层有⼀个原则,就是尽量保证领域层的⼀个纯粹,那你如何保证领域层的⼀个纯粹性? 5.防腐层的职责是什么?
应用层、领域层、接口层、基础层、触发器层、通用信息层。
⼀般我们把主线流程成为核⼼领域,⽤于⽀撑主线流程的算作⽀撑领域或者核⼼⼦域。以抽奖为整个路线看,需要有3个步骤;参与、执⾏、兑现。也就是对应的活动、抽奖、奖品。⽽规则引擎其实没有也能完成抽奖,并且规则引擎也可以适⽤于其他模块下,所以它可以被看做是通⽤域/核⼼⼦领域。
不只是领域层也包括其他层设计都要遵守软件的设计的第⼀原则;康威定律(不只是领域层也包括其他层设计都要遵守软件的设计的第⼀原则;康威定律,分治、抽象和知识,只有把问题拆解的⾜够清晰,才越容易被处理。),分治、抽象和知识,只有把问题拆解的⾜够清晰,才越容易被处理。这包括;
- 遵循单⼀职责原则:确保每个类和⽅法都只关注⼀个特定的领域概念或逻辑,⽽不会处理与领域⽆关的内容。这有助于避免将⾮领域相关的逻辑混⼊到领域层中。
- 封装业务逻辑:确保所有业务逻辑都封装在领域层内部,以确保其他层不会直接访问领域逻辑。这有助于保护领域层的纯粹性,并使其更容易维护和扩展。
- 避免与其他层的交互:确保领域层仅与应⽤程序的其他领域层交互,⽽不与数据访问层或表示层等其他层直接交互。这有助于确保领域层只包含业务逻辑,并使其更易于理解和测试。
是什么场景下使⽤了责任链模式,什么场景使⽤了组合模式,为什么?
在设计完抽奖前、中、后松耦合的结构模型后,对于抽奖前要执⾏哪种抽奖,是单向选择问题。所以 这⾥使⽤了责任链模式,进⾏节点流程判断,从⿊名单、权重,最后到默认,⾛⼀个单独的具体抽奖,所以使⽤责任链更为合适。
之后是进⼊抽奖的中和后,这两部的流程是相对复杂的,需要判断⽤户抽奖了⼏次,对于不同次会限定是否能获得某个奖品,同时还有库存的扣减,如果库存不⾜或者不满⾜n次抽奖得到某个奖品,则会进⾏兜底。 那么这就是⼀个树规则的交叉流程,所以会使⽤了组合模式构建⼀颗规则树,并通过数据库表的动态配置决定在抽奖前完成后,后续的流程要如何进⾏。
接⼝的单⼀职责设计
单⼀职责原则的核⼼思想是,⼀个类应该只有⼀个引起它变化的原因。也就是说⼀个类应该只负责⼀项任务或功能,如果⼀个类承担了过多的职责,那么这个类就会变得复杂,难以维护和扩展。
这样的原则在⼀些需要⻓期使⽤、迭代、维护的功能设计上,是⾮常重要的。我们要尽可能的让⼤营销的抽奖领域领域模块具备独⽴性,所以要使⽤单⼀职责原则。在这个原则约束下,设计了3个接⼝类; 抽奖策略接⼝、奖品信息接⼝、库存处理接⼝(异步扣减等),这样3个接⼝的设计,在将来需要扩展的时候,会⾮常容易。
策略领域和奖品领域为什么要划分开
首先通过用例图梳理四色建模的领域事件,在领域事件脑暴完成,之后就是识别领域⻆⾊和对象,这个过程会显⽽易⻅的发现有抽奖领域、发奖领域。 他们可以作为解耦设计,独⽴使⽤。因为抽奖不⼀定发奖,抽奖可以独⽴提供算法结果,由外部其他系统使⽤。 发奖也可以除了抽奖的发奖,还有积分兑换的发奖等。如果抽奖和发奖合并,那么外部调⽤就会不那么清晰。
看到你简历上说把抽奖划分为抽奖前、中、后,三个动作。请具体结合场景讲解下,为什么这样设计
这个的设计得益于在 Spring/MyBatis 框架源码的学习,在源码中经常会出现对⼀个流程进⾏拆分解耦,流程可扩展的点,如 Spring 是 Bean 对象的拆解,MyBatis 是会话流程的拆解。 所以在设计⼤营销的抽奖模块时,对于需求中的各类功能点:⿊名单抽奖、权重抽奖、默认抽奖、抽奖N次解锁、兜底抽奖等等情况,是可以拆解为抽奖前、中、后,3个⾏为动作的, 基于这样的考虑后,就可以设计出⾮常容易扩展的松耦合结构。
抽奖也是⼀种瞬时峰值很⾼的业务场景,那么对于抽中奖品后的库存扣减是怎么做的?(最关键的技术点)
最初使⽤了redis独占锁,但这样会出现排队问题,导致有库存,但吞吐量不佳。后来设计为颗粒度更细的⽆锁化(乐观锁)设计。
为了避免库存扣减直接更新库表的⾏级锁,⽽导致⼤量的⽤户进⾏等待状态。 所以把数据库表的库存同步到 Redis 缓存中,通过 decr 扣减的⽅式进⾏消费,同时为了确保在临界状态、库存恢复、异常处理等情况下不超卖,⽽对每次扣减后的库存状态进⾏ setnx 加锁兜底,来保证不超卖。—— 这样的设计是颗粒度更⼩的锁⽅案设计,性能接近于⽆锁化。 当库存扣减后小于0时,立即将库存恢复为0,并返回扣减失败;防止因并发扣减导致的库存负数情况。 锁的键名设计: cacheKey + "_" + surplus ,即原始缓存键+扣减后的库存值;这种设计使得每个库存状态都有唯一的锁,确保不同扣减操作不会相互影响; 锁的过期时间设置为活动结束时间+1天,确保活动期间锁有效,活动结束后自动释放。
加setnx锁 setNx 锁的目的是兜底【setnx 在 redisson 是用 trySet 实现,即redissonClient.getBucket(key).trySet】,避免在集群配置、⽹络、 库存恢复、⼈⼯调整等场景,incr/decr的数值可能出现 “假更新”,导致后续扣减时重复操作数据库,最终引发超卖。
例如:因为运营调整的是库存,而不是总数量,所以我们要把每个数字当作是一次发出去的奖品,setNx锁相当于每个已售商品都有"已售"钢印,系统会在真正扣减前会检查“钢印”是否已存在。即使数字被改错,钢印也无法伪造。后续碰到这个数字(重点)先执行 decr,再根据setnx判断的结果作为返回值。 不是把 “库存” 当成一个笼统的 “剩余数量”(比如 “奖品 A 剩 200 个”),而是给每个可售的奖品单位分配一个唯一数字编号(比如奖品 A 总库存 200 时,编号就是 1~200;补货 100 后,编号扩展到 1~300)。 所谓 “扣减库存”,本质是 “占用一个编号”—— 比如用户抽奖得到 1 个奖品 A,就是从 1~300 里选一个编号(比如 50),标记这个编号 “已售”,再同步扣减数据库库存。
1. 案例背景:从“总库存200”到“补货100后总库存300”
- 活动初始:奖品A总库存200,Redis里预热的“总库存缓存”是200(注意:这里预热的是总库存编号范围,不是“剩余库存”——剩余库存是“总库存 - 已售编号数量”)。
- 运营补货:要给奖品A加100个库存,此时总库存变成300,需要重新向Redis“预热总库存缓存”——把总库存编号范围从“1~200”更新为“1~300”(这里必须强调:预热的是“总库存的编号上限”,不是直接改剩余库存的数值)。
2. 关键矛盾:补货后出现“两个库存区间”,锁状态不同
补货后,库存编号分成了两个区间,它们的“锁状态”(是否有钢印)完全不同:
库存区间 | 来源 | 锁状态(是否有“已售钢印”) | 原因 |
---|---|---|---|
0~200 | 初始库存 | 部分有,部分无 | 初始200个编号中,之前已经卖过的(比如50、120号)已经打上钢印,没卖过的(比如150、180号)还没钢印 |
201~300 | 新增补货 | 全部无 | 这100个是刚补的,还没人买过,所以没有任何钢印 |
3. 扣减流程:先decr,再用setNx判断,决定是否扣数据库
当用户下单,需要扣减库存时,系统会针对“要占用的编号”执行以下步骤(核心是“用setNx的结果判断是否真的需要扣数据库”):
- 先执行decr:更新Redis里的“剩余库存数值”(比如剩余库存从150变成149)——这一步是先同步Redis的“表面库存”,让后续请求能看到最新剩余数量。
- 再用setNx判断钢印:针对当前要占用的编号(比如选了25号,属于0~200区间;或选了250号,属于201~300区间),执行
trySet
(打钢印):- 情况1:setNx成功(没钢印)→ 说明这个编号是“真·未售”(要么是0~200里没卖过的,要么是201~300里新增的),需要扣减数据库库存(比如把数据库里奖品A的库存从300改成299)。
- 情况2:setNx失败(有钢印)→ 说明这个编号“之前已经卖过”(比如0~200里的50号,之前卖过已打钢印),不扣数据库库存——避免重复扣减数据库导致超卖。
4. 最终目的:防止超卖
为什么这样能防超卖?核心是解决了“Redis总库存缓存被修改后,重复扣减数据库”的问题:
- 假设没有setNx锁:补货时Redis总库存从100(剩余)改成300,之前已经卖过的50号,因为Redis缓存被覆盖,可能被系统误判为“未售”,再次执行decr并扣数据库——导致50号被卖两次,数据库库存多扣一次,引发超卖。
- 有了setNx锁:不管Redis总库存怎么改,50号的钢印(
stock:lock:A:50
)一直存在,setNx必然失败,系统不会重复扣数据库——确保“一个编号只被卖一次,数据库只被扣一次”,彻底防超卖。
即使不考虑手动补库存的情况,如果集群、主从故障,不加分段锁setNx还是会可能超卖的,所以这里的分段锁setNx不单单是为了补库存的场景而设计。
- 在 redis 集群模式下,decr 请求操作可能发生网络抖动超时返回。这个时候 decr 有可能成功,也有可能失败。 可能是请求超时,也可能是请求完的应答超时。那么 decr 的值可能就不准。【实际使用中10万次,可能会有10万零1和不足10万】, 那么为了这样一个临界状态的可靠性,增加了setNx锁。setNx 是 “存在性判断”,结果只有成功(锁创建,可扣减) 或失败(锁已存在,不可扣减)
- 发生主从切换的时候,如果主节点的 decr 还没同步到从节点,主节点挂了,丢失了部分未同步的数据,decr 的值从 8 变成 6, 如果没有加锁就可能超卖,属于极端情况下的一种兜底策略,有 setNX 锁拦截后,会更加可靠。setNx 的锁是基于 Redis 键值对存在性的,和主从同步无关.
库存扣减失败的两种情况
- 如果可⽤库存⼩于0,那么就把redis奖品库存设置成0
- 如果setnx失败,说明扣减了之前已经扣减过的库存,此时已经扣减过,不会重复扣减
只要库存扣减失败,就不会发送MQ消息异步去更新MySQL奖品的库存(没必要更新,库存扣减都失败了)同时后续活动完成,有定时任务去修复数据库中的奖品库存。
为什么要加setnx锁,直接decr扣减,或者写个lua脚本不可以么
分段锁接近于无锁化,乐观锁。而以前的直接加锁,锁全活动是独占锁,悲观锁。我们要的目的就是接近于不加锁,并且保证性能。 另外 lua 脚本实际压测性能并不好。decr + 锁,尽可能避免超卖。但不加锁,基本等于裸奔,虽然大概率不出问题,但出问题是不可知的。 我们做系统设计,就要考虑它可能存在的风险,尽可能降低,而不是等待它发生。
那如果考虑集群故障,机器挂掉的情况,setNX 不也会报错吗?
setNX 如果失败了,就直接报错返回 "活动库存不足" 即可,也就是可能会导致少卖,但是不会导致超卖。 并且 decr 和 setNX 的 key 不同,decr 的 key 和滑块锁的 key 大概率不在同一节点上,从而双重保证,如果 senNx 的 key 和库存的 key 节点都 down 机了,那这里确实有超卖的可能,不过这个概率可以低到忽略不计。
decr 和 incr 两种扣减方式有什么不同?
二种方式都可以,decr 适合固定库存场景,和 0 对比,incr 适合可以补库存的场景,和库存总量对比。
那为什么要分段,直接对一个 key setNX 不可以吗?
分段锁的本质是 “将全局锁拆分成多个独立的小锁”(比如把库存 A 分成 10 个段,对应stock🔒A:1到stock🔒A:10共 10 个 key),每个分段锁独立控制一部分库存。这种设计的优势直接命中秒杀场景的需求。 分段锁的话,setNX 因为是非独占锁,“多个分段锁之间互不干扰”;所以 key 不存在释放。setNX 的 key 的过期时间可以优化为活动的有效期时间为结束。 如果对 “整个库存” 只设置一个全局锁(比如用stock🔒A作为唯一 key 执行 setNX),会导致 “全局串行化”—— 所有请求都必须竞争这一把锁,同一时间只有一个请求能拿到锁执行扣减,性能很差。 并且独占锁,其实你永远也不好把握释放时间,因为秒杀都是瞬态的,释放的晚了活动用户都走了,释放的早了,流程可能还没处理完。
incr 扣减模式下,如果同一个用户并发进来,那么缓存中的库存就会+并发数,但实际这个用户只会领取到一条数据,所以就要恢复并发数-1的库存数量。这样种情况并不是 redis 不稳定导致的,而是同一用户并发导致的,应该及时去恢复数据啊,不然的话缓存中的库存直接一下就给一个用户并发干没了,然后再去恢复,效率太低了吧?
不需要恢复,还是回到上面,核心是保证不超卖,关于库存恢复,一般这类抽奖都是瞬态的,且 redis 集群非常稳定。 所以很少有需要恢复库存,如果需要恢复库存,那么是把失败的秒杀 incr 对应的值的 key,加入到待消费队列中。 等整体库存消耗后,开始消耗队列库存,等补偿恢复,活动已经基本过去了。所以超卖,快速结束是最好的。 这个一般是基于运营策略配置何种方式恢复库存,可以失败的专门扫描到恢复库存列表用于消耗,也可以不恢复(因为失败概率很低,也允许不超买即可)。
扣减库存的两种技术⽅案
不需要动态添加库存
- decr奖品库存,如果小于0,恢复到0
- setnx扣减掉的奖品库存。
需要动态添加库存的场景(运营操作)
- 奖品库存 incr 和奖品总库存(正常不会并发去更新,只有运营去补库存)⽐ ,如果没超过,扣减就成功
- setnx 库存
幂等性是怎么做的?假如我去抽奖,前端的防重复功能失效了,连点了两下,也就是说同样的一个请求,并发到了你的服务端,怎么保证只发一次奖?如果没有幂等的话,那我是不是直接抓一个 curl 请求,不断调用你的接口,把你的次数都用完,等着发奖就 OK 了?
业务防重ID。抽奖用的是你的个人库存,这部分可以加分布式锁,一个活动,一个sc场景,只能一个抽奖进行中活动,这样组合一个key加分布式锁就可以了。
如果在系统运⾏过程中还要加库存呢,怎么办呢,如何保证redis与数据库的⼀致性
先更新数据库库存,更新成功后,可以发mq,mq可以有补偿。确保⼀定会发mq。之后通过 setnx 给本次mq的流⽔id加锁,和库存 incrby 操作,⼀个 lua 脚本操作。 这样可以保证唯⼀,如果失败了,也可以重试这个时候要注意使用 incr 和总量对比。通过可以对失败的进行记录。之后使用 incr 和总量 + 失败量对比。
补库存操作
- 更新数据库 策略奖品表的总库存;
- 发送MQ消息 (事务消息)
- 后续消费 确保成功即可,出错,⼈⼯接⼊
- Redis设置总库存,幂等性设计,加分布式锁,对于本次操作 (分布式锁如果是zk,也可以),总库存incrBy
假设最开始库存最开始是100 扣了4次 99 98 97 96这⼏个加锁了 redis中的库存值⽬前是96 后来数据库库存更新到了120 那从96(应该有⼀个新的库存变量存96,原来的库存值应该不能变吧,因为后续还要从96开始decr)incr后再加锁,也会消费失败报异常吧,这⼏条加锁都过了之后,到后⾯继续加和总量⽐,如果后⾯的库存全都加完锁了,再从96开始decr吗?
对于动态添加库存的场景,就不能和0比较了。之后锁的方式是incr往上添加值,96、97、98、99,之后再有新的库存,继续加之后和总量比。 在第二阶段,库存锁的key,设置为活动过期时间加延后一天。
库存的扣减是通过 Redis 滑块锁实现的,那么最终同步库是怎么做的,怎么降低对数据库的压⼒的?(MySQL和数据库⼀致性的⽅案)
关于 redis 缓存和数据库表库存数据的流程,设计了异步更新,保持最终⼀致性的设计。在执⾏完库存的扣减操作后(在抽奖中规则树库存节点流程), 发送⼀个扣减完成到 Redis 的异步队列(可以使⽤MQ+延迟消费),使用 Redisson的RDelayedQueue 实现Redis延迟队列,之后通过定时 Schedule Job 来消费队列。这样就可以控制效率速率,降低对数据库的压⼒。 (因为我们不能 Redis 扣减的多快,就直接打到库表上,那样对数据库的压⼒依然很⼤,容易打挂)
过去,阿⾥云商⽤的 RocketMQ ⽀持任意时间延迟消息, 但是开源 4.x 版本的不⽀持. 只有默认 18 个 level 的延迟消息, 分别是 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h。然 ⽽,最新版本5.0中,RocketMQ引⼊了任意时间延迟消息的灵活性。它的实现是基于时间轮算法,并 具有可调整的精度参数。
当库存扣减时,redis中的库存扣减完成了,如果此时系统挂掉了,往mysql同步数据失败了,怎么办? 库存扣减成功后,将中奖信息和task记录写⼊mysql失败,如何处理?
就是崩溃了,都不⾏了。怎么办的过程。
- 服务,降级,挂挡板。暂时不对外,外部的活动⻚导流到其他模块。
- 根据抽奖单记录校准库存,之后重新更新 Redis 库存。
- 全部恢复后,重新对外活动。
项目中的分布式锁使用场景
项目中有两个地方使用了分布式锁,分别来处理库存的抢占竞争和分布式任务调度的抢占。
- 库存的抢占设计的是接近于无锁化的库存编号扣减后加锁,做兜底设计,这样的用户的抢占就是 decr 后的结果加锁,降低竞争。
- 另外一个是项目是分布式架构,有多个任务执行(补偿mq、流转订单状态等),多个服务实例(比如多个订单服务节点)可能同时触发相同的补偿任务(比如补偿发 MQ、流转订单状态),避免发送多了,就会做一个抢占设计。谁先拿到可执行key,那么这个任务就执行。这样确保了,一个任务挂了,也可以有另外任务做处理。
你依赖于缓存,那假设Redis全宕机了怎么办(虽然概率⽐较⼩),虽然有故障恢复,但是故障恢复有可能会数据丢失
Redis 宕机,触发挡板机制,活动暂时下线。数据恢复后在上线活动。但基本我们整个抽奖系统运⾏n年,100% 交付需求,全年⽆此类重⼤事故发⽣。 这也得益于我们所建⽴的 系统开发交付 SOP 标准化流程体系。 (活动先下线,⾛降级;定时任务去扫描抽奖单 更新库存等数据 等数据更新完毕后,再重新上线服务 Redis只是⼀个缓存,我们只要有⼿段保存MySQL数据恢复,那么Redis数据即可恢复) 数据不⼀致后,要先处理数据 等数据恢复了再说。
技术⽅案设计(RocketMQ延时消息+ xxl-job异步更新奖品库存)
- RocketMQ延时消息,延时5S去消费。(甚⾄可以去改配置⽂件⾥的延时等级) 可以改成3S。
- xxl-job 异步更新奖品库存;项目使用
SendMessageTaskJob.java
实现定时任务,通过 @XxlJob 注解标记( SendMessageTaskJob_DB1 和 SendMessageTaskJob_DB2 ) 降低数据库压⼒;如果还是存在奖品库存不⼀致的情况,会有⼀个兜底任务,在活动下线以后,扫描发奖单,去纠正数据库库存。
异步更新奖品库存的完整流程
- 消息生成 :在业务逻辑中生成奖品发放消息,通过 SendAwardMessageEvent 构建消息对象
- 任务存储 :将消息对象封装为 TaskEntity 并存储到数据库中,记录消息状态为待发送
- 定时扫描 :XXL-Job定时任务 SendMessageTaskJob 每5秒执行一次,扫描待发送的消息任务
- 消息发送 :通过 taskService.sendMessage() 方法调用 EventPublisher ,使用RabbitTemplate将消息发送到 send_award 队列
- 消息消费 :消费者通过 @RabbitListener 监听 send_award 队列,接收消息后调用相应的业务方法更新奖品库存
- 状态更新 :根据消息发送结果,更新任务状态为完成或失败
在项⽬中你提到了可以⽀持不同场景的抽奖诉求,⽐如;多少积分后可以抽奖⼀个固定范围的奖品,或者抽奖n次后,才可以中奖某个奖品。这部分你是怎么做的?库表怎么设计的?
权重抽奖:权重策略会以策略ID+权重值组合的方式装配存放到 Redis Map 下。执行抽奖时,查询用户积分;找到用户积分对应的权重范围;在符合的权重范围内抽奖。
次数锁:通过规则树实现,属于规则树的一个节点。查询用户抽奖次数,大于规则限定值放行,小于拦截走兜底奖励。
配置了抽奖策略规则表。
量化规则引擎是⼀个组件,如果有⼀个新的业务进来,如何复⽤? 它的复⽤性体现在哪?能否⽀持⻛控可A/Btest需求?
量化规则引擎的组件设计,本身就是⼀个独⽴的领域服务。但什么最开始没有给按照⼀个单独的系统它拆成独⽴的微服务呢? 因为我们⽬前的实际业务场景体量还没有那么⼤,不太适合扩⼤维护成本,但⼜考虑将来可能会有其他业务也需要同类的模块,所以以独⽴的领域进⾏设计, 哪怕以后真的有更⼤的场景需要使⽤,也能更加⽅便的拆分出去独⽴部署。
它的复⽤性体现在,它是将同类业务场景中的共性需求,凝练成通⽤的业务组件,⽽不是把业务逻辑和功能性服务捆绑,所以在量化规则的库表中配置上相应的渠道和要决策的⼦节点, 再通过⼦节点的决策树配置,就可以被新的业务场景使⽤。因为它是规则引擎结构设计,所以灵活性很好,复⽤性⾼。
A/B Test 本身就可以作为决策树中的节点果实来配置,对AB⽤户发放不同的策略结果。所以结合⻛控提供的数据,作为⼀个逻辑节点使⽤也是可以的。并且换个节点可以配置到不同的决策树中。
另外决策树也可以进⾏连接,也就是扩展决策树的果实类型,不是发放结果,⽽是发放下⼀个决策树的ID,那么这样还可以把⻛控作为⼀个单独的共⽤决策树使⽤。其他需要使⽤⻛控模型的决策树,配置上即可。
扣库存的时候,如果库存在缓存扣成功了,但是消息队列没发送成功怎么办?
核心原则就是保持不超卖,如果发送MQ失败了,那么有补充校验的任务系统会扫描抽奖时所产⽣的订单状态。补发MQ消息。(BCP巡检服务)
规则引擎的设计⽬的
- 这是一个基于降低重复编码和提高可维护性,并需要符合当前项目诉求,同时不过度的设计和减少运维成本的前提下,在技术调研后所做的微型规则引擎设计实现。
- 主要作用就是解决抽奖业务场景下对个性化运营诉求的处理,如:人群标签、交易记录、抽奖资格等规则的可配置化的交叉使用。
- 规则引擎的设计是⼀个⼆叉树判断,实现⼿段运⽤到了组合模式、⼯⼚模式等。并为了便于维护和使⽤,进⾏了库表对⼆叉树的抽象设计,树根、节点、⼦叶,映射为⼆叉树编码的相关属性信息。 同时,也可以基于这样的库表做前端⻚⾯的托拉拽配置操作,降低运营成本。
- 其实动态的规则引擎配置,其实放⼤了看就是 BPMN + Drools + Groovy,的⼀个低代码实现框架。以上就是我在做规则引擎设计的一些思考、调研和落地。
抽奖算法如何提供O(1)时间复杂度,提⾼抽奖效率
在⼤营销系统中,运营⼈员配置好抽奖活动后,开始上线对外后,会进⾏数据的预热数据。这个预热的过程会把活动信息、策略信息、库存信息都存储到 Redis ⾥进⾏使⽤。 ⽽抽奖的策略就是记录了⼀个策略下N个奖品的概率,将概率转换为对应的整数数量,以Map的形式写⼊到缓存中。那么在抽奖的时候就按照整数数量⽣成随机数来抽奖。这样⽤空间换时间的效率是⾮常⾼的。
并且通过乱序操作,即使知道概率分布,由于乱序操作,外部也无法预测具体哪个随机数会对应哪个奖品。
如何对所有分布式节点的应⽤,活动信息本地内存更新?
通常我们会有诉求在不重启系统的时候,就要动态变更所有分布式应⽤节点中某个属性的值,如开关、缓存、调试⽇志开启/关闭、熔断、限流、或者抽奖⿊名单以及概率等。这些东⻄通常不是 Redis 存储, ⽽是应⽤中具体字段的属性值,这样效率更⾼。通过使用DCC动态配置,即使⽤到类似于 Zookeeper 组件的临时节点监听,动态变更字段值。
基于ZooKeeper在数据一致性、树形结构、节点监听机制和分布式协调能力等方面的优势,这些特性使其更适合作为配置中心使用。 同时,项目通过组件职责分离,将Redis用于缓存和高频数据处理,ZooKeeper用于配置管理,Nacos用于服务注册发现,形成了一个功能完备、各负其责的微服务架构支撑体系。
如果把redis中 滑块锁过期时间设置为活动过期时间的时候,如果活动时间很⻓导致滑块锁过多怎么解决
可以考虑给活动库存的锁的key上年⽉⽇,每个⽇的key,明天就重新从新的key开始了。缩短了活动时间,之后这样就⽐较好较短时间存储了。
Redis集群⽅式⽤Cluster,分散到不同的节点上。
在抽奖前,需要先创建一个「抽奖订单」(创建订单中可能要扣减账户的次数余额),然后才能真正执行抽奖行为。「抽奖订单」的设计,相对于「直接扣减账户中的抽奖次数余额」,能够解决什么实际问题?
- 抽奖单是一种行为记录的流水,有了抽奖单就可以做幂等重试。在后续的流程失败后,抽奖单则不会被消费,用户可以重新使用抽奖单抽奖。如果是系统随机发奖,还可以系统基于失败的抽奖单自己做重试补偿。这种思想来自于商城下单,有了订单之后才有订单的支付。
- 订单的存在可以让流程有了暂停和继续的操作。如果只是直接扣减账户抽奖次数余额,就只会得到执⾏结果,那么都是即时状态,过后⽆法追查校准。
- 适合写简历的,不只是抽奖,在做授信操作的时候,也会记录授信单。记录用户的发起时间和动作,以及相关的数据。如果什么都不做,就只是执行结果,那么就都是即时状态了,过后也没法追查。
- 查询未被使⽤的活动参与订单记录
- 额度账户过滤&返回账户构建对象
- 构建订单
- 填充抽奖单实体对象
- 保存聚合对象 - ⼀个领域内的⼀个聚合是⼀个事务操作
多个用户同时进行权重抽奖,如何保证这些用户都抽到奖
凡是能达到权重范围的⽤户,是运营提前做了预算库存的,所以⼀定是会给发奖的。
在抽奖中后环节时,若此时对库存进⾏了修改,如何去更新缓存和数据库的数据?
⼀般来说,不太允许更新已经发布的活动的库存,容易造成客诉。所有的运营⼿段,库存的设置,都是提前申请审批的,不是随意加的。如果要加,可以先更新数据库,更新成功后,添加缓存库存即可。
对sku下单时,扣减sku库存是先扣redis缓存 然后发MQ消息,通过⼀个定时任务缓慢更新数据库保证数据最终⼀致性。如果redis缓存扣减成功之后,redis就挂了,后⾯的MQ消息也没有发出去,那么重启redis之后 ⼜从DB中重新导⼊数据,这样就导致了数据不⼀致,可能出现超卖的情况 怎么解决?
使⽤setnx做了兜底操作,就算获取了旧的库存数据,但之前的锁都在redis⾥,可以防⽌超卖。 ⼀般真挂了,活动就加挡板了,不会对外了。等恢复了,要校准数据,之后才能对外的。 另外 redis 挂了,集群都挂了,是重⼤事故,各个服务也都不可⽤了。这个时候不会对研发下⼿, 要⼲的是redis运维。
关于奖品库存扣减,假设库存是三个,若给⼀个⽤户发了5个奖品,如何处理?
库存扣减成功才会准许发放相应的奖品,库存扣减失败的奖品(会有两个),⾛兜底奖励。每⼀个库存的扣减都有粒度很细的分布式锁兜底(setnx,decr操作)
如果⽤户签到两次,如何防⽌获得多次抽奖充值?
根据当前⽇期作为业务字段,拼接上⽤户名,返利类型,构成唯⼀ID,保证⽤户⼀天只能签到⼀次。 (mysql底层是如何实现唯⼀键约束的?通过在需要唯⼀性约束的列上创建唯⼀索引来实现的。唯⼀索引使⽤B+树等数据结构,它可以快速检索并保证索引列的唯⼀性。)
incr防⽌超卖和decr防⽌超卖代码上如何实现,在业务上来讲有什么差别
incr防⽌超卖的实现: 从0开始累加,直到达到缓存中记录的库存个数,就不再卖出商品。从库存开始累加-1,和decr差不多。使⽤场景:动态添加库存
decr防⽌超卖的实现: 扣减库存,当库存扣减为-1,则说明库存不⾜。使⽤场景:库存活动开始时就已固定好,后续很难或者⽆法再改变
在高并发场景下,使用旁路缓存策略,也就是在数据有更新的时候,先更新数据库,再删除缓存,如果删除缓存后,有高并发的请求,不久缓存击穿了么,在不使用分布式锁的前提下,还有什么解决办法么
亿级流量,如何保证Redis与MySQL的一致性?操作失败 如何设计 补偿?
60分(基本): 先更新DB再删除缓存(黄金组合)+延迟双删方案。
80分(优秀): 使用异步删除策略(内存队列→消息队列→binlog+MQ三者区别对比),清晰指出延迟范围及场景。
120分(顶级): 提出「三级补偿机制」(延迟队列+消息队列+定时任务兜底),解决Redis删除失败的容错及抖动问题。 配合灰度上线和顺序消费方案,确保高并发场景的最终一致性与可靠性。
将热门商品的库存扣减请求进行合并,减少磁盘IO开销;为了数据一致需要插入库存流水校准数量,将二者放在一个事务内部,先执行Insert再update减少行锁持有时间,在表中增加版本号防止旧数据覆盖新数据,用binglog异步更新(分区路由保证顺序),设计时间窗口降低缓存频率,对库存为0的特殊场景实时刷新。用集群代替单机,增加线程池数量
为什么要自研市面不是有吗
市面的路由组件比如 shardingsphere 但过于庞大,还需要随着版本做一些升级。而我们需要更少的维护成本。结合自身的业务需求,我们的路由组件可以分库分表、自定义路由协议,扫描指定库表数据等各类方式。研发扩展性好,简单易用。自研的组件更好的控制了安全问题,不会因为一些额外引入的jar包,造成安全风险。
当然,我们的组件主要是为了更好的适应目前系统的诉求,所以使用自研的方式处理。就像shardingsphere 的市场占有率也不是 100% ,那么肯定还有很多公司在自研,甚至各个大厂也都自研一整套分布式服务,来让自己的系统更稳定的运行。分库分表基本是单表200万才分。
👉降低维护成本避免版本依赖,提供更灵活的业务定制能力,以及更好的安全管控。这种轻量级方案既满足了单表200万数据的分片需求,又保持了代码的简洁性和可控性
你们为什么分库分表?
虽然分库分表基本是单表200万才分。但不能为了等到系统到了200万数据,才拆。那么⼯作量会⾮常⼤。
如果等到数据量达到瓶颈才开始分库分表改造工作量会非常大,影响线上业务。
因为有成熟方案,所以前期就分库分表了。为了节省服务器空间,前期在同一台服务器上通过虚拟机创建多个数据库实例,这样既不过多占用服务器资源,也方便后续数据量上来了好拆分。
同时,抽奖系统是瞬时峰值较高的系统,历史数据不一定多。所以我们希望用户可以快速检索个人数据做最优响应。因为大家都知道,抽奖这东西,push发完,基本就1~3分钟结束,10分钟人都没了。这也是做了分库分表的理由。
路由流程
基于AOP实现分库分表路由流程:通过拦截数据库操作,对用户ID进行Hash路由计算,将路由结果存储在ThreadLocal实现线程内共享,并在SQL执行前动态切换目标库表,实现了对业务代码零侵入的数据分片机制
路由算法
三种散列算法对比,待补:整数取模/乘法/斐波那契
- 除法散列:通过取 K 除以 M 的余数,将关键字 K 映射到 M 个槽中的某一个位置上,即散列函数为:h(K) = K mod M 表格大小通常是 2 的幂。
- 乘法散列:
- 用关键字k乘上常数A(0<A<1),并去除kA的小数部分
- 用m乘以这个值,再取结果的底floor 公式: h(K)=Math.floor[m(aK mod 1)]
- 斐波那契散列是一种特殊形式的乘法散列,只不过它的乘法因子选择的是一个黄金分割比例值。
在算法选项过程中我们对比了三种主流散列算法进行了雪崩测试:除法散列满足50%数据变化的表是斐波那契散列的3倍;乘法散列通过乘法和位移代替除法,扩容下数据迁移量不稳定。
雪崩测试: ○准备10万个单词用作样本数据。 ○对比测试除法散列、乘法散列、斐波那契散列。 ○基于条件1、2,对数据通过不同的散列算法分两次路由到8库32表和16库32表中,验证每个区间内数据的变化数量,是否在50%左右。 ○准备一个 excel 表,来做数据的统计计算。
严格雪崩标准( SAC ) ,在密码学中,雪崩效应是密码算法的理想属性,通常是分组密码和密码散列函数,其中如果输入发生轻微变化(例如,翻转单个位),输出会发生显着变化。 简单来说,当我们对数据库从8库32表扩容到16库32表的时候,每一个表中的数据总量都应该以50%的数量进行减少。这样才是合理的。
分库分表后的数据怎么提供汇总、聚合的查询呢?
使用阿⾥的 canal 组件,基于 mysql 的 binlog ⽇志,把⾃⼰伪装为⼀个从数据库,通过 dump 交换完成数据的接收和处理。最终把数据同步到 Elasticsearch 等⽂件服务中在提供聚合查询。 对于需要实时的查询以及数据的处理,还可以⽤到 Flink ⽅式进⾏流式计算。
为什么不根据时间来分库分表,⽽是根据⼈员ID来分库分表,后期数据量⼤怎么扩容?
- 根据时间做,需要按照时间分⽚进⾏处理,⽐较适合处理冷数据。
- ⽬前抽奖系统为实时业务数据,一个用户的请求数据可能分布在多个时间分片(如用户去年和今年的数据在不同库表),如果基于时间分⽚处理带来的问题是,当⼀个⽤户请求进来做业务,很难定位这个⽤户所属的库表,也包括⼀些基于”⼈维度“的事务处理。
- ⼀般基于⽤户ID所做的分库分表,会根据业务体量的发展设定⼀个2-3年增量的分库分表规模。⽐如每个库32张表,分4个库。(PS:这⾥不要说就4个表、8个表,那你分表还有个P⽤)
- 为了处理因分库分表所带来的服务器资源占⽤数量,会采⽤虚拟机操作,⼀般⼀台标C的物理机,进⾏1虚5的配置进⾏处理, 这样在后续第⼀波业务爆发增⻓的时候,可以把虚拟机换为物理机,这个时候是不需要迁移数据的,成本较低。
- 当后期数据量较⼤的时候,采⽤binlog+canal同步数据的⽅式进⾏扩容,并逐步把新数据写⼊到新库中,当两⽅数据库完全同步后,开始重启实例进⾏切换。 当然这个成本和⻛险是会有的,但随着我们的整个流程完整度的提⾼,在公司内部已经成为标准SOA作业。(分库分表扩容)
- 当然我们还有⼀些额外的考虑,怎么做到⾃动扩展,针对这⽅⾯我们⽬前开始尝试给⽤户⽣成的ID中写⼊库表信息,这个信息是加密的, 随着⽤户量的增多⾃动扩展数据库表,随着⽤户体量的增加,挂载后新的⽤户就可以注册到新库表了。 另外,我们还思考,把数据库的路由前置到RPC层,解决数据库笛卡尔积交叉链接的问题,当然这是另外⼀个场景了,这⾥就不做过多的扩展了。
分库分表怎么根据⾮分⽚键查询?
分库分表,uid做hash⽔平分表,若需要通过其他字段实时查询,例username,则可以有以下解决思路
⽤户端(实时):
- 可以做username到uid的映射,通过建⽴索引表,username定位到uid,可以将这份映射存⼊缓存。
- ⽣成id,例如:uid=f(username),缺点有id冲突。
- 基因法,通过username抽取“基因”融⼊uid中
运营端:
- 前后台分离架构,web/service/db分离,避免后台的查询影响前台
- 数据冗余,宽表
- 搜索引擎
⽣产者可能多次发送同⼀个MQ,怎么保证奖品不会超发?
这是⼀个幂等的设计处理,MQ 的消息是必须含带具有唯⼀标识的业务ID的。⽐如订单ID、奖品ID、⽀付单ID、交易单ID、贷款单ID等等。本项目中使用 RandomStringUtils.randomNumeric(11) 生成11位随机数字。作为唯一ID 接收MQ的系统,通过唯⼀ID业务,更新或者写库的时候可以保证幂等性。这样也就不会产⽣超发的可能。
分库分表怎么让任务扫描到指定的库表
分库分表以后,需要扫描每个库表中的任务表,则需要⼿动设定具体要扫描的库和表。如果分库分表的数量⽐较多,可以⽤不同的任务配置扫描不同的库表⽅式来部署,这样可以提⾼扫描效率。
通过自定义的多数据源路由组件 db-router-spring-boot-starter 实现了任务对指定库表的精确扫描。
如果在多机部署的情况下,是不是每台机器都会有这个定时任务,如果它们都捞到同⼀条发送失败的消息,会不会导致消息的重复发送?怎么避免?
⼀个任务就是要有多机备份,避免⼀个挂了,就没有⼈执⾏了。对消息做幂等性操作,对每条消息做唯一标识,放到redis里。之后这⾥的⽅案是加锁;
- 设计⼀个抢占锁,多个任务抢占同⼀个锁,谁抢占到了,谁可以执⾏。
- 如果抢占的执⾏失败了,删掉锁,重新执⾏。
- 如果删锁失败,对于是谁抢占的,谁可以做重⼊锁,继续执⾏。
- 锁有失效时间,如果抢占到的⾃⼰挂了,等待锁失效后,重新轮候抢占。
首先通过任务分⽚屏蔽这种情况;如果还是遇到这种情况,进行加锁再执⾏。(锁是有超时时间的,不会太短)
为什么不直接使⽤内存作为缓存⽽引⼊Redis
- 在营销复杂计算场景中,为了提⾼性能确实会⽤本地内存 + redis 缓存的⽅案。但本地内存会有⼀个问题,就是分布式架构下, 在初始和变更数据,需要所有环境保持统⼀数据,并需要配有动态配置中⼼来通知更新。需要⼀定的维护成本。
- ⼀般做这类的系统,以及配置类的,是会存redis⼀份,之后在拉取到本地内存⼀份。本地内存与 redis 中数据进⾏版本校验和定期更新。 这个是实际场景⽅案,所有⾯试官会追着你这样问。下次可以讲两套⽅案是⼀起使⽤的【不过最好最好案例,流程先跑通,避免解释不清。】
- 对于2⽅案,结合 redis 也有发布订阅能⼒,可以发完成本地内存更新,这个在《动态线程池组件》中使⽤了。
⽤redis的setnx实现分布锁,为什么⽤set x可以实现分布式锁,setnx不是单节点的吗?
- setnx是原⼦操作,确保只有⼀个客户端能成功获取锁。其余客户端全部失败。(什么是原⼦性?Redis的原⼦性操作指的是在执⾏过程中不会被其他操作打断, 即操作要么完全执⾏成功,要么完全不执⾏,从⽽保证数据的⼀致性和可靠性。)
- 超时机制,set nx 可以设置额外参数 表示超时时间,保证出现异常情况下 锁依然能够释放。
- 避免竞态条件 多个客户端同时尝试获取锁时,确保只有⼀个客户端能够成功获取锁,保证了锁的互斥性.
- redis是分布式缓存,对分布式服务都可⻅
要实时显示一个用户参加了多少次活动,以及活动有哪些用户参加,如何实现?
👉业务监控 grafana+普罗米修斯监控,通过JMeter进行压测
分库分表组件如果是范围查询或者in查询涉及到数据在多个不同库表就完成不了,在公司中这种情况都是怎么处理的
- 数据同步机制: 利用Canal模拟MySQL slave节点,实时监听master的binlog日志 将分散在多个分库分表的数据实时同步到Elasticsearch 保证了数据的实时性和一致性
- 具体实现: 在应用层配置双数据源:MySQL负责写入,Elasticsearch负责复杂查询 通过canal-adapter配置需要同步的分库分表信息 在Elasticsearch中建立统一索引,支持复杂查询场景
方案优势:
- 零侵入:不需要修改现有业务代码
- 高性能:Elasticsearch天然支持复杂查询和海量数据检索
- 实时性:基于binlog的增量同步,数据延迟极小
工厂在责任链中如何运用?
根据唯一编号,eg. strategyId 创建对应责任链
责任链如何解决节点互斥?
- 本节传递数据依赖于next().logic(userId, strategyId),每个节点入参都是独立的, 而不是前一个节点的输出直接作为下一个节点的输入;
- 由于逻辑互斥,符合条件直接返回, 只需要将userId和strategyId传递给下一个节点即可;
大营销项目的Domain领域层是根据什么进行划分的?
目的领域层根据 核心业务领域 进行了明确的垂直划分,主要包含以下几个独立领域:
- 活动领域(activity) :负责抽奖活动的参与、账户额度管理、SKU库存等核心业务
- 奖品领域(award) :处理奖品发放、中奖记录、奖品配置等相关功能
- 积分领域(credit) :管理用户积分账户、积分交易、积分订单等业务
- 返利领域(rebate) :实现用户行为返利、返利订单处理等逻辑
- 策略领域(strategy) :负责抽奖策略定义、规则处理、概率计算等核心算法
- 任务领域(task) :管理异步任务、消息发送等基础设施
这种划分方式确保了每个领域聚焦于自身的核心业务,高内聚、低耦合。
每个领域内部按照DDD的标准分层结构进行组织,体现了清晰的领域模型设计:
- 模型层(model) :包含领域的核心概念
- 聚合根(aggregate) :如 TradeAggregate 、 UserAwardRecordAggregate 等,代表一个完整的业务事务边界
- 实体(entity) :如 ActivityOrderEntity 、 UserAwardRecordEntity 等,具有唯一标识的业务对象
- 值对象(valobj) :如 AwardStateVO 等,用于描述属性状态,没有唯一标识
- 仓储层(repository) :定义领域持久化抽象接口,如 IActivityRepository 、 IStrategyRepository 等,将领域模型与底层存储解耦
- 服务层(service) :实现领域业务逻辑,包含领域服务和应用服务
- 事件层(event) :处理领域事件,如 ActivitySkuStockZeroMessageEvent 等,实现领域间通信
项目领域层的划分体现了明确的职责分离原则 :
- 活动领域 :专注于用户参与活动的流程管理,包括额度控制、库存扣减
- 策略领域 :独立负责抽奖算法和规则处理,采用责任链+决策树模式实现复杂规则
- 奖品领域 :负责奖品的发放流程,支持不同类型奖品的分发策略
这种设计使得各领域可以独立演化,同时通过仓储接口和领域事件进行协作。
接口的幂等性是如何保证的?
核心的幂等主要是数据库唯一索引,之后其他的手段,缓存,MQ等,都是防护。
如果先扣缓存里的库存,再更新数据库,那万一缓存清除了或者延迟队列/定时任务出问题了,那怎么保证数据库的库存正确扣减?
只要是缓存库存扣减了,并且是有效的。那么就写了订单记录。只要有订单记录就可以根据订单记录校准库存。
如果现在就一个Redis,数据库,让你重新设计扣库存怎么设计?能不能先更数据库,再让缓存的数据跟数据库的一致?
先更新库,用库抗会影响性能。不适合做先更新库操作,且不能用数据库抗这样的场景。
每次扣减一个库存用setnx加锁,那会不会影响性能?
锁粒度很小,主要用于防止超卖的兜底保护,而非主要的并发控制手段。
当Redis 中对应奖品库存为 0 时,使用 MQ + 定时任务方式同步Redis 库存到 MySQL。假设运营侧需要一个统计报表的大盘查看对应奖品库存,要求库存数据近实时展示。假设还是使用 MQ + 定时任务的方式实现这个功能。问题是并发场景下 MQ 的 partition 不会设置为单一的(防止热点奖品数据堆积到一个 partition,造成数库存同步延迟),而是多个 partition 配合处理数据,如何保证 partition 不会堆积,同时要保证多个 partition 之间数据有序性,这里如何设计?预先不清楚哪些奖品是热点数据。
- 在MQ中,可以使用某种基于奖品ID基于奖品ID的Hash取模的分区策略。这样,同一个奖品的库存更新会被路由到同一个Partition中。
- 在每个Partition内,消息是有序的。
- 对于可能堆积的问题,这个一般都是要做分布式mq消费的,很难堆积的。除非配置了很少的机器。而且实际中,也不会非得为了有序性或者统计报表做这样的强设计,而是报表可以基于很多数据源做实现,Flink 实时统计。
在抽奖前,需要先创建一个「抽奖订单」(创建订单中可能要扣减账户的次数余额),然后才能真正执行抽奖行为。「抽奖订单」的设计,相对于「直接扣减账户中的抽奖次数余额」,能够解决什么实际问题?
- 抽奖单是一种行为记录的流水,有了抽奖单就可以做幂等重试。在后续的流程失败后,抽奖单则不会被消费,用户可以重新使用抽奖单抽奖。如果是系统随机发奖,还可以系统基于失败的抽奖单自己做重试补偿。这种思想来自于商城下单,有了订单之后才有订单的支付。订单的存在可以让流程有了暂停和继续的操作。
- 适合写简历的,不只是抽奖,在做授信操作的时候,也会记录授信单。记录用户的发起时间和动作,以及相关的数据。如果什么都不做,就只是执行结果,那么就都是即时状态了,过后也没法追查。
对于分布式过期锁,如果在消费消息未结束前锁过期了,还是会出现MQ重复消费的问题?如何处理呢
在一个遥远的分布式王国,消息队列(MQ)就是传递消息的信鸽。可是,消息队列有个坏毛病,它有时候会重复发送消息,好像喝多了还没回过神来,看到就发!为了防止大家吃“回锅肉”,国王提出了“三步锁神法”(一锁二判三更新),这方法在王国里传得沸沸扬扬,大家都开始尝试。 一锁:先下手为强 一天,国王宣布比赛开始,每个消息处理者(消费者)都急匆匆地抢着锁,谁抢到锁,谁就有权处理这条消息。可这锁有个问题,它是个“老古董”,有时间限制的——过期了就不认账!所以,大家抢到锁后,必须争分夺秒地处理消息,生怕被其他人抢走风头。 二判:双重认证 有个聪明的小伙子,叫阿判,他在比赛中想出了个妙招:“万一我还没处理完,锁就过期了咋办?”于是,他发明了一个“双重认证”系统。阿判规定,当他处理完消息后,再次确认锁是否还有效。如果有效,万事OK;如果无效,他得做点儿手脚,让别人知道他还在继续处理这条消息,不能抢他的“风头”。 三更新:后续巩固 然而,阿判还是担心锁到期的问题。于是,他又动了动脑筋,决定每隔一段时间就更新一下锁的有效期。这样,锁就像是不断补充燃料的火箭,只要他在处理消息,锁的有效期就会不断延长,直到消息处理完毕。 幽默点睛: 结果,阿判的妙招在分布式王国里大获全胜,大家都称他为“锁神”。他的秘诀?“你得让锁跟你一起‘加班’!” 这样,即使锁本来会过期,但因为你的“加班”努力,它的寿命也跟着延长,MQ的重复消费问题再也不复存在。 所以,记住阿判的“三步锁神法”:抢到锁(一锁),确认有效性(二判),不断更新(三更新),这样就不会让MQ像个“吃了炫迈”的信鸽,重复地把消息传来传去啦!
如果让你来评估项⽬的QPS的话,你会⽤什么⽅式来评估?(补充:不要做压测,就通过现在的设计以及硬件配置推导QPS应该达到什么⽔准?)
⾸先需要根据业务提供的推⼴规模、渠道、⼈数,来评估。- 这⾥前⾯按照28法则(80%的流量 在20%的时间内产⽣)评估过。 - 假如系统有1000万⽤户, 那么每天来点击⻚⾯的占⽐20%,也就是200万⽤户访问。
- 假设平均每个⽤户点击50次,那么总共有1亿的PV
- ⼀天24个⼩时,平均活跃时间段算在5个⼩时内【24*20%】,那么5个⼩时预计有8000万点击,也就是平均每秒4500个请求。
- 4500是⼀个均值,按照电商类峰值的话,⼀般是3~4倍均值量,也就是5个⼩时每秒18000个请求【QPS=1.8万】
项目的安全性如何保证
- 鉴权登录「jwt、spring security」,⼤营销是⼀个微服务,登录是其他系统。⽐如 openai 是有登录的,通过 微信公众号鉴权。可以参考这个;⽹站提示⽤公众号扫码登录,他们是怎么实现的? | ⼩傅哥 bugstack ⾍洞栈
- 数据加密,https、敏感数据 aes 加密【如,openai 的 apikey 可以加密】
- 防⽌SQL 注⼊、⼊参数据校验 @Valid
- cors 跨域配置,http请求安全头设置,X-Content-Type-Options、X-Frame-Options、X-XSSProtection
- 系统监控;普罗⽶修斯、在星球的基础教程⾥有,openai 项⽬也对接了监控,后续⼤营销也会对接。还有咱 们星球新开的项⽬,透视业务流程监控。
- 依赖漏洞扫描,需要⼀些⼯具,公司⾥是提交代码会被扫描。也可以⾃⼰搜索⼯具扫描 owasp dependencycheck
- 代码审计,idea 插件类的,检查代码问题,在星球 idea plugin 教程⾥有
你在做项⽬中,什么问题难住你的时间最⻓,为什么?
对⼤营销抽奖模型流程的设计和库表设计,最为耗时,因为我需要不断的在思考如何拆解出⼀个好扩展的松耦合结构,同时拆解后,还要保证搜耦合下的⾼内聚。
关于抽奖系统是不是⾼并发,与传统秒杀系统的区别与联系?
- 春晚红包正是抽奖的形式,拼多多⼀进⻚⾯就有各种【转转转】来获得⼀个券,⽀付完成⼜⼀个转转转。直接领券远没有抽奖来的刺激,即使是发券,也是⽤抽奖⽅式更多。 (故抽奖系统也是⾼并发场景)
- 每秒的请求量如果超过1000tps,打到库上资源竞争,都会出现⼤量的数据库连接等待。⼀般⼀个应⽤分配的数据库连接池也就那么20来个。如果都打到库上,都能把库打挂。
遇到过哪些运行时异常,怎么排查解决的
如刚开始项目开发引入脚手架以外的组件,进行调试的时候,因为Jar版本不同。出现过编译通过,调用的时候方法不存在。 通过 Maven Helper 插件,检查到有其他组件多引入了相同Jar包。另外还有一些如开发调试中发现的空指针问题,如查后需要增加空对象判断。 此外其他一些更多是功能流程实现细节上,如项目中的规则树节点判断流程问题,抛出一些自定义的异常。这些通过在方法上断点调试逐步的解决。
应用刚启动完成,外部调用过程中发现操作数据库连接池不足,超时断开,过一会又好了?是什么问题?
因为它是刚开始有问题,过一会又好了,所以很有可能是池化的连接数配置的最小值与最大值不是一个,这样应用就会先初始一个最小范围的连接数, 随着调用没了在初始化到最大连接数。所以一般我们会把最小连接数和最大连接数配置为一个,避免使用的时候还需要初始化。 因为初始化连接也是需要花费时间的。【再有注意配置链接的超时时间,不要太小,也不要太大】
Redis 库存耗尽时,你为什么要清空延迟队列?答:
- 当库存耗尽时,所有后续的库存扣减应该被拒绝
- 延迟队列中可能存在尚未处理的库存更新任务,但此时库存已经为0
- 清空队列可以避免这些任务执行后导致数据库库存出现负数
如果延迟队列里还有合法消息,会不会丢单?
- 理论上会存在丢单风险,因为清空队列会丢弃合法的库存更新请求
- 但从业务逻辑来看,当库存耗尽时,系统已经不再接受新的抽奖请求
- 延迟队列中的任务通常是已经完成的扣减操作的记录,清空队列是为了避免重复更新
MQ 消息堆积会怎么影响抽奖流程?如何解决?
- 增加消费者实例,提高消费能力
- 使用线程池并行处理消息(项目中已使用 ThreadPoolExecutor )
- 优化消息处理逻辑,减少处理时间
- 实现消息重试机制,避免处理失败导致的堆积
- 考虑使用消息死信队列,处理无法消费的消息
面试题
【美团】 领域模型怎么设计,抽奖过程怎么样,DDD四层架构和职责,以及为什么要这么设计?少卖和超卖。
领域模型就是头脑风暴,罗列事件和行为,根据实体来划分领域,简单的说就是根据业务流程来划分的,这个过程包括:活动与、抽奖域、积分域、兑换域。也就是用户根据某种记录,发放计算,兑换活动参与资格,完成抽奖获得奖品。
超卖不会出现,有个保证的点,⼀个是 decr 值的限制,另外⼀个是对每个key加锁的兜底设计。确保了不会超卖。 少卖是有可能的,核⼼原因是因为 decr 操作和数据操作不是⼀个事务,有可能库存扣减完了,但最终操作库失败了。 那么这个库存就丢失了,可能会少卖。但⼀般并不会对少卖做过多的流程,如果想管理, 也可以把少卖的库存异常,加⼊单独的 redis 队列来重新消费就可以了。
【淘天】对于数据库和redis的一致性怎么解决
- 通过decr更新redis,写入延迟队列,通过定时任务,趋势更新数据库,保证最终一致性
- 当decr库存值小号为0时,通过发送MQ消息,更新最终的库存量(可能会导致少卖的情况)。
- 活动到期后,任务扫描活动产生订单量,校准库存。
什么情况下使用DDD架构,什么情况下使用MVC架构
DDD是软件设计方法,更适合复杂的架构,如果指的是设计方法中的工程模型架构,那么DDD是要优于MVC架构的,因为MVC的约束相对较低,个人开发还好, 如果是多人协作,就会出现腐化严重的问题。
设计模式带来了什么好处
设计模式可以让工程设计的迭代性、扩展性、维护性都更强,更好。例如在设计抽奖策略计算时,用到责任链和组合模式的规则树。规则树可以动态的调整配置的节点, 来满足各种业务诉求。还可以结合产品的需求,迭代的时候添加对应的节点就可以。避免了大量if...else的使用,让变动范围缩小到指定的类中, 研发成本降低,提测质量更高,交付效率更强。
最近⼤营销抽奖项⽬被问到抽奖项⽬按道理应该不是⼀个⾼并发的过程,不是想那种抢优惠券这种属于⾼并发,那如果抽奖不是⾼并发,那为啥还要把库存缓存到redis去抗并发呢?
- 春晚红包也是抽奖,同样具有瞬时并发性,拼多多⼀进⻚⾯就有各种【转转转】来获得⼀个券,⽀付完成⼜⼀个转转转。直接领券远没有抽奖来的刺激,即使是发券,也是⽤抽奖⽅式更多。
- 抽奖涉及频繁的库存扣减、中奖记录写入等写操作每秒的请求量如果超过1000tps,打到库上资源竞争,都会出现⼤量的数据库连接等待。 ⼀般⼀个应⽤分配的数据库连接池也就那么20来个。如果都打到库上,都能把库打挂。
抽奖算法提问
- 数据库路由算法 答:hashcode保证两次散列结果⼀致,扰动函数保证散列均匀。 问:除了 hashcode数据库路由算法还有其他的吗?答:斐波那契散列不用于数据库路由算法,因为器不满足严格的雪崩标准。
- 抽奖除了加分布式锁还有没有别的思路? 答:这⾥本质上还是保证库存不超卖,说了低并发下 的数据库⾏锁,redis队列(将抽奖请求按顺序放入队列,逐个处理,本质是 “串行化” 请求,避免并发冲突。) 问:还有其他思路吗? 答:提前分配完毕奖品,预⽣成中奖结果。⽐如微信春晚红包,提前把奖品随机好,写⼊到个⼈记录。⽤户开 奖只是到点查看结果。
- 计次模型,设计⼀天⼀次的参与规则,怎么实现?答:提供了总、⽉、⽇库存,改变日库存即可。
- 抽奖算法的实现?这个随机怎么可控,怎么避免⾼价的奖品⼀开始就被抽掉了,类似于活动中间阶 段才能中⼀次⼀等奖答:有次数锁、有权重,可以避免⼀开始就中⼀等奖。
- 了解过其他抽奖算法吗?答:线性同余⽣成器通过递推公式生成伪随机数序列、梅森旋转算法以梅森素数为周期长度生成随机数、 洗牌算法通过逆向遍历数组,逐步将当前元素与随机位置的元素交换,确保每个排列等概率、加权随机根据元素权重分配概率,常用方法包括: 线性搜索:计算累积权重,生成随机数后遍历匹配区间; 二分查找:预处理累积权重数组,通过二分法快速定位。
- ⿊名单如果上到⼀定规模,⽐如百万级别,有其他的设计思路吗?答:不直接存储全部具体内容(如用户 ID 等),而是通过人群标签化 + Redis 存储的方式优化。 黑名单⼀般会设定⼈群标签,将这些标签及对应的规则存储在 Redis 中,利用 Redis 的高性能特性支持快速查询和匹配; 后端数据库中无需存储海量黑名单明细,只需保存 “人群标签” 的标识(如标签 ID),通过标识关联到 Redis 中对应的标签规则。
- 扣减库存的分段竞态锁⽤ incr 还是 decr,为什么是 incr/decr?答:incr 和总量⽐,decr 和 0 ⽐。decr 适合过程中不允许补充库存的。incr 可以在过程中添加 库存,因为总量可以增加对⽐。
【携程】我跟他说⽤户在抽奖系统⽤积分兑换抽奖机会,我⽅会向mq发消息,积分微服务那边(不是我们组负责的)拿到消息之后扣减会员的积分,他问我:
- 你为什么在这种积分扣减⽤mq?我说流量削峰,他说这不是关键。
答:更关键的是系统解耦与异步协作。由于抽奖系统和积分系统分属不同团队,通过 MQ 传递扣减消息, 可避免两个系统强耦合(无需直接依赖对方的 RPC/HTTP 接口可用性)。 尤其类似积分扣减这类可能涉及复杂流程(如校验、记账)的操作,异步消息能让抽奖系统快速响应用户,后续通过消息回调确认结果,类似支付场景中 “先下单、后等待支付结果回调” 的逻辑,提升用户体验。
- 如果⽤户⽤脚本频繁兑换抽奖机会,你们怎么应对的
答:通过积分⽀付接⼝返回的信息开始变更订单记录,发放抽奖次数。⽤户是⽤⾃⼰的积分兑换的,账户会进⾏额度拦截。之后接⼝会配置频次拦截。 基于订单状态流转:只有积分扣减成功(通过积分系统返回的交易单号确认),才发放抽奖次数,确保兑换行为的有效性。
- 如果下游服务迟迟没有对mq进⾏消息消费,你们怎么处理的?你假设作为系统的架构者,是怎么监控 这种状况的
答:系统是有边界管控的,在实际⼯作中,当前的系统要保证发送mq,下游的系统要保证消费mq。mq 的消费会有监控配置,⽐如⽇常每分钟100次,如果连续n次在n分钟内,低于80次或者⾼于140次, 则进⾏报警。这样就可以监控到了。
上述问题是把抽奖和积分看作两个单独的微服务。在我的项目中: 积分扣减没用mq啊,增加抽奖次数才用的mq,积分扣减和写task是一个事务,mq消费的时候加次数和修改订单状态是一个事务
使用了task任务表的场景:
- 奖品发放通知 :在抽奖活动中发放奖品后,通过Task表记录并发送消息
- 积分调整通知 :用户积分变动后,通过Task表异步发送通知
- 返利处理通知 :用户行为返利后,通过Task表记录并异步处理
使用MQ的场景
- 行为返利入账场景:通过 RebateMessageCustomer 消费者监听 send_rebate 主题,实现用户行为返利的异步入账处理。 当用户完成特定行为(如签到、分享等)时, BehaviorRebateService 创建返利订单并组装MQ消息对象,通过 task 表存储待发送消息, 最终由定时任务发送MQ消息,触发积分或SKU的入账操作。
- 奖品发送场景:通过 SendAwardCustomer 消费者监听 send_award 主题,实现用户中奖后的奖品异步发放。 当用户抽奖中奖后,系统发送MQ消息到该主题,消费者接收到消息后调用 IAwardService 的 distributeAward 方法进行奖品发放处理。
- 活动SKU库存耗尽处理:通过 ActivitySkuStockZeroCustomer 消费者监听 activity_sku_stock_zero 主题, 实现活动SKU库存为0时的库存清理处理。当活动商品库存消耗完毕时,系统发送MQ消息,消费者接收到消息后调用 IRaffleActivitySkuStockService 的 clearActivitySkuStock 和 clearQueueValue 方法进行库存状态更新。
- MQ消息可靠发送机制:通过 SendMessageTaskJob 定时任务(每5秒执行一次),从分库分表的 task 表中查询未发送的消息任务, 调用 ITaskService 的 sendMessage 方法进行发送,并根据发送结果更新任务状态为完成或失败,确保消息的可靠传递。
关于上下游交互的问题
- 每次面试官都会问一嘴,基于ratelimiter是做单机限流吗? 实际生产中主要是使用分布式限流吗,为什么要基于分布式做呢,单机限流满足不了需求吗,分布式限流具体实现方式是什么呢, 基于redis、zookeeper自己实现还是基于限流框架做呢
答:基本都是做单机限流,控制单台服务器的请求处理速率。要的就是单台机器承载量。分布式做集中限流, 其目标是全局统一控制流量(比如整个集群每秒最多处理 10000 次请求),但它需要一个 “集中式组件”(如 Redis、ZooKeeper)来协调所有节点的限流策略(比如所有节点都向 Redis 汇报请求数,由 Redis 判断是否超限)。 集中式组件本身可能成为瓶颈,因为所有请求都需要先经过集中组件判断是否允许通过,会增加网络延迟(跨节点通信); 若流量激增,集中组件(如 Redis)的处理能力、网络带宽可能先被耗尽,反而导致整个系统的限流逻辑失效。
大部分场景下,单机限流已经能满足需求:
- 集群的总承载能力 = 单节点承载能力 × 节点数量。只要通过单机限流确保每个节点稳定,整个集群的总能力即可通过 “扩容节点” 线性提升(比如单节点限 1000QPS,10 个节点就能支撑 10000QPS);
- 真正需要 “全局统一限流” 的场景很少(比如对外暴露的 API 有严格的总调用量限制,如每日 100 万次),这类场景才需要分布式限流,但属于少数情况。
- 限流、降级有必要做到外部服务粒度的嘛?(例如有3个外部系统,有必要为每个系统单独限流、降级吗,例如只是不提供给某个系统服务)
答:当外部服务涉及付费购买的调用权益时,单独粒度的限流、降级是有必要的。例如你去调⽤⼀些学⽣认证、公安⽹,不同的权限调⽤频次是不⼀样的。
- 奖品单状态的变更,我还是有⼀点模糊,如果是下游发奖,那下游应该是不能访问奖品服务的库 吧,那下游发完奖如何更新奖品单状态呢?会有回调接⼝或者是下游再发⼀个发奖完毕的消息吗?
答:内部系统⼀般是发消息,外部系统⼀般是回调。
- 抽奖活动会和业务系统绑定吗?是不是得在活动表加⼀个字段,然后鉴权的时候只能调⽤当前系统 的抽奖活动
答:会配置渠道⽅,不同的渠道⽅都可以加授权。
在抽奖过程时,需要预热处理,可是不可能让⽤户点击活动装配呀,这个问题你怎么解决?
抽奖活动需要的预热处理(如加载活动数据、初始化奖品库存等),无需用户手动触发,而是通过运营配置 + 审核机制自动完成:
- 运营人员配置好活动后,提交审核;
- 审核通过后,系统自动执行预热操作(无需用户参与);
- 预热完成后,活动不会立即开放,而是等待到达预设的有效期(开始时间),届时自动对用户开放使用。
在抽奖的结果中,如果一个用户在前端抽到了一个奖品,对用户是可见的。可是在扣减库存时,却发现没有了对应的库存,此时应该怎么办?
在库表设计中,抽奖的每条策略都是由库存限制的,抽到后才会展示给用户。如果库存不足就直接走兜底积分了。 这就是为什么要给策略上也加库存,直接奖品加库存是不可以的原因。
既然为了想承受更高并发使用redis做库存扣减,那生成奖品id后续要等中奖订单入库才能给用户展示结果吗?那用redis做库存扣减不就没意义了
最关键的在于如果没有日redis做库存扣减,就要到数据库做库存扣减。那么就会有很多请求在同一个表行记录开始独占竞争加锁。其余的请求就会进入等待状态。 直至耗尽数据库的连接。整个服务就会被拖垮,一个普通的查询也会从原来的几十毫秒变成到一分钟也拿不到结果了。 使用redis就是为了解决这个问题。
之后在写入库里的记录,只是记录 “谁中了奖”,不再涉及库存竞争(无竞争—)。那么就不会让数据库被夯住。可以快速被处理。在大厂的数据库配置,基本这类操作不会被 redis 慢多少。
面试实际提问:https://wx.zsxq.com/group/48411118851818/topic/2855841145815841