type
status
date
slug
summary
tags
category
icon
password
Sub-item
Last edited time
Mar 24, 2024 03:12 AM
Parent item
领域
架构设计的主要目的是为了解决软件系统复杂度带来的问题。
架构即(重要)决策,是在一个有约束的盒子里去求解或接近最合适的解。这个有约束的盒子是团队经验、成本、资源、进度、业务所处阶段等所编织、掺杂在一起的综合体(人,财,物,时间,事情等)。架构无优劣,但是存在恰当的架构用在合适的软件系统中,而这些就是决策的结果。
1.系统复杂度的来源
高性能
- 单机高性能
- 分时并发系统:多进程、多线程、多协程
- 多核处理器并行:SMP对称多处理
背景:提高任务执行效率
目标:充分利用CPU资源
方案:
技术方案:进程间通信、多线程并发、支持协程等技术方案
- 集群高性能
- 额外增加一台任务分配器,实现业务服务器之间负载均衡(LVS,nginx,HAProxy):轮询、权重
- 任务分配器和业务服务器之间的连接管理:活性检查
- 任务分配器本身也需要拓展为多台机器
- 按照业务拆分,不同仔细业务采取不同的扩展策略
- 采用DDD思想,拆分复杂业务
背景:单机无法满足业务处理
目标:提升系统整体的处理业务的能力
方案:集群通过增加机器的方式,但同时引入新的问题需要解决。
任务分配
任务分解:
高可用
保证系统无中断地执行业务功能。本质上通过“冗余”来实现。
- 高可用计算。通过ZooKeeper采用的就是1主多备,而MemCached采用的就是全主0备
- 高可用存储。存储高可用的难点不在于如何备份数据,而在于如何减少或者规避数据不一致对业务造成的影响。著名的 CAP 定理论证了存储高可用不可能同时满足“一致性、可用性、分区容错性”,最多满足其中两个。
- 高可用的状态决策。是高可用计算和存储的基础。系统需要能够判断当前的状态是正常还是异常,如果出现了异常就要采取行动来保证高可用。通过冗余实现的高可用系统,状态决策本质上就不可能做到完全正确。
- 独裁式。所有状态上报给决策者
- 协商式。启动后都是备机,协商一台作为主机
- 民主式。投票选举。例如zookeeper
可扩展性
正确预测变化,完美封装变化。
代码层面有设计模式,整洁架构等等。
低成本、安全、规模
- 往往只有“创新”才能达到低成本目标。
- 功能安全
- 架构安全
防火墙:隔离不同的网络,功能强大,性能一般。
一般需要借助云服务商和运营商提供带宽和流量清洗的功能。
- 规模带来复杂度的主要原因就是“量变引起质变”。
- 功能越来越多,导致系统复杂度指数级上升
- 数据越来越多,系统复杂度发生质变
架构设计流程
1. 有的放矢-识别复杂度
复杂度包括高性能,高可用,可扩展性,安全,低成本,规模等。实际情况下,复杂度只是其中一个,少数情况下包含其中2个。如果出现3个及以上的复杂度,说明系统有问题,或者架构师判断出现严重失误。
如果一个系统的多个复杂度都存在问题,那么不要幻想一次架构重构解决所有问题。
- 列出所有复杂度问题,按照优先级排序
不用担心解决高优先级的问题后,解决后面复杂度问题时,需要推翻重来,在前面决绝问题时肯定有多个方案可选,总可以挑出性价比最高的方案。即使决定要推倒从来,新的方案也必须能够同时解决之前的问题,这种情况一般是引入了新技术,同时解决了几个问题。
2.按图索骥-设计备选方案
- 设计3-5个备选方案
- 不要设想能设计一个最优秀的方案
- 不要只做一个方案
- 备选方案的差异要明显
- 备选方案不要局限于熟悉的技术
- 备选方案无需过于详细。关注技术选型,而不是技术细节
3.深思熟虑-评估和选择备选方案
360度环评
- 分析架构质量属性:性能、可用性、方案复杂度(规模)、可扩展性、硬件成本、安全性等等,按照优先级排序
- 设计备选方案。
- 对备选方案的每个指标进行分析
- 选择最终方案。应该按照各项质量属性的优先级选择,而不能按照数量对比,或者加权计分的方式。
4.精雕细琢-详细方案设计
在具体的技术方案中的技术点和备选方案中的选择。
极端情况下,发现备选方案不可行。如何避免:
- 架构师需要熟悉技术关键细节
- 方案尽量降低复杂度
- 采用团队的方式进行设计
存储高性能
1. 关系数据库
读写分离 - 分散读写压力
数据库部署为一主多从的集群,主服务器负责写入操作(以及个别实时性要求高的数据的读取操作),从服务器负责读取操作
mysql的复制延时在秒级。
读写分离需要解决复制延迟:
- 二次读取。读从机失败后再读一次主机。
- 关键业务读写都在主机,非关键业务采用读写分离。
分库分表 - 分散存储压力,以及读写压力
- 业务分库。
- 无法join
- 无法做事务
- 服务器成本
分库后的问题:
- 分表
- 垂直分表。
- 水平分表。
- 范围路由:根据ID值。缺点分布可能不均匀
- Hash路由:分布平均,扩展新表很麻烦
- 配置路由:需要多查一次配置路由表
数据记录数一样,列内容不一样。
表的操作次数增加。
列内容一样,但是记录条数不一样。
一般超过5000万需要分表,如果是复杂的表,超过1000万也可以分表,对于简单的表,1亿也可以不分表。
分表的复杂性1:分表后的路由
分表的复杂性2:join操作
分表的复杂性3:count操作
分表的复杂性4:order by操作
2. NoSQL
not only sql. 作为关系型数据库的补充,不是代替。
- K-V存储:解决关系数据库无法存储数据结构的问题,以Redis为代表。
没有严格遵循ACID;
- 文档数据库:解决关系数据库强schema约束的问题,以MongoDB为代表。
可以存储任意数据,No-schema,以json/bson格式存储;缺点是没有事务,无法join;
- 列式数据库:解决关系数据库大数据场景下的I/O问题,以HBase为代表。
- 全文搜索引擎:解决关系数据库的全文搜索性能问题,以Elasticsearch为代表。
缓存
绝大部分业务是读多写少,一次生成,多次使用。增加缓存减少对数据库的访问压力
- 缓存穿透
- 数据库中数据存在,第一次读取后,放入缓存
- 数据库中数据不存在,第一次读取后,为防止恶意攻击缓存穿透,需要在缓存中加入一个默认空值
- 数据库中数据存在,但是数据量太大,全部生成缓存耗时耗资源,导致访问都集中到数据库,此时,可以只缓存被访问的数据,并设置过期时间,例如1天。
- 如果是爬虫,只能加强监控。
缓存穿透是指缓存没有发挥作用,业务系统虽然去缓存查询数据,但缓存中没有数据,业务系统需要再次去存储系统查询数据。
- 缓存雪崩
- 缓存更新时加锁保护,对于集群,加分布式锁。
- 后台主动更新缓存,缓存永久有效,对于缓存内存不足,就会“踢”掉不被访问的缓存,这是需要在业务层面发现缓存失效之后,通过消息队列通知后台更新缓存,后台主动更新还能支持缓存预热。
当缓存批量失效(过期)后,大量并发请求到数据库存储系统,引起系统性能急剧下降。
解决方案
单机计算高性能
需要解决下面2个问题
- 服务器如果管理连接
- 服务器如何处理请求
PPC
主进程监听,每个连接都动态创建一个子进程处理,适合连接数较少的服务,例如数据库服务器。
在主进程listen并accept, 每次accept之后,创建一个子进程。
缺点: foek进程代价较高,进程减需要通信,并发连接数最大几百,太多容易导致频繁调度,系统压力大。
Pre-fork
解决PPC动态创建子进程成本过高的问题,提前创建好子进程。
在主进程listen后,创建一批子进程,每个子进程都在accept同一个socket,这里需要解决一个惊群问题:所有的子进程都会收到通知,在linux2.6之后的内核已解决该问题。
TPC
主进程listen并accept, 在子线程中进行业务处理。解决fork开销大和进程间通信复杂的问题。但需要解决数据共享的互斥保护,并且容易导致死锁,并且线程异常容易导致进程退出,因此还没有PPC使用的多。
Pre-thread
和Pre-fork类似,预先创建线程
- 主进程listen,并accept,有连接时,把连接交给线程处理
- 主进程listen,在线程中accept(同样需要靠内核解决惊群问题)
Reactor
在上述方案中,经常和线程在连接处理完之后就释放了,造成资源浪费,Reactor的思路就是资源复用,即不再为每个连接创建进程,而是创建进程池,并在一个进程中处理多个连接。
一个连接的处理一般为“read-process-write”,如果没有数据,那么阻塞在read上,这时候进程就空闲在那边,浪费资源,此时如果改为一个进程处理多个连接,只要有一个连接read阻塞,其他连接也无法被处理。 最简单的方式是把read改为非阻塞,经常不断轮询所有连接的read,谁有数据就处理谁,如果连接很多,成千上万, 那么效率就很低。
IO多连接复用的方案解决了上述问题。内核监听全部连接,当某个连接有数据时,就通知用户进程处理。
IO多连接复用
select的方式。每次需要用户指定监听的socket数组。让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理,处理时也需要再遍历一次:
虽然服务器进程会被select阻塞,但是select会利用内核不断轮询监听其他客户端的IO操作是否完成。
select机制的问题
- 一旦某个socket收到数据后,需要重新select所有IO。每次调用select,都需要把
fd_set
集合从用户态拷贝到内核态,如果fd_set
集合很大时,那这个开销也很大;
- 同时,每次调用select都需要在内核遍历传递进来的所有
fd_set
,如果fd_set
集合很大时,那这个开销也很大;
- 为了减少数据拷贝带来的性能损坏,内核对被监控的
fd_set
集合大小做了限制,并且这个是通过宏控制的,大小不可改变(32个整数大小,1024bits) — — 一次最多只能select 1024个socket。
- 进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。
poll的方式。poll和select一样,但是对select进行了一些改进。但只解决了上述select的第3个问题,有了以下的改进:
- 不限制监听槽的个数。poll使用 pollfd 结构,而不是select结构fd_set结构,所以poll是链式的,没有最大连接数的限制。
- 重复通知。poll有一个特点是水平触发,也就是通知程序fd就绪后,这次没有被处理,那么下次poll的时候会再次通知同个fd已经就绪。
epoll的方式。epoll相比poll更进一步,除了没有对描述符数目的限制,它所支持的文件描述符上限是整个系统最大可以打开的文件数目。
因为select|/poll每次有数据到来并处理完后都需要重新传递列表给内核,把是将“维护等待队列”和“阻塞进程”两个步骤合二为一,导致效率比较低。然而大多数应用场景中,需要监视的socket相对固定,并不需要每次都修改。epoll将这两个操作分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程。显而易见的,效率就能得到提升。
另一个低效的原因是序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。计算机共有三个socket,收到数据的sock2和sock3被rdlist(就绪列表)所引用。当进程被唤醒后,只要获取rdlist的内容,就能够知道哪些socket收到数据。
综上所述,多连接复用结合线程池就是Reactor。
- 单Reactor单进程/线程。无法发挥多核优势。适合业务处理非常快的场景,如redis。
- 单Reactor多线程。在Handler上只负责响应事件,不负责处理业务,数据发给processer业务线程处理。需哟啊处理数据共享问题
- 多Reactor多进程/线程。父进程Reactor负责处理连接事件,子进程Reactor负责调用Handler。多Reactor多进程代表是nginx,多Reactor多线程代表是memcache。
集群计算高性能
高可用—CAP理论
在一个分布式系统中(相互连接并共享数据的节点集合),当涉及操作时,只能保证一致性、可用性、分区容错性三者中的两个,另外一个必须被牺牲。
- Consistency一致性:对某个指定的客户端来说,读操作能够返回最新的写的结果
- Availability可用性:非故障节点在合理的时间内返回合理的响应
- Partition Tolerance分区容忍性:当出现网络分区后,系统能够继续履行职责
系统只能选择CP或AP,不存在只能保证CA的系统。应为如果要保证CA,那么系统不能有数据写入,只能读,否则数据将不一致。
CP代表:在有分区的情况下,为了保证数据一致性,访问未同步节点时,应该返回错误
AP代表:在有分区的情况下,为了保证数据可用性,访问未同步节点时,数据可能不一致
CAP细节
- 实际项目中,有的数据需要保证CP,比如账号信息,有的需要保证AP,比如用户信息
- 实际系统不存在严格意义的一致性,因为不可忽略网络延时
- 在不发生分区的情况下,可以保证同时满足CA
- 发生分区后需要做相应的日志,便于恢复
ACID
数据库中保证事务正确性的理论。
- Atomicity原子性:一个事务中,要么全部完成,要么全部不完成
- Consistency一致性:事务开始之前和结束之后,数据库完整性没有被破坏
- Isolation隔离性:允许事务并发执行
- Durability持久性:事务结束后,数据的修改是永久的
BASE
如果无法保证AP强一致性,那么系统可以采用合适的方式达到基本可用的最终一致性
FEMA
对系统范围内的潜在故障模式加以分析,按照严重程度进行分类,以确定失效对系统的最终影响。
FEMA是从用户角度分析功能点,生成一个FEMA分析表
存储高可用
本质都是将数据复制到多个存储设备,
主备复制(适用:后台管理系统的数据)
- 正常运行时,主机的数据通过复制通道备份到备机,备机平时不对外提供任何读写服务
- 主机故障后,系统处于不可用状态,客户端的请求不会被发给备机
- 如果主机短时间恢复,那么继续提供服务
- 如果短时间无法恢复,手动将备机升为主机,增加新的机器作为新的备机,还没有来得及同步到备机的数据,需要进行人工恢复, 也可能永远丢失
特点是:故障需要人工干预
主从复制
- 正常运行时,主机的数据通过复制通道复制到从机
- 主机提供客户端的写操作, 从机提供客户端的读操作,一些实时性高的数据也可以读主机
- 主机故障后,客户端无法写, 但是可以继续从从机读,因此写业务不可用
- 如果主机短时间恢复,那么继续提供写服务
- 如果短时间无法恢复,手动将从机升为主机,增加新的机器作为新的从机,还没有来得及同步到从机的数据,需要进行人工恢复, 也可能永远丢失
特点是:故障需要人工干预
双机切换
把上述人工干预的步骤由系统主动完成。
关键设计:
- 主备间状态判断
- 互连式:有专门的状态通道传递状态,但如果通道本身出现故障,将导致错误的切换操作
- 中介式:状态上报给第三方中介。相比互连式,连接管理更简单、状态决策更简单。mongoDB采用该方式。zookeeper也是该方式,作为主备倒换的状态中介
- 倒换时机
- 倒换策略
- 倒换触发
- 数据冲突解决
数据集中集群
不管是一主一从,一主一备等,数据都只能往主机上写。典型应用是zookeeper管理数据集群,解决主备的状态检测、主备切换。
适合数据量不大,集群数量不多的场景。
数据分散集群
每台服务器负责存储和备份一部分数据
每台服务器都可以读写数据,但必须有一个角色来负责执行数据分配算法,典型应用是Hadoop。
分布式事务算法
保证分布在多个节点上的数据统一提交或回滚,满足ACID的要求。
2PC - 2阶段提交
先广播询问,收到回复之后,再广播commit,再等待ack消息。
3PC - 3阶段提交
先广播询问,收到回复之后,再广播precommit,记录undo和redo的信息,再等待ack消息,再广播发送正式提交的命令。
2PC 二阶段提交(强一致性)
有2个角色:协调者和参与者。
- Commit请求阶段(投票阶段)。
- 协调者询问是否可以提交,并等待参与者回应
- 参与者执行事务操作,并写入Undo和Redo信息到日志,执行成功,返回Yes, 执行失败,返回No
- 协调者收到全部Yes,那么发出COMMIT消息给所有参与者;或者协调者收到No,那么发出ROLLBACK消息
- Commit提交阶段(完成阶段)
- 参与者完成COMMIT, 提交事务,并释放事务占用的资源,回复ACK消息;或者收到ROLLBACK消息,执行 Undo, 并释放事务占用的资源
- 协调者收到全部ACK,完成事务;或者取消事务
优点:实现简单
缺点:同步阻塞,消息通信期间不能做其他事;状态可能不一致,比如COMMIT有可能丢失,导致参与者超时回滚;单点故障,协调者故障后,参与者会一直阻塞;
3PC 三阶段提交(强一致性)
解决了2PC的单点故障问题,在2PC的二个阶段之间插入一个准备阶段。
- canCommit请求阶段(提交判断阶段)。
- 协调者询问是否可以提交,并等待参与者回应
- 参与者判断自身是否可以提交,返回Yes或No
- 协调者如果收到全部Yes,那么进入第二阶段,否则终止事务
- preCommit请求阶段(准备提交阶段)。
- 协调者发出preCommit,,并等待参与者回应
- 参与者执行事务操作,并写入Undo和Redo信息到日志,返回ACK
- doCommit提交阶段(提交执行阶段)
- 协调者收到全部ACK,那么发出doCommit消息给所有参与者,否则发出ROLLBACK消息
- 参与者完成COMMIT, 提交事务,并释放事务占用的资源,回复haveCommited消息;或者等待doCommit消息超时,继续提交事务,并释放事务占用的资源
缺点:同样存在数据不一致问题
分布式一致性算法
分布式事务算法是保证多节点数据的统一提交或回滚,满足ACID要求。
分布式一致性算法是保证同一数据在多个节点上的一致性。满足CAP理论中的CP要求。
常用技术是复制状态机。
- Paxos。理论上证明正确的算法,但是复杂且缺少细节。
- Raft。为了工程实现而设计的简化版Paxos,较为简单。
- ZAB。是zookeeper采用的算法。