分布式系统

1. 什么是注册中心

  • 服务提供者(RPC Server):在启动时,向 Registry 注册自身服务,并向 Registry 定期发送心跳汇报存活状态。

  • 服务消费者(RPC Client):在启动时,向 Registry 订阅服务,把 Registry 返回的服务节点列表缓存在本地内存中,并与 RPC Sever 建立连接。

  • 服务注册中心(Registry):用于保存 RPC Server 的注册信息,当 RPC Server 节点发生变更时,Registry 会同步变更,RPC Client 感知后会刷新本地 内存中缓存的服务节点列表。

2. CAP 理论

CAP 理论

CAP 理论指出,对于一个分布式系统来说,当设计读写操作时,只能同时满足以下三点中的两个:

  1. 一致性 (Consistency):所有节点访问同一份最新的数据副本。

  2. 可用性 (Availability):非故障的节点在合理的时间内返回合理的响应。

  3. 分区容错性 (Partition Tolerance):分布式系统出现网络分区(由于某种故障,系统中的某些节点之间失去了通信,导致整个系统被分为多个不连通的部分)的时候,仍然能够对外提供服务。

CAP 三选二

CAP 理论告诉我们 C、A、P 三者不能同时满足,最多只能满足其中两个。

  • 如果允许其中一个副本更新,则会导致数据不一致,即丧失了 C 性质。

  • 如果为了保证一致性,将分区某一侧的副本设置为不可用,那么又丧失了 A 性质。

  • 除非两个副本可以互相通信,才能既保证 C 又保证 A,这又会导致丧失 P 性质。

一般来说使用网络通信的分布式系统,无法舍弃 P 性质,那么就只能在一致性和可用性上做一个艰难的选择。

3. Etcd 与 Raft

从 Raft 原理到实践

4. Consul 的主要特征

  • CP 模型,使用 Raft 算法来保证强一致性,不保证可用性;

  • 支持服务注册与发现、健康检查、KV Store 功能。

  • 支持多数据中心,可以避免单数据中心的单点故障,而其部署则需要考虑网络延迟, 分片等情况等。

5. Consul 多数据中心

若两个 DataCenter,他们通过 Internet 互联,同时请注意为了提高通信效率,只有 Server 节点才加入跨数据中心的通信。

在单个数据中心中,Consul 分为 Client 和 Server 两种节点(所有的节点也被称为 Agent),Server 节点保存数据,Client 负责健康检查及转发数据请求到 Server;Server 节点有一个 Leader 和多个 Follower,Leader 节点会将数据同步到 Follower,Server 的数量推荐是 3 个或者 5 个,在 Leader 挂掉的时候会启动选举机制产生一个新的 Leader。

集群内的 Consul 节点通过 gossip 协议(流言协议)维护成员关系,也就是说某个节点了解集群内现在还有哪些节点,这些节点是 Client 还是 Server。

集群内数据的读写请求既可以直接发到 Server,也可以通过 Client 使用 RPC 转发到 Server,请求最终会到达 Leader 节点,在允许数据延时的情况下,读请求也可以在普通的 Server 节点完成。

6. Consul 的底层通讯协议 Gossip

gossip 协议也称之为流行病协议,它的信息传播行为类似流行病,或者森林的大火蔓延一样,一个接着一个,最终导致全局都收到某一个信息。

在 gossip 协议的网络中,有很多节点交叉分布,当其中的一个节点收到某条信息的时候,它会随机选择周围的几个节点去通知这个信息,收到信息的节点也会接着重复这个过程,直到网络中所有的节点都收到这条信息,才算信息同步完成。

在某个时刻下,网络节点中的信息可能是不对称的,gossip 协议不是一个强一致性的协议,而是最终一致性的协议,理解了这一层,我们去看 consul 的日志的时候,就能有一些端倪了,因为 consul 服务网络在运行的过程中,如果有新的服务注册进来,那么其他的节点会收到某个服务或者节点加入的信息。

7. Gossip 的优缺点

优点:

  • 扩展性好,加入网络方便

  • 容错性好,某个节点离开网络,不会影响整体的消息传播

  • 去中心化,Gossip 协议的网络中,不存在中心节点的概念,每个节点都可以成为消息的第一个传播者,只要网络可达,信息就能散播到全网。

  • 一致性收敛:这种一传十、十传百的消息传递机制,能够保证消息快速收敛,并保证最终一致性。

缺点:

  • 消息延迟:这个是由它的特性决定的,消息的扩散需要时间,这中间各个节点的消息是不一致的。

  • 消息冗余:A 节点告知 B 的信息,B 可能会反过来告知 A,这个时候 A 本身已经包含这个消息,却还要处理 B 的请求,这会造成消息的冗余,提高节点处理信息的压力。

8. Base 理论

Base 理论的核心思想是最终一致性。

  • 基本可用

不追求 CAP 中的「任何时候,读写都是成功的」,而是系统能够基本运行,一直提供服务。基本可用强调了分布式系统在出现不可预知故障的时候,允许损失部分可用性,相比正常的系统,可能是响应时间延长,或者是服务被降级。

  • 软状态

软状态可以对应 ACID 事务中的原子性,在 ACID 的事务中,实现的是强制一致性,要么全做要么不做,所有用户看到的数据一致。软状态则是允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。

  • 最终一致性

数据不可能一直是软状态,必须在一个时间期限之后达到各个节点的一致性,在期限过后,应当保证所有副本保持数据一致性,也就是达到数据的最终一致性。 在系统设计中,最终一致性实现的时间取决于网络延时、系统负载、不同的存储选型、不同数据复制方案设计等因素。

9. Paxos 算法

Quorum 选举算法

用一句话解释那就是,在 N 个副本中,一次更新成功的如果有 W 个,那么我在读取数据时是要从大于 N-W 个副本中读取,这样就能至少读到一个更新的数据了。

Quorum 的应用

Quorum 机制无法保证强一致性,也就是无法实现任何时刻任何用户或节点都可以读到最近一次成功提交的副本数据。 Quorum 机制的使用需要配合一个获取最新成功提交的版本号的 metadata 服务,这样可以确定最新已经成功提交的版本号,然后从已经读到的数据中就可以确认最新写入的数据。

Paxos 的节点角色

  • Proposer 提案者

    • 不同的 Proposer 可以提出不同的甚至矛盾的 value,比如某个 Proposer 提议“将变量 X 设置为 1”,另一个 Proposer 提议“将变量 X 设置为 2”,但对同一轮 Paxos 过程,最多只有一个 value 被批准。

  • Acceptor 批准者

    • 在集群中,Acceptor 有 N 个,Acceptor 之间完全对等独立,Proposer 提出的 value 必须获得超过半数(N/2+1)的 Acceptor 批准后才能通过。

  • Learner 学习者

    • 这里 Leaner 的流程就参考了 Quorum 议会机制,某个 value 需要获得 W=N/2 + 1 的 Acceptor 批准,Learner 需要至少读取 N/2+1 个 Accpetor,最多读取 N 个 Acceptor 的结果后,才能学习到一个通过的 value。

  • Client 产生议题者

    • Client 角色,作为产生议题者,实际不参与选举过程,比如发起修改请求的来源等。

选举过程

  1. 准备阶段

Proposer 生成全局唯一且递增的 ProposalID,向 Paxos 集群的所有机器发送 Prepare 请求,这里不携带 value,只携带 N 即 ProposalID。 Acceptor 收到 Prepare 请求后,判断收到的 ProposalID 是否比之前已响应的所有提案的 N 大,如果是,则:

  • 在本地持久化 N,可记为 Max_N;

  • 回复请求,并带上已经 Accept 的提案中 N 最大的 value,如果此时还没有已经 Accept 的提案,则返回 value 为空;

  • 做出承诺,不会 Accept 任何小于 Max_N 的提案。 如果否,则不回复或者回复 Error。

  1. 选举阶段

Proposer 提出一个提案并发送给 Acceptor;Acceptor 收到提案后会回复 Prepare,如果回复数量大于一半且 value 为空,则 Proposer 发送 Accept 请求,并带上自己指定的 value;如果回复数量大于一半且有的回复 value 不为空,则 Proposer 发送 Accept 请求,并带上 ProposalID 最大的 value;如果回复数量小于等于一半,则 Proposer 尝试更新生成更大的 ProposalID 再进行下一轮;Accpetor 收到 Accept 请求后,如果收到的 N 大于等于 Max_N,则回复提交成功并持久化 N 和 value,否则不回复或者回复提交失败;最后,Proposer 统计所有成功提交的 Accept 回复,如果回复数量大于一半,则表示提交 value 成功,并通知所有 Proposer 和 Learner;否则,尝试更新生成更大的 ProposalID 进入下一轮。

9. Paxos 常见的问题

  1. 如果半数以内的 Acceptor 失效,如何正常运行?

第一种,如果半数以内的 Acceptor 失效时还没确定最终的 value,此时所有的 Proposer 会重新竞争提案,最终有一个提案会成功提交。

第二种,如果半数以内的 Acceptor 失效时已确定最终的 value,此时所有的 Proposer 提交前必须以最终的 value 提交,也就是 Value 实际已经生效,此值可以被获取,并不再修改。

  1. Acceptor 需要接受更大的 N,也就是 ProposalID 有什么意义?

这种机制可以防止其中一个 Proposer 崩溃宕机产生阻塞问题,允许其他 Proposer 用更大 ProposalID 来抢占临时的访问权。

10. Zab 与 Paxos 算法的联系与区别

Paxos 的思想在很多分布式组件中都可以看到,Zab 协议可以认为是基于 Paxos 算法实现的,先来看下两者之间的联系:

  • 都存在一个 Leader 进程的角色,负责协调多个 Follower 进程的运行

  • 都应用 Quorum 机制,Leader 进程都会等待超过半数的 Follower 做出正确的反馈后,才会将一个提案进行提交

  • 在 Zab 协议中,Zxid 中通过 epoch 来代表当前 Leader 周期,在 Paxos 算法中,同样存在这样一个标识,叫做 Ballot Number

两者之间的区别是,Paxos 是理论,Zab 是实践,Paxos 是论文性质的,目的是设计一种通用的分布式一致性算法,而 Zab 协议应用在 ZooKeeper 中,是一个特别设计的崩溃可恢复的原子消息广播算法。

Zab 协议增加了崩溃恢复的功能,当 Leader 服务器不可用,或者已经半数以上节点失去联系时,ZooKeeper 会进入恢复模式选举新的 Leader 服务器,使集群达到一个一致的状态。

11. 基于消息补偿的最终一致性

核心思想

基于消息的最终一致性通常涉及将分布式事务拆分为多个本地事务,并通过消息队列进行协调。消息的发送和处理过程是关键,确保消息能够可靠地传递到所有相关服务。此方案的基本流程如下:

  1. 本地事务执行:事务发起者首先执行本地事务,例如创建订单。

  2. 消息发送:在本地事务成功后,发送一条消息到消息队列,通知其他服务(如库存服务)进行相应的操作。

  3. 消息处理:消息消费者接收到消息后,执行其本地事务,如更新库存。

  4. 结果反馈:消费者处理完毕后,反馈处理结果。如果处理失败,可能需要进行补偿操作。

关键问题

在实现基于消息的最终一致性时,需要解决几个关键问题:

  • 原子性:确保本地事务和消息发送操作要么同时成功,要么同时失败。这通常通过使用本地消息表或事务消息来实现。

  • 消息可靠性:确保消息能够被消费者成功接收和处理。若消息未被处理,需设计重试机制。

  • 幂等性:确保消息的重复消费不会导致数据的不一致。例如,如果同一条消息被处理多次,系统的状态应保持一致。

实现方案

本地消息表

核心思想是将消息的发送与业务操作放在同一个数据库事务中。具体步骤如下:

  • 在事务发起方,新建一个存储事务消息表即是本地消息表。

  • 事务发起方在一个事务中处理业务和把消息状态写入消息表,并发送消息到消息中间件。发送消息到消息中间件失败会重试发送。

  • 事务参与方,从中间件中读取消息,然后在一个本地事务中完成自己的业务逻辑。如果本地事务处理成功,就返回一个处理结果消息:成功。如果本地事务处理失败,给事务发起方发送一个业务补偿的消息,通知事务发起方进行回滚操作。

  • 事务发起方读取中间件的处理结果,如果是成功的消息,那么更新消息表状态。如果是失败的消息,那么进行事务回滚操作。

  • 定时扫描还没处理的消息或失败消息,在发送一遍,进行事务处理。

事务消息

事务消息通过消息队列的事务机制来确保消息的可靠性。生产者在发送消息前,先执行本地事务,只有在事务成功后,才会提交消息。

  1. 生产者生产消息给 MQ,此时未提交,称该消息为 HalfMsg,即半消息

  2. MQ 受到该消息,会给生产者一个 OK 确认

  3. 生产者执行本地事务

  4. 根据本地事务执行的结果向 MQ 提交 commit 或者 rollback,MQ 根据 commit 或者 rollback 对消息进行相应操作,即消费或者丢弃

  5. 如果生产者提交 commit 或者 rollback 提交超时,即第四步没有收到来自生产者的请求,MQ 回调检查该消息

  6. MQ 回调检查本地事务的状态

12. 对比两阶段提交,三阶段协议有哪些改进?

  • 引入超时机制 在 2PC 中,只有协调者拥有超时机制,如果在一定时间内没有收到参与者的消息则默认失败,3PC 同时在协调者和参与者中都引入超时机制。

  • 添加预提交阶段 在 2PC 的准备阶段和提交阶段之间,插入一个准备阶段,使 3PC 拥有 CanCommit、PreCommit、DoCommit 三个阶段,PreCommit 是一个缓冲,保证了在最后提交阶段之前各参与节点的状态是一致的。

优点:

  • 1.协调者和参与者都引入了超时机制,优化了在 2PC 中长时间收不到消息导致长时间阻塞的情况,一定时间内收不到消息就超时处理。

  • 2.优化了阻塞范围,3PC 把 2PC 第一阶段细分成 2 个阶段,这样 3PC 第一阶段就是很简单的操作,这个阶段不会阻塞。

  • 3.3PC 的 PreCommit 中中断事务回滚的操作是一个轻量级操作,这个也是因为细分为 2 个阶段缘故。

  • 4.由于增加了 CanCommit 询问阶段,事务成功提交的把握增大。即使不成功,这个阶段回滚风险也变小。同上 3。

  • 5.协调者单点风险减小,在 3PC 中,如果 PreCommit 阶段之后发生了协调者宕机,下一阶段 DoCommit 无法执行了,但是 3PC 这时默认操作是提交事务而不是回滚事务或持续等待,就相当于避免了协调者单点问题。为什么能这样?因为 PreCommit 阶段的存在。

3PC 对单点问题、阻塞问题和回滚时性能都有所改善。

缺点:

  • 数据不一致没有改善,比如进入 PreCommit 之后,协调者发出的事务预提交状态的消息是:”否“ - 失败的,刚好此时网络故障,有部分参与者直到超时都未能收到“否”的消息,这些参与者将会错误提交事务,导致参与者之间数据不一致的问题。

三阶段提交协议存在的问题

三阶段提交协议同样存在问题,具体表现为,在阶段三中,如果参与者接收到了 PreCommit 消息后,出现了不能与协调者正常通信的问题,在这种情况下,参与者依然会进行事务的提交,这就出现了数据的不一致性。

13. TCC 事务模型

  • Try 阶段:调用 Try 接口,尝试执行业务,完成所有业务检查,预留业务资源。

  • Confirm 或 Cancel 阶段:两者是互斥的,只能进入其中一个,并且都满足幂等性,允许失败重试。

    • Confirm 操作:对业务系统做确认提交,确认执行业务操作,不做其他业务检查,只使用 Try 阶段预留的业务资源。

    • Cancel 操作:在业务执行错误,需要回滚的状态下执行业务取消,释放预留资源。

把上面步骤在分阶段,可以把 TCC 看作是应用层实现的 2PC 两阶段提交:

  • 第一阶段:Try 操作,确认资源是否可执行,同时对要用到的资源进行锁定,

  • 第二阶段:Confirm 操作 或 Cancle 操作。如果第一阶段 Try 执行成功,那么就开始真正执行业务,并释放资源;如果第一阶段 Try 执行失败,那么执行回滚操作,预留的资源取消,使资源回到初始状态。

Try 阶段失败可以 Cancel,如果 Confirm 和 Cancel 阶段失败了怎么办?

TCC 中会添加事务日志,如果 Confirm 或者 Cancel 阶段出错,则会进行重试,所以这两个阶段需要支持幂等;如果重试失败,则需要人工介入进行恢复和处理等。

14. 分布式锁的常用实现

基于关系型数据库

以唯一索引为例,创建一张锁表,定义方法或者资源名、失效时间等字段,同时针对加锁的信息添加唯一索引,比如方法名,当要锁住某个方法或资源时,就在该表中插入对应方法的一条记录,插入成功表示获取了锁,想要释放锁的时候就删除这条记录。

  • 存在单点故障风险 数据库实现方式强依赖数据库的可用性,一旦数据库挂掉,则会导致业务系统不可用,为了解决这个问题,需要配置数据库主从机器,防止单点故障。

  • 超时无法失效 如果一旦解锁操作失败,则会导致锁记录一直在数据库中,其他线程无法再获得锁,解决这个问题,可以添加独立的定时任务,通过时间戳对比等方式,删除超时数据。

基于 Redis 缓存

setnx 是「set if not exists」如果不存在,则 SET 的意思,当一个线程执行 setnx 返回 1,说明 key 不存在,该线程获得锁;当一个线程执行 setnx 返回 0,说明 key 已经存在,那么获取锁失败,expire 就是给锁加一个过期时间。

使用 setnx 和 expire 有一个问题,这两条命令可能不会同时失败,不具备原子性,如果一个线程在执行完 setnx 之后突然崩溃,导致锁没有设置过期时间,那么这个锁就会一直存在,无法被其他线程获取。

为了解决这个问题,在 Redis 2.8 版本中,添加了 SETEX 命令,SETEX 支持 setnx 和 expire 指令组合的原子操作,解决了加锁过程中失败的问题。

基于 Zookeeper 实现

当客户端对某个方法加锁时,在 ZooKeeper 中该方法对应的指定节点目录下,生成一个唯一的临时有序节点。

判断是否获取锁,只需要判断持有的节点是否是有序节点中序号最小的一个,当释放锁的时候,将这个临时节点删除即可,这种方式可以避免服务宕机导致的锁无法释放而产生的死锁问题。

下面描述使用 ZooKeeper 实现分布式锁的算法流程,根节点为 /lock:

  • 客户端连接 ZooKeeper,并在 /lock 下创建临时有序子节点,第一个客户端对应的子节点为 /lock/lock01/00000001,第二个为 /lock/lock01/00000002;

  • 其他客户端获取 /lock01 下的子节点列表,判断自己创建的子节点是否为当前列表中序号最小的子节点;

  • 如果是则认为获得锁,执行业务代码,否则通过 watch 事件监听 /lock01 的子节点变更消息,获得变更通知后重复此步骤直至获得锁;

  • 完成业务流程后,删除对应的子节点,释放分布式锁。

15. 微服务中使用应用网关的优劣

通过在微服务架构中引入 API 网关,可以带来以下的收益:

  • API 服务网关对外提供统一的入口供客户端访问,隐藏系统架构实现的细节,让微服务使用更为友好;

  • 借助 API 服务网关可统一做切面任务,避免每个微服务自己开发,提升效率,使系统更加标准化;

  • 通过 API 服务网关,可以将异构系统进行统一整合,比如外部 API 使用 HTTP 接口,内部微服务可以使用一些性能更高的通信协议,然后在 - 网关中进行转换,提供统一的外部 REST 接口;

  • 通过微服务的统一访问控制,可以更好地实现鉴权,提高系统的安全性。

API 网关并不是一个必需的角色,在系统设计中引入网关,也会导致系统复杂性增加,带来下面的问题:

  • 在发布和部署阶段需要管理网关的配置,保证外部 API 访问的是正常的服务实例;

  • API 服务网关需要实现一个高可用伸缩性强的服务,避免单点失效,否则会成为系统的瓶颈;

  • 引入 API 服务网关额外添加了一个需要维护的系统,增加了开发和运维的工作量,提高了系统复杂程度。

16. 分布式调用跟踪的业务场景

  • 故障快速定位:通过调用链跟踪,一次请求的逻辑轨迹可以完整清晰地展示出来。在开发的过程中,可以在业务日志中添加调用链 ID,还可以通过调用链结合业务日志快速定位错误信息。

  • 各个调用环节的性能分析:在调用链的各个环节分别添加调用时延,并分析系统的性能瓶颈,进行针对性的优化。

  • 各个调用环节的可用性,持久层依赖等:通过分析各个环节的平均时延、QPS 等信息,可以找到系统的薄弱环节,对一些模块做调整,比如数据冗余等。

  • 数据分析等:调用链是一条完整的业务日志,可以得到用户的行为路径,并汇总分析。

17. 分布式链路追踪实现原理

Span 来表示一个服务调用开始和结束的时间,也就是时间区间,并记录了 Span 的名称以及每个 Span 的 ID 和父 ID,如果一个 Span 没有父 ID 则被称之为 Root Span。

一个请求到达应用后所调用的所有服务,以及所有服务组成的调用链就像是一个树结构,追踪这个调用链路得到的树结构称之为 Trace,所有的 Span 都挂在一个特定的 Trace 上,共用一个 TraceId。

在一次 Trace 中,每个服务的每一次调用,就是一个 Span,每一个 Span 都有一个 ID 作为唯一标识。同样,每一次 Trace 都会生成一个 TraceId 在 Span 中作为追踪标识,另外再通过一个 parentSpanId,标明本次调用的发起者。

18. 容器化升级对服务有哪些影响

Namespace

Namespace 的目的是通过抽象方法使得 Namespace 中的进程看起来拥有它们自己的隔离的全局系统资源实例。 Linux 内核实现了六种 Namespace:Mount namespaces、UTS namespaces、IPC namespaces、PID namespaces、Network namespaces、User namespaces,功能分别为:隔离文件系统、定义 hostname 和 domainame、特定的进程间通信资源、独立进程 ID 结构、独立网络设备、用户和组 ID 空间。

Docker 在创建一个容器的时候,会创建以上六种 Namespace 实例,然后将隔离的系统资源放入到相应的 Namespace 中,使得每个容器只能看到自己独立的系统资源。

Cgroups

Docker 利用 CGroups 进行资源隔离。CGroups(Control Groups)也是 Linux 内核中提供的一种机制,它的功能主要是限制、记录、隔离进程所使用的物理资源,比如 CPU、Mermory、IO、Network 等。

简单来说,CGroups 在接收到调用时,会给指定的进程挂上钩子,这个钩子会在资源被使用的时候触发,触发时会根据资源的类别,比如 CPU、Mermory、IO 等,然后使用对应的方法进行限制。

19. 服务网格有哪些应用

Sidecar 设计模式

在系统设计时,边车模式通过给应用程序添加边车的方式来拓展应用程序现有的功能,分离通用的业务逻辑,比如日志记录、流量控制、服务注册和发现、限流熔断等功能。通过添加边车实现,微服务只需要专注实现业务逻辑即可,实现了控制和逻辑的分离与解耦。

边车模式中的边车,实际上就是一个 Agent,微服务的通信可以通过 Agent 代理完成。在部署时,需要同时启动 Agent,Agent 会处理服务注册、服务发现、日志和服务监控等逻辑。这样在开发时,就可以忽略这些和对外业务逻辑本身没有关联的功能,实现更好的内聚和解耦。

服务网格

Service Mesh 基于边车模式演进,通过在系统中添加边车代理,也就是 Sidecar Proxy 实现。

Service Mesh 可以认为是边车模式的进一步扩展,提供了以下功能:

  • 管理服务注册和发现

  • 提供限流和降级功能

  • 前置的负载均衡

  • 服务熔断功能

  • 日志和服务运行状态监控

  • 管理微服务和上层容器的通信

20. 分表分库后引入的问题

  • 分布式事务问题 对业务进行分库之后,同一个操作会分散到多个数据库中,涉及跨库执行 SQL 语句,也就出现了分布式事务问题。

    比如数据库拆分后,订单和库存在两个库中,一个下单减库存的操作,就涉及跨库事务。关于分布式事务的处理,可以使用分布式事务中间件,实现 TCC 等事务模型;也可以使用基于本地消息表的分布式事务实现。

  • 跨库关联查询问题 分库分表后,跨库和跨表的查询操作实现起来会比较复杂,性能也无法保证。在实际开发中,针对这种需要跨库访问的业务场景,一般会使用额外的存储,比如维护一份文件索引。另一个方案是通过合理的数据库字段冗余,避免出现跨库查询。

  • 跨库跨表的合并和排序问题 分库分表以后,数据分散存储到不同的数据库和表中,如果查询指定数据列表,或者需要对数据列表进行排序时,就变得异常复杂,则需要在内存中进行处理,整体性能会比较差,一般来说,会限制这类型的操作。

24. 高并发系统的通用设计方法

高并发系统的演进应该是循序渐进,以解决系统中存在的问题为目的和驱动力的。

Scale-out(横向拓展)、缓存和异步这三种方法可以在做方案设计时灵活地运用,但它不是具体实施的方案,而是三种思想,在实际运用中会千变万化。

25. 高并发下的架构分层

分层有什么好处

  • 分层的设计可以简化系统设计,让不同的人专注做某一层次的事情

  • 分层之后可以做到很高的复用

  • 分层架构可以让我们更容易做横向扩展

如何来做系统分层

  • 需要理清楚每个层次的边界是什么

  • 层次之间一定是相邻层互相依赖,数据的流转也只能在相邻的两层之间流转

分层架构的不足

  • 增加了代码的复杂度

  • 把每个层次独立部署,层次间通过网络来交互,那么多层的架构在性能上会有损耗

26. 如何提升系统性能

高并发系统设计的三大目标:高性能、高可用、可扩展

性能优化原则

  • 性能优化一定不能盲目,一定是问题导向的

  • 性能优化也遵循“八二原则”,用 20% 的精力解决 80% 的性能问题。所以我们在优化过程中一定要抓住主要矛盾,优先优化主要的性能瓶颈点

  • 性能优化也要有数据支撑。在优化过程中,你要时刻了解你的优化让响应时间减少了多少,提升了多少的吞吐量

  • 性能优化的时候要明确目标

性能的度量指标

  • 平均值 平均值对于度量性能来说只能作为一个参考

  • 最大值 过于敏感

  • 分位值 分位值排除了偶发极慢请求对于数据的影响,能够很好地反应这段时间的性能情况,分位值越大,对于慢请求的影响就越敏感

脱离了并发来谈性能是没有意义的,我们通常使用吞吐量或者同时在线用户数来度量并发和流量,使用吞吐量的情况会更多一些。但是你要知道,这两个指标是呈倒数关系的。

高并发下的性能优化

  • 提高系统的处理核心数 提高系统的处理核心数就是增加系统的并行处理能力

  • 减少单次任务响应时间 CPU 密集型系统中需要处理大量的 CPU 运算,那么选用更高效的算法或者减少运算次数就是这类系统重要的优化手段 IO 密集型系统指的是系统的大部分操作是在等待 IO 完成,这类系统的性能瓶颈可能出在系统内部,也可能是依赖的其他系统。可以采用工具和监控的方法进行优化

27. 系统怎样做到高可用

可用性的度量

**MTBF(Mean Time Between Failure)**是平均故障间隔的意思,代表两次故障的间隔时间,也就是系统正常运转的平均时间。这个时间越长,系统稳定性越高。

**MTTR(Mean Time To Repair)**表示故障的平均恢复时间,也可以理解为平均故障时间。这个值越小,故障对于用户的影响越小。

灰度发布

灰度发布指的是系统的变更不是一次性地推到线上的,而是按照一定比例逐步推进的。一般情况下,灰度发布是以机器维度进行的。比方说,我们先在 10% 的机器上进行变更,同时观察 Dashboard 上的系统性能指标以及错误日志。如果运行了一段时间之后系统指标比较平稳并且没有出现大量的错误日志,那么再推动全量变更。

系统设计思路

  • 故障转移

  • 超时控制

  • 降级

  • 限流

28. 如何让系统易于拓展

集群系统中,不同的系统分层上可能存在一些“瓶颈点”,这些瓶颈点制约着系统的横线扩展能力。

高可扩展性的设计思路

拆分是提升系统扩展性最重要的一个思路,它会把庞杂的系统拆分成独立的,有单一职责的模块。相对于大系统来说,考虑一个一个小模块的扩展性当然会简单一些。将复杂的问题简单化,这就是我们的思路。

29. 如何减少频繁创建数据库连接的性能损耗

使用池化技术

以数据库连接池为例

如果当前连接数小于最小连接数,则创建新的连接处理数据库请求;如果连接池中有空闲连接则复用空闲连接;如果空闲池中没有连接并且当前连接数小于最大连接数,则创建新的连接处理请求;如果当前连接数已经大于等于最大连接数,则按照配置中设定的时间(C3P0 的连接池配置是 checkoutTimeout)等待旧的连接可用;如果等待超过了这个设定时间则向用户抛出错误。

  • 池子的最大值和最小值的设置很重要,初期可以依据经验来设置,后面还是需要根据实际运行情况做调整。

  • 池子中的对象需要在使用之前预先初始化完成,这叫做池子的预热,比方说使用线程池时就需要预先初始化所有的核心线程。如果池子未经过预热可能会导致系统重启后产生比较多的慢请求。

  • 池化技术核心是一种空间换时间优化方法的实践,所以要关注空间占用情况,避免出现空间过度使用出现内存泄露或者频繁垃圾回收等问题。

30. 如何实现分库分表

垂直拆分

在微博系统中有和用户相关的表,有和内容相关的表,有和关系相关的表,这些表都存储在主库中。在拆分后,我们期望用户相关的表分拆到用户库中,内容相关的表分拆到内容库中,关系相关的表分拆到关系库中。

水平拆分

  • 按照某一个字段的哈希值做拆分,这种拆分规则比较适用于实体表,比如说用户表,内容表,我们一般按照这些实体表的 ID 字段来拆分。比如说我们想把用户表拆分成 16 个库,64 张表,那么可以先对用户 ID 做哈希,哈希的目的是将 ID 尽量打散,然后再对 16 取余,这样就得到了分库后的索引值

  • 按照某一个字段的区间来拆分,比较常用的是时间字段。你知道在内容表里面有“创建时间”的字段,而我们也是按照时间来查看一个人发布的内容

31. 如何保证分库分表后 ID 的全局唯一性

基于 Snowflake 算法搭建发号器

Snowflake 算法设计的非常简单且巧妙,性能上也足够高效,同时也能够生成具有全局唯一性、单调递增性和有业务含义的 ID,但是它也有一些缺点,其中最大的缺点就是它依赖于系统的时间戳,一旦系统时间不准,就有可能生成重复的 ID。所以如果我们发现系统时钟不准,就可以让发号器暂时拒绝发号,直到时钟准确为止。

32. 如何选择缓存的读写策略

  1. Cache Aside 是我们在使用分布式缓存时最常用的策略,你可以在实际工作中直接拿来使用。

  2. Read/Write Through 和 Write Back 策略需要缓存组件的支持,所以比较适合你在实现本地缓存组件的时候使用;

  3. Write Back 策略是计算机体系结构中的策略,不过写入策略中的只写缓存,异步写入后端存储的策略倒是有很多的应用场景。

33. 使用 CDN 进行静态资源加速

如何让用户的请求到达 CDN 节点

CNAME 记录在 DNS 解析过程中可以充当一个中间代理层的角色,可以把将用户最初使用的域名代理到正确的 IP 地址上。

DNS 解析结果需要做本地缓存,降低 DNS 解析过程的响应时间。

如何找到离用户最近的 CDN 节点

GSLB(Global Server Load Balance,全局负载均衡), 它的含义是对于部署在不同地域的服务器之间做负载均衡,下面可能管理了很多的本地负载均衡组件。它有两方面的作用:

  • 它是一种负载均衡服务器,负载均衡,顾名思义嘛,指的是让流量平均分配使得下面管理的服务器的负载更平均;

  • 它还需要保证流量流经的服务器与流量源头在地缘上是比较接近的。

GSLB 可以通过多种策略,来保证返回的 CDN 节点和用户尽量保证在同一地缘区域,比如说可以将用户的 IP 地址按照地理位置划分为若干的区域,然后将 CDN 节点对应到一个区域上,然后根据用户所在区域来返回合适的节点;也可以通过发送数据包测量 RTT 的方式来决定返回哪一个节点。

34. 每秒 1 万次请求的系统要做服务化拆分吗?

其实,系统的 QPS 并不是决定性的因素。影响的因素可以归纳为以下几点:

  • 系统中,使用的资源出现扩展性问题,尤其是数据库的连接数出现瓶颈;

  • 大团队共同维护一套代码,带来研发效率的降低,和研发成本的提升;

  • 系统部署成本越来越高。

35. 微服务化后,系统架构要如何改造?

微服务拆分的原则

  • 做到单一服务内部功能的高内聚,和低耦合。

  • 你需要关注服务拆分的粒度,先粗略拆分,再逐渐细化。

  • 拆分的过程,要尽量避免影响产品的日常功能迭代。

36. 通过 RPC 框架实现 10 万 QPS 下毫秒级的服务调用

  1. 选择高性能的 I/O 模型,推荐使用同步多路 I/O 复用模型;

  2. 调试网络参数,这里面有一些经验值的推荐。比如将 tcp_nodelay 设置为 true,也有一些参数需要在运行中来调试,比如接受缓冲区和发送缓冲区的大小,客户端连接请求缓冲队列的大小(back log)等等;

  3. 序列化协议依据具体业务来选择。如果对性能要求不高,可以选择 JSON,否则可以从 Thrift 和 Protobuf 中选择其一。

37. 分布式系统如何寻址?

  • 注册中心可以让我们动态地,变更 RPC 服务的节点信息,对于动态扩缩容,故障快速恢复,以及服务的优雅关闭都有重要的意义;

  • 心跳机制是一种常见的探测服务状态的方式,在实际的项目中也可以使用;

  • 我们需要对注册中心中管理的节点提供一些保护策略,避免节点被过度摘除导致的服务不可用。

38. 横跨几十个分布式组件的慢请求要如何排查?

无论是服务追踪还是业务问题排查,你都需要在日志中增加 requestId,这样可以将你的日志串起来,给你呈现一个完整的问题场景。如果 requestId 可以在客户端上生成,在请求业务接口的时候传递给服务端,那么就可以把客户端的日志体系也整合进来,对于问题的排查帮助更大。

采用 traceId + spanId 这两个数据维度来记录服务之间的调用关系(这里 traceId 就是 requestId),也就是使用 traceId 串起单次请求,用 spanId 记录每一次 RPC 调用。

39. 怎样提升系统的横向扩展能力?

在微服务架构中,我们也会启动多个服务节点,来承接从用户端到应用服务器的请求,自然会需要一个负载均衡服务器

负载均衡分为两种

  • 代理类 —— Nginx

  • 客户端类 —— 嵌入 RPC 框架配合服务发现等

40. API 网关如何做?

入口网关

  • 它提供客户端一个统一的接入地址,API 网关可以将用户的请求动态路由到不同的业务服务上,并且做一些必要的协议转换工作。

  • 在 API 网关中,我们可以植入一些服务治理的策略,比如服务的熔断、降级,流量控制和分流。

  • 客户端的认证和授权的实现,也可以放在 API 网关中。

  • API 网关还可以做一些与黑白名单相关的事情,比如针对设备 ID、用户 IP、用户 ID 等维度的黑白名单。

出口网关

在应用服务器和第三方系统之间,部署出口网关,在出口网关中,对调用外部的 API 做统一的认证、授权,审计以及访问控制。

41. 多机房部署实现跨地域的分布式系统

不同机房的数据传输延迟,是造成多机房部署困难的主要原因,你需要知道,同城多机房的延迟一般在 1ms~3ms,异地机房的延迟在 50ms 以下,而跨国机房的延迟在 200ms 以下。

同城多机房方案可以允许有跨机房数据写入的发生,但是数据的读取,和服务的调用应该尽量保证在同一个机房中。

异地多活方案则应该避免跨机房同步的数据写入和读取,而是采取异步的方式,将数据从一个机房同步到另一个机房。

多机房部署是一个业务发展到一定规模,对于机房容灾有需求时,才会考虑的方案,能不做则尽量不要做。一旦你的团队决定做多机房部署,那么同城双活已经能够满足你的需求了,这个方案相比异地多活要简单很多。而在业界,很少有公司,能够搭建一套真正的异步多活架构,这是因为这套架构在实现时过于复杂,所以,轻易不要尝试。

42. 通过服务网格屏蔽服务化系统的服务治理细节

  1. Service Mesh 分为数据平面和控制平面。数据平面主要负责数据的传输;控制平面用来控制服务治理策略的植入。出于性能的考虑,一般会把服务治理策略植入到数据平面中,控制平面负责服务治理策略数据的下发。

  2. Sidecar 的植入方式目前主要有两种实现方式,一种是使用 iptables 实现流量的劫持;另一种是通过轻量级客户端来实现流量转发。

43. 服务端指标监控怎么做

监控指标如何选择

谷歌针对分布式系统监控的经验总结,四个黄金信号(Four Golden Signals)。它指的是,在服务层面一般需要监控四个指标,分别是延迟,通信量、错误和饱和度。

  • 延迟指的是请求的响应时间。比如,接口的响应时间、访问数据库和缓存的响应时间。

  • 通信量可以理解为吞吐量,也就是单位时间内,请求量的大小。比如,访问第三方服务的请求量,访问消息队列的请求量。

  • 错误表示当前系统发生的错误数量。这里需要注意的是, 我们需要监控的错误既有显示的,比如在监控 Web 服务时,出现 4XX 和 5XX 的响应码;也有隐示的,比如,Web 服务虽然返回的响应码是 200,但是却发生了一些和业务相关的错误(出现了数组越界的异常或者空指针异常等),这些都是错误的范畴。

  • 饱和度指的是服务或者资源到达上限的程度(也可以说是服务或者资源的利用率),比如说 CPU 的使用率,内存使用率,磁盘使用率,缓存数据库的连接数等等。

如何采集数据指标

  • Agent 是一种比较常见的,采集数据指标的方式。我们通过在数据源的服务器上,部署自研或者开源的 Agent,来收集收据,发送给监控系统,实现数据的采集。在采集数据源上的信息时,Agent 会依据数据源上,提供的一些接口获取数据。

  • 另一种很重要的数据获取方式,是在代码中埋点。

  • 通过日志进行收集。

监控数据的处理和存储

可以通过 Grafana 来连接时序数据库,将监控数据绘制成报表。

44. 用户的使用体验应该如何监控

可以搭建一个端到端的 APM 监控系统

  1. 从客户端采集到的数据可以用通用的消息格式,上传到 APM 服务端,服务端将数据存入到 Elasticsearch 中,以提供原始日志的查询,也可以依据这些数据形成客户端的监控报表;

  2. 用户网络数据是我们排查客户端,和服务端交互过程的重要数据,你可以通过代码的植入,来获取到这些数据;

  3. 无论是网络数据,还是异常数据,亦或是卡顿、崩溃、流量、耗电量等数据,你都可以通过把它们封装成 APM 消息格式,上传到 APM 服务端,这些用户在客户端上留下的踪迹可以帮助你更好地优化用户的使用体验。

服务端的开发人员往往会陷入一个误区,认为我们将服务端的监控做好,保证接口性能和可用性足够好就好了。事实上,接口的响应时间只是我们监控系统中很小的一部分,搭建一套端到端的全链路的监控体系,才是你的监控系统的最终形态。

45. 怎样设计全链路压力测试平台

压力测试是一种发现系统性能隐患的重要手段,所以应该尽量使用正式的环境和数据;

  • 对压测的流量需要增加标记,这样就可以通过 Mock 第三方依赖服务和影子库的方式来实现压测数据和正式数据的隔离;

  • 压测时,应该实时地对系统性能指标做监控和告警,及时地对出现瓶颈的资源或者服务扩容,避免对正式环境产生影响。

这套全链路的压力测试系统对于我们来说有三方面的价值:

  • 其一,它可以帮助我们发现系统中可能出现的性能瓶颈,方便我们提前准备预案来应对;

  • 其次,它也可以为我们做容量评估,提供数据上的支撑;

  • 最后,我们也可以在压测的时候做预案演练,因为压测一般会安排在流量的低峰期进行,这样我们可以降级一些服务来验证预案效果,并且可以尽量减少对线上用户的影响。所以,随着你的系统流量的快速增长,你也需要及时考虑搭建这么一套全链路压测平台,来保证你的系统的稳定性。

46. 成千上万的配置项要如何管理

  • 配置存储是分级的,有公共配置,有个性的配置,一般个性配置会覆盖公共配置,这样可以减少存储配置项的数量;

  • 配置中心可以提供配置变更通知的功能,可以实现配置的热更新;

  • 配置中心关注的性能指标中,可用性的优先级是高于性能的,一般我们会要求配置中心的可用性达到 99.999%,甚至会是 99.9999%。

并不是所有的配置项都需要使用配置中心来存储,如果你的项目还是使用文件方式来管理配置,那么你只需要,将类似超时时间等,需要动态调整的配置,迁移到配置中心就可以了。对于像是数据库地址,依赖第三方请求的地址,这些基本不会发生变化的配置项,可以依然使用文件的方式来管理,这样可以大大地减少配置迁移的成本。

47. 如何屏蔽非核心系统故障的影响?

在分布式环境下最怕的是服务或者组件慢,因为这样会导致调用者持有的资源无法释放,最终拖垮整体服务。

  • 服务熔断的实现是一个有限状态机,关键是三种状态之间的转换过程。

    • 当调用失败的次数累积到一定的阈值时,熔断状态从关闭态切换到打开态。一般在实现时,如果调用成功一次,就会重置调用失败次数。

    • 当熔断处于打开状态时,我们会启动一个超时计时器,当计时器超时后,状态切换到半打开态。你也可以通过设置一个定时器,定期地探测服务是否恢复。

    • 在熔断处于半打开状态时,请求可以达到后端服务,如果累计一定的成功次数后,状态切换到关闭态;如果出现调用失败的情况,则切换到打开态。

  • 开关降级的实现策略主要有返回降级数据和异步两种方案。

    • 数据库的压力比较大,我们在降级的时候,可以考虑只读取缓存的数据,而不再读取数据库中的数据。

    • 对于写数据的场景,一般会考虑把同步写转换成异步写,这样可以牺牲一些数据一致性和实效性来保证系统的可用性。

熔断和降级是保证系统稳定性和可用性的重要手段,在你访问第三方服务或者资源的时候都需要考虑增加降级开关或者熔断机制,保证资源或者服务出现问题时,不会对整体系统产生灾难性的影响。

48. 高并发系统中我们如何操纵流量?

  • 限流是一种常见的服务保护策略,你可以在整体服务、单个服务、单个接口、单个 IP 或者单个用户等多个维度进行流量的控制;

  • 基于时间窗口维度的算法有固定窗口算法和滑动窗口算法,两者虽然能一定程度上实现限流的目的,但是都无法让流量变得更平滑;

  • 令牌桶算法和漏桶算法则能够塑形流量,让流量更加平滑,但是令牌桶算法能够应对一定的突发流量,所以在实际项目中应用更多。

限流策略是微服务治理中的标配策略,只是你很难在实际中确认限流的阈值是多少,设置的小了容易误伤正常的请求,设置的大了则达不到限流的目的。所以,一般在实际项目中,我们会把阈值放置在配置中心中方便动态调整;同时,我们可以通过定期地压力测试得到整体系统以及每个微服务的实际承载能力,然后再依据这个压测出来的值设置合适的阈值。

49. 分布式限流器如何解决超高 QPS 问题

本地限流(Sentinel 等)在很多场景由于精确度低,不能很好的满足业务需求,需要引入分布式集中限流能力。但分布式精准限流对 QPS 不是太大时效果很好,但对于几万甚至几十万 QPS 的服务,依赖中心化 Redis 的精准限流器无法达到要求,就需要才用优化的手段来解决瓶颈问题。

超高 QPS 优化

本质上是 Redis 的热 Key 问题,业界通常有两种常见解决方案:

  1. 拆 Key,将热 Key 数据分散存储在不同的 Redis 节点上,可以使用一致性哈希等分片技术。

  2. 增加本地缓存池,加入 step(步长)的概念,每次访问在 Redis 取令牌是用步长为单位取到本地缓存,新的请求现在本地令牌桶获取令牌,消耗完之后再去到 Redis 直到 Redis 桶中的令牌被消耗完毕。相当于依靠远端 Redis + 本地结合的方式可以支持高并发。

在本方案中针对平稳的高 QPS 水位,采用固定 step + 本地缓存结合的概念来优化高 QPS 问题。

突发的高 QPS 流量

在极端场景下上面的解决方案也会遇到问题,突发不可预期的高流量,固定 step 不能解决流量突刺,导致 Redis 被打崩。

我们可以引入动态 step 的概念来解决突发流量的问题,根据当时的流量并发度动态调整。

故障的保底——自动熔断

分布式限流中 Redis 错误率上升,需要支持自动 & 手动降级到本地限流,而熔断器需要有三种状态

  1. Closed 状态:对资源的访问直接通过熔断器的检查。

  2. Open 状态:开启熔断器,对资源的访问会被切断。

  3. Half-Open 状态:该状态下除了探测流量,其余对资源的访问也会被切断。探测流量指熔断器处于半开状态时,会周期性的允许一定数目的探测请求通过,如果能够正常返回说明探测成功,重置到 Closed 状态,失败则回滚到 Open 状态。

50. 高并发下如何保证接口的幂等性

  1. 分布式锁 :用户通过浏览器发起请求,生成订单号作为唯一业务字段,使用 redis 的 SetNX 命令,将该订单 code 设置到 redis 中,同时设置超时时间,判断是否设置成功,如果设置成功,说明是第一次请求,则进行数据操作。 如果设置失败,说明是重复请求,则直接返回成功。

  2. 乐观锁 (Optimistic Locking) :使用乐观锁机制可以在并发环境下保证接口的幂等性。在处理请求之前,先获取资源的版本号或时间戳,并将其作为请求的一部分发送给服务器。服务器在处理请求时,先检查资源的版本号或时间戳是否与请求中的一致。

  3. 悲观锁 (Pessimistic Locking) :使用悲观锁可以在并发环境下保证接口的幂等性。在处理请求时,先对资源进行加锁,确保同一时间只有一个请求可以访问该资源。

51. 布隆过滤器底层实现

布隆过滤器由一个位数组和多个哈希函数组成。当要添加一个元素时,将该元素经过多个哈希函数得到多个哈希值,并将对应的位数组位置置为 1。当要查询一个元素是否存在时,同样将该元素经过多个哈希函数得到多个哈希值,并检查对应的位数组位置是否都为 1。如果有任意一个位置为 0,则可以确定该元素一定不存在于集合中;如果所有位置都为 1,则该元素可能存在于集合中,但有一定的误判率。

Last updated