在去年年终总结的时候,我提出了这样的困惑,究竟什么是真正的技术能力,是对于各种底层技术的钻研吗?钻研是好事,但实践下来,深入钻研并不在实际工作中有用,且钻研的越深,忘得越快,因为钻研得越深,那么这个技术点它所关联的知识点就越多,边边角角都给忘了,核心的东西反倒串不起来。一些中间件的知识,看了又忘,忘了又看,这也成了我过去几年在技术知识沉淀上的一个痛苦过程。
直到最近把一些架构方面的书全都看完,包括:《软件架构设计》余春龙、《亿级流量网站架构核心技术》张开涛、《搞定系统设计》Alex Xu,也把李道华的极客时间课程《从0开始学架构》又看了一遍之后,似乎有了一种柳暗花明又一村的感觉。其实我们现在几乎所有的业务架构设计理念都可以从优秀的中间件产品中找到它们的应用,所以当我较为系统的去学习了软件架构设计之后,回头再去思考具体一个个中间件底层技术知识点,在某种层度上其实是可以串联起来的,这也让我对最近的学习成果更觉欣喜。
软件架构设计主要目的是解决软件复杂度带来的问题,而软件复复杂度主要来自三个方面:高性能、高可用、可扩展,所以软件架构的大部分知识基本也都是围绕这三个方面展开。当然架构设计不仅于此,还需要考虑系统设计的安全问题,成本问题(开发成本、测试成本、运维成本)等等。
软件架构知识复杂庞大,但究其第一性原理,不外乎计算机科学家在过去几十年中提出的7个伟大思想,这些思想非常强大,以至于在应用这些思想产生首台计算机之后的很长时间里,不管是计算机架构师,还是软件系统架构师,都在设计中反复使用这些思想,通过模仿向先驱致敬。工程师在做技术方案设计或者软件优化的时候也往往能找到这些思想的痕迹。这些思想在《计算机组成原理与设计》(两位作者是图灵奖获得者,值得推崇)这种本有提及,这边做个简单介绍。
1、使用抽象简化设计:提高硬件和软件生产率的主要技术之一是使用抽象(abstraction)来表示不同的设计层次——隐藏低层细节以提供给高层一个更简单的模型。
2、加速经常性事件:加速经常性事件(make the common case fast)远比优化罕见情形能够更好地提升性能。具有讽刺意味的是,经常性事件往往比罕见情形更简单,因此通常更容易提升。
3、通过并行提高性能:自计算诞生以来,计算机架构师就通过并行计算操作来获得更高性能。
4、通过流水线提高性能:任务切分,通过流水线并行执行,CPU并行指令执行就是其应用。
5、通过预测提高性能:某些情况下,假设从预测错误中恢复的代价并不高,且预测相对准确,则平均来说进行预测并开始工作可能会比等到明确结果后再执行更快。
6、存储层次:在存储层次中,速度最快、容量最小并且每位价格最昂贵的存储器处于顶层,而速度最慢、容量最大且每位价格最便宜的存储器处于底层。
7、通过冗余提高可靠性:通过引入冗余组件来使系统可靠,该组件在系统发生故障时可以替代失效组件并帮助检测故障。
接下来,我们带着这7个伟大的思想,去应用到的软件架构设计当中来。前面提到,软件架构主要解决的是高性能、高可用,可扩展性3个方面的问题。由于文章篇幅有限,本文仅谈软件架构是如何对应高性能要求的。
高性能其实我们就可以理解为高并发,而高并发问题可以简单分为:高并发读、高并发写、读写同时高并发3类。
1、侧重于高并发读的场景,比如搜索引擎;电商商品搜索;电商系统的商品详情展示等。
2、侧重于高并发写的场景,比如广告扣费系统:用户的每次浏览或点击都需要对余额做扣减。
3、读写高并发的场景,比如电商库存系统和秒杀系统;支付系统和微信红包;微博朋友群。
应对高并发读和高并发写的策略有所不同,下面通过一些案例分析在不用场景可采取的策略。
一、高并发读
策略一:缓存银弹
如果高并发读流量扛不住了,相信很多人首先想到的是加缓存,它的本质就是空间换时间,下面列举几个典型的案例。
案例1:本地缓存(包括Nginx缓存和应用缓存) + Memached/Redis的集中缓存。
缓存的使用需要保障缓存的命中率,所以需要考虑好缓存的刷新策略和回收算法,以尽可能提高缓存的命中率。
缓存刷新策略:1、主动更新,当数据库中数据发生变更时,主动删除或者更新缓存中的数据;2、被动更新,当用户的查询请求到来时,如果缓存过期,再更新缓存。
缓存命中率:回收策略:基于空间;基于时间;基于对象应用(堆内缓存)。回收算法:FIFO(先进先出);LRU(最近最少使用);LFU(最不长使用)。
案例2: Mysql的Master/Slave
缓存策略很容易用来缓存各种结构相对简单的数据,但对于有的场景,需要用到多张表关联查询,比如后端Admin系统要操作复杂的业务数据,如果直接查业务系统的数据库,会影响C端用户的高并发访问。对于这种查询,可以为Mysql加一个或者多个Slave,来分担主库的读压力。当然也可以把多张表的关联结果缓存成,但这会存在一个问题:在多张表中的任何一张表数据发生变化,缓存都得刷新。
案例3:CDN静态文件加速(动静分离)
在网站页面展示的内容中,分为静态内容和动态内容两部分。静态内容,比如图片,HTML,JS,CSS等文件。动态内容:需要根据用户信息或其他信息(比如当前时间)实时生成并返回给用户的信息。对于静态内容,常用的策略就是CDN。一个静态文件缓存到了全网的各个节点,当第一个用户访问的时候,离用户最近的节点没有缓存数据,CDN就会去源系统抓取文件缓存到该节点,后续用户访问就可直接从这个节点获取静态内容。
策略二:并发读
无论是读还是写,串行改并行都是一个常用的策略。
案例1:异步RPC
如果多个RPC调用之间没有耦合关系,可以通过采用并行调用的方式来减少查询处理耗时。
案例2: 冗余请求
客户端首先向一台服务器发送请求,在一定时间内没有收到服务端的响应,则立马给另外一台(或者多台)发送同样的请求,客户端等待一个响应到达之后,终止其他请求的处理。上面“一定时间”定义为:95%请求的响应时间。对于这种方式,Google公司的测试数据表示:采用这种方法,可以仅用2%的额外请求将系统99.9%的请求响应时间从1800ms降到了74ms.
策略三: 重读轻写
案例1: 微博或朋友圈Feeds流
微博首页或者微信朋友圈都有类似的查询场景:用户关注了n个人(或者有n个好友),每个人都在不断地发微博或朋友圈,系统需要把这n个人发布的内容按时间排序成一个列表,这就是微博Feeds流。Feeds流是一个持续更新并展示给用户的信息流。它将用户主动订阅的若干消息源组合在一起形成内容聚合器,帮助用户持续地获取最新的订阅源内容。
在这种场景下,如果单纯只靠一个用户关系表和内容发布表,这种数据模型无法满足高并发查询请求的。我们可以采取重写轻读的方法做数据扩散。假设某个用户有1000个粉丝,发布一条微博之后,只要写入自己的收件箱就返回成功,然后异步把这条微博推送给1000个粉丝的收件箱,也就是写扩散,这样每个用户读Feeds流时就不用做实时聚合了,只需要查自己的收件箱就可以。
案例2: 多表关联查询:宽表与搜索引擎
在策略一中提到一种场景:后端需要对业务数据做多表关联查询时,通过Slave解决,这种方法适合没有分库的情况。
如果数据库已经分库,那么需要从多个库查询数据聚合,这就无法使用数据库的排序和分页功能,此时也只能采用重读轻写的思路,把关联数据提前计算好,存在一个地方,读的时候直接查询聚合好的数据。也可以用ES类的搜索引擎来实现,把多表关联的结果做成一个个文档,放在搜索引擎里面,也可以做到灵活排序和分页查询。
我们也可以称这种方式为“数据异构”。分库分表中有一个最为常见的场景,为了提升数据库的查询能力,我们都会对数据库做分库分表操作。比如订单库,开始的时候我们是按照订单ID维度去分库分表,那么后来的业务需求想按照商家维度去查询,此时查询某一个商家下的所有订单就非常麻烦,这个时候我们通过数据异构把存储一张商家维度的订单表就可解决这一问题。
总结:读写分离
不论时加缓存、动静分离,还是重读轻写(数据异构),其本质都是读写分离,也就是微服务架构经常提到的CQRS(Comman Query Responsibitlity Separation)
下图总结了读写分离架构的典型模型,该模型有几个典型特征:
(1)分别为读写设计不同的数据结构。把系统分为读和写两个不同的视角来设计,各自设计高并发读和高并发写的数据结构或数据模型。可以看到,缓存其实是读写分离的一个简化,或者说是一个特例,左边写(DB)和右边读(缓存)其实用的基本一样的数据结构。
(2)写的这一端,通常也是在线业务DB,通过分库分表抵抗压力。读的这一端,为了扛住高并发压力,针对不通的业务场景,可能是缓存,也可能是提前join好的宽表,或者是ES搜索引擎,如果ES的性能不足,则可以自己建立倒排索引和搜索引擎。
(3)读和写的串联。定时任务把业务数据库中的数据转换为适合高并发读的数据结构,或者是写的一端把数据变更完成后发送消息给读的一端消费消息,或者读的一端直接监听数据库中的binlog,监听数据变化来更新读的一端的数据。
(4)读比写有延迟。因为左边写的数据是实时变化的,然后读的一端消费消息,读取端肯定会有所延迟,但和写之间是最终一致性,而不是强一致性,在绝大部分场景下应该是不影响业务的。
二、高并发写
策略一:数据分片
数据分片就是对要处理的数据或请求分成多份并行处理。
案例1: 数据库的分库分表。
分表后,虽然数据请求还是在一台机器上,但是可以更加充分利用机器的CPU,内存等资源。分库后,可以利用多台机器的资源。
案例2: JDK ConcurrentHashMap的实现
ConcurrentHashMap在内部分成了若干了槽,也就是若干个HashMap,这些槽可以并发的读写,槽与槽之间是独立的,不会发生数据互斥。
案例3: Kafka的partition
在Kafaka中,一个topic表示一个逻辑上的消息队列,但物理上会分散到多个partition上存储,partition之间也是相互独立的,也就提高了topic的并发量。
案例4: ES的分布式索引
在搜索引擎里有一个基本的策略就是分布式索引。当为一个比较的数据集建立索引时,索引本身也会很大,无法并发查询,可以把数据集分成n份,建成n个小索引,一个请求过来之后,可以并行的在n个索引上查询,再把结果集进行合并。
策略二:任务分片
数据分片是对要处理的数据或者请求进行分片,而任务分片是对处理程序本身进行分片。现实生活中工厂的流水线作业就是任务分片的例子。
案例1:CPU指令流水线
一致CPU指令可以拆成“取指”,“译码”,“执行”,“回写”四个阶段,四个阶段可以并行执行。
案例2: Map/Reduce
把任务拆解同时运行,归并排序算法也是这种思想,在时间复杂度上明显降低。
案例3: 1+N+M的网络模型
在服务端的网络编程中,不论时Tomcat,Netty, 还是Linux的epoll,都是基于这个1+N+M的网络模型。把一个请求的处理分成了三个工序:监听、I/O,业务逻辑处理。1个监听线程负责监听客户端socket请求;N个I/O线程负责对Socket进行读写,N约等于CPU的核数,M个线程负责对请求进行逻辑处理。进一步讲,Work线程还可以拆分成解码,业务逻辑处理,编码等环节。进一步提升并发度。
策略三:异步化
异步化常用接口表和队列术两种方式,接口表就是服务端收到请求后,把请求体写到接口表中,通过定时任务异步处理接口表中的请求数据。队列术就是把请求放到消息队列中,通过异步消息消费任务异步处理。此外,消息队列在系统解耦,数据同步,流量削峰等场景中也有广泛的使用。
场景1: 短信验证码注册或登录
通常注册或者登录APP或小程序时,采用的方式为短信验证码,短信的发送需要依赖第三方的短信发送平台。而公网的调用可能耗时较长,如果是同步调用,则应用服务器会被阻塞,当请求达到一定量级之后,应用就会卡死。
改成异步调用就能避免这一问题。应用服务器收到客户端请求后,放到消息队列并立即返回成功,然后有一个后台任务,从消息队列中读取消息,取调用第三方短信平台发送验证码。即使客户端请求的并发量很大,最多是消息堆积在消息队列里面,同时消息消费如果调用第三方平台超时,也很容易发起重试。
场景2: LSM树(写内存 + Write-Ahead日志)
LSM用到的一个核心思想就是“异步写”。LSM树支持的KV存储,当插入的时候,K是无序的,但是磁盘上又需要按照K的大小顺序存储(方便检索),也就是在磁盘上要实现一个SortedHashMap,但不可能在插入的同时对磁盘上的数据进行排序,因为需要保证数据存储顺序,需要做随机写,并且可能做大规模的数据移动,该操作必然非常耗时。
那LSM是怎么解决这个问题呢?
既然写磁盘慢,那就先不写磁盘,在内存中维护一个SortedHashMap,这样写就性能非常高了,但是数据只存储在内存,系统只要宕机就会丢失数据,所以另外再写一条日志,也就是Write-Head日志。日志有一个关键的有点就是顺序写入,即只会在日志尾部追加,而不用随机写入,所以写日志的耗时时非常短的。
有了日志的顺序写入,再加上内存的SortedHashMap,再有一个后台任务定期把内存中的SortedHashMap合并刷到磁盘文件中即可,通过异步落盘,大幅度的提高了写入的性能。这里发现这个思路和Mysql InnoDB存储引擎的redo log有异曲同工之妙。
写内存+Write-Ahead日志的这种思路不仅在数据库和KV存储领域使用,在上层业务领域中同样可以使用。比如高并发扣减电商系统中的库存,如果直接在数据库中扣,数据库会扛不住,则可以在redis中扣,同时写一条日志。当Redis宕机,把所有日志重放完毕,再用数据库中的初始化数据Redis中的数据。
场景3: Kafka的PipLine
Kafka为了保证其高可用性,会为每个Partition准备多个副本,假设一个Partition有3个副本,一个被选为Leader,另外两个时Follower。对于同步发送,客户端每发送一条消息,Leader需要把这条消息同步到两个Follower之后,才会对客户端返回成功。要实现这一点,最朴素的想法就是当客户端给Leader发送一个msg,Leader把msg同步给Follower1和Follower2,然后给客户端返回成功,这种想法很直接,但是显然效率不够。对于该问题,Kafka采用了一个典型的策略来解决,那就时PipLine,也是异步化的一种。
如图,Leader并不会主动给两个Follower同步数据,而是等Follower主动过来拉取,并且是批量拉取。当Leader收到客户端的消息msg并且保存到本地后,就去做其他事情了,比如接受下一条消息,此时客户端还处于阻塞状态,等待msg返回。只要等两个Follower都把消息同步过去之后,Leader才会给客户端返回接受成功。
那这种方式为什么叫PipLine呢?因为Leader并不是一个一个的处理消息,而是一批一批的处理,Leader和两个Follower像是组成一个管道,消息像水一样流过管道。
PipLine是异步化的一个例子,同时它也是策略二所讲的任务分片的典型例子,对于Leader来说,他把任务做了拆分,一个接受并存储客户端的消息任务,一个是同步消息到Follower的任务,两个任务是并行的。
策略四:批量
“批量”的含义通俗易懂,既然一条一条写入慢,那就多条合并,一次写入。
场景1:Kafka的百万QPS写入
Kafka的客户端在内存中为每一个Partition准备了一个队列,称为RecordAccumulator. Producer线程一条条的发送消息,这些消息都进入都内存队列,然后通过Sender线程从这些队列中批量地提取消息发送给到Kafka集群。
对于具体的批量策略,Kafka提供了几种参数进行配置,可以按Batch的大小或等待时间来批量操作。
场景2: Mysql小事务合并机制
比如扣减库存,对同一个SKU,本来是扣10次,每次扣1,就会产生10个事务,在Mysql内核里面合并成1个扣10个,也就是10个事务合并成了一个。
在多机房的数据库多活(跨数据中心的数据库复制)场景中,事务合并也是加速数据库复制的一个重要策略。
以上就是我在架构学习当中总结的一些提升软件性能的应对策略,很多思想都是可以具体的业务场景中套用的。先从架构层面看软件设计,然后再深入细节,或许会对一些优秀的开源软件设计理解得更加深入。
作者:西门吹雪