性能指的是啥?
机器的资源主要有三种:CPU、IO、内存和带宽,此外还有磁盘,但是因为现在磁盘在经济上比较便宜,所以考虑较少。
与之对应的指标是CPU使用率,IO使用率,内存使用率,带宽占用率等;此外还有多个指标共同作用的指标,比如延迟(一般是CPU和IO配合影响两者);还有相关的衍生指标,比如吞吐量,延迟等。
这篇文章主要讲方法和概念,比较抽象,后面可以结合具体设计的例子来做更好的讲解。阅读量超过1w就加续集吧。
性能优化方法
性能的指标衡量方法从打出来说有两种:
- 系统整体性能:整体吞吐量、延迟等
- 单机性能:单机的吞吐量、延迟等
系统整体性能
系统整体性能的提升体现的是系统的可扩展性,如果系统可以方便进行水平扩展,则可以使用多个廉价机器进行扩展,不存在单点瓶颈。当前众多典型的中间件,比如zookeeper,redis,kafka等都支持自然水平扩展。系统整体性能的提升有一些通法:
- 主从:主、从节点都有全量数据,从节点是主节点的冗余。一般来说主节点支持读写或者只支持写,从节点只支持读取,每次主节点数据更新后,会把数据同步给从节点。主节点一般只有一个,从节点可以有多个,从节点越多,能支持的读取qps越高,但是主从同步开销也越大。主从方式为了提高可用性,通常会有个从节点升级为主节点的机制,但是这个就不是性能相关的问题了,而是可用性的问题。比如redis的sentinel机制,或者zookeeper的leader选举机制。可用性之后再单开一篇吧。
举几个例子:1)系统设计中常说的读写分离可以通过主从来实现;2) redis主从:主节点数据更新之后会通过SYNC,PSYNC同步给从节点;3)zookeeper是典型主从,leader和follower,zookeeper的leader选举机制来保证可用性,但是参与选举的节点越多,选举的开销和成功率就会降低,从而影响系统恢复时间,为了减轻这个问题,zookeeper引入了observer机制,observer只提供读取功能,不参与选举,从而提高读吞吐,不影响选举性能; - 分片: 也是多个机器来承担流量,和分片的最大不同是,单个分片只承担一部分请求(读/写)。一般是根据业务上的某个key进行hash,然后分配到不同节点来进行处理。分片和主从是可以协同工作的。使用了分片,系统就是有状态的了,部分节点故障之后,可能存在局部不可用的情况。为了保障系统的可用性,不同分片机制也有不同应对策略。
举几个例子:1) 一致性hash:使用hash按照key进行hash之后请求到不同节点上,当某个服务端节点故障之后,因为一致性hash的机制,会自动请求到下个hash环上的节点,从而实现自动迁移;2) redis cluster:redis cluster是对key取CRC16之后分配到不同节点上(每个分片负责一部分请求,同时每个分片又可以有一主多从,主节点可读写,从节点只读),redis节点负责哪些请求是在集群创建的时候分配的,不能自动进行调整。为了保证可用性,每个分片都有主从节点,当主节点故障之后,从节点会通过选举机制成为新的主节点;3) 还有一种是中心化的分片机制,会有个元数据服务来存储整个集群的分片信息(哪些分片在哪些节点上),并进行心跳监测,当某些节点故障之后,元数据服务将请求路由到其他节点上来支持故障分片的功能。 - 系统无状态化: 如果系统是无状态的,理论上可以无限水平扩展,使用大量的低性能机器来提供整个系统的高吞吐能力。一个有状态的例子rds,当吞吐量上升的时候,除了分片只能通过使用更高性能的机器来提供服务,价格更高不说,当吞吐量提升到一定程度之后,业界技术可能就没有足够性能的机器来支持服务了。无状态的服务运维成本也低,出现问题也容易排查。对于使用低性能机器扩展的一个不完全例子是混部,就是使用性能不同的机器来支持同一个服务,这种情况需要对不同机器配置不同的权重来做负载均衡。一定程度上说,这个也是有状态的。
单机性能
通过一定的方式,提高单机的资源利用率。一是通过优化,把没有充分利用的资源,利用起来;二是降低单请求、任务的资源消耗。
- 缓存:缓存一般是使用性能成本更低的数据来替代性能成本更高的数据。很多时候缓存是多级的:寄存器>内存>磁盘>网络磁盘或者rpc服务。使用缓存有诸多好处,可谓是性能优化大杀器,可以避免重复运算或者IO,从而降低CPU、IO、带宽开销(不同场景缓存的侧重点不同),还能降低系统延迟,但是会增加存储的使用。但是价格上,一般运算硬件比存储更贵,所以一般是划算的。缓存有明显的问题:数据一致性和数据延迟,就看业务能否容忍。使用缓存需要对业务场景有预先分析:缓存命中率(即短时间内一个key上重复请求次数)、数据量(内存是否够用)、有无热点(数据倾斜)、缓存时间对业务影响(短时间内数据的变化比例及对业务影响)
讨论几个问题:1)多级缓存:redis是持久化数据的缓存,内存是redis的缓存。比如用户数据持久化在db中,但是db一般没法承担极高并发,可以将数据缓存到redis中;如果是请求级别需要获取这个数据,redis也要经过一次网络开销,产生一定延迟,可以在内存中缓存一份;如果占用内存过多,可以考虑使用一致性哈希/主从进行分片;2) 热点数据自动加载:如果将数据缓存在内存中,有两种方式:主动触发加载和OnDemand加载,OnDemand可以自动筛选热点数据,但是在失效时会产生一个较大的耗时尖峰。前者无耗时尖峰,但是多占内存;后者自动加载热点数据,但是会偶有耗时尖峰。选择还是看场景,有热点数据,且数据量大(内存装不下),可以使用OnDemand,无热点且耗时敏感,选主动触发。3)缓存一致性:在修改持久化数据之后,保持缓存和持久化数据的一致。读缓存不会改变缓存一致性,所以不用考虑。通常有四种做法,但是都不能根本上解决。a. 先修改持久化数据,再更新缓存;b. 先修改持久化数据,再删除缓存;c. 先修改缓存,再更新持久化数据;d. 先删除缓存,再更新持久化数据;因为更新缓存和更新持久化数据不是原子操作,所以对于任何一种方案,总有一个时间点使得缓存和持久化数据不一致,但是我们希望使用一种使不一致时间尽可能短的方案,做到最终一致性。修改的过程中,存在两个问题:I)更新/删除缓存/持久化数据失败;II)并发写入(并发的原因还是因为写持久化数据和写缓存不是原子的)导致的时序问题。I)的问题可以通过重试来解决。a/c/d都存在一个问题,对II)的处理不友好。以a为例,如果有两个线程T1,T2同时请求,顺序是:T1修改持久化数据->T2修改持久化数据->T2修改缓存->T1修改缓存。导致的结果是持久化数据是T2的数据,但是缓存是T1的数据,之后如果没有修改或者缓存过期,再也不会会到一致性的状态,不能保持最终一致性;d的情况更抽象一点:如果有两个线程T1,T2同时请求,顺序是:T1删除缓存->T2删除缓存->T1更新持久化数据->T3线程请求(此时因为缓存miss,会加载持久化数据,但是是T1的数据)->T2更新持久化数据。此时也会出现不一致性,且无法自动恢复到一致状态。对于b,无论以怎样的顺序操作,都会有一次缓存失效,触发重新加载持久化数据逻辑。 - 并行化:顺序上没有依赖的逻辑可以进行并行处理,可以降低延迟。比如一个服务依赖多个下游获取数据,可以并行获取,耗时上,串行是所有服务调用耗时的叠加,并行是耗时最长的链路。但是前提是各个数据之间没有依赖关系,存在依赖关系的就只能串行了,当然也可以优化逻辑,想办法去掉依赖关系。
- 功能分离:可以共享的运算可以单独抽离一个系统,单独进行运算,将结果进行分发。比如广告召回系统中,每个实例都需要构建广告集合的倒排索引,这个消耗的CPU非常大,为了降低负载,可以单独把构建倒排的功能抽出来,分发给集群中的各个节点,节点只承担召回功能,不参与倒排构建。
- 批次化处理:把单个数据的处理,合并后一起处理,降低资源消耗,有些时候就是资源的复用。批次化处理会造成数据的处理有一定延迟,注意评估对业务影响。
举几个例子:1) redis client的pipeline机制会把多个命令合并到一次网络IO来进行处理。如果有50个命令,不使用pipeline,需要执行50次写socket io操作(发送请求),50次网络round-trip,50次redis命令处理,50次读socket io操作(接收结果),最终才获取到了数据。但是使用了pipeline,只需要执行1次写socket io操作(发送请求),1次网络round-trip,50次redis命令处理,1次读socket io操作(接收结果),实际是50个命令复用了一个网络io和往返,节省了CPU和网络带宽和延迟,当然redis的处理还是没有降低。2) TCP nagle算法,这个和redis类似,不再赘述。对于延迟问题,这个问题更典型一些,专门描述下延迟问题。nagle实际上是对于诸如终端设备,没有必要当用户输入单个字符之后就立即发送到对端,可以等用户输入多个字符之后一起发送,共享同一个TCP报文。这里就有个权衡:如果字符太少就发报文,则报文载荷利用率过低;如果字符数太多,可能就会等太久。我们参照nagle这个问题可以这么处理:a. 数据量达到一定量级;b. 等待时间超过一定限制;c. 高优先级数据;只要满足一个条件就进行处理。3) 批次合并处理:统计广告事件的展点消数据,结果写入redis,可以单个事件就往redis里写一次,进行加一,无疑请求量是巨大的,可以先在内存中攒数据,在相应维度上聚合,聚合多条数据之后一次写入N次聚合结果。延迟问题可设置一个较小的聚合时间。 - 增量(迭代)处理:适用场景为处理增量数据比处理全量数据开销更小
举几个例子:1)序列 a i a_{i} ai窗口为k的滑动平均 A v g n = A v g n − 1 + a n − a n − k k Avg_n=Avg_{n-1}+\frac{a_n-a_{n-k}}{k} Avgn=Avgn−1+kan−an−k,使用迭代的方式,节省每次的全量计算时间复杂度从 n k nk nk降低至 n n n; 2)定期加载一份会变化的数据,比如广告数据,服务端维护 < i d , t s > <id, ts> <id,ts>或者 < i d , h a s h > <id, hash> <id,hash> 映射,如果数据如果发生变化就更新ts/hash,客户端维护一份 < i d , t s > <id, ts> <id,ts>或者 < i d , h a s h > <id, hash> <id,hash> 映射,每次请求都带上这个映射,服务端比对和自己的映射关系的差异,只返回改变的数据(增加、删除、更新),如果加载间隔内数据变化量远小于全量,就有性能收益;3)流式更新、binlog,主动传播变化数据。 - 近似代替精确
有些场景,我们不需要100%的准确性,允许一定误差,这种情况,我们就可以做些优化。
举几个例子:1)HypherLogLog,统计不重复的整数个数,如果id的范围很大,占用内存会很多,可以使用HyperLogLog进行近似;2)开平方根魔数:https://blog.csdn.net/u012028275/article/details/113793827;3)浮点运算转换成整数进行计算,损失精度,但是整数运算快得多了;4)采样,比如广告投放系统中,展点消数据量级非常大,为了计算后验ctr,cvr可以采样10%数据进行计算,有一定偏差,但是可接受。统计独立的ip地址数。统计某种类型的操作数,可以单次操作随机数对N取余,如果结果为0,则操作数直接加N,如果操作数递增的成本比随机取余要高的多,就是值得的。 - 数据压缩
- 降低存储使用率,弊端是提高了CPU使用了。但是对于文本类型的数据,典型的压缩算法压缩比可以达到1:5,典型的压缩算法有gzip/zstd/snappy等。CPU有一定增长,但是考略到节省的存储,有些场景还是有ROI的。
- 充分利用系统硬件:
- DMA:kafka性能高的一个原因就是kafka使用了DMA做到了零拷贝,降低了CPU使用率,降低延迟;
- 协处理器:GPU/FPGA/专门加密硬件/浮点协处理器等,某些机器上配置有专门的加解密硬件,CPU的运算开销。使用CPU可能运算比较慢,但是使用专门的硬件,就会快得多。
- 系统性能分析工具
- 利用各种编程语言、系统提供的分析工具来分析性能瓶颈,例如pprof, perf, sar等。
- 清理无用逻辑
- 这块容易被忽略,但是业务上删除逻辑是很重要的性能优化手段,带来的性能提升往往不是单纯从性能角度所能比拟的。
- 优化数据结构、编码等
- 该用map的是不是用了list遍历?该用hashmap的是不是用了treemap?容器类是不是没有预先分配长度?诸如此类