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());
}
}
这将由于几个原因而失败:
ConcurrentLinkedQueue将为添加到队列中的每个元素创建一个包装节点。这将有效地使创建的对象数量增加一倍。- 对象被放在Java堆上,造成堆内存压力和垃圾收集问题。在我的机器上,这导致我的整个JVM变得没有反应,唯一的办法是用 "kill -9 "强行杀死它。
- 该队列不能从其他进程(即其他JVM)读取。
- 一旦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上,大大减少应用程序的抖动。
最后,还有一个企业版,具有跨服务器集群的队列复制功能,为实现高可用性和提高分布式架构的性能铺平了道路。企业版还包括各种其他功能,如加密、时区滚动和异步应用者。