【原创申明:文章为原创,欢迎非盈利性转载,但转载必须注明来源】
这是在我们开发的一个支付系统中暴露的一个BUG,问题本身比较简单,有意思的是解决问题的过程。将过程分享出来,希望能够对大家有所帮助。
一、错误现象
在我们的支付系统中,有一个账户模块负责记录交易的流水,以供后续的查询以及对账清账等功能使用。就在春节放假前最后一天,当客户完成交易后,运营同事发现一个天大的问题,流水表中的部分金额,跟提交支付的金额有出入,差了几分钱。
这位客官说了,几分钱的问题,还是问题?哈哈,我也这么想,奈何运营、产品、测试同事们都不答应。好吧,其实我们程序猿是有洁癖的,怎么容忍有这样的问题出现?把火车票、机票都先放在看不见的地方,解决问题先。
先从不同的数据库中找出付款前后的金额进行比较,发现还真不是个案。这是当时比较的结果,黑体部分有差异。
这些数据中,业务系统的金额跟客户提交金额相等,账户记录的金额有异。
二、分析并定位问题
1.数据流转过程
下图是一个简略的支付、记录流水的过程。
通过检查各个环节的报文及数据库中保存的数据,发现问题出在第4步,金额在支付系统中无误,发送到账户系统并保存到数据库后就出现了误差。这儿发生了什么?
2.账户记账的处理过程
这是一个简略的处理过程,支付系统生成json并传输到账户系统,解析后保存到数据库。
经过查看各个环节的日志,发现问题出在解析环节。
3.错误重现
经过定位、调试,发现问题出在解析json数据的代码上。账户系统接收到传输来的json数据后,首先保存在一个字符串content中,然后利用代码将字符串转换为json对象。
JSONObject json = JSONObject.fromObject(content);
在Eclipse中设置断点跟踪,发现这行代码执行前后的变量值差异:
在转换前后,金额从 527726.03 变成了527726,这个差异符合前面观察到的错误现象。仔细查看json字符串,发现金额没有使用双引号括起来,说明生成json的时候,直接赋值的是金额,而不是转成字符串后再赋值。
那么如果将金额用双引号括起来,会有这个问题吗?再测试一下
神奇的是,转换为字符串后,转成json就没有问题了。
我们解析json,使用的是sf的json-lib库,其他json库是不是也有问题呢?使用另两个json库做了一些测试后发现,只有json-lib有这个问题。
有问题
<dependency>
<groupId>net.sf.json-lib</groupId>
<artifactId>json-lib</artifactId>
<version>2.4</version>
<classifier>jdk15</classifier>
</dependency>
没问题
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20160212</version>
</dependency>
和
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.6.2</version>
</dependency>
三、初步解决方案
根据前面的分析,立刻就有了两个很自然的解决方案:修改json中金额的格式、换JSON库。
1.修改json格式
用这个方案,只需要在支付系统中生成json对象的时候,将金额转成字符串之后在赋值到json即可。
但这种方案有缺点,需要将所有生成json的地方都检查一遍,确保所有金额都用字符串传递。因为这个地方代码有问题,其他地方代码也会有问题,只是还没暴露出来而已。
2.替换json库
这种方案,可以将json-lib替换为org.json。暂时不考虑gson,是因为这个gson库需要为json编写对应的Java类,修改工作量比较大。
那么,json-lib和org.json在代码生有什么差异呢?网上找了找,粗略的比较如下:
json-lib |
org.json |
|
构造 json 对象 |
JSONObject.fromObject(content) |
new JSONObject(content) |
是否存在key |
containsKey() |
has() |
array方法 |
size() add() |
length() put() |
读取json的限制 |
限制数据格式 |
|
spring封装 |
MappingJackson2HttpMessageConverter 支持 |
貌似缺省不支持 |
这种方案的代码量也是很大,所有涉及到json转换的地方都需要修改代码。如果采用替换json库的方法,有没有更简便一点的做法呢?
把《设计模式》里面的各种名称想了想,“适配器模式”,能不能用上?
3.替换json库+适配器
针对这个方案,做了一些技术预演,大概思路如下图
理想的目标是所有源码只需要使用一次查找-替换操作即可。
这个方案应该是可行的,只是这两个适配器类的写法需要比较严谨一点,写完代码后需要经过充分的测试无误,才能真正执行。
四、问题解决了吗?
前面提到了三种解决方案,从修改工作量上来看,第一种方案应该是最合适的,只需要修改支付系统的代码即可,代码也容易定位,修改也不容易出错。采用适配器的这个方案,看起来很高大上的样子,但风险较大,暂时先放弃。
还有没有更简单的方法?
1.json-lib为什么会出错?
负责开发账户的同事,下载了json-lib的源码,进行了进一步的跟踪调试,更准确的定位到了出错的位置:是在调用commons-lang.jar中的NumberUtils类中代码时出错。下图是一个简单的调用过程。
最终出错的地方是在解析 Float !!
重新写一个最简单的测试用例,
float floatValue = Float.valueOf("542772.03");
结果,floatValue = 542772.0。这是JDK的Float 数据类型固有的问题,我们同时在JDK1.7和JDK1.8下进行测试,都有这个问题。
同时,顺手写了一个测试用例,找出最小的十个会出错的金额,如下:
error1131072.01131072.02
error2131072.04131072.05
error3131072.07131072.06
error4131072.09131072.1
error5131072.13131072.12
error6131072.15131072.16
error7131072.18131072.19
error8131072.21131072.2
error9131072.24131072.23
error10131072.26131072.27
基本上每过几分钱就会出错。
2.有什么新的解决方案?
能想到两个新的方案
1、修改 java.lang.Float
2、修改 org.apache.commons.lang.math.NumberUtils
这两种方案,技术上可行吗?要从这个思路上去解决问题,需要解决两个问题:
1、能不能修改源码,解决BUG?
2、怎么让修改后的类,生效?
考虑到后续需要讨论的解决方案,先介绍一个大家可能司空见惯但没注意过的概念::ClassLoader
3.JVM ClassLoader
参考书目:《深入理解Java虚拟机》,有兴趣的自行阅读。(其实是我也讲不清楚)
① Tomcat中的class 加载顺序
对于普通java类,按照如下优先级进行加载。
l tomcat/webapps/<war>/WEB-INF/classes
l tomcat/webapps/<war>/WEB-INF/lib/*.jar
l tomcat/lib/*.jar
l jre/lib/*.jar
是不是所有的java类都是这个加载顺序?如果可以,我们是不是可以随便重载jdk自己提供的类?
② JRE ClassLoader
Java在设计的时候已经考虑到这个风险,不能允许随便替换JRE自己的类。所以,针对JRE自身的代码,使用的是另一套ClassLoader。对所有java.*和javax.*,使用的加载顺序
详细解析,自行查资料吧,我也不懂。
关键是结论:除非我们重写 JRE的jar,才能通过修改 java.lang.Float来解决问题。何况Float的问题,应该不好修改,否则Java早解决了。
3.怎么修改NumberUtils
在NumberUtils,方法 createNumber(String)首先调用createFloat(String)解析,如果抛Exception,再调用createDouble(String)。
有两个自然地修改方案:
1、修改 createNumber(),不再调用 createFloat(),直接调用createDouble()。
2、修改 createFloat(),如果数据解析出错,抛异常。
下面列了一个粗略的修改createFloat(String)的实现,基本思路是解析后再同原字符串做一个比较,如果值不同则抛异常。
public static Float createFloat(String str) {
if (str == null) {
return null;
}
str = removeZeroTail(str);
Float floatValue = Float.valueOf(str);
if (!removeZeroTail(String.valueOf(floatValue)).equals(str)) {
throw new NumberFormatException(str + " parse float error.");
}
return floatValue;
}
4.修改后的NumberUtils放哪儿?
根据前面对class loader的分析,修改后的NumberUtils类,有两个保存位置。
① 在账户系统中重写NumberUtils类
将NumberUtils类重写在src/main/java中,部署后在war/WEB-INF/classes下。
如果采用这个方案,需要在所有的项目中重写这个类。
③ 重做一个commons-langs.jar
我们使用的版本是2.6,如果能够重做一个新的版本,并让各个项目能方便的引用,这个方案应是最简单的。恰好,我们有内部的Maven库,分享jar不是问题。
五、最终方案:重做commons-lang.jar
1.代码修改
这个就不多说了,Eclipse建一个项目,进行必要的修改,然后打包放到内部maven库中。顺便推荐一个搭建maven内部库的利器:nexus,价格便宜(免费)量又足。当然前提是你需要有一个能够供大家访问的服务器。
2.项目修改方案
各项目修改方案,仅需要修改 pom.xml
① 所有引用了commons-lang的depencency
<dependency>
<groupId>net.sf.json-lib</groupId>
<artifactId>json-lib</artifactId>
<version>2.4</version>
<classifier>jdk15</classifier>
<exclusions>
<exclusion>
<artifactId>commons-lang</artifactId>
<groupId>commons-lang</groupId>
</exclusion>
</exclusions>
</dependency>
注意exclusion所有的commons-lang老版本引用。
② 引用commons-lang的新版本
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.7.0-SNAPSHOT</version>
</dependency>
六、解决方案的变迁过程
简单列一下方案变迁过程,
1、支付系统修改json格式的封装代码,金额都使用字符串。
2、账户系统替换 json 解析包。
3、写一个 json proxy,从org.json继承,实现json-lib的接口。
4、在项目中重写 NumberUtils工具类。
5、重做一个commons-lang的新版本,各项目引用。
我有时候爱说一句很装的话:一个问题,如果你找到了一个解决方案,那么说明你还没有理解这个问题。