Java创建具有低延迟的百万亿字节大小的队列

621 阅读5分钟

Java创建具有低延迟的TB级队列

队列通常是软件设计模式中的基本组件。但是,如果每秒收到数百万条消息,而且多进程消费者需要能够读取所有消息的完整账目,那该怎么办?在堆成为限制因素之前,Java只能容纳这么多的信息,结果是高影响的垃圾收集,有可能使我们无法完成目标的SLA,甚至使JVM停止几秒钟甚至几分钟。

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

应用程序

在这篇文章中,目标是维护一个来自市场数据源的对象队列(例如,在交易所交易的证券的最新价格)。也可以选择其他业务领域,如来自物联网设备的感官输入或阅读汽车行业的碰撞记录信息。原理是一样的。

首先,一个持有市场数据的类被定义:

public class MarketData extends SelfDescribingMarshallable {
    int securityId;
    long time;
    float last;
    float high;
    float low;

    // Getters and setters not shown for brevity

}

注意:在现实世界的场景中,当使用float和double来保存货币价值时,必须非常小心,否则会导致四舍五入的问题[Bloch18, Item 60]。然而,在这篇介绍性文章中,我想让事情保持简单。

还有一个小的实用函数MarketDataUtil::create,它将在调用时创建并返回一个新的随机MarketData对象:

static MarketData create() {
    MarketData marketData = new MarketData();
    int id = ThreadLocalRandom.current().nextInt(1000);
    marketData.setSecurityId(id);
    float nextFloat = ThreadLocalRandom.current().nextFloat();
    float last = 20 + 100 * nextFloat;
    marketData.setLast(last);
    marketData.setHigh(last * 1.1f);
    marketData.setLow(last * 0.9f);
    marketData.setTime(System.currentTimeMillis());
    return marketData;
}

现在,我们的目标是创建一个持久的、并发的、低延迟的、可从多个进程访问的、可容纳数十亿对象的队列。

天生的方法

有了这些类,我们可以探索使用ConcurrentLinkedQueue的天真方法:

public static void main(String[] args) {

    final Queue<MarketData> queue = new ConcurrentLinkedQueue<>();

    for (long i = 0; i < 1e9; i++) {

        queue.add(MarketDataUtil.create());

    }

}

这将由于几个原因而失败:

  1. ConcurrentLinkedQueue将为添加到队列中的每个元素创建一个包装节点。这将有效地使创建的对象数量增加一倍。
  2. 对象被放在Java堆上,造成堆内存压力和垃圾收集问题。在我的机器上,这导致我的整个JVM变得没有反应,唯一的办法是用 "kill -9 "强行杀死它。
  3. 该队列不能从其他进程(即其他JVM)读取。
  4. 一旦JVM终止,队列的内容就会丢失。因此,队列是不持久的。

看看其他各种标准的Java类,可以得出结论,没有对大型持久化队列的支持。

使用Chronicle Queue

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

public static void main(String[] args) {

    final MarketData marketData = new MarketData();

    final ChronicleQueue q = ChronicleQueue

            .single("market-data");

    final ExcerptAppender appender = q.acquireAppender();


    for (long i = 0; i < 1e9; i++) {

        try (final DocumentContext document =

                     appender.acquireWritingDocument(false)) {

             document

                    .wire()

                    .bytes()

                    .writeObject(MarketData.class, 

                            MarketDataUtil.recycle(marketData));


        }

    }

}

使用配备2.3 GHz 8核英特尔酷睿i9的MacBook Pro 2019,只用一个线程就可以每秒插入300万条信息。该队列通过给定目录 "market-data "中的一个内存映射文件进行持久化。人们期望一个MarketData对象至少占据4(int securityId)+8(long time)+4*3(float last, high and low)=24字节。

在上面的例子中,有10亿个对象被追加,导致映射文件占用30,148,657,152个字节,这相当于每条消息占用了30个字节。在我看来,这确实是非常有效的。

可以看出,一个MarketData实例可以被反复使用,因为Chronicle Queue会把当前对象的内容平铺到内存映射文件上,允许对象重复使用。这就更能减少内存压力。这就是recycle方法的工作原理:

static MarketData recycle(MarketData marketData) {

    final int id = ThreadLocalRandom.current().nextInt(1000);

    marketData.setSecurityId(id);

    final float nextFloat = ThreadLocalRandom.current().nextFloat();

    final float last = 20 + 100 * nextFloat;


    marketData.setLast(last);

    marketData.setHigh(last * 1.1f);

    marketData.setLow(last * 0.9f);

    marketData.setTime(System.currentTimeMillis());


    return marketData;

}

从Chronicle Queue中读取

从编年史队列中读取内容是很简单的。继续上面的例子,下面显示了如何从队列中读取前两个MarketData对象:

public static void main(String[] args) {

    final ChronicleQueue q = ChronicleQueue

            .single("market-data");

    final ExcerptTailer tailer = q.createTailer();


    for (long i = 0; i < 2; i++) {

        try (final DocumentContext document =

                     tailer.readingDocument()) {

            MarketData marketData = document

                    .wire()

                    .bytes()

                    .readObject(MarketData.class);

            System.out.println(marketData);

        }

    }

}

这可能会产生以下输出:

!software.chronicle.sandbox.queuedemo.MarketData {

  securityId: 202,

  time: 1634646488837,

  last: 45.8673,

  high: 50.454,

  low: 41.2806

}


!software.chronicle.sandbox.queuedemo.MarketData {

  securityId: 117,

  time: 1634646488842,

  last: 34.7567,

  high: 38.2323,

  low: 31.281

}

有一些规定可以有效地寻求尾随者的位置,例如,到队列的末端或到某个索引。

下一步是什么?

还有许多其他的功能,不在本文的讨论范围之内。例如,队列文件可以被设置为以一定的时间间隔滚动(如每天、每小时或每分钟),有效地创造了信息的分解,以便旧的数据可以随着时间的推移被清理。还有一些规定,能够隔离CPU,并将Java线程锁定在这些隔离的CPU上,大大减少应用程序的抖动。

最后,还有一个企业版,具有跨服务器集群的队列复制功能,为实现高可用性和提高分布式架构的性能铺平了道路。企业版还包括各种其他功能,如加密、时区滚动和异步应用者。