写点什么

如何完成日千万级别以上的订单对账(一)

用户头像
谙忆
关注
发布于: 2021 年 04 月 13 日

概述

前些日子发表了一篇对账的预热,现在来一篇干货。文章精而不在多,多了也浪费大家时间。所以,这也是我放弃原来的公众号注册现在这个服务号来进行分享经验的原因之一。每月几篇分享,足以。


平时很少登录公众号后台,如果有需要联系的,可以通过我的博客发送邮件给我。


本系列分为两篇。本篇主要讲解针对千万级别订单对账系统的分析以及架构,以及实际项目中遇到的坑,和解决方案。


本系列中不介绍讲解中涉及到的中间件,数据库,框架,插件等基础知识,若学习基础知识或者项目搭建的,可以关注我的GitHub/博客(https://github.com/chenhaoxiang),不定期 push 项目代码/文章。本文中不会采用专业对账中的专有名词,尽管放心阅读。


前期该系统是作为一个备用系统开发的,也就没有那么多讲究,重构了两次,现在支持对账数据量多少的瓶颈完全在 Redis 了,目前将近千万级别订单量的对账,使用服务器内存高峰在 2G 左右。


现在二期对账系统的开发(一期对账系统和二期对账系统是分开的,不是重构)也在进行中了(针对亿级别订单量的对账),在后面会出如何完成日千万级别以上的订单对账(二)。

订单对账

对账的基本 5 大步骤,按照正常的对账来走,基本离不开下面这 5 个步骤


  • 1.数据加载(数据的缓存是不可少的,需要重新对账的情况很常见)

  • 2.数据对比(分批比对很重要,服务器内存从此与订单量无关)

  • 3.差异入库(注意校验差异数据量,否则百万,千万差异一次入库,你可以想象)

  • 4.差异处理(不建议自动处理,但是可以设置为一键处理,但是一定要人员点确认)

  • 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<Object>,导致堆外内存溢出过一次(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 传输数据超时等等


  • i.关于平台方数据错误,以及渠道方数据不完整,这个完全是无法控制的,主动权在别人那里。可以做到的就是,快速定位问题所在。在前期,第一次遇到通道方数据出问题的时候,整个定位耗费了整整一个多小时,导致商户资金到账延迟了几个小时,公司资损金额巨大。这是完全无法接受的,也是当初我考虑不到位的后果。后来针对平台方问题以及通道方数据问题,进行了 MD5 校验、文件大小校验以及订单量校验,任何一项都即时通过机器人发送到钉钉群里,后面也出过几次银联数据和通道方订单数据的问题,都是在分钟内通过电话联系对应公司的开发人员,校验/重新生成/确认新的方案。确保问题即时被解决。

  • ii.特殊符号的情况比较少,需要处理的符号可以确认的就是空格和制表符一定要进行处理

  • iii.redis 传输数据时间过长,也会造成连接被关闭,记得将超时时间设置长一点

JVM 的优化

在一期系统运行前期,OOM 的事件也发生过几次,在这里,也介绍一下如何进行 JVM 的优化,防止 OOM


Java 堆,可以简单的分为新生代和老生代。


新生代:存放生命周期比较短的对象。


老年代:存放生命周期比较长的对象(简单的说就是几次 GC 后没有回收的对象)。


在对账系统中,每天的运行时间只有那么几十分钟。


而这几十分钟的时间内,在 for 循环中,会产生千万级别的对象,例如订单号这种无法重复使用的字符串等等。


所以,针对新生代的内存分配,一定要等于/高于老生代的内存的

关于年轻代和年老代的选择

  • 年轻代大小选择:响应时间优先的应用,尽可能设大,直到接近系统的最低响应时间限制(根据实际情况选择)。在此种情况下,年轻代收集发生 gc 的频率是最小的。同时,也能够减少到达年老代的对象。吞吐量优先的应用,也尽可能的设置大,因为对响应时间没有要求,垃圾收集可以并行进行。

  • 年老代大小选择:响应时间优先的应用,年老代一般都是使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数。如果堆设置小了,会造成内存碎片、高回收频率以及应用暂停而使用传统的标记清除方式;如果堆大了,则需要较长的收集时间。


我选择将新生代:老生代的比例调整为 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 数据拼接的情况下


  • +号效率为 format 的 30 倍左右

  • +号效率为 buulder 的 2 倍左右

  • 有数据为证:


测试代码如下:


/** * 动态拼接字符串测试 * 动态拼接字符串指的是仅在运行时才知道最终字符串的子字符串 * @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 存储数据,在对比差异数据时分批比对,缓解了服务器内存压力。


另外,不使用对象进行读取文件订单数据,使用字符串方式进行订单数据的读取和比对。不需要再进行序列化操作,速度更快,更省内存。最后,业务方(我司的****部门)增加了一些其他的需求,原来的一期系统难以继续支持(继续支持下去的后果就是,开发艰难,后来者维护会越来越难),也是开发二期系统的原因之一。


后面会再来一篇二期系统的对账开发(会考虑附上部分架构的模板代码)


一期系统的对账开发就介绍到这里了,希望对大家有帮助,相信认真开完的人也会有一定的收货。


如果您有好的方案/建议,可以直接留言,采纳的方案/建议会落实到下一篇文章进行感谢。


用户头像

谙忆

关注

CSDN博客专家 2020.02.07 加入

公众号:程序编程之旅。曾经写过C、C++,使用过Cocos2dx开发过游戏、安卓端、IOS端、PC端页面均开发过。目前专注Java开发,SaaS内核、元数据的研究。偶尔玩玩爬虫。 https://chenhx.blog.csdn.net/

评论

发布
暂无评论
如何完成日千万级别以上的订单对账(一)