诱因
近期测试反应某项目生产环境通过某种操作流程可以使接口报错。但部分接口可用,部分接口不可用。
排查
以下的排查过程如实地记录了当时排查问题的所思所想,可能走了不少弯路。但本着大胆假设,小心求证的态度,一步步逼近问题根源。虽然问题得以解决,但问题的源头尚未明确。此文意在记录排查过程和排查思路。
由于是生产环境,原谅我使用浓厚的马赛克抹去敏感信息。
是字符串越界问题?
遇到问题不要慌,首先先上环境看下日志里都发生了什么:
java.lang.StringIndexOutOfBoundsException: String index out of range: -2089467602
at java.lang.String.getChars(String.java:824)
at com.alibaba.fastjson.serializer.SerializeWriter.writeFieldValueStringWithDoubleQuoteCheck(SerializeWriter.java:1906)
at com.alibaba.fastjson.serializer.ASMSerializer_18_UserLoginDTO.write(Unknown Source)
at com.alibaba.fastjson.serializer.ASMSerializer_10_UserCacheDTO.write(Unknown Source)
at com.alibaba.fastjson.serializer.JSONSerializer.write(JSONSerializer.java:285)
at com.alibaba.fastjson.JSON.toJSONString(JSON.java:745)
at com.alibaba.fastjson.JSON.toJSONString(JSON.java:683)
at com.alibaba.fastjson.JSON.toJSONString(JSON.java:648)
复制代码
好嘛,这不就是个序列化成Json字符串时报错嘛。
但仔细一看,发现事情没有这么简单,为什么越界的是个负数-2089467602
?
来看下java.lang.String.getChars(String.java:824)
(注:该环境为JDK8):
public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
if (srcBegin < 0) {
throw new StringIndexOutOfBoundsException(srcBegin);
}
if (srcEnd > value.length) {
throw new StringIndexOutOfBoundsException(srcEnd);
}
if (srcBegin > srcEnd) {
throw new StringIndexOutOfBoundsException(srcEnd - srcBegin); // 824行
}
System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
}
复制代码
该错由824行throw new StringIndexOutOfBoundsException(srcEnd - srcBegin)
抛出,说明srcEnd - srcBegin = -2089467602
。大的减小的能减成负数,要么srcEnd就是负数;要么srcBegin是个非常小的负数,srcEnd是非常大的正数,两个一减,溢出成负数。这就要看下调用该方法时传入的是什么值。
看上一级在调用这个方法时,com.alibaba.fastjson.serializer.SerializeWriter.writeFieldValueStringWithDoubleQuoteCheck(SerializeWriter.java:1906)
都传了什么进来(注:fastjson版本为1.2.67,不同版本有差异):
public void writeFieldValueStringWithDoubleQuoteCheck(char seperator, String name, String value) {
int nameLen = name.length();
int valueLen;
int newcount = count;
if (value == null) {
valueLen = 4;
newcount += nameLen + 8;
} else {
valueLen = value.length();
newcount += nameLen + valueLen + 6;
}
// 省略无关代码
value.getChars(0, valueLen, buf, valueStart); // 1906行
// 省略无关代码
}
复制代码
看到text.getChars时有点诧异,srcBegin=0,srcEnd=valueLen=value的字符串长度。这…这是怎么算出负数的????
这不科学!我要看看这text里对象长什么样!
但…但这是生产环境,不像本地调试那样方便。
这时候,要请出我们的好兄弟:阿尔萨斯!
不好意思,不是这个阿尔萨斯。
是这个:
阿里巴巴开源的Java诊断工具,地址:github.com/alibaba/art… 。 它可以:查看运行状态,看JVM参数,看方法调用链,看观察方法的入参和返回结果等等。具体使用方法可以看看他们的文档:arthas.aliyun.com/doc/ 。
下面的排查过程将会持续使用该工具进行辅助验证猜想。并不会对命令做过多介绍,因为官方有文档示例也有在线教程。
下载arthas,并拷贝到生产环境运行。
Jar包老规矩:java -jar arthas-boot.jar
然后选择进程后回车,arthas完成增强。此时就可以使用arthas的功能了。
arthas原理: www.jianshu.com/p/4e34d0ab4…
回到刚刚的排查过程,现在我们要看writeFieldValueStringWithDoubleQuoteCheck(char seperator, String name, String value)
的入参value到底长啥样。
使用命令watch com.alibaba.fastjson.serialize.SerializeWriter writeStringWithSingleQuote "{params,throwExp}"
查看方法入参:
可以看到@Object[]那行开始,从上至下依次是入参的顺序。类分别是Character、String、UserImVO。
等等……为什么String的入参可以传一个其他对象进来啊??
这时候我开始怀疑是不是fastjson的bug,导致生成的ASMSerializer_18_UserLoginDTO类有问题,将错误的对象传入了方法内。
为了验证猜想,看看ASMSerializer_18_UserLoginDTO.write
的对象是什么:
这……write的对象里的id就是UserImVO类型,和我们期望的String类型大相径庭。
是FastJson问题?
at com.alibaba.fastjson.serializer.SerializeWriter.writeFieldValueStringWithDoubleQuoteCheck(SerializeWriter.java:1906)
at com.alibaba.fastjson.serializer.ASMSerializer_18_UserLoginDTO.write(Unknown Source)
at com.alibaba.fastjson.serializer.ASMSerializer_10_UserCacheDTO.write(Unknown Source)
at com.alibaba.fastjson.serializer.JSONSerializer.write(JSONSerializer.java:285)
复制代码
这时候,思路回到问题的起点。目光聚焦在中间两个由fastjson生成的class上。com.alibaba.fastjson.serializer.ASMSerializer_18_UserLoginDTO
和com.alibaba.fastjson.serializer.ASMSerializer_10_UserCacheDTO
插播:简单说下fastjson(以1.2.67为例)是怎么根据对象生成json字符串的。
- 我们调用JSON.toJSONString(Object)
- 生成JSONSerializer并初始化SerializeConfig
- SerializeConfig内持有ASMSerializerFactory并创建SerializeBeanInfo,前者生成对象的Serializer,后者遍历对象后得到对象信息
- ASMSerializerFactory.createJavaBeanSerializer根据SerializeBeanInfo,创建名为ASMSerializer_序号_简短类名的class,使用ASM生成二进制的字节码。
- 生成字节码后使用类加载器ASMClassLoader.defineClassPublic加载该类到jvm
- 使用该类输出json字符串
那我们要怎么看到生产环境的运行时class信息呢?没错,又是arthas。
使用命令dump com.alibaba.fastjson.serializer.ASMSerializer_18_UserLoginDTO
生成一份class到硬盘。我这里直接把两个class都保存下来。
打开ASMSerializer_18_UserLoginDTO
,并找到有关打印id字段的代码:
var6 = "id"; // field 名称
var13 = var10.getId(); // 这里var10是UserLoginDTO
if (var13 == null) { // 不走这里
if (var9.isEnabled(132)) {
var9.write(var12);
var9.writeFieldNameDirect(var6);
var9.writeNull(0, 128);
var12 = ',';
}
} else {
// 不为null,走这里。这里的var12是字符串逗号,var6是name,var13是value
var9.writeFieldValueStringWithDoubleQuoteCheck(var12, var6, var13);
var12 = ',';
}
复制代码
该代码节选自ASMSerializer_18_UserLoginDTO.class
生成id字段的代码。可以看出fastjson如实地获取了userLoginDTO内的id对象,没有做什么骚操作。那么很显然,在传入fastjson之前,这个对象内的id就已经不是string类型了。
接着我沿着业务代码(这里就不做展示了)顺藤摸瓜,找到了UserLoginDTO产生源头——来自redis。
该项目使用了jetcache封装了对redis数据的操作。
这里再插播下jetcache(github.com/alibaba/jet… ):JetCache is a Java cache abstraction which provides consistent use for various caching solutions.
一样的方法,使用watch查看jetcache查数据返回的对象,发现果然id的类为UserImVO。看来数据从redis取出来时就已经不对了。
于是我翻看了相关jetcache的配置,发现此时配置序列化反序列化器为kryo,那么问题难道是……
kryo(github.com/EsotericSof… ):Kryo is a fast and efficient binary object graph serialization framework for Java.
是Kryo问题?
我们再观察一下jetcache提供的kryo在反序列化时返回的值,便可知一二:
这里我们查看的是jetcache在从redis取到byte[]后,将byte[]通过封装好的kryo反序列化类反序列化成想要的对象。图中可以明显看出从redis中获取数据后在反序列化成对象时,数据就已经不正常。
是kryo的锅?
我从可以正确生成json字符串的日志里取出相关数据,并通过相同版本的kryo序列化和反序列化,发现并不能复现该问题。
于是整理了以上被我怀疑过的库及版本信息。
库 | 报错的接口 | 不报错的接口 |
---|---|---|
fastjson | 1.2.67 | 1.2.70 |
jetcache | 2.6.0.M2 | 2.6.0.M2 |
kryo | 4.0.2 | 4.0.2 |
业务代码 | 相差3个月 | 相差3个月 |
- 针对fastjson,我翻看了68、69、70的changelog,发现并未有相关问题修复。但相关方法诸如
writeFieldValueStringWithDoubleQuoteCheck
改动挺大。 - jetcache和kryo均为一模一样的版本,且相同的对象在我本地序列化反序列化时未出现问题。
那么查到最后是业务代码的问题?
因为报错接口与不报错的接口共同都会调用该段代码,不报错的接口更新时间比报错的晚3个月。于是我查看了期间代码更新记录,发现UserLoginDTO新增了若干个字段。难道新增若干个字段就会影响到kryo的反序列化结果吗?
结论
后续将业务代码更新后,该问题得以解决。虽然解决的方法有点简单粗暴,但这问题依然尚未明确。我的猜想依旧停留在kryo的问题上。
参考
- arthas文档:arthas.aliyun.com/doc/en/