侧边栏壁纸
博主头像
牧云

怀璧慎显,博识谨言。

  • 累计撰写 102 篇文章
  • 累计创建 12 个标签
  • 累计收到 8 条评论

目 录CONTENT

文章目录

从0开始学架构(1):架构理论

秋之牧云
2025-12-04 / 0 评论 / 0 点赞 / 17 阅读 / 0 字

1. 架构定义

1.1. 开篇

架构设计需要考虑哪些问题

架构设计方法论

架构设计的流程

深入理解已有的成熟的架构模式、在已有模式上改进和创新

  • 架构设计合程序设计的差异

    • 架构设计关键思维:判断和取舍
    • 程序设计的关键思维:逻辑和实现
  • 架构基础

    • 本质、历史背景和目的
    • 复杂度来源、设计原则、流程
  • 高性能架构模式

    • 存储
    • 计算
  • 高可用架构模式

    • CAP原理
    • FMEA分析方法
    • 常用高可用存储架构和计算架构
    • 设计方法和技巧
  • 可扩展架构模式

    • 扩展模式
    • 常见架构模式
  • 架构实战

    • 架构原则
    • 架构流程
    • 架构模式

1.2. 概念

  • 系统与子系统

    • 有关联的个体
    • 按照某种规则运作
    • 系统能力与个体的能力存在本质差别,不是个体能力之和,而是产生一种新的能力
    • 回答问题:我们在讨论架构时,到底在讨论什么
  • 一个系统的架构,只包含顶层这一个层级的架构,不包括下属子系统的架构。子系统也会有自己的架构,同样也只包括顶层

  • 模块与组件

  • 模块和组件都是系统的组成部分,只是拆分角度不同

  • 业务角度拆分:模块,目的是职责分离

  • 物理角度拆分:组件,目的是单元复用,英文Component对应中文“零件”,零件是物理概念

  • 以一个最简单的网站系统来为例。假设我们要做一个学生信息管理系统,这个系统从逻辑的角度来拆分,可以分为“登录注册模块”“个人信息模块”和“个人成绩模块”;从物理的角度来拆分,可以拆分为Nginx、Web服务器和MySQL。

  • 回答问题:架构师应该关注什么

    • 首先要从业务的角度把系统拆分成一个个模块角色
    • 其次考虑从物理部署的角度把字体拆分成组件角色
  • 框架与架构

    • 框架是组件规范
    • 框架是提供基础功能的产品
    • 架构是“基础结构”
    • 框架(Framework)关注的是“规范”,架构(Architecture)关注的是“结构
    • 回答问题:框架和架构有什么区别
  • 框架是一套开发规范

  • 架构是某一套开发规范下的具体落地方案,包括各个模块间的组合关系及运作规则

  • 架构定义:4R架构

  • 软件架构指软件系统的顶层(Rank)结构,它定义了系统由哪些角色(Role)组成,角色之间的关系(Relation)和运作规则(Rule)

  • Rank,顶层结构

    • 软件架构应该是分层的,对应系统和子系统的分层关系,通常我们只需要关注某一层的架构,最多展示相邻的两层架构,“自顶向下,逐步细化”

    1764811274440.png

  • Role

    • 系统包括哪些角色,每个角色负责一部分功能,按照业务拆分,架构设计的最重要的工作之一
  • Relation

    • 角色之间的关系,对应架构设计图中的连线,关系最终使用代码来实现,包括链接方式(HTTP、RPC、TCP、UDP)、数据协议(JSON、XML、二进制)、具体接口
  • Rule

    • 角色之间如何运作来完成系统功能,系统能力不是各个角色的能力之和,而是产生一种新的能力
    • 使用系统序列图来体现

1764811289153.png

1764811302409.png

2. 架构设计的历史背景

如果想要深入理解一个事物的本质,最好的方式就是去追寻这个事物出现的历史背景和推动因素

  • 机器语言
  • 汇编语言
  • 高级语言
  • 面向对象

软件架构的出现有其历史必然性。20世纪60年代第一次软件危机引出了“结构化编程”,创造了“模块”概念;20世纪80年代第二次软件危机引出了“面向对象编程”,创造了“对象”概念;到了20世纪90年代“软件架构”开始流行,创造了“组件”概念。我们可以看到,“模块”“对象”“组件”本质上都是对达到一定规模的软件进行拆分,差别只是在于随着软件的复杂度不断增加,拆分的粒度越来越粗,拆分的层次越来越高。

3. 架构设计的目的

  • 软件技术的发展历史,就是一部与“复杂度”做斗争的历史,架构设计的主要目的是为了解决软件系统复杂度带来的问题

  • 架构设计原则:架构设计是为了解决软件复杂度

    • 识别复杂度所在地,针对复杂点进行架构设计
    • 业界成熟的架构,都是为了解决某一个复杂点
  • 如何分析复杂度

假设我们需要设计一个大学的学生管理系统,其基本功能包括登录、注册、成绩管理、课程管理等。当我们对这样一个系统进行架构设计的时候,首先应识别其复杂度到底体现在哪里。

性能:一个学校的学生大约1 ~ 2万人,学生管理系统的访问频率并不高,平均每天单个学生的访问次数平均不到1次,因此性能这部分并不复杂,存储用MySQL完全能够胜任,缓存都可以不用,Web服务器用Nginx绰绰有余。

可扩展性:学生管理系统的功能比较稳定,可扩展的空间并不大,因此可扩展性也不复杂。

高可用:学生管理系统即使宕机2小时,对学生管理工作影响并不大,因此可以不做负载均衡,更不用考虑异地多活这类复杂的方案了。但是,如果学生的数据全部丢失,修复是非常麻烦的,只能靠人工逐条修复,这个很难接受,因此需要考虑存储高可靠,这里就有点复杂了。我们需要考虑多种异常情况:机器故障、机房故障,针对机器故障,我们需要设计MySQL同机房主备方案;针对机房故障,我们需要设计MySQL跨机房同步方案。

安全性:学生管理系统存储的信息有一定的隐私性,例如学生的家庭情况,但并不是和金融相关的,也不包含强隐私(例如玉照、情感)的信息,因此安全性方面只要做3个事情就基本满足要求了:Nginx提供ACL控制、用户账号密码管理、数据库访问权限控制。

成本:由于系统很简单,基本上几台服务器就能够搞定,对于一所大学来说完全不是问题,可以无需太多关注。

1764811328218.png

4. 软件复杂度来源

4.1. 高性能

  • 软件系统中高性能带来的复杂度主要体现在两方面
    • 单台计算机内部为了高性能带来的复杂度
    • 多台计算机集群为了高性能带来的复杂度

4.1.1. 单机复杂度

  • 操作系统

    • 进程
  • 进程间通信:管道、消息队列、信号量、共享存储等

  • 线程:并行、互斥锁

  • 有了多线程后,操作系统调度的最小单位就变成了线程,而进程变成了操作系统分配资源的最小单位

  • 多核心

如果我们要完成一个高性能的软件系统,需要考虑如多进程、多线程、进程间通信、多线程并发等技术点,而且这些技术并不是最新的就是最好的,也不是非此即彼的选择。在做架构设计的时候,需要花费很大的精力来结合业务进行分析、判断、选择、组合,这个过程同样很复杂。举一个最简单的例子:Nginx可以用多进程也可以用多线程,JBoss采用的是多线程;Redis采用的是单进程,Memcache采用的是多线程,这些系统都实现了高性能,但内部实现差异却很大。

4.1.2. 集群复杂度

  • 背景:业务的发展速度超过了硬件的发展速度
  • 通过大量机器来提升性能,并不仅仅是增加机器这么简单,让多台机器配合起来达到高性能的目的,是一个复杂的任务
    • 任务分配:任务分配的意思是指每台机器都可以处理完整的业务任务,不同的任务分配到不同的机器上执行

1764811343138.png

  • 需要增加一个任务分配器:可能是硬件网络设备(例如,F5、交换机等),可能是软件网络设备(例如,LVS),也可能是负载均衡软件(例如,Nginx、HAProxy),还可能是自己开发的系统
  • 任务分配器和真正的业务服务器之间有连接和交互(即图中任务分配器到业务服务器的连接线),需要选择合适的连接方式,并且对连接进行管理(连接建立、连接检测、连接中断后如何处理)
  • 任务分配器需要增加分配算法(轮询算法、权重分配、负载分配),业务服务器还要能够上报自己的状态给任务分配器(实际上的性能一般按照8折计算)
  • 随着性能的增加,任务分配器本身又会成为性能瓶颈,当业务请求达到每秒10万次的时候,单台任务分配器也不够用了,任务分配器本身也需要扩展为多台机器

1764811357093.png

  • 任务分配器从1台变成了多台:需要将不同的用户分配到不同的任务分配器上(DNS轮询、智能DNS、CDN(Content Delivery Network,内容分发网络)、GSLB设备(Global Server Load Balance,全局负载均衡))
  • 任务分配器和业务服务器的连接从简单的“1对多”(1台任务分配器连接多台业务服务器)变成了“多对多”(多台任务分配器连接多台业务服务器)的网状结构
  • 状态管理、故障处理复杂度也大大增加(一般任务分配器数量比业务服务器要少)
  • “任务分配器”也并不一定只能是物理上存在的机器或者一个独立运行的程序,也可以是嵌入在其他程序中的算法,例如Memcache的集群架构。

1764811369114.png

  • 任务分解

    • 通过任务分配的方式,我们能够突破单台机器处理性能的瓶颈,通过增加更多的机器来满足业务的性能需求,但如果业务本身也越来越复杂,单纯只通过任务分配的方式来扩展性能,收益会越来越低
    • 业务越来越复杂,单台机器处理的性能会越来越低
    • 原因:
  • 简单的系统更加容易做到高性能

  • 可以针对单个任务进行扩展:只需要针对有瓶颈的子系统进行性能优化或者提升,不需要改动整个系统

  • 并不是拆分的越多越好,拆分的越多,系统间调用的复杂度会呈指数级增长

1764811379214.png

  • 最终决定业务处理性能的还是业务逻辑本身,业务逻辑本身没有发生大的变化下,理论上的性能是有一个上限的,系统拆分能够让性能逼近这个极限,但无法突破这个极限,对于架构设计来说,如何把握这个粒度就非常关键了

4.2. 高可用

  • 高可用的定义:系统无中断地执行其功能的能力
  • 如何实现:本质上都是通过“冗余”来实现高可用
  • 区别:
    • 高可用的“冗余”解决方案,单纯从形式上来看,和之前讲的高性能是一样的,都是通过增加更多机器来达到目的,但其实本质上是有根本区别的:高性能增加机器目的在于“扩展”处理性能;高可用增加机器目的在于“冗余”处理单元

4.2.1. 计算高可用(业务逻辑处理)

计算有一个特点就是无论在哪台机器上进行计算,同样的算法和输入数据,产出的结果都是一样的,所以将计算从一台机器迁移到另外一台机器,对业务并没有什么影响

  • 单机变双机

1764811390931.png

  • 需要增加一个任务分配器

  • 任务分配器和真正的业务服务器之间有连接和交互

  • 任务分配器需要增加分配算法

    • 主备、主主
    • 主备方案又可以细分为冷备、温备、热备
  • 高可用集群架构

1764811401721.png

  • 分配算法更加复杂,可以是1主3备、2主2备、3主1备、4主0备,具体应该采用哪种方式,需要结合实际业务需求来分析和判断,并不存在某种算法就一定优于另外的算法。例如,ZooKeeper采用的就是1主多备,而Memcached采用的就是全主0备。

4.2.2. 存储高可用

存储与计算相比,有一个本质上的区别:将数据从一台机器搬到到另一台机器,需要经过线路进行传输。传输就会耗时,这意味着整个系统在某个时间点上,数据肯定是不一致的。按照“数据+ 逻辑= 业务”这个公式来套的话,数据不一致,即使逻辑一致,最后的业务表现就不一样了。

以最经典的银行储蓄业务为例,假设用户的数据存在北京机房,用户存入了1万块钱,然后他查询的时候被路由到了上海机房,北京机房的数据没有同步到上海机房,用户会发现他的余额并没有增加1万块。

1764811413750.png

所以存储高可用的难点不在于如何备份数据,而在于如何减少或者规避数据不一致对业务造成的影响

分布式领域里面有一个著名的CAP定理,从理论上论证了存储高可用的复杂度。也就是说,存储高可用不可能同时满足“一致性、可用性、分区容错性”,最多满足其中两个,这就要求我们在做架构设计时结合业务进行取舍。

4.2.3. 高可用状态决策

无论是计算高可用还是存储高可用,其基础都是“状态决策”,即系统需要能够判断当前的状态是正常还是异常,如果出现了异常就要采取行动来保证高可用。但在具体实践的过程中,恰好存在一个本质的矛盾:通过冗余来实现的高可用系统,状态决策本质上就不可能做到完全正确。常见的决策方式:

  • 独裁式
    • 独裁式决策指的是存在一个独立的决策主体,我们姑且称它为“决策者”,负责收集信息然后进行决策;所有冗余的个体,我们姑且称它为“上报者”,都将状态信息发送给决策者。

1764811425595.png

  • 独裁式的决策方式不会出现决策混乱的问题,因为只有一个决策者,但问题也正是在于只有一个决策者。当决策者本身故障时,整个系统就无法实现准确的状态决策。如果决策者本身又做一套状态决策,那就陷入一个递归的死循环了。
  • 协商式
  • 协商式决策指的是两个独立的个体通过交流信息,然后根据规则进行决策,最常用的协商式决策就是主备决策

1764811434347.png

  • 基本协商规则

    • 2台服务器启动时都是备机
    • 2台服务器建立连接
    • 2台服务器交换状态信息
    • 某1台服务器做出决策,成为主机;另一台服务器继续保持备机身份
  • 难点在于,如果两者的信息交换出现问题(比如主备连接中断),此时状态决策应该怎么做

    • 两主:备机在连接中断的情况下认为主机故障,那么备机需要升级为主机

1764811444893.png

  • 无主:备机在连接中断的情况下不认为主机故障,则此时如果主机真的发生故障,那么系统就没有主机了

1764811453455.png

  • 增加更多的连接,降低连接中断对状态带来的影响,但同时又引入了这几条连接之间信息取舍的问题,即如果不同连接传递的信息不同,应该以哪个连接为准?实际上这也是一个无解的答案,无论以哪个连接为准,在特定场景下都可能存在问题。

1764811461560.png

  • 综合分析,协商式状态决策在某些场景总是存在一些问题的。
  • 民主式
  • 民主式决策指的是多个独立的个体通过投票的方式来进行状态决策。例如,ZooKeeper集群在选举leader时就是采用这种方式。民主式决策和协商式决策比较类似,其基础都是独立的个体之间交换信息,每个个体做出自己的决策,然后按照“多数取胜”的规则来确定最终的状态,ZooKeeper的选举算法ZAB

1764811474319.png

  • 民主式决策还有一个固有的缺陷:脑裂,脑裂的根本原因是,原来统一的集群因为连接中断,造成了两个独立分隔的子集群,每个子集群单独进行选举,于是选出了2个主机

1764811488755.png

  • 为了解决脑裂问题,民主式决策的系统一般都采用“投票节点数必须超过系统总节点数一半”规则来处理,这种方式虽然解决了脑裂问题,但同时降低了系统整体的可用性,即如果系统不是因为脑裂问题导致投票节点数过少,而真的是因为节点故障(例如,节点1、节点2、节点3真的发生了故障),此时系统也不会选出主节点,整个系统就相当于宕机了,尽管此时还有节点4和节点5是正常的。
  • 综合分析,无论采取什么样的方案,状态决策都不可能做到任何场景下都没有问题,但完全不做高可用方案又会产生更大的问题,如何选取适合系统的高可用方案,也是一个复杂的分析、判断和选择的过程。

4.3. 可扩展性

可扩展性是指,系统为了应对将来需求变化而提供的一种扩展能力,当有新的需求出现时,系统不需要或者仅需要少量修改就可以支持,无须整个系统重构或者重建。面向对象思想的提出,就是为了解决可扩展性带来的问题;后来的设计模式,更是将可扩展性做到了极致。得益于设计模式的巨大影响力,几乎所有的技术人员对于可扩展性都特别重视。

4.3.1. 预测变化

4.3.1.1. 两年法则

只预测2年内的可能变化,不要试图预测5年甚至10年后的变化。

4.3.2. 应对变化

4.3.2.1. 方案一:提炼出“变化层”和“稳定层”

将不变的部分封装在一个独立的“稳定层”,将“变化”封装在一个“变化层”(也叫“适配层”)。这种方案的核心思想是通过变化层来隔离变化

1764811516891.png

无论是变化层依赖稳定层,还是稳定层依赖变化层都是可以的

1764811525545.png

无论采取哪种形式,通过剥离变化层和稳定层的方式应对变化,都会带来两个主要的复杂性相关的问题。

  • 变化层和稳定层如何拆分
  • 变化层和稳定层之间的接口如何设计

4.3.2.2. 方案二:提炼出“抽象层”和“实现层”

提炼出一个“抽象层”和一个“实现层”,通过实现层来封装变化

典型的实践就是设计模式和规则引擎,“装饰者”模式

规则引擎和设计模式类似,都是通过灵活的设计来达到可扩展的目的,但“灵活的设计”本身就是一件复杂的事情

4.3.2.3. 1写2抄3重构原则(事不过三,三则重构)

不要一开始就考虑复杂的可扩展性应对方法,而是等到第三次遇到类似的实现的时候再来重构,重构的时候采取隔离或者封装的方案

举个最简单的例子,假设你们的创新业务要对接第三方钱包,按照这个原则,就可以这样做:

  • 1写:最开始你们选择了微信钱包对接,此时不需要考虑太多可扩展性,直接快速对照微信支付的API对接即可,因为业务是否能做起来还不确定。
  • 2抄:后来你们发现业务发展不错,决定要接入支付宝,此时还是可以不考虑可扩展,直接把原来微信支付接入的代码拷贝过来,然后对照支付宝的API,快速修改上线。
  • 3重构:因为业务发展不错,为了方便更多用户,你们决定接入银联云闪付,此时就需要考虑重构,参考设计模式的模板方法和策略模式将支付对接的功能进行封装。

4.4. 低成本、安全、规模

4.4.1. 低成本(是架构设计的附加约束)

当我们的架构方案只涉及几台或者十几台服务器时,一般情况下成本并不是我们重点关注的目标,但如果架构方案涉及几百上千甚至上万台服务器,成本就会变成一个非常重要的架构设计考虑点。

当我们设计“高性能”“高可用”的架构时,通用的手段都是增加更多服务器来满足“高性能”和“高可用”的要求;而低成本正好与此相反,我们需要减少服务器的数量才能达成低成本的目标。

低成本给架构设计带来的主要复杂度体现在,往往只有“创新”才能达到低成本目标

类似的新技术例子很多,我来举几个。

  • NoSQL(Memcache、Redis等)的出现是为了解决关系型数据库无法应对高并发访问带来的访问压力。
  • 全文搜索引擎(Sphinx、Elasticsearch、Solr)的出现是为了解决关系型数据库like搜索的低效的问题。
  • Hadoop的出现是为了解决传统文件系统无法应对海量数据存储和计算的问题。
  • Facebook为了解决PHP的低效问题,刚开始的解决方案是HipHop PHP,可以将PHP语言翻译为C++语言执行,后来改为HHVM,将PHP翻译为字节码然后由虚拟机执行,和Java的JVM类似。
  • 新浪微博将传统的Redis/MC + MySQL方式,扩展为Redis/MC + SSD Cache + MySQL方式,SSD Cache作为L2缓存使用,既解决了MC/Redis成本过高,容量小的问题,也解决了穿透DB带来的数据库访问压力(来源:http://www.infoq.com/cn/articles/weibo-platform-archieture )。
  • Linkedin为了处理每天5千亿的事件,开发了高效的Kafka消息系统。
  • 其他类似将Ruby on Rails改为Java、Lua + redis改为Go语言实现的例子还有很多。

4.4.2. 安全

从技术的角度来讲,安全可以分为两类:一类是功能上的安全,一类是架构上的安全。

安全本身是一个庞大而又复杂的技术领域,并且一旦出问题,对业务和企业形象影响非常大。例如:

  • 2016年雅虎爆出史上最大规模信息泄露事件,逾5亿用户资料在2014年被窃取。

  • 2016年10月美国遭史上最大规模DDoS攻击,东海岸网站集体瘫痪。

  • 2013年10月,为全国4500多家酒店提供网络服务的浙江慧达驿站网络有限公司,因安全漏洞问题,致2千万条入住酒店的客户信息泄露,由此导致很多敲诈、家庭破裂的后续事件。

  • 功能安全

    • XSS攻击、CSRF攻击、SQL注入、Windows漏洞、密码破解等,本质上是因为系统实现有漏洞,黑客有了可乘之机。这种行为就像小偷一样,黑客和小偷的手法都是利用系统或家中不完善的地方潜入,并进行破坏或者盗取。因此形象地说,功能安全其实就是“防小偷”
    • 从实现的角度来看,功能安全更多地是和具体的编码相关,与架构关系不大
    • 架只能预防常见的安全漏洞和风险(常见的XSS攻击、CSRF攻击、SQL注入等),无法预知新的安全问题,而且框架本身很多时候也存在漏洞
    • 功能安全是一个逐步完善的过程,而且往往都是在问题出现后才能有针对性的提出解决方案,我们永远无法预测系统下一个漏洞在哪里,也不敢说自己的系统肯定没有任何问题
    • 功能安全其实也是一个“攻”与“防”的矛盾,只能在这种攻防大战中逐步完善,不可能在系统架构设计的时候一劳永逸地解决。
  • 架构安全

    • 如果说功能安全是“防小偷”,那么架构安全就是“防强盗”
    • 理论上来说系统部署在互联网上时,全球任何地方都可以发起攻击
    • 传统的架构安全主要依靠防火墙,防火墙最基本的功能就是隔离网络,通过将网络划分成不同的区域,制定出不同区域之间的访问控制策略来控制不同信任程度区域间传送的数据流

1764811547400.png

  • 防火墙的功能虽然强大,但性能一般,成本高,在传统的银行和企业应用领域应用较多,在互联网领域,防火墙的应用场景并不多,互联网的业务具有海量用户访问和高并发的特点,防火墙的性能不足以支撑,防火墙设备在没有发生攻击的时候又没有什么作用
  • 一般也不会堆防火墙来防DDoS攻击,因为DDoS攻击最大的影响是大量消耗机房的出口总带宽,不管防火墙处理能力有多强,当出口带宽被耗尽时,整个业务在用户看来就是不可用的,因为用户的正常请求已经无法到达系统了。防火墙能够保证内部系统不受冲击,但用户也是进不来的。对于用户来说,业务都已经受到影响了,至于是因为用户自己进不去,还是因为系统出故障,用户其实根本不会关心。
  • 基于上述原因,互联网系统的架构安全目前并没有太好的设计手段来实现,更多地是依靠运营商或者云服务商强大的带宽和流量清洗的能力,较少自己来设计和实现。

4.4.3. 规模

规模带来复杂度的主要原因就是“量变引起质变”,当数量超过一定的阈值后,复杂度会发生质的变化。常见的规模带来的复杂度有:

  • 功能越来越多,导致系统复杂度指数级上升
    • 系统的复杂度=功能数量+功能之间的连接数量(假设系统间的功能都是两两相关的)
    • 3个功能的系统复杂度= 3 + 3 = 6
    • 8个功能的系统复杂度= 8 + 28 = 36
    • 具备8个功能的系统的复杂度不是比具备3个功能的系统的复杂度多5,而是多了30,基本是指数级增长的

1764811561828.png

1764811573046.png

  • 数据越来越多,系统复杂度发生质变

    • 最典型的例子莫过于使用关系数据库存储数据,我以MySQL为例,MySQL单表的数据因不同的业务和应用场景会有不同的最优值,但不管怎样都肯定是有一定的限度的,一般推荐在5000万行左右。
    • 如果因为业务的发展,单表数据达到了10亿行,就会产生很多问题,例如:
  • 添加索引会很慢,可能需要几个小时,这几个小时内数据库表是无法插入数据的,相当于业务停机了。

  • 修改表结构和添加索引存在类似的问题,耗时可能会很长。

  • 即使有索引,索引的性能也可能会很低,因为数据量太大。

  • 数据库备份耗时很长。

  • 单表拆分为多表

  • 拆表的规则是什么

    • by id
    • by time
  • 拆完表后查询如何处理

    • 多表查询如何保证性能

5. 架构设计三原则

架构设计领域并没有一套通用的规范来指导架构师进行架构设计,更多是依赖架构师的经验和直觉,因此架构设计有时候也会被看作一项比较神秘的工作。

5.1. 合适原则

合适原则宣言:“合适优于业界领先”。

5.2. 简单原则

简单原则宣言:“简单优于复杂”。

5.3. 演化原则

演化原则宣言:“演化优于一步到位”。

6. 架构设计流程

6.1. 识别复杂度

架构的复杂度主要来源于“高性能”“高可用”“可扩展”等几个方面,实际上大部分场景下,复杂度只是其中的某一个,少数情况下包含其中两个,如果真的出现同时需要解决三个或者三个以上的复杂度,要么说明这个系统之前设计的有问题,要么可能就是架构师的判断出现了失误,即使真的认为要同时满足这三方面的要求,也必须要进行优先级排序。

接手了一个每个复杂度都存在问题的系统,不要幻想一次架构重构解决所有问题,问题要一个个来解决

将主要的复杂度问题列出来,然后根据业务、技术、团队等综合情况进行排序,优先解决当前面临的最主要的复杂度问题

  • 如何识别复杂度

    • 排查法
  • 否需要高性能

    • 设计的目标应该以峰值来计算。峰值一般取平均值的3倍
    • 设计目标设定为峰值的4倍(一般不要设定在10倍以上)
  • 需要高可用性

  • 否需要高可扩展性

  • 其他方面的复杂度

    • 安全性
    • 成本

6.2. 设计备选方案

架构师的工作并不神秘,成熟的架构师需要对已经存在的技术非常熟悉,对已经经过验证的架构模式烂熟于心,然后根据自己对业务的理解,挑选合适的架构模式进行组合,再对组合后的方案进行修改和调整。

经过时间考验,已经被各种场景验证过的成熟技术很多。例如,高可用的主备方案、集群方案,高性能的负载均衡、多路复用,可扩展的分层、插件化等技术,绝大部分时候我们有了明确的目标后,按图索骥就能够找到可选的解决方案。

只有当这种方式完全无法满足需求的时候,才会考虑进行方案的创新,而事实上方案的创新绝大部分情况下也都是基于已有的成熟技术。

  • NoSQL:Key-Value的存储和数据库的索引其实是类似的,Memcache只是把数据库的索引独立出来做成了一个缓存系统。
  • Hadoop大文件存储方案,基础其实是集群方案+ 数据复制方案。
  • Docker虚拟化,基础是LXC(Linux Containers)。
  • LevelDB的文件存储结构是Skip List。

在《技术的本质》一书中,对技术的组合有清晰的阐述:

新技术都是在现有技术的基础上发展起来的,现有技术又来源于先前的技术。将技术进行功能性分组,可以大大简化设计过程,这是技术“模块化”的首要原因。技术的“组合”和“递归”特征,将彻底改变我们对技术本质的认识。

可选的模式有很多,组合的方案更多,往往一个问题的解决方案有很多个,挑选合适自己业务、团队、技术能力的方案才是好方案

备选方案:

  • 备选方案的数量以3 ~ 5个为最佳

  • 备选方案的差异要比较明显

    • 备选阶段关注的是技术选型,而不是技术细节
  • 备选方案的技术不要只局限于已经熟悉的技术

6.3. 评估和选择备选方案

每个方案都是可行的,如果方案不可行就根本不应该作为备选方案。

没有哪个方案是完美的

评价标准主观性比较强

如何选择:360度环评

  • 列出我们需要关注的质量属性点
  • 分别从这些质量属性的维度去评估每个方案
  • 综合挑选适合当时情况的最优方案

常见的方案质量属性点

  • 性能
  • 可用性
  • 硬件成本
  • 项目投入
  • 复杂度
  • 安全性
  • 可扩展性

需要遵循架构设计原则1“合适原则”和原则2“简单原则”,避免贪大求全,基本上某个质量属性能够满足一定时期内业务发展就可以了。

需要遵循架构设计原则3“演化原则”,避免过度设计、一步到位的想法

基于评估结果整理出360度环评表,一目了然地看到各个方案的优劣点,没有哪个方案是完美的

每个属性点有权重和优先级,需要考虑到

正确的做法是按优先级选择,即架构师综合当前的业务发展情况、团队人员规模和技能、业务发展预测等因素,将质量属性按照优先级排序,首先挑选满足第一优先级的,如果方案都满足,那就再看第二优先级……以此类推。

6.4. 详细方案设计

将方案涉及的关键技术细节给确定下来

  • 假如我们确定使用Elasticsearch来做全文搜索,那么就需要确定Elasticsearch的索引是按照业务划分,还是一个大索引就可以了;副本数量是2个、3个还是4个,集群节点数量是3个还是6个等。
  • 假如我们确定使用MySQL分库分表,那么就需要确定哪些表要分库分表,按照什么维度来分库分表,分库分表后联合查询怎么处理等。
  • 假如我们确定引入Nginx来做负载均衡,那么Nginx的主备怎么做,Nginx的负载均衡策略用哪个(权重分配?轮询?ip_hash?)等。

详细设计方案阶段可能遇到的一种极端情况就是在详细设计阶段发现备选方案不可行,一般情况下主要的原因是备选方案设计时遗漏了某个关键技术点或者关键的质量属性。

  • 架构师不但要进行备选方案设计和选型,还需要对备选方案的关键细节有较深入的理解
  • 通过分步骤、分阶段、分系统等方式,尽量降低方案复杂度

7. 高性能数据库集群

大部分情况下,我们做架构设计主要都是基于已有的成熟模式,结合业务和团队的具体情况,进行一定的优化或者调整;即使少部分情况我们需要进行较大的创新,前提也是需要对已有的各种架构模式和技术非常熟悉。

高性能数据库集群的第一种方式是“读写分离”,其本质是将访问压力分散到集群中的多个节点,但是没有分散存储压力;第二种方式是“分库分表”,既可以分散访问压力,又可以分散存储压力。

7.1. 读写分离

读写分离的基本原理是将数据库读写操作分散到不同的节点上

1764811587622.png

读写分离的基本实现是

  • 数据库服务器搭建主从集群,一主一从、一主多从都可以。
  • 数据库主机负责读写操作,从机只负责读操作。
  • 数据库主机通过复制将数据同步到从机,每台数据库服务器都存储了所有的业务数据。
  • 业务服务器将写操作发给数据库主机,将读操作发给数据库从机。

需要注意的是,这里用的是“主从集群”,而不是“主备集群”。“从机”的“从”可以理解为“仆从”,仆从是要帮主人干活的,“从机”是需要提供读数据的功能的;而“备机”一般被认为仅仅提供备份功能,不提供访问功能。所以使用“主从”还是“主备”,是要看场景的,这两个词并不是完全等同的。

设计复杂度:主从复制延迟分配机制

  • 主从复制延迟

主从复制延迟会带来一个问题:如果业务服务器将数据写入到数据库主服务器后立刻(1秒内)进行读取,此时读操作访问的是从机,主机还没有将数据复制过来,到从机读取数据是读不到最新数据的,业务上就可能出现问题。例如,用户刚注册完后立刻登录,业务服务器会提示他“你还没有注册”,而用户明明刚才已经注册成功了。

解决主从复制延迟有几种常见的方法

  • 写操作后的读操作指定发给数据库主服务器

    • 这种方式和业务强绑定,对业务的侵入和影响较大
  • 读从机失败后再读一次主机

    • 这就是通常所说的“二次读取”,二次读取和业务无绑定,只需要对底层数据库访问的API进行封装即可,实现代价较小,不足之处在于如果有很多二次读取,将大大增加主机的读操作压力
  • 关键业务读写操作全部指向主机,非关键业务采用读写分离

    • 例如,对于一个用户管理系统来说,注册+登录的业务读写操作全部访问主机
  • 分配机制(将读写操作区分开来)

    • 程序代码封装
  • 在代码中抽象一个数据访问层(所以有的文章也称这种方式为“中间层封装”),实现读写操作分离和数据库服务器连接的管理。例如,基于Hibernate进行简单封装,就可以实现读写分离,基本架构是

1764811600045.png

程序代码封装的方式具备几个特点:

  • 实现简单,而且可以根据业务做较多定制化的功能。
  • 每个编程语言都需要自己实现一次,无法通用,如果一个业务包含多个编程语言写的多个子系统,则重复开发的工作量比较大。
  • 故障情况下,如果主从发生切换,则可能需要所有系统都修改配置并重启。
  • 开源实现
  • 淘宝的TDDL(Taobao Distributed Data Layer,外号:头都大了),通用数据访问层,所有功能封装在jar包中提供给业务代码调用。其基本原理是一个基于集中式配置的 jdbc datasource实现,具有主备、读写分离、动态数据库配置等功能

1764811611791.png

  • 中间件封装

中间件封装指的是独立一套系统出来,实现读写操作分离和数据库服务器连接的管理。中间件对业务服务器提供SQL兼容的协议,业务服务器无须自己进行读写分离。对于业务服务器来说,访问中间件和访问数据库没有区别,事实上在业务服务器看来,中间件就是一个数据库服务器

1764811622719.png

数据库中间件的方式具备的特点是:

  • 能够支持多种编程语言,因为数据库中间件对业务服务器提供的是标准SQL接口。
  • 数据库中间件要支持完整的SQL语法和数据库服务器的协议(例如,MySQL客户端和服务器的连接协议),实现比较复杂,细节特别多,很容易出现bug,需要较长的时间才能稳定。
  • 数据库中间件自己不执行真正的读写操作,但所有的数据库操作请求都要经过中间件,中间件的性能要求也很高。
  • 数据库主从切换对业务服务器无感知,数据库中间件可以探测数据库服务器的主从状态。例如,向某个测试表写入一条数据,成功的就是主机,失败的就是从机。

由于数据库中间件的复杂度要比程序代码封装高出一个数量级,一般情况下建议采用程序语言封装的方式,或者使用成熟的开源数据库中间件,目前的开源数据库中间件方案中,MySQL官方先是提供了MySQL Proxy,但MySQL Proxy一直没有正式GA,现在MySQL官方推荐MySQL Router,MySQL Router的主要功能有读写分离、故障自动切换、负载均衡、连接池等

1764811634705.png

奇虎360公司也开源了自己的数据库中间件Atlas,Atlas是基于MySQL Proxy实现的

1764811647411.png

Atlas是一个位于应用程序与MySQL之间中间件。在后端DB看来,Atlas相当于连接它的客户端,在前端应用看来,Atlas相当于一个DB。Atlas作为服务端与应用程序通信,它实现了MySQL的客户端和服务端协议,同时作为客户端与MySQL通信。它对应用程序屏蔽了DB的细节,同时为了降低MySQL负担,它还维护了连接池。

7.2. 分库分表

  • 数据量太大,读写的性能会下降,即使有索引,索引也会变得很大,性能同样会下降。
  • 数据文件会变得很大,数据库备份和恢复需要耗费很长时间。
  • 数据文件越大,极端情况下丢失数据的风险越高(例如,机房火灾导致数据库主备机都发生故障)。

7.2.1. 业务分库

**业务分库指的是按照业务模块将数据分散到不同的数据库服务器。**例如,一个简单的电商网站,包括用户、商品、订单三个业务模块,我们可以将用户数据、商品数据、订单数据分开放到三台不同的数据库服务器上,而不是将所有数据都放在一台数据库服务器上。

1764811659241.png

业务分库带来的问题

  • join操作问题

    • 代码处理
  • 事务问题

    • 虽然数据库厂商提供了一些分布式事务的解决方案(例如,MySQL的XA),但性能实在太低,与高性能存储的目标是相违背的。
  • 成本问题

基于上述原因,对于小公司初创业务,并不建议一开始就这样拆分,主要有几个原因:

  • 初创业务存在很大的不确定性,业务不一定能发展起来,业务开始的时候并没有真正的存储和访问压力,业务分库并不能为业务带来价值。
  • 业务分库后,表之间的join查询、数据库事务无法简单实现了。
  • 业务分库后,因为不同的数据要读写不同的数据库,代码中需要增加根据数据类型映射到不同数据库的逻辑,增加了工作量。而业务初创期间最重要的是快速实现、快速验证,业务分库会拖慢业务节奏。

7.2.2. 分表

几亿用户数据,如果全部存放在一台数据库服务器的一张表中,肯定是无法满足性能要求的,此时就需要对单表数据进行拆分

单表数据拆分有两种方式:垂直分表水平分表。示意图如下

1764811671703.png

为了形象地理解垂直拆分和水平拆分的区别,可以想象你手里拿着一把刀,面对一个蛋糕切一刀:

  • 从上往下切就是垂直切分,因为刀的运行轨迹与蛋糕是垂直的,这样可以把蛋糕切成高度相等(面积可以相等也可以不相等)的两部分,对应到表的切分就是表记录数相同但包含不同的列。例如,示意图中的垂直切分,会把表切分为两个表,一个表包含ID、name、age、sex列,另外一个表包含ID、nickname、description列。
  • 从左往右切就是水平切分,因为刀的运行轨迹与蛋糕是平行的,这样可以把蛋糕切成面积相等(高度可以相等也可以不相等)的两部分,对应到表的切分就是表的列相同但包含不同的行数据。例如,示意图中的水平切分,会把表分为两个表,两个表都包含ID、name、age、sex、nickname、description列,但是一个表包含的是ID从1到999999的行数据,另一个表包含的是ID从1000000到9999999的行数据。

实际架构设计过程中并不局限切分的次数,可以切两次,也可以切很多次

单表进行切分后,是否要将切分后的多个表分散在不同的数据库服务器中,可以根据实际的切分效果来确定,并不强制要求单表切分为多表后一定要分散到不同数据库中。原因在于单表切分为多表后,新的表即使在同一个数据库服务器中,也可能带来可观的性能提升,如果性能能够满足业务要求,是可以不拆分到多台数据库服务器的,毕竟我们在上面业务分库的内容看到业务分库也会引入很多复杂性的问题;如果单表拆分为多表后,单台服务器依然无法满足性能要求,那就不得不再次进行业务分库的设计了。

分表能够有效地分散存储压力和带来性能提升,但和分库一样,也会引入各种复杂性

  • 垂直分表

    • 垂直分表适合将表中某些不常用且占了大量空间的列拆分出去
    • 垂直分表引入的复杂性主要体现在表操作的数量要增加
  • 水平分表

    • 水平分表适合表行数特别大的表,有的公司要求单表行数超过5000万就必须进行分表,这个数字可以作为参考,但并不是绝对标准,关键还是要看表的访问性能。对于一些比较复杂的表,可能超过1000万就要分表了;而对于一些简单的表,即使存储数据超过1亿行,也可以不分表。

复杂性体现在:

  • 路由

    • 水平分表后,某条数据具体属于哪个切分后的子表,需要增加路由算法进行计算,这个算法会引入一定的复杂性
    • 常见的路由算法
  • 范围路由

    • 选取有序的数据列(例如,整形、时间戳等)作为路由的条件,不同分段分散到不同的数据库表中。以最常见的用户ID为例,路由算法可以按照1000000的范围大小进行分段,1 ~ 999999放到数据库1的表中,1000000 ~ 1999999放到数据库2的表中,以此类推。
    • 一般建议分段大小在100万至2000万之间
    • 优点是可以随着数据的增加平滑地扩充新的表,例如,现在的用户是100万,如果增加到1000万,只需要增加新的表就可以了,原有的数据不需要动
    • 一个比较隐含的缺点是分布不均匀,假如按照1000万来进行分表,有可能某个分段实际存储的数据量只有1000条,而另外一个分段实际存储的数据量有900万条。
  • Hash路由

    • 选取某个列(或者某几个列组合也可以)的值进行Hash运算,然后根据Hash结果分散到不同的数据库表中。同样以用户ID为例,假如我们一开始就规划了10个数据库表,路由算法可以简单地用user_id % 10的值来表示数据所属的数据库表编号,ID为985的用户放到编号为5的子表中,ID为10086的用户放到编号为6的字表中。
    • 复杂点主要体现在初始表数量的选取上,表数量太多维护比较麻烦,表数量太少又可能导致单表性能存在问题。而用了Hash路由后,增加子表数量是非常麻烦的,所有数据都要重分布。
    • Hash路由的优缺点和范围路由基本相反,Hash路由的优点是表分布比较均匀,缺点是扩充新的表很麻烦,所有数据都要重分布。
  • 配置路由

    • 配置路由就是路由表,用一张独立的表来记录路由信息。同样以用户ID为例,我们新增一张user_router表,这个表包含user_id和table_id两列,根据user_id就可以查询对应的table_id。
    • 配置路由设计简单,使用起来非常灵活,尤其是在扩充表的时候,只需要迁移指定的数据,然后修改路由表就可以了。
    • 配置路由的缺点就是必须多查询一次,会影响整体性能;而且路由表本身如果太大(例如,几亿条数据),性能同样可能成为瓶颈,如果我们再次将路由表分库分表,则又面临一个死循环式的路由算法选择问题。
  • join操作

    • 水平分表后,数据分散在多个表中,如果需要与其他表进行join查询,需要在业务代码或者数据库中间件中进行多次join查询,然后将结果合并。
  • count()操作

    • 获取记录总数用于分页或者展示,水平分表前用一个count()就能完成的操作,在分表后就没那么简单了。常见的处理方式有下面两种:
  • count()相加:对每个表进行count()操作,然后将结果相加,缺点是慢

  • 记录数表:新建一张表,假如表名为“记录数表”,包含table_name、row_count两个字段,每次插入或者删除子表数据成功后,都更新“记录数表”。缺点是对子表的操作要同步操作“记录数表”,容易导致数据不一致,无法放在同一事务中进行处理

  • 定时更新:“count()相加”和“记录数表”的结合

  • order by操作

  • 水平分表后,数据分散到多个子表中,排序操作无法在数据库中完成,只能由业务代码或者数据库中间件分别查询每个子表中的数据,然后汇总进行排序。

实现方法:

和数据库读写分离类似,分库分表具体的实现方式也是“程序代码封装”和“中间件封装”,但实现会更复杂。读写分离实现时只要识别SQL操作是读操作还是写操作,通过简单的判断SELECT、UPDATE、INSERT、DELETE几个关键字就可以做到,而分库分表的实现除了要判断操作类型外,还要判断SQL中具体需要操作的表、操作函数(例如count函数)、order by、group by操作等,然后再根据不同的操作进行不同的处理。例如order by操作,需要先从多个库查询到各个库的数据,然后再重新order by才能得到最终的结果。

0

评论区