作者简介:陶辉,毕业于西安交通大学计算机科学与技术专业,现就职于杭州智联达数据有限公司CTO兼联合创始人,专注于利用互联网技术帮助建筑行业实现转型升级。曾在华为、腾讯、思科、阿里巴巴等公司从事分布式系统下的数据处理工作。腾讯云最具价值专家TVP。具有丰富的Linux下高性能服务器开发和大型分布式系统设计经验。《深入理解 Nginx:模块开发与架构解析》一书的作者。本次分享分为4个部分:基础资源优化。无论是X86、ARM,还是Linux、Windows,无论是脚本语言还是C语言,这种优化都是有效的。相对来说,好处比较大,也比较包容。我把它列为基本资源优化。网络效率优化。这包括三层三层,一是系统层,二是应用层,三是传输效率的优化。减少请求延迟。如何使用缓存等,我们注重用户体验,让单个请求更快。吞吐量。提高系统并发性,如何在吞吐量方面提高系统并发性。1.基础资源优化在我看来,基础资源优化的核心是提高资源利用率。资源分为四个部分:CPU缓存。CPU的缓存分为三级。CPU访问缓存的延迟应该在10纳秒左右,一级缓存可能只有1纳秒左右。这时候我们就很关心如何提高CPU缓存的命中率,这是一种常见的优化方法。记忆。内存具有数百纳秒的速度。当你的频率很高的时候,就没那么快了,所以内存池就会很多。比如C、JVM、Python、Golang、Lua等都有自己的内存池。这些如何提高内存池分配速度,减少碎片,提高内存利用率。磁盘。磁盘有两种,一种是HDD,机械硬盘的寻址需要七八毫秒,磁头转速比较慢。过去很多软件技术都在优化这方面,尤其是PageCache磁盘缓存的使用。包括电梯调度算法,零拷贝,或者DirectIO等都是围绕机械盘来完成的;另一种SSD,SSD和机械硬盘是完全不同的方法,包括编程方法和优化方法都与传统方法不同。相同的。所以这里简单总结一下,以PageCache为切入点,包括命中率和IO调度算法。调度。分布式系统中有很多请求。如何切换这些请求,无论是多进程、多线程,还是协程,如何让它的调度性能更高、更高效、数据同步更快?关于CPUCPU缓存的话题有很多。这是一个例子。相信大家都用过Nginx。Nginx有两种哈希表,一种是域名哈希表。如果只用普通的三级域名,可能摸不着。但是,如果有4个、5个或更多的域名,则可能会超过64字节。这时候就需要增加哈希表的bucket_size。再举个例子,Nginx最强大的地方在于它的变量,任何功能都可以通过变量来实现。变量也存储在哈希表中。当这个变量比较大的时候,也应该调整为64字节。那么为什么是最初的64个字节呢?比如你要调整到100字节,可不可以?调整为128字节或64字节的整数倍。为什么?关于CPU缓存,我这里举个例子。2005年左右,CPU的频率提升到3Ghz~4Ghz左右就停止了,因为单个CPU发热量太大,只能横向发展。多个CPU的好处之一是真正的并发性。操作系统中的并发是通过时间片切换来实现的,并不是真正意义上的微观并发。但是多核CPU的并发有问题。当两个CPU同时访问两个进程或线程时,两个数据同时落入一个64字节。CPU缓存不是一个字节拉取,而是一批拉取,每批64字节。为了解决这个问题,Java中有一个常用词叫paddingmethod。刚才讲的Nginx也是一种padding的方式。它并没有使用那么多的字节,但是需要填充那么多,才能在微观层面上做到真正的并发。因为CPU需要保证一致性,如果数据不一致,就没有高性能和正确性。CPU缓存对很多代码都有影响,不仅是写中间件的,还有应用层的开发者。内存关于内存池,我想起了3年前我给华为的一个内部培训。当时有个同学问我,Nginx要不要换成谷歌的TCMalloc?(TCMalloc是Google开发的内存池。)每次应用程序需要分配内存时,操作系统内核都会提供称为brk和mmap的系统调用。这些系统调用效率不高,因为有内核态到用户态的切换,那怎么办呢?C程序有一个C库,在Linux中默认叫做Ptmalloc2。默认情况下,分配一个字节将为您预分配64MB字节。当你分配第二个字节的时候,你还是会从这个池中找,释放后会回到这个内存池中。这个游泳池有很多问题。比如ptmalloc,就是一个很一般的内存池,算是效率特别高的。比如A线程释放了,B线程就可以直接使用了。一定有并发问题,一定要加锁,加锁性能不高,像tcmalloc和ptmalloc默认这个,什么都不改。Google的TCMalloc分为小内存、中内存、大内存。小内存小于256KB字节。小内存不考虑共享,所以不需要加锁,速度很快;对于中内存和大内存,速度不如ptmalloc。如果我们做服务器开发,尤其是Nginx简单服务器开发,如果是动态服务器,我们往往会分配几MB的内存,但是在只有负载均衡和简单lua脚本操作的情况下是不可能分配大内存的,所以TCmalloc非常适合它。这里我只是介绍了C库的内存池。其实上面有很多内存池。例如,Nginx有一个用于共享内存的内存池slab。这个slab在openresty中是多路复用的。还有一个用于普通存储的内存。池,还有连接内存池和请求内存池。如果是别的语言,比如lua虚拟机,有自己的lua,有自己的内存池。Java有JVM内存池,golang也有自己的内存池。比如golang使用tcmalloc修改垃圾回收机制。这是我希望引起大家注意的,主要还是靠线下学习。磁盘磁盘,我想环绕PageCache。PageCache,一个传统的机械硬盘,你想让它并发,但它不能并发,它一直在旋转,所以你得用一种调度算法,让它尽可能地朝一个方向旋转。如果你看过它,你会认为PageCache的不间断缓存命中。比如零拷贝,零拷贝永远有效吗?比如你做直播或者视频的CDN,文件很大,再次被命中的概率不高。页缓存非常有限。如果文件这么大,因为有很多并发线在同时获取不同的文件,被命中的概率很低。如果它进入PageCache会发生什么?很难再次命中,PageCache也会降低其性能。因为PageCache用的最少,所以现在只要进来,就一定要出去。出去的东西都是小资源和文件,很有可能被再次访问,让你失去再次命中的机会,所以才会有DirectIO和asynchronousIO,linux的作者觉得接口设计不好,不过这东西很好用,一点用都没有。关于磁盘高速缓存还有很多要分享的。比如SDD,SDD在我看来是另外一个物种。大家通常认为“SDD比机械硬盘具有更高的IOPS、更低的延迟、更高的thoughtput,也就是更高的总吞吐量”,绝对不是这么简单的事情。我举个例子:首先,SSD有一个问题,叫做写放大。大家都知道Kafka,为什么Kafka的性能这么好呢?一个特点是削峰填谷,必须持久化数据。既然是写硬盘性能,为什么能这么好呢?由于充分利用了磁盘的旋转速度,消息队列本质上是一个顺序队列,必须不断追加到一个文件的末尾,所以磁盘利用率非常高。只要装上很多机械盘,性能就可以不断提升。如果我换成SSD,有这样的好处吗?SSD本来就快很多,但是这种方法有个问题,因为它有写放大的问题。什么是写放大?比如我们本来写日志文件,在后面追加一些字节,但是SSD不行。SSD有一个page,page是它的基本单位,它是按page来的。比如当前页已经有一个字节了,现在要写第二个字节,它会把整个页读入缓冲区,然后添加,最后写入一个没有人写过的新的。翻过的页数,所以有很大的放大效果。每次写一个byte,可能会写几个K,最怕的就是inplace写,会导致Kafka使用的方法出现问题。第二,磨损均衡。SSD寿命不好,为什么寿命短?因为不能清除,所以每个内存单元可以清除的次数是有限的。如果一个硬盘有一个特别热的数据,比如说是给操作系统的,经常读写,读完就坏了。这让所有人都难以忍受,其他地方还好。所以我设置了一个GC来为你不停地移动它。这边是热数据,这边是冷数据。我会为你移动它。但是这种机制存在一个问题,给应用带来了挑战。如果采用传统方式编写应用,不断进入大量写操作,就会不断触发该机制。当它的GC追上meter时,它又变成了阻塞,性能又下降了。事实上,SSD和HDD有很多不同之处。比如在使用机械硬盘的时候,你永远不会考虑多开几个线程来让速度变快,因为硬盘就在那儿转,开多了线程最终还是要排队的。但是SSD不一样,多开几个就这么快,因为它天生就可以做并发。比如SSD在随机读写方面也很擅长。我们尽量减少在机械硬盘上的数据读写,但是我们可以在SSD上读写数据。回到高并发,高并发为什么要用协程,为什么比多线程更快更高效?在我看来,有两个原因:1、每个协程消耗的内存很低。每根线有多高?我为你建立了一个堆内存池。你说一个字节创建一个内存池,线程有栈。什么时候栈会溢出?堆栈有多大?一般为2MB到8MB。对于这么大的线程,您能想象多少内存?如果在几十G的内存上有10000个线程并发运行,内存就不够用了,其他业务就做不了了。最大的问题是同时处理一个请求消耗的内存不可能多,协程可以做到。基本上几KB到十几KB的协程就可以处理一个请求。因此,通过十几G的内存,可以并发发送几万、几十万的请求。2、转换成本低。开启线程时,存在从内核态到用户态的切换,需要进行大量的拷贝。协程的话,因为是全栈用户态,不切技能的设备,或者不切技能的设备,可以直接调度到代码里切,所以成本比较低.最后总结一下,CPU有一个特别有效的工具。有时候在优化性能的时候,需要找到瓶颈,瓶颈越多,优化的价值就越大。如何找到瓶颈?我推荐火焰图。如果必须自己管理和编写日志,很容易错过。你不需要安装任何东西,你只需要安装一个Linuxperf+FlumeGraph软件。它分为两种类型,onCPU和offCPU。onCPU主要以暖色为主,看CPU消耗多少,因为所有的功能都考虑在内;而offCPU是冷色,看每个函数需要处理多长时间或者线程进入休眠?这两张图的一个好处是它是SVG矢量图,所以可以匹配各种正则表达式,还可以点击放大,它会将相同的函数合并到同一个调用栈中,方便对比哪个函数花费的时间最长。2.网络效率优化网络效率优化主要是编解码和改进传输方式。编解码器分为三部分:系统层传输效率。系统层主要是TCP协议,其优化包括三次握手链建立优化和四次握手关闭优化。主要看你的网络环境,丢包率高不高,时间延长不长;如果丢包率很好,可以调整重试次数。还有缓冲区优化和拥塞控制。应用层编码效率。应用层传输效率。对于应用层,主要依赖HTTP协议。1996年是HTTP1.0,1999年是HTTP1.1,我们现在主要用1.0和1.1,2015年是HTTP2,都运行在TLS和TCP上,为什么?因为可以简化开发效率。TCP实现的是有序字节流,TLS也是基于有序字节流。如果没有有序的字节流,现在是行不通的。例如,在QUIC中重新实现这一点。HTTP2,有了有序的字节流,很多问题不用考虑,不管文件多大,只要正常传输即可。这就带来了一个问题。在一个有序的字节流上,放了一个无序的东西(HTTP是多并发的,并发一定是无序的)。无序的东西承载在有序的字节流上,问题是行头被阻塞了。它有多个流。只要出现一个stream,TCP就有可能丢包,后面的stream都不行,只能通过UDP解决。UDP解决后,出现了一个QUIC层。这个QUIC层是一个独立的层。它在TCP层做了很多事情(丢包、重传、拥塞控制)。TLS也需要改写,HTTP2中的stream封装也放在里面。HTTP3有什么好处?1.复用;2、连接迁移;3.QPACK编码;4.丢包重传。让我举一个例子。比如你抓一个HTTP3的包,读包就知道UDPHeader在最上面。UDPHeader是一个4元组:源IP、目标IP、源端口和目标端口。接下来是包头。PacketHeader中有一个整数,叫做connection_id。为什么会有connection_id?以前的连接是如何定义的?四元组(源IP、目的IP、源端口、目的端口),只要四元组变了,就需要重新连接。物联网时代,各种高速移动设备会频繁切换,比如5G基站频繁切换,或者切换到WIFI。这时候IP地址肯定会发生变化,需要重新建立连接。重连的代价太高了,怎么不重连呢?从包头中,定义了一个connection_id整数。只要整数保持不变,就可以重用连接,不需要TLS握手。为什么可以这样做?其实很简单。这些东西加密到HTTP3。如果可以解密,安全性就没有问题。在PacketHeader中,这个叫做connectionmigration,也就是我们所说的ConnectionMigration,连接迁移,这是HTTP3的第一次使用。HTTP3的第二个用途是多路复用。复用到QUICFrameHeader。PacketHeader定义连接是乱序字节流连接。只要你不丢包,如果丢包,我们有一个ID,可以找到重传。但是顺序乱了,我不管了,顺序在QUICFrameHeader里面,他搞了个东西,他重新定义了一个概念叫QUIC流,就像用TCP连接一样,这里有个TCP连接就是与TCP连接相同。大家都知道TCP在三次握手的时候是在同步序列的。SYN参数的全称是SynchronizeSequenceNumbers。双方的序号是同步的,因为每发送一个字节序号都会加1,对方接受确认时也是如此。他身上也有一个序列,一切都一模一样。基于这个有序的字节流,解决了队头拥塞问题,所以它的复用是真正的复用。接下来是HTTP3帧头。QUICFrameHeader已经提供了TCP连接,但是HTTP有很多独立的应用。比如除了request/response,还有serverpush,就是一个新的FrameHeader,首先在前面加了一层head来完成这个功能。它还包括另一个功能,QPACK,众所周知,HTTP2中有一个HPACK,功能是压缩包头。最高压缩就像我们看视频的时候,有关键帧和增量帧。如果视频经过压缩,压缩后音量可以缩小数百倍,而且看起来还挺清晰的。是keyframe和incrementalFrame,压缩效率特别高,和HPACK完全一样。当您第一次在连接上传输HTTP标头时,这实际上是您的关键帧。后面做增量帧的时候,传一个数字就行了。但是这个方法也有计时的效果,谁先来。于是QPACK出现了,它是基于无序连接来实现一个功能。单播和组播,什么是单播?例如,主机1同时向主机2、3和4发送。如果是单播,则建立三个TCP连接,分别是红、蓝、绿。如果是网络层多播,就是这样发送的。例如主机1发送后,路由器A复制报文,发一份给路由器B,再发一份给主机2,到达路由器B后,又复制一遍。两份,一份给主机3,一份给主机4,所以它的网络效率应该特别高。但是它没有办法穿越网络。一是安全问题,容易引发网络风暴;第二,routerA和routerB不是同一个厂家生产的,不好处理。因此,只有在局域网内才会有网络层组播(ARP协议等)。因此,我们能使用的最好的东西就是应用层的组播。当主机1要发送给2、3、4时,就是这样发送的。Host1可能成为中心化节点来拉取它。也可以主动去拉。拉取完成后,3会从中央服务器拉取。比如4级没有,3级就拉给他。这非常有用。在大型集群中,会发布一个数百兆的新版本。这时候,如果单台机器推给大家,机器就会爆炸。即使你用的是10G或者40G的外接网卡,你的下限带宽也只有几G,抵挡不住几万台服务器同时拉动你的服务器。比如阿里开源的蜻蜓就很好用。它只是使用了这个概念,而且实现起来太容易了。可以实现任何协议。还是用HTTP协议好,整个实践成本会低很多。再比如GossIP协议,其实就是传染病协议。Redis集群中的管理节点都使用GossIP协议。3.减少请求延迟减少请求延迟,提升用户体验。我分为4个部分:缓存异步MapReduce流式计算除了刚才说的读缓存和加载缓存,还有写缓存。我们知道有一个CAP定理,缓存一定有P,因为数据是冗余的,在两台机器上都存在。缓存,但是在注重一致性的时候,它是一个直写缓存。AP可能会关心writeback的缓存,所以单次请求的使用率会更高。BASE理论也有很多缓存的用途,比如Nginx或者Openresty,后端宕机,每次访问都会返回502请求。如果加点东西,配置HTTP502,会直接把原来的缓存返回给客户端。所以可用性也提高了,相当于容灾,提供了基本的可用性。MapReduce讲的很多,分为3个步骤:数据分发,每个节点进行Map函数计算,后面合并输出。我们很多人用SQL做MapReduce,因为你用的是SQLGroupbyaggregationcalculation,它天生就兼容分布式和回避。无论是求标准差还是求平均值,也很容易做并行计算。可以办到。当然如果有前向和后向依赖就没办法了。MapReduce还有一个特点就是和数据源强相关,所以基本上Java生态在这方面是无敌的。原因是每个人的数据都放在里面,所以数据是互联网公司的核心资产——数据都在这里了,其他框架写的再好也没用。流式计算和MapReduce有很大的区别。它有一个时间窗口。不管你用的是技术窗口还是时间窗口,都是有时间顺序的,但是网报是没有时间顺序的,乱序的。所以窗口的划分比较麻烦。所以我们将有一个负载标记来在某种程度上缓解这种情况。二是业务强关联。有时候窗口是固定的时间,大部分时间可能是用户登录的时候,跟Session强相关。4.提高系统并发度提高系统并发度,如何高效地扩展系统?这里先说一下AKF扩展魔方。我觉得这个东西很好用,就给大家介绍一下。比如很多upstream都配置在Nginx上,upstream默认使用RoundRobin。不管你的上游是8核16G还是4核8G,你都会分配权重。两者之间的权重似乎不同,但任何请求两者都可以处理。所以它实际上是复制的,使用最少的连接数,它的重点是上游服务器的负载。这就是X轴,X轴的成本很低,加机器就行了。但是说到Y轴和Z轴,就不一样了。他们是基于邀请。比如MySQL,读写分离。我看到了一个select语句和一个update语句。如果是select语句,我随便找一个slave和standby数据库访问,就返回X轴了。如果是更新,我可能要进主库,就是读写分离。API网关也经常出现这种情况。代码重构后,希望将用户和日志划分到不同的集群中进行处理。这时候都是以Y轴为主,Y轴的成本很高。Z轴是分库分表,基本上是基于hash的,用户A的数据可能只到服务器1,用户B的数据可能到服务器2。Z轴的成本就不好说了。比如哈希算法,为什么叫一致性哈希?因为你对这种基于散列的负载平衡有很大的问题。请求的集合是一个近乎无限的集合,但是上游服务器的映射集合是非常有限的。您有多少台服务器和多少种选择?可以映射到这样的集群。从大簇映射到小簇,不管之前经过多少算法,最后一定要有盈余操作。如果没有多余的话,还没有总结成这么小的一套。如果要求余数,就有问题了。余数是不能变的,因为一旦你上游的机器宕机或者变大,余数就变了,整个哈希函数就变了。只要这个功能发生变化,整个Z轴就会发生很大的变化。我们将使用一次性哈希,它与哈希函数不同。哈希函数其实就是O1复杂度,但是一致性哈希,它不是O1复杂度,它会变成logM,它会把请求的关键字信息映射到一个哈希节点。hash是一个32位整数,32位整数构成0到42亿。42亿之后会形成一个环,到0的时候,经过这样有序的分割,正常使用的时候,是二分查找的。一致性哈希的正常使用还是有问题的。我们一般使用虚拟节点层。为什么要使用虚拟节点层?我们要避免雪崩效应。如果上游服务器挂了,只会影响周围的节点。如果周围的节点已经达到80%的负载,本机的流量全部通过,它也会挂掉。挂了之后,下游也挂了,都挂了。我们希望的是A节点挂掉后,所有节点都能平分流量。这是最好的。其实二次哈希就够了。所谓虚拟节点层,听上去很牛逼,其实就是二次哈希。第一个哈希形成一个环,第二个哈希使环成为另一个哈希,基本上是两层。一般来说我们每一层都是100到200,有了这样的东西,我们避免了雪崩,还有一个好处就是分布的均匀性。不管用什么样的功能,完成后,自己的数据请求分布不一致,很多数据很大。这个时候怎么解决呢?二次哈希可以使分布更均匀。最后,我们来谈谈坚持。我们以前做持久化数据的时候,用过两种方法。例如,如果一条数据有很多冗余副本,我们会想到两种方法。每次我们写,我们都写两套。性能很差。我写的时候随便写一个,用异步的方式同步到其他机器上。一致性比较差,可能会丢失数据,但是性能会更好。到了亚马逊,2000年发表了一篇Quorum论文,里面说的是NWR算法。数据冗余个数为N,W为写节点数,R为读节点数,N为冗余节点数。所谓high可以用W+R>N来实现。W+R大于N。比如数据有3个节点R1R2R3,read和write。如果我们写,返回1和3,不返回2。这个时候还是可以成功的。另外,读的时候只需要读2个,一定要能读1或者3。这时候我们只要通过时间戳的方式判断谁的数据是正确的,就可以保证数据的强一致性.W大于N,本来写了3个节点,现在挂了1个节点。如果你比这个小,例如,你只有2个节点。如果挂了一个节点,写就不行了。如果有3个节点,挂了一个还能用。今天的分享到此结束。我总结一下,自下而上的看法不一定正确。
