活动是通过秒杀领取的。(即:活动对应着某一商品)
这里超卖指:对于一个活动它的参与量有数量限制,就是活动的库存,当活动的领取数大于活动库存总量,就是超卖
用户秒杀参与活动的资格(领取活动)
针对这个抽奖系统,会有不同的抽奖活动那就是【活动表】,不同的抽奖规则【抽奖策略表】,对人群的过滤【准入规则表】
然后记录用户信息的【用户表】,一个用户可以参加不同的活动【用户领取活动表】,抽奖完成后会生成该用户的【抽奖单】。
(1)在用户领取活动表中添加state状态用于记录当前领取的活动有没有执行抽奖。目的是当抽奖过程中发生失败(系统,网络等原因),还没生成抽奖单
到数据库中,这时用于保留未使用抽奖的状态,避免又去重复领取一遍活动。
也就是说:用户领取活动后,当前活动抽奖为未执行状态,
抽奖活动开始执行时,先判断活动state,未执行才抽奖,否则不抽。以此避免在抽奖过程中发生失败,该次领取活动失效,又要重新领取活动导致该用户的活动总次数减少。
先说下面这俩(上面这个感觉表达不清楚):
(1)用户参与一次抽奖对应一个抽奖单:这是通过【用户领取活动表】中的领取ID(雪花算法),对应生成抽奖单的UUID,UUID设置了唯一约束,用来保持幂等性
(2)怎么保证kafka重复消费的幂等性?【用到MQ场景:Redis扣减数据写回DB;发奖】
生产者发送每条数据的时候,里面加一个全局唯一的业务id,消费者拿到后,先根据这个id去Redis里查一下之前消费过吗。如果没有消费过,就处理然后将id写入redis。如果消费过了,就不处理,以此保证不重复处理相同的消息。
抽奖单中添加mq_state标识MQ消息发送是否成功,如果发送失败就通过定时任务补偿MQ消息;发送成功就更改mq_state状态。
1、生产者已把消息发送到mq,在mq给生产者返回ack的时候网络中断,故生产者未收到确定信息,认为消息未发送成功网络重连后生产者重发消息,但实际情况是mq已成功接收到了消息,造成mq接收了重复的消息
2、消费者在消费mq中的消息时,mq已把消息发送给消费者,消费者在给mq返回ack时网络中断,故mq未收到确认信息在网络重连后再次发送给该消费者,但实际上该消费者已成功消费了该条消息,造成消费者消费了重复的消息。
解决办法
1、mq接收生产者传来的消息:
mq内部会为每条消息生成一个全局唯一、与业务无关的消息id,当mq接收到消息时,会先根据该id判断消息是否重复发送,mq再决定是否接收该消息。
2、消费者消费mq中的消息:
也可利用mq的该id来判断,或者可按自己的规则生成一个全局唯一id,每次消费消息时用该id先判断该消息是否已消费过
1.在活动领取流程涉及路由切换的分布式事务,面对这个问题,为了避免同一个事务下,连续操作DAO而多次调用自定义注解的路由切换,导致声明式事务失效。
所以,将数据源的切换放在事务处理前,事务操作通过编程式参与次数表的活动次数扣减
与写入用户领取活动表
连在一起合并为一个事务,保证两次操作的原子性,进行处理。
【这俩表是同一个数据源,只是DAO操作上就添加着路由切换,就会执行,从而导致事务失效】
2.鉴于抽奖系统的实时性要求,希望用户流程体验更加流畅,支撑更大的并发量,没有对整个流程添加过多的或者大块的事务,降低性能,而是采用最终一致性的方式进行处理。
3.由在面对秒杀场景时,在分库分表后可以支撑更大的秒杀体量。同时对于单key的秒杀,还采用了滑块分段锁的方式使用redis和MQ进行处理,来提高吞吐量和减少数据库压力。
注:为什么切换路由会使声明式事务失效?
因为虽然路由组件通过AOP计算出了路由,但没有取到,而是复用了Spring事务给我们保存的connection,所以引起了路由失效。
(具体来说有些复杂见 Spring声明式事务引起的路由失效分析 )
解决方式:
解决方法正是在Spring事务开启之前,就手动地计算路由保存到RouterHolder之中,再手动开启Spring事务,这样就能取到正确的路由。
seata 两段式提交,但基本大家用的不多。因为要尽可能降低对数据库连接的长时间占用,要做到快速释放连接。
所以基本都是 MQ 和任务补偿做最终一致。
【保证一致性就是保证并发安全】
疑问:
(1)‘MQ 和任务补偿做最终一致?’ MQ怎么解决的分布式事务????
(2)MQ有没有做持久化:. MQ 消息是基于库表记录的任务扫描发送消息的,所以是有对应的持久化处理的。另外也可以创建一个单独的 Task 表,表中专门写 MQ 消息记录,用于发送失败重试等,这样可以统一管理。
我编的:
(1)秒杀场景下:使用redis decr奖品库存扣减并Setnx设置库存锁兜底保证不超卖,库存扣减写入异步延时队列,并定时任务扫
描扣减库存,缓解数据库压力。
(2)将发奖流程使用MQ异步处理。
(3)同一个库里的不同表间的增添和修改,使用spring的注解声明式事务处理
(4)分库分表后的用户抽奖单,为了保证分布式事务处理使用编程式事务
活动表、抽奖策略配置表、准入规则引擎表、用户抽奖单记录表、以及配合这些表数据结构运行的其他表
,如:记录用户领取活动表、用户活动参与次数表 等。针对这个抽奖系统,会有不同的抽奖活动那就是【活动表】,不同的抽奖规则【抽奖策略表】,对人群的过滤【准入规则表】
然后记录用户信息的【用户表】,一个用户可以参加不同的活动【用户领取活动表】,抽奖完成后会生成该用户的【抽奖单】。
分库分表、自定义路由协议,扫描指定库表数据等各类方式
。研发扩展性好,简单易用。我们的这个路由组件,只是针对该抽奖系统的,在将用户的大量抽奖单在保存到数据库中时,通过用户ID计算出对应的库和表,将用户抽奖单使用分库分表保存来减轻数据库压力,
(1)自定义一个注解@DBRouter(key = “uId”),用于放置在需要被数据库路由的DAO操作上。比如新增用户领取活动。
(2)在AOP 切面拦截中,根据用户ID进行相应的数据库路由计算,并且使用扰动函数加强散列,得到一个索引位置后,在根据库表的数量折算出具体落到那个库表中,最后将计算的库表信息保存到线程的ThreadLocal中。
(3) 通过Mybatis 拦截器,拦截 SQL 语句动态修改添加分表信息,再设置回 Mybatis 执行 SQL 中。
具体操作:获取StatementHandler,获取自定义注解判断是否进行分表操作,statementHandler.getBoundSql()语句获取SQL,从Threadlocal中读取目标库表,替换SQL表名,最后通过反射修改SQL语句。
如果一个场景需要在同一个事务下,连续操作不同的DAO操作,那么就会涉及到在 DAO 上使用注解 @DBRouter(key = “uId”) 反复切换路由的操作。虽然都是一个数据源,但这样切换后,事务就没法处理了。
解决:这里选择了一个较低的成本的解决方案,就是把数据源的切换放在事务处理前,而事务操作也通过编程式编码进行处理。
因为用if-else语句去判断是哪种数据比较麻烦且代码量大大增加,对以后的维护增加了难度,所以我们使用组合模式,将对象组合成树形结构。
搭建规则引擎树,需要的表【规则树总表】【规则树结点表】【规则树结点连线表】,规则树节点放在数据库中方便动态化配置,
每个节点的逻辑就是一个过滤器(作比对),最后交给树结构执行引擎串联节点间的关系,最后将接口交给外部调用
可以在传入信息或者数据库里,拿到比对值然后在树结构节点里做比对,
在执行引擎里,遍历树结构,while(判断叶子结点还是中间结点){拿到中间结点的决策key(就是判断依据age/gender…),得到具体值,放到过滤器中得到下一个节点往左侧走还是右侧走},遍历到叶子结点结束,就能拿到最后的活动号
规则引擎的设计是一个二叉树判断,通过使用组合模式,将判断节点组合成树形结构,就不用使用if-else语句去判断,便于维护和使用。
这个规则引擎包括:logic 逻辑过滤器、engine 引擎执行器。
逻辑过滤器是一个个二叉树的判断结点,
引擎执行器就是在遍历树结构,通过从该树的根节点开始 ,while循环判断,是中间结点,就拿到中间结点的判断依据,查询用户的具体属性,然后放到过滤器中得到下一个节点往左侧走还是右侧走,直到到达叶子结点结束,最后得到该用户筛选后能参与的活动ID。
使用的单项随机概率抽奖就是分配好的奖品概率是固定的。
将概率值存放在数组中,根据概率值直接定义中奖结果,比如20%的一等奖中奖率,就开辟100的数组空间20个经过散列后随机分布的下标位置能中一等奖,
抽奖时用户随机在100范围内生成数组的索引+扰动,查找对应位置对应是否有奖,用空间换时间。
(不公布抽奖结果,大量抽奖并发打进来概率是一样,中奖抽空的位置数组设为没奖)
1.模板模式处理抽奖流程,
基于模板设计模式,规范化抽奖执行流程。包括:提取抽象类、编排模板流程、定义抽象方法、执行抽奖策略、扣减中奖库存、包装返回结果等。主要就:以抽象类 AbstractDrawBase 编排定义流程,用 DrawExecImpl 做具体抽奖流程实现。
比如抽象类中定义:1. 获取抽奖策略2.判断是否可以进行抽奖3.执行抽奖算法4.包装结果
具体实现类:抽奖过程具体实现。
2.工厂搭建发奖domain
本质:就是为了简化if else判断不同类型使用不同的代码处理, 使用map将不同的类型和对应的代码联系到一起。让代码变得更整洁。
工厂模式:是一种创建型设计模式,在父类中提供一个创建对象的方法,允许子类决定实例化对象的类型。
工厂模式通过调用的时候提供发奖类型,工厂返回对应的发奖服务。通过这样由具体的子类决定返回结果,并做相应的业务处理。
“发放奖品”工厂作用:外部提供一个奖品类型,工厂提供这个奖品类型需要提供什么样的服务去处理。
3.组合模式
组合模式搭建用于量化人群的规则引擎,用于用户参与活动之前,通过规则引擎过滤性别、年龄、首单消费、消费金额、忠实用户等各类身份来量化出具体可参与的抽奖活动。
组合模式就是不用ifelse来判断,而是通过组合节点搭建一棵二叉树,而库表中则需要把这样一颗二叉树存放进去
在秒杀流程,先扣减Redis缓存的库存,使用incr,decr对redis操作。因为incr\decr操作是原子性的,将对应的key值-1后返回结果,如果结果<0就发生超卖,将这个订单取消。
然后发送一个 MQ 消息对数据库中的库存进行处理。因为 MQ 可以消峰,减少对数据库的压力。
滑块锁最早是为了恢复库存,但其实还有另外一个作用。
如果缓存失效,key被删除,缓存key从0开始计数,那么你之前已经对key… key_n加锁了,这样可以保证不超卖。避免风险。
因为incr\decr操作时原子性的,将对应的key值-1后返回结果,如果结果<0就发生超卖,将这个订单取消。
decr 请求操作也可能在请求时发生网络抖动超时返回,这个时候decr有可能成功,也有可能失败。setNx 锁拦截后,会更加可靠。
setNx是对商品编号(活动ID)加锁,一般在确认领取后(插入领取记录)删除锁,如果删除失败可以定时在活动结束后删除。这样并不会导致死锁,虽然这个商品最后没有卖出(活动没领取),最重要的是保证不超卖。
setNx 因为是非独占锁,可以在活动结束后释放;而独占锁在秒杀过程中不好把握线程释放时间,释放的晚了活动用户都走了,释放的早了,流程可能还没处理完。
如果没有锁,可能会超卖。
关于库存恢复,一般这类抽奖都是瞬态的,且redis集群非常稳定。所以很少有需要恢复库存,如果需要恢复库存,那么是把失败的秒杀incr对应的值的key,加入到待消费队列中。等整体库存消耗后,开始消耗队列库存。
线程超时或者系统宕机等意外情况发现,锁会一直被某些线程持有,造成死锁状态。
如果设置了超时时间来解决死锁,超时时间难以把控,且容易出现一个线程删了另一个线程的锁。
使用定时任务也是为了避免MQ消费引起并发问题,所以如果并发量较大,使用定时任务处理缓存和数据库库存同步。
我自己的疑惑:
xxl-job怎么还有数据库表单?
Xxl-job是一个分布式任务调度平台。它使用数据库来存储任务调度相关的信息,如任务调度状态、执行日志等。因此,即使你不直接操作数据库表单,xxl-job依然需要数据库来保存这些信息。
如果你指的是需要创建Xxl-job特定的数据库表单,那么你可以在Xxl-job提供的资源中找到SQL脚本。通常,这些脚本会在其源码包中的"doc"目录下的"db"子目录中。你需要根据你使用的数据库类型(如MySQL、PostgreSQL等)来选择相应的SQL脚本执行。
消息队列主要用于:在分布式系统中存储转发消息。场景:异步处理,应用解耦,流量削峰和消息通讯四个场景。
使用MQ消息的特性,把用户抽奖到发货的流程进行解耦。这个过程中包括了消息的发送、库表中状态的更新、消息的接收消费、发奖状态的处理等。
在抽奖单中加入mq_state字段用来判断是否发送成功,定时任务检查扫描用户的抽奖单看mq_state是否标记为已发送,发送失败的话就需要补偿发送MQ消息,发送成功消费者处理MQ消息,执行发奖。
XXL-JOB是一个分布式任务调度平台,处理需要使用定时任务解决的场景。
主要用在通过定时任务扫描用户的抽奖单看mq_state是否标记为已发送,发送失败的话就需要补偿发送MQ消息,发送成功消费者处理MQ消息,执行发奖。