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
2
# 基于数据库的乐观锁,这样会少一次数据库查询,而且能够天然的保证数据操作的原子性。
update product set stock=stock-1 where id=product and stock > 0;

但需要频繁访问数据库,我们都知道数据库连接是非常昂贵的资源。在高并发的场景下,可能会造成系统雪崩。而且,容易出现多个请求,同时竞争行锁的情况,造成相互等待,从而出现==死锁==的问题。

6.2、redis 扣减库存

==lua 脚本扣减库存==

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
StringBuilder lua = new StringBuilder();
lua.append("if (redis.call('exists', KEYS[1]) == 1) then");
lua.append(" local stock = tonumber(redis.call('get', KEYS[1]));");
lua.append(" if (stock == -1) then");
lua.append(" return 1;");
lua.append(" end;");
lua.append(" if (stock > 0) then");
lua.append(" redis.call('incrby', KEYS[1], -1);");
lua.append(" return stock;");
lua.append(" end;");
lua.append(" return 0;");
lua.append("end;");
lua.append("return -1;");

先判断商品id是否存在,如果不存在则直接返回。
获取该商品id的库存,判断库存如果是-1,则直接返回,表示不限制库存。
如果库存大于0,则扣减库存。
如果库存等于0,是直接返回,表示库存不足。

7、分布式锁

如果在高并发下,有大量的请求都去查一个缓存中不存在的商品,这些请求都会直接打到数据库。数据库由于承受不住压力,而直接挂掉。

7.1、setNx 加锁
1
2
3
4
5
6
7
8
if (jedis.setnx(lockKey, val) == 1) {
jedis.expire(lockKey, timeout);
}

// 用该命令其实可以加锁,但和后面的设置超时时间是分开的,并非原子操作。

// 假如加锁成功了,但是设置超时时间失败了,该lockKey就变成永不失效的了。在高并发场景中,该问题会导致非常严重的后果。

7.2、set 加锁
1
2
3
4
5
6
7
8
9
10
11
12
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;

lockKey:锁的标识
requestId:请求id
NX:只在键不存在时,才对键进行设置操作。
PX:设置键的过期时间为 millisecond 毫秒。
expireTime:过期时间

7.3、释放锁

requestId 是在释放锁的时候用的。

1
2
3
4
5
if (jedis.get(lockKey).equals(requestId)) {
jedis.del(lockKey);
return true;
}
return false;
1
2
3
4
5
6
// lua脚本
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
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
2
3
4
5
1)、所有的请求在处理之前都需要拿到一个可用的令牌才会被处理;
2)、根据限流大小,设置按照一定的速率往桶里添加令牌;
3)、桶设置最大的放置令牌限制,当桶满时、新添加的令牌就被丢弃或者拒绝;
4)、请求达到后首先要获取令牌桶中的令牌,拿着令牌才可以进行其他的业务逻辑,处理完业务逻辑之后,将令牌直接删除;
5)、令牌桶有最低限额,当桶中的令牌达到最低限额的时候,请求处理完之后将不会删除令牌,以此保证足够的限流

9.3、漏桶算法

漏桶算法其实很简单,可以粗略的认为就是注水漏水过程,往桶中以一定速率流出水,以任意速率流入水,当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。