大数据优化技巧

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

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

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

  • \1. 数据加载(数据的缓存是不可少的,需要重新对账的情况很常见)
  • \2. 数据对比(分批比对很重要,服务器内存从此与订单量无关)
  • \3. 差异入库(注意校验差异数据量,否则百万,千万差异一次入库,你可以想象)
  • \4. 差异处理(不建议自动处理,但是可以设置为一键处理,但是一定要人员点确认)
  • \5. 清理缓存(内存中的缓存一定要清除)

画个图大概就是下面这样的: [外链图片转存失败, 源站可能有防盗链机制, 建议将图片保存下来直接上传 (img-1xnbrs3h-1576118739838)(blogimg.chenhaoxiang.cn/18-10-24/89…%5D)

查询订单的时候,每日千万级别的订单数据,如果使用通常的分页查询,那么查询的速度会越来越来慢。在这里推荐根据时间优先查询出最小 id 和最大 id,然后再根据 id,分批查询订单数据。

在一期系统中,我使用了 Redis 作为订单数据缓存以及订单比对,并且通过取模,将订单分批。这样的好处就是,水平扩展非常的方便。无需担心业务的增长。 缺点就是,依赖 Redis 服务器,由于是 Redis 是单线程,即使我们增加服务器,分批处理数据传输到 Redis 进行数据比对,但是随着业务增加,对账速度也会越来越慢(可以使用 Redis 集群,以及分批传输比对解决该问题)。

注意!Redis 服务器一定要和服务器在一个内网进行数据传输! 否则,速度会让你绝望的~

该系统花费时间最多的地方是在下载文件和加载文件数据的时候。

下载就不说了,通道方提供 FTP 下载的服务器带宽就那么大。

主要是加载文件,我们是可以处理的,一期系统使用的是单线程加载,并且是加载对象,加载以及序列化需要的时间也不能忽略,在这里消耗时间比较多。将近千万的数据大约需要 10 分钟左右,这是无法接受的。

序列化强烈推荐 Protostuff(比 JSON 序列化也要快,不推荐 kryo)。不要使用 Java 原生序列化。 Protostuff 无论是从性能,还是需要内存大小来说,比 Java 原生好太多了(实际上,opencsv 加载对账数据是可以优化成不需要使用对象的,在下篇二期对账系统中会体现出来。传输到 Redis 中可以选择字符串或者使用 Protostuff 序列化成字节流进行传输)

[Java 序列化框架性能比较 (blog.csdn.net/qq_26525215…%5D(https://blog.csdn.net/qq_26525215/article/details/82943040))

系统中要应用一些设计模式,例如:对账可以使用策略抽象工厂模式,每一种对账的实现对应着一种具体的策略实现,并且尽量将系统中实现的细粒度化,方便解耦以及方便复用。

商户维度对账是为了校验商户的收入的,以及出款。所以也非常重要。前面的订单对账可以理解为是为商户对账而服务的。 基本步骤其实和订单对账类似, 对于该维度对账不做过多解释 [外链图片转存失败, 源站可能有防盗链机制, 建议将图片保存下来直接上传 (img-gmUn2kbY-1576118739839)(blogimg.chenhaoxiang.cn/18-10-24/60…%5D)

依赖 & 特点

在项目中也用到了很多的第三方工具,如下图

img

另外也用到了一些设计模式以及系统的特点

img

注意事项

\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 传输数据超时等等

  • 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 倍左右

    有数据为证:

    img

测试代码如下:

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 进行拼接字符串对账花费时间:

img

format 拼接优化为 + 号连接之后:

img

\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 存储数据,在对比差异数据时分批比对,缓解了服务器内存压力。

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

作者:谙忆1024

链接:https://juejin.cn/post/6911970334749130765

来源:掘金

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值