极客时间《左耳听风》专栏读书笔记之弹性设计篇。
隔离设计 bulkheads
按服务类型进行隔离
- 域名隔离
- 服务器隔离
- 数据库隔离
按请求做分离
- 完全资源隔离(资源共享度低、实现复杂度低)
- 共享服务、共享数据(隔离度低、占用成本低)
- 折中:共享服务、数据分区
异步通讯设计 asynchronous
为什么需要异步通讯?先看看同步通讯的缺点:
- 影响吞吐量、浪费资源:A同步调用B,B同步调用C,当同步调用的链条太长时,系统中响应最慢的那个结点决定了整体调用时间,拖慢了处理快的结点,影响系统吞吐量,浪费上游调用方的资源(因为需要保存context等待结果返回)
- 只能一对一调用,出故障时极容易出现多米诺骨牌效应(雪崩)
异步通讯有哪几种模式:
- sender调用receiver,receiver立即返回一个确认信号。紧接着sender间隔轮询receiver,检测调用是否完成;另一种办法是,sender在调用时携带上callback地址,receiver处理完成后主动调用callback地址,sender弱依赖receiver
- 把sender当成publisher,receiver当成subscriber,subscriber主动订阅publisher的消息,收到消息后触发相应逻辑。如库存服务订阅订单服务,在订单生成后的30分钟内锁住该一定件数的库存。对于publisher,它只需要subscriber的ACK即可,不需要知道subscriber干成什么样,此时subscriber弱依赖publisher
- sender => broker => receiver。增加一个broker中间件,sender和receiver间的依赖彻底打破,sender只管生产消息到broker,receiver只管从broker消费消息。同步调用本质上是一种函数调用,有入参、需要保存context,然后等待return结果,此时sender是有状态的。broker模式是一种event driven architecture,状态下沉到broker,使得sender无状态化,它们之间交互的只有event(含data)。比如:订单服务 -> broker_a => 支付服务 => broker_b => 物流服务。
broker模式至少有以下好处:
- 可替换:服务解耦了,只要遵循broker的消息规范,服务可任意替换
- 隔离:服务相互隔离,其扩容、运维、开发互不影响
- adapter:服务增加adapter相当容易(鉴权、熔断、降级、限流、日志)
- 非阻塞:服务通过事件关联,不会相互block
- 高吞吐:服务的吞吐量依赖关系也解开了,可各自按照服务自身的情况进行扩容。把抖动的流量变成平均流量,起到了削峰的作用
但同时broker模式也带来以下坏处:
- 观测性:业务的处理变得复杂,不像同步调用那样容易观测,需要一些辅助可视化工具
- 事件乱序:分布式化后难以保证事件在消费时的顺序性,因此要求服务本身不依赖事件的时序来设计
- 事务复杂:事务的处理变得复杂,可能需要二阶段提交或者退化为最终一致性
broker作为核心的中间件,必须满足以下特性:
- 高可用;
- 高性能而且是可水平扩展的;
- 持久化不丢数据。
幂等设计 idempotency
用数学语言表达就是:y = f(x),只要x值不变,y的值也不变。比如abs(x),调用多少次得到的都是同一个值。为什么需要幂等设计?对API的调用可能存在timeout,此时有两个解决办法:
- 系统暴露另外个接口给调用方,去查询调用是否完成
- 系统支持重复调用,调用方检测到timeout重复调用即可
以上两个方案都需要有一个全局id来标识一次调用,全局id可参考Twitter的snowflake算法,通过机房、机器、时间戳、worker数量、计数器等多参数构造全局id。
对于API系统,需要有存储用于存储和查询全局id,用于判断该调用是否已完成,已完成的话直接返回。存储的全局id必须是无状态的容易扩容的,否则将成为系统的瓶颈,可以使用关系型数据库或者NoSQL服务(如MongoDB)
服务的状态 state
服务的状态无处不在,比如:
- 多服务调用的上下文
- 幂等调用记录的某次调用的标识ID
- 用户的session
无状态的服务stateless
- 可随意扩容伸缩
- 一般需要把状态下沉到分布式存储,如Redis/MySQL/ZooKeeper等
- 为了减少服务到分布式存储的网络开销,一般还需要在每台服务上做全量缓存,内存上较为浪费
有状态的服务stateful
- data locality:状态和数据都是特定机器保存,更低的延时;更强的一致性,更高的可用性
- 实现方式之一:sticky connection,比如持久化的长连接,这种方式容易造成服务端的负载不均衡,需要客户端配合一起实现反向压力back pressure
- 实现方式之二:路由节点,根据元数据索引来路由到特定机器
- 实现方式之三:摘掉路由节点,服务本身也承担路由功能,多服务之间通过gossip发现其他服务的元数据
- 容错策略:把状态数据持久化到一个高可用的分布式存储,这样在应用重启时可以快速拉取恢复,再从其他replica节点拉取少量数据即可
补偿事务 compensating transaction
ACID的可扩展性差,BASE是基于ACID的一个变种:
- basic availability:可能出现短暂的不可用
- soft-state:介于有状态、无状态之间,这些数据可能不是强一致的(为了提高性能)
- eventually consistency:最终一致性
亚马逊在创建订单时并不会去锁库存,因此并发创建订单是无锁的,这可能出现超卖,但能提供下单的吞吐量,不用受限于锁库存、更新库存等耗时操作。订单创建后,被并行的进行处理(异步),此时才会真正去扣减库存并发货,此时若发现库存没有了则会触发致歉邮件同时触发退款。这背后就是BASE的思想了。因此,为了实现BASE,我们需要对事物进行补偿(回滚)。业务补偿的设计有两个重点:
- 努力达成业务:这意味着上游应进行重试、且服务的调用需要是幂等的
- 如果多次尝试都无法成功则需要对事务进行补偿:调用是多服务组合的,需要有一个高可用的工作流引擎记录成功进行到哪一步,把已经成功的有影响的操作进行回滚
重试的设计 retry
- when: 什么时候需要重试
- how: 重试多少次、重试的间隔(fail fast/exponential backoff)
- 注意:重试要求对端服务支持幂等调用,否则会有副作用;重试逻辑也可以下沉到service mesh,也可由spring进行代理(annotation)
熔断设计 circuit breaker
熔断器设计灵感由于电气中的保险丝,它是介于调用方和被调用方的一个安全保障。熔断器有以下三种状态:
- closed:此时调用直达到调用方
- open:此时调用直接被熔断器拒绝,当一定时间内的失败调用次数达到阈值会从closed进入此状态
- half-open:进入open后达到一个阈值时间自动进入此状态,此时新进来的流量若能正常调用成功,则恢复到closed状态,否则重新变为open状态
在设计熔断器时,有以下要点需要考虑:
- 错误的类型:有些返回(如服务集群crash)就应该让熔断器直接进入open状态
- 测试服务可用:与其直接进入half-open状态让真实流量来探活,还不如在切换到此状态前先调用对方的健康检查接口确保调用方的可用性
- 手动重置:提供admin接口,便于运维人员的人为介入改变熔断器状态
- 分区问题:有些后端服务是分区的,如分库分表,部分分区不可用不代表整个服务不可用
限流设计 throttle
限流有4个主要目
- 保障SLA(可用性)和RT(响应时间)
- 多租户情况下防止单一租户将资源耗尽
- 应对突发流量,如秒杀
- 节约成本,系统不需要时候运行在最高容量
在行为上,它表现为以下几种:
- 拒绝服务:可以拒绝掉那些请求最多的客户端,防止恶意攻击
- 服务降级:可以把不重要服务停掉,可以只返回部分而非全量数据,可以返回缓存(牺牲一致性)
- 特权请求:有限的资源分配给更重要的客户
- 延时处理:要队列来缓存,达到削峰效果
- 弹性伸缩:自动化运维对TOP5的高负载服务进行弹性伸缩
限流的静态实现方式有以下几种
- 计数器方式:请求进来+1,请求结束-1,当计数值大于M触发限流,当计数值小于N解除限流
- 队列方式:请求积压到队列,processor一空闲就获取任务进行处理。如果需要考虑高优请求先处理,可以将队列设计成两个队列(一个高优、一个次优),多个队列可设置时间权重防止某个队列出现饥饿
- 漏斗算法:请求积压到队列,processor以特定的速率取任务进行处理,不能超过这个限定的速率
- 令牌桶算法:请求积压队列,processor只要能在令牌桶取到令牌就可以进行处理。处理速率受限于令牌桶的令牌数,当令牌桶积压了多数令牌,且恰好有大量待处理请求,则此时可能有一个较高的QPS
以上几种限流方式,都需系统预设一个静态的阈值。限流的动态实现方式是基于RT(response time),实现思路如下:
- 利用采样,或者reservoir sampling,统计一段时间的RT的P99和P90所在位置(毫秒值)
- 如果P99和P90的时间慢于我们的期望值,将QPS进行折半限流,然后模仿TCP拥塞处理将QPS值进入慢启动,直到P99和P90又低于期望值,重新折半QPS
最后,限流在设计中还应该有以下考量
- 限流设计应在架构早期引入
- 限流模块性能必须足够好,对流量变化需要非常敏感
- 限流应该有手动开关供应急使用
- 限流发送应该有事件通知运维
- 限流发生时,对于拒绝掉的请求带上限流状态码,以供客户端做相应调整
- 限流应让后端感知到,如设置Header,服务端可决定是否降级
降级设计 degradation
降级,本质是为了解决资源不足和访问量过大的问题。降级需要牺牲的一般有:
- 降低一致性:流程一致性,数据一致性
- 停止次要功能:停止不重要的功能,把资源释放出来
- 简化功能:不再返回全量数据,而是部分数据
降低一致性
- 流程一致性:流程异步化,如果期间某个流程失败需要进行补偿
- 数据一致性:返回缓存数据,使用cache aside缓存模式(参考缓存套路)
停止次要功能
- 优先停掉次要流量,停次要功能毕竟有损体验
- 对用户做一些补偿,如跳转到红包页面
简化功能
- 仅返回最小可用数据
设计要点
- 定义清楚降级的条件
- 梳理哪些业务可以牺牲
- 降级做成配置开关化,或是通过调用参数来辨别是否降级
- 需要客户端配合,如有些次要数据是否能不返回
- 不是常规case,需要演练
弹性设计的总结
服务冗余:服务不能是单点,需要多个replica,这需要服务发现、负载均衡、动态路由、健康检查4个功能或组件
- 负载均衡:nginx或haproxy等技术
- 服务发现、动态路由、健康检查:Zuul作为API网关实现动态路由,Consul或Zookeeper作为服务发现
- 自动化运维:kubernates服务调度、伸缩和故障转移
服务解耦:把服务隔离开来,使其不相互影响。水平上,可业务分区(业务隔离),或用户分区(多租户;垂直上,需要异步通讯;服务编排和聚合上,需要工作流把服务串起来(如Spring或Akka的Streams);一致性上,需要业务补偿来做反向交易
- bulkheads模式:用户分片、业务分片、数据库拆分
- 自包含系统:无外部服务依赖,把一组相关的微服务给拆出来
- 异步通讯:异步通讯、消息队列、事件驱动
- 自动化运维:需要有一个服务调用链和性能监控的监控系统
服务容错:让这个架构能接受失败的设计。重试带来幂等问题,为了保障稳定性需要有限流、降级、熔断
- 错误方面:重试设计+熔断设计+幂等设计
- 一致性:强一致性使用两阶段提交,最终一致性使用异步通讯+事件补偿
- 流控:限流设计+降级设计
- 自动化运维:网关流量调度,服务监控