【外发版】理论到实战,高可用架构踩坑说明书
在构建高可用系统时,开发者常常面临应用、数据库、缓存、消息队列等多维度的挑战。本文结合京东真实技术场景,系统梳理高可用架构实践中常见的技术陷阱与解决方案,深入剖析每个技术组件的可用性保障要点。旨在为工程师提供一套踩坑说明书,帮助团队在系统设计阶段规避潜在风险,提升线上系统的稳定性和容错能力。
一、前言
通常情况下,我们在说一个事情之前,一定要把事情本身及其定义说得明明白白。那么,在对高可用架构具体展开之前,我们先要好好说说,什么是高,怎么样才算高,多高才算高,其次才是怎么才能做到高。
以系统建设场景为例,通常意义的高可用的标准至少要达到4个9的水准,即以一天为例,每天至少要保证少于8.64秒的故障产生。如果以更严格的5个9的水准来看,每天至少要保证少于近1秒的故障产生。达到了这样的标准,才算高可用。
试问,假定我们的高可用标准为以上标准,扪心自问下,您能做到么?
笔者有自信,这个标准,很多团队都做不到。那么,我们有没有可以遵循的标准,指引我们往这个方向去前进和努力呢。我理解这个需要大家群策群力。笔者能力有限,水平一般。以下内容,将基于笔者实际经历的一些常见的问题点及应对方案进行展开,期望可以对大家的实际工作有一定的帮助作用。值得注意的是,避开这些问题点,是否可以达成高可用的标准不好说,但是很容易就走到高可用的反面。
在实际展开内容之前,要达成高可用的标准,除常规的发布变更可能引发的风险和故障导致高可用标准时效外,我们还需要考虑线上在正常运行态下,也会存在"一切都不可靠,都会坏,且马上就会坏"的情况,同时需考虑线上业务急速增长可平稳支撑的解决方案。包括但不限于考虑容器、db、rpc依赖、redis缓存、mq消息等内外部一切可能涉及的因素,考虑出现单点故障或大面积故障后业务会引发的变化,要求增加无死角的各类监控(分钟级和秒级),提前准备改造方案和预案。且要求对应用程序的每一行代码及依赖中间件的底层原理做到了如指掌,出现问题时才有可能做到快速定位分析、快速恢复、快速响应。
以下内容从常见的几个涉及高可用的主题进行展开,每个主题都会下探一些常见的容易出现问题的场景及应对方案。
二、应用高可用
2.1 代码故障
笔者把代码故障分为两类,一类是应用类的,主要是应用系统的开发人员引入的,此类问题较易发现,定位难度虽有一定的差异,但是往往修复的方案相对可控,基本都可在应用系统对应的研发团队内闭环解决;另外一类是平台类,主要包括依赖的包括但不限于JDK平台,及各类依赖的开源代码组件及内部平台组件,包括但不限于RPC框架、缓存框架等。
2.1.1 应用类故障
主要涉及100%命中或大概率命中的业务场景,包括但不限于int溢出、字符长度溢出、除法为0、空指针异常等,此类场景在业务命中后,会极易导致此类场景的服务出现完全不可用的情况。
具体问题可分别描述如下,问题的排名及影响程度不分先后:
2.1.1.1 int溢出
以下图为例,程序中执行了Integer.parseInt的方法,一般情况下功能也没有什么问题,但是如果出现需要转换的值,超出了int的最大范围,或者需要转换的值,从数字类型变更调整为了字符串类型。两种场景均会导致转换失败,如果此项转换出现在主流程或者业务流量较大的场景,出现的问题影响及损失就不可计量了。
朋友们,如果你的代码有这种情况,建议抓紧看看评估下,是否存在业务上的潜在风险?
2.1.1.2 字符长度溢出
比较常见的问题有两类,一类会造成应用程序中的字符长度和数据库的字符长度不匹配,出现后会出现数据库无法保存的故障;一类是业务代码中有对某些字符串有取固定某几位的逻辑或者判定长度执行特定逻辑,这种在上游没有评估到位出现字符串的长度增加、减少,或者位置出现偏移的时候,也极易出现问题。
比如如下逻辑,识别仓是否为云仓业务,执行的逻辑为判定仓编号是否为9位数字,且首字母是否为8开头。此类判定逻辑随着时间变化,就比较容易产生问题。我们还是好好祈求下,期望业务范围永远在这个范围里面,或者后续要调整的时候,有人可以识别到相关所有的依赖方。
再比如如下逻辑,为京东里面依赖比较多的一个逻辑,即识别商品编号,来判定商品是否为普通图书、台版书、音像、电子书等业务。识别的逻辑为依赖商品的编号的号段来做逻辑,后来随着商品的量级急剧膨胀,原来的号段逻辑不够用了,相关的团队不得不引入了一个外部的远程的配置中心,并提供了独立的sdk加载远端的配置来实现复杂的号段逻辑。此类逻辑,也是高可用实践中应极力避免的场景。
2.1.1.3 除法故障
在涉及到需要数学计算的场景里,这个问题比较常见。通常出现问题一般常见的故障为:1)未设置小数点及舍弃位规则 ,导致除法不能整除;2)业务上出现了除数为0的场景。
上述两个场景在笔者的经历中多出现过,且出现问题后此类场景的业务也会出现完全不可用的情况。
php
Exception in thread "main" java.lang.ArithmeticException: Rounding necessary
at java.math.BigDecimal.commonNeedIncrement(BigDecimal.java:4179)
at java.math.BigDecimal.needIncrement(BigDecimal.java:4386)
at java.math.BigDecimal.divideAndRound(BigDecimal.java:4361)
at java.math.BigDecimal.setScale(BigDecimal.java:2473)
at java.math.BigDecimal.setScale(BigDecimal.java:2515)
2.1.1.4 代码逻辑故障
这个分类里,会有各种五花八门的故障,而且这些故障只有你想不到,没有你写不出来的故障。曾经有名产品走到一名研发跟前说,嘿,这么认真,又在写bug呢。要破此局,需要大家共勉。
站在消费者的角度而言,一般而言,都期望下完单付完款后,可以尽快收到宝贝。那么技术上,就要求整体系统的链路维持高可用的状态,即支付后整体履约链路在日常情况下,可维持秒级平稳且无毛刺出现的情况,基于更高的要求目标,在下游服务可能出现抖动的情况下,仍要保障整体下传链路的通畅性,保障调用量级稳定。
一般而言,此类复杂的履约链路,往往会依赖内外部大量的RPC服务,此类服务对应的性能指标往往不一致,为应对这种差异,往往会需要一个流程框架设置不同的业务流程节点来串联相关的服务,且此流程框架中需对有不同性能的业务节点提供差异化的服务,也即对不同性能节点的业务节点配置差异化的线程池队列。那么对此类业务场景,应对高可用场景,至少应有如下场景需重点考虑:
•目标
1、在待处理任务充足的情况下,每个业务线程都有足够的待执行任务需要处理,不出现线程饥饿的场景;
2、在待处理数据充足的情况下,主业务线程提交的待处理的任务数量过大,防止出现雪崩情况产生。
•优化方案
业务主线程先获取任务线程池缓冲区实时待处理任务数量,并动态决定是否需要提交待处理任务,保障提交的待处理任务不会超过缓冲区大小且不会出现业务线程饥饿的情况,同时下传流程框架移除CountDownLatch的逻辑限制,移除按批次处理的逻辑,使业务主线程提交一批待处理任务后,不会做任何等待,可以再次获取系统执行权。
•优化效果
优化前,下游服务抖动,引发调用量出现毛刺;优化后,即使下游服务抖动,调用量依然是相对稳定。
2.1.2 平台类故障
此类故障一般都隐藏得较深,不易发现。而且及时发现了,一般平台的响应速度均会较快,可在新的版本中进行修复,应用开发人员将对应的版本进行升级即可完成问题修复。常见出现的问题是应用开发人员使用了一些存在问题的低版本,此处的难点在于如何对涉及的依赖平台组件完成平稳无缝升级,这个问题及应对的方案留给大家。在实操中,我们也可以发现,即使是大名鼎鼎的JDK,这个所有人赖以生存的基础底座,都有一些极其低级的bug。
需要说明的是,下方的较多代码bug或故障,较多情况并不会对实际业务造成影响,但是以高可用的标准来要求的话,还是有必要多多关注,早日完成问题修复,不留潜在风险。
以下以笔者曾经遇到的平台类故障进行展开描述,问题排名及影响不分先后。
2.1.2.1 JDK故障--数组越界
此数组越界问题是在大促备战中发现。需要注意的是,此问题实际不影响业务功能,因为JDK对此类异常进行了捕获并处理,但是应用系统中因为此问题会导致潜在的高频抛出和捕获异常,而众所周知,高频抛出异常对应用服务器的压力及GC耗时均有较大的影响。
需要说明的,下方复现问题的代码,您在控制台默认启动试运行的时候,是不会出现下方的堆栈信息的。那么什么时候会出现这个异常呢?简单,我们在JDK的源码类SignatureParser对应的异常代码里面增加对应的断点即可。通过查阅资料,我们发现JDK在8u311的版本修复了这个bug,相关的bug链接为:bugs.openjdk.org/browse/JDK-...,实际代码fix的链接为:hg.openjdk.org/jdk8u/jdk8u...。
那么这个问题什么时候会出现,笔者可以很明确的说:很多场景都会出现,最简单的场景即为复现代码中使用json解析的场景即会出现。
那么这个问题,应该如何修复呢?从上方的信息可以看到,JDK在8u311版本中已完成问题修复,那么我们直接升级对应的版本即可。看起来很简单,对么?
但是实际有这么简单么?那当然不会,否则我也不会写到这里了。笔者从部署平台询遍了所有可使用的镜像版本,里面涉及到JDK8的版本,没有一个版本高于8u311版本,这也就意味着,此问题,在所有使用JDK8版本的应用里面,都有此类潜在隐患。想想这个问题,是不是觉得很震撼,很奇妙?
问题堆栈为:
css
"main@1" prio=5 tid=0x1 nid=NA runnable
java.lang.Thread.State: RUNNABLE
at java.lang.ArrayIndexOutOfBoundsException.<init>(ArrayIndexOutOfBoundsException.java:65)
at sun.reflect.generics.parser.SignatureParser.current(SignatureParser.java:95)
at sun.reflect.generics.parser.SignatureParser.parseSuperInterfaces(SignatureParser.java:559)
at sun.reflect.generics.parser.SignatureParser.parseClassSignature(SignatureParser.java:214)
at sun.reflect.generics.parser.SignatureParser.parseClassSig(SignatureParser.java:156)
at sun.reflect.generics.repository.ClassRepository.parse(ClassRepository.java:57)
at sun.reflect.generics.repository.ClassRepository.parse(ClassRepository.java:41)
at sun.reflect.generics.repository.AbstractRepository.<init>(AbstractRepository.java:74)
at sun.reflect.generics.repository.GenericDeclRepository.<init>(GenericDeclRepository.java:49)
at sun.reflect.generics.repository.ClassRepository.<init>(ClassRepository.java:53)
at sun.reflect.generics.repository.ClassRepository.make(ClassRepository.java:70)
at sun.reflect.generics.repository.ClassRepository.<clinit>(ClassRepository.java:43)
at java.lang.Class.getGenericInfo(Class.java:2548)
at java.lang.Class.getGenericSuperclass(Class.java:765)
at org.codehaus.jackson.type.TypeReference.<init>(TypeReference.java:33)
复现问题的代码为:
typescript
public class ATest {
public static void main(String[] args) {
String str = "{"aa":"bb"}";
try {
Object o = JacksonMapper.getInstance()
.readValue(str, new TypeReference<Map<String, String>>() {
});
} catch (IOException e) {
e.printStackTrace();
}
}
}
JDK的修复方式如下所示,修复的逻辑可以看到也比较简单,将原来粗暴的异常修改为前置逻辑判定即可:
2.1.2.1 RPC框架故障--找不到方法异常
JSF目前为京东平台内部使用的RPC框架,此技术框架目前提供2种交互协议:msgpack和hessian。容易出现问题导致存在高可用隐患的场景为:服务提供方和服务依赖方,交互的时候使用的是msgpack的协议,且交互的输入输出有使用BigDecimal的话,通过火焰图等性能分析工具分析应用程序运行时状态,我们会发现火焰图中有大量的无法找到方法的异常。
那么这个方案有没有解决方案呢?有的,按平台建议,我们将RPC框架的协议,从默认的msgpack,切换为hessian即可。那么除了这个方案,JSF在msgpack协议下的这个bug还有没有其他的解决方案呢。很遗憾的知悉大家,没有了。那么切换为hessian有没有什么潜在风险和隐患呢?应该还是有一些的,详细可以参见JSF的文档说明,另要说明一点,从msgpack协议,切换为hessian协议,默认是会有少许的性能损耗的。
火焰图异常信息为:
可稳定复现的demo代码如下所示,此处还需要再强调一点:在控制台默认启动试运行的时候,是不会出现下方的堆栈信息的。要能发现并定位出这个异常,需要我们在JDK的源码类对应的异常代码里面增加对应的断点即可。
java
import com.jd.jsf.gd.codec.msgpack.MsgpackDecoder;
import com.jd.jsf.gd.codec.msgpack.MsgpackEncoder;
import java.math.BigDecimal;
public class ATest {
public static void main(String[] args) {
MsgpackEncoder msgpackEncoder = new MsgpackEncoder();
PayDetailVo payDetailVo = new PayDetailVo();
payDetailVo.setCurrencyPrice(BigDecimal.TEN);
byte[] encode = msgpackEncoder.encode(payDetailVo);
MsgpackDecoder msgpackDecoder = new MsgpackDecoder();
PayDetailVo decode = (PayDetailVo) msgpackDecoder.decode(encode, PayDetailVo.class);
System.out.println(decode.getCurrencyPrice());
}
private static class PayDetailVo {
private BigDecimal currencyPrice;
public BigDecimal getCurrencyPrice() {
return currencyPrice;
}
public void setCurrencyPrice(BigDecimal currencyPrice) {
this.currencyPrice = currencyPrice;
}
}
}
此处再多说一点,对这个问题产生的原因再详细展开下。从上方火焰图的信息可以看到,实际出现的异常是获取BigDecimal的构造函数,并且是BigDecimal的默认的构造函数时,出现的无法找不到类的异常。但是您猜猜BigDecimal有没有默认的构造函数呢?答案是真没有。
详细参见下方截图:
2.1.2.1 缓存框架故障--缓冲区溢出异常
jimdb为京东内部可和redis对应的缓存技术框架。这个问题出现的场景为,所有低于2.1.12(23年8月份发布)版本的jimdb,特别是之前强烈大家推荐升级的2.2.8-shade版本,在操作任意读、写redis命令时,均可稳定出现这个异常。
需要说明的是,此类异常,也和上面介绍的异常类似,常规方式无法看出来,只有对性能及服务高可用有极致追求,通过压力测试及火焰图等工具才能比较快的定位出来。
那么,您能看看,您自己负责的系统,使用的jimdb版本是什么版本么。
详细的异常火焰图如下:
具体产生的异常代码如下所示:
从上方的信息,比较容易看到是SDK在定义缓冲区的大小时长度不足预期,往缓冲区放的时候出现了溢出异常,但是SDK的代码捕获了这类异常,同时将缓冲区的大小扩大一倍类解决问题。还是那句话,您要问业务功能上有没有问题,那当然是没有。但是您要问这种写法真的好么?那笔者觉得这真是一个让人可以深思的好问题。
SDK对这个问题的解决方案也很粗暴,上方JDK的处理方式有异曲同工之秒,修复后的代码详细可参加下方的截图:
2.1.2.1 缓存框架故障--空指针异常
这个问题出现的场景为发现上方的缓冲区异常后,我们应用开发人员抓紧升级到2025年7月18号升级的最新版版本2.3.1-HOTFIX-T2,继续开启压测和火焰图后,发现应用中存在空指针异常的情况出现。还是说明一下,这个不影响业务功能,只是对性能有一定的损耗,具体损耗会依据应用系统的不同略有差异。
这个问题最后定位下来,是应用里面的一个jar(titan-profiler-sdk)较为老旧,新版本的缓存SDK依赖的新版的jar功能,最终在运行态会出现本案例的空指针异常。
这个问题的火焰图信息如下
2.2 单容器故障
这一点想要描述的是线上单点或少批量的机器故障。在一般的认知里面,线上服务均为集群部署模式,单点或者少量故障,理论上不会对服务的可用性造成太大的影响。但是,真的是这样么?
以笔者的经验来看,单容易故障不造成太大的问题,一般出现在业务场景不太复杂的场景。以下以笔者的实操经历,从4个方面来进行详细展开,描述因单容器故障造成实际影响的案例:
1)线上服务为web类服务:此类场景,往往在域名控制台中心绑定了域名和容器IP,假定某一台或者某几台容器出现故障无法访问,在没有人工介入的情况下,考虑下此域名的业务使用方,是否会出现间歇性不能访问的情况?是否有些工具可以做到自动切换,但是切换周期有多?以及是否所有的业务场景均配置了自动切换功能呢?这个问题,留给大家去思考
2)线上服务为RPC提供方服务:您服务对应的容器异常宕机了,您收到的一般为容器故障或网络连通性异常等告警。但是RPC的接口依赖方,其对应的服务可用率在问题出现期间,是否会出现可用率下跌的问题,及多长时间会自动恢复?这个问题,也留给大家去思考
3)线上服务为MQ消费服务:MQ的工作原理,在下文MQ高可用章节会详细展开,此处简单说一下:单机故障后,在MQ客户度没有较好的优雅关机的情况下,故障的那一瞬间,假定应用程序已从MQ的服务端拿到了较多的消息在本地进行消费,没有来得及给MQ服务端进行应答的时候,同时假定MQ服务端的超时时间设置比较长的情况下,会出现MQ服务端的消息短时间内出现部分锁死无法消费,进而出现消息短时间内积压的情况出现。您是否遇到过类似的问题?
4)线上服务为流程框架调度类服务:此类服务一般会有一个流程管理框架,在流程服务本身依赖的容器故障,或者流程服务依赖的服务出现故障时,系统不同的设计,会有不同的故障反应。
以流程服务本身依赖的容器故障为例,严谨的流程框架需保障流程处理的数据能够被及时调度起来,否则极易出现任务执行延迟导致业务同步延迟的情况,那么怎么定义及时调度起来,如何及时调度起来呢?这个问题,同样留给大家去思考。
再以流程服务依赖的服务的容器出现故障为例,严谨的流程框架要尽量降低依赖服务的问题造成框架本身的调度问题,包括但不限于引发调度框架的业务量级出现突增或者突降的情况,在保障业务平稳运行的情况下,还要对依赖的服务做好一定的保护措施,包括但不限于增加一定的措施,防止依赖的服务短时间内调用量级过大引发依赖服务出现雪崩的情况出现。
2.3 机房故障
这类问题不太常见,但是一旦出现,就是致命的影响,出现后必定会出现各类人仰马翻的情况。可以设想,一旦某一个机房整体出现故障,那么我们可以简单分析下哪些业务会产生影响。
1)流量入口:可以观察到流量入口对应的机房出现了故障,对应的流量会出现短时间无法访问的异常,这时候比较快的方式是摘除对应的流量入口,需要考验的就是定位问题的速度和摘除时的手速了。
2)各类RPC服务:各类垂直调用的RPC服务,在流量入口摘除整体机房后,理论上相关的机房的流量就会自动消除,看起来好像没有什么业务影响。但是这个只是理论上的,目前线上还存在一些应用没有做手动或自动的垂直分组隔离,同时也会有一些任务会出现流量逃逸的情况出现。其他条线的业务,如果流量入口没有控制好,那么至少故障机房的影响就需要好好评估下了。
3)各类MQ服务:这个就要依赖MQ的部署机房架构了。如果MQ对应的服务端部署在多个机房,理论上发送端的可用率就很难做到100%。MQ平台是否可以做到发送时自动摘流,以笔者有限的经验来看,现阶段应该还不太行。
4)各类DB服务:这个就更是一团乱麻了。不展开分析了。
5)缓存服务:不展开分析了。
2.4 GC故障
市面上常常有一种说法,说面试造火箭,入厂拧螺丝。指的一般都是各类八股文的考题,其中GC在里面居多。从笔者的观察来看,在较多的非核心系统里面,GC往往不为大家所关注。即使在较核心的系统里,GC的问题也不是能引起所有角色的关注。哪怕是在笔者写文章的此时此刻,线上某个团队的某一个0级核心服务对应的TP999数据也会出现随着时间的延长,会出现性能数据逐步缓慢攀升;并随着全量发布后,性能出现急剧回落至正常水平的情况。即使是我们团队,GC的耗时较高待优化的系统,在清单里面的也有较多。
下文以我们实际遇到的2个具体的优化案例进行展开,期望对大家有一定的帮助作用。
•gc调优--连接池调优
◦表现:在某压测过程中,某一类接单一直卡在5000qps上不去,上游应用调用接单服务tp99超350ms已上,但是接单服务应用的吞吐率上不去。
◦原因:1)通过JVM监控发现youngGC频繁且峰值耗时在400ms左右,并未触发fullGC。经分析当前容器是4C8G,jvm配置gc并行处理线程数是(ParallelGCThreads=4),存在垃圾回收线程不够的情况。2)数据库连接池参数配置不合理,导致频繁创建和回收连接池,整体gc的耗时在400毫秒上下浮动。
◦举措:1)升级到容器规格由4C8G升级到8C16G,同时调整JVM堆内存、新生代内存大小,调整GC并行处理线程数到8(ParallelGCThreads=8)。youngGC时长问题解决,youngGC耗时在30ms以下;2)优化连接池参数
结果:在没有其他优化的前提下,优化前平均耗时176.7ms,优化后平均耗时17.2ms。
•GC调优--字符串缓存关闭
◦表现:在压测过程中,持续的高流量试跑导致部分实例在过程出现FGC情况,引发服务对应的TP99段时间内飙高,进而导致整体调用量级出现毛刺和掉坑。同时观察发现的堆内存时间随着时间的推移稳定增长,直到触发一次fullgc。
◦原因:拆分中使用了Jackson1进行json序列化,其中会用 String.intern() 来做字符串的缓存,这样重复的字符串就可以只存一份了,因为返回的 Json 里的 Key 是 一个Java生成的UUID,这个key几乎不会重复,所以导致缓存池里的字符串越积越多,直到GC的时候才会回收。
◦举措:通过代码关闭jackson中的字符串缓存,问题解决。此问题还有没有其他好的解决方案,也留给大家去思考。
三、db高可用
3.1 JED查询单分片故障
需要说明的是,JED为京东内部可和mysql对应的一类数据库。和传统mysql不同,JED自带路由网关,期望将底层的mysql实现对研发透明,业务逻辑和JED交互的时,若SQL中带有分片hash键,则网关会计算并路由hash到对应的mysql实例上;若SQL中不带有分片hash键,则网关会将请求发送给所有的mysql实例,并在网关层聚合返回结果。
此处主要要强调的是,我们要理解JED的工作原理,按一般逻辑,我们和JED交互的时候,最佳实践是SQL中带着分片键,不然会引发跨片查询。跨片查询存在2类弊端:1)因为是对所有的分片进行并发查询,最后完成数据的归集,那么性能会存在一定程度的损耗(损耗程度取决于SQL的复杂度);2)任意一个mysql分片宕机,并发查询的时候必定会命中这个坏的分片,最终会出现查询结果完全不可用场景。
3.2 JED事务故障
业务逻辑开启事务时,默认会使用select @@session.tx_read_only语句,此语句一是会影响性能;二是此语句在JED场景下,因为此sql并没有带分片键,会随机选择一个分片进行查询扫描,若很不巧,扫描的分片整好是坏的那台机器,可能会加大失败的概率。也即假定我们线上有10个JED的分片,若现在某一个分片出现了故障,那么按推论,故障的比例应该是百分之十,但是因为此项事务机制导致随机扫分片,会将故障的比例升高扩大至20%。
解决方案:通过jdbcurl上配置useLocalSessionState = true
3.3 JED全局唯一键故障
jed如果使用全局自增id,在没有特殊诉求的情况下,会默认使用第一个分片,即当第一个分片宕机时,按预期是只有一个分片对应的业务出现故障,但是这种场景下,最后的结果与预期不符,所有的insert均会全部失败。
3.4 慢sql故障
这个比较好理解,不详细展开
3.5 大事务故障
在各个系统设计中,因为历史架构设计原因,存在较多大事务,在业务量级较小的情况下,此种用法尚没有太多问题,但是一旦上强度业务量级和并发上来,此种设计机制对数据库会形成较大的压力,导致数据库对应的qps无法提升,从而影响整体服务的吞吐。
以履约的其中一个系统举例,历史上利用接单防重表的事务来保障2个RPC写操作的一致性,该大事务会导致DB锁等待,吞吐量上不去。虽然可以通过扩JED分片的机制来减少单分片的锁等待,提升吞吐量,但还是存在架构不合理,性能瓶颈的问题。
解决方案:对防重表增加状态机制,通过一次insert和一次update操作保障2个RPC的写,避免使用insert大事务。
3.6 流量放大故障
所谓流量放大,以订单条线为例,假定处理一个订单,正常业务需对应10条读写sql语句,但是通过监控发现存在一个订单对应10倍到100倍的sql情况产生。
这个问题,比较致命,也比较隐藏,在db没有压力瓶颈的情况下,不易发现;在db有压力瓶颈的情况下,要么不好发现,要么发现后来不及调整。建议大家多关注监控,对此类流量放大的情况,提前完成治理工作。
3.7 db字段长度不足故障
这种问题,一般出现在此字段上下游没有对齐的情况。在业务发展初期,或者在团队规模较小的时候,大家的字段长度都保持类似,但是随着业务不断发展,原来的字段长度已不足以支撑业务发展,上游将字段提前完成了扩容,但是并没有好的工具可以梳理到下游的影响,导致下游遗漏了修改动作。在某一个夜黑风高的夜晚,终于出现了写入db失败的异常,最终引发业务故障。
3.8 单集群存储不足故障
此处想强调的是存储资源不足的情况。试问下各位,您能说清楚您负责的系统,考虑目前的业务增长趋势保持不变、增长趋势翻10倍、增长趋势翻100倍的场景下,您负责的db存储,还可以支撑多久?如果支撑时间很短,必然说只有一个月的话,应该有什么解决方案呢?这个问题,留给大家。
较多系统存在单库或者JED容量已经无法满足业务增长的情况,在小流量的情况下,数据库不是瓶颈,一旦流量激增,一个跨分片的SQL或者一个JED单片的故障,都将引发一场灾难。但是从传统的mysql升级到jed,包括升级到目前的DongDal组件,还是有一些注意事项需要大家多多关注:
•check语法是否支持:传统mysql升级至JED时,check原有SQL中使用的语法在JED是否支持
•关注网关性能:升级JED时需关注负载均衡、网关的配置及性能波动。例如,JED的负载均衡、网关、分片都在汇天机房,如果汇天网段出现网络不问题,TCP重传数高的问题。可能会导致JED查询、修改等语句执行时间TP99升高。
•低峰期执行DDL:执行对数据库执行表结构修改时,需要观察每个分片的QPS(包括总QPS、read QPS、write QPS),在QPS的低峰时,且各分片QPS均匀无异常波动,才可以执行表结构修改。黄金流程链路建议凌晨2点后,同时需要如果有大数据抽数任务,也需要提前做协调和沟通,避免业务系统变更引发大数据侧的事故。
•避免跨片查询:SQL中最好都带上增加分片键,不然会引发跨片查询,跨片查询的性能会存在一定程度的损耗(损耗程度取决于SQL的复杂度)
•增加数据库连接池的探活配置:客户端到JED网关之间还有LB作为网络代理,LB会主动清理空闲10分钟及以上的连接。需要业务侧连接池进行保活或探测连接,否则LB会把连接杀掉,再次使用时会出现异常。
•事务30秒超时限制:为了提升JED网关连接池的使用效率,保护底层MySQL实例,针对事务有30秒的超时限制。
•JED单实例安全水位线:磁盘使用过大:进行数据归档或结转;QPS过大:增加缓存
类型 | 安全 | 中危 | 高危 |
---|---|---|---|
磁盘使用(非归档类) | <=2T | >2T&<4T | >4T |
QPS | <=1万 | >1万&<=3万 | >3万 |
四、redis高可用
4.1 JIMDB超时和热key治理
JIMDB超时时间设置若不合理,在JIMDB故障时较容易出现无法快速熔断,阻塞业务的情况,同时若应用中存在单点热key的存在,如果该热key正好在故障的JIMDB分片上,就比较容易造成故障产生。
•超时时间治理: 根据历史上的业务监控数据,我们应将JIMDB的超时时间设置在合理的阈值,实现业务快速熔断。调整配置的读、写命令超时时间以及新建连接超时时间。同时注意 JAVA-SDK版本在 2.1.15及以下版本SDK不支持读写超时分开控制;如果应用使用了这些SDK,所有的读写请求超时会统一使用写命令超时参数。另外,新建链接超时过大,可能导致无法快速释放链接,进而放大单片故障对业务系统的影响
•热key治理: 需要评估该热key的实现规则是否合理,尤其避免key为固定常量的写法
4.2 JIMDB高危命令治理
在使用jimdb时,为了使多个原子操作保持一致性,通常会使用lua脚本将多个原子操作打包处理。jimdb提供的上传lua脚本方法scriptLoad,会扫描所有节点并上传,当有一个节点不可用时,上述上传动作会阻塞其他正常节点的上传,直至异常节点超时返回或异常节点主从切换完成正常返回。该过程会影响正常分片的使用。
•减少lua脚本上传:建议在初始化时执行一次lua脚本上传。jimdb内部在主从节点也分别存储了脚本,在发生主从切换时,可以不用再次上传。
•针对特定异常补充上传:当节点不可用或扩分片的场景捕获该异常ScriptNotFoundException,重新上传lua脚本。
五、mq高可用
MQ在各个应用场景中,被当做一个神兵利器来使用,但是通过这些年的观察,较多人对其底层原理和各类注意事项并没有太清晰的认知,此处以JMQ(Jingdong Message Queue)京东自研的低延迟、高并发、高可用、高可靠的分布式消息流处理平台为案例,结合笔者曾经遇到的问题展开描述,期望对大家有所帮助。
5.1 JMQ应答超时故障
•表现:某场景消费消息监控在2025-06-24的14:17:10至14:19:03有明显下降
•原因:上线中消费实例拉取消息后,直接关闭应用tomcat实例,客户端获取到的队列partition的占用就不会释放,积压2分钟内消息的积压持续增长,2分钟后又快速消费掉了。
需说明的是:以MQ消费存在的锁消息及滑动窗口逐批消费的概念及原理,基于MQ的链路要想做到秒级无延迟无抖动,极难做到。
以下再举一个较为形象的例子,来展开说明下MQ的发送及消费原理:
与redis、jimdb这种内存式存储的存储中间件不同,MQ的存储其实是基于连续的文件来存储的,这一点认知特别重要。
以上方的消费示意图来看,MQ概念中的Broker可以近似认为是各个单独的容器,队列则是各个容器中不同的存储文件,可以简单认为在一个Broker中每个队列会对应不同的文件,MQ的写入方会顺序写入文件,MQ的消费方则会顺序读取文件。
可以观测到,如果业务量级比较大的情况下,消息的消费速度主要首先于以下几个因素:单个消息消费的耗时、消息Broker的数量、消息队列的个数。可以看到单个消息消费的耗时越小,则消费速度越快;消息Broker的数量、消息队列的个数越大,则消费的速度越快。这也是为什么不同的业务场景下,MQ团队会给我们设置差异化的消息Broker的数量、消息队列的个数的最主要的原因。
从上方的示意图也可以看到,在一般的情况下,每个队列的写入和读取都是顺序的,以写入为例,只有新的消息写入成功后,下一条消息才可以在这个队列继续写入;以消费为例,只有排在队头的消息被成功消费,队头后面的消息才有被消费的机会。一般而言,写入的速度取决于文件存储磁盘的速度,一般没有瓶颈;往往有瓶颈的为消费的速度。消费的速度跟不上的情况下,MQ上观测到的就是会出现消息积压,业务无法及时处理的情况。
这种情况,因为瓶颈点在MQ服务器,往往增加应用服务器的数量,并不会有好的改善效果。那么在受限于机器资源等原因导致消息Broker的数量、消息队列的个数无法持续扩大的情况下,MQ团队提供了一个方案可以进一步加速消费的速度。也即在每个队列的维度上增加一定的并发,实现原理为使用了滑动窗口的机制,即为每个队列再虚拟出30个虚拟可并行执行的队列,假定每个队列的并发数是30,则消费方理论上可以同时从这30个虚拟队列中拿到消息。此种方式变相的相当于了扩大Broker或者队列的数量,也是加速消费的比较好的实践。
那么对这一点,有没有什么注意的事项呢。当然有。问题点就在这个滑动窗口的机制和原理里。滑动窗口虚拟出30个并行的队列,继续往下滑的前提条件是这30个队列的每一个消息都被成功消费了,请注意,这里指的是每一条消息都被成功消费了。那么很容易就可以观测到,消息消费基本是分批处理的机制,每个批次的数量取决于并行的大小,假定批次中的任意一个消息没有得到及时应答,那么这个队列后的所有消息,仍然不会被消费到,业务上观测到的就是这个队列的消息又会出现积压了。
那么什么时候回出现没有及时应答的情况呢,这个就比较多了,在应用服务器异常宕机、应用服务器对应的依赖服务出现剧烈抖动、少量抖动时,MQ服务器都不能快速收到消息成功消费的应答,也即此队列的消息有极大的概率会出现被堵住的情况。
这个问题有好的解决方案么?在笔者看来,并没有太好的解决方案,设置MQ的默认应答时长可以一定程度减缓这个问题的发生,但是本质上无法解决。在考虑高并发无延迟的情况下,采用MQ的技术方案一定要慎重。另外,MQ本身设定的技术应用场景就是为了上下游解耦使用,应对的一个场景为削峰填谷,应对的是上下游速率较大可能不一致的情况,或应对的场景为有多个下游依赖方,指望通过服务的方式通知所有下游不太现实的情况。
有关消费的原理,再展开描述如下:
存储:消息是存储在partition里的,是一条挨着一条存储的。如上图的"服务端"。
消费:在客户端拉取消息时,服务端会从消费位置(如上图的"消费位置")开始,拿一批消息返回给客户端。客户端A拉走一批消息后,服务端要避免,这批消息被另外一个客户端拉走,所以在客户端确认结果之前,该partition会一直被客户端A占用,保证不会被其他客户端再拉取消息。客户端成功消费这些消息后,会给客户端确认,服务端收到确认后,会移动消费位置,并释放占用(锁),等待下次拉取。
客户端会有一个消费线程不停的在循环执行:拉取消息 -- > 执行消费逻辑 --> 确认结果 -- > 拉取消息 ............ 这个流程。
当服务端1号partition的消息被客户端A拉走后,为了避免这批消息被重复消费,服务端会记录:1号partition正在被客户端A占用,避免被其他客户端重复拉走消息。
这个时候,如果客户端A突然假死(或者其他异常场景),那客户端A对1号partition的占用就不会释放。占用不释放,1号partition的消息就永远不会被其他客户端消费到。为了避免这种情况,服务端会有一个占用超时的概念,即如果客户端一个比较长的时间内没有返回消费结果,那服务端就认为遇到了特殊情况,客户端将"永远"不会再返回结果了。在这种场景下,服务端会主动清理占用,保证消费的继续进行。占用超时在JMQ的管理端叫"应答超时",默认值是120秒,也正好匹配了此现象产生的原因。
•解决方案
◦措施一:超时应答超时时间
▪调整应答超时,将应答超时时间调小,减少上述场景带来的影响。
▪具体调整到多少:最近一段时间的消费tp999值的10倍。
▪如果最近一周的最大tp999是5秒,那就调整成50秒.
•措施二:升级支持优雅关机:MQ SDK底层增加支持,应对此类场景支持优雅关机,可主动释放MQ服务端的锁。
5.2 JMQ消息过大故障
目前消息发送,如果在消息体超过一定的大小的情况下,默认会开启压缩,但是在一些极限的情况下,仍然会出现消息体过大发送失败的情况。对于这一点,笔者还是建议各个系统的业务模型需要尽量精简,尽量降低无关的业务内容全部一股脑塞到消息队列中。
5.3 JMQ存储故障
某次发现消息突然出现服务性能飙高的情况,多轮排查后发现是MQ服务器对应的1G的下行网络带宽被写满,进而导致上行网络带宽发送也存在问题发送失败。
出现这个问题,需得满足几个前提条件,首先是消息体比较大,其次是消费方比较多。在流量压力突增的情况下,所有的消费者都从MQ服务上去获取下载消息,类似一堆人去一个集中的服务器去拷片,理论上无上限瓶颈的网卡直接被打满。
本文内容源于一线技术团队的实战经验,凝聚了众多工程师的智慧结晶。我们将持续更新更多实战案例和技术思考,与业界同仁共同探索高可用架构的最佳实践。