不同场景下的不同架构案例

如果你想快速搭建一个简单的秒杀系统,只需要把你的商品购买页面增加一个“定时上架”功能,仅在秒杀开始时才让用户看到购买按钮,当商品的库存卖完了也就结束了。这就是当时第一个版本的秒杀系统实现方式。

但随着请求量的加大(比如从1w/s到了10w/s的量级),这个简单的架构很快就遇到了瓶颈,因此需要做架构改造来提升系统性能。这些架构改造包括:

  1. 把秒杀系统独立出来单独打造一个系统,这样可以有针对性地做优化,例如这个独立出来的系统就减少了店铺装修的功能,减少了页面的复杂度;
  2. 在系统部署上也独立做一个机器集群,这样秒杀的大流量就不会影响到正常的商品购买集群的机器负载;
  3. 将热点数据(如库存数据)单独放到一个缓存系统中,以提高“读性能”;
  4. 增加秒杀答题,防止有秒杀器抢单。

image-20250912114800915

然而这个架构仍然支持不了超过100w/s的请求量,所以为了进一步提升秒杀系统的性能,我们又对架构做进一步升级,比如:

  1. 对页面进行彻底的动静分离,使得用户秒杀时不需要刷新整个页面,而只需要点击抢宝按钮,借此把页面刷新的数据降到最少;
  2. 在服务端对秒杀商品进行本地缓存,不需要再调用依赖系统的后台服务获取数据,甚至不需要去公共的缓存集群中查询数据,这样不仅可以减少系统调用,而且能够避免压垮公共缓存集群。
  3. 增加系统限流保护,防止最坏情况发生。

我们对页面进行了进一步的静态化,秒杀过程中不需要刷新整个页面,而只需要向服务端请求很少的动态数据。而且,最关键的详情和交易系统都增加了本地缓存,来提前缓存秒杀商品的信息,热点数据库也做了独立部署,等等。

image-20250912114836701

动静分离方案

何为动静数据

简单来说,“动态数据”和“静态数据”的主要区别就是看页面中输出的数据是否和URL、浏览者、时间、地域相关,以及是否含有Cookie等私密数据。比如说:

  1. 很多媒体类的网站,某一篇文章的内容不管是你访问还是我访问,它都是一样的。所以它就是一个典型的静态数据,但是它是个动态页面。
  2. 我们如果现在访问淘宝的首页,每个人看到的页面可能都是不一样的,淘宝首页中包含了很多根据访问者特征推荐的信息,而这些个性化的数据就可以理解为动态数据了。

那么,怎样对静态数据做缓存呢?我在这里总结了几个重点。

第一,你应该把静态数据缓存到离用户最近的地方。静态数据就是那些相对不会变化的数据,因此我们可以把它们缓存起来。缓存到哪里呢?常见的就三种,用户浏览器里、CDN上或者在服务端的Cache中。你应该根据情况,把它们尽量缓存到离用户最近的地方。

第二,静态化改造就是要直接缓存HTTP连接。相较于普通的数据缓存而言,你肯定还听过系统的静态化改造。静态化改造是直接缓存HTTP连接而不是仅仅缓存数据,如下图所示,Web代理服务器根据请求URL,直接取出对应的HTTP响应头和响应体然后直接返回,这个响应过程简单得连HTTP协议都不用重新组装,甚至连HTTP请求头也不需要解析。

image-20250912115240035

第三,让谁来缓存静态数据也很重要。不同语言写的Cache软件处理缓存数据的效率也各不相同。以Java为例,因为Java系统本身也有其弱点(比如不擅长处理大量连接请求,每个连接消耗的内存较多,Servlet容器解析HTTP协议较慢),所以你可以不在Java层做缓存,而是直接在Web服务器层上做,这样你就可以屏蔽Java语言层面的一些弱点;而相比起来,Web服务器(如Nginx、Apache、Varnish)也更擅长处理大并发的静态文件请求。

如何做动静分离的改造

下面,我以典型的商品详情系统为例来详细介绍。这里,你可以先打开京东或者淘宝的商品详情页,看看这个页面里都有哪些动静数据。我们从以下5个方面来分离出动态内容。

  1. URL唯一化。商品详情系统天然地就可以做到URL唯一化,比如每个商品都由ID来标识,那么http://item.xxx.com/item.htm?id=xxxx就可以作为唯一的URL标识。为啥要URL唯一呢?前面说了我们是要缓存整个HTTP连接,那么以什么作为Key呢?就以URL作为缓存的Key,例如以id=xxx这个格式进行区分。
  2. 分离浏览者相关的因素。浏览者相关的因素包括是否已登录,以及登录身份等,这些相关因素我们可以单独拆分出来,通过动态请求来获取。
  3. 分离时间因素。服务端输出的时间也通过动态请求获取。
  4. 异步化地域因素。详情页面上与地域相关的因素做成异步方式获取,当然你也可以通过动态请求方式获取,只是这里通过异步获取更合适。
  5. 去掉Cookie。服务端输出的页面包含的Cookie可以通过代码软件来删除,如Web服务器Varnish可以通过unset req.http.cookie 命令去掉Cookie。注意,这里说的去掉Cookie并不是用户端收到的页面就不含Cookie了,而是说,在缓存的静态数据中不含有Cookie。

分离出动态内容之后,如何组织这些内容页就变得非常关键了。这里我要提醒你一点,因为这其中很多动态内容都会被页面中的其他模块用到,如判断该用户是否已登录、用户ID是否匹配等,所以这个时候我们应该将这些信息JSON化(用JSON格式组织这些数据),以方便前端获取。

前面我们介绍里用缓存的方式来处理静态数据。而动态内容的处理通常有两种方案:ESI(Edge Side Includes)方案和CSI(Client Side Include)方案。

  1. ESI方案(或者SSI):即在Web代理服务器上做动态内容请求,并将请求插入到静态页面中,当用户拿到页面时已经是一个完整的页面了。这种方式对服务端性能有些影响,但是用户体验较好。
  2. CSI方案。即单独发起一个异步JavaScript 请求,以向服务端获取动态内容。这种方式服务端性能更佳,但是用户端页面可能会延时,体验稍差。

动静分离的几种架构方案

前面我们通过改造把静态数据和动态数据做了分离,那么如何在系统架构上进一步对这些动态和静态数据重新组合,再完整地输出给用户呢?

这就涉及对用户请求路径进行合理的架构了。根据架构上的复杂度,有3种方案可选:

  1. 实体机单机部署;
  2. 统一Cache层;
  3. 上CDN。

方案1:实体机单机部署

这种方案是将虚拟机改为实体机,以增大Cache的容量,并且采用了一致性Hash分组的方式来提升命中率。这里将Cache分成若干组,是希望能达到命中率和访问热点的平衡。Hash分组越少,缓存的命中率肯定就会越高,但短板是也会使单个商品集中在一个分组中,容易导致Cache被击穿,所以我们应该适当增加多个相同的分组,来平衡访问热点和命中率的问题。

image-20250912115638023

实体机单机部署有以下几个优点:

  1. 没有网络瓶颈,而且能使用大内存;
  2. 既能提升命中率,又能减少Gzip压缩;
  3. 减少Cache失效压力,因为采用定时失效方式,例如只缓存3秒钟,过期即自动失效。

这个方案中,虽然把通常只需要虚拟机或者容器运行的Java应用换成实体机,优势很明显,它会增加单机的内存容量,但是一定程度上也造成了CPU的浪费,因为单个的Java进程很难用完整个实体机的CPU。

另外就是,一个实体机上部署了Java应用又作为Cache来使用,这造成了运维上的高复杂度,所以这是一个折中的方案。如果你的公司里,没有更多的系统有类似需求,那么这样做也比较合适,如果你们有多个业务系统都有静态化改造的需求,那还是建议把Cache层单独抽出来公用比较合理,如下面的方案2所示。

方案2:统一Cache层

所谓统一Cache层,就是将单机的Cache统一分离出来,形成一个单独的Cache集群。统一Cache层是个更理想的可推广方案,该方案的结构图如下:

image-20250912115702515

将Cache层单独拿出来统一管理可以减少运维成本,同时也方便接入其他静态化系统。此外,它还有一些优点。

  1. 单独一个Cache层,可以减少多个应用接入时使用Cache的成本。这样接入的应用只要维护自己的Java系统就好,不需要单独维护Cache,而只关心如何使用即可。
  2. 统一Cache的方案更易于维护,如后面加强监控、配置的自动化,只需要一套解决方案就行,统一起来维护升级也比较方便。
  3. 可以共享内存,最大化利用内存,不同系统之间的内存可以动态切换,从而能够有效应对各种攻击。

这种方案虽然维护上更方便了,但是也带来了其他一些问题,比如缓存更加集中,导致:

  1. Cache层内部交换网络成为瓶颈;
  2. 缓存服务器的网卡也会是瓶颈;
  3. 机器少风险较大,挂掉一台就会影响很大一部分缓存数据。

要解决上面这些问题,可以再对Cache做Hash分组,即一组Cache缓存的内容相同,这样能够避免热点数据过度集中导致新的瓶颈产生。

一个商品数据存储在多个Cache实例中,如何保证数据一致性呢?

这个专栏中提的Hash分组都是基于Nginx+Varnish实现的,Nginx把请求的URL中的商品ID进行Hash并路由到一个upstream中,这个upstream挂载一个Varnish分组(如下图所示)。这样,一个相同的商品就可以随机访问一个分组的任意一台Varnish机器了。

image-20250912162900348

有Cache的地方就必然存在失效问题。为啥要失效?因为要保证数据的一致性。所以要用到Cache必然会问如何保证Cache和DB的数据一致性,如果Cache有分组的话,还要保证一个分组中多个实例之间数据的一致性,就像保证MySQL的主从一致一样。

其实,失效有主动失效和被动失效两种方式。

  • 被动失效,主要处理如模板变更和一些对时效性不太敏感数据的失效,采用设置一定时间长度(如只缓存3秒钟)这种自动失效的方式。当然,你也要开发一个后台管理界面,以便能够在紧急情况下手工失效某些Cache。
  • 主动失效,一般有Cache失效中心监控数据库表变化发送失效请求、系统发布也需要清空Cache数据等几种场景。其中失效中心承担了主要的失效功能,这个失效中心的逻辑图如下:

image-20250912163041092

失效中心会监控关键数据表的变更(有个中间件来解析MySQL的binglog,然后发现有Insert、Update、Delete等操作时,会把变更前的数据以及要变更的数据转成一个消息发送给订阅方),通过这种方式来发送失效请求给Cache,从而清除Cache数据。如果Cache数据放在CDN上,那么也可以采用类似的方式来设计级联的失效结构,采用主动发请求给Cache软件失效的方式,如下图所示:

image-20250912163117755

这种失效有失效中心将失效请求发送给每个CDN节点上的Console机,然后Console机来发送失效请求给每台Cache机器。

方案3:上CDN

在将整个系统做动静分离后,我们自然会想到更进一步的方案,就是将Cache进一步前移到CDN上,因为CDN离用户最近,效果会更好。

但是要想这么做,有以下几个问题需要解决。

  1. 失效问题。前面我们也有提到过缓存时效的问题,不知道你有没有理解,我再来解释一下。谈到静态数据时,我说过一个关键词叫“相对不变”,它的言外之意是“可能会变化”。比如一篇文章,现在不变,但如果你发现个错别字,是不是就会变化了?如果你的缓存时效很长,那用户端在很长一段时间内看到的都是错的。所以,这个方案中也是,我们需要保证CDN可以在秒级时间内,让分布在全国各地的Cache同时失效,这对CDN的失效系统要求很高。
  2. 命中率问题。Cache最重要的一个衡量指标就是“高命中率”,不然Cache的存在就失去了意义。同样,如果将数据全部放到全国的CDN上,必然导致Cache分散,而Cache分散又会导致访问请求命中同一个Cache的可能性降低,那么命中率就成为一个问题。
  3. 发布更新问题。如果一个业务系统每周都有日常业务需要发布,那么发布系统必须足够简洁高效,而且你还要考虑有问题时快速回滚和排查问题的简便性。

从前面的分析来看,将商品详情系统放到全国的所有CDN节点上是不太现实的,因为存在失效问题、命中率问题以及系统的发布更新问题。那么是否可以选择若干个节点来尝试实施呢?答案是“可以”,但是这样的节点需要满足几个条件:

  1. 靠近访问量比较集中的地区;
  2. 离主站相对较远;
  3. 节点到主站间的网络比较好,而且稳定;
  4. 节点容量比较大,不会占用其他CDN太多的资源。

最后,还有一点也很重要,那就是:节点不要太多。

基于上面几个因素,选择CDN的二级Cache比较合适,因为二级Cache数量偏少,容量也更大,让用户的请求先回源的CDN的二级Cache中,如果没命中再回源站获取数据,部署方式如下图所示:

image-20250912115722599

使用CDN的二级Cache作为缓存,可以达到和当前服务端静态化Cache类似的命中率,因为节点数不多,Cache不是很分散,访问量也比较集中,这样也就解决了命中率问题,同时能够给用户最好的访问体验,是当前比较理想的一种CDN化方案。

除此之外,CDN化部署方案还有以下几个特点:

  1. 把整个页面缓存在用户浏览器中;
  2. 如果强制刷新整个页面,也会请求CDN;
  3. 实际有效请求,只是用户对“刷新抢宝”按钮的点击。

这样就把90%的静态数据缓存在了用户端或者CDN上,当真正秒杀时,用户只需要点击特殊的“刷新抢宝”按钮,而不需要刷新整个页面。这样一来,系统只是向服务端请求很少的有效数据,而不需要重复请求大量的静态数据。

秒杀的动态数据和普通详情页面的动态数据相比更少,性能也提升了3倍以上。所以“抢宝”这种设计思路,让我们不用刷新页面就能够很好地请求到服务端最新的动态数据。

二八原则:有针对性地处理好系统的“热点数据”

发现热点数据

发现静态热点数据

如前面讲的,静态热点数据可以通过商业手段,例如强制让卖家通过报名参加的方式提前把热点商品筛选出来,实现方式是通过一个运营系统,把参加活动的商品数据进行打标,然后通过一个后台系统对这些热点商品进行预处理,如提前进行缓存。但是这种通过报名提前筛选的方式也会带来新的问题,即增加卖家的使用成本,而且实时性较差,也不太灵活。

不过,除了提前报名筛选这种方式,你还可以通过技术手段提前预测,例如对买家每天访问的商品进行大数据计算,然后统计出TOP N的商品,我们可以认为这些TOP N的商品就是热点商品。

发现动态热点数据

我们可以通过卖家报名或者大数据预测这些手段来提前预测静态热点数据,但这其中有一个痛点,就是实时性较差,如果我们的系统能在秒级内自动发现热点商品那就完美了。

能够动态地实时发现热点不仅对秒杀商品,对其他热卖商品也同样有价值,所以我们需要想办法实现热点的动态发现功能。

这里我给出一个动态热点发现系统的具体实现。

  1. 构建一个异步的系统,它可以收集交易链路上各个环节中的中间件产品的热点Key,如Nginx、缓存、RPC服务框架等这些中间件(一些中间件产品本身已经有热点统计模块)。
  2. 建立一个热点上报和可以按照需求订阅的热点服务的下发规范,主要目的是通过交易链路上各个系统(包括详情、购物车、交易、优惠、库存、物流等)访问的时间差,把上游已经发现的热点透传给下游系统,提前做好保护。比如,对于大促高峰期,详情系统是最早知道的,在统一接入层上Nginx模块统计的热点URL。
  3. 将上游系统收集的热点数据发送到热点服务台,然后下游系统(如交易系统)就会知道哪些商品会被频繁调用,然后做热点保护。

这里我给出了一个图,其中用户访问商品时经过的路径有很多,我们主要是依赖前面的导购页面(包括首页、搜索页面、商品详情、购物车等)提前识别哪些商品的访问量高,通过这些系统中的中间件来收集热点数据,并记录到日志中。

image-20250912120422769

处理热点数据

处理热点数据通常有几种思路:一是优化,二是限制,三是隔离

先来说说优化。优化热点数据最有效的办法就是缓存热点数据,如果热点数据做了动静分离,那么可以长期缓存静态数据。但是,缓存热点数据更多的是“临时”缓存,即不管是静态数据还是动态数据,都用一个队列短暂地缓存数秒钟,由于队列长度有限,可以采用LRU淘汰算法替换。

再来说说限制。限制更多的是一种保护机制,限制的办法也有很多,例如对被访问商品的ID做一致性Hash,然后根据Hash做分桶,每个分桶设置一个处理队列,这样可以把热点商品限制在一个请求队列里,防止因某些热点商品占用太多的服务器资源,而使其他请求始终得不到服务器的处理资源。

最后介绍一下隔离。秒杀系统设计的第一个原则就是将这种热点数据隔离出来,不要让1%的请求影响到另外的99%,隔离出来后也更方便对这1%的请求做针对性的优化。

具体到“秒杀”业务,我们可以在以下几个层次实现隔离。

  1. 业务隔离。把秒杀做成一种营销活动,卖家要参加秒杀这种营销活动需要单独报名,从技术上来说,卖家报名后对我们来说就有了已知热点,因此可以提前做好预热。
  2. 系统隔离。系统隔离更多的是运行时的隔离,可以通过分组部署的方式和另外99%分开。秒杀可以申请单独的域名,目的也是让请求落到不同的集群中。
  3. 数据隔离。秒杀所调用的数据大部分都是热点数据,比如会启用单独的Cache集群或者MySQL数据库来放热点数据,目的也是不想0.01%的数据有机会影响99.99%数据。

当然了,实现隔离有很多种办法。比如,你可以按照用户来区分,给不同的用户分配不同的Cookie,在接入层,路由到不同的服务接口中;再比如,你还可以在接入层针对URL中的不同Path来设置限流策略。服务层调用不同的服务接口,以及数据层通过给数据打标来区分等等这些措施,其目的都是把已经识别出来的热点请求和普通的请求区分开。

流量削峰这事应该怎么做?

排队

要对流量进行削峰,最容易想到的解决方案就是用消息队列来缓冲瞬时流量,把同步的直接调用转换成异步的间接推送,中间通过一个队列在一端承接瞬时的流量洪峰,在另一端平滑地将消息推送出去。

但是,如果流量峰值持续一段时间达到了消息队列的处理上限,例如本机的消息积压达到了存储空间的上限,消息队列同样也会被压垮,这样虽然保护了下游的系统,但是和直接把请求丢弃也没多大的区别。就像遇到洪水爆发时,即使是有水库恐怕也无济于事。

除了消息队列,类似的排队方式还有很多,例如:

  1. 利用线程池加锁等待也是一种常用的排队方式;
  2. 先进先出、先进后出等常用的内存排队算法的实现方式;
  3. 把请求序列化到文件中,然后再顺序地读文件(例如基于MySQL binlog的同步机制)来恢复请求等方式。

答题

最早期的秒杀只是纯粹地刷新页面和点击购买按钮,它是后来才增加了答题功能的。那么,为什么要增加答题功能呢?

这主要是为了增加购买的复杂度,从而达到两个目的。

第一个目的是防止部分买家使用秒杀器在参加秒杀时作弊。2011年秒杀非常火的时候,秒杀器也比较猖獗,因而没有达到全民参与和营销的目的,所以系统增加了答题来限制秒杀器。增加答题后,下单的时间基本控制在2s后,秒杀器的下单比例也大大下降。

第二个目的其实就是延缓请求,起到对请求流量进行削峰的作用,从而让系统能够更好地支持瞬时的流量高峰。这个重要的功能就是把峰值的下单请求拉长,从以前的1s之内延长到2s~10s。这样一来,请求峰值基于时间分片了。这个时间的分片对服务端处理并发非常重要,会大大减轻压力。而且,由于请求具有先后顺序,靠后的请求到来时自然也就没有库存了,因此根本到不了最后的下单步骤,所以真正的并发写就非常有限了。这种设计思路目前用得非常普遍,如当年支付宝的“咻一咻”、微信的“摇一摇”都是类似的方式。

这里,我重点说一下秒杀答题的设计思路。

image-20250912155807956

如上图所示,整个秒杀答题的逻辑主要分为3部分。

  1. 题库生成模块,这个部分主要就是生成一个个问题和答案,其实题目和答案本身并不需要很复杂,重要的是能够防止由机器来算出结果,即防止秒杀器来答题。
  2. 题库的推送模块,用于在秒杀答题前,把题目提前推送给详情系统和交易系统。题库的推送主要是为了保证每次用户请求的题目是唯一的,目的也是防止答题作弊。
  3. 题目的图片生成模块,用于把题目生成为图片格式,并且在图片里增加一些干扰因素。这也同样是为防止机器直接来答题,它要求只有人才能理解题目本身的含义。这里还要注意一点,由于答题时网络比较拥挤,我们应该把题目的图片提前推送到CDN上并且要进行预热,不然的话当用户真正请求题目时,图片可能加载比较慢,从而影响答题的体验。

其实真正答题的逻辑比较简单,很好理解:当用户提交的答案和题目对应的答案做比较,如果通过了就继续进行下一步的下单逻辑,否则就失败。我们可以把问题和答案用下面这样的key来进行MD5加密:

  • 问题key:userId+itemId+question_Id+time+PK
  • 答案key:userId+itemId+answer+PK

验证的逻辑如下图所示:

image-20250912155917942

注意,这里面的验证逻辑,除了验证问题的答案以外,还包括用户本身身份的验证,例如是否已经登录、用户的Cookie是否完整、用户是否重复频繁提交等。

除了做正确性验证,我们还可以对提交答案的时间做些限制,例如从开始答题到接受答案要超过1s,因为小于1s是人为操作的可能性很小,这样也能防止机器答题的情况。

分层过滤

前面介绍的排队和答题要么是少发请求,要么对发出来的请求进行缓冲,而针对秒杀场景还有一种方法,就是对请求进行分层过滤,从而过滤掉一些无效的请求。分层过滤其实就是采用“漏斗”式设计来处理请求的,如下图所示。

image-20250912160024914

假如请求分别经过CDN、前台读系统(如商品详情系统)、后台系统(如交易系统)和数据库这几层,那么:

  • 大部分数据和流量在用户浏览器或者CDN上获取,这一层可以拦截大部分数据的读取;
  • 经过第二层(即前台系统)时数据(包括强一致性的数据)尽量得走Cache,过滤一些无效的请求;
  • 再到第三层后台系统,主要做数据的二次检验,对系统做好保护和限流,这样数据量和请求就进一步减少;
  • 最后在数据层完成数据的强一致性校验。

这样就像漏斗一样,尽量把数据量和请求量一层一层地过滤和减少了。

分层过滤的核心思想是:在不同的层次尽可能地过滤掉无效请求,让“漏斗”最末端的才是有效请求。而要达到这种效果,我们就必须对数据做分层的校验。

分层校验的基本原则是:

  1. 将动态请求的读数据缓存(Cache)在Web端,过滤掉无效的数据读;
  2. 对读数据不做强一致性校验,减少因为一致性校验产生瓶颈的问题;
  3. 对写数据进行基于时间的合理分片,过滤掉过期的失效请求;
  4. 对写请求做限流保护,将超出系统承载能力的请求过滤掉;
  5. 对写数据进行强一致性校验,只保留最后有效的数据。

影响性能的因素有哪些?

影响性能的因素

我们讨论的主要是系统服务端性能,一般用QPS(Query Per Second,每秒请求数)来衡量,还有一个影响和QPS也息息相关,那就是响应时间(Response Time,RT),它可以理解为服务器处理响应的耗时。

首先,我们先来看看响应时间和QPS有啥关系

对于大部分的Web系统而言,响应时间一般都是由CPU执行时间和线程等待时间(比如RPC、IO等待、Sleep、Wait等)组成,即服务器在处理一个请求时,一部分是CPU本身在做运算,还有一部分是在各种等待。

理解了服务器处理请求的逻辑,估计你会说为什么我们不去减少这种等待时间。很遗憾,根据我们实际的测试发现,减少线程等待时间对提升性能的影响没有我们想象得那么大,它并不是线性的提升关系,这点在很多代理服务器(Proxy)上可以做验证。

如果代理服务器本身没有CPU消耗,我们在每次给代理服务器代理的请求加个延时,即增加响应时间,但是这对代理服务器本身的吞吐量并没有多大的影响,因为代理服务器本身的资源并没有被消耗,可以通过增加代理服务器的处理线程数,来弥补响应时间对代理服务器的QPS的影响。

其实,真正对性能有影响的是CPU的执行时间。这也很好理解,因为CPU的执行真正消耗了服务器的资源。经过实际的测试,如果减少CPU一半的执行时间,就可以增加一倍的QPS。

也就是说,我们应该致力于减少CPU的执行时间。

其次,我们再来看看线程数对QPS的影响

单看“总QPS”的计算公式,你会觉得线程数越多QPS也就会越高,但这会一直正确吗?显然不是,线程数不是越多越好,因为线程本身也消耗资源,也受到其他因素的制约。例如,线程越多系统的线程切换成本就会越高,而且每个线程也都会耗费一定内存。

那么,设置什么样的线程数最合理呢?其实很多多线程的场景都有一个默认配置,即“线程数 = 2 * CPU核数 + 1”。除去这个配置,还有一个根据最佳实践得出来的公式:

线程数 = [(线程等待时间 + 线程CPU时间) / 线程CPU时间] × CPU数量

当然,最好的办法是通过性能测试来发现最佳的线程数。

换句话说,要提升性能我们就要减少CPU的执行时间,另外就是要设置一个合理的并发线程数,通过这两方面来显著提升服务器的性能。

如何发现瓶颈

那么,如何发现CPU的瓶颈呢?其实有很多CPU诊断工具可以发现CPU的消耗,最常用的就是JProfiler和Yourkit这两个工具,它们可以列出整个请求中每个函数的CPU执行时间,可以发现哪个函数消耗的CPU时间最多,以便你有针对性地做优化。

当然还有一些办法也可以近似地统计CPU的耗时,例如通过jstack定时地打印调用栈,如果某些函数调用频繁或者耗时较多,那么那些函数就会多次出现在系统调用栈里,这样相当于采样的方式也能够发现耗时较多的函数。

如何优化系统

减少编码

Java的编码运行比较慢,这是Java的一大硬伤。在很多场景下,只要涉及字符串的操作(如输入输出操作、I/O操作)都比较耗CPU资源,不管它是磁盘I/O还是网络I/O,因为都需要将字符转换成字节,而这个转换必须编码。

那么如何才能减少编码呢?例如,网页输出是可以直接进行流输出的,即用resp.getOutputStream()函数写数据,把一些静态的数据提前转化成字节,等到真正往外写的时候再直接用OutputStream()函数写,就可以减少静态数据的编码转换。

我在《深入分析Java Web技术内幕》一书中介绍的“Velocity优化实践”一章的内容,就是基于把静态的字符串提前编码成字节并缓存,然后直接输出字节内容到页面,从而大大减少编码的性能消耗的,网页输出的性能比没有提前进行字符到字节转换时提升了30%左右。

减少序列化

序列化大部分是在RPC中发生的,因此避免或者减少RPC就可以减少序列化,当然当前的序列化协议也已经做了很多优化来提升性能。有一种新的方案,就是可以将多个关联性比较强的应用进行“合并部署”,而减少不同应用之间的RPC也可以减少序列化的消耗。

所谓“合并部署”,就是把两个原本在不同机器上的不同应用合并部署到一台机器上,当然不仅仅是部署在一台机器上,还要在同一个Tomcat容器中,且不能走本机的Socket,这样才能避免序列化的产生

Java极致优化

Java和通用的Web服务器(如Nginx或Apache服务器)相比,在处理大并发的HTTP请求时要弱一点,所以一般我们都会对大流量的Web系统做静态化改造,让大部分请求和数据直接在Nginx服务器或者Web代理服务器(如Varnish、Squid等)上直接返回(这样可以减少数据的序列化与反序列化),而Java层只需处理少量数据的动态请求。针对这些请求,我们可以使用以下手段进行优化:

  • 直接使用Servlet处理请求。避免使用传统的MVC框架,这样可以绕过一大堆复杂且用处不大的处理逻辑,节省1ms时间(具体取决于你对MVC框架的依赖程度)。
  • 直接输出流数据。使用resp.getOutputStream()而不是resp.getWriter()函数,可以省掉一些不变字符数据的编码,从而提升性能;数据输出时推荐使用JSON而不是模板引擎(一般都是解释执行)来输出页面。

并发读优化

  • 像商品中的“标题”和“描述”这些本身不变的数据,会在秒杀开始之前全量推送到秒杀机器上,并一直缓存到秒杀结束;
  • 像库存这类动态数据,会采用“被动失效”的方式缓存一定时间(一般是数秒),失效后再去缓存拉取最新的数据。

你可能还会有疑问:像库存这种频繁更新的数据,一旦数据不一致,会不会导致超卖?

这就要用到前面介绍的读数据的分层校验原则了,读的场景可以允许一定的脏数据,因为这里的误判只会导致少量原本无库存的下单请求被误认为有库存,可以等到真正写数据时再保证最终的一致性,通过在数据的高可用性和一致性之间的平衡,来解决高并发的数据读取问题。

秒杀系统“减库存”设计的核心逻辑

如果要设计一套秒杀系统,那我想你的老板肯定会先对你说:千万不要超卖,这是大前提。

我们平常购物都是这样,看到喜欢的商品然后下单,但并不是每个下单请求你都最后付款了。你说系统是用户下单了就算这个商品卖出去了,还是等到用户真正付款了才算卖出了呢?这的确是个问题!

我们可以先根据减库存是发生在下单阶段还是付款阶段,把减库存做一下划分。

减库存有哪几种方式

总结来说,减库存操作一般有如下几个方式:

  • 下单减库存,即当买家下单后,在商品的总库存中减去买家购买数量。下单减库存是最简单的减库存方式,也是控制最精确的一种,下单时直接通过数据库的事务机制控制商品库存,这样一定不会出现超卖的情况。但是你要知道,有些人下完单可能并不会付款。
  • 付款减库存,即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。但因为付款时才减库存,如果并发比较高,有可能出现买家下单后付不了款的情况,因为可能商品已经被其他人买走了。
  • 预扣库存,这种方式相对复杂一些,买家下单后,库存为其保留一定的时间(如10分钟),超过这个时间,库存将会自动释放,释放后其他买家就可以继续购买。在买家付款前,系统会校验该订单的库存是否还有保留:如果没有保留,则再次尝试预扣;如果库存不足(也就是预扣失败)则不允许继续付款;如果预扣成功,则完成付款并实际地减去库存。

减库存可能存在的问题

假如我们采用“下单减库存”的方式,即用户下单后就减去库存,正常情况下,买家下单后付款的概率会很高,所以不会有太大问题。但是有一种场景例外,就是当卖家参加某个活动时,此时活动的有效时间是商品的黄金售卖时间,如果有竞争对手通过恶意下单的方式将该卖家的商品全部下单,让这款商品的库存减为零,那么这款商品就不能正常售卖了。要知道,这些恶意下单的人是不会真正付款的,这正是“下单减库存”方式的不足之处。

“付款减库存”又会导致另外一个问题:库存超卖。假如有100件商品,就可能出现300人下单成功的情况,因为下单时不会减库存,所以也就可能出现下单成功数远远超过真正库存数的情况,这尤其会发生在做活动的热门商品上。这样一来,就会导致很多买家下单成功但是付不了款,买家的购物体验自然比较差。

那么,既然“下单减库存”和“付款减库存”都有缺点,我们能否把两者相结合,将两次操作进行前后关联起来,下单时先预扣,在规定时间内不付款再释放库存,即采用“预扣库存”这种方式呢?

这种方案确实可以在一定程度上缓解上面的问题。但是否就彻底解决了呢?其实没有!针对恶意下单这种情况,虽然把有效的付款时间设置为10分钟,但是恶意买家完全可以在10分钟后再次下单,或者采用一次下单很多件的方式把库存减完。针对这种情况,解决办法还是要结合安全和反作弊的措施来制止。

例如,给经常下单不付款的买家进行识别打标(可以在被打标的买家下单时不减库存)、给某些类目设置最大购买件数(例如,参加活动的商品一人最多只能买3件),以及对重复下单不付款的操作进行次数限制等。

针对“库存超卖”这种情况,在10分钟时间内下单的数量仍然有可能超过库存数量,遇到这种情况我们只能区别对待:对普通的商品下单数量超过库存数量的情况,可以通过补货来解决;但是有些卖家完全不允许库存为负数的情况,那只能在买家付款时提示库存不足。

大型秒杀中如何减库存?

目前来看,业务系统中最常见的就是预扣库存方案,像你在买机票、买电影票时,下单后一般都有个“有效付款时间”,超过这个时间订单自动释放,这都是典型的预扣库存方案。而具体到秒杀这个场景,应该采用哪种方案比较好呢?

由于参加秒杀的商品,一般都是“抢到就是赚到”,所以成功下单后却不付款的情况比较少,再加上卖家对秒杀商品的库存有严格限制,所以秒杀商品采用“下单减库存”更加合理。另外,理论上由于“下单减库存”比“预扣库存”以及涉及第三方支付的“付款减库存”在逻辑上更为简单,所以性能上更占优势。

“下单减库存”在数据一致性上,主要就是保证大并发请求时库存数据不能为负数,也就是要保证数据库中的库存字段值不能为负数,一般我们有多种解决方案:一种是在应用程序中通过事务来判断,即保证减后库存不能为负数,否则就回滚;另一种办法是直接设置数据库的字段数据为无符号整数,这样减后库存字段值小于零时会直接执行SQL语句来报错;再有一种就是使用CASE WHEN判断语句,例如这样的SQL语句:

1
UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END

秒杀减库存的极致优化

由于MySQL存储数据的特点,同一数据在数据库里肯定是一行存储(MySQL),因此会有大量线程来竞争InnoDB行锁,而并发度越高时等待线程会越多,TPS(Transaction Per Second,即每秒处理的消息数)会下降,响应时间(RT)会上升,数据库的吞吐量就会严重受影响。

这就可能引发一个问题,就是单个热点商品会影响整个数据库的性能, 导致0.01%的商品影响99.99%的商品的售卖,这是我们不愿意看到的情况。一个解决思路是遵循前面介绍的原则进行隔离,把热点商品放到单独的热点库中。但是这无疑会带来维护上的麻烦,比如要做热点数据的动态迁移以及单独的数据库等。

而分离热点商品到单独的数据库还是没有解决并发锁的问题,我们应该怎么办呢?要解决并发锁的问题,有两种办法:

  • 应用层做排队。按照商品维度设置队列顺序执行,这样能减少同一台机器对数据库同一行记录进行操作的并发度,同时也能控制单个商品占用数据库连接的数量,防止热点商品占用太多的数据库连接。
  • 数据库层做排队。应用层只能做到单机的排队,但是应用机器数本身很多,这种排队方式控制并发的能力仍然有限,所以如果能在数据库层做全局排队是最理想的。阿里的数据库团队开发了针对这种MySQL的InnoDB层上的补丁程序(patch),可以在数据库层上对单行记录做到并发排队。

你可能有疑问了,排队和锁竞争不都是要等待吗,有啥区别?

如果熟悉MySQL的话,你会知道InnoDB内部的死锁检测,以及MySQL Server和InnoDB的切换会比较消耗性能,淘宝的MySQL核心团队还做了很多其他方面的优化,如COMMIT_ON_SUCCESS和ROLLBACK_ON_FAIL的补丁程序,配合在SQL里面加提示(hint),在事务里不需要等待应用层提交(COMMIT),而在数据执行完最后一条SQL后,直接根据TARGET_AFFECT_ROW的结果进行提交或回滚,可以减少网络等待时间(平均约0.7ms)。据我所知,目前阿里MySQL团队已经将包含这些补丁程序的MySQL开源。

另外,数据更新问题除了前面介绍的热点隔离和排队处理之外,还有些场景(如对商品的lastmodifytime字段的)更新会非常频繁,在某些场景下这些多条SQL是可以合并的,一定时间内只要执行最后一条SQL就行了,以便减少对数据库的更新操作。

如果异步的请求失败了,怎么办?

对秒杀来说,我觉得如果失败了直接丢弃就好了,最坏的结果就是这个人没有抢到而已。但是你非要纠结的话,就要做异步消息的持久化以及重试机制了,要保证异步请求的最终正确处理一般都要借助消息系统,即消息的最终可达,例如阿里的消息中间件是能承诺只要客户端消息发送成功,那么消息系统一定会保证消息最终被送到目的地,即消息不会丢。因为客户端只要成功发送一条消息,下游消费方就一定会消费这条消息,所以也就不存在消息发送失败的问题了。

准备Plan B:如何设计兜底方案

降级

所谓“降级”,就是当系统的容量达到一定程度时,限制或者关闭系统的某些非核心功能,从而把有限的资源保留给更核心的业务。它是一个有目的、有计划的执行过程,所以对降级我们一般需要有一套预案来配合执行。如果我们把它系统化,就可以通过预案系统和开关系统来实现降级。

降级方案可以这样设计:当秒杀流量达到5w/s时,把成交记录的获取从展示20条降级到只展示5条。“从20改到5”这个操作由一个开关来实现,也就是设置一个能够从开关系统动态获取的系统参数。

这里,我给出开关系统的示意图。它分为两部分,一部分是开关控制台,它保存了开关的具体配置信息,以及具体执行开关所对应的机器列表;另一部分是执行下发开关数据的Agent,主要任务就是保证开关被正确执行,即使系统重启后也会生效。

image-20250912161948237

执行降级无疑是在系统性能和用户体验之间选择了前者,降级后肯定会影响一部分用户的体验,例如在双11零点时,如果优惠券系统扛不住,可能会临时降级商品详情的优惠信息展示,把有限的系统资源用在保障交易系统正确展示优惠信息上,即保障用户真正下单时的价格是正确的。所以降级的核心目标是牺牲次要的功能和用户体验来保证核心业务流程的稳定,是一个不得已而为之的举措。

限流

如果说降级是牺牲了一部分次要的功能和用户的体验效果,那么限流就是更极端的一种保护措施了。限流就是当系统容量达到瓶颈时,我们需要通过限制一部分流量来保护系统,并做到既可以人工执行开关,也支持自动化保护的措施。

这里,我同样给出了限流系统的示意图。总体来说,限流既可以是在客户端限流,也可以是在服务端限流。此外,限流的实现方式既要支持URL以及方法级别的限流,也要支持基于QPS和线程的限流。

首先,我以内部的系统调用为例,来分别说下客户端限流和服务端限流的优缺点。

  • 客户端限流,好处可以限制请求的发出,通过减少发出无用请求从而减少对系统的消耗。缺点就是当客户端比较分散时,没法设置合理的限流阈值:如果阈值设的太小,会导致服务端没有达到瓶颈时客户端已经被限制;而如果设的太大,则起不到限制的作用。
  • 服务端限流,好处是可以根据服务端的性能设置合理的阈值,而缺点就是被限制的请求都是无效的请求,处理这些无效的请求本身也会消耗服务器资源。

在限流的实现手段上来讲,基于QPS和线程数的限流应用最多,最大QPS很容易通过压测提前获取,例如我们的系统最高支持1w QPS时,可以设置8000来进行限流保护。线程数限流在客户端比较有效,例如在远程调用时我们设置连接池的线程数,超出这个并发线程请求,就将线程进行排队或者直接超时丢弃。

限流无疑会影响用户的正常请求,所以必然会导致一部分用户请求失败,因此在系统处理这种异常时一定要设置超时时间,防止因被限流的请求不能fast fail(快速失败)而拖垮系统。

拒绝服务

如果限流还不能解决问题,最后一招就是直接拒绝服务了。

当系统负载达到一定阈值时,例如CPU使用率达到90%或者系统load值达到2*CPU核数时,系统直接拒绝所有请求,这种方式是最暴力但也最有效的系统保护方式。例如秒杀系统,我们在如下几个环节设计过载保护:

在最前端的Nginx上设置过载保护,当机器负载达到某个值时直接拒绝HTTP请求并返回503错误码,在Java层同样也可以设计过载保护。

拒绝服务可以说是一种不得已的兜底方案,用以防止最坏情况发生,防止因把服务器压跨而长时间彻底无法提供服务。像这种系统过载保护虽然在过载时无法提供服务,但是系统仍然可以运作,当负载下降时又很容易恢复,所以每个系统和每个环节都应该设置这个兜底方案,对系统做最坏情况下的保护。