影响:
产生了实际的资金损失;后续运维计量不可用时间为35分钟;
最后定级为S0级别故障;影响较大。
Part2.目标
这个事情影响较大,领导比较重视系统的可用性。新一年的质量OKR是4给9的可用性,是52.3分钟;
当然我们定目标之前因为上面这个背景问题损失了35分钟,所以指定目标么,总得有挑战,自己定的目标是:5个9的可用性,即一年不可用时间为5.2分钟。
Part3.挑战
说实话一开始要觉得做这个事情,也是不知道要怎么做的,大伙儿也都没有正儿八经的可用性调优经验。
整个过程也是摸着石头过河,下面就是我们调优经验的总结。
具体措施过程如下:
Part4.措施
定义可用性
第一步:定义【可用性】
【非故障的节点在合理的时间内返回合理的响应(不是错误和超时的响应,不保证是最新的数据)】。
可用性的两个关键:一个是合理的时间,一个是合理的响应。
合理的时间指的是请求不能无限被阻塞,应该在合理的时间给出返回。
合理的响应指的是系统应该明确返回结果并且结果是正确的,这里的正确指的是比如应该返回50,而不是返回40。
总结来说:分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。并且对于核心的功能,其要在合理的时间有合理的响应。
基于这个定义,在定义可用性时,我们要做的事情如下:
梳理功能,找各业务方代表确认,哪些是核心功能,哪些是非核心功能可以放弃。
核心功能,在极端情况下,如果要被降级以保证可用性,此时降级的策略是怎么样的?或者说降级时业务逻辑怎样?
以上2点讨论清楚,然后和部门领导check下就ok了。
第二步:量化【可用性】
这也要结合具体的业务功能,比如排列一下各个接口的重要程度,然后拍脑袋定定一下对应权限。
然后和运维同学确认下异常时的计量方式,一般是异常时相关接口的 某1分钟(可以调整监控粒度)域名可用性*业务功能权重 可以算出来 这分钟损失了多少时间。
上面2个点确认清楚 大家就对可用性的概念有了共识了。
改造大纲
这些措施项并不是一开始就有这个全貌,是基于结果梳理的:
Part4.1.缓存调优
4.1.1.分布式cache-redis侧
已有是redisCluster的HA架构,增加了一个降级redis来单独存放核心业务实体以提高整体可用性。
之前导致的不可用异常原因是因为核心业务数据及非核心业务数据都放在一个redis,而正是因为非核心的业务数据因单点大热key的异常导致了redis的不可用。
基于这一点,根据业务场景将核心业务实体的redis数据,除了放在原有redis中;单独搭了一套降级redis,仅将这部分核心业务数据放进这个降级redis中。
那么非核心业务数据导致主redis异常时,业务层代码再降级到降级redis。
这一步的改造,可以背景问题中的不可用问题。也是我们最初所想到的。
4.1.2. 本地进程缓存侧
重点放在本地进程缓存侧的改造:
4.1.2.1.本地缓存调优:3个策略
3个策略:更新策略(TTL)、一致性策略、降级策略
1.TTL&更新策略:
改造之前是:对于最核心的业务实体是3分钟过期,用户侧线程同步阻塞更新策略;改造为异步线程增量更新,TTL改为2小时过期。
这么改的背后的动力还是GC频繁,GC的细节在下一章会细讲。
增量更新的模式是新增了一个变更事件表,(整体架构是CQRS架构),
在Commend工程里进行业务实体变更时,加一个AOP切面,捕获变更实体ID,将id写入变更实体表,
然后整个集群【在异步线程】每2分钟去轮询这个变更事件表的增量变更id,然后更新内存中的这些过期实体。
改了之后效果明显:上下游改善(用户请求45ms>30ms依赖服务集群压力减少40台缩容为20台),ygc改善(次数15秒一次>10秒一次时间40ms>30ms)。
2.一致性策略:
改造之前TTL是3分钟过期:
问题1:若某key有变更,最慢的机器3分钟才能刷新;问题2:集群间这个key过期时间不同步,不能保证用户请求的单调一致性;
改造后:采用异步异步线程增量更新,集群最慢2分钟更新,但整个集群同步节奏一致,corn表达式在偶数分钟第一秒获取增量变更id,,1秒加盐分散对redis的压力防雪崩。
也就是说增量变更部分,整个集群在2秒内可以达到一致性状态,可以保证用户请求单调一致性或会话一致性(用户点击操作,两次点击直接相隔大于3秒)。
3.更新时2级降级+3阶段查询:
更新时2个降级准备:
第一级:刷新redis时,对于核心key, 除了刷入主redis,也刷入降级redis, 注意:这里只针对这部分核心的key
第二级:有个异步线程周期性扫ehcache中的所有核心业务实体,json序列化到本地文件系统。
这一步操作要结合自身业务场景,如果核心业务实体是千万级别,可以考虑将顶部热游做json序列化, 如果业务实体不多,则可以考虑将全部实体都做json序列化。
这一级降级异常重要,若周边依赖系统都挂了(因下游系统不可以,网络异常等致命问题),此时可基于本地文件系统做基础实体返回,保证业务系统基本可用。
查询时3阶段查询:
第一阶段:
先查询本地缓存,若差得实体,则直接返回;若本地缓存为空或者实体过期,此时进入二阶段。
这里务必注意: 查询本地缓存时,一般的本地缓存中间件都会使用惰性过期机制,也就是说get这个key时,若该key过期,则会释放这个key,
此时千万不能使用原生的get API,要获取中间件底层的存储数据结构,如ehcache地产的componentStore, 进行get操作,本质思路是不触发过期key的释放。
第二阶段(核心3级降级构建):
因第一阶段未差得key,此时要发起远程redis的查询,若远程redis不可用,则取降级redis的key;
若此时因为网络原因不能访问redis, 或者两级redis都不可用,则取本地缓存中过期实体(再set进本地缓存,ttl拉长3分钟);
若本地缓存中也没有实体则取本地文件系统做最后尝试,即取本地文件系统json文件,构建为内存实体,set进本地缓存;
通过上面三级降级,只要某实体之前被成功构建过一次,被成功加载进内存一次且被json序列化到本地文件系统;
那么在未来的查询中,只要用户请求到这台机器链路是通的,即便周边系统全挂,那么这个实体一定能够查询返回。
第三阶段:再去本地缓存中get一次,此时基本能差得实体,若没有则返回空。
4.1.2.2.本地缓存调优:4个防护
4个防护:防雪崩(冷热分离)、防击穿、防穿透、防污染
1.启动时缓存预热:
即key的冷热分离,启动时对热点数据进行启动预热。
对于热点数据的构建,可以有很多模式:
可以是单独一个服务来维护热点数据;
简单点的,可以加一个异步线程,周期性扫描本地ehcache中的实体,根据命中率统计排序,取顶部一定比例的数据作为热点数据列表存储到本地文件系统,在启动时则取改列表进行预热构建。
2.防雪崩:
缓存雪崩定义:【 当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力,造成数据库后端故障,从而引起应用服务器雪崩。】
即本质是缓存大面积失效,导致请求直连数据库,导致后端组件故障从而引起全局不可用的场景。这里我想扩展一下这个定义:
核心2点:1是短时间内大面积key失效,这个失效可以是缓存组件不可以,也可以是某段时间内确实有大面积的key变更;
2是瞬时压力请求后端,这个后端的定义是指rt性能比当前缓存rt低一个数量级的后端:
如果当前是jvm进程缓存,后端可以是指redis也可以是指mysql也可以是指依赖的一个微服务;如果当前是redis后端可以是指mysql也可以是单独的一个微服务。
在本例中是指依赖的一个微服务。
具体场景是在间隔2分钟内,变更事件表中有大量多的key,此时整个集群都需要刷新,对下游服务的请求压力巨大,可能引起下游服务挂掉。
基于这个背景,解决缓存雪崩的思路就是每偶数分钟获取变更事件表的变更key集合时,请求下游实体服务时,基于加盐的方式(random1000ms)分散对下游集群的瞬时压力,防止其挂掉。
3.热key防击穿:
击穿一般是指的热key,在业务高点时过期导致请求直接打到高能耗媒介(如mysql);
解决思路2个方向:
1个方向是避开业务高点,比如发布时避开业务高点,在凌晨发布,或者发布时有缓存预热;
第2个方向是key不要在用户侧线程同步更新,即缓存中的key的ttl比更新周期长,用户请求时总能拿到key,更新在异步线程更新。
以是2个思路都可以解决击穿的问题,可以结合具体业务场景来定解决方案。
4.空key防穿透:
这个空值分2个场景:
一般场景是指在key的id范围内,某个key被删除了,此时要存空对象(注意区分null值);
但是更致命的往往是指有外部攻击,外部识别了我们的请求,然后遍历一个很大的id范围来捞数据,此时可能很多很多的key都是不存在的,
因为#1防穿透逻辑会导致内存爆掉非常危险,因此很有必要限定一个范围,
比如if(id<=0 || id>max(DB_id)+10000) return null;
这个max(DB_id)需要定期异步更新,比如10分钟更新一次,而10000则是基于业务10分钟内最大会增加的key。
以上2步基本可以做到穿透保护。
5.缓存污染:
缓存污染的定义是指基于某个key拿到实体后,对实体进行了修改,导致后向请求业务异常。
这个问题的解决方案2个方向:
方向1是基于缓存get时进行深拷贝,一般缓存中间件都有这个配置项,这个方法从结果上绝对可以规避污染的问题,
但是每次都深拷贝一个对象对YoungGC非常不友好,特别是在业务高点,所以一般也不建议用这个方式来解决。
方向2还是在开发阶段来规避,比如 1.内部培训强调不能对缓存数据进行变更 2.基于模板方法模式规范化使用ehcacheapi:3.代码审核
4.1.2.3.缓存调优-效果:
以上的缓存调优帮助我们规避了一次线上S0的故障:
在2020年国庆节第一天,我们的redis中的key大面积失效(包括降级redis),导致集群去redis中拿实体时都为空
(原因是国庆前一周最后一次发布变更刷入redis的逻辑出了问题,而redis中的key又是7天失效),
因为有后面2级降级(内存过期实体以及本地json文件),使得实体都可以返回展示,
帮我们逃过了一劫。太幸运!~~~~
4.1.2.4.一句话总结:
3分钟过期同步阻塞更新模式改为2小时过期异步增量更新模式提升了上下游性能;
2级降级+3阶段查询极大提高了本地cache的可用性;4个防护进一步提高了本地cache的可用性;
最终改造帮我们规避了一次线上S0故障。
Part4.2.GC调优
3个方向,降空间 降时间 降频
4.2.1.降空间:
1.去除命中率低的key:全面梳理业务,根据命中率统计,一些命中率低的业务场景去除缓存逻辑;
2.优化粗粒度key:对于某些粗粒度的key,根据DDD思想,对粗粒度缓存进行瘦身,List实体列表改为List id列表。
3.打薄上层业务缓存:某些接口上层有业务缓存,依赖的service里又有缓存,可以考虑去除上层业务缓存。
4.只缓存一份实体,基于GraphQL进行查询:
在我们的业务场景里,某个实体有30个字段,实体有10000个,缓存的key是 ID-SceneType,
如 key1:1001-Simple,代表1001缓存实体 但是这个Bean里只有10个字段;key2:1001-Detail, 代表1001缓存实体,里面有全部30个字段;
类似的type有8种场景类型,也就是说在业务高点极端情况下,10000个实体最多会缓存80000个;这是极大的空间浪费。
针对这个场景,其优化思路就是缓存10000个实体的全量字段,查询时根据要的场景类似临时拿到字段即可。
这个场景的优化,帮我们大大降级了缓存空间。
5.惰性过期释放优化:
一般本地缓存都是惰性过期机制,即没有异步线程扫码,某key过期了,依赖用户侧线程,get到这个key,判断下这个key的ttl是否有效,没有则释放这个key;
也就是说,特别是冷端的key, 某个极其冷门的key被定向搜索时查询到了,缓存也构建了,如果key的ttl为3分钟,
但是如果这key后向一直没被访问到,虽然key在内存中早已过期,在应用重启之前,这个key会一直hold在内存中,
这便是极其典型的站着茅坑不LS啊。即便后面被访问到,也仅仅是判断过期释放了这个key,然后有会新构建一次这个key,恶性循环了....;
所以针对冷端key惰性过期不释放这个场景,不妨参考下redis的内存管理机制,如下图:
通过上面对比可知道,本地缓存的管理,对于冷端过期的key缺少了一道释放环节;
从中间件角度考虑,我想缓存中间件设计人员肯定也知道这个问题,但是要解决这个问题一定也是要一个异步线程来扫,
但这无疑提高了中间件的复杂度,至少ehcache中是没有这个异步线程的。
那么解决这个问题也是简单,就添加一个异步线程,周期性的触发,然后扫码ehcache中所有cache中的所有key。
在我们的业务场景中,这个触发机制会设计的稍微复杂一定,触发条件是if(距离上一次扫码间隔是否超过2小时 或 当前老年代占比是否66%)2个条件1个达到即可,
这里解释一下66%的设计思考,我们线上是使用的cms收集器,触发阈值是68%,
这个异步线程是5分钟调度触发去探测上面的2个条件,线上差不多是4小时触发一次CMS,老年代增长2%差不多需要10分钟,
即主要考虑在cms快要触发前能碰上一次ehcache的全量扫码来释放这些冷端过期的key,这样无疑能提高cms回收的效能。
6. 制定代码审核规范:
添加cache要讨论及代码审核,特别是粗粒度。
之前就是添加缓存没有约束,大家自己觉得这里慢了需要缓存就缓存一下,结果就是我们ehcache里配置了近100个cache,显而易见很多cache并不是核心重要的,
特别是有些功能时间久了不维护了,但是在去除cache时往往大家又非常谨慎,导致不必要的内存浪费。
GC降空间调优-效果:
线上是4G内存3G老年代,parallelGC, 每次是3G到2.4G, 仅能释放0.6G;
通过上面1-6的优化后效果是3G到0.8G,可以释放空间为2.2G,多释放了1.6G空间,效果是非常巨大和明显的。
4.2.2.降STW停顿时间
之前线上parallelGC的STW停顿时间,要5~6秒,吓人的很。
通过上面#1的优化,空间下降对于STW停顿时间是有很大帮助的,结果是5~6秒下降到3秒左右,原因是FGC时,存活对象少,意味着要计算的位置以及移动的对象就少,时间显然是变少的。
怎么从3秒到180ms的调优,分2步,第一步是parallelGC升级为CMSGC,这个效果非常明显了,3S直接到300ms,
4.2.3.降频:
降频是顺势而为的,通过#1#2的调优,GC频次也由平均1小时一次降到平均6小时一次。效果也是显著。
4.2.4. 一句话总结:
优化key的设计,以及异步线程进行冷端key的释放,有效降低FGC回收后的空间,且STW从6秒到3秒;
然后通过gc垃圾回收器的升级及gc参数的调优,STW到180ms,gc次数1小时1次降低到6小时1次。
Part4.3.隔离改造
隔离和降级其实有点关联,不过隔离更侧重于两个系统、组件彼此不相互影响,而降级则是关注于一个系统、组件的返回;
很多时候角度不一样这2个概念可以相互解释。隔离:其核心思想就是两者不相互影响,A系统异常不影响B系统的正常返回;更多时候是非核心逻辑异常不影响核心逻辑的返回。
改造从下述5个方向入手:
4.3.1.微服务拆分隔离
我们线上系统是14年开始建设,早期架构时是个单体,我也相信在那个时空背景下单体架构是比较合适的决策。
但是这个架构到了现在就不合适了,随着业务的发展规模的扩张,虽然我们有集群,但是问题也是显而易见的。
(关于DDD的是思考,为什么初创团队很难运用DDD进行落地,【【详见这篇文章】】)(这里有2本经典的书推荐大家,微服务架构设计模式 & 实现领域驱动设计);
关于单体架构的问题有很多,【【详见这篇文章】】,本质还是因为没有隔离带来各个维度上的问题,如可用性、可开发性、可维护性、可迭代性;
这里我们主要关注系统可用性,即因为多个业务领域的逻辑耦合在一起,导致非核心域的异常影响了核心域的可用性。
具体的做法是:基于DDD的思路梳理业务,分拆领域,规范限界上下文!
我们根据业务的梳理最终拆分了几个业务领域,然后进行拆分改造,当然改造的过程不会一蹴而就,会有很多问题,
具体问题详见(【【详见这篇文章】】:统一业务实体的困难,因为部分被外部使用;各过程初期拆的时候 因为时间 是简单复制,导致相同实体存在于各个工程中,基于sdk的方式解决)。
最终架构变成这样子(改造之前是所有逻辑耦合在一个系统中的):
改造过程虽然漫长,效果也不是马上显示的,但是这个意义对未来却是重大的。
除了技术层面可用性的提升,另一个层面,组内执行这个过程中,大家潜移默化地有了DDD的思想,这对于后面接到新需求,大家会去考虑:
这个业务是哪个领域的?聚合模型是否符合DDD规范?边界是否合理?代码应该放在哪个工程还是新建一个工程?等等,
也就是说 至少保证了新的需求代码都往微服务 DDD上面靠,而老的逻辑则慢慢改造或者下线摒弃,我相信未来总是长期向好的。
4.3.2.线程池隔离
线程池这个东西大家都熟悉,问题就是大家觉得自己熟悉,觉得仅仅只是个提升性能的工具,没有过多考虑,最终就成了滥用。
最典型的使用场景:某同学做一个需求,觉得有性能问题,然后决定用线程池,工程里找找是否有已有线程池,发现有一个,然后就复用这个线程池。【表情 捂脸流泪】
事物总是一分为二,在使用线程池享受到高性能的同时,也要考虑下面2个问题:1.线程池是否隔离?2.若新增线程池,参数配置口径是否上下游对齐;3.中间件线程池
#1隔离问题:很容易被忽略,比如某个线程池,承接了核心的首页计算;同事也承接了异步发送日志消息的计算,这显然是非常不合适的;
从业务侧,如果线程池饱和,我们肯定会选择有限保留首页的计算,首页的展示。
也就是说,不同的业务场景,使用相同的线程池,最好各业务场景在【重要程度】上是基本一致的,如果某个业务场景比较重要,
那么这个时候,注意不是【强烈建议】了,而是【严格要求】此时必须为这样重要场景独立配置一个单独的线程池。
#2线程池口径问题:在为某个场景配置一个新线程池时,线程池原理【【详见这篇文章】,单线程超时等待的原理 timeout的原理 执行线程还是hold住了】,
关于参数的配置想必大家都清楚,对于应用层很少有CPU密集计算的场景,绝大部分都是依赖外部IO操作的,此时可以将核心线程数大胆地调大一些,
队列必须指定有界队列,然后配置好饱和丢弃策略,丢弃时打印日志进行监控等。
这里我想额外表达的就是参数的配置除了基本的结合业务qps的考量之外,一定还要考虑上下游线程池,大家可以看下一个外部请求进来,可能会横穿很多个线程池:
也就是说多个线程直接,大小口径一定要对齐,比较合理的是漏斗状的,约接近请求入口处,线程池越大;
你下面口子开的很大,上面开的很小是没有意义的;特别要注意的是,上面开的大,但是下面的池口子开的小,这个时候就会饱和丢弃,一定要观察线上饱和丢弃的日志,
很多时候是一些老配置,很早之前的业务规模小时没啥问题,但是业务大起来,某些被饱和丢弃业务受损,类似问题一开始总容易被忽略,如果你还没饱和丢弃日志定位问题是很难的,
所以线程池上下游口径的我呢提是要在平时重点梳理排查的,问题就在于这个不用天天梳理,且总容易被忘记,
没有人会记着这个事情,你说气人不气人,只能说谁负责任期内出事情,谁摊上谁倒霉了,你说气人不气人【表情 捂脸流泪】。
#3中间件线程池问题:Hystrix的线程池问题,新手很容易犯错误,往往就是拷贝一个注解头,参数简单改几下,他也不知道改了这几个参数代表啥。
我认为Hystrix最要考虑的就是隔离,即你结合下游接口,梳理重要性,如果这个接口是重要接口,则GroupKey一定要单独一个,避免与其它线程池复用而相互影响。
4.3.3.读写隔离
在工程层面,往往会有对内使用的后台运营工程,主要是配置写入;其次是对外的api工程,主要是查询;
此时基于CQRS架构,做命令查询职责分离,也就是工程层面的读写隔离;commend工程往往改动比较频繁,这样可以避免commend工程改动发布对query工程的影响。
第二个就是用户侧的读写,最后也进行隔离,这个也要结合业务规模,比如我们的业务场景,用户侧的读相比用户侧的写会高几个数量级,用户侧读工程会做大量性能优化,
也就是说用户侧的读写在设计上会有很大差异,此时用户侧写的功能也是单独机器部署了;这样也是做到隔离不相互影响。系统架构如下:
4.3.4.代码主次隔离
这个其实说降级更合理一点,如下两个图:
首先有个原则就是28原则,核心逻辑总是少的,可能只占20%的代码量,非核心的总是复杂且多,因为复杂且容易出错。
上面这个改造就只是简单加2个trycatch,但是带来效果确实明显的,80%非核心逻辑往往容易出错,但是通过trycatch保护不会影响核心内容逻辑的返回;
还是那句话,思路是最重要的,有了这个结构思路,就可以保证后续新代码都是基于这个结构,大家去新增代码逻辑也会做这个方面的思考。
如果已有代码逻辑没这个结构,开发同学又没这个思维意识,这个就特别容易被忽略。
但是改造好了,虽然是细节,对可用性是又实打实的帮助的。
所以看到这里的同学,就这一点,赶紧去梳理下线上核心业务场景,核心业务功能,改起来几分钟,对系统可用性帮助是巨大的,
哪天系统非核心部分逻辑除了问题,然后通过你现在的改造救命了,嘿嘿嘿,你立功了。
4.3.5.物理隔离
这个就是双机房部署了,公司规模小业务规模小可以先不考虑,
到一定规模时,运维团队肯定也有了,运维团队会主动推进这个事情的,业务开发团队要做的就是配合,
讲这个点就是说业务团队要意识到双机房部署的重要性,这个是极端情况下救命的,所以涉及这个的事情一定要积极配合,
特别是你觉得你负责的业务非常重要,那更要积极配合,有必要还得跟在运维团队后面催着他们早点搞这个事情。这样在极端情况下你又多了一条退路了。
4.3.6.一句话总结
【通过多级隔离改造,有效规避非核心业务异常对核心业务的影响,间接提升了核心业务的可用性】
Part4.4.上下游防护:
4.4.1.上游限流(入口限流)
限流背后的思想核心是外部请求超过当前系统的负载,在保证系统可用性的前提下要进行限流保护。