互联网高并发设计手段

901

系统性能两大核心点

  • 吞吐量(Throughput)
  • 响应延迟(Response Delay)

优化目标

  1. 缩短响应时间
  2. 提供并发数
  3. 让系统处于合理状态 Number of Concurrent Users

上图是高并发软件性能模型。图中三条曲线,分别表示:

  • 资源的利用情况Utilization,包括硬件资源和软件资源)
  • 吞吐量Throughput,这里是指每秒事务数)
  • 响应时间Response Time)。

坐标轴的横轴从左到右表现了并发用户数Number of Concurrent Users)的不断增长。

随着并发用户数的增长,资源占用率和吞吐量会相应的增长,但是响应时间的变化不大;不过当并发用户数增长到一定程度后,资源占用达到饱和,吞吐量增长明显放缓甚至停止增长,而响应时间却进一步延长。如果并发用户数继续增长,你会发现软硬件资源占用继续维持在饱和状态,但是吞吐量开始下降,响应时间明显的超出了用户可接受的范围,并且最终导致用户放弃了这次请求甚至离开。

根据这种性能表现,图中划分了三个区域,分别是

  • Light Load(较轻的压力)
  • Heavy Load(较重的压力)
  • Buckle Zone(用户无法忍受并放弃请求)。

在Light Load和Heavy Load两个区域交界处的并发用户数,我们称为“最佳并发用户数(The Optimum Number of Concurrent Users)”,而Heavy Load和Buckle Zone两个区域交界处的并发用户数则称为“最大并发用户数(The Maximum Number of Concurrent Users)”。 当系统的负载等于最佳并发用户数时,系统的整体效率最高,没有资源被浪费,用户也不需要等待;当系统负载处于最佳并发用户数和最大并发用户数之间时,系统可以继续工作,但是用户的等待时间延长,满意度开始降低,并且如果负载一直持续,将最终会导致有些用户无法忍受而放弃;而当系统负载大于最大并发用户数时,将注定会导致某些用户无法忍受超长的响应时间而放弃。

所以我们性能优化的目标就是让系统保持在Heavy Load(较重的压力) 区域。

优化手段

1. 空间换时间

系统时间是瓶颈。

这种情况是CPU处理时间很当,但是空间费用我们能接受,所以我们解决的问题的方法就是用空间来换时间。

例如:缓存复用计算结果,降低时间开销,因为CPU时间较内存容量更昂贵

2. 时间换空间

数据大小是瓶颈。

这种情况是空间占用很大,处理时间能接受,所以我们解决问题的方法就是用时间换空间。

  • 例如1:网络传输是瓶颈,使用系统时间换取传输的空间,使用HTTP的gzip压缩算法。解压缩的时候会消耗CPU处理时间。
  • 例如2:APP的请求分类接口,使用版本号判断哪些数据更新,只下载更新的数据。比如一些商品分类类目、城市列表等,一般很少会更新,可以缓存到本地,不能每次登录的时候都去拉取,所以针对这些数据我们可以做一个版本号,如果发现更新了就去拉取数据更新。再比如一些IM社交软件的用户列表,每次登录的时候都去拉一次,其实大家想想每次录的时候都是拉一次挺消耗网络流量的。而这个时候往往你的QQ好友列表变化并不是特别频繁,这个时候怎么办呢?我们会在这个QQ好友的列表有一个时间戳作为版本号,这个版本号在server端存一份,client端存一份,在每次登录的时候会先去判断一下这个版本号是否一致,如果不一致再做更新。

3. 找到系统瓶颈

分析系统业务流程,找到关键路径并分解优化。

  • 一个服务集群4W的QPS,调用量前5的接口贡献了3.5W的QPS
  • 对关键路径的代码优化收益最大,当然系统剩下的部分收益也不能忽视,比如剩下5k QPS接口若性能有问题也可能把整体服务性能拖垮。

所以我们需要抓主要矛盾。大概解决思路总结如下:

  • 整个请求服务调了多少RPC接口;
  • 载入多少数据;
  • 使用什么算法;
  • 非核心流程能否异步化;
  • 没有数据依赖的逻辑能否并行执行

优化层次

从整体到细节,从全局角度到局部视角

1. 架构设计层次

  • 关注系统控制、数据流程
  • 如何拆分系统,如何使各部分系统整体负载更加均衡,充分发挥硬件设施性能优势,减少系统内部开销等。

架构设计层次实现手段:

  • 分布式系统微服务化
  • 分库分表,读写分离,数据分片
  • 无状态化设计,动态水平弹性扩展
  • 调用连路梳理,热点数据尽量靠近用户
  • 分布式Cache、多级多类型缓存
  • 容量规划
  • 提前拒绝,保证柔性可用

2. 算法逻辑层次

  • 算法选择是否高效,是否使时间优先级的还是空间优先级,算法逻辑优化,空间时间优化任务并行处理,使用无锁数据结构等。
  • 空间换时间
    • ThreadLocal
  • 时间换空间
    • 采用压缩算法压缩数据,更复杂的逻辑减少数据传输

2.1 算法逻辑优化层次实现细节

用更高效的算法替换现有算法,而不改变其接口

增量式算法、复用之前的计算结果,比如一个报表服务,要从全量数据中生成报表数据量很大,但是每次增量的数据较少,则可以考虑只计算增量数据和之前计算结果合并。这样处理的数据量就小很多。

并发和锁的优化,读多写少的业务场景下,基于CAS的LockFree比Mutex性能更好

当系统时间是瓶颈,采取空间换时间逻辑算法,分配更多空间节省系统时间

缓存复用计算结果,降低时间开销,CPU时间较内存容量更加昂贵

当系统空间容量是瓶颈的时候,采用时间换空间算法策略

网络传输是瓶颈,使用系统时间换取空间的压缩,HTTP的gzip的压缩算法

APP的请求分类接口,使用版本号判断那些数据更新,只下载更新的数据,使用更多的代码逻辑处理更细颗粒度的数据

并行执行,比如一段逻辑调用了多个RPC接口,而这些接口之间并没有数据依赖,则可以考虑并行调用,降低响应时间。

异步执行,分析业务流程中的主次流程,把次要流程拆分出来异步执行,更进一步可以拆分到单独的模块去执行,比如使用消息队列,彻底和核心流程解耦,提高核心流程的稳定性以及降低响应是时间。

3. 代码优化层次

  • 关注代码细节优化,代码实现是否合理,是否创建了过多的对象,循环遍历是否高效,cache使用的是否合理,是否重用计算结果等。

3.1 代码优化层次实现细节

  • 循环遍历是否合理高效,不要在循环里调用RPC接口、查询分布式缓存、执行SQL等
    • 先调批量接口组装好数据、再循环处理
  • 代码逻辑避免生成过多对象和无效对象
    • 输出log时候的log级别判断,避免new无效对象
  • ArrayList、HashMap初始容量设置是否合理
    • 扩容代价
  • 对数据对象是否合理重用,比如通过RPC查到的数据能复用则必须复用
  • 根据数据访问特性选择合适数据结构,比如读多写少,考虑CopyOnWriteArrayList(写时Copy副本)
  • 拼接字符串的时候是使用String相加还是使用StringBuilder进行append(在StringBuilder的容量预分配的情况下StringBuilder的性能比String相加性能高15倍左右)
  • 是否正确初始化数据。有些全局共享的数据,饿汉式模式,在用户访问之前先初始化好

3.2 数据库代码优化层次实现细节

  • 数据库建表语句使用尽量小的数据结构
    • 表示状态的字段,如果状态值在255以内使用unsigned tinyint,IP使用int而非varchar
  • 使用enum的场景使用tinyint替代,enum扩展需要该表
  • 避免使用select * 查询语句,只查询需要的字段,避免浪费数据IO、内存、CPU、网络传输
  • 分析查询场景经理合适的索引,分析字段的可选择性,索引长度,对长的varchar使用前缀索引
  • 字段尽量为Not NULL类型,MySQL手册说吗允许NULL的字段需要额外的存储空间去处理NULL ,并且很难查询优化

以上优化的目的为了降低服务器CPU使用率、IO流量、内存占用、网络消耗、降低响应时间

在做查询时,可以通过代码逻辑,加一下查询条件,利用索引来提升查询速度。

3.3 局部优化层次实现细节

以下两段代码哪个执行速度快?

long[][] a = new long[10000][10000];
    for(int i=0;i<a.length;i++){
        for(int j =0;j<a[i].length;j++){
            a[i][j]=j;
        }
    }
long[][] a = new long[10000][10000];
    for(int i=0;i<a.length;i++){
        for(int j =0;j<a[i].length;j++){
            a[j][j]=j;
        }
    }

这两段代码,唯一的区别在于填充方式,第一种方式是按行填充,第二种方式是按列进行填充。显然是第一种方式更快一些。 虽然是二维数组,但是在内存堆列中还一维数组进行存储的。大家可以在试一下,第一种运行速度比第二种方式快20倍。

那深层次原因是什么呢?

我们知道在计算机体系中,除了有内存来做缓存,在CPU层面也有cache结构。而越靠近CPU读取速度越快。如下图:

缓存的结构大概时这样的,从1级到3级速度越来越慢,最后通过总线与内存连接。 本质上内存是一个大的一维数组,二维数组在内存中按行排列,现存放a[0]行,再存放a[1]行,第一种遍历方式,是行遍历,先遍历完一行再遍历第二行,符合局部性原理,Cache Hit(缓存命中率高),第二行遍历方式,是列遍历,遍历完第一列遍历第二列,由于下一列和上一列的数组元素在内存中并不是连续的,很可能导致Cache Miss(缓存未命中),CPU需要去内存载入数据,速度较CPU L1 Cache的速度降低了很多(主存100ns,L1cache 0.5ns)

扩大到一般场景,业务系统使用缓存降低响应时间提高性能,必须提高缓存命中率。很聚焦的高频访问,时效性要求不高很适合缓存提升性能,很聚焦的高频访问业务如banner,广告位,时效性要求不是特别高,比如更新了可以不用实时体现,很适合使用缓存提升性能。

如果对数据实时性要求很高,比如严格的时效性,需要慎重考虑更新缓存带来一致性问题。

时效性和缓存的冲突,比如商品服务对商品进行了缓存,由于更新缓存和更新商品不是同一个事务,则对数据时效性要求高的如交易,就只能直接从数据库查商品信息。

4.案例一:电商秒杀系统

电商秒杀系统的主要特点:

    1. 瞬时大量并发,在某一个时刻99%的用户涌入
    1. 有效请求很低,可以认为有效请求数和库存数一致,可能99%以上的流量都是无效的
  • 库存数据一致性要求很高,不能超卖或者不卖

明白了秒杀系统的特点之后呢,我们就可以针对性的设计秒杀系统。我们在做很多事之前,也是一样的思路,需要明白这个事的特点,也就是关键点是什么?这样才能把这个事情做好。

既然在瞬时会有大量用户和无效请求,所以我们在分层架构系统中,就会在上层尽量把无效请求过滤掉。上层可以是不精确的过滤,通过层层限流最后一层做数据一致性校验,扣减库存。如下图:

HTML、JS、CSS等静态文件存放CDN,缓存到用户端(APP/浏览器),减少对服务器的压力

非实时动态数据(秒杀期间如商品标题、商品描述、图片URL列表、店铺信息、秒杀活动信息等),这些数据缓存在用户访问链路中靠近用户的位置,粗过滤一部分流量,比如用户是否有秒杀资格、秒杀是否已结束等,这些数据实时性要求不高。

实时数据如用户营销数据(如红包、折扣)、商品库存等再过滤一批用户。

经过多层过滤最终落到数据库的流量已经很少,最终在数据库层面使用事务保证扣减库存准确性。

5.案例二:Feed系统

Feed系统特点:

  1. 读写比例-100:1甚至更高,典型的读多写少
  2. 冷热数据明显-80%是当天数据,20%的用户是活跃用户
  3. 热点效应明显-明星时间、重大节日
  4. 高访问量-用户闲的没事就刷

基于以上特点,Feed系统应该分级缓存

  • 读多写少、冷热数据明显,热点数据缓存到调用链路更靠近用户的地方
  • L1缓存容量小负责抗最热点的数据,L2缓存考虑目标是容量,缓存更大防伪的数据,比如一般用户的timeline,高热点数据单独缓存,比如设置白名单,大V的用户数据单独缓存
  • feed(关注的feed、topic的feed,一些运营的feed)前几页的访问比例,像前三页占了97%,针对这种业务特性,把前面几页数据作为热点数据踢到L1 cache

feed系统还有一个重点,就是你发了一条微博,到底是采用push还是pull。比如你的粉丝有1000万,你发了一条动态,就需要把这条动态同时展示在1000万粉丝面前。这个时候可以就需要采用pull的方式更合适。但是如果发一条朋友圈,因为微信好友是有上限的,比如微信限制好友数是5000人,这个时候就会采用push的方式更合适。

所以我们设计feed系统策略是:

  • 基于写扩散消息统一推送通道
  • 推送策略:拆分数据并行推,活跃用户先推,非活跃用户慢慢推
    • 有1W个用户关注的号,发了一个feed,拆分成100份,没份100个并行推
    • 1W个用户里活跃的可能有2000个,活跃用户先推,非活跃用户慢慢推,保证活跃用户用户体验,非活跃用户退了很大概率也不看。那怎么实现呢?其实很简单就是维护一个活跃用户的列表。
  • 消息标准化格式
  • 统一数据数据流,职责明确

6.欢迎关注

第一次写博客,人生需要打Tag,用来快速回忆,就跟commit代码一样。上面有些案例描述的不正确的地方欢迎指正,接下来我会持续更新,欢迎关注。