学习极客时间-左耳听风专栏的个人学习记录。

弹力设计

索引:

  • 认识故障和弹力设计
  • 隔离设计
  • 异步通讯设计
  • 幂等性设计
  • 服务的状态
  • 补偿事务
  • 重试设计
  • 熔断设计
  • 限流设计
  • 降级设计
  • 总结

认识故障和弹力设计-认识篇

简介

本大章讲【弹力设计】,其实也就是【容错设计】(保障的重点就是【系统可用性】),主要包含:

  • 容错能力
    • 服务隔离:A服务挂了不影响B服务
    • 异步调用(通过上下游解耦减少因下游异常导致的上层故障、不可用的情况)
    • 请求幂等性(即确保同个请求发送多次的效果和一次是一样的),这样用户在没收到确认返回时,可以安全重试
  • 可伸缩性(有/无状态的服务)
  • 一致性(事务补偿、重试)
  • 应对大流量的能力(熔断、降级)

可用性测量

有个工业界普遍采用的公式: Availability=MTTF/(MTTF+MTTR)

MTTF(Mean Time To Failure):平均故障前的时间,也就是正常运行的时间(不要被Failure所迷感) MTTR(Mean Time To Recover):发生故障后的恢复时间

故障原因

  1. 分类一:计划性
  • 无计划性
    • 系统级故障
    • 数据或中介故障
    • 自然灾害、人为破坏
  • 有计划性
    • 日常:备份、批处理
    • 系统维护
    • 升级
  1. 分类二:问题类别归类
  • 网络: 连接、带宽等
  • 性能: CPU、慢SQL、内存、Java Full GC、IO等
  • 安全: 网络攻击等
  • 运维: 系统更新等
  • 管理: 没有梳理出关键服务及服务间依赖关系等
  • 硬件: 硬盘、 网卡、断电、交换机、挖掘机问题

故障不可避免

尤其是分布式系统,故障是常见的、突发难缠。所以我们的目标是降低MTTR。在设计编码阶段,就要Design For Failure。

弹力设计之隔离设计

介绍

隔离设计(Bulkheads),中文翻译为隔板(一般船上用隔板将空间隔离成多个封闭空间,防止有漏水等情况出现的灾难蔓延)

隔离方式分类:

  • 以服务的种类
  • 以用户的请求

以服务种类划分

比如,在一个购物系统中分别将【用户】、【商品】、【社区】三个板­块分开,即每个板块有自己的域名、服务器、数据库(也就是接入层、应用层、数据层)。这样,一个板块挂掉后,不会影响其他板块。这也是微服务的思想,每个服务做好对外暴露。

但,也存在以下这些问题;

  • 一个处理需跨服务时,响应时间会更长从而降低性能(因此, 需要避免在同一个服务中涉及太多请求)
  • 涉及到多个服务关联的请求,一个服务挂了,这个请求依然走不下去(最好可做到 step-by-step,分步保存。这样恢复后可以从失败的节点开始即可)
  • 如果有大数据平台, 需要抽取各模块数据进行外理,过程也较复杂
  • 如果涉及较复杂的跨服务的交互,需要使用Pub/Sub(发布/订阅)模式的持久化消息传递机制辅助数据交换
  • 多服务之间的分布式事务问题(二阶段提交等方式解决)

以用户请求划分

概念:将用户分为多个组;每个组分配单独的实例。这样,当某个服务实例挂掉,不会影响其他实例。例如,对于比较重要的客户,可用专门独立的实例。

这也是所谓的“多租户”模式,一般涉及三种程序的隔离:

  1. 数据 + 服务隔离(完全独立):成本高,资源浪费
  2. 数据隔离 + 服务共享(折中):一般选择折中方案
  3. 数据 + 服务共享(完全共享):成本低,但开发难度大、隔离性不好

隔离设计的重点

  • 确定合适的隔离粒度
  • 考虑成本、性能、复杂度、资源使用等,找到一个均衡方案
  • 隔离技术需配合一些高可用、异步、中间件、熔断等技术一起使用
  • 运维和监控的配合,能力提升

异步通讯设计

同步/异步通讯

一个形象的类比:

  • 同步 → 打电话
  • 异步 → 发邮件
    人不能接多个电话,但可以同时收多封邮件。

【同步】有哪些缺陷(对应来说就是【异步】的优势):

  1. 被调用方的吞吐量要不小于调用方的,整个同步调用链的性能由最慢的那个服务决定
  2. 同步链的所有经过的服务都需保存现场(context)等待远端返回,因此高并发场景下极度耗资源
  3. 同步只能一对一,不能一对多
  4. 同步链上一方故障导致的多米诺骨牌效应(异步可解耦服务)

异步通讯三种方式

请求响应式

Sender → Receiver
返回结果两种方式获得:
- Sender 轮询 Receiver : 是否已处理完成 - Sender 注册一个回调方法,Receiver 处理完调回调方法通知 Sender (例子:商家 → 支付页 → 跳回商家)

这种方式有个很大的缺陷就是服务之间耦合度太高

订阅方式(常用语MVC):一定程度解耦

Sender ← 订阅 ← Receiver(订阅方)
消息关系 : Sender 把消息 → 订阅队列 → Receiver 获取

重点:Sender 消息发过去就好,不关心这事干得咋样

举例:

  1. 订单系统 发支付信息 → 支付信息队列(第三方订阅)

  2. 支付信息队列 发信息 → 支付第三方

  3. 第三方处理后,发支付成功信息 → 支付结果队列 (订单系统订阅)

  4. 支付结果队列 发信息 → 订单系统

    优势:通信依靠消息,一定程度的解耦
    但非完全解耦,因为发送方是直接给订阅方发订阅事件

通过Broker方式:完全解耦

Sender 发布 → Broker(中间人) ← 订阅 Receiver

Sender 与 Receiver 之间没有直接关联

要求 Broker 满足:

  • 高可用
  • 可水平扩展,高性能
  • 可持久化不丢数据

事件驱动设计

第二、三种方式属于事件驱动架构,需要一系列消息通道。各服务做完发出事件,由订阅事件的服务接收做关联处理。

优势:

  • 服务间低依赖
  • 服务的开发运维等高度隔离
  • 服务间通过事件关联,不会阻塞住
  • 服务增加 Adapter 更简单(日志、熔断等)
  • 各服务处理步伐不会被约束住,吞吐被解开

弊端:

  • 整体流程更复杂
  • 事件乱序问题需解决(用状态机等)
  • 事务处理变复杂

异步通讯设计的重要点

要厘清设计重点,首先知道 【Why】,然后推出 【How】:

  1. WHY:为什么要异步通讯
    • 解耦
      • 服务维护独立,故障隔离
      • 更大吞吐量,不互相影响性能
    • 用队列方式,实现均匀、非波动请求(削峰)
  2. HOW:设计主要关注
    • Broker 高可用性
    • 分布式消息有序性不好把控,尽量设计幂等
    • 实现较好的跟踪机制
    • 要有一个总检方把控整体逻辑,故障后也便于恢复(常见于银行的对账系统)
    • 幂等处理,支持请求方没收到 ACK 可重传

幂等性设计

概念

一次请求和多次请求的结果是一致的,数学化表达是:f(f(x)) 

为何需要幂等性设计:
请求的返回一般有三个结果:

  • Success
  • Failure
  • Timeout

其中,Timeout无法告知上游具体下游对于此请求的状态,因为下游可能没收到请求/处理成功/处理失败… 反正都有可能,那么就需要重试。

重试一般两种方式:

  1. 下游提供一个查询接口,上游调用若发现之前的请求成功了,就不再发送;否则,重新请求。
  2. 幂等性设计的请求重新发送,这就需要下游提供的接口是满足幂等性设计的。

全局ID

要实现幂等性设计,一般需要有个全局唯一ID来判别。

在分布式系统中,要生成不冲突的全局唯一ID可用分布式ID生成算法(如Snowflake/Redis的ID生成算法)。

但不推荐UUID(形如f47ac10b-58cc-4372-a567-0e02b2c3d479):

  • 占空间大,索引效率低
  • 过于随机,没有顺序性

幂等性处理流程

  1. 需要把ID存储下来
  2. 收到insert/update等请求时,将ID作为条件。如insert时判断已有此ID,则报错。

ID的存储方式可用关系型或key-value(如MongoDB)方式。

注意:要使服务无状态,ID存储方式应是共享的。此时存储技术成为关键要素。

HTTP的幂等性

  1. 不会有"副作用"的类型:HTTP GET/HEAD/OPTIONS
  2. 有"副作用"但幂等的:HTTP PUT / DELETE
    传的URI是实际操作的对象,如PUT同一个id:1234的贴子。
  3. 有"副作用"且不幂等的:HTTP POST
    传的URI不是实际操作的对象,如两次POST会创建两个贴子。

POST如何实现幂等性:
考虑一个发帖场景:

  • 在表单页加一个隐藏的token(此表单页token不变)。此方式实际上把POST -> PUT
  • 提交时带上token,后端序列化token与存储的比对,重复就不继续。
  • 更稳妥的做法:后端成功后返回302,前端跳转到GET请求(也就是PRG(Post/Redirect/Get)模式)。
  • 若是web,最好将提交页面置为过期,防止用户返回上个页面再次提交。

服务的状态

何为“状态”

状态,通常指的是程序的数据和上下文(context),比如每次请求的状态(session等)。

根据服务是否保存状态,分为无状态服务(Stateless)和有状态服务(Stateful)。

无状态服务

无状态服务 一般是分布式系统所青睐的(易于伸缩等好处),但需要配合第三方存储来保存状态。因此,一般需要这个第三方是高可用、可扩展的。

比如,不那么重要的数据可保存在Redis,重要的保存在Mysql/Zookeeper/Etcd等。

无状态服务与“函数式思想”有一致性:函数往往就是只有处理逻辑,通常不改变入参,只返回结果。

有状态服务

有状态服务 虽然不像无状态服务一样“无副作用”和易于水平伸缩,但也有其优势:

  • 数据本地化:低延迟
  • 更高的可用性和一致性:需要客户端的请求落在同一个实例,也就是Sticky Session和Sticky Connection。

有状态服务的核心特点是:服务实例的内存中存储了特定客户端的状态(如用户会话数据、事务上下文等)。
→ 当客户端发起 新请求 时,若该请求未被正确路由到原实例,将导致状态丢失(如购物车清空、登录失效)。

Sticky Session实现: 一般可用哈希映射 :但这样可能导致请求不均衡。为了实现请求负载均衡,可使用元数据索引映射后端实例和请求,以及还需要一个路由(可替换为配置或Gossip协议)

无状态/有状态服务的根本区别

数据类型 存储位置 对服务状态属性的影响
业务流水/参数 外部数据库(如MySQL/PostgreSQL) ⚠️ 不影响服务状态属性(有/无状态服务都如此)
请求上下文状态 服务内部(内存/本地磁盘) ✅ 有状态服务的核心特征
动态配置/会话 外部服务(如Redis/配置中心) ✅ 无状态服务的典型设计

💎 关键结论
有状态与无状态服务的根本区别,在于是否在服务实例内部维护请求上下文状态(Request Context State)

关于本地缓存:

  • ❌ 错误认知:“缓存不算状态,只是性能优化”
  • ✅ 真相
    • 若缓存的数据是请求上下文相关(如用户会话)→ 有状态服务
    • 若缓存的是全局静态数据(如城市列表)→ 仍为无状态服务

服务状态的容错设计

很多高可用的设计采用“运行时就复制”的方案(其核心思想是:在数据/状态产生的同时(运行时),立即将其复制到多个冗余节点,而非事后异步同步。这种方案通过牺牲部分写入性能,换取极高的可用性和一致性),如Zookeeper, Kafka, Redis, Elasticsearch。它们在运行复制时就考虑一致性(因此采用二阶段提交等)。

对于有状态服务:推荐还是采用分布式的文件系统。这样当一个节点挂掉后,可启用新节点,然后把文件系统挂载过来,这样就启动时装载好大部分数据,迅速恢复服务。此思想不适用于无状态服务:因为无状态服务的核心特征是请求处理不依赖本地持久化状态。其故障恢复机制完全不同:

对比维度 有状态服务 无状态服务
状态存储位置 本地磁盘或分布式存储 外部服务(Redis/DB/ConfigServer)
启动依赖 需加载大量持久化数据 仅需代码/镜像+轻量配置
故障恢复目标 恢复状态+服务 仅恢复计算能力
典型恢复时间 秒级~分钟级(依赖数据量) 毫秒级~秒级

补偿事务

ACID 和 BASE

ACID:适用于传统关系型数据库

  • Atomicity:原子性
  • Consistency:一致性
  • Isolation:隔离性
  • Durability:持久性

而分布式(尤其是微服务),这么玩的话很难满足高性能。一般来说,分布式系统对于CAP (Consistency, Availability, Partition Tolerance 分区容忍性) 理论,最多只能满足两个

ps. 分区容忍性含义:

一个具有分区容忍性的系统,不会因为发生了网络分区而整体崩溃或停止响应

网络分区:在分布式系统中,多个节点(服务器、计算机)通过网络连接在一起协同工作。当网络发生故障(如路由器故障、交换机故障、网线断开、网络拥塞等)时,可能会导致整个网络被分割成两个或多个彼此无法通信的子网络。每个子网络内部的节点可以互相通信,但子网络之间的节点则完全无法通信。这种网络被分割成孤立部分的情况就称为“网络分区”

于是,为提高性能,出现了对ACID的变种:BASE,

  • Basically Available:基本可用,可能包含中间暂时不可用的状态,后续快速恢复。
  • Soft-state:软状态(处于无状态和有状态中间),可短暂保有一些状态和数据。
  • Eventually Consistent:最终一致性,在一个短暂时间段内是不一致的,最终是一致的。

这些特性的很大一部分特点是“容忍故障”,也就更为“弹力”。可以容忍短时间内的不一致,只要最终一致性,而非强一致性。

作者举了个订单的例子:

  • “ACID:订单等待前面的订单处理完”
  • “BASE:异步处理,分两步确定(确定收到订单 + 确定订单确认成功),如库存不足,则告知用户下单失败。”

有趣的是,ACID(酸) Vs. BASE(碱)这正好是相对的。

业务补偿

我们有很多事,是共同决定一件事的结果的,而这些事牵扯的对象也可能是不同的,在分布式系统中尤是。如果是一个事务内的事,若条件不满足或有变化的,需要业务上做相应的事务补偿。

业务补偿一般要实现:

  • 幂等性,可不断重试
  • 可回滚到初始状态(起始状态定义
  • 有变化的请求,需启动整个业务的更新机制

设计重点

业务补偿主要做两件事:

  1. 努力实现目标
  2. 若实现不了,启用补偿机制,回滚流程

设计重点:

  1. 请求支持幂等性和重试机制
  2. 用工作流引擎维护和监控整个业务的状态,这个引擎帮我们实现目标,失败时进行补偿。
  3. 补偿不一定是严格反向操作,可并行可简单。
  4. 补偿一般是强业务相关,不具通用性。
  5. 下层最好有资源预留机制(如电商支持等待15分钟内支付)。

重试设计

重试时机

重试一般适用于短暂的异常,如:

  • 调用超时
  • 被调用方返回诸如资源不足、流控中、繁忙中、维护中等状态。

一般明显的业务或程序错误不重试,如报错:

  • 权限不足
  • 5xx等明显代码错误
  • 非法数据

重试策略

  1. 简单指数退避策略 (Exponential Backoff):重试时间间隔呈指数级增长,便于被调用方从容处理请求(类似TCP拥塞控制机制)。
  2. Spring的重试机制:使用@Retryable注解实现,支持指定的多种充实和back-off策略。

重试设计的重点

  • 确认需要重试的场景。
  • 重试请求需要满足幂等性,否则可能引起问题。
  • 不要一直重试。针对一直失败的请求,对于新请求可以考虑快速报错(什么时候后端恢复的时机识别可使用熔断设计)。
  • 重试时间、次数设计:
    • 如果是前端调用,一般立刻返回错误。
    • 如果是流控,则采用指数退避策略,避免造成更多流量。
  • 重试不要侵入业务代码,可使用注解或底层实现,或用ServiceMesh实现。
  • 对于事务操作,一般希望可以重试(否则需要做复杂的业务补偿回滚),但是事务上下文的保存要注意设计。

熔断设计

上面说的重试机制,针对的是异常可重试,但能一直重试吗?如果下游已经挂了,一直重试反而使系统不可用、超时等问题。因此,熔断设计就是在一定重试失败后可以及时“熔断”的机制,此时可直接返回错误,而不去请求后端,既加强可用性,又保护后端不过载。

熔断器模式

主要解决两种场景:

  1. 保护系统可用性,可继续执行。
  2. 能诊断后端是否修正,如已修正,应用程序会再次请求。

可使用状态机来实现,包括以下三种状态:

  1. 闭合 (Closed) 状态(对应正常):有一个失败次数的计数器,统计一段时间内的失败次数。当失败次数高于设定的阈值时,状态转为断开(Open)。此时开启一个超时时钟,当时钟超过一定时间后,会转为半断开(Half-Open)状态。
  2. 断开 (Open) 状态(对应故障):系统不发送请求后端,会直接返回错误,或一个缓存的结果。
  3. 半断开 (Half-Open) 状态(对应故障后检测是否被修复):允许一定数量的请求到后端(这样防止开始时后端被大量请求再次拖垮)。若这些请求成功,意味着后端已恢复,则切换回Closed状态,同时重置计数器;否则,切回Open状态,重置计时器。

此外,介绍了Netflix的Hystrix熔断实现逻辑。

熔断设计的重点

  • 错误类型区分:不同类型的错误,处理方式和重试程序可以有所区分。比如某些需要重试的请求,多少次失败才触发熔断;而有些明显服务端挂断的异常(恢复时间长),可直接熔断。
  • 日志监控:使管理员更清楚了解熔断器使用情况、接口异常情况等。
  • 支持手动重置:当观测到服务 UP/Down时,可不等自动熔断/恢复,可支持人工干预手动重置(立即生效)。
  • 分区资源支持分区熔断:如数据库分库分表后,若只有某分区挂了,却返回的是整体挂的话不大合适。因此,最好支持更精确的熔断控制。
  • 探测服务是否可用:可用Ping等方式定期检测远程健康,而非等待定期重试机制,更加灵活。
  • 并发问题:注意熔断器处理并发请求的可用性,不要成为阻塞点,且对调用情况的统计需要避免导致锁,一般要用无锁的数据结构或atomic原子操作。
  • 针对重试错误请求:有时请求失败与所用参数有关。不同参数的请求,在Half-Open时再次使用此失败参数尝试可更精确判断问题。

限流设计

实际应用诸如:

  • 数据库访问连接池
  • 线程池
  • Nginx下限制并发连接数的limit_conn模块

限流策略

限流目的是对并发访问进行限速,当超过一定速率,则触发相应的限流行为。一般限流策略有以下几种:

  • 拒绝服务:识别到高并发/异常的请求源,把请求直接拒绝掉。
  • 服务降级:将一些不重要的服务先关掉,把资源让出来。
  • 特权请求:将资源优先给重要的请求。
  • 延时处理:使用队列承载请求,防止突发带来的影响。
  • 弹性伸缩:筛选出使用频率最高的Top N请求,对相关服务做自动化伸缩,需要有一个能自动发布、部署等的环境。

限流实现方式

  1. 简单计数器:请求收到+1,请求处理完-1,当达到设定的阈值时,暂停接收新请求。
  2. 队列算法:队列存储进来的请求,后端服务按照自身处理情况去队列拉取请求(如果后端处理得快,则当前队列就消耗得越快)。若队列已满,则不接收新的请求。可配合权重+优先级实现不同请求的优先级处理。
  3. 漏斗算法:队列存储的不是实际请求,而是剩余可处理的空间。请求进来时若队列里还有空间,才接收。相当于在队列算法基础上加一个限速器,使得请求处理速率是匀速的。
  4. 令牌桶算法:队列里装的是令牌(token),请求只有拿到令牌才能处理。在流量小时令牌桶中令牌,在流量大时可大规模消耗令牌,但后端最大处理规模也不会超过令牌桶的最大容纳。

对于2、3、4算法:方法2无法精确控制处理速率(主要取决于后端处理多快);方法3和4则可以控制(精确控制长期平均处理速率)。

  1. 基于响应时间的动态限流:前几种方法需要提前确定最大阈值,是很困难的(实际应用时阈值是动态变化的)。方法5可以感知系统的当前压力来自动化限流。一个设计典范是TCP的拥塞控制算法,实时调整滑动窗口大小,以让发送的速率和当前的网络相匹配。需要记录请求的响应时间,从而计算一段时间内的P90或P99(将响应时间排序的前90%或99%),以此作为当前请求处理指标来控制。

限流设计要点

  1. 限流目的

    • 保证SLA可用性。
    • 防止多租户情况下某方把资源耗尽。
    • 防止突发的高流量造成的不利影响。
    • 节省成本,不需因为偶尔的高发流量扩容。
  2. 设计要点

    • 限流模块应该在早期就加入设计。
    • 应支持手动控制。
    • 发生时应有通知机制,给到运维关注,或是触发自动伸缩部署。
    • 限流错误返回前端时应带相应标识,前端可基于此标识调整发送速率或重试。
    • 限流应给后端应用也带上标识,后端可进行服务降级等操作。
    • 限流模块性能要好,对流量要很灵敏,也能真正达到限流。

降级设计

本质:通过牺牲掉一些功能,保障系统平稳运行。

  • 牺牲对象
    • 强一致性 → 最终一致性
    • 停用某些功能
    • 简化功能

以下依次说明:

牺牲强一致性

强一致性一般耗费资源较大,一般系统可转为最终一致性。一般有异步流程和降低数据一致性两种做法。

  1. 异步流程:例如购物应用,用户的下单和下单成功是异步的,无须实时等待下单传递。
  2. 降低数据一致性:使用缓存机制。

停用某些功能

可停用某些不重要的功能。但此方法对用户体验来说影响较大,不大推荐。

简化功能

将一些消耗资源较大的功能简化为更简单的功能。
举例:一个购物页面,原来会返回商品的基础信息、评论、收藏等多种信息,可简化为仅返回最基础信息。
实现:一般API可提供两种版本,一种返回多种信息,一种仅返回简化后的信息,减少联表查询等高成本动作(前端也要做兼容改造)

降级设计要点

  • 厘清重要与次要功能,保关键弃次要。
  • 定义好降级发生的关键条件,如流量、网络等因素。
  • 牺牲一致性时,要做好“流水账”记录,便于后续“对账”。
  • 降级可设计为系统开关,也可用API返回不同版本来控制(可由上游驱动)。
  • 降级不常发生,故需做好演练。

弹力设计总结

关于 三个要素

  1. 冗余服务
  2. 服务隔离
  3. 容错设计

冗余服务

  • 负载均衡 + 服务健康检查:Nginx, HAProxy
  • 服务注册 + 动态路由 + 服务健康检查:Consul, Zookeeper
  • 自动化运维:k8s

隔离技术

  1. Bulkheads:业务分片,用户分片,数据拆分
  2. 异步通讯
  3. 自包含系统
  4. 自动化运维

容错设计

  1. 错误:重试、熔断、幂等性设计
  2. 一致性:
    • 强一致性(两阶段提交)
    • 最终一致性(异步)
  3. 流控:限流 + 降级
  4. 自动化运维

弹力设计之开发和运维

推荐 Spring Cloud + Kubernetes 包括服务监控 + 服务调度

  • Spring Cloud 有一套丰富 Java 库,作为应用栈可解决很多运行时问题。
  • K8s 是语言无关的,针对容器的。