架构
- 架构是顶层设计
- 需要明确系统包含哪些实体
- 需要明确实体运作和协作的规则
- 框架是提供规范所要求的基础功能的软件产品
- 组件是从技术维度上的复用
- 模块是从业务维度上职责的划分
- 系统是互相协同可运行的实体
架构设计的目的
- 主要目的是为了解决软件系统复杂度带来的问题
- 熟悉和理解需求,识别系统复杂性所在的地方,然后针对这些复杂点进行架构设计
- 架构设计并不是要面面俱到,不需要每个架构都具备高性能、高可用、高扩展等特点,而是要识别出复杂点然后有针对性地解决问题。
- 理解每个架构方案背后所需要解决的复杂点,然后才能对比自己的业务复杂点,参考复杂点相似的方案。
复杂度来源
1、高性能
单台计算机内部为了高性能带来的复杂度
- 进程和线程
- 需要结合业务进行分析、判断、选择、组合。举一个最简单的例子:Nginx 可以用多进程也可以用多线程,JBoss 采用的是多线程;Redis 采用的是单进程,Memcache 采用的是多线程,这些系统都实现了高性能,但内部实现差异却很大
集群为了高性能带来的复杂
任务分配
每台机器都可以处理完整的业务任务,不同的任务分配到不同的机器上执行
- 需要增加一个任务分配器,这个分配器可能是硬件网络设备,可能是软件网络设备,也可能是负载均衡软件,还可能是自己开发的系统。选择合适的任务分配器也是一件复杂的事情,需要综合考虑性能、成本、可维护性、可用性等各方面的因素
- 任务分配器从 1 台变成了多台,这个变化带来的复杂度就是需要将不同的用户分配到不同的任务分配器上,常见的方法包括 DNS 轮询、智能 DNS、CDN(Content Delivery Network,内容分发网络)、GSLB 设备(Global Server Load Balance,全局负载均衡)等
- 任务分配器和真正的业务服务器之间有连接和交互;需要选择合适的连接方式,并且对连接进行管理。例如,连接建立、连接检测、连接中断后如何处理等。
- 任务分配器和业务服务器的连接从简单的“1 对多”变成了“多对多”的网状结构
- 机器数量从 3 台扩展到 30 台(一般任务分配器数量比业务服务器要少),状态管理、故障处理复杂度也大大增加。
- 任务分配器需要增加分配算法。例如,是采用轮询算法,还是按权重分配,又或者按照负载进行分配。如果按照服务器的负载进行分配,则业务服务器还要能够上报自己的状态给任务分配器
任务分解
把原来大一统但复杂的业务系统,拆分成小而简单但需要多个系统配合的业务系统。
- 系统的功能越简单,影响性能的点就越少,就更加容易进行有针对性的优化
- 当各个逻辑任务分解到独立的子系统后,整个系统的性能瓶颈更加容易发现,而且发现后只需要针对有瓶颈的子系统进行性能优化或者提升,不需要改动整个系统,风险会小很多
- 如果系统拆分得太细,为了完成某个业务,系统间的调用次数会呈指数级别上升,而系统间的调用通道目前都是通过网络传输的方式,性能远比系统内的函数调用要低得多。
2、高可用
本质上都是通过“冗余”来实现高可用
业务的逻辑处理高可用
- 增加一个任务分配器,选择合适的任务分配器也是一件复杂的事情,需要综合考虑性能、成本、可维护性、可用性等各方面因素。
- 任务分配器和真正的业务服务器之间有连接和交互,需要选择合适的连接方式,并且对连接进行管理。例如,连接建立、连接检测、连接中断后如何处理等。
- 任务分配器需要增加分配算法。例如,常见的双机算法有主备、主主,主备方案又可以细分为冷备、温备、热备。
- 高可用集群相比双机来说,分配算法更加复杂,可以是 1 主 3 备、2 主 2 备、3 主 1 备、4 主 0 备,具体应该采用哪种方式,需要结合实际业务需求来分析和判断,并不存在某种算法就一定优于另外的算法。例如,ZooKeeper 采用的就是 1 主多备,而 Memcached 采用的就是全主 0 备。
存储高可用
- 将数据从一台机器搬到到另一台机器,需要经过线路进行传输这意味着整个系统在某个时间点上,数据肯定是不一致的
- 数据 + 逻辑 = 业务;数据不一致,即使逻辑一致,最后的业务表现就不一样
- 难点不在于如何备份数据,而在于如何减少或者规避数据不一致对业务造成的影响
常见的高可用状态决策方式
独裁式
- 指的是存在一个独立的决策主体,称为“决策者”,负责收集信息然后进行决策;所有冗余的个体,称为“上报者”,都将状态信息发送给决策者。
- 不会出现决策混乱的问题,因为只有一个决策者
- 当决策者本身故障时,整个系统就无法实现准确的状态决策
协商式
指的是两个独立的个体通过交流信息,然后根据规则进行决策,最常用的协商式决策就是主备决策
民主式
指的是多个独立的个体通过投票的方式来进行状态决策。例如,ZooKeeper 集群在选举 leader 时就是采用这种方式。
- 独立的个体之间交换信息,每个个体做出自己的决策,然后按照“多数取胜”的规则来确定最终的状态。
- 选举算法复杂
- 有一个固有的缺陷:脑裂,统一的集群因为连接中断,造成了两个独立分隔的子集群,每个子集群单独进行选举,于是选出了 2 个主机
3、可扩展性
应对将来需求变化而提供的一种扩展能力,当有新的需求出现时,系统不需要或者仅需要少量修改就可以支持,无须整个系统重构或者重建
预测变化
- 不能每个设计点都考虑可扩展性
- 不能完全不考虑可扩展性
- 所有的预测都存在出错的可能性
应对变化
第一种方案是将“变化”封装在一个“变化层”,将不变的部分封装在一个独立的“稳定层”
- 系统需要拆分出变化层和稳定层
- 需要设计变化层和稳定层之间的接口。对于稳定层来说,接口肯定是越稳定越好;但对于变化层来说,在有差异的多个实现方式中找出共同点,并且还要保证当加入新的功能时原有的接口设计不需要太大修改
第二种方案是提炼出一个“抽象层”和一个“实现层”。抽象层是稳定的,实现层可以根据具体业务需要定制开发,当加入新的功能时,只需要增加新的实现,无须修改抽象层。
4 、低成本
如果架构方案涉及几百上千甚至上万台服务器,成本就会变成一个非常重要的架构设计考虑点
低成本给架构设计带来的主要复杂度体现在,往往只有“创新”才能达到低成本目标。
- NoSQL(Memcache、Redis 等)的出现是为了解决关系型数据库无法应对高并发访问带来的访问压力
- 全文搜索引擎(Sphinx、Elasticsearch、Solr)的出现是为了解决关系型数据库 like 搜索的低效的问题
- Hadoop 的出现是为了解决传统文件系统无法应对海量数据存储和计算的问题
- 新浪微博将传统的 Redis/MC + MySQL 方式,扩展为 Redis/MC + SSD Cache + MySQL 方式,SSD Cache 作为 L2 缓存使用,既解决了 MC/Redis 成本过高,容量小的问题,也解决了穿透 DB 带来的数据库访问压力
- Linkedin 为了处理每天 5 千亿的事件,开发了高效的 Kafka 消息系统
5、安全
功能上的安全
功能安全其实就是“防小偷”
功能安全更多地是和具体的编码相关,与架构关系不大。现在很多开发框架都内嵌了常见的安全功能,能够大大减少安全相关功能的重复开发,但框架只能预防常见的安全漏洞和风险(常见的 XSS 攻击、CSRF 攻击、SQL 注入等),无法预知新的安全问题,而且框架本身很多时候也存在漏洞(例如,流行的 Apache Struts2 就多次爆出了调用远程代码执行的高危漏洞,给整个互联网都造成了一定的恐慌)。
架构上的安全
架构安全就是“防强盗”
传统的架构安全主要依靠防火墙,防火墙最基本的功能就是隔离网络,通过将网络划分成不同的区域,制定出不同区域之间的访问控制策略来控制不同信任程度区域间传送的数据流。
防火墙的功能虽然强大,但性能一般,所以在传统的银行和企业应用领域应用较多
互联网的业务具有海量用户访问和高并发的特点,防火墙的性能不足以支撑;尤其是互联网领域的 DDoS 攻击,轻则几 GB,重则几十 GB。如果用防火墙来防,则需要部署大量的防火墙,成本会很高。DDoS 攻击最大的影响是大量消耗机房的出口总带宽。不管防火墙处理能力有多强,当出口带宽被耗尽时,整个业务在用户看来就是不可用的,因为用户的正常请求已经无法到达系统了。
联网系统的架构安全目前并没有太好的设计手段来实现,更多地是依靠运营商或者云服务商强大的带宽和流量清洗的能力
6、规模
很多企业级的系统,既没有高性能要求,也没有双中心高可用要求,也不需要什么扩展性,但这样的系统往往功能特别多,逻辑分支特别多。特别是有的系统,发展时间比较长,不断地往上面叠加功能,后来的人由于不熟悉整个发展历史,可能连很多功能的应用场景都不清楚,或者细节根本无法掌握,面对的就是一个黑盒系统,看不懂、改不动、不敢改、修不了,复杂度自然就感觉很高了。
-
功能越来越多,导致系统复杂度指数级上升
- 具备 8 个功能的系统的复杂度不是比具备 3 个功能的系统的复杂度多 5,而是多了 30,基本是指数级增长的,主要原因在于随着系统功能数量增多,功能之间的连接呈指数级增长
-
数据越来越多,系统复杂度发生质变
目前的大数据理论基础是 Google 发表的三篇大数据相关论文
-
Google File System 是大数据文件存储的技术理论,
-
Google Bigtable 是列式数据存储的技术理论,
-
Google MapReduce 是大数据运算的技术理论
架构设计三原则
合适原则
合适原则宣言:“合适优于业界领先”
“亿级用户平台”失败的例子
- 没那么多人,却想干那么多活,是失败的第一个主要原因
- 没有那么多积累,却想一步登天,是失败的第二个主要原因
- 没有那么卓越的业务场景,却幻想灵光一闪成为天才,是失败的第三个主要原因
没有了大公司的平台、资源、积累,只是生搬硬套大公司的做法,失败的概率非常高。
简单原则
简单原则宣言:“简单优于复杂”
- 结构的复杂性
- 组成复杂系统的组件数量更多
- 同时这些组件之间的关系也更加复杂
- 组件越多,就越有可能其中某个组件出现故障
- 某个组件改动,会影响关联的所有组件
- 定位一个复杂系统中的问题总是比简单系统更加困难
- 逻辑的复杂性
- 单个组件承担了太多的功能,导致软件工程的每个环节都有问题
- 采用了复杂的算法,难以理解,进而导致难以实现、难以修改,并且出了问题难以快速解决
演化原则
演化原则宣言:“演化优于一步到位”
- 首先,设计出来的架构要满足当时的业务需要
- 其次,架构要不断地在实际应用过程中迭代,保留优秀的设计,修复有缺陷的设计,改正错误的设计,去掉无用的设计,使得架构逐渐完善。
- 第三,当业务发生变化时,架构要扩展、重构,甚至重写;代码也许会重写,但有价值的经验、教训、逻辑、设计等却可以在新架构中延续。
架构设计流程
1、识别复杂度
将主要的复杂度问题列出来,然后根据业务、技术、团队等综合情况进行排序,优先解决当前面临的最主要的复杂度问题
2、设计备选方案
关注的是技术选型,而不是技术细节
- 备选方案的数量以 3 ~ 5 个为最佳。少于 3 个方案可能是因为思维狭隘,考虑不周全;多于 5 个则需要耗费大量的精力和时间,并且方案之间的差别可能不明显。
- 备选方案的差异要比较明显。例如,主备方案和集群方案差异就很明显,或者同样是主备方案,用 ZooKeeper 做主备决策和用 Keepalived 做主备决策的差异也很明显。但是都用 ZooKeeper 做主备决策,一个检测周期是 1 分钟,一个检测周期是 5 分钟,这就不是架构上的差异,而是细节上的差异了,不适合做成两个方案。
- 备选方案的技术不要只局限于已经熟悉的技术。
3、评估和选择备选方案
列出我们需要关注的质量属性点,然后分别从这些质量属性的维度去评估每个方案,再综合挑选适合当时情况的最优方案
常见的方案质量属性点有:性能、可用性、硬件成本、项目投入、复杂度、安全性、可扩展性等。
在评估这些质量属性时,需要遵循架构设计原则 1“合适原则”和原则 2“简单原则”,避免贪大求全,基本上某个质量属性能够满足一定时期内业务发展就可以了
如果某个质量属性评估和业务发展有关系(例如,性能、硬件成本等),需要评估未来业务发展的规模时,一种简单的方式是将当前的业务规模乘以 2 ~4 即可,如果现在的基数较低,可以乘以 4;如果现在基数较高,可以乘以 2。
按优先级选择,即综合当前的业务发展情况、团队人员规模和技能、业务发展预测等因素,将质量属性按照优先级排序,首先挑选满足第一优先级的,如果方案都满足,那就再看第二优先级……以此类推。
4、详细方案设计
将方案涉及的关键技术细节给确定下来
高性能数据库集群
读写分离
读写分离的基本原理是将数据库读写操作分散到不同的节点上
- 读写分离的基本实现是:
- 数据库服务器搭建主从集群,一主一从、一主多从都可以
- 数据库主机负责读写操作,从机只负责读操作
- 数据库主机通过复制将数据同步到从机,每台数据库服务器都存储了所有的业务数据
- 业务服务器将写操作发给数据库主机,将读操作发给数据库从机
主从复制延迟
解决主从复制延迟有几种常见的方法:
写操作后的读操作指定发给数据库主服务器
- 这种方式和业务强绑定,对业务的侵入和影响较大,如果哪个新来的程序员不知道这样写代码,就会导致一个 bug。
读从机失败后再读一次主机
- 二次读取和业务无绑定,只需要对底层数据库访问的 API 进行封装即可,实现代价较小,不足之处在于如果有很多二次读取,将大大增加主机的读操作压力。
关键业务读写操作全部指向主机,非关键业务采用读写分离
分配机制
将读写操作区分开来,然后访问不同的数据库服务器:
程序代码封装
- 在代码中抽象一个数据访问层,实现读写操作分离和数据库服务器连接的管理
- 实现简单,而且可以根据业务做较多定制化的功能
- 每个编程语言都需要自己实现一次,无法通用,如果一个业务包含多个编程语言写的多个子系统,则重复开发的工作量比较大
- 故障情况下,如果主从发生切换,则可能需要所有系统都修改配置并重启。
- 目前开源的实现方案中,淘宝的 TDDL(Taobao Distributed Data Layer,外号: 头都大了)是比较有名的。它是一个通用数据访问层,所有功能封装在 jar 包中提供给业务代码调用。其基本原理是一个基于集中式配置的 jdbc datasource 实现,具有主备、读写分离、动态数据库配置等功能,基本架构是:
中间件封装
- 独立一套系统出来,实现读写操作分离和数据库服务器连接的管理。中间件对业务服务器提供 SQL 兼容的协议,业务服务器无须自己进行读写分离。对于业务服务器来说,访问中间件和访问数据库没有区别,事实上在业务服务器看来,中间件就是一个数据库服务器。
- 能够支持多种编程语言,因为数据库中间件对业务服务器提供的是标准 SQL 接口。
- 数据库中间件要支持完整的 SQL 语法和数据库服务器的协议(例如,MySQL 客户端和服务器的连接协议),实现比较复杂,细节特别多,很容易出现 bug,需要较长的时间才能稳定。
- 数据库中间件自己不执行真正的读写操作,但所有的数据库操作请求都要经过中间件,中间件的性能要求也很高。
- 数据库主从切换对业务服务器无感知,数据库中间件可以探测数据库服务器的主从状态。例如,向某个测试表写入一条数据,成功的就是主机,失败的就是从机。
分库分表
业务分库
业务分库指的是按照业务模块将数据分散到不同的数据库服务器。
join 操作问题
- 业务分库后,原本在同一个数据库中的表分散到不同数据库中,导致无法使用 SQL 的 join 查询。
事务问题
- 原本在同一个数据库中不同的表可以在同一个事务中修改,业务分库后,表分散到不同的数据库中,无法通过事务统一修改。
成本问题
- 业务分库同时也带来了成本的代价,本来 1 台服务器搞定的事情,现在要 3 台,如果考虑备份,那就是 2 台变成了 6 台。
分表
单表进行切分后,是否要将切分后的多个表分散在不同的数据库服务器中,可以根据实际的切分效果来确定,并不强制要求单表切分为多表后一定要分散到不同数据库中。
垂直分表
垂直分表适合将表中某些不常用且占了大量空间的列拆分出去
复杂性主要体现在表操作的数量要增加
水平分表
水平分表适合表行数特别大的表,有的要求单表行数超过 5000 万就必须进行分表,这个数字可以作为参考,但并不是绝对标准,关键还是要看表的访问性能。对于一些比较复杂的表,可能超过 1000 万就要分表了;而对于一些简单的表,即使存储数据超过 1 亿行,也可以不分表。但不管怎样,当看到表的数据量达到千万级别时,作为架构师就要警觉起来,因为这很可能是架构的性能瓶颈或者隐患。
路由
水平分表后,某条数据具体属于哪个切分后的子表,需要增加路由算法进行计算,这个算法会引入一定的复杂性。
常见的路由算法有:
-
**范围路由:**选取有序的数据列(例如,整形、时间戳等)作为路由的条件,不同分段分散到不同的数据库表中。以最常见的用户 ID 为例,路由算法可以按照 1000000 的范围大小进行分段,1 ~ 999999 放到数据库 1 的表中,1000000 ~ 1999999 放到数据库 2 的表中,以此类推。
- 设计的复杂点主要体现在分段大小的选取上,分段太小会导致切分后子表数量过多,增加维护复杂度;分段太大可能会导致单表依然存在性能问题,一般建议分段大小在 100 万至 2000 万之间,具体需要根据业务选取合适的分段大小。
- 优点是可以随着数据的增加平滑地扩充新的表。
- 缺点是分布不均匀,假如按照 1000 万来进行分表,有可能某个分段实际存储的数据量只有 1000 条,而另外一个分段实际存储的数据量有 900 万条。
-
**Hash 路由:**选取某个列(或者某几个列组合也可以)的值进行 Hash 运算,然后根据 Hash 结果分散到不同的数据库表中。同样以用户 ID 为例,假如我们一开始就规划了 10 个数据库表,路由算法可以简单地用 user_id % 10 的值来表示数据所属的数据库表编号,ID 为 985 的用户放到编号为 5 的子表中,ID 为 10086 的用户放到编号为 6 的字表中
- 设计的复杂点主要体现在初始表数量的选取上,表数量太多维护比较麻烦,表数量太少又可能导致单表性能存在问题。
- 优点是表分布比较均匀
- 缺点是扩充新的表很麻烦,所有数据都要重分布
-
**配置路由:**配置路由就是路由表,用一张独立的表来记录路由信息。同样以用户 ID 为例,我们新增一张 user_router 表,这个表包含 user_id 和 table_id 两列,根据 user_id 就可以查询对应的 table_id。
- 设计简单,使用起来非常灵活,尤其是在扩充表的时候,只需要迁移指定的数据,然后修改路由表就可以了。
- 缺点就是必须多查询一次,会影响整体性能;而且路由表本身如果太大(例如,几亿条数据)
join 操作
水平分表后,数据分散在多个表中,如果需要与其他表进行 join 查询,需要在业务代码或者数据库中间件中进行多次 join 查询,然后将结果合并。
count() 操作
水平分表后,虽然物理上数据分散到多个表中,但某些业务逻辑上还是会将这些表当作一个表来处理。例如,获取记录总数用于分页或者展示,水平分表前用一个 count() 就能完成的操作,在分表后就没那么简单了。常见的处理方式有下面两种:
- **count() 相加:**具体做法是在业务代码或者数据库中间件中对每个表进行 count() 操作,然后将结果相加。这种方式实现简单,缺点就是性能比较低。例如,水平分表后切分为 20 张表,则要进行 20 次 count(*) 操作,如果串行的话,可能需要几秒钟才能得到结果。
- **记录数表:**具体做法是新建一张表,假如表名为“记录数表”,包含 table_name、row_count 两个字段,每次插入或者删除子表数据成功后,都更新“记录数表”。性能要大大优于 count() 相加的方式,缺点是复杂度增加不少,对子表的操作要同步操作“记录数表”,如果有一个业务逻辑遗漏了,数据就会不一致;且针对“记录数表”的操作和针对子表的操作无法放在同一事务中进行处理,异常的情况下会出现操作子表成功了而操作记录数表失败,同样会导致数据不一致。
- **定时更新:**记录数表的方式也增加了数据库的写压力,因为每次针对子表的 insert 和 delete 操作都要 update 记录数表,所以对于一些不要求记录数实时保持精确的业务,也可以通过后台定时更新记录数表。定时更新实际上就是“count() 相加”和“记录数表”的结合,即定时通过 count() 相加计算表的记录数,然后更新记录数表中的数据。
order by 操作
数据分散到多个子表中,排序操作无法在数据库中完成,只能由业务代码或者数据库中间件分别查询每个子表中的数据,然后汇总进行排序。
高性能NoSQL
NoSQL 作为 SQL 的一个有力补充
常见的 NoSQL 方案分为 4 类:
- K-V 存储:解决关系数据库无法存储数据结构的问题,以 Redis 为代表
- Redis 的事务只能保证隔离性和一致性,无法保证原子性和持久性
- 文档数据库:解决关系数据库强 schema 约束的问题,以 MongoDB 为代表
- 可以存储和读取任意的数据
- 业务上增加新的字段,无须再像关系数据库一样要先执行 DDL 语句修改表结构,程序代码直接读写即可。
- 对于历史数据,即使没有新增的字段,也不会导致错误,只会返回空值,此时代码进行兼容处理即可。
- 可以很容易存储复杂数据
- 特别适合电商和游戏这类的业务场景
- 代价就是不支持事务
- 无法实现关系数据库的 join 操作,需要查两次
- 列式数据库:解决关系数据库大数据场景下的 I/O 问题,以 HBase 为代表
- 典型的场景就是海量数据进行统计。例如,计算某个城市体重超重的人员数据,实际上只需要读取每个人的体重这一列并进行统计即可,而行式存储即使最终只使用一列,也会将所有行数据都读取出来。如果单行用户信息有 1KB,其中体重只有 4 个字节,行式存储还是会将整行 1KB 数据全部读取到内存中,这是明显的浪费。而如果采用列式存储,每个用户只需要读取 4 字节的体重数据即可,I/O 将大大减少。
- 列式存储还具备更高的存储压缩比,能够节省更多的存储空间,因为单个列的数据相似度相比行来说更高,能够达到更高的压缩率
- 列式存储将不同列存储在磁盘上不连续的空间,导致更新多个列时磁盘是随机写操作;而行式存储时同一行多个列都存储在连续的空间,一次磁盘写操作就可以完成,列式存储的随机写效率要远远低于行式存储的写效率。
- 列式存储高压缩率在更新场景下也会成为劣势,因为更新时需要将存储数据解压后更新,然后再压缩,最后写入磁盘。
- 一般将列式存储应用在离线的大数据分析和统计场景中,因为这种场景主要是针对部分列单列进行操作,且数据写入后就无须再更新删除。
- 全文搜索引擎:解决关系数据库的全文搜索性能问题,以 Elasticsearch 为代表
- 其基本原理是建立单词到文档的索引。之所以被称为“倒排”索引,是和“正排“索引相对的,“正排索引”的基本原理是建立文档到单词的索引。
- 全文搜索引擎的索引对象是单词和文档,而关系数据库的索引对象是键和行
- 将关系型数据按照对象的形式转换为 JSON 文档,然后将 JSON 文档输入全文搜索引擎进行索引
高性能缓存架构
在某些复杂的业务场景下,单纯依靠存储系统的性能提升不够的,典型的场景有:
- 需要经过复杂运算后得出的数据,存储系统无能为力
- 读多写少的数据,存储系统有心无力
缓存就是为了弥补存储系统在这些复杂业务场景下的不足,其基本原理是将可能重复使用的数据放到内存中,一次生成、多次使用,避免每次使用都去访问存储系统。
缓存穿透
缓存穿透是指缓存没有发挥作用,业务系统虽然去缓存查询数据,但缓存中没有数据,业务系统需要再次去存储系统查询数据。通常情况下有两种情况:
- 存储数据不存在
- 如果查询存储系统的数据没有找到,则直接设置一个默认值(可以是空值,也可以是具体的值)存到缓存中,这样第二次读取缓存时就会获取到默认值,而不会继续访问存储系统。
- 缓存数据生成耗费大量时间或者资源
- 存储系统中存在数据,但生成缓存数据需要耗费较长时间或者耗费大量资源。如果刚好在业务访问的时候缓存失效了,那么也会出现缓存没有发挥作用,访问压力全部集中在存储系统上的情况。
- 典型的就是电商的商品分页,假设我们在某个电商平台上选择“手机”这个类别查看,由于数据巨大,不能把所有数据都缓存起来,只能按照分页来进行缓存,由于难以预测用户到底会访问哪些分页,因此业务上最简单的就是每次点击分页的时候按分页计算和生成缓存。通常情况下这样实现是基本满足要求的,但是如果被竞争对手用爬虫来遍历的时候,系统性能就可能出现问题。
- 分页缓存的有效期设置为 1 天,因为设置太长时间的话,缓存不能反应真实的数据。
- 通常情况下,用户不会从第 1 页到最后 1 页全部看完,一般用户访问集中在前 10 页,因此第 10 页以后的缓存过期失效的可能性很大。
- 竞争对手每周来爬取数据,爬虫会将所有分类的所有数据全部遍历,从第 1 页到最后 1 页全部都会读取,此时很多分页缓存可能都失效了。
- 由于很多分页都没有缓存数据,从数据库中生成缓存数据又非常耗费性能(order by limit 操作),因此爬虫会将整个数据库全部拖慢。
- 通常的应对方案要么就是识别爬虫然后禁止访问,但这可能会影响 SEO 和推广;要么就是做好监控,发现问题后及时处理,因为爬虫不是攻击,不会进行暴力破坏,对系统的影响是逐步的,监控发现问题后有时间进行处理。
缓存雪崩
缓存雪崩是指当缓存失效后引起系统性能急剧下降的情况。当缓存过期被清除后,业务系统需要重新生成缓存,因此需要再次访问存储系统,再次进行运算,这个处理步骤耗时几十毫秒甚至上百毫秒。而对于一个高并发的业务系统来说,几百毫秒内可能会接到几百上千个请求。由于旧的缓存已经被清除,新的缓存还未生成,并且处理这些请求的线程都不知道另外有一个线程正在生成缓存,因此所有的请求都会去重新生成缓存,都会去访问存储系统,从而对存储系统造成巨大的性能压力。这些压力又会拖慢整个系统,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃。
- 更新锁机制
- 对缓存更新操作进行加锁保护,保证只有一个线程能够进行缓存更新,未能获取更新锁的线程要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
- 分布式集群的业务系统要实现更新锁机制,需要用到分布式锁
- 后台更新机制
- 由后台线程来更新缓存,而不是由业务线程来更新缓存,缓存本身的有效期设置为永久,后台线程定时更新缓存。
- 需要考虑一种特殊的场景,当缓存系统内存不够时,会“踢掉”一些缓存数据,从缓存被“踢掉”到下一次定时更新缓存的这段时间内,业务线程读取缓存返回空值,而业务线程本身又不会去更新缓存,因此业务上看到的现象就是数据丢了。
- 后台线程除了定时更新缓存,还要频繁地去读取缓存(例如,1 秒或者 100 毫秒读取一次),如果发现缓存被“踢了”就立刻更新缓存,这种方式实现简单,但读取时间间隔不能设置太长,因为如果缓存被踢了,缓存读取间隔时间又太长,这段时间内业务访问都拿不到真正的数据而是一个空的缓存值,用户体验一般。
- 业务线程发现缓存失效后,通过消息队列发送一条消息通知后台线程更新缓存。可能会出现多个业务线程都发送了缓存更新消息,但其实对后台线程没有影响,后台线程收到消息后更新缓存前可以判断缓存是否存在,存在就不执行更新操作。这种方式实现依赖消息队列,复杂度会高一些,但缓存更新更及时,用户体验更好。
- 既适应单机多线程的场景,也适合分布式集群的场景,相比更新锁机制要简单一些
- 还适合业务刚上线的时候进行缓存预热
缓存热点
虽然缓存系统本身的性能比较高,但对于一些特别热点的数据,如果大部分甚至所有的业务请求都命中同一份缓存数据,则这份数据所在的缓存服务器的压力也很大
- 复制多份缓存副本,将请求分散到多个缓存服务器上,减轻缓存热点导致的单台缓存服务器压力
- 设定一个过期时间范围,不同的缓存副本的过期时间是指定范围内的随机值
单服务器高性能模式
PPC与TPC
单服务器高性能的关键之一就是服务器采取的并发模型,并发模型有如下两个关键设计点:
以上两个设计点最终都和操作系统的 I/O 模型及进程模型相关。
- I/O 模型:阻塞、非阻塞、同步、异步
- 进程模型:单进程、多进程、多线程
PPC
PPC 是 Process Per Connection 的缩写,其含义是指每次有新的连接就新建一个进程去专门处理这个连接的请求,这是传统的 UNIX 网络服务器所采用的模型。
父进程“fork”子进程后,直接调用了 close,看起来好像是关闭了连接,其实只是将连接的文件描述符引用计数减一,真正的关闭连接是等子进程也调用 close 后,连接对应的文件描述符引用计数变为 0 后,操作系统才会真正关闭连接
- fork 代价高:站在操作系统的角度,创建一个进程的代价是很高的,需要分配很多内核资源,需要将内存映像从父进程复制到子进程。即使现在的操作系统在复制内存映像时用到了 Copy on Write(写时复制)技术,总体来说创建进程的代价还是很大的。
- 父子进程通信复杂:父进程“fork”子进程时,文件描述符可以通过内存映像复制从父进程传到子进程,但“fork”完成后,父子进程通信就比较麻烦了,需要采用 IPC(Interprocess Communication)之类的进程通信方案。例如,子进程需要在 close 之前告诉父进程自己处理了多少个请求以支撑父进程进行全局的统计,那么子进程和父进程必须采用 IPC 方案来传递信息。
- 支持的并发连接数量有限:如果每个连接存活时间比较长,而且新的连接又源源不断的进来,则进程数量会越来越多,操作系统进程调度和切换的频率也越来越高,系统的压力也会越来越大。因此,一般情况下,PPC 方案能处理的并发连接数量最大也就几百。
并发几百连接的场景下,反而更多地是采用 PPC 的方案,因为 PPC 方案不会有死锁的风险,也不会多进程互相影响,稳定性更高
TPC
TPC 是 Thread Per Connection 的缩写,其含义是指每次有新的连接就新建一个线程去专门处理这个连接的请求。与进程相比,线程更轻量级,创建线程的消耗比进程要少得多;同时多线程是共享进程内存空间的,线程通信相比进程通信更简单。因此,TPC 实际上是解决或者弱化了 PPC fork 代价高的问题和父子进程通信复杂的问题。
- 创建线程虽然比创建进程代价低,但并不是没有代价,高并发时(例如每秒上万连接)还是有性能问题。
- 无须进程间通信,但是线程间的互斥和共享又引入了复杂度,可能一不小心就导致了死锁问题。
- 多线程会出现互相影响的情况,某个线程出现异常时,可能导致整个进程退出(例如内存越界)。
TPC 还是存在 CPU 线程调度和切换代价的问题
Reactor与Proactor
Reactor
PPC 模式最主要的问题就是每个连接都要创建进程,连接结束后进程就销毁了,这样做其实是很大的浪费。
为了解决这个问题,一个自然而然的想法就是资源复用,即不再单独为每个连接创建进程,而是创建一个进程池,将连接分配给进程,一个进程可以处理多个连接的业务。
引入资源池的处理方式后,会引出一个新的问题:进程如何才能高效地处理多个连接的业务?
当一个连接一个进程时,进程可以采用“read -> 业务处理 -> write”的处理流程,如果当前连接没有数据可以读,则进程就阻塞在 read 操作上。这种阻塞的方式在一个连接一个进程的场景下没有问题,但如果一个进程处理多个连接,进程阻塞在某个连接的 read 操作上,此时即使其他连接有数据可读,进程也无法去处理,很显然这样是无法做到高性能的。
解决这个问题的最简单的方式是将 read 操作改为非阻塞,然后进程不断地轮询多个连接。这种方式能够解决阻塞的问题,但解决的方式并不优雅。首先,轮询是要消耗 CPU 的;其次,如果一个进程处理几千上万的连接,则轮询的效率是很低的。
为了能够更好地解决上述问题,只有当连接上有数据的时候进程才去处理,这就是 I/O 多路复用技术的来源。
- 当多条连接共用一个阻塞对象后,进程只需要在一个阻塞对象上等待,而无须再轮询所有连接,常见的实现方式有 select、epoll、kqueue 等。
- 当某条连接有新的数据可以处理时,操作系统会通知进程,进程从阻塞状态返回,开始进行业务处理。
I/O 多路复用结合线程池,完美地解决了 PPC 和 TPC 的问题,而且“大神们”给它取了一个很牛的名字:Reactor,是“事件反应”的意思,可以通俗地理解为“来了一个事件我就有相应的反应”,Reactor 会根据事件类型来调用相应的代码进行处理。Reactor 模式也叫 Dispatcher 模式(在很多开源的系统里面会看到这个名称的类,其实就是实现 Reactor 模式的),更加贴近模式本身的含义,即 I/O 多路复用统一监听事件,收到事件后分配(Dispatch&