学习极客时间-左耳听风专栏的个人学习记录。
弹力设计
索引:
- 认识故障和弹力设计
- 隔离设计
- 异步通讯设计
- 幂等性设计
- 服务的状态
- 补偿事务
- 重试设计
- 熔断设计
- 限流设计
- 降级设计
- 总结
认识故障和弹力设计-认识篇
简介
本大章讲【弹力设计】,其实也就是【容错设计】(保障的重点就是【系统可用性】),主要包含:
- 容错能力
- 服务隔离:A服务挂了不影响B服务
- 异步调用(通过上下游解耦减少因下游异常导致的上层故障、不可用的情况)
- 请求幂等性(即确保同个请求发送多次的效果和一次是一样的),这样用户在没收到确认返回时,可以安全重试
- 可伸缩性(有/无状态的服务)
- 一致性(事务补偿、重试)
- 应对大流量的能力(熔断、降级)
可用性测量
有个工业界普遍采用的公式: Availability=MTTF/(MTTF+MTTR)
MTTF(Mean Time To Failure):平均故障前的时间,也就是正常运行的时间(不要被Failure所迷感)
MTTR(Mean Time To Recover):发生故障后的恢复时间
故障原因
- 分类一:计划性
- 无计划性
- 系统级故障
- 数据或中介故障
- 自然灾害、人为破坏
- 有计划性
- 日常:备份、批处理
- 系统维护
- 升级
- 分类二:问题类别归类
- 网络: 连接、带宽等
- 性能: CPU、慢SQL、内存、Java Full GC、IO等
- 安全: 网络攻击等
- 运维: 系统更新等
- 管理: 没有梳理出关键服务及服务间依赖关系等
- 硬件: 硬盘、 网卡、断电、交换机、挖掘机问题
故障不可避免
尤其是分布式系统,故障是常见的、突发难缠。所以我们的目标是降低MTTR。在设计编码阶段,就要Design For Failure。
弹力设计之隔离设计
介绍
隔离设计(Bulkheads),中文翻译为隔板(一般船上用隔板将空间隔离成多个封闭空间,防止有漏水等情况出现的灾难蔓延)
隔离方式分类:
- 以服务的种类
- 以用户的请求
以服务种类划分
比如,在一个购物系统中分别将【用户】、【商品】、【社区】三个板块分开,即每个板块有自己的域名、服务器、数据库(也就是接入层、应用层、数据层)。这样,一个板块挂掉后,不会影响其他板块。这也是微服务的思想,每个服务做好对外暴露。
但,也存在以下这些问题;
- 一个处理需跨服务时,响应时间会更长从而降低性能(因此, 需要避免在同一个服务中涉及太多请求)
- 涉及到多个服务关联的请求,一个服务挂了,这个请求依然走不下去(最好可做到 step-by-step,分步保存。这样恢复后可以从失败的节点开始即可)
- 如果有大数据平台, 需要抽取各模块数据进行外理,过程也较复杂
- 如果涉及较复杂的跨服务的交互,需要使用Pub/Sub(发布/订阅)模式的持久化消息传递机制辅助数据交换
- 多服务之间的分布式事务问题(二阶段提交等方式解决)
以用户请求划分
概念:将用户分为多个组;每个组分配单独的实例。这样,当某个服务实例挂掉,不会影响其他实例。例如,对于比较重要的客户,可用专门独立的实例。
这也是所谓的“多租户”模式,一般涉及三种程序的隔离:
- 数据 + 服务隔离(完全独立):成本高,资源浪费
- 数据隔离 + 服务共享(折中):一般选择折中方案
- 数据 + 服务共享(完全共享):成本低,但开发难度大、隔离性不好
隔离设计的重点
- 确定合适的隔离粒度
- 考虑成本、性能、复杂度、资源使用等,找到一个均衡方案
- 隔离技术需配合一些高可用、异步、中间件、熔断等技术一起使用
- 运维和监控的配合,能力提升
异步通讯设计
同步/异步通讯
一个形象的类比:
- 同步 → 打电话
- 异步 → 发邮件
人不能接多个电话,但可以同时收多封邮件。
【同步】有哪些缺陷(对应来说就是【异步】的优势):
- 被调用方的吞吐量要不小于调用方的,整个同步调用链的性能由最慢的那个服务决定
- 同步链的所有经过的服务都需保存现场(context)等待远端返回,因此高并发场景下极度耗资源
- 同步只能一对一,不能一对多
- 同步链上一方故障导致的多米诺骨牌效应(异步可解耦服务)
异步通讯三种方式
请求响应式
Sender → Receiver
返回结果两种方式获得:
- Sender 轮询 Receiver : 是否已处理完成
- Sender 注册一个回调方法,Receiver 处理完调回调方法通知 Sender (例子:商家 → 支付页 → 跳回商家)
这种方式有个很大的缺陷就是服务之间耦合度太高
订阅方式(常用语MVC):一定程度解耦
Sender ← 订阅 ← Receiver(订阅方)
消息关系 : Sender 把消息 → 订阅队列 → Receiver 获取
重点:Sender 消息发过去就好,不关心这事干得咋样
举例:
-
订单系统 发支付信息 → 支付信息队列(第三方订阅)
-
支付信息队列 发信息 → 支付第三方
-
第三方处理后,发支付成功信息 → 支付结果队列 (订单系统订阅)
-
支付结果队列 发信息 → 订单系统
优势:通信依靠消息,一定程度的解耦
但非完全解耦,因为发送方是直接给订阅方发订阅事件
通过Broker方式:完全解耦
Sender 发布 → Broker(中间人) ← 订阅 Receiver
Sender 与 Receiver 之间没有直接关联
要求 Broker 满足:
- 高可用
- 可水平扩展,高性能
- 可持久化不丢数据
事件驱动设计
第二、三种方式属于事件驱动架构,需要一系列消息通道。各服务做完发出事件,由订阅事件的服务接收做关联处理。
优势:
- 服务间低依赖
- 服务的开发运维等高度隔离
- 服务间通过事件关联,不会阻塞住
- 服务增加 Adapter 更简单(日志、熔断等)
- 各服务处理步伐不会被约束住,吞吐被解开
弊端:
- 整体流程更复杂
- 事件乱序问题需解决(用状态机等)
- 事务处理变复杂
异步通讯设计的重要点
要厘清设计重点,首先知道 【Why】,然后推出 【How】:
- WHY:为什么要异步通讯
- 解耦
- 服务维护独立,故障隔离
- 更大吞吐量,不互相影响性能
- 用队列方式,实现均匀、非波动请求(削峰)
- 解耦
- HOW:设计主要关注
- Broker 高可用性
- 分布式消息有序性不好把控,尽量设计幂等
- 实现较好的跟踪机制
- 要有一个总检方把控整体逻辑,故障后也便于恢复(常见于银行的对账系统)
- 幂等处理,支持请求方没收到 ACK 可重传
幂等性设计
概念
一次请求和多次请求的结果是一致的,数学化表达是:f(f(x))
为何需要幂等性设计:
请求的返回一般有三个结果:
- Success
- Failure
- Timeout
其中,Timeout无法告知上游具体下游对于此请求的状态,因为下游可能没收到请求/处理成功/处理失败… 反正都有可能,那么就需要重试。
重试一般两种方式:
- 下游提供一个查询接口,上游调用若发现之前的请求成功了,就不再发送;否则,重新请求。
- 幂等性设计的请求重新发送,这就需要下游提供的接口是满足幂等性设计的。
全局ID
要实现幂等性设计,一般需要有个全局唯一ID来判别。
在分布式系统中,要生成不冲突的全局唯一ID可用分布式ID生成算法(如Snowflake/Redis的ID生成算法)。
但不推荐UUID(形如f47ac10b-58cc-4372-a567-0e02b2c3d479):
- 占空间大,索引效率低
- 过于随机,没有顺序性
幂等性处理流程
- 需要把ID存储下来
- 收到insert/update等请求时,将ID作为条件。如insert时判断已有此ID,则报错。
ID的存储方式可用关系型或key-value(如MongoDB)方式。
注意:要使服务无状态,ID存储方式应是共享的。此时存储技术成为关键要素。
HTTP的幂等性
- 不会有"副作用"的类型:HTTP GET/HEAD/OPTIONS
- 有"副作用"但幂等的:HTTP PUT / DELETE
传的URI是实际操作的对象,如PUT同一个id:1234的贴子。 - 有"副作用"且不幂等的: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(碱)这正好是相对的。
业务补偿
我们有很多事,是共同决定一件事的结果的,而这些事牵扯的对象也可能是不同的,在分布式系统中尤是。如果是一个事务内的事,若条件不满足或有变化的,需要业务上做相应的事务补偿。
业务补偿一般要实现:
- 幂等性,可不断重试
- 可回滚到初始状态(起始状态定义)
- 有变化的请求,需启动整个业务的更新机制
设计重点
业务补偿主要做两件事:
- 努力实现目标
- 若实现不了,启用补偿机制,回滚流程
设计重点:
- 请求支持幂等性和重试机制
- 用工作流引擎维护和监控整个业务的状态,这个引擎帮我们实现目标,失败时进行补偿。
- 补偿不一定是严格反向操作,可并行可简单。
- 补偿一般是强业务相关,不具通用性。
- 下层最好有资源预留机制(如电商支持等待15分钟内支付)。
重试设计
重试时机
重试一般适用于短暂的异常,如:
- 调用超时
- 被调用方返回诸如资源不足、流控中、繁忙中、维护中等状态。
一般明显的业务或程序错误不重试,如报错:
- 权限不足
- 5xx等明显代码错误
- 非法数据
重试策略
- 简单指数退避策略 (Exponential Backoff):重试时间间隔呈指数级增长,便于被调用方从容处理请求(类似TCP拥塞控制机制)。
- Spring的重试机制:使用
@Retryable注解实现,支持指定的多种充实和back-off策略。
重试设计的重点
- 确认需要重试的场景。
- 重试请求需要满足幂等性,否则可能引起问题。
- 不要一直重试。针对一直失败的请求,对于新请求可以考虑快速报错(什么时候后端恢复的时机识别可使用熔断设计)。
- 重试时间、次数设计:
- 如果是前端调用,一般立刻返回错误。
- 如果是流控,则采用指数退避策略,避免造成更多流量。
- 重试不要侵入业务代码,可使用注解或底层实现,或用ServiceMesh实现。
- 对于事务操作,一般希望可以重试(否则需要做复杂的业务补偿回滚),但是事务上下文的保存要注意设计。
熔断设计
上面说的重试机制,针对的是异常可重试,但能一直重试吗?如果下游已经挂了,一直重试反而使系统不可用、超时等问题。因此,熔断设计就是在一定重试失败后可以及时“熔断”的机制,此时可直接返回错误,而不去请求后端,既加强可用性,又保护后端不过载。
熔断器模式
主要解决两种场景:
- 保护系统可用性,可继续执行。
- 能诊断后端是否修正,如已修正,应用程序会再次请求。
可使用状态机来实现,包括以下三种状态:
- 闭合 (Closed) 状态(对应正常):有一个失败次数的计数器,统计一段时间内的失败次数。当失败次数高于设定的阈值时,状态转为断开(Open)。此时开启一个超时时钟,当时钟超过一定时间后,会转为半断开(Half-Open)状态。
- 断开 (Open) 状态(对应故障):系统不发送请求后端,会直接返回错误,或一个缓存的结果。
- 半断开 (Half-Open) 状态(对应故障后检测是否被修复):允许一定数量的请求到后端(这样防止开始时后端被大量请求再次拖垮)。若这些请求成功,意味着后端已恢复,则切换回Closed状态,同时重置计数器;否则,切回Open状态,重置计时器。
此外,介绍了Netflix的Hystrix熔断实现逻辑。
熔断设计的重点
- 错误类型区分:不同类型的错误,处理方式和重试程序可以有所区分。比如某些需要重试的请求,多少次失败才触发熔断;而有些明显服务端挂断的异常(恢复时间长),可直接熔断。
- 日志监控:使管理员更清楚了解熔断器使用情况、接口异常情况等。
- 支持手动重置:当观测到服务 UP/Down时,可不等自动熔断/恢复,可支持人工干预手动重置(立即生效)。
- 分区资源支持分区熔断:如数据库分库分表后,若只有某分区挂了,却返回的是整体挂的话不大合适。因此,最好支持更精确的熔断控制。
- 探测服务是否可用:可用Ping等方式定期检测远程健康,而非等待定期重试机制,更加灵活。
- 并发问题:注意熔断器处理并发请求的可用性,不要成为阻塞点,且对调用情况的统计需要避免导致锁,一般要用无锁的数据结构或atomic原子操作。
- 针对重试错误请求:有时请求失败与所用参数有关。不同参数的请求,在Half-Open时再次使用此失败参数尝试可更精确判断问题。
限流设计
实际应用诸如:
- 数据库访问连接池
- 线程池
- Nginx下限制并发连接数的limit_conn模块
限流策略
限流目的是对并发访问进行限速,当超过一定速率,则触发相应的限流行为。一般限流策略有以下几种:
- 拒绝服务:识别到高并发/异常的请求源,把请求直接拒绝掉。
- 服务降级:将一些不重要的服务先关掉,把资源让出来。
- 特权请求:将资源优先给重要的请求。
- 延时处理:使用队列承载请求,防止突发带来的影响。
- 弹性伸缩:筛选出使用频率最高的Top N请求,对相关服务做自动化伸缩,需要有一个能自动发布、部署等的环境。
限流实现方式
- 简单计数器:请求收到+1,请求处理完-1,当达到设定的阈值时,暂停接收新请求。
- 队列算法:队列存储进来的请求,后端服务按照自身处理情况去队列拉取请求(如果后端处理得快,则当前队列就消耗得越快)。若队列已满,则不接收新的请求。可配合权重+优先级实现不同请求的优先级处理。
- 漏斗算法:队列存储的不是实际请求,而是剩余可处理的空间。请求进来时若队列里还有空间,才接收。相当于在队列算法基础上加一个限速器,使得请求处理速率是匀速的。
- 令牌桶算法:队列里装的是令牌(token),请求只有拿到令牌才能处理。在流量小时令牌桶中攒令牌,在流量大时可大规模消耗令牌,但后端最大处理规模也不会超过令牌桶的最大容纳。
对于2、3、4算法:方法2无法精确控制处理速率(主要取决于后端处理多快);方法3和4则可以控制(精确控制长期平均处理速率)。
- 基于响应时间的动态限流:前几种方法需要提前确定最大阈值,是很困难的(实际应用时阈值是动态变化的)。方法5可以感知系统的当前压力来自动化限流。一个设计典范是TCP的拥塞控制算法,实时调整滑动窗口大小,以让发送的速率和当前的网络相匹配。需要记录请求的响应时间,从而计算一段时间内的P90或P99(将响应时间排序的前90%或99%),以此作为当前请求处理指标来控制。
限流设计要点
-
限流目的:
- 保证SLA可用性。
- 防止多租户情况下某方把资源耗尽。
- 防止突发的高流量造成的不利影响。
- 节省成本,不需因为偶尔的高发流量扩容。
-
设计要点:
- 限流模块应该在早期就加入设计。
- 应支持手动控制。
- 发生时应有通知机制,给到运维关注,或是触发自动伸缩部署。
- 限流错误返回前端时应带相应标识,前端可基于此标识调整发送速率或重试。
- 限流应给后端应用也带上标识,后端可进行服务降级等操作。
- 限流模块性能要好,对流量要很灵敏,也能真正达到限流。
降级设计
本质:通过牺牲掉一些功能,保障系统平稳运行。
- 牺牲对象:
- 强一致性 → 最终一致性
- 停用某些功能
- 简化功能
以下依次说明:
牺牲强一致性
强一致性一般耗费资源较大,一般系统可转为最终一致性。一般有异步流程和降低数据一致性两种做法。
- 异步流程:例如购物应用,用户的下单和下单成功是异步的,无须实时等待下单传递。
- 降低数据一致性:使用缓存机制。
停用某些功能
可停用某些不重要的功能。但此方法对用户体验来说影响较大,不大推荐。
简化功能
将一些消耗资源较大的功能简化为更简单的功能。
举例:一个购物页面,原来会返回商品的基础信息、评论、收藏等多种信息,可简化为仅返回最基础信息。
实现:一般API可提供两种版本,一种返回多种信息,一种仅返回简化后的信息,减少联表查询等高成本动作(前端也要做兼容改造)
降级设计要点
- 厘清重要与次要功能,保关键弃次要。
- 定义好降级发生的关键条件,如流量、网络等因素。
- 牺牲一致性时,要做好“流水账”记录,便于后续“对账”。
- 降级可设计为系统开关,也可用API返回不同版本来控制(可由上游驱动)。
- 降级不常发生,故需做好演练。
弹力设计总结
关于 三个要素:
- 冗余服务
- 服务隔离
- 容错设计
冗余服务
- 负载均衡 + 服务健康检查:Nginx, HAProxy
- 服务注册 + 动态路由 + 服务健康检查:Consul, Zookeeper
- 自动化运维:k8s
隔离技术
- Bulkheads:业务分片,用户分片,数据拆分
- 异步通讯
- 自包含系统
- 自动化运维
容错设计
- 错误:重试、熔断、幂等性设计
- 一致性:
- 强一致性(两阶段提交)
- 最终一致性(异步)
- 流控:限流 + 降级
- 自动化运维
弹力设计之开发和运维
推荐 Spring Cloud + Kubernetes 包括服务监控 + 服务调度
- Spring Cloud 有一套丰富 Java 库,作为应用栈可解决很多运行时问题。
- K8s 是语言无关的,针对容器的。