高性能(3)- 设计手段

最后更新:2020-06-27

1. 减少请求数量

既然请求量大,那么第一个方面可以考虑是否可以让请求量不那么大,或者说至少进入我们业务系统的量不这么大。

除了下面提到的两点,我们还可以从业务的角度考虑一下:

  • 如果这是一个限时活动,那么我们的活动受众群体是否需要是所有用户,如果不是的话是否就可以通过减少受众减少并发;
  • 如果需要群发推送让用户来参与非秒杀类活动是否要考虑错时安批推送,避免因为推送引起的人为大并发等等,在技术手段接入之前先看看运营和产品手段能否减少不必要的大流量。

1.1. 合并请求

每一个独立的网络请求都是开销,我们可以通过合并动态静态的请求来减少请求数量。现在的Web前端应用基本都会在构件打包阶段对脚本、CSS进行压缩合并等预处理。

对于后端动态请求而言,我们更需要在设计阶段考虑接口的粒度,并且区分对待实时处理和批处理的架构,数据批处理的工作不太适合通过循环调用远程接口的方式实现。

1.2. 边缘加速

CDN就是边缘加速的一个例子,一般而言我们使用CDN不仅仅为了让用户访问数据更快,而且通过在边缘节点做一定的缓存策略可以让节点帮我们挡住很大部分的流量(特别是静态资源,除了回源的请求都可以由CDN挡掉)。

更进一步说,一些CDN可以做一些定制化的处理,允许业务方提供一些简单的脚本在节点做边缘计算,比如在秒杀场景下根据一定的策略直接在CDN节点进行计算,放行0.1%的用户流量进入我们的后端系统。

2. 提升处理性能

第二个方面优化的方向是提高单个请求的处理性能,也就是减少请求的处理时间,优化请求处理调度和占用的资源。

2.1. 空间换时间

缓存

一般有两种做法:

  • 一种是在程序启动的时候从外部数据源初始化大量的不怎么变的数据到内存中,在内存中形成面向搜索友好的数据结构(比如哈希表),提供快速的数据访问,之后所有的请求都无需请求数据源,采用定时拉取或监听变动消息的方式同步变动。
  • 一种是利用分布式缓存做计算结果的缓存,具有比较短的过期时间,可以挡掉大量重复请求,对于搜索条件组合较多的请求命中率差。当然,缓存除了使用空间换时间之外,一般还会利用存储介质的性能差异来提升性能,所以我们看到通过内存缓存数据比较常见。

缓冲

和缓存相近但又截然不同的概念是缓冲。IO操作一般都会使用缓冲区,在我们实现业务的时候也可以利用这种思想。

对非时间敏感的调用进行适当蓄水,甚至合并,一次性提交到后端服务,比如玩一个抓红包的游戏,用户在屏幕上点点点来抓红包,是否真的有必要每次都向数据库更新红包余额呢?还是可以在服务端缓冲一下,10次更新一次余额甚至整个游戏只提交一次?

还比如,我们需要对内存中的一些数据做处理,处理的时间会比较久,在处理的时候显然不能持续服务业务了。为了一致性考虑需要做悲观锁处理,这个时候我们就可以考虑开辟一块所谓的缓冲区,专门用于数据处理,处理好之后把指针指向新的缓冲区,再回收使用老的区域做持续处理,就像JVM中的From和To区域来回倒腾,这也算一种缓冲使用。

面向数据读取优化

比如在发微博的时候找出大V下一定数量的活跃的在线粉丝,比如5000个,直接把微博写入他们的关注微博列表中去(推数据过去),这样在那些粉丝刷新自己微博首页的时候就能更快(不用去关联拉数据了)。

又比如许多时候我们会做所谓的固化视图的工作,在写入数据的时候就直接写为我们之后要读取的复杂数据结构(比如数据需要Join N个表才能获得的,在写入的时候就直接组成这样的数据写到数据表)。

或者可以说我们做哈希结构,做B树索引,做倒排索引都是这样的思路,使用一些有利于我们之后读取、查询和搜索的数据结构来加速数据的读取(虽然写入的时候耗时多一点,并且需要占用额外的空间)。

数据预读取

说白了就是预测到将来用户可能会访问的请求,进行预加载或是预处理,然后之后真正请求到来的时候这个访问就会特别快。

2.2. 处理异步化

我们知道高并发的请求如果来源是用户的点击,那么这个量不太可控,而且不均衡。

对于来自用户的请求,如果是读取请求往往没太多好办法去异步处理,毕竟你需要同步返回用户信息,对于操作类的写入请求可以尽量异步化处理,仅仅把最关键的环节作为同步处理,那么直面用户的同步请求的执行时间就会大大减少。

使用线程池来进行异步处理一些非关键的任务

使用线程池进行异步处理是指Fire-and-forget类型的处理,不需要等待处理完成的结果并且返回给前端。

使用MQ进行异步处理

比如下单的主流程就是落地和发MQ通知其它模块,落地后后续出库、物流的流转全部是其它模块在收到MQ消息后异步处理的。

2.3. 任务并行化

让任务中的子任务并行执行,这样会比一个一个串行执行子任务来的快。

比如可以把多个子任务提交到线程池执行,然后等待所有任务都完成后进行结果汇总,这样总的耗费时间就是最慢的那个子任务的执行时间。可以使用Java8的CompletableFuture进行任务编排处理。

2.4. 合适的存储

这种架构的存储方式能够很好应对非常巨大的并发量,原因在于:

  • 每一种数据库系统,特别是NOSQL都有自己的特性,我们可以充分利用这些特性来打造适合业务,适合高并发读写比的服务。
  • 我们可以结合之前异步化的思想把最重要的关系型数据库的落库走同步处理,其它走异步处理,这样既可以利用多种数据库的特性又可以让数据写入不影响主流程。

当然,选用了合适的存储还不够,每一种存储系统也都需要精心去调优参数以及使用最佳实践去访问和使用存储(比如关系型数据库索引如何建立,如何优化查询)。

对于大部分业务服务来说无非是IO操作慢,大部分是网络IO慢,网络IO无非是外部存储服务或外部服务,所以这里提到的存储的优化是非常重要的一环。

还有一半就是外部服务的优化,但是外部服务的优化往往需要靠其它团队,不完全是自己能掌控的。

2.5. 更快的网络

这里提到更快的网络意思是纯网络层面的链路,我们是否理清楚了到底是怎么走的,比如:

  • 调用其它团队内部服务域名公网解析还是内网解析?访问链路走的是公网还是内网?
  • 我们是如何调用其它服务的?详见《朱晔的互联网架构实践心得S2E4:小议微服务的各种玩法》
  • 经过多少防火墙、反向代理?
  • 走HTTPS还是HTTP?
  • 如果是走公网走的是机房什么出口?
  • 是长连接还是短链接(特别是HTTP请求)?

归根到底就是我们最好能了解这些外部服务在网络层面花费的情况是否达到预期,比如一个外部服务调用我们看到耗时1秒的时间,拼命追着下游去优化服务,但是下游说为服务端执行时间只有30ms呀?

结果一查发现整个调用跨了4个机房走了2次公网2次专线,然后还经过了4个网关转发,这些东西耗时970ms,这就很尴尬了。

我觉得一个能接受的情况是内网调用网络损耗在5ms以内,公网调用在50ms以内(跨国除外)。

对于大并发的系统来说任何一个环节增加很少的延时可能都会导致最前端超时或队列溢出。

之前也遇到过两个服务之间的调用因为专线维护从专线切到走公网+VPN的形式代码层面毫无变动,只是网络链路的改动因为大家都没有重视,链路切换后的白天在并发上去之后全线崩溃的问题。

当然,对于现在的微服务架构来说需要有很好的分布式追踪基础服务我们才好理清服务调用和调用的损耗。

3. 增加处理能力

优化处理性能往往没有这么快,即使能优化往往也无法实现几十倍几百倍的性能提高,对于高并发程序来说我们肯定需要有一定的处理资源来应对,最悲惨的事情莫过于有一堆服务器但是用不起来,最理想的架构是每一个组件都可以横向扩展,并且随着服务器资源的增多能相应提升总体处理能力。

3.1. 模块拆分

拆分是最好的手段,对于业务应用可以这么来拆:

  • 直接拆成子站,除了一些公共服务(比如用户、商户),其它全部独立
  • 横向,按模块拆分成微服务独立部署
  • 纵向(或者说分层,更多是物理分层),按功能拆分成专门处理数据的服务、专门落地的服务、专门汇总数据的服务等等

对于数据库来说也是一样:

  • 拆分数据库,拆分数据库到不同的实例(服务器)
  • 纵向,拆分成几个1:1的小表
  • 横向,把同一个表的数据拆分到不同的数据库

当业务可以拆分的时候其实应对大并发没这么难,最困难的是拆无可拆,就是大并发针对的是同一个表同一行的数据的情况,而且读写的量都很大,而且要求强一致性的情况。

3.2. 负载均衡

对于无状态的服务来说,我们可以通过负载均衡来实现服务的负载分发,需要关注的是几个点:

  • 负载均衡的策略
  • Backend健康检测
  • 服务失效后从负载均衡摘除,恢复后的上线
  • 发布系统和负载均衡的联动
  • 负载均衡特别是7层覆盖,对于请求头做的改动会是怎样的

3.3. 分区处理

又叫做Sharding、Partition,指的是把数据、任务进行分区,分发到不同的节点同时处理,提高并行度。

这点和拆分有一些相近,但是更多指的是想同的数据和任务需要批量循环处理的时候去做下分区,然后并行执行,应用这个思想的几个例子:

  • 数据表的分表分库,然后由类似Proxy的中间件进行数据路由和汇总处理
  • 比如Java 8 parallelStream的思想把数据分成多份在不同的线程同时处理
  • 比如ConcurrentHashMap锁分段的思想,把全局的锁改为分段锁减少冲突

分区不但能提高并行度使用更多的资源来处理数据而且还可以减少冲突,但是分区处理后最终还是需要Reduce的,这个过程的处理方式以及处理的损耗需要进行考虑。

而且每一个分区的处理速度不一定均衡,所以不能完全假设分成N份系统的执行速度就提高了N倍。

3.4. 纵向扩展

纵向扩展说白了就是升级单台服务器的配置或使用更强力的小型机来替换普通服务器。

有的时候纵向扩展也是无奈之举,就像之前所说的对于一个很小的单表,虽然只有寥寥几个字段已无法再瘦身,但是读写量超大,强一致,或许也只能使用更强大硬件通过强大的IIOPS撑起这样的数据库。

我们之前提到的增加处理能力往往是指使用更多的服务器来支撑,更多的服务器意味着通讯需要跨网络,网络有损耗也有不稳定因素存在,分布式服务的状态需要同步,而且服务器越多就越可能出现失效的服务(假设1万台服务器,每天出问题的服务器在千分之一那就是10台了)。

分布式,横向扩展说白了是有很大代价的,在当今硬件没有这么昂贵的情况下往往也不失为一种方案:

  • 为缓存服务器提供更大的内存
  • 为随机IO要求高的Mysql、ES等服务器提供SSD磁盘
  • 为不易做拆分的核心负载均衡处理器提供高配服务器
  • 为极端高并发的数据库使用小型机

参考资料

https://mp.weixin.qq.com/s/sTI6NGjSgzMTlQJVRRGrqw

Edgar

Edgar
一个略懂Java的小菜比