秒杀项目实战总结

优惠券秒杀

数据库设计

优惠券秒杀核心数据库表:

  1. 优惠券voucher表:库存stock和开始时间和结束时间

  2. 优惠券订单order表:主键id是自定义全局唯一ID,userID,goodID

    【秒杀表详情】

    秒杀用户表、商品信息表、秒杀商品表(记录该商品的秒杀始末时间,秒杀价和剩余量)、秒杀订单表(记录了秒杀用户名和秒杀的商品还有订单号)、订单详情表(通过秒杀订单号来查找对应的订单详情,里面记载更详实的业务信息)、

    【全局唯一ID实现】

    因为增加ID的安全性和单表数据量有限,所以mysql不使用自增ID,使用全局唯一ID。

    全局唯一ID(8字节):模仿生成分布式ID的雪花算法

    • 符号位1bit:0/1
    • 时间戳31bit:当前日期long64bit
    • 序列号32bit:redis自增incr(业务+日期),如果只使用业务ID,则自增有上限

    当前日期(long64位)左移32位,或运算序列号count(long):timestamp << 32 | count

基于mysql完成同步秒杀

方案

过程:使用事务,先查再扣,直接更新数据库中库存字段。

  • 提交优惠券ID后,

  • 去数据库查询优惠券信息,判断秒杀时间是否开始+判断库存是否充足,

  • 扣减库存,创建订单写入数据库,返回订单ID。

    update table_prmo set num = num - 1 WHERE id = 1001 and num > 0
    

优点:该方案的优点是简单便捷,查验库存时直接查库即可获取到实时库存。且有数据库事务保证,不用考虑数据丢失和不一致的问题。

缺点:

  • 库存是数据库中的单个字段,在更新库存时,所有的请求需要等待行锁。一旦并发量大了,就会有很多请求阻塞在这里,导致请求超时,进而系统雪崩。
  • 频繁请求数据库,比较耗时,且会大量占用数据库连接资源。

系统超卖问题

原因:查询操作和更新操作不是原子性的,会导致在并发的场景下,出现库存超卖的情况。

**解决:**未优化前使用mysql扣减库存,如何保证不发生超卖问题?

  1. 悲观锁:保证只有一个线程进行查询扣减操作

  2. 通过CAS乐观锁完成库存扣减,CAS是高效的无锁更新方式,即在扣减库存的时候加上原始值:

    #stock做乐观锁
    update product set stock=stock-1 where stock=#{上一次的库存}  and id = 1 and stock>0
    #利用version解决ABA问题
    update voucher 
    set stock = stock - 1, version = version+1
    WHERE voucher_id = 1001 and version = old_version
    #可以继续简化,添加版本号是多余的
    update voucher 
    set stock = stock - 1 
    WHERE voucher_id = 1001 and stock = old_stock
    

用户重复下单问题

**原因:**查询订单和扣减库存创建订单还是会发生多线程并发安全问题,比如多线程查询订单时,可能发生查询到的订单都是0的情况,它们则会继续向下执行创建订单。

  1. 根据voucherID和userID去mysql查询订单表,判断是否存在
  2. 然后扣减库存,创建订单

**解决:**加synchronized锁+事务:查询订单->判断订单->扣减库存->新增订单

用户第一次请求成功获取分布式锁后,直至第一次请求成功释放已获取的分布式锁或超时释放,不然用户第二次请求会获取分布式锁失败,这样保证A用户只会成功领取一张。

不要在voucherorderServer这个类加锁,加synchronized(userId.toString().intern())

  • 单机情况下:通过加synchronized(userId.toString().intern())查询数据库order表,以userid和voucherid查询

  • 分布式情况下:一人一单并发安全问题,使用分布式锁解决集群下一人一单的并发安全问题

    • 原因:在分布式系统下,每个JVM都有自己的锁,导致每个锁都可以由线程获取。

分布式锁

  • 加锁: SET lockKey requestId NX PX 30000
    • lockKey:用户ID+优惠券ID
    • requestId 可以是线程标识或者全局唯一ID
    • 解决 set 和 expire的非原子性
  • 解锁:除了超时被动释放还有主动释放,使用lua脚本保证判断锁标识和释放锁的原子性。
    1. 获得锁的线程标识
    2. 判断是否和指定的标识一致
    3. 如果一致则释放锁
    4. 如果不一致则什么都不做
  • 双重检测

基于缓存和mysql完成异步秒杀

方案

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KG61ko7a-1683458469407)(images/image-20230221230530476.png)]

  • 扣减库存缓存化
  • 异步同步数据库
  • 库存数据库最终一致性保证

输入:voucherID,userID

输出:返回订单ID

**过程:**发券前先把全部库存初始化入redis,在redis中完成对库存的检查和订单(一人一单)的判断,预扣库存,然后把任务交给redis消息队列。

**优点:**解决数据库行锁限制,提高性能。

**缺点:**系统流程会比较复杂,而且需要考虑缓存丢失或宕机数据恢复的问题,容易造成库存数据不一致。

缓存(库存)预热

秒杀品在活动开始前,会将库存等数据提前写到缓存中,这样秒杀品在高并发读取时,可以获得准实时的剩余库存信息,而不必触达数据库。

【优化】不一次性读入所有缓存

每次从数据库申请固定步长的库存进行发放,发完再次申请

这种方式需要注意双重检查锁:

问题:线程A刚开始查询优惠券缓存,线程B正尝试获取分布式锁,由于缓存不存在,线程A开始查询数据库,线程B成功获得锁,开始更新缓存,线程A尝试获得分布式锁,而线程B已经释放分布式锁,线程A获得了锁,又一次更新缓存,而线程B已经成功返回。缓存被重复更新了两次。

解决:在线程成功得到锁以后,再次判断优惠券缓存的存在。

问题:查询优惠券不存在,需要进mysql查询,需要加分布式锁

预扣库存

预扣库存,而不是最终的库存扣减。换句话说,如果缓存中的库存扣减成功,则表示数据库中有库存,此时放行到数据库中进行新一轮的竞争。但是,如果缓存中的库存扣减失败,则表示库存已经不存在,此时不必再进入数据库竞争,可以直接拒绝本次请求。

redis库存扣减:

  • LUA脚本:检查库存和检查订单;

  • 调用LUA脚本执行库存扣减,保证原子性;

  • 据LUA脚本的返回码,判断扣减是否成功

    • 如果秒杀品id不存在,返回-1
    • 如果当前剩余库存不足以扣减,返回-3
    • 如果扣减成功,返回1,否则返回-2

Redis消息队列

LUA脚本返回成功,将voucherID、UserID、orderID存入消息队列

库存恢复*:

取消订单是下单的逆向操作。因此,下单时所发生的数据变更,在订单取消时需要恢复,包括订单数据、库存数据和库存缓存数据。需要关注的核心逻辑有:

  • 用户在执行取消动作时,必须先获得锁,防止抖动等造成重复取消;
  • 取消过程包括三个关键步骤:
    • 将订单置为取消状态;
    • 恢复秒杀品中的库存;
    • 恢复库存缓存中的库存。
  • 取消订单时如果出现库存恢复失败等情况,必须抛出异常回滚事务。

在用户取消订单或下单失败时,需要执行库存恢复,恢复过程包括缓存库存恢复和数据库库存恢复。

redis库存恢复:

  • 恢复库存时,如果库存数据不存在,则拒绝恢复;
  • 调用LUA脚本执行库存恢复;
  • 根据LUA脚本的返回结果,判断恢复是否成功。

MySQL层面的库存恢复在逻辑上与前面的库存扣减类似。当然,它们的改进策略也是一致的。

库存获取:

获取指定秒杀品的可用库存,在FlashSale中有多处场景使用,比如,秒杀品详情页、秒杀品列表页等。在执行这些请求时不可能到数据库中去查询。

  • 获取库存数据时,优先从本地缓存中读取(可能存在延迟或不一致,但可以接受);
  • 本地缓存不在时,将通过秒杀品的库存KEY从分布式缓存中获取,并设置到本地中;
  • 在从分布式缓存中获取时,应当先获取对应的锁,否则返回稍后再试。

rabbitMQ

redis消息队列

  • 异步队列:
    • 使用list结构作为队列,rpush生产消息,lpop消费消息。可是如果队列空了,客户端就会陷入 pop 的死循环,不停地 pop,没有数据,接着再 pop,又没有数据。这就是浪费生命的空轮询。当lpop没有消息的时候,要适当sleep一会再重试。
    • 可不可以不用sleep呢?list还有个指令叫blpop/brpop,在没有消息的时候,它会阻塞住直到消息到来。
  • 延时队列:
    • sortedset,拿时间戳作为score,消息内容作为key调用zadd来生产消息,消费者用zrangebyscore指令获取N秒之前的数据轮询进行处理。
  1. 基于List的消息队列:

    • 优点:
      • 利用Redis存储,不受限于JVM内存上限
      • 基于Redis的持久化机制,数据安全性有保障
      • 可以满足消息的有序性
    • 缺点:
      • 无法避免消息丢失
      • 只支持单消费者
  2. 基于PubSub的消息队列:可以订阅channel

    • 优点:采用发布订阅模型,支持多生产、多消费
    • 缺点:
      • 不支持数据持久化
      • 无法避免消息丢失
      • 消息的堆积有上限,超出时数据丢失
  3. 基于Stream的消息队列:

    • 消息唯一ID:*由redis自动生成:时间戳+递增数字

    • 生产消息:XADD;读取消息:XREAD

    • 特点:

      • 消息可回溯:消息读完不消失,永久保存在队列中
      • 一个消息可以被多个消息读取
      • 消息可以阻塞读取
      • 有消息漏读的风险:在处理消息时来了很多消息,只能读到最新的一条

基于Stream的消息队列-消费者组:将多个消费者分到一个组中,监听同一个队列。

  • 消息分流:队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息的处理速度。

  • 消息标识:消费者组会维护一个标识,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标识处读取消息,确保每一个消息都会被消费。

  • 消息确认:消费者收到消息后,消息处于pending状态,并存入一个pending-list。当处理完成后需要通过XACK来确认消息,标记消息为已处理,才会从pending-list内移除。

  • 特点:

    • 消息可回溯
    • 可以多消费者抢消息,加快消费速度;将消息先写入mysql中,后面空闲了再处理
    • 可以阻塞读取
    • 没有消息漏读的风险(消息标识)
    • 由消息确认机制,保证消息至少被消费一次(消息确认)

代码流程:

  • 创建一个Stram类型的消息队列,名为Stream.orders;
  • 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向Stream.orders中添加消息,内容包含voucherID、userID、orderID;
  • 项目启动时,开启一个线程任务,尝试获得Stream.orders中的消息,完成下单。
    • 获取消息队列中的订单信息
    • 判断消息获取是否成功
    • 如果获取失败,说明没有消息,继续下一次循环
    • 如果获取成功,可以下单
    • ACK确认

(重复循环,保证pending-list内的订单全部完成,确保异常全部能处理)

基于分库分表完成秒杀

问题

预期和压测

  • 瓶颈:

    • 单个 MySQL 的每秒写入在 4000 QPS 左右,超过这个数字,MySQL 的 I/O 时延会剧量增长。

    • MySQL 单表记录到达了千万级别,查询效率会大大降低,如果过亿的话,数据查询会成为一个问题。

    • Redis 单分片的写入瓶颈在 2w 左右,读瓶颈在 10w 左右

  • 你觉得单商品QPS达到多少是可以达到要求,你做项目前预期是多少?

  • 扣减库存时MySQL行锁遇到过吗,没优化前QPS是多少?

  • 用的什么压测工具?压的时候考虑的是单个商品的扣减库存还是整个系统的?

  • mysql同步秒杀压测:200QPS

  • 异步秒杀优化完压测:单种优惠券200张,1000线程循环一次,QPS:1500

对项目做了哪些优化

  • cas乐观锁解决超卖问题
  • 分布式锁解决重复下单问题
  • 基于缓存和mysql完成异步秒杀,完成了库存行锁的优化(异步写入数据库)
  • 限流

幂等

  • 幂等检查:查询用户ID和订单ID

  • 接口幂等性:利用数据库的唯一索引来保证幂等。

  • 消息队列超发:幂等ID解决,保证幂等ID全局唯一,或者数据库建表自增、雪花算法、uuID

    创建记录表:记录下处理过的幂等ID

    处理消息前都会去表里查一下看之前有没有处理过

高并发下怎么防止数据重复

  1. 给表建唯一索引

    缺点:业务表必须是物理删除,delete from product where id=123;

    逻辑删除:update product set delete_status=1

    问题:这种逻辑删除的表,是没法加唯一索引的。

    • 假设之前给商品表中的name和model加了唯一索引,如果用户把某条记录删除了,delete_status设置成1了。后来,该用户发现不对,又重新添加了一模一样的商品。由于唯一索引的存在,该用户第二次添加商品会失败,即使该商品已经被删除了,也没法再添加了。
    • 把name、model和delete_status三个字段同时做成唯一索引:可以解决用户逻辑删除了某个商品,后来又重新添加相同的商品时,添加不了的问题。但如果第二次添加的商品,又被删除了。该用户第三次添加相同的商品,还是添加不了。
  2. 分布式锁

    如果每添加一个商品都要加分布式锁的话,会非常影响性能。显然对于批量接口,加redis分布式锁,不是一个理想的方案。

  3. 统一mq异步处理

    通过RocketMQ的顺序消息,单线程异步复制添加商品的,可以暂时解决商品重复的问题。

    问题:

    • 现在所有的添加商品功能都改成异步了,之前同步添加商品的接口如何返回数据呢?这就需要修改前端交互,否则会影响用户体验。
    • 之前不同的添加商品入口,是多线程添加商品的,现在改成只能由一个线程添加商品,这样修改的结果导致添加商品的整体效率降低了。
  4. insert on duplicate key update

  5. 防重表推荐

项目中有遇到啥问题,怎么排查的?

  • 超卖问题:原因+解决办法+优化
  • 重复下单问题:原因+解决办法+优化

库存行锁优化(异步秒杀逻辑)

  • 扣减库存缓存化
  • 异步同步数据库
  • 库存数据库最终一致性保证

热点库存问题(高并发读库存)

**问题:**由于 MySQL 存储数据的特点,同一数据在数据库里肯定是一行存储(MySQL),因此会有大量线程来竞争 InnoDB 行锁,而并发度越高时等待线程会越多,TPS(TransactionPer Second,即每秒处理的消息数)会下降,响应时间(RT)会上升,数据库的吞吐量就 会严重受影响。这就可能引发一个问题,就是单个热点商品会影响整个数据库的性能, 导致 0.01% 的商品 影响 99.99% 的商品的售卖。

**解决:**改进方案是将单库存字段分散成多库存字段(库存拆分),随机或者轮询某一个库存字段进行更新扣减库存,分散数据库的行锁,减少并发量大的情况数据库的行锁瓶颈。

  • 加分布式锁
  • 多级缓存
  • 热key(高并发读库存)拆分

方案一:

高 QPS 下单 key 读取和写入的会打到一个实例上,优化拆分多个key:

  • 写入:设计拆分 100 个 key,每次发红包根据请求的 actID%100 使用 incr 命令累加该数字,因为不能保证幂等性,所以超时不重试。

  • 读取:与写入流程类似,优先读取本地缓存,如果本地缓存值为为 0,那么去读取各个 Redis 的 key 值累加到一起,进行返回。

  • 问题:

    • 拆分 100 个 key 会出现读扩散的问题,需要申请较多 Redis 资源,存储成本比较高。而且可能存在读取超时问题,不能保证一次读取所有 key 都读取成功,故返回的结果可能会较上一次有减少。
    • 容灾方案方面,如果申请备份 Redis,也需要较多的存储资源,需要的额外存储成本。

方案二

在方案一实现的基础上进行优化,并且要考虑数字不断累加、节约成本与实现容灾方案。在写场景,通过本地缓存进行合并写请求进行原子性累加,读场景返回本地缓存的值,减少额外的存储资源占用。使用 Redis 实现中心化存储,最终大家读到的值都是一样的。每个 docker 实例启动时都会执行定时任务,分为读 Redis 任务和写 Redis 任务。

  • 读取:

    1. 本地的定时任务每秒执行一次,读取 Redis 单 key 的值,如果获取到的值大于本地缓存那么更新本地缓存的值。
    2. 对外暴露的 sdk 直接返回本地缓存的值即可。
    3. 有个问题需要注意下,每次实例启动第一秒内是没有数据的,所以会阻塞读,等有数据再返回。
  • 写入:

    1. 因为读取都是读取本地缓存(本地缓存不过期),所以处理好并发情况下的写即可。

    2. 本地缓存写变量使用 go 的 atomic.AddInt64 支持原子性累加本地写缓存的值。

    3. 每次执行更新 Redis 的定时任务,先将本地写缓存复制到 amount 变量,然后再将本地写缓存原子性减去 amount 的值,最后将 amount 的值 incr 到 Redis 单 key 上,实现 Redis 的单 key 的值一直累加。

    4. 容灾方案是使用备份 Redis 集群,写入时进行双写,一旦主机群挂掉,设计了一个配置开关支持读取备份 Redis。两个 Redis 集群的数据一致性,通过定时任务兜底实现。

后续还有哪些可以优化的?

  • mysql读写分离:主库负责执行数据更新请求,然后将数据变更实时同步到所有从库,用从库来分担查询请求,解决数据库写入影响查询的问题。主从同步存在延迟,正常情况下延迟不超过1ms,优惠券的领取或状态变更存在一个耗时的过程,主从延迟对于用户来说无感知。

  • mysql分库分表:查询用户相关的优惠券数据是优惠券最频繁的查询操作之一,对用户的优惠券数据分库分表,进行数据水平拆分,提升db读写能力。

    给用户发了券,那么用户肯定需要查询自己获得的券。基于这个逻辑,我们以 user_id 后四位为分片键,对用户领取的记录表做水平拆分,以支持用户维度的领券记录的查询。

  • mysql水平扩容:优惠券秒杀,归根结底是要对用户的领券记录做持久化存储。可以在不同服务器上部署 MySQL 的不同分片,对 MySQL 做水平扩容,这样一来,写请求就会分布在不同的 MySQL 主机上,这样就能够大幅提升 MySQL 整体的吞吐量。

  • redis水平扩容:每种券都有对应的数量,在给用户发券的过程中,我们是将发券数记录在 Redis 中的,大流量的情况下,我们也需要对 Redis 做水平扩容,减轻 Redis 单机的压力。

  • 构建多级缓存:引入jd-hotkey组件,热key实时同步到本地缓存中,减少访问分布式缓存。

分布式ID设计

  1. 数据库自增ID:

    • 实现:基于数据库的自增ID,需要单独使用一个数据库实例,在这个实例中新建一个单独的表。

    • 缺点:业务系统每次需要一个ID时,都需要请求数据库获取,性能低,并且如果此数据库实例下线了,那么将影响所有的业务系统。

  2. 数据库多主模式:

    • 实现:如果我们两个数据库组成一个主从模式集群,正常情况下可以解决数据库可靠性问题,但是如果主库挂掉后,数据没有及时同步到从库,这个时候会出现ID重复的现象。我们可以使用双主模式集群,也就是两个Mysql实例都能单独的生产自增ID,这样能够提高效率,但是如果不经过其他改造的话,这两个Mysql实例很可能会生成同样的ID。需要单独给每个Mysql实例配置不同的起始值和自增步长。
    • 缺点:但是这种方案的扩展性不太好,如果两台Mysql实例不够用,需要新增Mysql实例来提高性能时,这时就会比较麻烦。
  3. Redis生成分布式ID:

    • 和利用Mysql自增ID类似,可以利用Redis中的incr命令来实现原子性的自增与返回

    • 但是要考虑持久化的问题。Redis支持RDB和AOF两种持久化的方式:

      • RDB持久化相当于定时打一个快照进行持久化,如果打完快照后,连续自增了几次,还没来得及做下一次快照持久化,这个时候Redis挂掉了,重启Redis后会出现ID重复。
      • AOF持久化相当于对每条写命令进行持久化,如果Redis挂掉了,不会出现ID重复的现象,但是会由于incr命令过得,导致重启恢复数据时间过长。
  4. 雪花算法

    • 能让负责生成分布式ID的每台机器在每毫秒内生成不一样的ID

    • 核心思想是:分布式ID固定是一个long型的数字,一个long型占8个字节,也就是64个bit

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vnhlbEV1-1683458469408)(images/16cfca8df31b5ad7tplv-t2oaga2asx-zoom-in-crop-mark4536000.png)]

      • 第一个bit位是标识部分,在java中由于long的最高位是符号位,正数是0,负数是1,一般生成的ID为正数,所以固定为0。
      • 时间戳部分占41bit,这个是毫秒级的时间,一般实现上不会存储当前的时间戳,而是时间戳的差值(当前时间-固定的开始时间),这样可以使产生的ID从更小值开始。
      • 工作机器id占10bit,这里比较灵活,比如,可以使用前5位作为数据中心机房标识,后5位作为单机房机器标识,可以部署1024个节点。
      • 序列号部分占12bit,支持同一毫秒内同一个节点可以生成4096个ID

如此依赖Redis,Redis挂了怎么办

  • RDB和AOF恢复机制
  • 构建Redis集群
  • 多级缓存
  • 可以选择不一次性读取全部库存进入redis,分批缓存

少卖问题

原因:扣减库存成功,数据库创建订单失败

  • 同步时,失败后就应该让Redis的库存再加上1。
  • 异步时,消息队列发送的可靠性。

数据库和缓存一致性问题

三大缓存读写问题

限流

常见的限流算法:

  • 计数器:请求进来后,计数器数值增加,计数器的数值达到设定的阈值后,则将拒绝服务。
  • 漏桶算法(Leaky Bucket):有一个固定容量的漏桶,水流(请求)可以按照任意速率先进入到漏桶里,但漏桶总是以固定的速率匀速流出,当流入量过大的时候(超过桶的容量),则多余水流(请求)直接溢出。
  • 令牌桶:令牌按固定的速率被放入令牌桶中,桶中最多存放 N 个令牌(Token),当桶装满时,新添加的令牌被丢弃或拒绝。当请求到达时,将从桶中删除 1 个令牌。令牌桶中的令牌不仅可以被移除,还可以往里添加,所以为了保证接口随时有数据通过,必须不停地往桶里加令牌。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Guanam_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值