亿级日志队列回放性能测试初探

队列通常是软件设计模式中的基本组件。但是如果每秒接收到数百万条消息,改如何处理?如果多个消费者都需要能够读取所有消息,又改如何处理?难道需要把所有消息的数据都放在内存中吗?这样 JVM GC 又表现如何?

之前我写过几个流量回放模型:

虽然方案 2 已经被更优秀的方案替代,但是思路相同,均是把日志进行格式转换之后存放(这一点跟 goreplay 略有相似),在千万日志级别,我是直接放在内存中。大约 1 千万日志的大小约为 1G,这样来说对 JVM 内存压力并不高,对于 GC 的影响也可以接受,目前的测试结果是 YoungGC 1次/3s,全程无 FullGC。

但是如果想要更近一步,实现更大规模的日志回放,就不能采取这种方式,需要把日志存在磁盘中,用的时候顺序读取,这个速度大概 80 万/s。也算是满足需求了。但是其中需要使用java.lang.String#split(java.lang.String, int),又比较消耗性能。

这个时候接触了Chronicle Queue,看了简介,简直爆炸,而且 API 简单好用,性能又高。特别是支持 TB 级别文件高性能、低延迟的读写。太符合我的需求了。后续我再根据实际情况进行实践、测试、分享。

本文介绍如何使用 Chronicle Queue 创建巨大的持久队列,同时保持可预测和一致的低延迟。

演示

在本文中,我维护一个保留日志回放的日志队列,首先是一个日志类,对原来的文章进行了一些Chronicle Queue化改造,保留了日志时间戳、host等信息。

    private static class FunLog extends SelfDescribingMarshallable {

        String url

        String host

        int time

        FunLog() {
        }

        FunLog(String url, String host, int time) {
            this.url = url
            this.host = host
            this.time = time
        }
    }

官方提醒:字段值为浮点类型时,切记注意有效位数长度问题。有兴趣的可以看一看Java 序列化10倍性能优化对比测试关于Chronicle Queue序列化相关方案。

最初的方案

首先想到了探索使用 ConcurrentLinkedQueue 的方法:

public static void main(String[] args) {
 
    final Queue<MarketData> queue = new ConcurrentLinkedQueue<>();
 
    for (long i = 0; i < 1e9; i++) {
 
        queue.add(new FunLog(Time.getDate(), index.getAndIncrement() + EMPTY, getMark()));
 
    }
 
}

但是最终将会崩溃,有几个原因:

  • ConcurrentLinkedQueue 将为添加到队列中的每个元素创建一个包装节点。这将使创建的对象数量增加一倍。
  • 对象放置在 Java 堆上,导致堆内存压力和垃圾收集问题,很可能导致卡死,只能强制结束进程。
  • 无法从其他进程(即其他 JVM)读取队列。
  • 一旦 JVM 终止,队列的内容就会丢失,队列不是持久化的。

其他各种标准 Java 类,均是不支持大型持久队列。

Chronicle Queue

Chronicle Queue 是一个开源库,旨在满足上述要求。这是设置和使用它的一种方法:

    static void main(String[] args) {
        String basePath = getLongFile("chronicle")
        ChronicleQueue queue = ChronicleQueue.singleBuilder(basePath).build()
        def appender = queue.acquireAppender()
        int total = 1_0000_0000
        def start = Time.getTimeStamp()
        total.times {
            def log = new FunLog(Time.getDate(), index.getAndIncrement() + EMPTY, getMark())
            appender.writeDocument(log)
        }
        def end = Time.getTimeStamp()
        output(total / (end - start) * 1000)
        output(queue.lastIndex() - queue.firstIndex())
    }

由于不可描述的原因,我本机的 IO 性能被降低了很多,但是在使用以上用例创建一个长度 1 亿的队列时,Chronicle Queue还是表现了非常好的性能,平均的 QPS 为 170 万,占用磁盘空间 4.5G,而且读取速度也保持在 160 万 QPS 量级。

读取用例如下:

    static void main(String[] args) {
        String basePath = getLongFile("chronicle")
        ChronicleQueue queue = ChronicleQueue.singleBuilder(basePath).build()
        def tailer = queue.createTailer()
        def log = new FunLog()
        int total = 1_0000_0000
        def start = Time.getTimeStamp()
        total.times {
            tailer.readDocument(log)
        }
        def end = Time.getTimeStamp()
        output(total / (end - start) * 1000)
        output(queue.lastIndex() - queue.firstIndex())
    }

可以看出,我只用了一个com.funtest.queue.Qt.FunLog对象,这样就进一步降低了 JVM 内存和 GC 的压力。当然我们写入队列时,也可以使用这样的方式,不过在我的设计中,直接读取日志文件进行格式转换,可以直接使用通用池化框架GenericObjectPool性能测试通用池化框架GenericKeyedObjectPool性能测试,后面有时间再来分享。

下面是我两次测试的 JVM 监控截图,可见Chronicle Queue的强大: