转载

陆金所 CAT 优化实践

背景

CAT 介绍

CAT (Central Application Tracking)是一个实时监控系统,由美团点评开发并开源,定位于后端应用监控。应用集成客户端的方式上报中间件和业务数据,支持 Transaction、Event 和 Heartbeat 等数据类型 Metrics 报表,也支持调用链路 Trace,对于发现和定位应用问题有很大帮助。

CAT 服务端也可以认为是一个 Lamda 架构 的报表系统,通过汇聚客户端上报的原始消息 MessageTree,实时计算出 Transaction、Event、Problem、heartbeat 等报表,保存在内存中; 历史报表序列化后保存到本地并上传到 DB 存储,原始上报数据压缩和建立索引后上传至 hdfs。

陆金所的后端应用监控也主要基于 CAT, 是在前几年的一个版本上做二次开发,增加了新的报表。各类架构中间件大量使用 CAT 埋点,并一直在丰富各类场景,在各类问题发现和问题定位发挥了很大作用。

下图是应用的 Transaction 报表,集成了多个中间件打点:

陆金所 CAT 优化实践

下图是某一个 MessageTree,用户通过报表中 Sample 或者通过额外的 ES 索引等搜索到某个应用的 MessageTree,trace 到具体调用事件

陆金所 CAT 优化实践

遭遇性能问题

由于业务扩大,应用数量剧增,生产环境的机器数从半年前的 6000+ 增加到 10000+;另外新版本中间件增加了埋点量,随着应用升级,单个应用实例上传的 CAT 数据也在增加。

在 19 年 12 月份, 发现会出现偶尔某些 CAT 实例无响应的问题。 由于当时手上有更紧急的问题处理,这些偶尔的崩溃往往通过重启来解决。 直到 20 年 1 月份,开发同学开始抱怨 CAT 界面响应慢,“重启大法” 不再管用了,往往上个小时刚刚重启,下个小时就又挂了。

具体表现

生产上的 CAT 用的都是物理机, 配置是志强物理核心 E5 双路 CPU(CPU 8*2,超线程 32 核), 128G 内存, OS redhat 6.5。高峰时的 CAT 的 context switch 相当高,达到 120 万 / 秒, 系统负载偶尔到 20 以上,一次较大的 Full GC 往往耗时 3-5 秒; 一次长时间的 GC 就可能造成 CAT 的端口无响应,只有重启才能解决。

临时治理

  1. GC 方式从 CMS 改到了 G1,并调大了 heap 到 80G
  2. 宕机的实例总是那么几台,这是负载不均衡造成的,因此我们修改客户端上报数据的路由规则让负载更加均衡
  3. 申请紧急扩容,无奈年末硬件资源和人员都非常紧张,远水解决不了近渴

生产上的应用集群还在扩大,明年还有更多项目需要上线,硬件扩容不仅增加硬件成本,也会增加运维成本;直接 提升性能应该是最好的方案。

测试环境 CAT 也存在类似的容量问题, 我们有 3 台物理机来跑 CAT,但我们的测试环境一共有 15000 个应用实例。 之前尝试过应用开启 CAT,但导致 CAT 崩溃,当前的策略是部分环境和应用开启 CAT 打点的方式,是能提供了部分的监控能力,但也对开发测试人员造成了不小的困扰。

对 CAT 做性能优化,一方面能解决生产容量不足的问题,另一方面也能协助规划测试环境的集群容量,大幅提升开发测试效率。

优化准备

优化方向

性能优化不是没有方向的,其实我们在 2018 年就观察到 CAT 在业务高峰时刻的上下文切换特别高(>1mil/s。 在 19 年的 Qcon 会议上,携程的梁锦华介绍了他们对 CAT 的性能优化工作, 服务端的优化集中在改进线程模型来降低上下文切换,改进内存模型来降低 GC。所以我们的优化也要覆盖这两个方向,另外,我们也要看下在 JVM、OS 配置层面能做哪些改进。

  • 线程模型优化:目的是降低上下文切换带来的开销;

    我们来看下什么是上下文切换,我们都知道现代 OS 基本是多任务的,CPU 资源在 OS 在不同的任务(线程)需求之间切换分配。为了确保正确性,每一次切换 OS 都需要保存上一次线程的运行状态,并加载下一个线程的状态,这些状态往往涉及 CPU 上的多种寄存器;另外,在切换到下一个的线程之后,还会造成内存访问效率的损失,这主要是不同线程运行时需要访问的数据不同,由此带来的多级缓存命中率下降而降低运行效率。

    一次上下文切换的直接开销在 1-5ns 级别,而带来的间接开销则可能到 1us 到数个 ms 之间,有兴趣的同学可以参考这两篇文章: Quantifying The Cost of Context Switch 、 Measuring context switching and memory overheads for Linux threads

  • 内存优化

    CAT 作为 APM 应用,每秒摄入的数据在几十到一百多 MB 级别,数据经过反序列化之后,还需要对内存报表做大量的更新操作,这个过程会创建特别多的临时对象,会造成频繁的 Young GC。CAT 内存中维护了当前小时的报表,每一个小时中中,常驻内存随着时间推移逐渐增大,造成可用内存减少,频繁触发 Full GC。

  • JVM/OS/ 网络设置优化

    JVM 已经发布到版本 11,新版本带来了一部分免费的性能提升, 另外 GC 的方式和参数也可以调整。 开启 OS 内存大页和调整网络参数等在理论上也能带来性能提升。

核心指标

作为一个实时数据摄入的报表系统,我们很快就确定了几个核心的性能指标:

  1. 服务端稳定性: 功能核心功能正常工作,服务端是否有 OOM、无响应甚至进程崩溃现象
  2. 服务端负载: 操作系统系统负载
  3. 摄入数据速率: 主要考察单位时间(1 小时)消费消息数量和数据大小
  4. 服务端消息丢失量:因为来不及处理而丢弃的消息数量
  5. 客户端失败消息数量:客户端由于发送速率低于生产速率造成的消息丢弃量

第一轮优化

下图描述了 CAT 的消息处理和线程模型,Netty Worker 线程生成 MessageTree 后,offer 到每个 Analyzer 专有队列(Blocking Queue)中,由 Analyzer 线程从队列中拉去后处理并生成对应的内存报表。

不难理解这里的设计初衷是让每个 Analyzer 独立使用其队列,实现了 Analyzer 处理的隔离,慢的 Analyer 不会影响那些快的 Analzyer。

陆金所 CAT 优化实践

对于某些重要且计算量较大的 Analyzer(例如图中 Transaction Analyzer),使用了多个队列,并根据客户端应用名的 hash 来均衡多个队列任务;CAT 内部自建报表合并机制来合并多份报表。

如果某一个 Analyzer 的队列满了导致无法推送,Netty 线程则会直接丢弃该消息,并统计丢弃次数。

下面的代码描述了插入消息队列的过程:

复制代码

publicvoiddistribute(MessageTree tree){
String domain = tree.getDomain();// domain 就是上传消息的应用名
for(Entry<Strig, List<PeriodTask>> entry: m_tasks.entrySet())
{
List<PeriodTask> tasks = entry.getValue();// PeriodTask 封装了消息队列
intindex =0;
intlength = tasks.size();// 多个 Analyzer 队列
if(length >1) {
index = Math.abs(domain.hashCode()) % length;
}
PeriodTask task = tasks.get(index);
if(!task.enqueue(tree)) {
// 记录的消息丢失
}
}
}

Analyzer 拉取并消费消息的代码如下:

复制代码

while(true)
// 无限循环拉取数据,最大 5ms 超时
MessageTree message = m_queue.poll(5, TimeUnit.MILLISECONDS);
if(message !=null) {
process(message)
}
}

现在一共有 22 个 Analyzer,略微有点多,我们也不能删除现有的 Analyzer,因为不少系统已经依赖 CAT 的各类报表来协助监控。

通过线下 profiling 并结合研究代码,我们发现:

  1. 队列的 offer 和 poll 占用了超过 7% 的 CPU 处理时间
  2. 从线程 dump 来看,Analyzer 线程经常处于 LockSupport.parkNanos 调用上
  3. 由于部分 Analyzer 有多个线程,Analyzer 线程总数量约 30 个,其线程 CPU 占用又不太高 (<30%)
  4. 不同类型的 Analyzer 只会处理满足特定条件的 MessageTree,但是 Netty Worker 线程在做 queue.offer 动作时没有判断 MessageTree 能否被该 Analyzer 处理,Analyzer 获取到部分 MessageTree 之后又丢弃

回到系统设计模式上来, 一组线程生成 MessageTree,并采用 BlockingQueue 发送到另一组线程来处理,这是典型的消息传递场景。 提到跨线程的消息传递,我们不能不提到大名鼎鼎的 Disruptor 的 RingBuffer 模型。

Disruptor 框架是 LMAX Exchange 开发的高性能队列模型,该框架充分利用了 Java 语言中的 volatile 语义,创新性地使用了 RingBuffer 数据结构,实现了在线程之间快速消息传递,支持批量消费。 吞吐量和延时性能都高于 Java 标准库中的 BlockingQueue,其性能关系是:

Disruptor > ArrayBlockingQueue > LinkedBlockingQueue

由于篇幅关系,我们就不在这里详细介绍 Disruptor 内部原理了,有兴趣的小伙伴请参考 Disruptor 介绍 。

线程模型尝试和调整

MessageTree 做预过滤是必须要做的,这部分很快做完了,但在线程模型的改动上我们经过了几次尝试:

尝试一

考虑到 Disruptor 做线程间的消息传递效率,我们将 BlockingQueue 简单替换成了 Disruptor 实现。 效果不是很明显,总体的 CPU 使用并没有下降多少。

由于 Disruptor 需要 Event 对象放入 RingBuffer,封装 MessageTree 的类定义如下:

复制代码

classMessageTreeEvent{
MessageTree message;
}

尝试二

为降低 Analyzer 线程数, 我们想到将多个 Analyzer 线程合并,在 Disruptor 框架下需使用同一个 RingBuffer。于是我们将一个 MessageTree 映射到多个 MessageTreeEvent,并通过1个全局的的 RingBuffer,分发给一个线程池来处理。考虑到 Ringbuffer 中 MessageTreeEvent 数量增加,我们将 RingBuffer 大小调整到 262144 (1<<18)

新的 MessageTreeEvent 定位如下:

复制代码

classMessageTreeEvent{
MessageTree message;
String analyzerId;
}

如果 22 个 Analyzer 都采用这个方法,并假设 MessageTree 速率为每秒 5 万,那么最大就有 22 * 5w/s = 110w/s 速率的消息需要通过 Ringbuffer。这个数字乍一看非常大,但如果对照性能 Disruptor 测试结果 , 这个速率对于 Disruptor 框架来说压力不大。

陆金所 CAT 优化实践

我们挑选了大概 10 个 Analyzer 加入这个大的 RingBuffer 来处理,但无论如何如何增大 buffer 消息丢弃情况还是有点多,特别是较为重要的 Transaction/Problem 等 Analyzer 的消息。

尝试三

考虑到不同的 Analyzer 重要程度不同,我们的尽量保证核心 Analyzer 能正常工作,那些不太重要的 Analyzer 丢一点消息是可以接受的。 于是我们给 Analyzer 引入了优先级概念,

复制代码

enumAnalyzerLevel {
HIGH(1),
MID(GLOBAL_REPORT_QUEUE_SIZE/16),
LOW(GLOBAL_REPORT_QUEUE_SIZE/4);

publicfinalintrequiredCapacity;
AnalyzerLevel(intrequiredCapacity) {
this.requiredCapacity=requirecapacity;
}
}

下面是往 RingBuffer 插入数据的代码, 也体现了 disruptor 的优点,hasAvailableCapacity 这个方法与 BlockingQueue 的 size 相比,其内部实现是无锁的。

复制代码

RingBuffer<MessageTreeEvent> ringBuffer = disruptor.getRingBuffer();
if(ringBuffer.hasAvailableCapacity(m_analyzer.getLevel().requiredCapacity)) {
longseq=ringBuffer.next();
try{
// 准备 MessageTreeEvent 对象
MessageTreeEvent event = ringBuffer.get(sequence);
event.message = messageTree;
event.analyzerName = m_analyzerName;
}finally{
ringBuffer.publish(seq) ;
}
}else{
// 丢弃并记录
}

我们又引入了分组的概念,将 Analyzer 分为 2 组,每一组使用一个 RingBuffer,每一个 RingBuffer 使用 2 个线程来消费。CAT 一共 22 个 Analyzer,我们将 15 个 Analyzer 改造到了新的线程模型 。

Disruptor 消费和启动代码如下:

复制代码

// int threadsPerRingBuffer = 2
WorkHandler<MessageTreeEvent> [] handlers =newWorkHanlder[threadsPerRingBuffer];
for(intindex =0; index < threadsPerRingBuffer; index ++) {
handlers[index] = createHanlder(index);// 创建多个消费线程对等
}
disruptor.handleEventWithWokerPool(handlers);// 设置 disruptor 的消费者
disruptor.start();// 启动

privateWorkHanlder<MessageTreeEvent>createHandler(intthreadIdx){
returnWorkerHanlder<MessageTreeEvent> () {
publicvoidonEvent(MessageTreeEvent event){
String analyzerName = event.analyzerName;
getAnalyzer(threadIdx).process(event.message);
}
};
}

另外, 有了之前合并线程成功的经验, 在仔细检查代码时和检查线程栈时,发现 Netty 的 worker 线程数为 24, 确实有点多。我们逐步降低,测试表明 Netty work 线程数为 2 时仍然一切正常,从 top -H 的输出来看,在 100MB/ 秒的网络摄入流量下,Netty Worker 线程的 CPU 也就在 70% 左右,未见客户端发送失败的情况。

最终的线程模型如下:

陆金所 CAT 优化实践

JVM 设置改动

在 JVM 和 GC 方式的选择上,我们选用了 open Jdk 11 和 G1 的方式,在测试环境,这个组合的运行稳定,GC 的延时较低, CAT 的页面响应也比较快。

优化工作做了 2 周, 快到了过年的时间,我们先找了 2 台机器验证,验证通过后更新到了所有实例。

改造效果

我们将测试环境 4500 台机器左右的流量导入到一台机器, 在修改前,这台 CAT 机器刚起来 1 分钟后就会陷入无响应状态。

改造后测试环境的这台服务器顺利跑了起来, 在小时消息量 0.94 亿,消息大小 210G 情况下 “top -H” 输出如下, 可以看到 Netty work 线程 (图中 epollEventLoopG)的占用不高,4 个全局的 Analyzer 线程 (图中 Cat-Global 开头线程) 的占用也不太高,无消息丢失。

陆金所 CAT 优化实践

陆金所 CAT 优化实践

在生产环境中也找出一台机器,通过配置路由规则,让其承载较大流量,这台机器在不同负载情况下表现如下:

消息量 消息大小 应用数 CPU(平均 / 范围) 上下文切换 核心消息丢失 非核心消息丢失
1.22 亿 320GB 1015 5.0 (1.8-8.9) 35 万 0 0
0.99 亿 324GB 1031 6.9 (1.4-14.9) 38 万 0 0
1.43 亿 312GB 1049 7.4 (2.3-16.9) 47 万 7330 76680

注: 我们区分了核心消息(优先级为 High 的 Analyzer)与非核心消息丢失。

G1GC 在生产环境表现稳定,一次 young G1GC 平均耗时约 200ms,未见 Old GC。

上下文切换下降了一半以上,CPU 负载也下来了很多 ,没有出现超过负载 20+ 的情况,应该可以安稳过年了!

未解决的问题

春节前的一轮优化主要覆盖线程模型优化与 JVM 设定, 内存优化还没做。

生产环境中 CAT 在日常的高峰流量中 CPU 负载依然超过 10,并随着小时报表在内存中积累,10 分钟后的 CPU 负载明显攀升 (如下图)

陆金所 CAT 优化实践

结合测试环境中 CAT 进程的堆 dump,"jmap -histo $pid" 的输出的分析中,我们发现还存在如下几个问题:

  1. CPU 使用率还是有点高,承载较大流量是出现核心消息丢失
  2. 上下文切换较高,平时负载在 40 万 / 秒, 高峰时间到 50 多万 / 秒
  3. 临时对象较多,例如 SimpleDateFormat/DecimalFormat 等对象
  4. LinkedHashMap 中的内存使用效率较低
  5. 驻留内存中简单对象数量太多

详细优化过程先从内存优化部分说起

内存优化

有效内存使用率概念

关于内存使用效率,和大家分享下 Java 中对象的大小概念

  • Shallow Size: 包含当前对象 Header 和对象直接拥有的内部数据,以下面的对象 s 为例,除了对象 Header 之外,包含 1 个数组引用、1 个 Map 引用、1 个 double 和 1 个 int, 其内部数据大小是 8*3+4 = 28 byte

    在 64 位 JVM 未开启指针压缩情况下加上对象 Header 16 byte 并保持 8 byte 的对齐,最终 Shallow Size 大小 28 + 16 + 4 = 48 byte

复制代码

classSample{
int[] intArray;// reference size 8
Map<String,String> map;// reference size 8
doubledoubleValue;// double size 8
intintValue;// int size 4
}
Sample s =newSample();

希望了解更多 java 对象内存布局的朋友可以使用 open jdk jol 工具 ,下面是利用 jol 打印上述对象 layout 的代码

复制代码

importorg.openjdk.jol.info.ClassLayout;
importorg.openjdk.jol.vm.VM;

publicclassObjectLayoutMain{
publicstaticvoidmain(String[] args)throwsException{
System.out.println(VM.current().details());
System.out.println(ClassLayout.parseClass(Sample.class).toPrintable());
}
}

以下是使用 “-Xms40g -Xmx40g” 的 vm 参数在 64 位 jvm11 下的输出

复制代码

# Running 64-bit HotSpot VM.
# Objects are 8 bytes aligned.
# Field sizes by type: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

org.jacky.playground.jol.Sample object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 16 (object header) N/A
16 8 double Sample.doubleValue N/A
24 4 int Sample.intValue N/A
28 4 (alignment/padding gap)
32 8 int[] Sample.intArray N/A
40 8 java.util.Map Sample.map N/A
Instance size: 48 bytes

注: 设置堆内存大于 32G 会关闭引用压缩 ,兴趣的同学可以自己跑一下看应用压缩或者是 32 位 JVM 下的输出。

  • Retain Size: 内存中的对象存在引用关系,消除循环后可以认为是一个个对象树。对象的 Retain Size 是该对象对应的对象树的大小。与对象的 Shallow Size 相比,Retain Size 是一个相对动态的值,随着其下层对象具体值变化而变化。

内存有效使用率定义如下:

内存使用率 = 实际数据占用大小 / Retain Size

更多内存效率理论请参考: Building Memory-efficient Java Applications: Practices and Challenges

优化实践

现状

从 CAT 的 heap dump 中我们看到最大的对象主要是当前小时的各种 Report 对象, 这些 Report 大量使用了多层级的 Map 结构,如下图 (图中的数字是经验估计数量)。 可以看到 Map 对象非常多, 特别是层次往下的那些对象。

陆金所 CAT 优化实践

现有代码采用 java 标准库中的 LinkedHashMap 来表示这些层次结构,这也就产生了大量 LinkedHashMap 以及子对象 LinkedhashMap$Entry,从下面堆 dump 的内存文件分析看到这几个 package 的对象在内存占用按照类型排行上非常靠前:

  • heartbeat.model.event.*
  • transaction.model.event.*
  • event.model.entity.*

陆金所 CAT 优化实践

内存使用概览

开放地址 HashMap 实现

我们发现对于那些靠近叶子节点的报表对象,采用 LinkedHashMap 在大多数时候有点多余,因为不需要记录插入顺序,可以简化成 HashMap,下面是这两者 Entry/Node 节点类的定义比较:

复制代码

//java.util.Hash
staticclassNode<K,V>implementsMap.Entry<K,V>{
finalinthash;
finalK key;
V value;
Node<K,V> next;
}
//java.util.LinkedHashMap
staticclassEntry<K,V>extendsHashMap.Node<K,V>{
Entry<K,V> before, after;// 额外的 before & after 引用
Entry(inthash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}

是不是还能进一步优化呢? 答案是可以,而且改动很小

很多 Java 技术栈的同学对标准库中利用链表法实现的 HashMap 比较熟悉, 但大学学过《数据结构》课程的同学可能还记得另一种 Hash 的实现 开放地址 Hash 。与链表法开一个链表来解决冲突的方式不同, 开放地址 Map 通过在线性表中重新计算一个新位置来解决。

实测大小对比:

下述测试代码生成一个包含大小为 size 的数组,其保存大小从 0 到 size-1 的 HashMap

复制代码

importorg.agrona.collections.Int2ObjectHashMap;
importjava.util.*

privatestaticMap[] populate(intsize,booleanuseOpenMap) {
List<Map<Integer, Range>> result =newArrayList<>();
for(inti =1; i <= size; i++) {
Map<Integer, Range> m = useOpenMap ?newInt2ObjectHashMap<>() :newLinkedHashMap<>();
for(intj =0; j < i; j++) {
m.put(j,newRange());
}
result.add(m);
}
returnresult.toArray(newMap[]{});
}
publicstaticclassRange{// retain size=56
intid =0;
intcount =0;
intfails =1;
doublesum =1.0;
doubleavg =1.0;
doublemax =1;
}

运行结果整理如下, 可以看到切换到 Int2ObjectHashMap 的实现就能轻松节省 30% 以上内存,如果值类型的 shallow 更小,节省还会更多。

size 设置 数组大小 (LinkedhashMap) 数组大小 (开放地址 Map) 节省比例
10 9104 byte 6304 byte 30.7%
100 692KB 459KB 33.7%
1000 77,302KB 43,506KB 43.7%

值得说明的是:

  1. 在 CPU 性能测试中,开放地址的 Map 的 get/put 都比 HashMap 性能略差,但绝对值差距很小
  2. 开放地址的 Map 在删除时需要在将这个位置 mark 成已删除,会造成空间浪费,但在 CAT 计算中无删除操作

对象消除

在上一个图中,可以注意到 heartbeat.model.event.Detail 对象数量非常之多,其占用内存就超过 1G!

对应到业务逻辑,每一个 Detail 都是描述应用 heartbeat 的某一个属性,例如 "SystenLoad"、“PhysicalFreeMemory”、"GC Count" 等,这些 Details 存在如下几个特点:

  1. Key 大量重复, Key 去重后数量很少,同一个应用的 CAT 客户端在不同时间、不同实例的 heartbeat 中的 Key 都一样; 还有部分 Key 是 CAT 客户端自带的,这部分 Key 对所有应用都一样
  2. Detail 的定义非常简单,m_label 总是为 null,可以直接去掉

复制代码

classDetail{
String m_id;
doublem_value;
String m_label;// 总为 null, 可以消除
}
  1. Detail 对象保存在 Extension 对象中,其中的 key 与 value 中 id 值相同

复制代码

classExtension{
Map<String, Detail> m_detais =newLinkedHashMap<>();
}

从上面的几个特点,我们可以将这里的 key 对象映射成 int,一个 detail 对象的有效数据就是一个 int 和一个 double 对象,总的有效大小为 12byte。

我们来找两个例子来计算下内存使用效率:

这是一个非典型场景, m_details 中 key 数量为 122,比较多

陆金所 CAT 优化实践

我们来计算上面 m_details hashmap 的内存使用效率:

  1. Retained size 17632
  2. 有效大小 122 * 12
  3. 内存有效率 122 * 12 / 17632 = 8.3%

下面这个 m_details,key 数量较小,内存使用效率: (12 *2)/472=5.1%

陆金所 CAT 优化实践

在使用 eclipse collection 的 LongDoubleMap 替代后,上述两例的使用效率分别提高到 46.1 和 15.8%。

其他内存优化

考虑到线程安全问题,SimpleDateFormat 和 DecimalFormat 等对象在使用时创建新实例,使用线程安全的实现来代替即可。

继续线程优化

为了可以更方便地调整全局线程 /ringBuffer,并始终保持不同线程之间负载和优先级的均衡,我们引入了 Analyzer 动态分组。

Analyzer 动态分组

我们对大约 20 个 Analyzer 按照重要正度和计算复杂度综合考虑排序,用于 Analyzer 分组。

Analyzer index 优先级
HeartbeatAnalyzer 0 High
DumpAnalyzer 1 High
TransactionAnalyzer 2 High
EventAnalyzer 3 High
DependencyAnalyzer 12 Low
MqAnalyzer 15 Low

动态分组保证那些计算量大且优先级又高的 Analyzer 不集中竞争计算资源, 实现规则如下

复制代码

effectiveRingBufferIndex = analyzer.getGlobalIndex() % ringbufferCount

我们将剩下的几个 Analyzer 合并到了全局线程组,对 Netty Worker 数、全局线程数和每个 RingBuffer 的消费线程数做了配置化。 默认开启 2 个 Netty Worker 线程,3 个全局线程 /ringBuffer,考虑到维护多份报表的内存开销较大,每个 RingBuffer 的消费线程数默认设置为 1。优化后的典型的线程配置如下:

陆金所 CAT 优化实践

另外继续增加了 Ringbuffer 大小到 524288 (2^19) ,当然我们也清楚增加缓存大小有两个坏处:

  1. 最大处理延时增加,考虑到 CAT 的处理能力,这个影响最大不超过 5 秒,业务上可以接受
  2. buffer 增大导致内存使用增加,由于 CAT 进程都是动辄几十 G 的堆,额外的百万个 buffer 对象带来的影响微乎其微

其他优化与尝试

  1. 对 ConcurrentHashMap 做 null 检查后使用 synchronize 改到使用 ConcurrentMap.computeIfAbsent

    CAT 启动或者跨小时的时候会集中创建 bucket,采用 null 检查 + synchronize 的方法会造成集中的线程堵塞

复制代码

ConcurrentMap m_buckets =newConcurrentHashMap<String, Bucket>();
// 改造前
bucket=m_buckets.get(path);
if(bucket ==null) {
synchronize(m_buckets) {
bucket= createBucker();// 慢操作
m_buckets.put(path, bucket);
}
}

复制代码

// 改造后
bucket=m_buckets.computeIfAbsent(path,path -> createBucket());
  1. 缩减 CAT 集群内部请求的线程数量,增加其 buffer 大小,并使用连接池来管理连接
  2. 增加磁盘写入线程数量和 buffer 来缓解测试环境磁盘写入较慢的问题
  3. 测试环境 OS 的电源管理从 on-demand 改成 performance 模式,与生产对齐
  4. 测试环境尝试开启内存大页,效果不太明显,生产环境也需要运维协助配置,暂放弃

效果

单机性能

为了验证优化效果,我们对某一台机器又加大了流量,比较了不同负载的表现

消息量 消息大小 CPU(平均 / 范围) 上下文切换 核心消息丢失 非核心消息丢失
1.01 亿 180GB 2.1(0.9-5) 19.2 万 0 0
1.27 亿 283.9GB 4.11.9-7.6) 20.2 万 0 1,394,670
1.45 亿 296.8GB 4.6(2.9-9.7) 21.2 万 0 552,854
1.74 亿 (*) 402GB(*) 8.1(2.2-13.8) 25 万 0 53,085,742

注:1.74 亿消息量是人为加大负载,每秒网络流量 114MB(402GB/3600) ,已打满千兆线路。

下图为小时消息量 1.45 亿下的系统表现:

陆金所 CAT 优化实践

可以看到上下文切换、CPU 的使用率和 GC 都非常平稳,核心消息丢失为 0;非核心消息丢失略高。可考虑增加全局处理线程数到4甚至5来缓解极端负载下的非核心消息丢失。

容量评估

基于最新的单机性能和总的生产数据量,现有生产环境集群还有约 50% 的冗余容量,未来 2 年都无需扩容。

测试环境的 CAT 容量也评估了出来,现有 3 台 CAT 支撑 15000 个测试应用实例有点勉强,正在申请额外 3 台服务器,这样就能支持所有的测试集群,并留有部分冗余。

思考

超线程

超线程(Hyper Thread,HT)给 OS 提供了更多的可用核心,但这些核心是毕竟是硬件虚拟出来的,目的是更好地使用 CPU 多余的计算和缓存资源,提供更高的吞吐量。

简单地认为开启 HT 可以免费获得一倍的可用线程并计算能力能翻倍是不可取的,物理核心和虚拟核心会竞争使用计算和缓存资源,在某些情况下甚至会降低吞吐量。

在计算密集的场景下,HT 的虚拟核心是不能计算在可用核心里面的,因为虚拟 CPU 的计算能力有限。这可能也是我们生产环境 CPU 飙到 20 左右就会出现计算能力严重不足,带来端口无响应等问题。

Java 内存使用效率

Java 有很好的面向对象的特性,在书写程序时带来了很多便利,但也带来了运行时刻的内存负担,每个对象都有个很大的 Header,有时 Header 甚至超过了本身数据的大小。

这有两个比较好的解决方案值得期待:

  1. Java 语言支持 struct 类型

    Java 语言 struct 类型需求很早就被提了出来,struct 类型和原生类型一样,不属于对象范畴,没有对象 Header 的内存成本。近年放在 valhalla 项目中, 19 年 5 月份发布了原型版,有兴趣的同学可以看下。

  2. java 与原生语言混合编程

    Oracle 的 graalvm 项目,支持 Java 语言与其他原生语言混合编程,在 Java 应用的性能瓶颈的部分采用 C 或者 Rust 语言来实现。 该项目已经开源,已经取得了一定的进展,可在官网下载社区版的 graalvm 的 JDK。

总结

两轮性能优化各耗时 2 周,回顾整个优化过程,我们制定了大体的方向,找到核心的性能指标,大量查找资料,从原理验证做起,并结合线下环境的逐步验证,直到目标达成为止。

在优化过程中,我们也学习了 CAT 本身设计巧妙的地方,例如异步化的实时数据处理、支持水平扩容、高效的序列化 / 反序列化和集群数据路由等。在此感谢美团点评的朋友把这个项目开源出来,让大量的开发者收益。

性能优化是一个综合的话题,并没有什么圣杯,只需在工作中勤摸索、常思考、积极与他人交流并敢于尝试总能有收获。我们把这次优化经历写出来,希望能抛砖引玉,也欢迎各位同行指正。

作者介绍

蔡健,陆金所应用架构师。2008 年复旦大学硕士毕业后加入大摩, 2016 年加入陆金所,负责 Java 架构中间件和应用监控;职业理念是专注,并对新技术时刻充满热情。

方超,陆金所应用架构师。十年工作经验。热爱生活和技术。

原文  https://www.infoq.cn/article/XvGZcW312MdatCKFMR8b
正文到此结束
Loading...