秒杀系统设计

492 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第21天,点击查看活动详情

前言

高并发设计,不管是面试还是对程序员的挑战而言,都是走向高阶绕不过去的点。谈起高并发场景,最经典的非秒杀莫属。

关于怎么设计,笔者的整理方向是从外到内,从页面到接口处理,依次包括页面静态化、去掉一些花里花哨不必要页面效果、前端防刷/防抖、临时关闭一些非必要功能-降级、限流、接口防刷、验签、鉴权、扣减库存、处理后续业务(生产订单等)。

下面我们来逐一介绍下。

秒杀系统的架构原则

在开展之前,我们先了解下基本的原则。

1.数据要尽量少

  • 用户请求的数据能少就少。请求的数据包括上传给系统的数据和系统返回给用户的数据(通常就是网页)。

    因为网络上传输需要时间;请求数据和返回数据都需要服务器做处理,而服务器在写网络时要做压缩和字符编码,这些都非常消耗CPU,所以减少传输数量可以显著减少CPU的使用。例如简化秒杀页面的大小,去掉不必要的装修效果,等等。

  • 系统依赖的数据能少就少,包括系统完成业务逻辑需要读取和保存的数据,这些数据一般是和后台服务以及数据库打交道的。调用其他服务会涉及数据的序列化和反序列化,而这也是CPU的一大杀手,同样也会增加延时。数据库本身也容易成为一个瓶颈,所以和数据库打交道越少越好,数据越简单越好、越小越好。

2.请求数要尽量少

用户请求的页面返回后,浏览器渲染这个页面还要包含额外请求,比如说,这个页面依赖的CSS/JavaScript、图片,以及Ajax请求等等都定义为“额外请求”。浏览器每发出一个请求都会有一些消耗,比如建立连接要做三次握手,DNS解析耗时。

例如,减少请求数最常用的一个实践就是合并 CSS 和 JavaScript 文件,把多个 JavaScript文件合并成一个文件,在 URL 中用逗号隔开(g.xxx.com/tm/xxb/4.0.…)。这种方式在服务端仍然是单个文件各自存放,只是服务端会有一个组件解析这个 URL,然后动态把这些文件合并起来一起返回。还有就是一个接口能完成的,就没必要为了通用拆分成两个接口,后端进行复用。

3.路径要尽量短

“路径”指的是用户发出请求到返回数据这个过程中,需要经过的中间节点数。每经过一个节点,一般都会产生一个新的socket连接。

然而,每增加一个连接都会增加新的不确定性。从概率统计上来说,假如一次请求经过 5个节点,每个节点的可用性是 99.9% 的话,那么整个请求的可用性是:99.9% 的 5 次方,约等于 99.5%。

所以缩短请求路径不仅可以增加可用性,同样可以有效提升性能(减少中间节点可以减少数据的序列化与反序列化),并减少延时(可以减少网络传输耗时)。

要缩短访问路径有一种方法,就是多个相互依赖的应用合并部署在一起,把远程过程调用编程JVM内部之间的调用。

4.依赖尽量少

比如说你要展示秒杀页面,而这个页面必须强依赖商品信息、用户信息,还有其他如优惠券、成交列表等这些秒杀不是非要不可的信息(弱依赖),这些弱依赖在紧急情况下可以去掉。

要减少依赖,我们可以给系统进行分级,比如0级系统、1级系统、2级系统、3级系统,0级系统尽量减少对1级系统的强依赖,防止重要的系统被不重要的系统拖垮。

5.不要单点

这个线上的服务都不会是单点,就不用说了。

6. 隔离

系统隔离:对秒杀拆分出来作为一个微服务,分开部署,不让它影响其他业务。 数据隔离:和其他业务使用不同的数据库。

页面静态化

什么是静态数据?

静态数据就是谁访问都一样,可以缓冲住不用动态去读库才能拿到。

怎么对静态数据做缓存呢?

第一,你应该把静态化数据缓存到离用户最近的地方。比如用户的浏览器里,CDN上。

image.png

第二,如果不用CDN可以使用代理服务器的Cache功能,比如使用nginx缓存图片、html、css等。

去掉一些花里花哨不必要页面效果

原则中有数据量要少,所以可以去掉去掉一些花里花哨不必要页面效果/装饰,提高并发量。

前端防刷/防抖

前端可以放刷/防抖,比如1秒钟只允许点1次.

临时关闭一些非必要功能-降级

原则中有依赖要少,所以可以临时关闭一些非必要功能,确保秒杀主功能资源充足。

限流

限流属于安全手段,根据压测测出服务的最高tps,避免服务器崩。使用网关设置进行限流。

接口防刷

  • 可以根据ip和用户id进行限制,比如同一个用户只能1分钟请求20次接口,一个ip一分钟只能请求100次接口。使用网关设置进行接口放刷。
  • 风控

验签

一定程度上防止使用程序进行模拟请求。

鉴权

这个是必须的。

扣减库存

使用缓存预先扣除

扣减库存,主要是超卖问题和数据库压力问题。

笔者想到的是使用redis进行缓存库存数,用户请求下单接口过来先进行预扣减,只有库存大于0才能进行扣减,那么怎么保证判断和decr库存的原子性呢。我们可以使用lua脚本。

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 > 0) then");
lua.append("        redis.call('decr', KEYS[1]);");
lua.append("        return 1;");
lua.append("    end;");
lua.append("    return 0;");
lua.append("end;");
lua.append("return -1;");

1代表成功可以继续执行业务;0代表库存不足,返回库存不足异常,-1代表值不存在,需要进行设置缓存。

因为redis的能并发写81000次/s,所以只能满足1个商品万级别的秒杀,如果一个商品需要满足更大的并发秒杀,可以使用redis集群进行分片,把商品库存分摊到不同的redis分库中,当然你需要进行分片负载均衡、分片的调度等。

还会发生redis宕机的情况,可以使用Redisson同步给多个redis进行同步操作,避免出现意外能顶替。

缓存击穿

可以看到上面会出现返回-1的情况,当然一般会设置一个足够秒杀时间的过期时间,但是不排除出现意外,比如淘汰掉了等。所以需要进行缓存set,因为涉及到读数据库同时不允许重复set,所以需要加锁进行控制,我们可以使用redis的set nx进行加锁。

考虑到redis会出现宕机的情况,所以我们可以使用RedLock,但是RedLock也会出现其中一台宕机恢复后因为锁没有来的及持久化的情况,所以会出现其他线程同时进来,如果其中一个先进行set成功之后,马上有其他线程读到了进行了扣减,另外获得锁成功的线程又进行set就会出现超卖情况。可以使用lua进行set,先判断是否存在,不存在才进行set。

解决这个问题其实还有有一个简单的方式,从业务上去解决,对库存进行少设置点,避免这种极端情况。

参考

如何设计一个秒杀系统

高并发下秒杀商品,你必须知道的9个细节