遵循"design for failure"的设计原则,未雨绸缪,具体优化方法有故障转移、超时控制、降级、限流
「什么时候进行主备切换?」
一般采用某种故障检测机制,比如心跳机制,备份节点定期发送心跳包,当多数节点未收到主节点的心跳包,表示主节点故障,需要进行切换。
「如何进行切换?」
一般采用paxos、raft等分布式一致性算法,在多个备份节点中选出新主节点。
在分布式环境下,服务响应慢可能比宕机危害更大,失败只是瞬时的,但调用延迟会导致占用的资源得不到释放,在高并发情况下会造成整个系统奔溃。
「如何合理设置超时时间?」
收集系统之间的调用日志,统计比如说 99% 的响应时间是怎样的,然后依据这个时间来指定超时时间。
关闭整个流程中非核心部分,保证主流程能稳定执行(详细见后文)
限制单位时间内的请求量,超过的部分直接返回错误 (详细见后文)
通过线上流量观察代码变更带来的影响
对系统中的部分节点/组件人为破坏,模拟故障,观察系统的表现。为了避免对生产系统造成影响,可以先部署另外一套与线上环境一摸一样的系统,在这上面进行故障演练
分库分表,按业务和数据纬度对库表进行水平/垂直拆分,突破单机限制。有以下两点需要注意:
按业务纬度,接口重要性纬度和请求来源等多个维度对服务进行拆分和隔离
数据库有两个大方面的优化方向:
池化是一种空间换时间的思路。预先创建好多个对象,重复使用,避免频繁创建销毁对象造成的开销
维护池中连接数量和保证连接可用性是连接池管理的两个关键点。
「请求获取连接流程」
初始化连接池时,需要指定最大连接数和最小连接数
「保证连接可用性」
指定一个最大线程数量,并利用一个有限大小的任务队列,当池中线程数量较少时,直接创建新线程去处理任务,当池中线程达到设置的最大线程数量后,可以将任务放入任务队列中,等待空闲线程执行。
❝CPU密集型任务,保持与CPU核数相当的线程就可以了,避免过多的上下文切换,降低执行效率 IO密集型,可以适当放开数量,因为在执行IO时线程阻塞,CPU空闲下来可以去执行其他线程的任务 ❞
分离后,从库可以用作数据备份,也可用于处理读请求,减少单机压力;
处理方法:
随着存储量变大,单机写入性能和查询性能会降低,分库分表能提高读写性能;按模块分库,实现不同模块的故障隔离
将数据库的表拆到不同数据库中,一般可以按业务来拆分,专库专用,将业务耦合度较高的表放到同一个库中
将单一表的数据按一定规则拆分到多个表中,需要选一个字段作为分区键。一般通过对某个字段hash进行分区或按某个字段(比如时间字段)的区间进行分区
可以开发一个单独的分布式发号器
「使用发号器而不是UUID的原因?发号器的好处?」
「常见的发号算法」
snowFlake:64bit 的二进制数字分成若干部分,每一部分都存储有特定含义的数据,比如说时间戳、机器 ID、序列号等等,最终生成全局唯一的有序 ID。
「发号器的实现」
❝发号器依赖服务节点本地时间戳,各节点时间戳可能没法准确同步,当节点重启时可能出现时间回拨现象 ❞
「发号器实现tips」
ID中有几位是序列号,表示在单个时间戳内最多可以创建多少个ID,当发号器的QPS不高时,单个时间戳只发一个ID,会导致ID的末位永远是1;这个时候分库分表使用ID作为分区健会导致数据不均匀
关系型数据库能提供强大的查询功能、事务和索引等功能;NoSQL可在某些场景下作为关系型数据库的补充:
❝LSM相关介绍: https://blog.csdn.net/jinking01/article/details/105377370 https://blog.csdn.net/SweeNeil/article/details/86482781 ❞
只更新缓存,不直接更新数据库,通过批量异步的方式来更新数据库。
主要用于底层存储性能与缓存相差较大,比如操作系统的page cache就使用这种方式避免磁盘的随机IO
「Cache-Aside」
「Read-Through/Write-Through」
与Cache-Aside相比,多了一层Cache-Provider,程序代码变的更简洁,一般在设计本地缓存可采用这个方式
「操作缓存时,要删除而不是更新缓存」
「删除缓存失败会影响一致性」
❝hash取模:读写时,客户端对key进行hash计算,并对缓存节点数取余,计算出数据所在的节点。该算法实现简单;但当缓存节点个数变化时,容易导致大批量缓存失效。 ❞
❝一致性hash算法:一个有2^32个槽的hash环,使用一定的hash函数,以服务器的IP或主机名作为键进行哈希,这样每台服务器就能确定其在哈希环上的位置;读写时,使用相同的hash函数对key进行hash,得到哈希环上的一个位置,顺时针查找到的第一个服务器,就是该key所在的缓存节点。
对缓存的所有读写请求都通过代理层完成,代理层提供路由功能,内置了高可用相关逻辑,保证底层缓存节点的可用
参考redis的哨兵+cluster实现
「避免消息队列数据堆积」
❝启动一个监控程序,定时将监控消息写入消息队列中,在消费端检查消费时与生产时间的时间间隔,达到阈值后发告警 通过消息队列提供的工具对队列内数据量进行监控❞
❝优化消费代码 增加消费并发度❞
「避免消息丢失(以kafka举例)」
❝失败重试 ack设置为all,保证所有的ISR都写入成功❞
❝保证副本数量和ISR数量❞
❝确保消费后再提交消费进度❞
「避免消息生产/消费重复(以kafka举例)」
❝更新kafka版本,利用kafka的幂等机制和事务机制保证消息不重复❞
❝消息id+业务幂等判断❞
「其他tips」
使用poll方式消费时需注意当无新消息时消费进程空转占用cpu,拉取不到消息可以等待一段时间再来拉取,等待的时间不宜过长,否则会增加消息的延迟。
一般建议固定的 10ms~100ms,也可以按照一定步长递增,比如第一次拉取不到消息等待 10ms,第二次 20ms,最长可以到100ms,直到拉取到消息再回到 10ms。
❝分布式追踪工具,分析请求中的性能瓶颈 服务端监控报表,分析服务和资源的宏观性能表现❞
序列化选型考虑:
❝常见的序列化方案 「json/xml」 优点:简单,方便,无需关注要序列化的对象格式;可读性强 缺点:序列化和放序列化速度较慢;占用空间大 「protobuf」 优点:性能好,效率高;支持多种语言,配合IDL能自动生成对应语言的代码 缺点:二进制格式传输,可读性差❞
假设调用者直接存储服务地址列表,当服务节点变更时,需要调用者配合,所以需要一个服务注册中心,用于存储服务节点列表,并且当服务端地址发生变化时,可以推送给客户端。常用的注册中心有zookeeper、etcd、nacos、eureka...
「客户端与服务端之间也需要维护一个心跳包活机制」
因为有可能服务端与注册中心网络正常,但客户端与服务端之间网络不通,这种时候需要把该服务节点从客户端的节点列表中剔除。
「需要采取一定的保护策略避免注册中心故障影响整个集群」
「注册中心管理服务数量越多,订阅的客户端数量也越多,一个服务发生变更时,注册中心需要推送大量消息,严重占用集群带宽」
一个请求的处理过程中,比较耗时的基本都是在IO部分,包括网络IO和磁盘IO,所以一般针对 数据库、磁盘、依赖的第三方服务这些地方的耗时即可
将日志统一上传到集中存储中,比如es,查看时直接带着requestId即可以把整条调用链查询出来(存储参考ELK)
❝全量打日志时,会对磁盘IO造成较大压力,所以需要进行采样打印,比如只打印“requestId%10=0”的日志 另外,由于打日志会影响接口响应耗时,可以提供一个开关,正常时关闭打印采集,当发生异常时再打开收集日志❞
「四层负载均衡(LVS)」
工作在传输层,性能较高,LVS-DR模式甚至可以在服务端回包时直接发送到客户端而不需要经过负载均衡服务器
「七层负载均衡(nginx)」
工作在应用层,会对请求URL进行解析,进行更细维度的请求分发。并且提供探测后端服务存活机制(nginx_upstream_check_module模块),nginx配合consul还可以实现新增节点自动感知;配置比四层负载均衡更加灵活
在高并发场景下,可以在入口处部署LVS,将流量分发到多个nginx服务器上,再由nginx服务器转发到应用服务器上
客户端中通过注册中心获取到全量的服务节点列表,发送请求前使用一定的负载均衡策略选择一个合适的节点
「静态策略」
选择时不会考虑后端服务的实际运行状态
「动态策略」
客户端上监控各后端服务节点状态。根据后端服务的负载特性,选择一个较好的服务节点
部署在应用服务和第三方系统之间,对调用外部的api做统一的认证、授权、审计以及访问控制
「性能」
「扩展性」
可以方便在网关的执行链路上增加/删除一些逻辑
在分布式系统中,由于某个服务响应缓慢,导致服务调用方等待时间过长,容易耗尽调用方资源,产生级联反应,发生服务雪崩。
所以在分布式环境下,系统最怕的反而不是某一个服务或者组件宕机,而是最怕它响应缓慢,因为,某一个服务或者组件宕机也许只会影响系统的部分功能,但它响应一慢,就会出现雪崩拖垮整个系统。
放弃部分非核心服务或部分请求,保证整体系统的可用性,是一种有损的系统容错方式,有熔断降级、开关降级等
服务调用方为调用的服务维护一个有限状态机,分别有关闭(调用远程服务)、半打开(尝试调用远程服务)、打开(不调用远程服务,直接返回降级数据)
在代码中预先埋设一些开关,控制时调用远程服务还是应用降级策略。开关可以通过配置中心控制,当系统出现问题需要降级时,修改配置中心变更开关的值即可
代码埋入开关后,需要验证演练,保证开关的可用性。避免线上出了问题需要降级时才发现开关不生效
在高负载时,核心服务不能直接降级处理,为了保证服务的可用性,可以限制系统的并发流量,保证系统能正常响应部分用户的请求,对于超过限制的流量,直接拒绝服务。
「时间窗口算法」
限制单位时间的流量,比如限制1秒1000次请求,超出部分拒绝服务。下一个1秒时重置请求量计数
❝在前后两个窗口的边界区如果有大流量可能不会触发限流策略
将窗口细化分为多个小窗口,比如要限制1秒1000的请求,将1秒的窗口划为5个大小为200ms的小窗口,每个小窗口有单独的计数,请求来时,通过判断最近5个小窗口的请求总量是否触发限流。
时间窗口算法可能会出现短时间的集中流量,为了使流量更加平滑,一般可采用漏桶算法和令牌桶算法
「漏桶算法」
漏桶算法其实非常形象,如下图所示可以理解为一个漏水的桶,当有突发流量来临的时候,会先到桶里面,桶下有一个洞,可以以固定的速率向外流水,如果水的从桶中外溢了出来,那么这个请求就会被拒绝掉。具体的表现就会向下图右侧的图表一样,突发流量就被整形成了一个平滑的流量。
实现可参考ratelimit
「令牌桶算法」
请求处理前需要到桶中获取一个令牌,如果桶中没有令牌就触发限流策略
桶中按一定速率放入新令牌,比如限制1s访问次数1000次,那每隔(1/1000)s=1ms的时间往桶中加入新令牌
同时注意桶中的令牌总数要有一个限制。
漏桶算法在突发流量时,流量先缓存到漏桶中,然后匀速漏出处理,这样流量的处理时间会变长;而令牌桶在一段空闲期后,会暂存一定量的令牌,能够应对一定的突发流量。
「过载保护」
以上的限流方案,都是设置一个限流阈值,当流量超过该阈值就阻止或减少流量就继续进行。但合理设置限流阈值并不容易,同时也很被动,比如设置限流阈值的依据是什么?当服务扩容或代码优化后阈值是否需要重新设置?
因此我们需要一种自适应的限流算法,能根据系统当前的负载自动决定是否丢弃流量。我们可以计算系统临近过载时的吞吐作为限流的阈值,进行流量控制
根据利科尔法则,系统的吞吐量 = 系统请求新增速率 x 请求平均耗时。
我们可以每500ms为一个bucket,Pass为每个bucket成功请求的数量,rt为bucket中的平均响应时间;维护一个大小为10bucket的滑动窗口,及统计最近5s的请求情况,触发过载保护时,获取滑动窗口内Pass最大的bucket,该bucket的pass * rt就是系统最大吞吐
服务进程维护一个变量inflight,新请求进来时加一,处理完成时减一
使用CPU使用率/内存使用率作为过载信号;使用一个独立的进程采样,每隔100ms触发一次采样,计算峰值时,可采用滑动平均值,避免毛刺现象。