不知不觉,我们已经讲到第五篇了,不知道听到这里,你对于秒杀系统的构建有没有形成一些框架性的认识,这里我再带你简单回忆下前面的主线。

前面的四篇文章里,我介绍的内容多少都和优化有关:
第一篇介绍了一些指导原则;

第二篇和第三篇从动静分离和热点数据两个维度,介绍了如何有针对性地对数据进行区分和优化处理;

第四篇介绍了在保证实现基本业务功能的前提下,尽量减少和过滤一些无效请求的思路。

这几篇文章既是在讲根据指导原则实现的具体案例,也是在讲如何实现能够让整个系统更“快”。我想说的是,优化本身有很多手段,也是一个复杂的系统工程。今天,我就来结合秒杀这一场景,重点给你介绍下服务端的一些优化技巧。

影响性能的因素

你想要提升性能,首先肯定要知道哪些因素对于系统性能的影响最大,然后再针对这些具体的因素想办法做优化,是不是这个逻辑?

那么,哪些因素对性能有影响呢?在回答这个问题之前,我们先定义一下“性能”,服务设备不同对性能的定义也是不一样的,例如CPU主要看主频、磁盘主要看IOPS(Input/Output Operations Per Second,即每秒进行读写操作的次数)。

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

正常情况下响应时间(RT)越短,一秒钟处理的请求数(QPS)自然也就会越多,这在单线程处理的情况下看起来是线性的关系,即我们只要把每个请求的响应时间降到最低,那么性能就会最高。

但是你可能想到响应时间总有一个极限,不可能无限下降,所以又出现了另外一个维度,即通过多线程,来处理请求。这样理论上就变成了“总QPS =(1000ms / 响应时间)× 线程数量”,这样性能就和两个因素相关了,一个是一次响应的服务端耗时,一个是处理请求的线程数。

接下来,我们一起看看这个两个因素到底会造成什么样的影响。

首先,我们先来看看响应时间和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、内存、磁盘以及网络等都可能会导致瓶颈。此外,不同的系统对瓶颈的关注度也不一样,例如对缓存系统而言,制约它的是内存,而对存储型系统来说I/O更容易是瓶颈。

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

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

虽说秒杀系统的瓶颈大部分在CPU,但这并不表示其他方面就一定不出现瓶颈。例如,如果海量请求涌过来,你的页面又比较大,那么网络就有可能出现瓶颈。

怎样简单地判断CPU是不是瓶颈呢?一个办法就是看当QPS达到极限时,你的服务器的CPU使用率是不是超过了95%,如果没有超过,那么表示CPU还有提升的空间,要么是有锁限制,要么是有过多的本地I/O等待发生。

现在你知道了优化哪些因素,又发现了瓶颈,那么接下来就要关注如何优化了。

如何优化系统
对Java系统来说,可以优化的地方很多,这里我重点说一下比较有效的几种手段,供你参考,它们是:减少编码、减少序列化、Java极致优化、并发读优化。接下来,我们分别来看一下。

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

每个字符的编码都需要查表,而这种查表的操作非常耗资源,所以减少字符到字节或者相反的转换、减少字符编码会非常有成效。减少编码就可以大大提升性能。

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

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

  1. 减少序列化

序列化也是Java性能的一大天敌,减少Java中的序列化操作也能大大提升性能。又因为序列化往往是和编码同时发生的,所以减少序列化也就减少了编码。

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

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

另外针对秒杀场景,我们还可以做得更极致一些,接下来我们来看第3点:Java极致优化。

  1. Java极致优化

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

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

也许有读者会觉得这个问题很容易解决,无非就是放到Tair缓存里面。集中式缓存为了保证命中率一般都会采用一致性Hash,所以同一个key会落到同一台机器上。虽然单台缓存机器也能支撑30w/s的请求,但还是远不足以应对像“大秒”这种级别的热点商品。那么,该如何彻底解决单点的瓶颈呢?

答案是采用应用层的LocalCache,即在秒杀系统的单机上缓存商品相关的数据。

那么,又如何缓存(Cache)数据呢?你需要划分成动态数据和静态数据分别进行处理:

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

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

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

总结一下

性能优化的过程首先要从发现短板开始,除了我今天介绍的一些优化措施外,你还可以在减少数据、数据分级(动静分离),以及减少中间环节、增加预处理等这些环节上做优化。

首先是“发现短板”,比如考虑以下因素的一些限制:光速(光速:C = 30万千米/秒;光纤:V = C/1.5=20 万千米/秒,即数据传输是有物理距离的限制的)、网速(2017年11月知名测速网站Ookla发布报告,全国平均上网带宽达到61.24 Mbps,千兆带宽下10KB数据的极限QPS 为1.25万QPS=1000Mbps/8/10KB)、网络结构(交换机/网卡的限制)、TCP/IP、虚拟机(内存/CPU/IO等资源的限制)和应用本身的一些瓶颈等。

其次是减少数据。事实上,有两个地方特别影响性能,一是服务端在处理数据时不可避免地存在字符到字节的相互转化,二是HTTP请求时要做Gzip压缩,还有网络传输的耗时,这些都和数据大小密切相关。

再次,就是数据分级,也就是要保证首屏为先、重要信息为先,次要信息则异步加载,以这种方式提升用户获取数据的体验。

最后就是要减少中间环节,减少字符到字节的转换,增加预处理(提前做字符到字节的转换)去掉不需要的操作。

此外,要做好优化,你还需要做好应用基线,比如性能基线(何时性能突然下降)、成本基线(去年双11用了多少台机器)、链路基线(我们的系统发生了哪些变化),你可以通过这些基线持续关注系统的性能,做到在代码上提升编码质量,在业务上改掉不合理的调用,在架构和调用链路上不断的改进。