性能优化原则
问题导向
不要过早进行优化,避免增加系统复杂度,同时也浪费研发人力
遵循二八原则
要抓住主要矛盾,优先优化主要的性能瓶颈
优化需要有数据支撑
要时刻了解你的优化让响应时间减少了多少,提升了多少的吞吐量。可以使用平均值、极值(最大/最小值)、分位值等作为统计的特征值
高可用性设计
系统设计
遵循"design for failure"的设计原则,未雨绸缪,具体优化方法有故障转移、超时控制、降级、限流
故障转移
对等节点直接可直接转移
节点分主节点和备用节点,转移时需要进行主备切换
什么时候进行主备切换?
一般采用某种故障检测机制,比如心跳机制,备份节点定期发送心跳包,当多数节点未收到主节点的心跳包,表示主节点故障,需要进行切换。
如何进行切换?
一般采用paxos、raft等分布式一致性算法,在多个备份节点中选出新主节点。
超时控制
在分布式环境下,服务响应慢可能比宕机危害更大,失败只是瞬时的,但调用延迟会导致占用的资源得不到释放,在高并发情况下会造成整个系统奔溃。
如何合理设置超时时间?
收集系统之间的调用日志,统计比如说 99% 的响应时间是怎样的,然后依据这个时间来指定超时时间。
降级
关闭整个流程中非核心部分,保证主流程能稳定执行(详细见后文)
限流
限制单位时间内的请求量,超过的部分直接返回错误 (详细见后文)
系统运维
灰度发布
通过线上流量观察代码变更带来的影响
故障演练
对系统中的部分节点/组件人为破坏,模拟故障,观察系统的表现。为了避免对生产系统造成影响,可以先部署另外一套与线上环境一摸一样的系统,在这上面进行故障演练
系统可用性度量指标
MTBF:两次故障之间的间隔,这个时间越长,系统越稳定。
MTTR:故障平均恢复时间,时间越短,故障对用户影响越小
可用性=MTBF / (MTBF+MTTR)
高扩展性设计
存储层
分库分表,按业务和数据纬度对库表进行水平/垂直拆分,突破单机限制。有以下两点需要注意:
最好一次性确定好节点/分表数量,避免频繁迁移数据
拆分后尽量避免使用事务,分布式事务需要协调各个模块的资源,容易出问题
业务层
按业务纬度,接口重要性纬度和请求来源等多个维度对服务进行拆分和隔离
数据库高可用设计
数据库有两个大方面的优化方向:
提升读写性能;
增强存储扩展能力,应对大数据量的存储需求
池化技术
池化是一种空间换时间的思路。预先创建好多个对象,重复使用,避免频繁创建销毁对象造成的开销
如何设计一个数据库连接池?
维护池中连接数量和保证连接可用性是连接池管理的两个关键点。
请求获取连接流程
初始化连接池时,需要指定最大连接数和最小连接数
连接池当前连接数 < 最小连接数: 创建新链接处理数据库请求
最小连接数 < 连接池当前连接数 < 最大连接数: 优先复用空闲连接,否则创建新连接处理请求
连接池连接数 > 最大连接数: 等待一段时间(自旋/线程休眠),超时还没有连接可以直接抛错
保证连接可用性
心跳机制,定期检查连接是否可用
每次使用连接前,先检验下连接是否可用,再进行SQL请求
如何设计一个线程池?
指定一个最大线程数量,并利用一个有限大小的任务队列,当池中线程数量较少时,直接创建新线程去处理任务,当池中线程达到设置的最大线程数量后,可以将任务放入任务队列中,等待空闲线程执行。
合理设置最大线程数量
CPU密集型任务,保持与CPU核数相当的线程就可以了,避免过多的上下文切换,降低执行效率
IO密集型,可以适当放开数量,因为在执行IO时线程阻塞,CPU空闲下来可以去执行其他线程的任务
等待队列必须有界,若不限制大小可能会导致队列任务数量过多,触发Full GC,直接导致服务不可用
必须监控等待队列中的任务数,避免最大线程数设置不合理导致大量任务留在等待队列中得不到执行
主从读写分离
分离后,从库可以用作数据备份,也可用于处理读请求,减少单机压力;
注意从库数量,从库越多,主库需要越多的资源用于数据复制,同时还占用主库网络带宽,一般最多挂3-5个从库
主从之间存在延迟,在某些场景下从库可能读不到最新的数据会导致错误。
处理方法:
1. 使用缓存,在更新数据后同时更新缓存,读的时候直接读缓存
2. 写主库后发送可以发送完整数据记录到消息队列,避免后面读库操作
3. 需要强一致的读请求直接读主库
需要对主从延迟进行监控
最好屏蔽分离后导致访问数据库方式的改变
1. 以基础库中间件的方式直接引进项目代码中,访问时直接访问该中间件,主流方案有TDDL、DDB等
2. 单独部署数据库代理层,业务代码使用时访问代理层,代理层转发到指定的数据源,有Cobar、Mycat、Atlas、DBProxy等,这种方案多了一次转发,性能上有一些损耗
分库分表
随着存储量变大,单机写入性能和查询性能会降低,分库分表能提高读写性能;按模块分库,实现不同模块的故障隔离
拆分方式
垂直拆分
将数据库的表拆到不同数据库中,一般可以按业务来拆分,专库专用,将业务耦合度较高的表放到同一个库中
水平拆分
将单一表的数据按一定规则拆分到多个表中,需要选一个字段作为分区键。一般通过对某个字段hash进行分区或按某个字段(比如时间字段)的区间进行分区
如何保证ID全局唯一?
可以开发一个单独的分布式发号器
使用发号器而不是UUID的原因?发号器的好处?
同一个发号器生成的id能保证有序
能在id中某一部分定义业务含义,有利于问题排查
常见的发号算法
snowFlake:64bit 的二进制数字分成若干部分,每一部分都存储有特定含义的数据,比如说时间戳、机器 ID、序列号等等,最终生成全局唯一的有序 ID。
发号器的实现
服务启动时,先向etcd注册中心获取当前机器号列表,计算得到未使用的机器号A,向etcd注册当前地址为机器号A的服务地址,该注册信息有ttl,需要定期进行续租操作,保证ttl不过期
服务向etcd获取该机器号最后的上报时间戳,若本地当前时间戳< 最后的上报时间戳,暂时拒绝发号请求,直到当前时间戳 > 上报时间戳。避免时间回拨问题
发号器依赖服务节点本地时间戳,各节点时间戳可能没法准确同步,当节点重启时可能出现时间回拨现象
服务可使用单进程处理id生成逻辑,避免加锁,线程模型可参考redis实现
每次生成ID后,本地会记录一个last_time(最后发号时间戳), 定期会上报etcd这个last time
发号器实现tips
ID中有几位是序列号,表示在单个时间戳内最多可以创建多少个ID,当发号器的QPS不高时,单个时间戳只发一个ID,会导致ID的末位永远是1;这个时候分库分表使用ID作为分区健会导致数据不均匀
变大时间戳单位,比如记录秒而不是毫秒
序列号的起始号设置为随机数
其他注意事项
最好屏蔽分离后导致访问数据库方式的改变(同上)
水平拆分后,为了避免全分区查询,尽量带上分区键;若查询条件中没有分区键,可创建查询条件字段与分区键的映射表,查询时先通过映射表找到分区键,再通过分区键去数据表中查询
水平拆分后,对于多表join的需求可直接把多个表的数据分别先查出来后在业务代码中进行关联
水平拆分后,对于一些聚合操作,比如count、sum,可以直接将聚合后的数据单独存储在一张表中或记录到redis中
关系型数据库和NoSQL
关系型数据库能提供强大的查询功能、事务和索引等功能;NoSQL可在某些场景下作为关系型数据库的补充:
提升写入性能,比如某些NoSQL使用LSM作为存储结构。写入时完全不需要访问磁盘,可提高写入性能,但这是在牺牲读性能的前提下。
LSM相关介绍:
https://blog.csdn.net/jinking01/article/details/105377370
https://blog.csdn.net/SweeNeil/article/details/86482781
倒排索引,通过「分词-记录ID」的映射,避免关系型数据库在模糊查询场景下扫描全表
某些NoSQL,比如mongodb,设计之初就考虑到了分布式和大数据存储的场景,具备了副本集、数据分片和负载均衡(当分片未均匀分布在各节点上时,会启动rebalance)的特性
缓存
缓存与数据库一致性保证
先操作缓存,再操作数据库
只更新缓存,不直接更新数据库,通过批量异步的方式来更新数据库。
主要用于底层存储性能与缓存相差较大,比如操作系统的page cache就使用这种方式避免磁盘的随机IO
先操作数据库,再操作缓存
Cache-Aside
Read-Through/Write-Through 与Cache-Aside相比,多了一层Cache-Provider,程序代码变的更简洁,一般在设计本地缓存可采用这个方式
操作缓存时,要删除而不是更新缓存
由于操作数据库和操作缓存之间没有原子性,所以如果采用更新缓存的方式可能导致最终缓存不是最新数据
若缓存值需要大量计算得到,更新频率高时,可能计算的缓存还没被读过又被更新了,浪费性能。
删除缓存失败会影响一致性
删除失败时,将失败的key存到消息队列中,异步重试删除
通过canal等工具监听binlog日志,将更新日志发送到消息队列中,异步删除相关的key
缓存设置过期时间,过期后重新加载到最新数据
缓存的高可用设计
客户端方案
数据分片,将数据分散到多个缓存节点,一般有hash取模和一致性hash两种分片算法。
hash取模:读写时,客户端对key进行hash计算,并对缓存节点数取余,计算出数据所在的节点。该算法实现简单;但当缓存节点个数变化时,容易导致大批量缓存失效。
一致性hash算法:一个有2^32个槽的hash环,使用一定的hash函数,以服务器的IP或主机名作为键进行哈希,这样每台服务器就能确定其在哈希环上的位置;读写时,使用相同的hash函数对key进行hash,得到哈希环上的一个位置,顺时针查找到的第一个服务器,就是该key所在的缓存节点。
1. 节点数量变化时,只有少量的key会漂移到其他节点上,不会导致大批量失效
2. 某个节点故障时,该节点上的缓存会全部压到后一个节点上,如果后一个节点承受不了,会继续引发故障,如此下去,最后造成整体系统的雪崩。可通过虚拟节点解决
3. 在集群中有两个节点 A 和 B,客户端初始写入一个 Key 为 k,值为 3 的缓存数据到 Cache A 中。这时如果要更新 k 的值为 4,但是缓存 A 恰好和客户端连接出现了问题,那这次写入请求会写入到 Cache B 中。接下来缓存 A 和客户端的连接恢复,当客户端要获取 k 的值时,就会获取到存在 Cache A 中的脏数据 3,而不是 Cache B 中的 4。所以必须设置缓存的过期时间
缓存节点设置主从机制,在主节点故障时客户端能自动切换
在客户端本地缓存少量热点数据,减少对缓存节点的压力
中间代理层方案
对缓存的所有读写请求都通过代理层完成,代理层提供路由功能,内置了高可用相关逻辑,保证底层缓存节点的可用
服务端方案
参考redis的哨兵+cluster实现
消息队列
异步处理
将请求先放入队列中,快速响应用户,之后异步通知用户处理结果
削峰填谷
避免高峰写时导致请求处理的延迟
解耦系统模块
多个模块之间解耦开来,通过发布订阅消息队列通信。各自系统的变更不会影响到另外一个
使用时注意事项
避免消息队列数据堆积
添加对应监控
启动一个监控程序,定时将监控消息写入消息队列中,在消费端检查消费时与生产时间的时间间隔,达到阈值后发告警
通过消息队列提供的工具对队列内数据量进行监控
减少消息延迟
优化消费代码
增加消费并发度
避免消息丢失(以kafka举例)
生产端
失败重试
ack设置为all,保证所有的ISR都写入成功
消息队列服务端
保证副本数量和ISR数量
消费端
确保消费后再提交消费进度
避免消息生产/消费重复(以kafka举例)
生产端
更新kafka版本,利用kafka的幂等机制和事务机制保证消息不重复
消费端
消息id+业务幂等判断
其他tips
使用poll方式消费时需注意当无新消息时消费进程空转占用cpu,拉取不到消息可以等待一段时间再来拉取,等待的时间不宜过长,否则会增加消息的延迟。
一般建议固定的 10ms~100ms,也可以按照一定步长递增,比如第一次拉取不到消息等待 10ms,第二次 20ms,最长可以到100ms,直到拉取到消息再回到 10ms。
分布式服务
一体化架构的痛点
数据库会成为性能瓶颈,MySQL客户端并发数量有限制
增加研发沟通的成本,抑制了研发效率的提升。
降低系统运维效率,随着系统代码量增多,一次构建的过程,花费的时间可能达到十几分钟;而且一次小改动可能会影响系统的其他模块
服务拆分原则
高内聚,低耦合,每个服务只负责自己的任务
关注拆分的粒度,在拆分初期,可先粗粒度拆分,随着团队对业务和微服务理解的加深,再逐渐细化
拆分不能影响日常功能迭代,可以先剥离独立的边界服务,减少对现有业务的影响,同时也能作为一个练习、试错的机会。
优先拆分被依赖的服务
接口要注意可扩展,兼容旧的请求方式,避免接口更新后,其他服务调用时报错
微服务化后引入了额外的复杂度
需要引入服务注册中心,并管理监控各个服务的运行状态
引入服务治理体系。对其他服务调用时,需要通过熔断、降级、限流、超时控制等方法,避免被调服务故障时,影响整个调用链。
引入监控系统。
分布式追踪工具,分析请求中的性能瓶颈
服务端监控报表,分析服务和资源的宏观性能表现
rpc选型考虑
采用合适的io模型,提升网络传输性能,一般采用io多路复用
选择合适的序列化方式
序列化选型考虑:
1. 是否跨语言、跨平台
2. 考虑时间和空间上的开销
3. 是否有足够的扩展性,避免稍微改动一个字段就会导致传输协议的不兼容,服务调用失败
常见的序列化方案
json/xml
优点:简单,方便,无需关注要序列化的对象格式;可读性强
缺点:序列化和放序列化速度较慢;占用空间大
protobuf
优点:性能好,效率高;支持多种语言,配合IDL能自动生成对应语言的代码
缺点:二进制格式传输,可读性差
服务注册与发现
假设调用者直接存储服务地址列表,当服务节点变更时,需要调用者配合,所以需要一个服务注册中心,用于存储服务节点列表,并且当服务端地址发生变化时,可以推送给客户端。常用的注册中心有zookeeper、etcd、nacos、eureka...
服务状态管理
rpc服务到注册中心完成注册,注册有时效限制
rpc服务每隔一定时间间隔需要向注册中心发送心跳包,注册中心收到后更新服务节点的过期时间
到了过期时间还未收到rpc服务节点的更新心跳包,认定该节点不可用,会将这个消息通知客户端
注意事项
客户端与服务端之间也需要维护一个心跳包活机制
因为有可能服务端与注册中心网络正常,但客户端与服务端之间网络不通,这种时候需要把该服务节点从客户端的节点列表中剔除。
需要采取一定的保护策略避免注册中心故障影响整个集群
客户端在收到「节点不可用」消息后,可以先主动ping下服务端,确认不可用后再剔除
自研注册中心时,当下线的节点数量超过一定数量时,可停止继续摘除服务节点,并发送相关告警
注册中心管理服务数量越多,订阅的客户端数量也越多,一个服务发生变更时,注册中心需要推送大量消息,严重占用集群带宽
控制一组注册中心管理的服务数量
扩容注册中心集群
规范注册中心推送消息的使用,比如服务变更时只推送变更的节点,而不是把整个最新可用列表推送出去,减少推送数据量
注册中心做削峰处理,避免并发流量过高
全链路追踪
哪些地方需要打日志?
一个请求的处理过程中,比较耗时的基本都是在IO部分,包括网络IO和磁盘IO,所以一般针对 数据库、磁盘、依赖的第三方服务这些地方的耗时即可
如何打日志?
同一个服务中,为请求添加一个日志标示符requestID,之后的日志中都带上requestID
采用切面编程的方法,在IO操作前后记录下时间,并计算出耗时
当一个请求处理需要跨多个服务时,可以用同一个requestId将多个服务的日志串起来,同时每个服务注册一个spanId,串起请求过程中经过的spanId,表示服务之间的调用关系
如何查看日志?
将日志统一上传到集中存储中,比如es,查看时直接带着requestId即可以把整条调用链查询出来(存储参考ELK)
全量打日志时,会对磁盘IO造成较大压力,所以需要进行采样打印,比如只打印“requestId%10=0”的日志
另外,由于打日志会影响接口响应耗时,可以提供一个开关,正常时关闭打印采集,当发生异常时再打开收集日志
负载均衡
服务端负载均衡
四层负载均衡(LVS)
工作在传输层,性能较高,LVS-DR模式甚至可以在服务端回包时直接发送到客户端而不需要经过负载均衡服务器
七层负载均衡(nginx)
工作在应用层,会对请求URL进行解析,进行更细维度的请求分发。并且提供探测后端服务存活机制(nginx_upstream_check_module模块),nginx配合consul还可以实现新增节点自动感知;配置比四层负载均衡更加灵活
在高并发场景下,可以在入口处部署LVS,将流量分发到多个nginx服务器上,再由nginx服务器转发到应用服务器上
客户端负载均衡
客户端中通过注册中心获取到全量的服务节点列表,发送请求前使用一定的负载均衡策略选择一个合适的节点
负载均衡策略
静态策略
选择时不会考虑后端服务的实际运行状态
轮训
加权轮训
随机
源地址hash:自于同一个源IP的请求将始终被定向至同一个后端服务
动态策略
客户端上监控各后端服务节点状态。根据后端服务的负载特性,选择一个较好的服务节点
最少连接
加权最少连接
最短延迟
基于本地的最小连接
API网关
入口网关
协议转换。为客户端提供统一的接入地址和协议,屏蔽掉后端服务不同的协议细节
植入服务熔断、服务降级、流量控制、分流控制等服务治理相关的策略
认证和授权。统一处理不同端的认证和授权,为后端服务屏蔽掉认证细节
黑白名单限制
出口网关
部署在应用服务和第三方系统之间,对调用外部的api做统一的认证、授权、审计以及访问控制
API网关实现/选型考虑
性能
使用IO多路复用提高性能
采用多线程池避免多个服务之间相互影响(不同服务使用不同的线程池,在同一个服务中针对不同接口设置不同的配额)
扩展性
可以方便在网关的执行链路上增加/删除一些逻辑
服务降级
在分布式系统中,由于某个服务响应缓慢,导致服务调用方等待时间过长,容易耗尽调用方资源,产生级联反应,发生服务雪崩。
所以在分布式环境下,系统最怕的反而不是某一个服务或者组件宕机,而是最怕它响应缓慢,因为,某一个服务或者组件宕机也许只会影响系统的部分功能,但它响应一慢,就会出现雪崩拖垮整个系统。
放弃部分非核心服务或部分请求,保证整体系统的可用性,是一种有损的系统容错方式,有熔断降级、开关降级等
熔断降级
服务调用方为调用的服务维护一个有限状态机,分别有关闭(调用远程服务)、半打开(尝试调用远程服务)、打开(不调用远程服务,直接返回降级数据)
***关闭->打开:当调用失败的次数累积到一定的阈值时,熔断状态从关闭态切换到打开态。一般在实现时,如果调用成功一次,就会重置调用失败次数。
打开->半打开:打开状态时,启动一个计时器,计时器超时后,切换成半打开状态;也可以设置一个定时器,定期探测服务是否恢复
半打开->打开:半打开状态下,如果出现调用失败的情况,切换回打开状态
半打开->关闭*:在半打开状态下,累计一定的成功调用次数后,会切换回关闭状态
开关降级
在代码中预先埋设一些开关,控制时调用远程服务还是应用降级策略。开关可以通过配置中心控制,当系统出现问题需要降级时,修改配置中心变更开关的值即可
代码埋入开关后,需要验证演练,保证开关的可用性。避免线上出了问题需要降级时才发现开关不生效
流量控制
为什么要限流?
在高负载时,核心服务不能直接降级处理,为了保证服务的可用性,可以限制系统的并发流量,保证系统能正常响应部分用户的请求,对于超过限制的流量,直接拒绝服务。
在哪进行限流?
API网关,可以对系统整体流量做塑形
在RPC服务中引入限流策略,避免单个服务被过大流量压垮
从哪些纬度进行限流?
对系统单位时间请求量做限制
对单接口单位时间请求量做限制
对单个客户端单位时间内请求量做限制
如何进行限流?
时间窗口算法
*固定窗口*
限制单位时间的流量,比如限制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触发一次采样,计算峰值时,可采用滑动平均值,避免毛刺现象。
*过载保护流程
设置当CPU使用率超过80%时,触发过载保护,请求进来时,判断passrt < inflight, 否则拒绝该请求
过载保护触发后,需要设置一个持续时间,不能CPU一降立即接触过载保护。否则一个短时间的CPU下降可能导致大量的请求被放行,严重时会打满CPU。
持续时间过后,重新根据CPU利用率决定是否继续过载保护