前些日子发表了一篇对账的预热,现在来一篇干货。 文章精而不在多,多了也浪费大家时间。所以,这也是我放弃原来的公众号注册现在这个服务号来进行分享经验的原因之一。每月几篇分享,足以。
平时很少登录公众号后台,如果有需要联系的,可以通过我的博客发送邮件给我。
本系列分为两篇。本篇主要讲解针对千万级别订单对账系统的分析以及架构,以及实际项目中遇到的坑,和解决方案。
本系列中不介绍讲解中涉及到的中间件,数据库,框架,插件等基础知识,若学习基础知识或者项目搭建的,可以关注我的GitHub/博客(https://github.com/chenhaoxiang),不定期push项目代码/文章。 本文中不会采用专业对账中的专有名词,尽管放心阅读。
前期该系统是作为一个备用系统开发的,也就没有那么多讲究,重构了两次,现在支持对账数据量多少的瓶颈完全在Redis了,目前将近千万级别订单量的对账,使用服务器内存高峰在2G左右。
现在二期对账系统的开发(一期对账系统和二期对账系统是分开的,不是重构)也在进行中了(针对亿级别订单量的对账),在后面会出如何完成日千万级别以上的订单对账(二)。
对账的基本5大步骤,按照正常的对账来走,基本离不开下面这5个步骤
画个图大概就是下面这样的:
查询订单的时候,每日千万级别的订单数据,如果使用通常的分页查询,那么查询的速度会越来越来慢。在这里推荐根据时间优先查询出最小id和最大id,然后再根据id,分批查询订单数据。
在一期系统中,我使用了Redis作为订单数据缓存以及订单比对,并且通过取模,将订单分批。这样的好处就是,水平扩展非常的方便。无需担心业务的增长。 缺点就是,依赖Redis服务器,由于是Redis是单线程,即使我们增加服务器,分批处理数据传输到Redis进行数据比对,但是随着业务增加,对账速度也会越来越慢(可以使用Redis集群,以及分批传输比对解决该问题)。
注意!**Redis服务器一定要和服务器在一个内网进行数据传输!**否则,速度会让你绝望的~
该系统花费时间最多的地方是在下载文件和加载文件数据的时候。
下载就不说了,通道方提供FTP下载的服务器带宽就那么大。
主要是加载文件,我们是可以处理的,一期系统使用的是单线程加载,并且是加载对象,加载以及序列化需要的时间也不能忽略,在这里消耗时间比较多。将近千万的数据大约需要10分钟左右,这是无法接受的。
序列化强烈推荐Protostuff(比JSON序列化也要快,不推荐kryo)。不要使用Java原生序列化。 Protostuff无论是从性能,还是需要内存大小来说,比Java原生好太多了(实际上,opencsv加载对账数据是可以优化成不需要使用对象的,在下篇二期对账系统中会体现出来。传输到Redis中可以选择字符串或者使用Protostuff序列化成字节流进行传输)
Java序列化框架性能比较(https://blog.csdn.net/qq_26525215/article/details/82943040)
系统中要应用一些设计模式,例如:对账可以使用策略抽象工厂模式,每一种对账的实现对应着一种具体的策略实现,并且尽量将系统中实现的细粒度化,方便解耦以及方便复用。
商户维度对账是为了校验商户的收入的,以及出款。所以也非常重要。前面的订单对账可以理解为是为商户对账而服务的。 基本步骤其实和订单对账类似,对于该维度对账不做过多解释
在项目中也用到了很多的第三方工具,如下图
另外也用到了一些设计模式以及系统的特点
1.一期系统中依赖opencsv解析CSV文件到对象中,由于opencsv内部使用多线程+netty读取文件数据到List,导致堆外内存溢出过一次(OOM)。解决方案可以扩大堆外内存,或者禁用netty使用堆外内存,转为使用堆内存。 扩大堆外内存:
-XX:MaxDirectMemorySize=1024m
禁用netty使用堆外内存:
-Dio.netty.noPreferDirect=true \
-Dio.netty.leakDetectionLevel=advanced \
在这里,视情况而定,如果你的服务器内存足够,将堆外内存扩大即可。毕竟禁用netty使用堆外内存会一定程度上影响解析文件的速度
你也可以选择自己解析csv文件,其实也挺方便的,本人也试了,但是需要处理的特殊数据有点多。例如,CSV文件是以逗号分隔列的,有的订单名称中会含有逗号,这个就需要特殊处理了。或者说数字强转字符串的符合等等,如果自己处理,都需要自己来进行特殊判断,在速度和可靠性上,其实并不如opencsv处理的好。所以最终也就确认了使用opencsv来进行解析csv文件。
2.opencsv中有一个可以针对对账进行改进的点,由于对账数据在进行插入操作比较频繁,所以不推荐使用数组集合,强烈建议使用链表集合。而opencsv中CsvToBean.parse()中使用的是ArrayList,可以使用装饰者模式将该类和CsvToBeanBuilder类重写,使用LinkedList实现。也可以利用反射,动态代理该方法的实现。经过实践,改用链表集合后,对账速度提升了1分钟左右
3.关于对账出问题的时候,如何快速定位,在对账中,难免有的情况下出现问题。在一期系统运行初期,遇到过各种各样的问题。银联/平台方数据错误、支付通道方数据不完整、某个数据未按照格式生成,多了特殊符号,导致解析错误、Redis传输数据超时等等
在一期系统运行前期,OOM的事件也发生过几次,在这里,也介绍一下如何进行JVM的优化,防止OOM
Java堆,可以简单的分为新生代和老生代。 新生代:存放生命周期比较短的对象。 老年代:存放生命周期比较长的对象(简单的说就是几次GC后没有回收的对象)。
在对账系统中,每天的运行时间只有那么几十分钟。 而这几十分钟的时间内,在for循环中,会产生千万级别的对象,例如订单号这种无法重复使用的字符串等等。 所以,针对新生代的内存分配,一定要等于/高于老生代的内存的
我选择将新生代:老生代的比例调整为2:1(具体的,请自行选择,在这里推荐1:1),另外,如果系统中对象使用比较挫,你可以定时显式调用GC,加快垃圾对象的回收
优化后效果明显,600多w数据对账速度快了62秒(该时间不包括FTP下载对账单,解密,解压的时间) 原来的设置: -Xms6G -Xmx6G -Xmn2G
优化后的设置: -Xms6G -Xmx6G -Xmn4G
但是注意,新生代的GC(Minor GC)一般采用的是复制算法,因为此算法的突出特点就是只关心哪些需要被复制,可达性分析只用标记和复制很少的存活对象。不用遍历整个堆,因为大部分都是要丢弃的。但是其缺点也很明显,需要浪费一半的内存空间(优化的话就是分成eden和survivor,这里不讨论)。优点也看到了,GC速度快(一般是比老生代的Major GC 快10倍以上)。
1.不要用Log4j输出文件名、行号,因为Log4j通过打印线程堆栈实现,生成大量String。此外,使用log4j时,建议先判断对应级别的日志是否打开,再做操作,否则也会生成大量String(slf4j接口里为了避免过早字符串拼接可能引起不必要的开销,将其推迟到了要打印的时候才拼接,可以不必显式的加一次if判断,但是注意,要使用format形式的写法,不用使用+号拼接)。
2.超过100W数据for循环的字符串拼接,JDK8以上推荐使用+号拼接。千万不要使用format进行拼接。 在500W数据拼接的情况下
测试代码如下:
/**
* 动态拼接字符串测试
* 动态拼接字符串指的是仅在运行时才知道最终字符串的子字符串
* @param args
*/
public static void main(String[] args) {
long st;
long et;
int size = 5000000;
st = System.nanoTime();
for (int i = 0; i < size; i++) {
format("format" + i, 21);
}
et = System.nanoTime();
System.out.println("format " + (et - st) / 1000000 + "ms");
st = System.nanoTime();
for (int i = 0; i < size; i++) {
plus("plus" + i, 21);
}
et = System.nanoTime();
System.out.println("plus " + (et - st) / 1000000 + "ms");
st = System.nanoTime();
for (int i = 0; i < size; i++) {
concat("concat" + i, 21);
}
et = System.nanoTime();
System.out.println("concat " + (et - st) / 1000000 + "ms");
st = System.nanoTime();
for (int i = 0; i < size; i++) {
builder("builder" + i, 21);
}
et = System.nanoTime();
System.out.println("builder " + (et - st) / 1000000 + "ms");
st = System.nanoTime();
for (int i = 0; i < size; i++) {
buffer("buffer" + i, 21);
}
et = System.nanoTime();
System.out.println("buffer " + (et - st) / 1000000 + "ms");
}
static String format(String name, int age) {
return String.format("使用%s,今年%d岁", name, age);
}
static String plus(String name, int age) {
return "使用" + name + ",今年" + age + "岁";
}
static String concat(String name, int age) {
return "使用".concat(name).concat(",今年").concat(String.valueOf(age)).concat("岁");
}
static String builder(String name, int age) {
StringBuilder sb = new StringBuilder();
sb.append("使用").append(name).append(",今年").append(age).append("岁");
return sb.toString();
}
static String buffer(String name, int age) {
StringBuffer sb = new StringBuffer();
sb.append("使用").append(name).append(",今年").append(age).append("岁");
return sb.toString();
}
可以自行进行校验。 在JDK5以后,其实JDK对于+号的字符串拼接,在编译以后都是使用的StringBuilder,所以说,为了方便,你完全不需要去考虑使用+号还是StringBuild,在10W次的循环以内,StringBuild拼接的效率大约是+号的1.1倍左右,完全可以忽略。而花费的时间,10W次使用+号拼接的时间是59ms,使用StringBuild是50ms。超过10W次循环,+号拼接花费的时间将小于StringBuild,循环次数越多,差距越明显。 (实际拼接需要的时间与个人电脑配置有关) 在没有并发的情况下,大胆的使用+号吧
在实际对账中,使用format进行拼接字符串对账花费时间:
format拼接优化为+号连接之后:
3.不要使用finalizer方法,会影响GC的执行
4.释放不必要的引用,各种流记得使用完后进行close,强烈推荐使用try-with-resources方式自动关闭流(JDK7以上支持)
5.尽量不要在for循环中动态加载类,如有必要,一定要缓存
6.for循环中尽量避免replace/replaceAll方法(可以使用apache的commons-lang里面的StringUtils对应的方法)的使用
7.千万级别数据在上午高峰期读取线上的订单从库,建议可以在每读取1W数据进行10ms左右的休眠(推荐在半夜进行缓存)
8.百万级别、千万级别+的数据集合,不要一次性进行读取/存入Redis,当然,你也可以这么做(记得把超时时间设置过长,否则会出现Redis响应超时)。
最简单的处理方式就是,可以对于订单号进行取模(但是更加建议使用charAt/substring取订单号中的某一位或者某几位随机的数进行拼接Key,因为订单号可能不是数字,我们公司的就不是…),分批存入Redis的不同key下的集合中,这样即使到达千万、亿级别的数据,只需要增加服务器,进行分布式对账即可。完全可以把时间控制在十万级别的对账的范围内(不排除可能出现千万数据订单号的那一位数字全部一样的情况,需要考虑该种情况的重新分配)。 charAt方法的源码为:
public char charAt(int index) {
if ((index < 0) || (index >= value.length)) {
throw new StringIndexOutOfBoundsException(index);
}
return value[index];
}
得益于String的内部实现,可以非常快速返回value当前位置的字符。调用charAt基本不会消耗时间。千万级别数据调用charAt方法,多100ms左右的时间。
9.将对账文件进行拆分,并且使用多线程读取实际对账需要的字符串(例如订单号,金额,手续费,状态等必要的字段)存储到Redis中,可以加快你很多的速度。
10.不要用Java原生序列化!在这里推荐fst、protostuff(不推荐kryo,坑位比较多,可自行百度)。
11.对账文件中的特殊符号一定要处理到位,例如制表符,空格等等,即使没有,但是一定要转换或者判断,防止某一天突然的特殊情况发生,加上某些判断,替换,千万级别订单对账时长大约延迟2分钟左右,且时间会随着订单量而线性增长。
12.每个对账的逻辑可能都不同,但是能够抽出来公共步骤或者方法的,一定要抽出类或者方法,不要任代码冗余,否则,后期的维护或者代码可读性非常差。
13.对账数据不要加载到数组集合中,选择LinkList或者set。如果是多线程下,选择线程安全的集合。 (注意,文章中的一些测试的时间,由于服务器性能的不同,会有一定的差距)
最近在学习区块链,本来我想着,能不能将一些区块链的知识点应用到对账中去,例如,使用默克尔树进行订单的对账,使用RocksDB存储订单数据比对等等。
其中使用默克尔树进行订单的对账是可行的,但是实际中,经过测试,一次HASH(O(n*x))的耗时大约是Set比对(O(n))的订单数据长度倍。不能接受,抛弃(n为订单量,x为每个订单数据字符串的长度)。
另外,使用RocksDB进行订单数据的存储和取模比对,目前已应用在二期对账系统中。 暂时对区块链的知识学习还在皮毛,继续学习中(后面会考虑将区块链学习/项目写一篇干货文章)
目前,一期系统已经稳定的跑了几个月,最近一个多月,未出现任何问题。
一期系统对账的瓶颈在Redis,以及系统如果需要改动,会非常的费劲。这是下面二期对账系统开发的原因之一。
首先,二期系统的架构设计,比一期系统肯定是要好的,将对账中很多模块给抽取了出来,方便复用以及重构。其次,使用RocksDB本地存储,不担心订单量过大,Redis内存不够,无法对账。使用多线程取模分key存储数据,在对比差异数据时分批比对,缓解了服务器内存压力。
另外,不使用对象进行读取文件订单数据,使用字符串方式进行订单数据的读取和比对。不需要再进行序列化操作,速度更快,更省内存。最后,业务方(我司的****部门)增加了一些其他的需求,原来的一期系统难以继续支持(继续支持下去的后果就是,开发艰难,后来者维护会越来越难),也是开发二期系统的原因之一。
后面会再来一篇二期系统的对账开发(会考虑附上部分架构的模板代码)
一期系统的对账开发就介绍到这里了,希望对大家有帮助,相信认真开完的人也会有一定的收货。
如果您有好的方案/建议,可以直接留言,采纳的方案/建议会落实到下一篇文章进行感谢。