秒杀那些事
1、九个细节
2、页面静态化
活动页面绝大多数内容是固定的,比如:商品名称、商品描述、图片等。为了减少不必要的服务端请求,通常情况下,会对活动页面做静态化处理。用户浏览商品等常规操作,并不会请求到服务端。只有到了秒杀时间点,并且用户主动点了秒杀按钮才允许访问服务端。
针对不同地区访问使用 CDN,它的全称是 Content Delivery Network,即内容分发网络。
3、秒杀按钮
==CDN 上的 js 文件是如何更新的?==
秒杀开始之前,js 标志为 false,还有另外一个随机参数。
当秒杀开始的时候系统会生成一个新的 js 文件,此时标志为 true,并且随机参数生成一个新值,然后同步给 CDN。由于有了这个随机参数,CDN 不会缓存数据,每次都能从 CDN 中获取最新的 js 代码。
4、读多写少
使用 Redis 抵抗大量的请求。
5、缓存问题
==秒杀之前查询商品是否存在==
根据商品 id,先从缓存中查询商品,如果商品存在,则参与秒杀。如果不存在,则需要从数据库中查询商品,如果存在,则将商品信息放入缓存,然后参与秒杀。如果商品不存在,则直接提示失败。
5.1 缓存击穿
比如商品 A 第一次秒杀时,缓存中是没有数据的,但数据库中有。虽说上面有如果从数据库中查到数据,则放入缓存的逻辑。
然而,在高并发下,同一时刻会有大量的请求,都在秒杀同一件商品,这些请求同时去查缓存中没有数据,然后又同时访问数据库。结果悲剧了,数据库可能扛不住压力,直接挂掉。
==使用分布式锁解决上面的问题==
项目启动之前进行==缓存预热==。
表面上看起来,确实可以不需要。但如果缓存中设置的过期时间不对,缓存提前过期了,或者缓存被不小心删除了,如果不加速同样可能出现缓存击穿。
5.2 缓存穿透
如果有大量的请求传入的商品 id,在缓存中和数据库中都不存在,这些请求不就每次都会穿透过缓存,而直接访问数据库了。
系统根据商品 id,先从布隆过滤器中查询该 id 是否存在,如果存在则允许从缓存中查询数据,如果不存在,则直接返回失败。
虽说该方案可以解决缓存穿透问题,但是又会引出另外一个问题:布隆过滤器中的数据如何更缓存中的数据保持一致?
这就要求,如果缓存中数据有更新,则要及时同步到布隆过滤器中。如果数据同步失败了,还需要增加重试机制,而且跨数据源,能保证数据的实时一致性吗?
==布隆过滤器绝大部分使用在缓存数据更新很少的场景中。==
==如果缓存数据更新非常频繁,把不存在的商品 id 也缓存起来。==
下次,再有该商品 id 的请求过来,则也能从缓存中查到数据,只不过该数据比较特殊,表示商品不存在。需要特别注意的是,这种特殊缓存设置的超时时间应该尽量短一点。
6、库存问题
6.1、数据库扣减库存
1 | # 基于数据库的乐观锁,这样会少一次数据库查询,而且能够天然的保证数据操作的原子性。 |
但需要频繁访问数据库,我们都知道数据库连接是非常昂贵的资源。在高并发的场景下,可能会造成系统雪崩。而且,容易出现多个请求,同时竞争行锁的情况,造成相互等待,从而出现==死锁==的问题。
6.2、redis 扣减库存
==lua 脚本扣减库存==
1 | StringBuilder lua = new StringBuilder(); |
7、分布式锁
如果在高并发下,有大量的请求都去查一个缓存中不存在的商品,这些请求都会直接打到数据库。数据库由于承受不住压力,而直接挂掉。
7.1、setNx 加锁
1 | if (jedis.setnx(lockKey, val) == 1) { |
7.2、set 加锁
1 | String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); |
7.3、释放锁
requestId 是在释放锁的时候用的。
1 | if (jedis.get(lockKey).equals(requestId)) { |
1 | // lua脚本 |
7.4、自旋锁
在秒杀场景下,会有什么问题?
答:每 1 万个请求,有 1 个成功。再 1 万个请求,有 1 个成功。如此下去,直到库存不足。这就变成均匀分布的秒杀了,跟我们想象中的不一样。
如何解决这个问题呢?
答:使用自旋锁。在规定的时间,比如 500 毫秒内,自旋不断尝试加锁,如果成功则直接返回。如果失败,则休眠 50 毫秒,再发起新一轮的尝试。如果到了超时时间,还未加锁成功,则直接返回失败。
7.5、redisson
8、 mq 异步处理
8.1 消息丢失问题
秒杀成功了,往 mq 发送下单消息的时候,有可能会失败。原因有很多,比如:网络问题、broker 挂了、mq 服务端磁盘问题等。这些情况,都可能会造成消息丢失
==加一张消息发送表==
在生产者发送 mq 消息之前,先把该条消息写入消息发送表,初始状态是待处理,然后再发送 mq 消息。消费者消费消息时,处理完业务逻辑之后,再回调生产者的一个接口,修改消息状态为已处理。
发送 mq 消息到 mq 服务端的过程中失败了
==使用 job,增加重试机制==。
用 job 每隔一段时间去查询消息发送表中状态为待处理的数据,然后重新发送 mq 消息。
8.2、重复消费问题
本来消费者消费消息时,在 ack 应答的时候,如果网络超时,本身就可能会消费重复的消息。但由于消息发送者增加了重试机制,会导致消费者重复消息的概率增大。
那么,如何解决重复消息问题呢?
==答:加一张消息处理表。==
消费者读到消息之后,先判断一下消息处理表,是否存在该消息,如果存在,表示是重复消费,则直接返回。如果不存在,则进行下单操作,接着将该消息写入消息处理表中,再返回。
有个比较关键的点是:下单和写消息处理表,要放在同一个事务中,保证原子操作。
8.3、垃圾消息问题
这套方案表面上看起来没有问题,但如果出现了消息消费失败的情况。比如:由于某些原因,消息消费者下单一直失败,一直不能回调状态变更接口,这样 job 会不停的重试发消息。最后,会产生大量的垃圾消息。
每次在 job 重试时,需要先判断一下消息发送表中该消息的发送次数是否达到最大限制,如果达到了,则直接返回。如果没有达到,则将次数加 1,然后发送消息。
8.4、延迟消费问题
下单时消息生产者会先生成订单,此时状态为待支付,然后会向延迟队列中发一条消息。达到了延迟时间,消息消费者读取消息之后,会查询该订单的状态是否为待支付。如果是待支付状态,则会更新订单状态为取消状态。如果不是待支付状态,说明该订单已经支付过了,则直接返回。
9、如何限流?
三种常见的限流算法
9.1、计数器算法
计数器算法是限流算法里最简单也是最容易实现的一种算法。比如我们规定,对于 A 接口来说,我们 1 分钟的访问次数不能超过 100 个。那么我们可以这么做:在一开 始的时候,我们可以设置一个计数器 counter,每当一个请求过来的时候,counter 就加 1,如果 counter 的值大于 100 并且该请求与第一个 请求的间隔时间还在 1 分钟之内,那么说明请求数过多;如果该请求与第一个请求的间隔时间大于 1 分钟,且 counter 的值还在限流范围内,那么就重置 counter,具体算法的示意图如下:
==存在临界问题!!!==
滑动窗口
滑动窗口,又称 rolling window。为了解决计数器算法带来的问题,我们引入了滑动窗口算法。如果学过 TCP 网络协议的话,那么一定对滑动窗口这个名词不会陌生。下面这张图,很好地解释了滑动窗口算法:
计数器算法其实就是滑动窗口算法,滑动窗口算法是它的细粒度表现
==当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。==
9.2、令牌桶算法
1 | 1)、所有的请求在处理之前都需要拿到一个可用的令牌才会被处理; |
9.3、漏桶算法
漏桶算法其实很简单,可以粗略的认为就是注水漏水过程,往桶中以一定速率流出水,以任意速率流入水,当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。