昨天晚上,线上出了一个很诡异的Bug,折腾了我一夜,到今天早上才找到原因,原因竟然是我之前代码重构的时候,用了一个所谓的"重构技巧"导致的,真的是血的教训。
事情是这样的,上周我重构了一段老代码,把一个很长的方法拆成了几个小方法,用了一个所谓的"方法引用"的技巧,觉得代码更简洁了,更优雅了,当时还挺得意的,觉得自己的重构水平又提高了。结果昨天晚上线上出问题了,一个功能偶尔会报错,而且是概率性的,时好时坏,很难复现,我排查了一夜,最后才发现,就是那个重构技巧导致的,真的是肠子都悔青了。
今天就来记录一下这次踩坑经历,聊聊这个诡异的Bug是怎么产生的,我是怎么排查的,以及从中得到了什么教训,希望大家不要像我一样,为了所谓的"优雅"而踩坑。
一、事情的经过:线上突然报警,概率性报错
先说说事情的经过吧。昨天晚上八点多,我正在家吃饭,突然收到了线上的报警短信,说某个接口的错误率升高了,超过了阈值。我赶紧打开电脑,登录监控系统,一看,确实,那个接口的错误率从平时的0.1%不到,升到了5%左右,而且还在波动,时高时低,很诡异。
我赶紧看错误日志,发现报错是空指针异常(NullPointerException),但是报错的位置很奇怪,是在一个工具类的方法里,而且是概率性的,不是每次都报错,有的时候正常,有的时候报错,很难复现。
这个接口是一个查询接口,用的人很多,虽然错误率只有5%,但是影响还是挺大的,很多用户反馈说有时候查询失败,刷新一下又好了。领导很重视,让我赶紧排查,尽快修复。
我一开始以为是数据的问题,可能是某些数据为空导致的,但是看了日志,发现报错的那些请求,数据都是正常的,没有空的数据,而且同样的数据,有时候报错,有时候不报错,这就很诡异了,不像是数据的问题。
我又以为是并发的问题,可能是多线程并发导致的,但是这个接口是查询接口,没有写操作,应该不会有并发问题啊,而且这个接口已经上线很久了,一直很稳定,最近也没改什么,怎么突然就出问题了呢?
哦,对了,上周我重构了这段代码,把一个很长的方法拆成了几个小方法,难道是重构导致的?但是我重构之后,测试环境测过了,没问题啊,而且上线之后,前几天也没问题,怎么突然今天就出问题了呢?
不管了,先回滚吧,把代码回滚到重构之前的版本,看看是不是重构的问题。回滚之后,观察了一会儿,错误率果然降下来了,恢复正常了。看来确实是我重构的代码导致的,但是到底是哪里的问题呢?我重构的时候,明明测试过了,没问题啊,怎么线上就出问题了呢?
不行,得找到原因,不然以后还会踩坑。于是,我开始了漫长的排查之路,从晚上九点多,一直排查到今天早上六点多,终于找到了原因,真的是让人哭笑不得。
二、排查过程:一步步缩小范围,终于找到原因
回滚之后,线上恢复正常了,但是我不能就这么算了,得找到原因,不然以后重构还会踩坑。于是,我开始在测试环境复现这个问题,一步步排查。
第一步:对比重构前后的代码,找出差异
首先,我把重构前后的代码都拿出来,对比一下,看看有什么差异。重构之前,是一个很长的方法,大概有两百多行,做了很多事情,参数校验、数据查询、数据处理、结果组装,都在一个方法里。重构之后,我把这个方法拆成了几个小方法,每个方法做一件事,比如参数校验、数据查询、数据处理、结果组装,然后在主方法里依次调用这几个小方法。
代码逻辑上,重构前后是一样的,没有改变业务逻辑,只是把方法拆小了,应该不会有问题啊。那问题出在哪里呢?
我仔细看了一遍,发现我在重构的时候,用了一个所谓的"技巧",就是把一些重复的代码,用方法引用(Method Reference)的方式提取出来,觉得这样更简洁,更优雅。比如,有几个地方都需要对数据做一个转换,我就写了一个通用的转换方法,然后用方法引用的方式来调用,觉得这样代码更简洁。
难道是方法引用的问题?但是方法引用是Java 8的特性,很常用啊,怎么会有问题呢?而且测试环境测过了,没问题啊。
第二步:在测试环境复现,但是复现不了
我在测试环境,用重构后的代码,反复测试,但是怎么都复现不了,不管怎么测,都是正常的,没有报错。这就更诡异了,线上概率性报错,测试环境怎么都复现不了,这可怎么排查?
我想,可能是并发量的问题,测试环境并发量小,线上并发量大,所以线上能复现,测试环境复现不了。于是,我用压测工具,在测试环境模拟高并发,压测了一会儿,果然,复现了!报错了,和线上一样的空指针异常,而且也是概率性的,不是每次都报错。
太好了,终于复现了,能复现就好办了。接下来就是一步步排查,找到根本原因。
第三步:加日志,缩小报错范围
我在代码里加了很多日志,把每个变量的值都打印出来,看看哪个变量是空的,导致的空指针异常。压测了一会儿,看日志,发现报错的时候,有一个变量是空的,这个变量是从一个方法的返回值来的,也就是说,那个方法返回了null,导致后面用的时候空指针了。
但是那个方法,逻辑很简单,就是做一个数据转换,输入不为空的话,输出就不应该为空啊,怎么会返回null呢?而且,输入的数据我看了,是正常的,不为空,那为什么方法返回null呢?
我又在那个方法里加了日志,看看方法内部的执行情况,结果发现,报错的时候,那个方法根本就没执行!也就是说,方法没执行,返回了null,所以后面用的时候空指针了。
这就更诡异了,方法明明被调用了,怎么会没执行呢?而且还是概率性的,有时候执行,有时候不执行,这是什么情况?
第四步:仔细看方法引用的代码,发现了问题
我盯着那段方法引用的代码,看了很久,突然,我发现了问题!
我用方法引用的时候,是这样写的:
Function<Data, Result> converter = this::convert;
// 然后在某个地方用
Result result = converter.apply(data);看起来没问题啊,方法引用,很正常的写法啊。但是,问题出在哪里呢?
我突然想到,这个convert方法,是不是有重载?对,这个convert方法有两个重载版本,一个是接收Data类型的参数,一个是接收String类型的参数。那方法引用this::convert,到底引用的是哪个重载版本呢?
Java的方法引用,在有重载的情况下,是根据上下文的目标类型来推断的,也就是说,根据Function<Data, Result>这个类型,推断出是接收Data参数的那个版本。按理说,应该没问题啊,类型是明确的。
但是,我又仔细看了一下代码,发现我在某个地方,把这个converter变量,赋值给了另一个类型的变量,而且是在一个条件分支里,有时候赋值,有时候不赋值,而且,那个变量的类型是泛型的,类型推断可能有问题!
哦,我知道了,问题出在类型擦除和重载方法引用的组合上!Java的泛型是类型擦除的,运行时泛型类型会被擦掉,而方法引用在有重载的情况下,是编译时根据目标类型推断的,但是如果目标类型是泛型,而且在某些情况下类型推断不准确,就可能导致引用了错误的重载版本,运行时参数类型不匹配,就会出问题!
但是,这也不应该返回null啊,应该是类型转换异常啊。我又仔细看了一下,发现那个错误的重载版本,接收的是String参数,而我传进去的是Data类型,运行时,因为类型擦除,参数变成了Object,然后方法内部,把Object强转成String,强转失败,抛了ClassCastException,但是,这个异常被吞掉了!
对,我在调用converter.apply(data)的地方,外面包了一个try-catch,catch了Exception,然后打了个warn日志,返回了null!我当时写的时候,觉得转换失败的话,返回null也没关系,后面会处理,结果,就是这个try-catch,把ClassCastException吞掉了,返回了null,导致后面空指针了!
而且,为什么是概率性的呢?因为类型推断的问题,有时候推断对了,引用的是正确的重载版本,就正常;有时候推断错了,引用的是错误的重载版本,就抛异常,被吞掉,返回null,就报错了。而且,这个和编译器的版本、编译的环境都有关系,所以测试环境可能没问题,线上就出问题了,真的是太坑了!
第五步:验证问题,确认原因
找到原因之后,我赶紧验证一下,把方法引用改成普通的方法调用,不用方法引用了,然后压测,果然,不再报错了,一切正常。然后,我又把方法引用改回来,但是去掉重载,只保留一个版本,压测,也正常了。
看来,确实是重载方法引用+类型擦除+异常被吞,这几个因素组合在一起,导致了这个诡异的概率性Bug,真的是让人防不胜防啊。
找到原因的时候,已经是今天早上六点多了,我折腾了一夜,终于找到了原因,虽然很累,但是也很有成就感,同时也很后怕,要是没找到原因,以后还会踩类似的坑。
三、这个Bug的根本原因:几个因素的组合
现在来总结一下这个Bug的根本原因,其实是几个因素组合在一起导致的,单独一个因素都不会有问题,但是组合在一起,就产生了这个诡异的概率性Bug:
1. 重载的方法引用
方法引用本身没问题,但是如果方法有多个重载版本,方法引用在编译时需要根据目标类型来推断引用哪个版本,如果目标类型是泛型,或者类型推断不明确,就可能推断错误,引用了错误的重载版本。
这个问题,在Java语言规范里其实是有说明的,重载方法引用的类型推断,在某些复杂的情况下,可能会有歧义,编译器的行为可能不确定,不同的编译器版本可能有不同的结果,所以,在有重载的情况下,尽量不要用方法引用,或者用明确的类型转换,确保引用的是正确的版本。
2. 泛型的类型擦除
Java的泛型是类型擦除的,运行时泛型类型会被擦掉,所以编译时推断的类型,运行时可能不生效,即使编译时推断对了,运行时也可能因为类型擦除而出现问题。
类型擦除是Java泛型的一个老问题了,很多坑都是类型擦除导致的,在使用泛型和方法引用、lambda表达式等新特性的时候,一定要注意类型擦除的问题,不要想当然地认为编译时的类型运行时也生效。
3. 异常被吞掉,返回null
如果只是前面两个问题,那会抛ClassCastException,很容易排查,但是我在外面包了一个try-catch,把异常吞掉了,打了个warn日志,返回了null,导致后面空指针,而且异常日志可能被淹没,很难发现,这才让这个Bug变得诡异,难以排查。
吞异常是一个很不好的习惯,很多诡异的Bug都是因为异常被吞掉导致的,出了问题找不到原因。即使要捕获异常,也要打清楚日志,或者把异常抛出去,不要静默返回null,不然出了问题很难排查。
4. 概率性出现,难以复现
因为类型推断的不确定性,这个Bug是概率性出现的,有时候正常,有时候报错,而且测试环境可能复现不了,只有线上高并发的时候才能复现,这就大大增加了排查的难度,让这个Bug变得更诡异。
很多线上的Bug都是概率性的,难以复现,排查起来很费劲,所以,在写代码的时候,一定要注意这些可能导致概率性问题的地方,比如并发、类型推断、缓存等,尽量写确定性的代码,不要写依赖编译器行为或者运行时环境的代码。
四、从中得到的教训和经验
这次踩坑,折腾了我一夜,但是也让我学到了很多,得到了很多教训和经验,分享给大家,希望大家不要像我一样踩坑。
1. 不要为了所谓的"优雅"而用复杂的特性,简单才是王道
这是我最大的教训。当时重构的时候,为了让代码看起来更"优雅"、更"简洁",用了方法引用这个技巧,觉得自己水平很高,结果反而踩了坑,折腾了一夜,还影响了线上用户。
其实,很多所谓的"优雅"、"技巧",都是华而不实的,增加了代码的复杂度,却没有带来实际的好处,反而容易踩坑。代码首先是给人看的,其次才是给机器执行的,简单、清晰、易懂的代码,才是最好的代码,不要为了炫技而用复杂的特性,简单才是王道。
方法引用、lambda表达式这些特性,本身是好的,能简化代码,但是也要看场景,在简单明确的场景下用,没问题,但是在有重载、有泛型、类型推断不明确的场景下,就要谨慎使用,不如用普通的方法调用,更清晰,更安全。
2. 重构一定要小心,不要改变代码的行为,要充分测试
重构的目的是改善代码的结构,而不是改变代码的行为,所以重构的时候,一定要确保行为不变,而且要充分测试,不仅要测正常的场景,还要测边界场景、并发场景,确保重构后的代码和重构前的行为完全一致。
我这次重构,虽然测试环境测过了,但是没有测高并发的场景,也没有测边界情况,所以没发现问题,上线之后高并发才出问题。以后重构,一定要充分测试,特别是并发场景,不能掉以轻心。
而且,重构最好小步进行,每次只改一点点,测完没问题再改下一点,不要一下子改很多,那样出了问题很难定位。我这次就是一下子把整个方法都重构了,出了问题还要一点点对比,找差异,很费劲。
3. 不要吞异常,异常要打清楚日志,或者抛出去
吞异常是一个很不好的习惯,很多诡异的Bug都是因为异常被吞掉导致的,出了问题找不到原因。即使要捕获异常,也要打清楚日志,把异常栈打出来,或者把异常包装一下抛出去,不要静默返回null,不然出了问题很难排查。
我这次就是因为吞了异常,返回null,导致后面空指针,而且异常日志被淹没了,找了很久才找到原因。以后写代码,一定不要随便吞异常,要让异常暴露出来,这样出了问题才能快速定位。
当然,也不是说所有异常都要抛出去,有些异常是可以预期的,比如网络超时、数据库连接失败,这些可以捕获,但是一定要打清楚日志,并且要有降级或者重试的机制,不要静默处理。
4. 线上概率性Bug,要考虑并发、类型推断、缓存等因素
线上的概率性Bug,往往比较难排查,因为难以复现,遇到这种Bug,要考虑几个常见的因素:并发问题、类型推断问题、缓存问题、环境差异问题等。
并发问题是最常见的,多线程共享变量、竞态条件、可见性问题等,都可能导致概率性Bug;类型推断问题,比如我这次遇到的方法引用重载推断,还有泛型类型擦除,也可能导致概率性问题;缓存问题,缓存不一致、缓存穿透、缓存雪崩等,也可能导致概率性问题;环境差异问题,测试环境和线上环境的差异,比如数据量、并发量、配置、编译器版本等,也可能导致测试环境没问题,线上出问题。
遇到概率性Bug,不要慌,一步步来,先在测试环境复现,尽量模拟线上的环境和并发量,能复现就好办了,然后加日志,缩小范围,一步步定位,总能找到原因的。
5. 代码评审很重要,能发现很多自己发现不了的问题
这次重构,如果我提交代码的时候,做了代码评审,让同事看一下,可能同事就能发现这个方法引用的问题,就不会上线出问题了。但是我当时觉得自己重构得很好,没问题,就没找人评审,自己合并上线了,结果出了问题。
代码评审是一个很好的实践,能发现很多自己发现不了的问题,因为自己写的代码,自己看的时候,会有思维定式,觉得没问题,但是别人看,就能发现问题。所以,不管是重构还是新功能,提交代码的时候,最好找人评审一下,不要自己觉得没问题就上线,多人看一下,能避免很多问题。
而且,代码评审也是一个学习的过程,看别人的代码,能学到很多好的实践,别人看你的代码,给你提意见,也能让你进步。所以,一定要重视代码评审,养成代码评审的好习惯。
五、给大家的一些重构建议
最后,结合这次踩坑经历,给大家一些重构的建议,希望大家在重构的时候,能少踩坑,更顺利:
1. 重构前,先确保有测试用例,重构后,行为要和之前一致。
2. 小步重构,每次只改一点点,测完没问题再改下一点,不要一下子改很多。
3. 不要为了炫技而用复杂的特性,简单、清晰、易懂的代码才是最好的。
4. 使用新特性(方法引用、lambda、Stream等)的时候,要注意边界情况,比如重载、泛型、类型推断,不确定的地方,用普通的写法更安全。
5. 不要吞异常,异常要打清楚日志,或者抛出去,不要静默返回null。
6. 重构后,要充分测试,不仅测正常场景,还要测边界场景、并发场景、异常场景。
7. 提交代码前,找人做代码评审,多人看一下,能避免很多问题。
8. 上线后,密切关注监控和日志,有问题及时回滚,不要硬扛。
六、写在最后
线上出了个代码重构技巧的Bug,我排查了一夜。
这次踩坑经历,真的是让我印象深刻,折腾了一夜,才找到原因,原因竟然是一个所谓的"重构技巧"导致的,真的是血的教训。
通过这次经历,我深刻地认识到,代码首先是给人看的,其次才是给机器执行的,简单、清晰、易懂的代码,才是最好的代码,不要为了所谓的"优雅"、"技巧"而用复杂的特性,那样只会增加代码的复杂度,容易踩坑,得不偿失。
同时,我也认识到了重构的风险,重构一定要小心,要确保行为不变,要充分测试,要小步进行,要代码评审,不能想当然,不能掉以轻心,不然线上出了问题,影响用户,就得不偿失了。
希望我的这次踩坑经历,能给大家一些警示,一些启发,大家在写代码、重构的时候,能少踩坑,更顺利,写出更稳定、更易维护的代码。
最后,用一句话结尾:"简单是可靠的前提,复杂是bug的温床。"愿我们都能写出简单、清晰、可靠的代码,少踩坑,少熬夜,做一个快乐的程序员。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录