架构设计相关
整理软件架构的概念、复杂度来源、设计原则与选型流程,并讨论数据库集群和 NoSQL 方案。
更新于
架构的定义
理解架构定义之前,需要先理解几个名词。
系统与子系统
系统泛指由一群有关联的个体组成,根据某种规则运作,能完成个别元件不能单独完成的工作的群体。它的意思是“总体”“整体”或“联盟”。 —— Wikipedia
子系统也是由一群有关联的个体所组成的系统,多半会是更大系统中的一部分。 —— Wikipedia
系统具备以下特点:
- 关联 : 系统是 由一群有关联的个体组成的 ,没有关联的个体堆在一起不能成为一个系统。例如,把一个发动机和一台 PC 放在一起不能称之为一个系统,把发动机、底盘、轮胎、车架组合起来才能成为一台汽车。
- 规则 : 系统内的个体需要按照指定的规则运作 ,而不是单个个体各自为政。规则规定了系统内个体分工和协作的方式。例如,汽车发动机负责产生动力,然后通过变速器和传动轴,将动力输出到车轮上,从而驱动汽车前进。
- 能力 : 系统能力与个体能力有本质的差别, 系统能力不是个体能力之和,而是产生了新的能力 。例如,汽车能够载重前进,而发动机、变速器、传动轴、车轮本身都不具备这样的能力。
在讨论一个系统的架构的时候,应该关注当前系统的顶层架构,而不包括子系统层级的架构。
模块与组件
软件模块(Module)是一套一致而互相有紧密关连的软件组织。它分别包含了程序和数据结构两部分。现代软件开发往往利用模块作为合成的单位。模块的接口表达了由该模块提供的功能和调用它时所需的元素。模块是可能分开被编写的单位。这使它们可再用和允许人员同时协作、编写及研究不同的模块。 —— Wikipedia
软件组件定义为自包含的、可编程的、可重用的、与语言无关的软件单元,软件组件可以很容易被用于组装应用程序中。 —— Wikipedia
模块和组件都是系统的组成部分,只是从不同的角度拆分系统而已:
- 从 业务逻辑 的角度来拆分系统后,得到的单元就是“模块”。
- 从 物理部署 的角度来拆分系统后,得到的单元就是“组件”。
两者的划分目的也不相同:
- 划分模块的主要目的是 职责分离 。
- 划分组件的主要目的是 单元复用 。
假设我们要做一个学生信息管理系统。
- 这个系统从逻辑的角度来拆分,可以分为 “登录注册模块” 、 “个人信息模块” 、 “个人成绩模块”
- 从物理的角度来拆分,可以拆分为
Nginx、 Web 服务器、MySQL。
框架与架构
软件框架(Software framework)通常指的是为了实现某个业界标准或完成特定基本任务的软件组件规范,也指为了实现某个软件组件规范时,提供规范所要求之基础功能的软件产品。 —— Wikipedia
- 框架是组件规范 : 例如,MVC 就是一种最常见的开发规范,类似的还有 MVP、MVVM、J2EE 等框架。
- 框架提供基础功能的产品 : 例如,Spring MVC 是 MVC 的开发框架,除了满足 MVC 的规范,Spring 提供了很多基础功能来帮助我们实现功能,包括注解(@Controller 等)、Spring Security、Spring JPA 等很多基础功能。
软件架构指软件系统的“基础结构”,创造这些基础结构的准则,以及对这些结构的描述。 —— Wikipedia
对比两者的定义可以发现: 框架关注的是 “ 规范 ” ,架构关注的是 “ 结构 ”
框架是一整套开发规范,架构是某一套开发规范下的具体落地方案,包括各个模块之间的 组合关系 以及它们协同起来完成功能的 运作规则 。
什么是架构
软件架构指软件系统的 顶层(Rank)结构 ,它定义了系统由哪些角色(Role) 组成,角色之间的 关系(Relation) 和 运作规则(Rule) 。
Rank
Rank 指软件架构是分层的,对应 “系统” 和 “子系统” 的分层关系。
通常情况下,我们只需要关注某一层的架构,最多展示相邻两层的架构,而不需要把每一层的架构全部糅杂在一起。无论是架构设计还是画架构图,都应该采取 “自顶向下,逐步细化” 的方式。
---
title: 微信架构示例
---
graph TD
subgraph Wechat[微信架构 L0]
User[用户]
Chat[聊天]
Circle[朋友圈]
Pay[支付]
end
subgraph FriendCircle[朋友圈架构 L1]
Moving[动态]
Comment[评论]
Message[消息]
Advertising[广告]
end
subgraph FriendComment[朋友圈评论子系统架构 L2]
Publish[发布]
Audit[审核]
Analysis[分析]
end
Circle --> FriendCircle
Comment --> FriendComment
Role
Role 指软件系统包含哪些角色,每个角色都会负责系统的一部分功能。
架构设计最重要的工作之一就是将系统拆分为多个角色。最常见的微服务拆分其实就是将整体复杂的 业务系统 按照业务领域的方式,拆分为多个微服务,每个微服务就是系统的一个角色。
Relation
Relation 指软件系统的角色之间的关系。
对应到架构图中其实就是连接线,角色之间的关系不能乱连,任何关系最后都需要代码来实现,包括连接方式( HTTP 、 TCP 、 UDP 和串口等)、数据协议( JSON 、 XML 和二进制等)以及具体的接口等。
Rule
Rule 指软件系统角色之间如何协作来完成系统功能。
系统能力不是个体能力之和,而是产生了新的能力。那么这个新能力具体如何完成的呢?具体哪些角色参与了这个新能力呢?
这就是 Rule 所要表达的内容。在架构设计的时候,核心的业务场景都需要设计 Rule 。
架构图示例
一般使用系统架构图描述 Rank 、 Role 、 Relation
使用系统序列图描述 Rule
简易支付系统架构图如下:
---
title: 电商系统架构示例图
---
graph TD
TradingCenter[交易中心]
LedgerCenter[账务中心]
AccountingCenter[会计中心]
LiquidationCenter[清算中心]
PaymentCenter[支付中心]
RiskControlCenter[风控中心]
MarketingCenter[营销中心]
VIPCenter[会员中心]
TradingCenter -->|交易记录| LedgerCenter
AccountingCenter -->|账务数据| LedgerCenter
LiquidationCenter -->|账务数据| LedgerCenter
TradingCenter -->|支付请求| PaymentCenter
PaymentCenter -->|优惠信息| MarketingCenter
PaymentCenter -->|风控咨询| RiskControlCenter
MarketingCenter -->|卡券| VIPCenter
RiskControlCenter -->|会员信息| VIPCenter
针对单独的 “扫码支付” 场景的系统序列图如下:
---
title: 扫码支付场景示例
---
sequenceDiagram
participant Client as 浏览器
participant TradingCenter as 交易中心
participant PaymentCenter as 支付中心
participant RiskControlCenter as 风控中心
Client ->> TradingCenter: 1: 请求支付
TradingCenter ->> TradingCenter: 1.1: 创建订单
TradingCenter ->> PaymentCenter: 1.2: 请求支付
PaymentCenter ->> RiskControlCenter: 1.2.1: 风控决策
RiskControlCenter -->> PaymentCenter: 1.2.2: 风控通过
PaymentCenter ->> PaymentCenter: 1.2.3: 银行扣款
PaymentCenter -->> TradingCenter: 1.2.4: 支付成功
TradingCenter -->> Client: 1.2.5: 支付结果
架构设计的目的
如果不做架构设计,系统也能跑起来,开发速度可能还更快,那么架构设计的目的是什么?
简单来说, 架构设计的主要目的是为了解决软件系统复杂度带来的问题
常见问题
Q: 从哪里开始设计?
A: 通过熟悉和理解需求,识别系统复杂性所在的地方,然后针对复杂点进行架构设计
Q: 架构设计时间不够怎么办?
A: 架构设计并不是要面面俱到,不需要每个架构都具备高性能、高可用、高扩展等特点,而是要识别出复杂点然后有针对性地解决问题。
Q: 多个方案可以参考,但是差别很大,选哪个?
A: 解每个架构方案背后所需要解决的复杂点,然后才能对比自己的业务复杂点,参考复杂点相似的方案。
架构设计实践
分析一个简单的案例,一起来看看如何将 “架构设计的真正目的是为了解决软件系统复杂度带来的问题” 这个指导思想应用到实践中。
假设我们需要设计一个大学的学生管理系统,其基本功能包括登录、注册、成绩管理、课程管理等。
当我们对这样一个系统进行架构设计的时候,首先应识别其复杂度到底体现在哪里。
- 性能: 一个学校的学生大约 1 ~ 2 万人,学生管理系统的访问频率并不高,平均每天单个学生的访问次数平均不到 1 次,因此性能这部分并不复杂,存储用 MySQL 完全能够胜任,缓存都可以不用,Web 服务器用 Nginx 绰绰有余。
- 可扩展性: 学生管理系统的功能比较稳定,可扩展的空间并不大,因此可扩展性也不复杂。
- 高可用: 学生管理系统即使宕机 2 小时,对学生管理工作影响并不大,因此可以不做负载均衡,更不用考虑异地多活这类复杂的方案了。但是,如果学生的数据全部丢失,修复是非常麻烦的,只能靠人工逐条修复,这个很难接受,因此需要考虑存储高可靠,这里就有点复杂了。我们需要考虑多种异常情况: 机器故障、机房故障,针对机器故障,我们需要设计 MySQL 同机房主备方案;针对机房故障,我们需要设计 MySQL 跨机房同步方案。
- 安全性: 学生管理系统存储的信息有一定的隐私性,例如学生的家庭情况,但并不是和金融相关的,也不包含强隐私的信息,因此安全性方面只要做 3 个事情就基本满足要求了: Nginx 提供 ACL 控制、用户账号密码管理、数据库访问权限控制。
- 成本: 由于系统很简单,基本上几台服务器就能够搞定,对于一所大学来说完全不是问题,可以无需太多关注。
---
title: 学校场景整体架设图
---
graph TD
subgraph LocalDataCenter[学校主机房]
Nginx[Nginx服务器]
WebServer[Web服务器]
PrimaryDB[主数据库]
SecondaryDB[备数据库]
Nginx --> WebServer
WebServer --> PrimaryDB
PrimaryDB --> SecondaryDB
end
subgraph RemoteDataCenter[学校备份机房]
RemoteDB[备份数据库]
PrimaryDB --> RemoteDB
end
复杂度来源
复杂度来源可以分为:
- 业务复杂度: 领域驱动设计
- 技术复杂度: 高性能、高并发、高可用
高性能
高性能复杂度主要体现在两方面:
- 单台计算机内部为了高性能带来的复杂度
- 多台计算机集群为了高性能带来的复杂度
单机复杂度
计算机内部最关键的地方就是操作系统,操作系统关乎能否充分发挥硬件性能。
操作系统中,和性能最相关的是 进程 、 线程 。
进程
进程发明的初衷是为了对应任务,每个任务都有自己独立的内存空间,进程间互不相关,由操作系统来进行调度。
为了达到多进程并行运行的目的,采用了分时的方式,将 CPU 时间分为很多片段,每个片段只能执行某个进程中的指令,感觉起来就是多进程在并行处理。
多进程虽然要求每个任务都有独立的内存空间,进程间互不相关,但从用户的角度来看,两个任务之间能够在运行过程中就进行通信,会让任务设计变得更加灵活高效。
否则如果两个任务运行过程中不能通信,只能是 A 任务将结果写到存储, B 任务再从存储读取进行处理,不仅效率低,而且任务设计更加复杂。
为了解决这个问题,进程间通信的各种方式被设计出来了,包括管道、消息队列、信号量、共享存储等。
线程
多进程让多任务能并行处理,但本身还有缺点,单个进程内部只能串行处理,而实际上很多内部任务并不严格要求按照时间顺序来执行,也需要并行处理。
为了解决这个问题,又发明了线程,作为进程内部的子任务,这些子任务会共享同一份进程数据。
为了保证数据的正确性,又发明了互斥锁等机制。
有了多线程后,操作系统调度的最小单位就是线程了,而进程是操作系统分配资源的最小单位
并行任务
多进程多线程虽然让多任务并行处理的性能大大提升,但本质上还是分时系统,并不能做到时间上真正的并行。
解决这个问题的方式显而易见,就是让多个 CPU 能够同时执行计算任务,从而实现真正意义上的多任务并行。
目前这样的解决方案有 3 种:
SMP: ( Symmetric Multi-Processor, 对称多处理器结构)NUMA: ( Non-Uniform Memory Access, 非一致存储访问结构)MPP: ( Massive Parallel Processing, 海量并行处理结构)
其中 SMP 是我们最常见的,目前流行的多核处理器就是 SMP 方案。
集群复杂度
由于业务发展速度太快,比硬件性能迭代升级的速度还要快。
单机的性能难以支撑增长后的业务量,必须通过集群的方式来实现高性能。
通过大量机器来提升性能,并不仅仅是增加机器这么简单,让多台机器配合起来达到高性能的目的,是一个复杂的任务。
实际上“任务”涵盖的范围很广, 可以指完整的业务处理,也可以单指某个具体的任务 。
例如, “存储” “运算” “缓存” 等都可以作为一项任务,因此存储系统、运算系统、缓存系统都可以按照任务分配的方式来搭建架构。
此外, “任务分配器” 也并不一定只能是物理上存在的机器或者一个独立运行的程序,也可以是嵌入在其他程序中的算法,例如 Memcache 的集群架构。
常见的几种方式有: 任务分配 、 任务分解
任务分配
任务分配的意思是指每台机器都可以处理完整的业务任务,不同的任务分配到不同的机器上执行。
---
title: 单机架构升级集群架构
---
graph LR
subgraph Single[单机架构]
SingleUser[用户] --> BusinessServer[业务服务器]
end
subgraph Cluster[集群架构]
ClusterUser[用户] --> Task[任务分配器]
Task[任务分配器] --> BusinessServer1[业务服务器1]
Task[任务分配器] --> BusinessServer2[业务服务器2]
end
Single --> Cluster
复杂点分析:
- 增加了一个任务分配器。
这个分配器可能是硬件网络设备(例如,F5、交换机等),可能是软件网络设备(例如,LVS),也可能是负载均衡软件(例如,Nginx、HAProxy),还可能是自己开发的系统。
选择合适的任务分配器也是一件复杂的事情,需要综合考虑性能、成本、可维护性、可用性等各方面的因素。
任务分配器和真正的业务服务器之间有连接和交互(即图中任务分配器到业务服务器的连接线),需要选择合适的连接方式,并且对连接进行管理。例如,连接建立、连接检测、连接中断后如何处理等。
任务分配器需要增加分配算法。例如,是采用轮询算法,还是按权重分配,又或者按照负载进行分配。如果按照服务器的负载进行分配,则业务服务器还要能够上报自己的状态给任务分配器。
当业务继续增加,任务处理不再是瓶颈,反而业务分配器会成为性能瓶颈。
为了解决性能问题,需要把任务分配器也做成多台设备:
---
title: 单任务分配升级多任务分配
---
graph LR
subgraph Single[单任务分配器]
SingleUser[用户] --> Task[任务分配器]
Task[任务分配器] --> BusinessServer1[业务服务器1]
Task[任务分配器] --> BusinessServer2[业务服务器2]
end
subgraph Cluster[多任务分配器]
ClusterUser[用户]
subgraph TaskManager[任务分配器]
direction LR
TaskManager1[任务分配器1]
TaskManager2[任务分配器2]
end
subgraph ClusterBusinessServer[业务服务器]
direction LR
ClusterBusinessServer1[业务服务器1]
ClusterBusinessServer2[业务服务器2]
end
ClusterUser -.用户分配.-> TaskManager
TaskManager --> ClusterBusinessServer
end
Single --> Cluster
多任务分配器复杂点分析:
- 任务分配器从 1 台变成了多台。
这个变化带来的复杂度就是需要将不同的用户分配到不同的任务分配器上(即图中的虚线“用户分配”部分),常见的方法包括 DNS 轮询、智能 DNS、CDN(Content Delivery Network,内容分发网络)、GSLB 设备(Global Server Load Balance,全局负载均衡)等。
- 任务分配器和业务服务器的连接从简单的 “ 1 对多” 变成了 “多对多” 的网状结构。
- 机器数量从 3 台扩展到更多台,状态管理、故障处理复杂度也大大增加。
任务分解
通过任务分配的方式,我们能够突破单台机器处理性能的瓶颈,通过增加更多的机器来满足业务的性能需求,但如果业务本身也越来越复杂,单纯只通过任务分配的方式来扩展性能,收益会越来越低。
造成这种现象的主要原因是业务越来越复杂,单台机器处理的性能会越来越低。为了能够继续提升性能,我们需要采取第二种方式: 任务分解 。
---
title: 任务分解架构示例
---
graph TB
InsertServer[插入服务器]
subgraph Subserver[任务子服务]
direction LR
Login[注册登录]
Message[消息]
LBS
Shaking[摇一摇]
Random[漂流瓶]
Other[其他业务]
end
InsertServer <--> Subserver
后台架构从逻辑上将各个子业务进行了拆分,包括:接入、注册登录、消息、LBS、摇一摇、漂流瓶、其他业务(聊天、视频、朋友圈等)。
通过这种任务分解的方式,能够把原来大一统但复杂的业务系统,拆分成小而简单但需要多个系统配合的业务系统。
从业务的角度来看,任务分解既不会减少功能,也不会减少代码量(事实上代码量可能还会增加,因为从代码内部调用改为通过服务器之间的接口调用)。
任务分解方案提升性能的原因如下:
- 简单的系统更加容易做到高性能
系统的功能越简单,影响性能的点就越少,就更加容易进行有针对性的优化。
系统很复杂的情况下,首先是比较难以找到关键性能点,因为需要考虑和验证的点太多;其次是即使花费很大力气找到了,修改起来也不容易,因为可能将 A 关键性能点提升了,但却无意中将 B 点的性能降低了,整个系统的性能不但没有提升,还有可能会下降。
- 可以针对单个任务进行扩展
当各个逻辑任务分解到独立的子系统后,整个系统的性能瓶颈更加容易发现,而且发现后只需要针对有瓶颈的子系统进行性能优化或者提升,不需要改动整个系统,风险会小很多。
以上述后台架构为例,如果用户数增长太快,注册登录子系统性能出现瓶颈的时候,只需要优化登录注册子系统的性能(可以是代码优化,也可以简单粗暴地加机器),消息逻辑、 LBS 逻辑等其他子系统完全不需要改动。
任务拆分可以提高性能,但是无限制的拆分并不能提升性能,反而会下降。
原因在于:如果系统拆分得太细,为了完成某个业务,系统间的调用次数会呈指数级别上升,而系统间的调用通道目前都是通过网络传输的方式,性能远比系统内的函数调用要低得多。
最终决定业务处理性能的还是业务逻辑本身,业务逻辑本身没有发生大的变化下,理论上的性能是有一个上限的,系统拆分能够让性能逼近这个极限,但无法突破这个极限。
因此,任务分解带来的性能收益是有一个度的,并不是任务分解越细越好,而对于架构设计来说,如何把握这个粒度就非常关键了。
高可用
系统无中断地执行其功能的能力,代表系统的可用性程度,是进行系统设计时的准则之一。 —— Wikipedia
根据定义可以发现,最关键的点就是 “ 无中断 ” ,这也是难点所在。
硬件会逐渐老化,软件随着迭代也会越来越复杂和庞大。
除了硬件和软件本质上无法做到“无中断”,外部环境导致的不可用更加不可避免、不受控制。例如,断电、水灾、地震,这些事故或者灾难也会导致系统不可用,而且影响程度更加严重,更加难以预测和规避。
所以,系统的高可用方案五花八门,但万变不离其宗,本质上都是通过 “冗余” 来实现高可用。
高可用的 “冗余” 解决方案,单纯从形式上来看,和之前讲的高性能是一样的,都是通过增加更多机器来达到目的,但其实本质上是有根本区别的:
- 高性能增加机器目的在于 “扩展” 处理性能 。
- 高可用增加机器目的在于 “冗余” 处理单元 。
计算高可用
计算即为业务的逻辑处理工作。
计算的特点是:无论在哪个环境/机器运行, 同样的输入与同样的程序,一定对应同样的输出 。
所以计算高可用的含义就是: 当某个计算服务器宕机后,有其他的计算服务器可以接手任务继续计算,也就是后备计算服务。
简单的多机集群架构如下所示:
---
title: 多机集群架示例
---
graph LR
subgraph Single[单机架构]
SingleUser[用户] --> RunServer[计算服务器]
end
subgraph Cluster[单机架构]
ClusterUser[用户] --> TaskManager[任务分配器]
TaskManager --> RunServer1[计算服务器1]
TaskManager --> RunServer2[计算服务器2]
end
Single --> Cluster
计算高可用的分配算法比 任务分配 更加复杂。
任务分配所有的服务器是平等的,只需要按照一定规则分配任务到对应机器上即可。
计算高可用会存在不同的角色,最终只会给调用者提供一个响应,由谁处理、由谁决策、决策者故障后如何升级为决策者等等,都是复杂度所在。
存储高可用
对于需要存储数据的系统来说,整个系统的高可用设计关键点和难点就在于 “存储高可用” 。
数据同步延迟
存储与计算相比,有一个本质上的区别: 将数据从一台机器搬到到另一台机器,需要经过线路进行传输 。
线路传输的速度是毫秒级别,同一机房内部能够做到几毫秒;分布在不同地方的机房,传输耗时需要几十甚至上百毫秒。
虽然毫秒对于人来说几乎没有什么感觉,但是对于高可用系统来说,就是本质上的不同,这意味着整个系统在某个时间点上,数据肯定是不一致的。
按照 “ 数据 + 逻辑 = 业务 ” 这个公式来套的话,数据不一致,即使逻辑一致,最后的业务表现就不一样了。
传输线路中断风险
除了物理上的传输速度限制,传输线路本身也存在可用性问题,传输线路可能中断、可能拥塞、可能异常(错包、丢包),并且传输线路的故障时间一般都特别长,短的十几分钟,长的几个小时都是可能的。
在传输线路中断的情况下,就意味着存储无法进行同步,在这段时间内整个系统的数据是不一致的。
小结
综合分析,无论是正常情况下的传输延迟,还是异常情况下的传输中断,都会导致系统的数据在某个时间点或者时间段是不一致的,而数据的不一致又会导致业务问题。
但如果完全不做冗余,系统的整体高可用又无法保证。
所以存储高可用的 难点不在于如何备份数据,而在于如何减少或者规避数据不一致对业务造成的影响 。
分布式领域里面有一个著名的 CAP 定理,从理论上论证了存储高可用的复杂度。也就是说,存储高可用不可能同时满足 “ 一致性 、 可用性 、 分区容错性 ” ,最多满足其中两个,这就要求我们在做架构设计时结合业务进行取舍。
高可用状态决策
无论是计算高可用还是存储高可用,其基础都是 “ 状态决策 ” ,即系统需要能够判断当前的状态是正常还是异常,如果出现了异常就要采取行动来保证高可用。
如果状态决策本身都是有错误或者有偏差的,那么后续的任何行动和处理无论多么完美也都没有意义和价值。
但在具体实践的过程中,恰好存在一个本质的矛盾: 通过冗余来实现的高可用系统,状态决策本质上就不可能做到完全正确 。
常见的决策策略有: 独裁式 、 协商式 、 民主式 。
独裁式
独裁式决策指的是存在一个独立的决策主体,我们姑且称它为 “决策者” , 负责收集信息然后进行决策;所有冗余的个体,我们姑且称它为 “上报者” ,都将状态信息发送给决策者。
独裁式的决策方式不会出现决策混乱的问题,因为只有一个决策者,但问题也正是在于只有一个决策者。
当决策者本身故障时,整个系统就无法实现准确的状态决策。
如果决策者本身又做一套状态决策,那就陷入一个递归的死循环了。
---
title: 独裁式状态决策
---
graph BT
Master[决策者]
Report1[上报者]
Report2[上报者]
Report3[上报者]
Report1 --> Master
Report2 --> Master
Report3 --> Master
协商式
协商式决策指的是两个独立的个体通过交流信息,然后根据规则进行决策, 最常用的协商式决策就是主备决策 。
这个架构的基本协商规则可以设计成:
- 2 台服务器启动时都是备机
- 2 台服务器建立连接
- 2 台服务器交换状态信息
- 某 1 台服务器做出决策,成为主机;另一台服务器继续保持备机身份。
如果两者的信息交换出现问题(比如主备连接中断),此时状态决策就容易有异常场景:
- 如果备机在连接中断的情况下认为主机故障,那么备机需要升级为主机,但实际上此时主机并没有故障,那么系统就出现了两个主机,这与设计初衷(1 主 1 备)是不符合的。
- 如果备机在连接中断的情况下不认为主机故障,则此时如果主机真的发生故障,那么系统就没有主机了,这同样与设计初衷(1 主 1 备)是不符合的。
- 如果为了规避连接中断对状态决策带来的影响,可以增加更多的连接。例如,双连接、三连接。这样虽然能够降低连接中断对状态带来的影响(注意:只能降低,不能彻底解决),但同时又引入了这几条连接之间信息取舍的问题,即如果不同连接传递的信息不同,应该以哪个连接为准?实际上这也是一个无解的答案,无论以哪个连接为准,在特定场景下都可能存在问题。
综合分析,协商式状态决策在某些场景总是存在一些问题的。
民主式
民主式决策指的是多个独立的个体通过投票的方式来进行状态决策。例如, ZooKeeper 集群在选举 leader 时就是采用这种方式。
民主式决策和协商式决策比较类似,其基础都是独立的个体之间交换信息,每个个体做出自己的决策,然后按照 “ 多数取胜 ” 的规则来确定最终的状态。
不同点在于民主式决策比协商式决策要复杂得多, ZooKeeper 的选举算法 ZAB ,绝大部分人都看得云里雾里,更不用说用代码来实现这套算法了。
除了算法复杂,民主式决策还有一个固有的缺陷:脑裂。
这个词来源于医学,指人体左右大脑半球的连接被切断后,左右脑因为无法交换信息,导致各自做出决策,然后身体受到两个大脑分别控制,会做出各种奇怪的动作。
脑裂的根本原因是,原来统一的集群因为连接中断,造成了两个独立分隔的子集群,每个子集群单独进行选举,于是选出了 2 个主机,相当于人体有两个大脑了。
为了解决脑裂问题,民主式决策的系统一般都采用 “ 投票节点数必须超过系统总节点数一半 ” 规则来处理。
这种方式虽然解决了脑裂问题,但同时降低了系统整体的可用性,即如果系统不是因为脑裂问题导致投票节点数过少,而真的是因为节点故障,此时系统也不会选出主节点,整个系统就相当于宕机了,尽管此时还有节点 4 和节点 5 是正常的。
可扩展性
可扩展性是指,系统为了应对将来需求变化而提供的一种扩展能力,当有新的需求出现时,系统不需要或者仅需要少量修改就可以支持,无须整个系统重构或者重建。
设计具备良好可扩展性的系统,有两个基本条件:
- 正确预测变化
- 完美应对变化
预测变化
预测变化的复杂性在于:
- 不能每个设计点都考虑可扩展性。
- 不能完全不考虑可扩展性。
- 所有的预测都存在出错的可能性。
对于架构师来说,如何把握预测的程度和提升预测结果的准确性,是一件很复杂的事情,而且没有通用的标准可以简单套上去,更多是靠自己的经验、直觉。
所以整体架构设计的时候,主要考虑近几年的情况,而不需要考虑五年、十年跨度的变化。
应对变化
即使能够预测到变化,如何应对变化也是一件复杂的事情。
隔离
将变化的部分隔离到 “变化层” ,而其余不变的部分称为 “稳定层” 。
- 对于稳定层来说,接口肯定是越稳定越好。
- 对于变化层来说,在有差异的多个实现方式中找出共同点,并且还要保证当加入新的功能时,原有的接口不需要太大修改,这是一件很复杂的事情,所以接口设计同样至关重要。
封装
提炼出 “抽象层” 与 “实现层” ,抽象层稳定不变, “实现层” 根据业务需求修改,整体实现依赖 “抽象层” 。
/**
* 基本组件接口定义了可以通过装饰器改变的操作。
*/
interface Component {
operation: () => string
}
/**
* 具体组件提供操作的默认实现。
* 这些类可能会有多种变体。
*/
class ConcreteComponent implements Component {
public operation(): string {
return 'ConcreteComponent'
}
}
/**
* 基础 Decorator 类遵循与其他组件相同的接口。
* 此类的主要目的是为所有具体装饰器定义包装接口。
* 包装代码的默认实现可能包括用于存储包装组件的字段以及初始化它的方法。
*/
class Decorator implements Component {
protected component: Component
constructor(component: Component) {
this.component = component
}
/**
* 装饰器将所有工作委托给被包装的组件。
*/
public operation(): string {
return this.component.operation()
}
}
/**
* 具体装饰器调用包装的对象并以某种方式改变其结果。
*/
class ConcreteDecoratorA extends Decorator {
/**
* 装饰器可以调用操作的父级实现,而不是直接调用包装对象。
* 这种方法简化了装饰器类的扩展。
*/
public operation(): string {
return `ConcreteDecoratorA(${super.operation()})`
}
}
/**
* 装饰器可以在调用包装对象之前或之后执行其行为。
*/
class ConcreteDecoratorB extends Decorator {
public operation(): string {
return `ConcreteDecoratorB(${super.operation()})`
}
}
/**
* 客户端代码使用组件接口与所有对象协同工作。
* 这样,它就可以独立于与之协同工作的具体组件类。
*/
function clientCode(component: Component) {
// ...
console.log(`RESULT: ${component.operation()}`)
// ...
}
// 这样,客户端代码就可以支持 Simple component
const simple = new ConcreteComponent()
console.log('Client: I\'ve got a simple component:')
clientCode(simple)
console.log('')
// 请注意装饰器不仅可以包装简单组件,还可以包装其他装饰器。
const decorator1 = new ConcreteDecoratorA(simple)
const decorator2 = new ConcreteDecoratorB(decorator1)
console.log('Client: Now I\'ve got a decorated component:')
clientCode(decorator2)
成本
当我们的架构方案只涉及几台或者十几台服务器时,一般情况下成本并不是我们重点关注的目标,但如果架构方案涉及几百上千甚至上万台服务器,成本就会变成一个非常重要的架构设计考虑点。
当我们设计 “高性能” “高可用” 的架构时,通用的手段都是增加更多服务器来满足 “高性能” 和 “高可用” 的要求。
低成本正好与此相反,我们需要减少服务器的数量才能达成低成本的目标。
因此, 低成本本质上是与高性能和高可用冲突的 ,所以低成本很多时候不会是架构设计的首要目标,而是架构设计的附加约束。
也就是说,我们首先设定一个成本目标,当我们根据高性能、高可用的要求设计出方案时,评估一下方案是否能满足成本目标,如果不行,就需要重新设计架构。
低成本给架构设计带来的主要复杂度体现在,往往只有 “创新” 才能达到低成本目标。
这里的 “创新” 既包括开创一个全新的技术领域,也包括引入新技术,如果没有找到能够解决自己问题的新技术,那么就真的需要自己创造新技术了。
安全
功能安全
从实现的角度来看,功能安全更多地是和具体的编码相关,与架构关系不大。
现在很多开发框架都内嵌了常见的安全功能,能够大大减少安全相关功能的重复开发,但框架只能预防常见的安全漏洞和风险,无法预知新的安全问题,而且框架本身很多时候也存在漏洞。
所以功能安全是一个逐步完善的过程,而且往往都是在问题出现后才能有针对性的提出解决方案,我们永远无法预测系统下一个漏洞在哪里,也不敢说自己的系统肯定没有任何问题。
功能安全其实也是一个“攻”与“防”的矛盾,只能在这种攻防大战中逐步完善,不可能在系统架构设计的时候一劳永逸地解决。
架构安全
传统的架构安全主要依靠防火墙,防火墙最基本的功能就是隔离网络,通过将网络划分成不同的区域,制定出不同区域之间的 访问控制策略 来控制不同信任程度区域间传送的数据流。
防火墙的功能虽然强大,但性能一般,所以在传统的银行和企业应用领域应用较多。
互联网的业务具有海量用户访问和高并发的特点,防火墙的性能不足以支撑。
互联网系统的架构安全目前并没有太好的设计手段来实现,更多地是依靠运营商或者云服务商强大的带宽和流量清洗的能力,较少自己来设计和实现。
规模
规模带来复杂度的主要原因就是“量变引起质变” 。
- 功能增加导致的复杂度上升: 功能有 N ,但是功能之间可能还有连接 M ,最终的复杂度是 N + M
- 数据增加导致的复杂度上升: 数据体量增加后,传统的数据收集、加工、存储、分析手段都难以适应,需要新技术才能解决。
大数据理论基础是 Google 的三篇大数据论文:
- The Google File System: 专有的分布式文件系统
- Bigtable: 存储大规模结构化数据
- MapReduce: 用于大规模数据集的并行运算
架构设计原则
架构设计不同于编程,有具体的编程语言规则约束,只要输入确定,结果就是确定的。
架构设计本质上是不确定的,同一个系统 A 公司与 B 公司做出来的架构可能差异很大,但是功能相同并且都能正常运转。
在众多设计中,需要遵循三个原则: 合适原则 、 简单原则 、 演化原则
合适原则
没有必要与知名架构对比,人力、知识积累、业务场景都不相同。
真正优秀的架构都是在企业当前人力、条件、业务等各种约束下设计出来的,能够合理地将资源整合在一起并发挥出最大功效,并且能够快速落地。
简单原则
大部分人在评价一个方案水平高低的时候,复杂性是其中一个重要的参考指标。
复杂度来源于: 结构复杂 、 逻辑复杂
结构复杂
结构复杂存在的三个问题:
- 组件越多,就越有可能其中某个组件出现故障
- 某个组件改动,会影响关联的所有组件
- 定位一个复杂系统中的问题更加困难
逻辑复杂
逻辑复杂包含两种情况:
- 功能复杂: 在功能迭代的时候,改动成本高
- 算法复杂: 难以理解,难以实现,难以修改,难以快速解决问题
演化原则
软件架构设计其实更加类似于大自然 “设计” 一个生物,通过演化让生物适应环境,逐步变得更加强大:
- 设计出来的架构要满足当时的业务需要
- 架构要不断地在实际应用过程中迭代,保留优秀的设计,修复有缺陷的设计,改正错误的设计,去掉无用的设计,使得架构逐渐完善
- 当业务发生变化时,架构要扩展、重构,甚至重写;代码也许会重写,但有价值的经验、教训、逻辑、设计等却可以在新架构中延续
架构设计流程
识别复杂度
将主要的复杂度问题列出来,然后根据业务、技术、团队等综合情况进行排序, 优先解决当前面临的最主要的复杂度问题 。
对于按照复杂度优先级解决的方式,存在一个普遍的担忧:如果按照优先级来解决复杂度,可能会出现解决了优先级排在前面的复杂度后,解决后续复杂度的方案需要将已经落地的方案推倒重来。
这个担忧理论上是可能的,但现实中几乎是不可能出现的,原因在于软件系统的可塑性和易变性。
对于同一个复杂度问题,软件系统的方案可以有多个,总是可以挑出综合来看性价比最高的方案。
即使架构师决定要推倒重来,这个新的方案也必须能够同时解决已经被解决的复杂度问题,一般来说能够达到这种理想状态的方案基本都是依靠新技术的引入。
对于不熟悉的业务,可以按照排除法来确定复杂度问题所在:
高性能
对于架构师来说,关注的不是一天的数据,而是 1 秒的数据,即 TPS ( Transactions Per Second, 即每秒事务数) 和 QPS ( Queries Per Second, 即每秒查询数)。
一般情况下,会使用以下公式估算:
- 峰值 = 平均值 * 3
- 性能余量 = 峰值 * 业务两年内预估增量率
高可用
根据业务场景判断数据丢失,或者某个业务子系统少读了一次数据,会有什么后果,比如触犯国家法律法规、造成用户流失、财产损失等。
高可用不一定是整个系统需要高可用,可以是某个系统甚至某个模块的高可用。
高可扩展性
考虑未来两年内是否还存在继续变化的可能性,现有架构能否满足这种变化等。
其他复杂度
根据具体业务再继续分析是否存在业务上的复杂度,如: 模块耦合、业务耦合、安全性、成本等等。
设计备选方案
新技术都是在现有技术的基础上发展起来的,现有技术又来源于先前的技术。将技术进行功能性分组,可以大大简化设计过程,这是技术“模块化”的首要原因。技术的“组合”和“递归”特征,将彻底改变我们对技术本质的认识。 —— 《技术的本质》
设计方案时,需要注意以下几点:
- 备选方案的数量一般在 3-5 个 。 少于 3 个方案可能是因为思维狭隘,考虑不周全;多于 5 个则需要耗费大量的精力和时间,并且方案之间的差别可能不明显。
- 备选方案的差异要比较明显 。 相同方案的不同细节配置,不适合独立成一个单独的方案,需要有明显的机制上的差异。
- 备选方案的技术不要只局限于已经熟悉的技术 。 设计架构时,架构师需要将视野放宽,考虑更多可能性,不要对自己熟悉的技术有过多的倾向,而忽略了新技术方案。
- 关注技术选型而不是技术细节 。 关注细节容易将同质方案也认为是不同的方案,并且细节能讨论的点太多,评审时容易绕进去。
- 方案必须有可行性 。 如果方案根本就不可行,不应该纳入到备选列表中,由于人力、资源等影响导致的不可落地不属于不可行范畴。
评估与选择方案
基本原则: 列出我们需要关注的质量属性点,然后分别从这些质量属性的维度去评估每个方案,再综合挑选适合当时情况的最优方案 。
常见的方案质量属性点有:
- 性能
- 可用性
- 硬件成本
- 项目投入
- 复杂度
- 安全性
- 可扩展性
在评估这些质量属性时,需要遵循 “合适原则” 和 “简单原则” ,避免贪大求全,基本上某个质量属性能够满足一定时期内业务发展就可以了。
考虑问题的时候需要遵循 “演进原则” ,不考虑小概率事件带来的影响,避免陷入过度设计,导致投入浪费。
在选择方案时,应该按照不同质量属性的优先级选择。
例如 BAT 级别的公司更关注质量而不太注重成本,初创公司更关注成本而不太关注质量,同样场景二者选型出来的技术方案也是不同的。
选型时按照质量属性,能满足第一优先级的质量属性的方案先挑出来,如果有多个方案再对比第二优先级,依次类推,直到选择出唯一的最适合方案。
详细方案设计
详细设计方案阶段可能遇到的一种极端情况就是在详细设计阶段发现备选方案不可行,一般情况下主要的原因是备选方案设计时遗漏了某个关键技术点或者关键的质量属性。
想要避免这种情况,就需要做到:
- 架构师不但要进行备选方案设计和选型,还需要对备选方案的关键细节有较深入的理解 。
- 通过分步骤、分阶段、分系统等方式,尽量降低方案复杂度 。 方案本身的复杂度越高,某个细节推翻整个方案的可能性就越高,适当降低复杂性,可以减少这种风险。
- 如果方案本身就很复杂,那就采取 设计团队 的方式来进行设计,通过分工博采众长。
细化时需要考虑的点可以按照用户旅程来考虑,包括:
数据如何流转?
系统调用方有哪些?如何与系统进行交互?
接口原子性如何保证?
事务回滚
存储时使用什么 结构 / 数据库表?
数据如何 复制 / 备份?
主备切换使用什么方案?
整体的通信协议的选型
网络异常时是否有重试机制
通信采用双工还是单工?长连接还是轮询?
重复发送是否有去重机制?
数据顺序是否有要求?
是否涉及权限管控风险
常见高性能架构
数据库集群
读写分离
读写分离的基本原理是 将数据库读写操作分散到不同的节点上 。
---
title: 读写分离架构示例
---
graph TB
BusinessServer[业务服务器]
subgraph dataset[数据库集群]
Master[主机]
Slave1[从机]
Slave2[从机]
Master -->|复制| Slave1
Master -->|复制| Slave2
end
BusinessServer -->|写| Master
BusinessServer -->|读| Slave1
BusinessServer -->|读| Slave2
基本规则是:
- 数据库服务器搭建主从集群,一主一从、一主多从都可以。
- 数据库主机负责读写操作,从机只负责读操作。
- 数据库主机通过复制将数据同步到从机,每台数据库服务器都存储了所有的业务数据。
- 业务服务器将写操作发给数据库主机,将读操作发给数据库从机。
这里用的是“主从集群”,而不是“主备集群”。“从机”的“从”可以理解为“仆从”,仆从是要帮主人干活的,“从机”是需要提供读数据的功能的;而“备机”一般被认为仅仅提供备份功能,不提供访问功能。所以使用“主从”还是“主备”,是要看场景的,这两个词并不是完全等同的。
由此架构就会诞生两大问题: 主从复制延迟 、 分配机制
主从复制延迟
主从复制延迟会带来一个问题:如果业务服务器将数据写入到数据库主服务器后立刻进行读取,此时读操作访问的是从机,主机还没有将数据复制过来,到从机读取数据是读不到最新数据的,业务上就可能出现问题。
解决方案有以下几种:
- 写后直接从主机读
- 缺点: 与业务强绑定,对业务侵入和危害很大,后续维护困难
- 二次读取: 默认读取从机,仅在 检测到延迟时 访问主机
- 优点: 与业务逻辑解耦,实现简单
- 缺点:
- 可能存在大量二次读取击穿主机
- 极端场景下不能解决一致性问题,比如两次读取中间有一次写操作
- 关键业务用主机,非关键业务读写分离
分配机制
分配机制考虑的是如何区分读写操作,一般有两种方式: 程序代码封装 、 中间件封装
程序代码封装指在代码中抽象一个数据访问层,实现读写操作分离和数据库服务器连接的管理。
---
title: 程序代码封装架构
---
graph TB
subgraph BusinessServer[业务服务器]
Business[业务层]
DataLayer[数据访问层]
Business --> DataLayer
end
subgraph dataset[数据库集群]
Master[主机]
Slave1[从机]
Slave2[从机]
Master -->|复制| Slave1
Master -->|复制| Slave2
end
DataLayer -->|写| Master
DataLayer -->|读| Slave1
DataLayer -->|读| Slave2
程序代码封装的方式具备几个特点:
- 实现简单,而且可以根据业务做较多定制化的功能。
- 每个编程语言都需要自己实现一次,无法通用,如果一个业务包含多个编程语言写的多个子系统,则重复开发的工作量比较大。
- 故障情况下,如果主从发生切换,则可能需要所有系统都修改配置并重启。
中间件封装指的是独立一套系统出来,实现读写操作分离和数据库服务器连接的管理。中间件对业务服务器提供 SQL 兼容的协议,业务服务器无须自己进行读写分离。对于业务服务器来说,访问中间件和访问数据库没有区别,事实上在业务服务器看来,中间件就是一个数据库服务器。
---
title: 中间件封装
---
graph TB
BusinessServer[业务服务器]
DataMiddleware[数据库中间件]
BusinessServer -->|标准SQL协议| DataMiddleware
subgraph dataset[数据库集群]
Master[主机]
Slave1[从机]
Slave2[从机]
Master -->|复制| Slave1
Master -->|复制| Slave2
end
DataMiddleware -->|写| Master
DataMiddleware -->|读| Slave1
DataMiddleware -->|读| Slave2
数据库中间件的方式具备的特点是:
- 能够支持多种编程语言,因为数据库中间件对业务服务器提供的是标准 SQL 接口。
- 数据库中间件要支持完整的 SQL 语法和数据库服务器的协议,实现比较复杂,细节特别多,很容易出现 bug,需要较长的时间才能稳定。
- 数据库中间件自己不执行真正的读写操作,但所有的数据库操作请求都要经过中间件,中间件的性能要求也很高。
- 数据库主从切换对业务服务器无感知,数据库中间件可以探测数据库服务器的主从状态。例如,向某个测试表写入一条数据,成功的就是主机,失败的就是从机。
分库分表
读写分离分散了数据库读写操作的压力,但 没有分散存储压力 ,当数据量达到千万甚至上亿条的时候,单台数据库服务器的存储能力会成为系统的瓶颈,主要体现在这几个方面:
- 数据量太大,读写的性能会下降,即使有索引,索引也会变得很大,性能同样会下降。
- 数据文件会变得很大,数据库备份和恢复需要耗费很长时间。
- 数据文件越大,极端情况下丢失数据的风险越高。
为了解决性能问题,就需要将 数据库 / 数据表 拆分。
业务分库
业务分库指的是按照业务模块将数据分散到不同的数据库服务器。
---
title: 业务分库架构
---
graph TB
BusinessServer[业务服务器]
subgraph DataCluster[数据库集群]
UserDataServer[存储用户数据的服务器]
CommodityDataServer[存储商品数据的服务器]
OrderDataServer[存储订单数据的服务器]
end
BusinessServer -->|获取业务数据| DataCluster
该方案也同样存在问题,比如:
join操作问题: 业务分库后,原本在同一个数据库中的表分散到不同数据库中,导致无法使用 SQL 的 join 查询。- 事务问题: 原本在同一个数据库中不同的表可以在同一个事务中修改,业务分库后,表分散到不同的数据库中,无法通过事务统一修改。虽然有分布式事务解决方案,但是性能太低,与高性能目标不一致。
- 成本问题: 服务器变多了,成本自然上升。
一般来说,单机数据库服务器支撑 10w 用户量级没什么问题,所以初创公司一般不会直接进行分库分表设计。
对于业界成熟的大公司来说,由于已经有了业务分库的成熟解决方案,并且即使是尝试性的新业务,用户规模也是海量的,这与前面提到的初创业务的小公司有本质区别,因此最好在业务开始设计时就考虑业务分库。
分表
当数据量增大时,即使分库也难以承受百万甚至千万用户规模的业务,单表数据也会达到单台服务器的处理瓶颈,所以有必要对数据表进行拆分。
拆分的方式有两种:
- 垂直分表: 列拆分,行不拆分
- 水平分表: 行拆分,列不拆分
进行拆分后,可以按照业务决定是否分数据库存放,并不是必须的。
在单表切分为多表后,新的表即使在同一个数据库服务器中,也可能带来可观的性能提升,如果性能能够满足业务要求,是可以不拆分到多台数据库服务器的
垂直分表适合将表中某些不常用且占了大量空间的列拆分出去,引入的复杂性主要体现在表操作的数量要增加,原本一次查询可能要修改为多次查询再拼接。
水平分表适合表行数特别大的表,有的公司要求单表行数超过 5000 万就必须进行分表,这个数字可以作为参考,但并不是绝对标准,关键还是要看表的访问性能。
水平分表后,某条数据具体属于哪个切分后的子表,需要增加路由算法进行计算,这个算法会引入一定的复杂性。
常见路由算法有:
- 范围路由: 选取有序的列作为条件,将不同范围的数据分开存放,比如淘宝的历史订单单独查询,只保留高查找概率的近期订单。
- 复杂度: 分段选取规则较为复杂,需要考虑拆分后的表数量、查询性能等因素。
- 优点: 可以随着数据增加而平滑扩充
- 缺点: 数据分布不均匀,可能有的表数据量较大,有的又很少
- Hash 路由: 选取某个列或者某些列的值,进行 Hash 计算,根据计算结果分散到不同的数据库表中。
- 复杂度: 分表规则的设计,如何进行 hash 值计算,取哪些列等等
- 优点: 数据分布均匀
- 缺点: 难以扩容,所有的数据都需要重新分布
- 配置路由: 配置路由就是路由表,用一张独立的表来记录路由信息。配置路由设计简单,使用起来非常灵活,尤其是在扩充表的时候,只需要迁移指定的数据,然后修改路由表就可以了。
- 优点:
- 实现简单
- 扩表方便
- 缺点:
- 必须多查询一次,会影响整体性能
- 路由表如果过大,影响性能,还需要继续拆分,死循环了
- 优点:
NoSQL
由于 SQL 存在一些业务不适配问题,比如:
- 无法存储数据结构,只能存储行
- schema 扩展不方便
- 大数据场景下 I/O 较高
- 全文搜索能力弱
针对不同的问题,诞生了不同的 NoSQL ( Not only SQL )
- K-V 存储: 解决关系数据库无法存储数据结构的问题,以 Redis 为代表。
- 文档数据库: 解决关系数据库强 schema 约束的问题,以 MongoDB 为代表。
- 列式数据库: 解决关系数据库大数据场景下的 I/O 问题,以 HBase 为代表。
- 全文搜索引擎: 解决关系数据库的全文搜索性能问题,以 Elasticsearch 为代表。
K-V 存储
K-V 存储的全称是 Key-Value 存储。
Redis 是 K-V 存储的典型代表,它是一款开源(基于 BSD 许可)的高性能 K-V 缓存和存储系统。Redis 的 Value 是具体的数据结构,包括 string 、 hash 、 list 、 set 、 sorted set 、 bitmap 、 hyperloglog ,所以常常被称为数据结构服务器。
Redis 的缺点主要体现在并不支持完整的 ACID 事务,Redis 虽然提供事务功能,但 Redis 的事务只能保证隔离性和一致性( I 和 C ),无法保证原子性和持久性( A 和 D )。
虽然 Redis 并没有严格遵循 ACID 原则,但实际上大部分业务也不需要严格遵循 ACID 原则。
文档数据库
为了解决关系数据库 schema 带来的问题,文档数据库应运而生。文档数据库最大的特点就是 no-schema,可以存储和读取任意的数据。目前绝大部分文档数据库存储的数据格式是 JSON(或者 BSON),因为 JSON 数据是自描述的,无须在使用前定义字段,读取一个 JSON 中不存在的字段也不会导致 SQL 那样的语法错误。
文档数据库的优点在于:
- 新增字段简单: 直接通过读写新数据格式即可。
- 历史数据不会出错: 历史数据上没有新增字段时,只会返回空值,不会报错,由业务兼容处理即可。
- 复杂数据存储方便: JSON 可以直接存储任意层级任意结构的数据。如果使用 SQL 需要不断拆分表,再添加映射表关联,相当复杂。
文档数据库的缺点在于:
- 不支持事务
- 不支持 join: 如果存储到两个地方的话,需要查两次,不能像 SQL 一样使用 join 只进行一次查询
列式数据库
与行式数据库(存储时按行存储,同一行数据存放到一起)对应,只是修改为列式存储(每一列的数据存储到相邻位置,方便读取)。
优点:
- 业务只读取少数列时效率高: 同一列的数据相邻存储,一次磁盘操作就能把需要的列连续读入内存,不需要读取整行。
- 同一列数据类型相同且相邻存储,压缩率更高,进一步减少磁盘 I/O 与存储占用。
缺点: 业务场景限制,如果业务场景不适配,反而比 SQL 还慢。
列式数据库适用于离线大数据分析和统计,不适用于需要按行修改的情况。
全文搜索引擎
SQL 处理全文搜索有以下问题:
- 全文搜索的条件可以随意排列组合,如果通过索引来满足,则索引的数量会非常多。
- 全文搜索的模糊匹配方式,索引无法满足,只能用 like 查询,而 like 查询是整表扫描,效率非常低。
全文搜索引擎采用的是倒排索引,正排索引与倒排索引的区别在于:
- 正排索引是根据名称检索内容,比如根据标题检索整篇文章,包括创建时间、修改时间等额外信息。
- 倒排索引是根据关键字来查询文档内容,比如关键字 A 在哪些文档中出现过。
全文搜索引擎的索引对象是 单词和文档 ,而关系数据库的索引对象是 键和行 ,两者的术语差异很大,不能简单地等同起来。因此,为了让全文搜索引擎支持关系型数据的全文搜索,需要做一些转换操作,即将关系型数据转换为文档数据。
目前常用的转换方式是将关系型数据按照对象的形式转换为 JSON 文档,然后将 JSON 文档输入全文搜索引擎进行索引。