高可用架构设计与实践综述

高可用架构设计与实践综述

文章目录

  • 高可用架构设计与实践综述
    • [第一章 高可用基础概念](#第一章 高可用基础概念)
      • [1.1 什么是高可用?](#1.1 什么是高可用?)
      • [1.2 高可用性和故障转移的实现方法](#1.2 高可用性和故障转移的实现方法)
      • [1.3 高可用架构组成](#1.3 高可用架构组成)
    • [第二章 负载均衡技术](#第二章 负载均衡技术)
      • [2.1 负载均衡概述](#2.1 负载均衡概述)
      • [2.2 SLB软负载](#2.2 SLB软负载)
        • [2.2.1 SLB是什么](#2.2.1 SLB是什么)
        • [2.2.2 SLB的特点](#2.2.2 SLB的特点)
        • [2.2.3 SLB可以做什么](#2.2.3 SLB可以做什么)
      • [2.3 F5硬负载](#2.3 F5硬负载)
        • [2.3.1 F5是什么](#2.3.1 F5是什么)
        • [2.3.2 F5流程](#2.3.2 F5流程)
        • [2.3.3 F5 BIG-IP的一些认识](#2.3.3 F5 BIG-IP的一些认识)
      • [2.4 Nginx负载均衡](#2.4 Nginx负载均衡)
        • [2.4.1 Nginx负载均衡概述](#2.4.1 Nginx负载均衡概述)
        • [2.4.2 正向代理和反向代理](#2.4.2 正向代理和反向代理)
        • [2.4.3 负载均衡常用算法](#2.4.3 负载均衡常用算法)
          • [1. Nginx默认轮询算法](#1. Nginx默认轮询算法)
          • [2. 基于比例加权轮询](#2. 基于比例加权轮询)
          • [3. 基于Ip的hash值分配](#3. 基于Ip的hash值分配)
          • [4. 基于服务器响应时间负载分配](#4. 基于服务器响应时间负载分配)
          • [5. 基于域名的hash结果](#5. 基于域名的hash结果)
        • [2.4.4 Nginx备份故障转移原理和验证](#2.4.4 Nginx备份故障转移原理和验证)
      • [2.5 Nginx和F5的区别](#2.5 Nginx和F5的区别)
      • [2.6 负载均衡架构演进](#2.6 负载均衡架构演进)
      • 总结
    • [第三章 系统保护机制](#第三章 系统保护机制)
    • [第四章 分布式一致性协议](#第四章 分布式一致性协议)
      • [4.1 理论基础](#4.1 理论基础)
        • [4.1.1 CAP定理](#4.1.1 CAP定理)
        • [4.1.2 BASE理论](#4.1.2 BASE理论)
      • [4.2 二阶段提交协议(2PC)](#4.2 二阶段提交协议(2PC))
        • [4.2.1 角色](#4.2.1 角色)
        • [4.2.2 阶段一](#4.2.2 阶段一)
        • [4.2.3 阶段二](#4.2.3 阶段二)
        • [4.2.4 二阶段提交缺点](#4.2.4 二阶段提交缺点)
      • [4.3 三阶段提交协议(3PC)](#4.3 三阶段提交协议(3PC))
        • [4.3.1 CanCommit阶段](#4.3.1 CanCommit阶段)
        • [4.3.2 PreCommit阶段](#4.3.2 PreCommit阶段)
        • [4.3.3 DoCommit阶段](#4.3.3 DoCommit阶段)
        • [4.3.4 三阶段提交优缺点](#4.3.4 三阶段提交优缺点)
      • [4.4 Paxos算法](#4.4 Paxos算法)
        • [4.4.1 Paxos角色](#4.4.1 Paxos角色)
        • [4.4.2 算法描述](#4.4.2 算法描述)
        • [4.4.3 Paxos算法运行示例](#4.4.3 Paxos算法运行示例)
        • [4.4.4 Paxos算法特点](#4.4.4 Paxos算法特点)
      • [4.5 Raft算法](#4.5 Raft算法)
        • [4.5.1 Raft协议概述](#4.5.1 Raft协议概述)
        • [4.5.2 Raft角色和基本概念](#4.5.2 Raft角色和基本概念)
        • [4.5.3 Raft工作流程](#4.5.3 Raft工作流程)
    • [第五章 限流技术详解](#第五章 限流技术详解)
      • [5.1 限流技术概述](#5.1 限流技术概述)
      • [5.2 滑动日志算法](#5.2 滑动日志算法)
      • [5.3 Redis分布式限流详解](#5.3 Redis分布式限流详解)
        • [5.3.1 Redis固定窗口限流](#5.3.1 Redis固定窗口限流)
        • [5.3.2 Redis滑动窗口限流](#5.3.2 Redis滑动窗口限流)
      • [5.4 限流技术总结](#5.4 限流技术总结)
    • [第六章 服务降级与熔断](#第六章 服务降级与熔断)
      • [6.1 服务降级详解](#6.1 服务降级详解)
        • [6.1.1 服务降级的常见场景](#6.1.1 服务降级的常见场景)
        • [6.1.2 服务降级的策略](#6.1.2 服务降级的策略)
        • [6.1.3 实施服务降级的技术手段](#6.1.3 实施服务降级的技术手段)
      • [6.2 服务熔断机制](#6.2 服务熔断机制)
        • [6.2.1 熔断器工作流程](#6.2.1 熔断器工作流程)
      • [6.3 服务灰度发布](#6.3 服务灰度发布)
        • [6.3.1 灰度发布概述](#6.3.1 灰度发布概述)
        • [6.3.2 灰度发布优点](#6.3.2 灰度发布优点)
        • [6.3.3 服务灰度发布的工具](#6.3.3 服务灰度发布的工具)
      • [6.4 全链路压测](#6.4 全链路压测)
    • [第七章 NGINX高可用配置](#第七章 NGINX高可用配置)
      • [7.1 NGINX备份故障转移原理](#7.1 NGINX备份故障转移原理)
        • [7.1.1 关键参数](#7.1.1 关键参数)
        • [7.1.2 故障转移配置示例](#7.1.2 故障转移配置示例)
        • [7.1.3 故障转移场景](#7.1.3 故障转移场景)
        • [7.1.4 故障转移逻辑](#7.1.4 故障转移逻辑)
    • 参考链接

第一章 高可用基础概念

1.1 什么是高可用?

高可用HA(High Availability)是分布式系统架构设计中必须考虑的因素之一,它通常是指,通过设计减少系统不能提供服务的时间。如果一个系统能够一直提供服务,那么这个可用性则是百分之百,但是天有不测风云。所以我们只能尽可能的去减少服务的故障。

参考链接高可用性和故障转移的方式

1.2 高可用性和故障转移的实现方法

以下是实现高可用性和故障转移的常见方法:

1. 主从复制:使用一个主服务器和多个从服务器来复制数据并提供冗余功能。如果主服务器发生故障,一个从服务器可以成为新的主服务器,并继续提供服务。

2. 负载均衡器:使用专用硬件设备或软件来平衡负载并确保故障转移。这些设备可以检测服务器状态,并在需要时将请求路由到其他可用服务器上。

3. 集群:使用多个服务器来提供同一应用程序,并且在其中任何一个节点发生故障时,应用程序可以继续运行。集群包括多种类型,例如高可用性集群、负载均衡集群和容器编排集群等。

4. DNS轮询:使用DNS服务器返回不同的IP地址来平衡负载。如果其中一个服务器发生故障,DNS服务器可以将请求路由到另一个服务器上。

5. 容器编排:使用像Kubernetes、Docker Swarm或Apache Mesos等容器编排和管理工具来处理应用程序容器的高可用性和故障转移。这些工具可以自动检测故障并重新启动容器,并在需要时将它们迁移到其他节点上。

需要注意的是,以上方法都有自己的优点和限制,因此您需要根据您的应用程序需求和基础架构环境选择最适合您的解决方案。同时,在使用任何一种方法之前,请务必备份所有相关数据,并小心地测试和验证您的配置以确保它们可靠和稳定。

1.3 高可用架构组成

  • DNS、负载均衡(Nginx、lvs)通过Keepalived方式保持高可用
  • 应用服务器(tongweb等)多机器部署
  • 数据库-主从
  • 中间件集群化部署

第二章 负载均衡技术

2.1 负载均衡概述

负载均衡(Load Balance),其含义就是指将负载(工作任务)进行平衡、分发到多个操作单元上进行运行,负载均衡构建在原有网络结构之上,它提供了一种透明且廉价有效的方法扩展服务器和网络设备的带宽、加强网络数据处理能力、增加吞吐量、提高网络的可用性和灵活性。

市面上常见的负载均衡有以下三种:

  1. 基于DNS负载均衡
  2. 基于软件负载均衡
  3. 基于硬件负载均衡

三种方案各有优劣,DNS负载均衡可以实现在地域上的流量均衡,硬件负载均衡主要用于大型服务器集群中的负载需求,而软件负载均衡大多是基于机器层面的流量均衡。

2.2 SLB软负载

2.2.1 SLB是什么

负载均衡(Server Load Balancer,简称SLB)是一种网络负载均衡服务,针对阿里云弹性计算平台而设计,可以对多台云服务器进行流量分发,在系统架构、系统安全及性能,扩展,兼容性设计上都充分考虑了弹性计算平台云服务器使用特点和特定的业务场景。

2.2.2 SLB的特点
  1. 高可用:采用全冗余设计,无单电,可用性高达99.99%,根据应用负载进行弹性扩容,在流量波动的情况下不中断对外服务。
  2. 低成本:相对于传统硬件负载均衡的高投入,可以节省极大的成本,无需一次性采购昂贵的负载均衡设备。
  3. 安全性:结合云盾提供的防DDoS攻击能力,包含:CC、SYN、flood等DDoS攻击方式。
  4. 收费模式:按出口流量计费
  5. 高并发:集群支持亿级并发连接,单实例提供千万级并发能力
2.2.3 SLB可以做什么
  1. 可以通过流量分发扩展应用系统对外的服务能力,消除单点故障提升应用系统的可用性,当求中一部分ECS宕机后,应用系统仍可以正常工作。
  2. SLB通过设置虚拟服务地址(IP),将位于同一地域(Region)的多台云服务器(ECS)资源虚拟成一个高性能,高可用的应用服务池。之后将客户端的网络请求分发到云服务器池中,根据规则转发到具体的ECS里。
  3. SLB服务会检查云服务器池中ECS的健康状态,自动隔离异常状态的ECS,从而解决了单台ECS的单点问题,提高了应用的整体服务能力。
  4. 在标准的负载均衡功能之外,SLB服务还具备TCP和HTTP抗DDoS攻击的特性,增强应用服务器的防护能力。

2.3 F5硬负载

2.3.1 F5是什么

F5 Network Big-IP,也就是我们常说的 F5,它是一个网络设备,可以简单的理解成类似于网络交换机的东西,性能是非常的好,每秒能处理的请求数达到百万级,当然价格也就非常非常贵了,十几万到上百万人民币都有。

参考链接百度百科F5方案

2.3.2 F5流程

F5会以一定的频率去探测一组服务器资源可用的port,之后提供一个统一的虚拟IP,应用请求到这个虚拟的IP后,F5会请求转发到服务器资源中能够处理请求的服务器里,之后把response响应给请求方。

  1. 应用发出服务请求到F5暴露的虚拟Ip
  2. BIGIP接收到请求,将请求数据包中目的IP地址改为可用的后台服务器IP地址,然后将数据包发出到后台选定的服务器
  3. 后台服务器收到后,将response包按照其路由发回到BIGIP
  4. BIGIP收到response包后将其中的源地址改回成虚拟IP地址,发回客户端,由此就完成了一个标准的服务器负载平衡的流程。
2.3.3 F5 BIG-IP的一些认识
  1. F5 BIG-IP是一台对流量和内容进行管理分配的设备,它提供12种灵活的算法将所有流量均衡的分配到各个服务器,而面对用户,只是一台虚拟服务器。
  2. F5 BIG-IP可以确认应用程序能否对请求返回对应的数据。假如F5 BIG-IP后面的某一台服务器发生服务停止、死机等故障,F5会检查出来并将该服务器标识为宕机,从而不将用户的访问请求传送到该台发生故障的服务器上。这样,只要其它的服务器正常,用户的访问就不会受到影响。宕机一旦修复,F5 BIG-IP就会自动查证应用已能对客户请求作出正确响应并恢复向该服务器传送。
  3. F5 BIG-IP通过OneConnection连接优化技术,卸载服务器端的处理压力,提升服务器处理性能。
  4. F5 BIG-IP通过流量整形和Qos机制区分和保证重要应用的带宽和服务等级。

2.4 Nginx负载均衡

2.4.1 Nginx负载均衡概述

Nginx (engine x) 是一个高性能的HTTP和反向代理web服务器,同时也是一个IMAP、POP3、SMTP代理服务器;Nginx可以作为一个HTTP服务器进行网站的发布处理,另外Nginx可以作为反向代理进行负载均衡的实现。我们这里主要介绍它的负载均衡功能的常用算法和流程。

通过上述的图解大家就可以看到,多个客户端给服务器发送的请求,Nginx服务器接收到之后,按照一定的规则分发给了后端的应用服务器进行处理。此时请求的来源也就是客户端是明确的,但是请求具体由哪台服务器处理的并不明确了,Nginx扮演的就是一个反向代理角色。反向代理时也可以通过多种算法来实现代理的负载均衡。

2.4.2 正向代理和反向代理

虽然正向代理服务器和反向代理服务器所处的位置都是客户端和真实服务器之间,所做的事情也都是把客户端的请求转发给服务器,再把服务器的响应转发给客户端,但是二者之间还是有一定的差异的。

正向代理其实是客户端的代理,帮助客户端访问其无法访问的服务器资源(如科学上网)

反向代理则是服务器的代理,帮助服务器做负载均衡,安全防护等。

2.4.3 负载均衡常用算法

负载均衡主要通过专门的硬件设备或者软件算法实现。通过硬件设备实现的负载均衡效果好、效率高、性能稳定,但是成本较高。而通过软件实现的负载均衡主要依赖于均衡算法的选择和程序的健壮性。均衡算法又主要分为两大类:

静态负载均衡算法:主要包括轮询算法、基于比率的加权轮询算法或者基于优先级的加权轮询算法。

动态负载均衡算法:主要包括基于任务量的最少连接优化算法、基于性能的最快响应优先算法、预测算法及动态性能分配算法等。

静态负载均衡算法在一般网络环境下也能表现的比较好,动态负载均衡算法更加适用于复杂的网络环境。

1. Nginx默认轮询算法

他会根据你upstream中配的server地址,依次请求到对应的服务器中,第一次请求到8080,第二次请求到8081,第三次请求到8080,以此类推。

2. 基于比例加权轮询

假如说,上述其中一台tomcat配置更高,扛得住更强的并发,而另一台相对配置较低,那我们就可以采用加权的方式,让请求更多的打到配置更高的tomcat服务器上。

其实对比上面不加权的轮询方式,这里在 upstream 指令中多了一个 weight 指令。该指令用于配置前面请求处理的权重,默认值为 1,weight可以控制请求到具体某一台服务器上的次数。

3. 基于Ip的hash值分配

我们设想一个问题,用户在浏览一个网页时需要请求到不同的资源,每个资源前可能都会有拦截器用来让用户去登录,这时用户在访问其中一个资源时已经登陆了,这个session存储到了当前服务器里,但是用户请求其他资源,负载均衡给代理到其他服务器了,用户的session就获取不到了,用户还需要重新登录,十分影响用户体验,这种情况我们常用两种解决方案:

①第一种方法是选择一个中间件,将登录信息保存在一个中间件上,这个中间件可以为Redis这样的数据库。那么第一次登录,我们将session 信息保存在 Redis 中,跳转到第二个服务器时,我们可以先去Redis上查询是否有登录信息,如果有,就能直接进行登录之后的操作了,而不用进行重复登录。

②第二种方法是根据客户端的IP地址划分,每次都将同一个 IP 地址发送的请求都分发到同一个 Tomcat 服务器,那么也不会存在 session 共享的问题。

注意:我们在 upstream 指令块中增加了 ip_hash 指令。该指令就是告诉 nginx 服务器,同一个 IP 地址客户端发送的请求都将分发到同一个 Tomcat 服务器进行处理。

4. 基于服务器响应时间负载分配

根据服务器处理请求的时间来进行负载,处理请求越快,也就是响应时间越短的优先分配。

fair:智能调整调度算法,动态的根据后端服务器的请求处理到响应的时间进行均衡分配,响应时间短处理效率高的服务器分配到请求的概率高,响应时间长处理效率低的服务器分配到的请求少;结合了前两者的优点的一种调度算法。但是需要注意的是Nginx默认不支持fair算法,如果要使用这种调度算法,请安装upstream_fair模块。

5. 基于域名的hash结果

按照访问的域名的hash结果分配请求,每个请求的url会指向后端固定的某个服务器,可以在Nginx作为静态服务器的情况下提高缓存效率。同样要注意Nginx默认不支持这种调度算法,要使用的话需要安装Nginx的hash软件包。

2.4.4 Nginx备份故障转移原理和验证
关键参数
  • backup:将服务器标记为备份服务器。当主服务器不可用时,请求将被传递给它。
  • proxy_next_upstream:默认值为 error timeout,用来定义哪些场景配置为不成功的尝试(unsuccessful attempts)。
  • proxy_next_upstream_tries:用来定义不成功的尝试(unsuccessful attempts)的尝试次数,0 表示不限制,尝试所有 upstream 的 server。
  • max_fails 和 fail_timeout:这些参数用于定义一个服务器被认为是不可用之前可以失败的次数(max_fails)以及在被认为是不可用之后多长时间内不再尝试该服务器(fail_timeout)。

2.5 Nginx和F5的区别

F5,硬负载

  • 优点:能够直接通过智能交换机实现,处理能力更强,而且与系统无关,负载性能强
  • 缺点:成本高,除设备价格高昂,而且配置冗余,很难想象后面服务器做一个集群,但最关键的负载均衡设备却是单点配置,无法有效掌握服务器及应用状态。

硬件负载均衡,一般都不管实际系统与应用的状态,而只是从网络层来判断,所以有时候系统处理能力已经不行了,但网络可能还来得及反应(这种情况非常典型,比如应用服务器后面内存已经占用很多,但还没有彻底不行,如果网络传输量不大就未必在网络层能反映出来)

Nginx,软负载

  • 优点:基于系统与应用的负载均衡,能够更好地根据系统与应用的状况来分配负载。这对于复杂应用是很重要的,性价比高,实际上如果几台服务器,用F5之类的硬件产品显得有些浪费,而用软件就要合算得多,因为服务器同时还可以跑应用、做集群等。
  • 缺点:负载能力受服务器本身性能的影响,性能越好,负载能力越大。

2.6 负载均衡架构演进

相信大家都听过这样的一道经典面试题:「请说出在淘宝网输入一个关键词到最终展示网页的整个流程,越详细越好」

这个问题很难,涉及到 HTTP,TCP,网关,LVS 等一系列相关的概念及诸多协议的工作机制,如果你能掌握到这其中的每个知识点,那将极大地点亮你的技能树,对于网络是如何运作也会了然于胸。

李大牛创业了,由于前期没啥流量,所以他只部署了一台 tomcat server,让客户端将请求直接打到这台 server 上。这样部署一开始也没啥问题,因为业务量不是很大,单机足以扛住,但后来李大牛的业务踩中了风口,业务迅猛发展,于是单机的性能逐渐遇到了瓶颈,而且由于只部署了一台机器,这台机器挂掉了业务也就跌零了。

为了避免单机性能瓶颈与解决单点故障的隐患,李大牛决定多部署几台机器(假设为三台),这样可以让 client 随机打向其中的一台机器,这样就算其中一台机器挂了,另外的机器还存活,让 client 打向其它没有宕机的机器即可。

现在问题来了,client 到底该打向这三台机器的哪一台呢,如果让 client 来选择肯定不合适,因为client 必须知道有哪几台 server,然后再用轮询等方式随机连接其中一台机器,但如果其中某台 server 宕机了,client 是无法提前感知到的,那么很可能 client 会连接到这台宕掉的 server 上,所以选择哪台机器来连接的工作最好放在 server 中,具体怎么做呢,在架构设计中有个经典的共识:没有什么是加一层解决不了的,如果有那就再加一层,所以我们在 server 端再加一层,将其命名为 LB(Load Balance,负载均衡),由 LB 统一接收 client 的请求,然后再由它来决定具体与哪一个 server 通信,一般业界普遍使用 Nginx 作为 LB。

采用这样的架构设计总算支撑了业务的快速增长,但随后不久李大牛发现这样的架构有点问题:所有的流量都能打到 server 上,这显然是有问题的,不太安全,那能不能在流量打到 server 前再做一层鉴权操作呢,鉴权通过了我们才让它打到 server 上,我们把这一层叫做网关(为了避免单点故障,网关也要以集群的形式存在)。

这样的话所有的流量在打到 server 前都要经过网关这一层,鉴权通过后才把流量转发到 server 中,否则就向 client 返回报错信息,除了鉴权外,网关还起到风控(防止羊毛党),协议转换(比如将 HTTP 转换成 Dubbo),流量控制等功能,以最大程度地保证转发给 server 的流量是安全的,可控的。

这样的设计持续了很长一段时间,但是后来李大牛发现这样的设计其实还是有问题,不管是动态请求,还是静态资源(如 js,css文件)请求都打到 tomcat 了,这样在流量大时会造成 tomcat 承受极大的压力,其实对于静态资源的处理 tomcat 不如 Nginx,tomcat 每次都要从磁盘加载文件比较影响性能,而 Nginx 有 proxy cache 等功能可以极大提升对静态资源的处理能力。

所谓的 proxy cache 是指 nginx 从静态资源服务器上获取资源后会缓存在本地的内存+磁盘中,下次请求如果命中缓存就从 Nginx 本机的 Cache 中直接返回了

所以李大牛又作了如下优化:如果是动态请求,则经过 gateway 打到 tomcat,如果是 Nginx,则打到静态资源服务器上。

这就是我们所说的动静分离,将静态请求与动态请求分开,这样 tomcat 就可以专注于处理其擅长的动态请求,而静态资源由于利用到了 Nginx 的 proxy cache 等功能,后端的处理能力又上了一个台阶。

另外需要注意的是并不是所有的动态请求都需要经过网关,像我们的运营中心后台由于是内部员工使用的,所以它的鉴权与网关的 api 鉴权并不相同,所以我们直接部署了两台运营中心的 server ,直接让 Nginx 将运营中心的请求打到了这两台 server 上,绕过了网关。

当然为了避免单点故障 Nginx 也需要部署至少两台机器,于是我们的架构变成了下面这样,Nginx 部署两台,以主备的形式存在,备 Nginx 会通过 keepalived 机制(发送心跳包) 来及时感知到主 Nginx 的存活,发现宕机自己就顶上充当主 Nginx 的角色。

看起来这样的架构确实不错,但要注意的是 Nginx 是七层(即应用 层)负载均衡器 ,这意味着如果它要转发流量首先得和 client 建立一个 TCP 连接,并且转发的时候也要与转发到的上游 server 建立一个 TCP 连接,而我们知道建立 TCP 连接其实是需要耗费内存(TCP Socket,接收/发送缓存区等需要占用内存)的,客户端和上游服务器要发送数据都需要先发送暂存到到 Nginx 再经由另一端的 TCP 连接传给对方。

所以 Nginx 的负载能力受限于机器I/O,CPU内存等一系列配置,一旦连接很多(比如达到百万)的话,Nginx 抗负载能力就会急遽下降。

经过分析可知 Nginx 的负载能力较差主要是因为它是七层负载均衡器必须要在上下游分别建立两个 TCP 所致,那么是否能设计一个类似路由器那样的只负载转发包但不需要建立连接的负载均衡器呢,这样由于不需要建立连接,只负责转发包,不需要维护额外的 TCP 连接,它的负载能力必然大大提升,于是四层负载均衡器 LVS 就诞生了。

可以看到 LVS 只是单纯地转发包,不需要和上下游建立连接即可转发包,相比于 Nginx 它的抗负载能力强、性能高(能达到 F5 硬件的 60%),对内存和cpu资源消耗比较低。

那么四层负载均衡器是如何工作的呢

负载均衡设备在接收到第一个来自客户端的SYN 请求时,即通过负载均衡算法选择一个最佳的服务器,并对报文中目标IP地址进行修改(改为后端服务器 IP ),直接转发给该服务器。TCP 的连接建立,即三次握手是客户端和服务器直接建立的,负载均衡设备只是起到一个类似路由器的转发动作。在某些部署情况下,为保证服务器回包可以正确返回给负载均衡设备,在转发报文的同时可能还会对报文原来的源地址进行修改。

综上所述,我们在 Nginx 上再加了一层 LVS,以让它来承接我们的所有流量,当然为了保证 LVS 的可用性,我们也采用主备的方式部署 LVS,另外采用这种架构如果 Nginx 容量不够我们可以很方便地进行水平扩容。

当然只有一台 LVS 的话在流量很大的情况下也是扛不住的,怎么办,多加几台啊,使用 DNS 负载均衡,在 DNS 服务器解析域名的时候随机打到其中一台 LVS 不就行了。

通过这样的方式终于可以让流量稳定流转了,有个点可能一些朋友会有疑问,下面我们一起来看看

既然 LVS 可以采用部署多台的形式来避免单点故障,那 Nginx 也可以啊,而且 Nginx 在 1.9 之后也开始支持四层负载均衡了,所以貌似 LVS 不是很有必要?

如果不用 LVS 则架构图是这样的:通过部署多台 Nginx 的方式在流量不是那么大的时候确实是可行,但 LVS 是 Linux 的内核模块,工作在内核态,它直接在内核中处理数据包,避免了用户空间和内核空间切换的开销,它的处理速度非常快,适合极高的并发请求。

而 Nginx 工作在用户态,所以在性能和稳定性上 Nginx 是不如 LVS 的,通过 LVS 进行初级流量分发,然后由 NGINX 进行更复杂的应用层处理,实现了功能和性能的最佳组合。这就是为什么我们要采用 LVS + Nginx 的部署方式。

另外相信大家也注意到了,如果流量很大时,静态资源应该部署在 CDN 上, CDN 会自动选择离用户最近的节点返回给用户。

总结

架构一定要结合业务的实际情况来设计,脱离业务谈架构其实是耍流氓,可以看到上文每一个架构的衍化都与我们的业务发展息息相关,对于中小型流量没有那么大的公司,其实用 Nginx 作为负载均衡足够,在流量迅猛增长后则考虑使用 lvs+nginx,当然像美团这样的巨量流量(数十 Gbps的流量、上千万的并发连接),lvs 也不管用了(实测虽然使用了 lvs 但依然出现了不少丢包的现象)所以它们开发出了自己的一套四层负载均衡器 MGW。

另外看了本文相信大家对分层的概念应该有更透彻的理解,没有什么是分层解决不了的事,如果有,那就再多加一层,分层使每个模块各司其职,功能解藕,而且方便扩展,大家很熟悉的 TCP/IP 就是个很好的例子,每层只管负责自己的事,至于下层是什么实现的上层是不 care 的。

第三章 系统保护机制

3.1 限流技术

3.1.1 限流概述

限流顾名思义,就是对请求或并发数进行限制;通过对一个时间窗口内的请求量进行限制来保障系统的正常运行。如果我们的服务资源有限、处理能力有限,就需要对调用我们服务的上游请求进行限制,以防止自身服务由于资源耗尽而停止服务。

在限流中有两个概念需要了解:

  • 阈值:在一个单位时间内允许的请求量。如 QPS 限制为10,说明 1 秒内最多接受 10 次请求。
  • 拒绝策略:超过阈值的请求的拒绝策略,常见的拒绝策略有直接拒绝、排队等待等。
3.1.2 固定窗口算法

固定窗口算法又叫计数器算法,是一种简单方便的限流算法。主要通过一个支持原子操作的计数器来累计 1 秒内的请求次数,当 1 秒内计数达到限流阈值时触发拒绝策略。每过 1 秒,计数器重置为 0 开始重新计数。

代码实现

下面是简单的代码实现,QPS 限制为 2,这里的代码做了一些优化,并没有单独开一个线程去每隔 1 秒重置计数器,而是在每次调用时进行时间间隔计算来确定是否先重置计数器。

java 复制代码
/**
 * @author https://www.wdbyte.com
 */
public class RateLimiterSimpleWindow {
    // 阈值
    private static Integer QPS = 2;
    // 时间窗口(毫秒)
    private static long TIME_WINDOWS = 1000;
    // 计数器
    private static AtomicInteger REQ_COUNT = new AtomicInteger();
    
    private static long START_TIME = System.currentTimeMillis();

    public synchronized static boolean tryAcquire() {
        if ((System.currentTimeMillis() - START_TIME) > TIME_WINDOWS) {
            REQ_COUNT.set(0);
            START_TIME = System.currentTimeMillis();
        }
        return REQ_COUNT.incrementAndGet() <= QPS;
    }
}

简单修改测试代码,可以进行验证:从输出结果中可以看到大概每秒操作 3 次,由于限制 QPS 为 2,所以平均会有一次被限流。

不过我们思考一下就会发现这种简单的限流方式是有问题的,虽然我们限制了 QPS 为 2,但是当遇到时间窗口的临界突变时,如 1s 中的后 500 ms 和第 2s 的前 500ms 时,虽然是加起来是 1s 时间,却可以被请求 4 次。

3.1.3 滑动窗口算法

我们已经知道固定窗口算法的实现方式以及它所存在的问题,而滑动窗口算法是对固定窗口算法的改进。既然固定窗口算法在遇到时间窗口的临界突变时会有问题,那么我们在遇到下一个时间窗口前也调整时间窗口不就可以了吗?

滑动窗口的示意图中,每 500ms 滑动一次窗口,可以发现窗口滑动的间隔越短,时间窗口的临界突变问题发生的概率也就越小,不过只要有时间窗口的存在,还是有可能发生时间窗口的临界突变问题。

代码实现

下面是基于以上滑动窗口思路实现的简单的滑动窗口限流工具类。

java 复制代码
package com.wdbyte.rate.limiter;

import java.time.LocalTime;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 滑动窗口限流工具类
 *
 * @author https://www.wdbyte.com
 */
public class RateLimiterSlidingWindow {
    /**
     * 阈值
     */
    private int qps = 2;
    /**
     * 时间窗口总大小(毫秒)
     */
    private long windowSize = 1000;
    /**
     * 多少个子窗口
     */
    private Integer windowCount = 10;
    /**
     * 窗口列表
     */
    private WindowInfo[] windowArray = new WindowInfo[windowCount];

    public RateLimiterSlidingWindow(int qps) {
        this.qps = qps;
        long currentTimeMillis = System.currentTimeMillis();
        for (int i = 0; i < windowArray.length; i++) {
            windowArray[i] = new WindowInfo(currentTimeMillis, new AtomicInteger(0));
        }
    }

    /**
     * 1. 计算当前时间窗口
     * 2. 更新当前窗口计数 & 重置过期窗口计数
     * 3. 当前 QPS 是否超过限制
     *
     * @return
     */
    public synchronized boolean tryAcquire() {
        long currentTimeMillis = System.currentTimeMillis();
        // 1. 计算当前时间窗口
        int currentIndex = (int)(currentTimeMillis % windowSize / (windowSize / windowCount));
        // 2.  更新当前窗口计数 & 重置过期窗口计数
        int sum = 0;
        for (int i = 0; i < windowArray.length; i++) {
            WindowInfo windowInfo = windowArray[i];
            if ((currentTimeMillis - windowInfo.getTime()) > windowSize) {
                windowInfo.getNumber().set(0);
                windowInfo.setTime(currentTimeMillis);
            }
            if (currentIndex == i && windowInfo.getNumber().get() < qps) {
                windowInfo.getNumber().incrementAndGet();
            }
            sum = sum + windowInfo.getNumber().get();
        }
        // 3. 当前 QPS 是否超过限制
        return sum <= qps;
    }

    private class WindowInfo {
        // 窗口开始时间
        private Long time;
        // 计数器
        private AtomicInteger number;

        public WindowInfo(long time, AtomicInteger number) {
            this.time = time;
            this.number = number;
        }
        // get...set...
    }
}
3.1.4 滑动日志算法

滑动日志算法是实现限流的另一种方法,这种方法比较简单。基本逻辑就是记录下所有的请求时间点,新请求到来时先判断最近指定时间范围内的请求数量是否超过指定阈值,由此来确定是否达到限流,这种方式没有了时间窗口突变的问题,限流比较准确,但是因为要记录下每次请求的时间点,所以占用的内存较多。

3.1.5 漏桶算法

漏桶算法中的漏桶是一个形象的比喻,这里可以用生产者消费者模式进行说明,请求是一个生产者,每一个请求都如一滴水,请求到来后放到一个队列(漏桶)中,而桶底有一个孔,不断的漏出水滴,就如消费者不断的在消费队列中的内容,消费的速率(漏出的速度)等于限流阈值。即假如 QPS 为 2,则每 1s / 2= 500ms 消费一次。漏桶的桶有大小,就如队列的容量,当请求堆积超过指定容量时,会触发拒绝策略。

由介绍可以知道,漏桶模式中的消费处理总是能以恒定的速度进行,可以很好的保护自身系统不被突如其来的流量冲垮;但是这也是漏桶模式的缺点,假设 QPS 为 2,同时 2 个请求进来,2 个请求并不能同时进行处理响应,因为每 1s / 2= 500ms 只能处理一个请求。

3.1.6 令牌桶算法

令牌桶算法同样是实现限流是一种常见的思路,最为常用的 Google 的 Java 开发工具包 Guava 中的限流工具类 RateLimiter 就是令牌桶的一个实现。令牌桶的实现思路类似于生产者和消费之间的关系。

系统服务作为生产者,按照指定频率向桶(容器)中添加令牌,如 QPS 为 2,每 500ms 向桶中添加一个令牌,如果桶中令牌数量达到阈值,则不再添加。

请求执行作为消费者,每个请求都需要去桶中拿取一个令牌,取到令牌则继续执行;如果桶中无令牌可取,就触发拒绝策略,可以是超时等待,也可以是直接拒绝本次请求,由此达到限流目的。

思考令牌桶的实现可以以下特点:

  1. 1s / 阈值(QPS) = 令牌添加时间间隔。
  2. 桶的容量等于限流的阈值,令牌数量达到阈值时,不再添加。
  3. 可以适应流量突发,N 个请求到来只需要从桶中获取 N 个令牌就可以继续处理。
  4. 有启动过程,令牌桶启动时桶中无令牌,然后按照令牌添加时间间隔添加令牌,若启动时就有阈值数量的请求过来,会因为桶中没有足够的令牌而触发拒绝策略,不过如 RateLimiter 限流工具已经优化了这类问题。
代码实现

Google 的 Java 开发工具包 Guava 中的限流工具类 RateLimiter 就是令牌桶的一个实现,日常开发中我们也不会手动实现了,这里直接使用 RateLimiter 进行测试。

引入依赖:

xml 复制代码
<dependency>
   <groupId>com.google.guava</groupId>
   <artifactId>guava</artifactId>
   <version>31.0.1-jre</version>
</dependency>

RateLimiter 限流体验:

java 复制代码
// qps 2
RateLimiter rateLimiter = RateLimiter.create(2);
for (int i = 0; i < 10; i++) {
    String time = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_TIME);
    System.out.println(time + ":" + rateLimiter.tryAcquire());
    Thread.sleep(250);
}

代码中限制 QPS 为 2,也就是每隔 500ms 生成一个令牌,但是程序每隔 250ms 获取一次令牌,所以两次获取中只有一次会成功。

3.1.7 Redis分布式限流

Redis 是一个开源的内存数据库,可以用来作为数据库、缓存、消息中间件等。Redis 是单线程的,又在内存中操作,所以速度极快,得益于 Redis 的各种特性,所以使用 Redis 实现一个限流工具是十分方便的。

固定窗口限流

Redis 中的固定窗口限流是使用 incr 命令实现的,incr 命令通常用来自增计数;如果我们使用时间戳信息作为 key,自然就可以统计每秒的请求量了,以此达到限流目的。

这里有两点要注意:

  1. 对于不存在的 key,第一次新增时,value 始终为 1。
  2. INCR 和 EXPIRE 命令操作应该在一个原子操作中提交,以保证每个 key 都正确设置了过期时间,不然会有 key 值无法自动删除而导致的内存溢出。

由于 Redis 中实现事务的复杂性,所以这里直接只用 lua 脚本来实现原子操作。

lua 复制代码
local count = redis.call("incr",KEYS[1])
if count == 1 then
  redis.call('expire',KEYS[1],ARGV[2])
end
if count > tonumber(ARGV[1]) then
  return 0
end
return 1
滑动窗口限流

通过对上面的基于 incr 命令实现的 Redis 限流方式的测试,我们已经发现了固定窗口限流所带来的问题,滑动窗口限流它可以大幅度降低因为窗口临界突变带来的问题,那么如何使用 Redis 来实现滑动窗口限流呢?

这里主要使用 ZSET 有序集合来实现滑动窗口限流,ZSET 集合有下面几个特点:

  1. ZSET 集合中的 key 值可以自动排序。
  2. ZSET 集合中的 value 不能有重复值。
  3. ZSET 集合可以方便的使用 ZCARD 命令获取元素个数。
  4. ZSET 集合可以方便的使用 ZREMRANGEBYLEX 命令移除指定范围的 key 值。

基于上面的四点特性,可以编写出基于 ZSET 的滑动窗口限流 lua 脚本。

lua 复制代码
--KEYS[1]: 限流 key
--ARGV[1]: 时间戳 - 时间窗口
--ARGV[2]: 当前时间戳(作为score)
--ARGV[3]: 阈值
--ARGV[4]: score 对应的唯一value
-- 1. 移除时间窗口之前的数据
redis.call('zremrangeByScore', KEYS[1], 0, ARGV[1])
-- 2. 统计当前元素数量
local res = redis.call('zcard', KEYS[1])
-- 3. 是否超过阈值
if (res == nil) or (res < tonumber(ARGV[3])) then
    redis.call('zadd', KEYS[1], ARGV[2], ARGV[4])
    return 1
else
    return 0
end
3.1.8 总结

这篇文章介绍实现限流的几种方式,主要是窗口算法和桶算法,两者各有优势。

  • 窗口算法实现简单,逻辑清晰,可以很直观的得到当前的 QPS 情况,但是会有时间窗口的临界突变问题,而且不像桶一样有队列可以缓冲。
  • 桶算法虽然稍微复杂,不好统计 QPS 情况,但是桶算法也有优势所在。
    • 漏桶模式消费速率恒定,可以很好的保护自身系统,可以对流量进行整形,但是面对突发流量不能快速响应。
    • 令牌桶模式可以面对突发流量,但是启动时会有缓慢加速的过程,不过常见的开源工具中已经对此优化。

单机限流与分布式限流

上面演示的基于代码形式的窗口算法和桶算法限流都适用于单机限流,如果需要分布式限流可以结合注册中心、负载均衡计算每个服务的限流阈值,但这样会降低一定精度,如果对精度要求不是太高,可以使用。

而 Redis 的限流,由于 Redis 的单机性,本身就可以用于分布式限流。使用 Redis 可以实现各种可以用于限流算法,如果觉得麻烦也可以使用开源工具如 redisson,已经封装了基于 Redis 的限流。

其他限流工具

文中已经提到了 Guava 的限流工具包,不过它毕竟是单机的,开源社区中也有很多分布式限流工具,如阿里开源的 Sentinel 就是不错的工具,Sentinel 以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性。

3.2 降级、熔断与全链路压测

3.2.1 服务降级
服务降级的常见场景
  1. 系统负载过高:在高峰期或者流量激增的情况下,为了防止系统崩溃,可以暂时关闭或降低某些非关键服务的质量。
  2. 依赖服务故障:当某个依赖服务不可用时,通过服务降级可以提供替代方案或者简化的功能,以确保系统核心功能的正常运作。
  3. 网络问题:在网络延迟或不稳定的情况下,降低某些服务的请求频率或数据量,以减轻系统负担。
  4. 资源不足:当系统资源(如 CPU、内存)不足时,通过服务降级减少资源消耗。
服务降级的策略
  1. 功能降级:暂时关闭或简化某些非关键功能。例如,关闭推荐系统、日志记录等不影响核心功能的服务。
  2. 数据降级:降低数据返回的粒度或数量。例如,从详细数据切换为概要数据。
  3. 延迟处理:将非紧急的任务延迟处理或批量处理。例如,将某些后台任务延迟执行,减轻当前系统负担。
  4. 限流:限制部分用户的访问,优先保证核心用户或付费用户的服务质量。
实施服务降级的技术手段
  1. 熔断器模式(Circuit Breaker Pattern):当检测到某个服务不可用时,主动切断对该服务的调用,避免影响其他服务。
  2. 限流器(Rate Limiter):限制单位时间内的请求数量,防止系统过载。
  3. 降级开关:通过配置中心动态调整服务降级策略,灵活应对不同场景。
  4. 缓存:使用缓存减轻数据库或其他后端服务的压力,提供快速响应。
3.2.2 服务熔断
熔断器工作流程
  1. 请求通过熔断器

    • 每个请求通过熔断器进行计数,监控其成功或失败状态。
    • 如果请求成功,计数器重置。
    • 如果请求失败,计数器记录失败。
  2. 监控和评估

    • Hystrix 会监控一段时间内(例如 10 秒)的请求数量和失败率。
    • 如果在这段时间内请求数量超过设定的最小请求数,并且失败率超过设定的阈值,熔断器将跳到打开状态。
  3. 短路请求

    • 在打开状态下,所有请求都会被短路,不会真正调用下游服务,而是立即返回一个预设的降级响应。
    • 这样可以防止下游服务的故障影响到整个系统,并减轻下游服务的负载。
  4. 尝试恢复

    • 在打开状态持续一段时间后,熔断器进入半开状态,允许少量请求通过。
    • 如果这些请求成功,熔断器将关闭,恢复正常请求。
    • 如果这些请求失败,熔断器将重新打开,并继续短路请求。
3.2.3 服务灰度发布
优点
  • 降低风险:通过逐步发布新版本,团队可以在小范围内验证新功能的有效性,降低全量发布带来的风险。
  • 快速反馈:在发布过程中,可以迅速收集用户反馈和监控数据,及时发现和解决问题。
  • 持续交付:灰度发布与持续交付和持续部署实践相结合,可以实现快速、频繁和稳定的发布。
  • 平滑过渡:避免了大规模发布带来的潜在问题,确保系统平稳运行。
服务灰度发布的工具
  1. 服务网关:如 NGINX、Kong 等,可以配置流量路由规则,实现灰度发布。
  2. 容器编排:如 Kubernetes,可以通过配置滚动更新和分阶段发布策略实现灰度发布。
  3. 服务网格:如 Istio、Linkerd,可以细粒度地控制服务间的流量,实现灰度发布。
3.2.4 全链路压测

全链路压测是一种系统性的性能测试方法,通过模拟真实的业务场景和用户行为,对整个系统进行压力测试,以验证系统在高负载下的性能表现和稳定性。

全链路压测的目标是:

  1. 验证系统容量:确定系统在不同负载下的性能边界。
  2. 发现瓶颈:识别系统中的性能瓶颈和潜在问题。
  3. 验证扩容策略:验证系统的水平扩展能力。
  4. 提升系统稳定性:通过压测发现并解决系统稳定性问题。

第四章 分布式一致性协议

4.1 理论基础

4.1.1 CAP定理

CAP 理论告诉我们,一个分布式系统不可能同时满足一致性(C:Consistency),可用性(A: Availability)和分区容错性(P:Partition tolerance)这三个基本需求,最多只能同时满足其中的2个。

4.1.2 BASE理论

BASE:全称:Basically Available(基本可用),Soft state(软状态),和 Eventually consistent(最终一致性)。

Base 理论是对 CAP 中一致性和可用性权衡的结果,其来源于对大型互联网分布式实践的总结,是基于 CAP 定理逐步演化而来的。其核心思想是:既是无法做到强一致性(Strong consistency),但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency)。

解释一下:什么是软状态呢?相对于原子性而言,要求多个节点的数据副本都是一致的,这是一种 "硬状态"。软状态指的是:允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。

4.2 二阶段提交协议(2PC)

二阶段提交协议(Two-phase Commit,即 2PC)是常用的分布式事务解决方案,即将事务的提交过程分为两个阶段来进行处理。在阶段二中,会根据阶段一的投票结果执行 2 种操作:执行事务提交,中断事务。

4.2.1 角色

① 协调者:事务的发起者

② 参与者:事务的执行者

4.2.2 阶段一
  • 事务询问:协调者向所有的参与者询问,是否准备好了执行事务,并开始等待各参与者的响应。
  • 执行事务:各参与者节点执行事务操作,并将 Undo 和 Redo 信息记入事务日志中。
  • 各参与者向协调者反馈事务询问的响应:如果参与者成功执行了事务操作,那么就反馈给协调者 Yes 响应,表示事务可以执行;如果参与者没有成功执行事务,就返回 No 给协调者,表示事务不可以执行。
4.2.3 阶段二

在阶段二中,会根据阶段一的投票结果执行 2 种操作:执行事务提交,中断事务。

执行事务提交步骤

  • 发送提交请求:协调者向所有参与者发出 commit 请求。
  • 事务提交:参与者收到 commit 请求后,会正式执行事务提交操作,并在完成提交之后释放整个事务执行期间占用的事务资源。
  • 反馈事务提交结果:参与者在完成事务提交之后,向协调者发送 Ack 信息。
  • 协调者接收到所有参与者反馈的 Ack 信息后,完成事务。

执行中断事务步骤

  • 发送回滚请求:协调者向所有参与者发出 Rollback 请求。
  • 事务回滚:参与者接收到 Rollback 请求后,会利用其在阶段一种记录的 Undo 信息来执行事务回滚操作,并在完成回滚之后释放在整个事务执行期间占用的资源。
  • 反馈事务回滚结果:参与者在完成事务回滚之后,想协调者发送 Ack 信息。
  • 中断事务:协调者接收到所有参与者反馈的 Ack 信息后,完成事务中断。
4.2.4 二阶段提交缺点

二阶段提交看起来确实能够提供原子性的操作,但是不幸的事,二阶段提交还是有几个缺点的:

  • 阻塞问题: 2PC的提交在执行过程中,所有参与事务操作的逻辑都处于阻塞状态,也就是说,各个参与者都在等待其他参与者响应,无法进行其他操作;
  • 单点问题: 协调者是个单点,一旦出现问题,其他参与者将无法释放事务资源,也无法完成事务操作;
  • 数据不一致:当执行事务提交过程中,如果协调者向所有参与者发送Commit请求后,发生局部网络异常或者协调者在尚未发送完Commit请求,即出现崩溃,最终导致只有部分参与者收到、执行请求。于是整个系统将会出现数据不一致的情形;
  • 保守:2PC没有完善的容错机制,当参与者出现故障时,协调者无法快速得知这一失败,只能严格依赖超时设置来决定是否进一步的执行提交还是中断事务。

4.3 三阶段提交协议(3PC)

三阶段提交(Three-phase commit),也叫三阶段提交协议(Three-phase commit protocol),是二阶段提交(2PC)的改进版本。

与两阶段提交不同的是,三阶段提交有两个改动点:

  • 引入超时机制。同时在协调者和参与者中都引入超时机制。
  • 在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。

也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段。

4.3.1 CanCommit阶段

3PC的CanCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。

  1. 事务询问 协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
  2. 响应反馈 参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No。
4.3.2 PreCommit阶段

协调者根据canCommit阶段参与者的反应情况来决定是否可以继续事务的PreCommit操作。根据响应情况,有以下两种可能。

假如协调者在CanCommit阶段从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行。

  1. 发送预提交请求: 协调者向参与者发送PreCommit请求,并进入Prepared阶段。
  2. 事务预提交: 参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。
  3. 响应反馈: 如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。

假如canCommit阶段有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。

  1. 发送中断请求: 协调者向所有参与者发送abort请求。
  2. 中断事务: 参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
4.3.3 DoCommit阶段

该阶段进行真正的事务提交,也可以分为以下两种情况。

执行提交

  1. 发送提交请求: 协调接在preCommit阶段收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送doCommit请求。
  2. 事务提交: 参与者接收到doCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
  3. 响应反馈: 事务提交完之后,向协调者发送Ack响应。
  4. 完成事务: 协调者接收到所有参与者的ack响应之后,完成事务。

中断事务

协调者在preCommit阶段没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。

  1. 发送中断请求: 协调者向所有参与者发送abort请求
  2. 事务回滚: 参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
  3. 反馈结果: 参与者完成事务回滚之后,向协调者发送ACK消息4.中断事务 协调者接收到参与者反馈的ACK消息之后,执行事务的中断。

在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者abort请求时,会在等待超时之后,会继续进行事务的提交。(其实这个应该是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了PreCommit请求,那么协调者产生PreCommit请求的前提条件是他在第二阶段开始之前,收到所有参与者的CanCommit响应都是Yes。(一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了)所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到commit或者abort响应,但是他有理由相信:成功提交的几率很大。)

4.3.4 三阶段提交优缺点

3PC有效降低了2PC带来的参与者阻塞范围,并且能够在出现单点故障后继续达成一致;

但3PC带来了新的问题,在参与者收到preCommit消息后,如果网络出现分区,协调者和参与者无法进行后续的通信,这种情况下,参与者在等待超时后,依旧会执行事务提交,这样会导致数据的不一致。

4.4 Paxos算法

像 2PC 和 3PC 都需要引入一个协调者的角色,当协调者 down掉之后,整个事务都无法提交,参与者的资源都出于锁定的状态,对于系统的影响是灾难性的,而且出现网络分区的情况,很有可能会出现数据不一致的情况。有没有不需要协调者角色,每个参与者来协调事务呢,在网络分区的情况下,又能最大程度保证一致性的解决方案呢。此时Paxos出现了。

Paxos 算法是 Lamport 于 1990 年提出的一种基于消息传递的一致性算法。由于算法难以理解起初并没有引起人们的重视,Lamport在八年后重新发表,即便如此Paxos算法还是没有得到重视。2006年 Google 的三篇论文石破天惊,其中的 chubby 锁服务使用Paxos 作为 chubbycell 中的一致性,后来才得到关注。

Paxos 协议是一个解决分布式系统中,多个节点之间就某个值(提案)达成一致(决议)的通信协议。它能够处理在少数节点离线的情况下,剩余的多数节点仍然能够达成一致。即每个节点,既是参与者,也是决策者。

4.4.1 Paxos角色

Paxos 协议的角色 主要有三类节点:

  • 提议者(Proposer):提议一个值;
  • 接受者(Acceptor):对每个提议进行投票;
  • 告知者(Learner):被告知投票的结果,不参与投票过程。
4.4.2 算法描述

第一阶段

b: 如果Acceptor收到的Prepare请求的编号Mn > 其己答复的任何Prepare请求的编号,则Acceptor对该请求作出答复,并承诺不接受任何编号小于編号Mn的提案。

第二阶段

4.4.3 Paxos算法运行示例

正常情况的提案选择

当系统运行正常时,提案的选择过程相对简单,大多数节点能够正常通信并达成一致。

情况1:S3先Accept S1的值,已返回Accept的ack,再见到S5的提案

关键点:S3也接到了S5的prepare提案,这时是否会有不一致的情况呢?

S3会把之前已接收的提案编号1和值x答复给S5,S5会替换Y为X然后应用编号2,x进行广播。

情况2:S3先Accept S1的值,再见到S5的提案,再返回Accept的ack

关键点:S3也接到了S5 prepare提案,这时是否会有不一致的情况呢?

在这种情况下,由于Paxos算法的承诺机制,S3会优先处理编号更高的提案。

情况3:S3还未经历Accept阶段时,就拿到了S5的prepare提案

关键点:S3还未经历Accept阶段时,就拿到了S5的prepare提案,这时是否会有不一致的情况呢?

这种情况下S1的提案会应用失败,需要重新发起新的一轮提案。

情况4:形成活锁

原始的Paxos算法(Basic Paxos)只能对一个值形成决议,决议的形成至少需要两次网络来回,在高并发情况下可能需要更多的网络来回,极端情况下甚至可能形成活锁。

Paxos是允许多个Proposer的,因此如果后一个提案总会让前面提案选中失败,就会出现死循环的情况。

4.4.4 Paxos算法特点
  1. 容错性强:能够处理少数节点故障的情况
  2. 理论完备:为分布式一致性提供了坚实的理论基础
  3. 实现复杂:算法理解和工程实现都比较困难
  4. 可能活锁:在极端情况下可能出现提案永远无法通过的情况

4.5 Raft算法

4.5.1 Raft协议概述

Paxos是论证了一致性协议的可行性,但是论证的过程据说晦涩难懂,缺少必要的实现细节,而且工程实现难度比较高,广为人知实现只有zk的实现zab协议。

Paxos协议的出现为分布式强一致性提供了很好的理论基础,但是Paxos协议理解起来较为困难,实现比较复杂。

然后斯坦福大学RamCloud项目中提出了易实现,易理解的分布式一致性复制协议Raft。Java,C++,Go等都有其对应的实现。

相比Paxos,Raft相对要简洁很多,引入主节点,通过竞选机制选择Leader。

4.5.2 Raft角色和基本概念

节点类型

  • Leader(主节点):接受client更新请求,写入本地后,然后同步到其他副本中
  • Follower(从节点):从Leader中接受更新请求,然后写入本地日志文件。对客户端提供读请求
  • Candidate(候选节点):如果follower在一段时间内未收到leader心跳,则判断leader可能故障,发起选主提议。节点状态从Follower变为Candidate状态,直到选主结束

基本概念

  • termId:任期号,时间被划分成一个个任期,每次选举后都会产生一个新的termId,一个任期内只有一个leader。termId相当于paxos的proposalId
  • RequestVote:请求投票,candidate在选举过程中发起,收到quorum(多数派)响应后,成为leader
  • AppendEntries:附加日志,leader发送日志和心跳的机制
  • election timeout:选举超时,如果follower在一段时间内没有收到任何消息(追加日志或者心跳),就是选举超时
4.5.3 Raft工作流程

Leader选举

Leader会周期性的发送心跳包给Follower。每个Follower都设置了一个随机的竞选超时时间,一般为150ms~300ms,如果在这个时间内没有收到Leader的心跳包,就会变成Candidate,进入竞选阶段。

  1. Follower检测不到Leader心跳,转为Candidate状态
  2. Candidate增加自己的term,投票给自己,并向其他节点发送RequestVote请求
  3. 如果获得多数派投票,成为Leader
  4. 如果收到其他Leader的心跳,转为Follower
  5. 如果选举超时,重新开始选举

日志复制

  1. Leader接收客户端请求
  2. Leader将日志条目追加到本地日志
  3. Leader向所有Follower发送AppendEntries请求
  4. 当多数派Follower确认后,Leader提交该日志条目
  5. 在下次心跳中通知Follower提交该日志条目

第五章 限流技术详解

5.1 限流技术概述

限流顾名思义,就是对请求或并发数进行限制;通过对一个时间窗口内的请求量进行限制来保障系统的正常运行。如果我们的服务资源有限、处理能力有限,就需要对调用我们服务的上游请求进行限制,以防止自身服务由于资源耗尽而停止服务。

在限流中有两个概念需要了解:

  • 阈值:在一个单位时间内允许的请求量。如QPS限制为10,说明1秒内最多接受10次请求。
  • 拒绝策略:超过阈值的请求的拒绝策略,常见的拒绝策略有直接拒绝、排队等待等。

5.2 滑动日志算法

滑动日志算法是实现限流的另一种方法,这种方法比较简单。基本逻辑就是记录下所有的请求时间点,新请求到来时先判断最近指定时间范围内的请求数量是否超过指定阈值,由此来确定是否达到限流,这种方式没有了时间窗口突变的问题,限流比较准确,但是因为要记录下每次请求的时间点,所以占用的内存较多。

5.3 Redis分布式限流详解

5.3.1 Redis固定窗口限流

Redis中的固定窗口限流是使用incr命令实现的,incr命令通常用来自增计数;如果我们使用时间戳信息作为key,自然就可以统计每秒的请求量了,以此达到限流目的。

这里有两点要注意:

  1. 对于不存在的key,第一次新增时,value始终为1。
  2. INCR和EXPIRE命令操作应该在一个原子操作中提交,以保证每个key都正确设置了过期时间,不然会有key值无法自动删除而导致的内存溢出。

由于Redis中实现事务的复杂性,所以这里直接只用lua脚本来实现原子操作。

lua 复制代码
local count = redis.call("incr",KEYS[1])
if count == 1 then
  redis.call('expire',KEYS[1],ARGV[2])
end
if count > tonumber(ARGV[1]) then
  return 0
end
return 1
5.3.2 Redis滑动窗口限流

通过对上面的基于incr命令实现的Redis限流方式的测试,我们已经发现了固定窗口限流所带来的问题,滑动窗口限流它可以大幅度降低因为窗口临界突变带来的问题,那么如何使用Redis来实现滑动窗口限流呢?

这里主要使用ZSET有序集合来实现滑动窗口限流,ZSET集合有下面几个特点:

  1. ZSET集合中的key值可以自动排序。
  2. ZSET集合中的value不能有重复值。
  3. ZSET集合可以方便的使用ZCARD命令获取元素个数。
  4. ZSET集合可以方便的使用ZREMRANGEBYLEX命令移除指定范围的key值。

基于上面的四点特性,可以编写出基于ZSET的滑动窗口限流lua脚本。

lua 复制代码
--KEYS[1]: 限流key
--ARGV[1]: 时间戳 - 时间窗口
--ARGV[2]: 当前时间戳(作为score)
--ARGV[3]: 阈值
--ARGV[4]: score对应的唯一value
-- 1. 移除时间窗口之前的数据
redis.call('zremrangeByScore', KEYS[1], 0, ARGV[1])
-- 2. 统计当前元素数量
local res = redis.call('zcard', KEYS[1])
-- 3. 是否超过阈值
if (res == nil) or (res < tonumber(ARGV[3])) then
    redis.call('zadd', KEYS[1], ARGV[2], ARGV[4])
    return 1
else
    return 0
end

5.4 限流技术总结

这篇文章介绍实现限流的几种方式,主要是窗口算法和桶算法,两者各有优势。

  • 窗口算法:实现简单,逻辑清晰,可以很直观的得到当前的QPS情况,但是会有时间窗口的临界突变问题,而且不像桶一样有队列可以缓冲。
  • 桶算法 :虽然稍微复杂,不好统计QPS情况,但是桶算法也有优势所在。
    • 漏桶模式消费速率恒定,可以很好的保护自身系统,可以对流量进行整形,但是面对突发流量不能快速响应。
    • 令牌桶模式可以面对突发流量,但是启动时会有缓慢加速的过程,不过常见的开源工具中已经对此优化。

单机限流与分布式限流

上面演示的基于代码形式的窗口算法和桶算法限流都适用于单机限流,如果需要分布式限流可以结合注册中心、负载均衡计算每个服务的限流阈值,但这样会降低一定精度,如果对精度要求不是太高,可以使用。

而Redis的限流,由于Redis的单机性,本身就可以用于分布式限流。使用Redis可以实现各种可以用于限流算法,如果觉得麻烦也可以使用开源工具如redisson,已经封装了基于Redis的限流。

第六章 服务降级与熔断

6.1 服务降级详解

6.1.1 服务降级的常见场景
  1. 系统负载过高:在高峰期或者流量激增的情况下,为了防止系统崩溃,可以暂时关闭或降低某些非关键服务的质量。
  2. 依赖服务故障:当某个依赖服务不可用时,通过服务降级可以提供替代方案或者简化的功能,以确保系统核心功能的正常运作。
  3. 网络问题:在网络延迟或不稳定的情况下,降低某些服务的请求频率或数据量,以减轻系统负担。
  4. 资源不足:当系统资源(如CPU、内存)不足时,通过服务降级减少资源消耗。
6.1.2 服务降级的策略
  1. 功能降级:暂时关闭或简化某些非关键功能。例如,关闭推荐系统、日志记录等不影响核心功能的服务。
  2. 数据降级:降低数据返回的粒度或数量。例如,从详细数据切换为概要数据。
  3. 延迟处理:将非紧急的任务延迟处理或批量处理。例如,将某些后台任务延迟执行,减轻当前系统负担。
  4. 限流:限制部分用户的访问,优先保证核心用户或付费用户的服务质量。
6.1.3 实施服务降级的技术手段
  1. 熔断器模式(Circuit Breaker Pattern):当检测到某个服务不可用时,主动切断对该服务的调用,避免影响其他服务。
  2. 限流器(Rate Limiter):限制单位时间内的请求数量,防止系统过载。
  3. 降级开关:通过配置中心动态调整服务降级策略,灵活应对不同场景。
  4. 缓存:使用缓存减轻数据库或其他后端服务的压力,提供快速响应。

6.2 服务熔断机制

6.2.1 熔断器工作流程
  1. 请求通过熔断器

    • 每个请求通过熔断器进行计数,监控其成功或失败状态。
    • 如果请求成功,计数器重置。
    • 如果请求失败,计数器记录失败。
  2. 监控和评估

    • Hystrix会监控一段时间内(例如10秒)的请求数量和失败率。
    • 如果在这段时间内请求数量超过设定的最小请求数,并且失败率超过设定的阈值,熔断器将跳到打开状态。
  3. 短路请求

    • 在打开状态下,所有请求都会被短路,不会真正调用下游服务,而是立即返回一个预设的降级响应。
    • 这样可以防止下游服务的故障影响到整个系统,并减轻下游服务的负载。
  4. 尝试恢复

    • 在打开状态持续一段时间后,熔断器进入半开状态,允许少量请求通过。
    • 如果这些请求成功,熔断器将关闭,恢复正常请求。
    • 如果这些请求失败,熔断器将重新打开,并继续短路请求。

6.3 服务灰度发布

6.3.1 灰度发布概述

服务灰度发布是一种渐进式发布策略,通过逐步将新版本的服务部署到生产环境中,先在小范围内验证新功能的稳定性和正确性,然后逐步扩大发布范围,最终实现全量发布。

6.3.2 灰度发布优点
  • 降低风险:通过逐步发布新版本,团队可以在小范围内验证新功能的有效性,降低全量发布带来的风险。
  • 快速反馈:在发布过程中,可以迅速收集用户反馈和监控数据,及时发现和解决问题。
  • 持续交付:灰度发布与持续交付和持续部署实践相结合,可以实现快速、频繁和稳定的发布。
  • 平滑过渡:避免了大规模发布带来的潜在问题,确保系统平稳运行。
6.3.3 服务灰度发布的工具
  1. 服务网关:如NGINX、Kong等,可以配置流量路由规则,实现灰度发布。
  2. 容器编排:如Kubernetes,可以通过配置滚动更新和分阶段发布策略实现灰度发布。
  3. 服务网格:如Istio、Linkerd,可以细粒度地控制服务间的流量,实现灰度发布。

6.4 全链路压测

全链路压测是一种系统性的性能测试方法,通过模拟真实的业务场景和用户行为,对整个系统进行压力测试,以验证系统在高负载下的性能表现和稳定性。

全链路压测的目标是:

  1. 验证系统容量:确定系统在不同负载下的性能边界。
  2. 发现瓶颈:识别系统中的性能瓶颈和潜在问题。
  3. 验证扩容策略:验证系统的水平扩展能力。
  4. 提升系统稳定性:通过压测发现并解决系统稳定性问题。

第七章 NGINX高可用配置

7.1 NGINX备份故障转移原理

7.1.1 关键参数
  • backup:将服务器标记为备份服务器。当主服务器不可用时,请求将被传递给它。
  • proxy_next_upstream:默认值为error timeout,用来定义哪些场景配置为不成功的尝试(unsuccessful attempts)。
  • proxy_next_upstream_tries:用来定义不成功的尝试(unsuccessful attempts)的尝试次数,0表示不限制,尝试所有upstream的server。
  • max_fails和fail_timeout:这些参数用于定义一个服务器被认为是不可用之前可以失败的次数(max_fails)以及在被认为是不可用之后多长时间内不再尝试该服务器(fail_timeout)。
7.1.2 故障转移配置示例
nginx 复制代码
http {
    upstream demo {
        server 127.0.0.1:8001;  # 默认: max_fails=1 fail_timeout=10s
        server 127.0.0.1:8002 backup;  # 默认: max_fails=1 fail_timeout=10s
    }

    server {
        listen       8000;
        server_name  localhost;
        location / {
            proxy_next_upstream error timeout http_500; # 哪些错误认为是不成功的尝试
            proxy_next_upstream_tries 2;  # 尝试下一个server的次数,默认为0不限制
            proxy_pass http://demo; 
        }
    }
}
7.1.3 故障转移场景
  1. 连接超时(timeout)

    • NG与后端服务器无法在proxy_connect_timeout内建立连接
    • 读/写操作超过proxy_read_timeout或proxy_send_timeout时发生
  2. 连接错误(error)

    • 当服务器端口未监听或被防火墙拦截时发生
  3. HTTP 5xx错误

    • 503 Service Unavailable:服务器资源过载,由于不可预见的流量峰值或恶意DDoS攻击,当服务器资源达到极限时导致服务中断;应用程序限流等情况主动配置返回503
    • 500 Internal Server Error:服务器遇到了一个未曾预料的状况,导致了它无法完成对请求的处理
    • 502 Bad Gateway:NG作为网关或者代理工作的服务器尝试执行请求时,从上游服务器接收到无效的响应
7.1.4 故障转移逻辑
  1. 当主服务器因上述逻辑不可用(proxy_next_upstream定义)时,如果没超过proxy_next_upstream_tries将尝试访问backup服务器。
  2. 当一个服务器在fail_timeout时间范围内连续失败次数达到max_fails时,该服务器会被标记为down。在此期间,请求将直接转发到backup服务器。
  3. 一旦fail_timeout时间过去,将重新开始尝试访问主服务器。

参考链接

高可用基础理论

分布式一致性协议

服务治理

限流技术

负载均衡技术

相关推荐
秋千码途2 分钟前
小架构step系列19:请求和响应
架构
shinelord明2 小时前
【计算机网络架构】树型架构简介
计算机网络·架构·计算机科学·计算机科学与技术
杨进军2 小时前
微前端之子应用的启动与改造
前端·架构
俞凡4 小时前
Netflix 数据网关实践
架构
_花卷4 小时前
🌟ELPIS-如何基于vue3完成领域模型架构
前端·vue.js·架构
司铭鸿6 小时前
Java无服务架构新范式:Spring Native与AWS Lambda冷启动深度优化
数据结构·算法·架构·排序算法·代理模式
小哈里6 小时前
【管理】持续交付2.0:业务引领的DevOps-精要增订本,读书笔记(理论模型,技术架构,业务价值)
运维·架构·devops·管理·交付
曾经的三心草9 小时前
微服务的编程测评系统-网关-身份认证-redis-jwt
redis·微服务·架构
cui_win10 小时前
Kafka 控制器(Controller)详解:架构、原理与实战
分布式·架构·kafka