海恩法则是德国飞机涡轮机的发明者帕布斯·海恩提出的一个在航空界关于飞行安全的法则。
每一起严重事故的背后,必然有29次轻微事故和300起未遂先兆以及1000起事故隐患。
作为开发者,安全生产是我们底线,敬畏每一行代码,挖掘每一个故障背后的根因,避免再次落入同一个坑是应该成为我们的工作习惯,这里有几个我们线上真实有趣的故障案例分享给大家。
三目运算符--最熟悉的陌生人
三目运算符任何一个初学JAVA的同学都会接触到,而且在我们代码中也处处可见:
expression1 ? expression2 : expression3
expression1 可以是计算为 boolean 值的任何表达式。如果 expression1 是 true ,那么将评估 expression2 。否则,将评估 expression3。然而就是这个最熟悉的陌生人给我们带来一次可怕的线上故障,故障过程略去不表,大家可以先看一段代码,盲猜一下哪里出了问题:
第一眼可能可能就能看出,最后一行三目运算法的 null != playBoyExtra貌似是多余的,因为前面已经判空并且return了啊。
是的,这个的确是个问题,但是不至于执行不下去,再看一下错误日志:三元运算符这一行竟然出现万恶的NPE。
为何这里会出现NPE,唯一有可能为null的只能是playBoyExtra.getOpenTime() == null,但是为何会抛出NPE不应该是三目运算结果是null?写个简单的测试代码:
public class ConditionalOperatorDemo {
public static void main(String[] args) {
PlayBoyExtra playBoyExtra = new PlayBoyExtra();
Long openTime = null != playBoyExtra ? playBoyExtra.getOpenTime():0L;
}
private static class PlayBoyExtra{
private Long openTime;
public Long getOpenTime() {
return openTime;
}
public void setOpenTime(Long openTime) {
this.openTime = openTime;
}
}
}
用javap -c 对测试代码进行反编译:
观察红框内的三行,这三行翻译过来其实等同于:
Long openTime = (Long)(null != playBoyExtra ? playBoyExtra.getOpenTime().longValue():0L);
第一步:执行playBoyExtra.getOpenTime()获取到一个Long类型的对象O1,然后这里在某些情况下返回的是null。
第二步:执行O1.longValue()得到long基本类型数值V2,也就是执行一个自动拆箱操作,在playBoyExtra.getOpenTime()返回null的情况下就变成了null.longValue(),就会抛出NPE。
第三步:执行三目运算,并且将三目运算结果long类型的V3进行自动装箱操作,Long openTime=(Long)V3。
为何会执行自动拆箱呢?这其实是三目运算符的语法规范。参考JLS-15.25.2 Conditional Operator ? : 章节【附录1】的描述:
如红框所言,如果第二和第三位操作一个是基本类型,一个是对象,那么会发生自动拆箱操作转换为基本类型。这里就是因为第三个值是基本类型“0L”,所以导致出现playBoyExtra.getOpenTime()会发生自动拆箱操作,而当playBoyExtra.getOpenTime().longValue()值为null的时候,null.longValue()就抛出NPE,了解原理,再实验一下,直接把“0L”改为包装类型是不是就不会发生自动拆箱操作就不会抛出异常了呢?
Long openTime = null != playBoyExtra ? playBoyExtra.getOpenTime():Long.valueOf(0L);
验证一下,果然如此,实际上openTime被赋予了null值,程序执行并没有报错,但是这里虽然程序执行没有报错,但是这个结果显然不是程序的本意,程序本意是如果playBoyExtra.getOpenTime()不到值,那么就初始化openTime为0L,也就是我们更习惯的写法是:
Long openTime = null != playBoyExtra.getOpenTime() ? playBoyExtra.getOpenTime():Long.valueOf(0L);
至于playBoyExtra本身是否为空,其实前面已经判断过了。至此,事情水落石出,三目运算符是不是有一种“最熟悉的陌生人”的感觉?
消息风暴--好心办坏事
2022年3月30号晚7点25分,一阵急促的告警电话袭来,负责同学迅速查看系统,发现数据库连接池被同一个UPADATE的SQL语句产生行锁,导致连接池占满,应用系统无法访问数据库,线上大量系统异常。
数据库相关的问题往往容易引发灾难性的重大问题,好在告警及时,迅速对这条SQL语句进行限流,DB连接池恢复,线上危机解除,但是必须找到根因才能算真正解除这个定时炸弹。线上代码变更在大多数情况下是引发故障的主要原因,通过最近的线上代码变更排查,日志排查,代码review等系列问题定位手段,最终发现问题出现一行代码上,下面是这行代码CR的截图:
粉红色部分表明是被删除的代码,绿色部分是更新后的代,他的核心差异是调用这个消息发送接口的时候,第二个和第三个参数进行了调换,为何会进行调换呢?让我们看一下producerManager.sendMessage方法的定义:
public boolean sendMessage(String topic, String tags, String message) {
if (message == null || tags == null) {
log.error("[MetaProducerManager] message|tags is null");
return false;
}
return sendMessage(new Message(topic, tags, message.getBytes()));
}
这是对RocketMQ消息中间件的发送接口的封装,三个核心入参:
topic:消息队列的topic,订阅者可以订阅特定topic的消息。
tags:消息队列细分tag,订阅者可以基于tags部分订阅此topic的消息。
massage:实际要发送消息body的内容。
如果看过这个定义,大概就知道为何开发同学要修改原来的那行代码了,因为之前是把messageBody当做tags参数,而TAG_EDIT标识则传给了message消息提了,这是一个显而易见的参数传递错误的BUG,我们这位细心的程序员在开发其他代码的时候,看到了这个如此显而易见的bug,顺手就修复了,然而这次修复,却带来了灾难性的后果。可以用下面这个图示意理解一下:
在分布式架构下,应用之间常常通过RPC远程调用或者消息订阅的方式相互通信,这里应用A和应用B存在应用B对应用A的数据库变更的RocketMQ消息订阅,消息消费后,在某些特殊数据的处理逻辑下下会产生对于应用A的数据库UPDATE接口的同步RPC调用,而一旦产生update数据库的操作,又会产生数据库变更的RocketMQ消息,这时候应用A和B之间就产生了循环调用直至数据库被update操作hang住,为何原来不会出现这种情况呢,很简单,因为原来BUG的存在,导致红色的消息订阅链路是失效的,也就是根本没有办法正确消费消息,因此这个循环无法建立,当这个BUG被修正的时候,终于消息风暴降临。
当然,其中有很多细节,就不一一表述,但是这种看到一个BUG,随手修掉的情况,真可谓“好心办坏事”。在面临一个复杂系统的时候,哪怕是修复一个显而易见的BUG,都要慎之又慎,必须要对整体链路和架构有充分判断和认知,才能尽量避免踩到前人的大坑中^_^。
页面卡顿--不一定是代码的锅
某个客户端页面,偶尔有用户舆情反馈页面会出现卡顿,客户端同学开展一系列排查工作,甚至直接联系用户,用完全一样的机型,相同版本,同样网络环境..各种手段都上了,然而始终无法复现,百思不得其解的情况下,拉服务端同学一起排查,然而一样一无所获,从服务端监控上看,接口RT非常稳定,几乎没有抖动,甚至没有用户相关的任何异常日志。直到某一天,忽然发现,似乎有问题的用户请求都落入到一个相同的EA119的机房,赶紧进行模拟验证:
果不其然,几十倍的响应时长的差异!而要了解差异的来源,又免不了各种复杂分析,也略去不表,问题的根因的确不在于代码,而是网络请求链路的差异,下图是出问题的时候网络链路。
我们为了系统容灾诉求,业务应用会进行多机房容灾部署,也就是业务服务器会部署在张北和南通等多个机房,但是在上图这个网络架构中,我们会发现客户端请求都会先到张北的Aserver(网络接入层),如果识别到是需要请求到南通机房的,会再进行一次转发到南通,而单元机房之间的网络传输必然带来较大的RT,当网络优化后,需要路由到南通机房的用户的网络请求直接请求南通机房的Aserver(网络接入层)以后,如下图所示:
整个世界都清净了!
无论是客户端还是服务端,虽然没有修改一行代码,但是这个问题的解决时间那是相当长,包括问题根因排查也是耗时颇久,有时候,未必是代码的锅,程序员如果要成长为优秀的架构师,不仅对业务架构要了解,很多时候也需要对网络架构等更广泛的技术领域有一定的理解,才能在众多复杂问题中抽丝剥茧,一锤定音。
PS:可能读者有个疑问,为何服务端监控看不到南通机房RT异常呢,原因很简单,服务端RT监控是应用层监控,也就是只监控了请求从进入业务应用到离开业务应用的时长,而真正耗时的是Aserver和业务应用间的时长。
后记
这三个问题都是线上真实发生的问题,从最基础的三目运算符,到相对复杂的分布式系统依赖和调用,再到更复杂的端到端网络架构,每一步都是程序员到架构师的修炼之路,在这技术日新月异的时代,保持强烈技术好奇心和持续学习的良好习惯,相信日积硅步足以致千里。如果能够完整读完这篇文章的你也有一点点收获,那么祝贺你又进步了一点点,顺祝您新年快乐,在新的一年里,兔氣揚眉,前兔似錦!
附录1:JSL 15.25:https://docs.oracle.com/javase/specs/jls/se8/html/jls-15.html#jls-15.25