硬件Bug追踪:从HBase性能异常到ARM CPU缓存失效

本文详细记录了HubSpot工程师如何追踪HBase数据库性能异常的完整过程,从软件排查到发现ARM架构CPU硬件Bug,涉及JVM垃圾回收机制、指令缓存失效和AWS Graviton2处理器缺陷等技术细节。

它从来不是硬件Bug,直到它真的是

大多数软件开发人员都听过这样的格言:“它从来不是编译器bug”,以及它的姊妹篇:“它从来不是硬件bug”。但如果你调查足够多的bug,最终会发现这个规则的例外,这就是我们最近在HubSpot发现的情况。我发现了硬件bug是如何影响我们用户体验的。

HubSpot软件产品的核心部分是CRM,HubSpot的大部分其他功能都构建在它之上。CRM存储着HubSpot客户客户的联系信息。这是大量数据,我们需要将其存储在能够可靠处理这种大数据集的地方。自2012年以来,HubSpot CRM一直托管在HBase中。HBase是一个为存储大量数据并提供快速随机访问而设计的数据库。与HubSpot的大多数软件一样,HBase是用Java编写的。

2025年8月1日,HubSpot CRM软件团队的一名技术负责人注意到,存储CRM数据的某个HBase集群偶尔出现减速。他观察到集群中的特定服务器在某些方面经历了比平时繁忙得多的时期。我们跟踪描述HBase服务器行为不同方面的许多指标。通过这些指标,他看到请求处理程序线程占用时间增加,请求延迟增加,磁盘读取延迟增加,CPU使用率增加,垃圾收集时间增加。这些变化显著到足以影响我们用户的体验:一些用户的页面加载速度明显变慢。然而,这些差异并不显著到触发我们的自动故障监控,这就是为什么HBase管理团队没有注意到它们。

当HBase性能突然这样变化时,通常是因为对数据库的请求流量发生了变化,数据库因新的工作负载而表现不同。我花了数周时间梳理HubSpot广泛的指标和日志存储库,试图找到HBase集群工作负载中与服务器减速相关的某种模式。我找到了一些我认为有希望的线索:向数据库发送可能昂贵请求的特定应用程序。我对HBase从这些应用程序接受的请求速率设置了限制,但这并没有使减速停止。最终我放弃了在请求流量中寻找会让我眼前一亮的模式。

当问题发生时,它往往持续约一小时,并且不够严重到拉响警报。因此,我们通常在问题结束后才发现它,这意味着一些Java可观察性技术无法使用,因为它们需要实时访问有问题的JVM。为了解决这个问题,我创建了一个检测器,在服务器遇到问题时识别它们,并自动分析它们的JVM。让这个运行几天后,我有了许多理论上捕捉到问题发生时的分析文件。

这里我展示了一个HBase服务器在神秘减速期间的CPU时间分析文件。我习惯于阅读HBase服务器的火焰图,这里让我印象深刻的是它看起来多么普通。HBase服务器的所有常规组件都可见,没有任何新的不寻常的东西。垃圾收集使用的CPU时间份额特别大,但我已经从指标中知道了这一点。我还从我们的分配率指标中知道,没有分配率的激增来解释为什么垃圾收集突然变得更耗时。CPU使用指标显示HBase JVM占用的CPU周期比平时多,但火焰图显示所有额外的CPU周期只是被正常的HBase服务器工作负载消耗掉了。

一天早上,我一时兴起,决定是时候升级到IntelliJ Ultimate了。之后,我双击了一个Java Flight Recorder格式的服务器分析文件,打算在YourKit中第十几次查看它,希望有新的东西会脱颖而出。我不知道的是,IntelliJ现在是我机器上打开JFR文件的默认应用程序,所以我看到了我的JFR文件的新视图。我以黑暗模式显示它,因为它更容易阅读。

这揭示了我以前从未见过的细节水平。我在上面拼接了两个截图,以显示在滚动我的JFR文件的"时间线"视图时我注意到的东西。这个显示的x轴代表拍摄此分析文件的10秒挂钟时间。绿色标记代表每次从线程采集样本的时间。因为这是CPU时间分析文件,只有当线程在CPU上运行时才会采集样本。因此,我们可以从线程的采样率推断它有多频繁在CPU上。ZWorkerYoung线程(Z垃圾收集器的一部分)中有两批样本,显示这些线程在那两个时期忙于做CPU密集型工作。这本身很好,也不有趣。然而,引起我注意的是,HBase的请求处理程序线程经历了活动上升、下降然后上升,这镜像了GC线程。这不应该发生。没有充分理由说明为什么HBase的请求处理程序在垃圾收集运行时会更忙。

我查看了GC和用户(非GC)线程中的许多单独样本,发现了一个主要的共同点。

JVM中的指令缓存失效

很大比例的样本在__aarch64_sync_cache_range终止。这告诉我需要更多地了解该函数的作用,以及为什么JVM调用它。我了解了一个称为"nmethods"的JVM内部概念,是"native methods"的缩写,这是一些代码在运行时在JVM中的表示方式。nmethods是已编译成机器代码的Java方法。每个nmethod都有一个与之关联的布尔状态,将nmethod标记为"armed"或"disarmed"。一些垃圾收集将每个nmethod的状态转换为armed。下次JVM内部或需要运行该方法的代码遇到nmethod时,必须将其解除武装。

每个nmethod都有一个"入口屏障",在方法之前运行的一小段代码,以确保运行时已正确准备方法并准备好运行。HubSpot的HBase服务器使用带有Z垃圾收集器的Java 21,在方法入口屏障中包含这段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void ZBarrierSetAssembler::nmethod_entry_barrier(MacroAssembler* masm) {
  // 检查nmethod是否已解除武装
  Label disarmed, slow_path;
  __ ldrb(rscratch1, Address(rscratch1, nmethod::disarmed_offset()));
  __ cbz(rscratch1, disarmed);
  
  // 慢速路径:需要解除武装
  __ lea(rscratch1, RuntimeAddress(StubRoutines::method_entry_barrier()));
  __ br(rscratch1);
  
  __ bind(disarmed);
}

这段代码在nmethod运行之前编辑其机器代码,以确保它更新以符合垃圾收集器的最新状态。最后一行确保编辑的指令从机器上的CPU缓存中移除。所有现代CPU在其每CPU缓存中缓存指令。如果有人在内存中编辑指令,必须考虑可能需要使该指令的多个缓存副本失效。

在HubSpot,我们使用Amazon Web Services服务器运行HBase。特别是,我们主要使用i4i、i4g、is4gen和i3en实例系列的混合。其中一些包含x86-64 CPU,一些包含arm64 CPU。x86-64架构自动保持CPU缓存与内存内容同步,因此ICache::invalidate_word在x86-64上运行时实际上不需要做任何事情。arm64架构不会自动保持CPU缓存与内存同步,因此在arm64上,ICache::invalidate_word是这样实现的:

1
2
3
void ICache::invalidate_word(address addr) {
  __builtin___clear_cache((char*)addr, (char*)(addr + sizeof(uintptr_t)));
}

__builtin___clear_cache,一个内置编译器函数,委托给编译器生成实现中的__aarch64_sync_cache_range。现在我们已经回答了为什么JVM使用__aarch64_sync_cache_range的问题:它需要在使用nmethods之前解除它们的武装。

我理解了在HBase减速事件期间主导CPU分析文件的堆栈跟踪的上下文:大量时间花在使CPU缓存失效,或处理由此产生的缓存未命中上。接下来我想知道为什么这在特定服务器上以短脉冲串发生。OpenJDK代码暗示nmethods在主要ZGC垃圾收集中被武装,但不在次要收集中。

我们跟踪HBase服务器中垃圾收集的频率和类型,所以很容易看到有更频繁主要收集的时期,并且这些时期与服务器减速相关。

Neoverse N1上的指令缓存失效

我本可以在调查的这一点停止,并得出结论缓存失效只是慢,因此我们应该避免主要收集,但这让我感觉不对。arm64上的主要收集不可能普遍对应用程序性能造成严重损害,因为有人会对此做些什么,或者至少提交错误报告。我想更确切地知道为什么__aarch64_sync_cache_range对性能如此有害。它的实现是编译器的一部分,我的Java副本是用GCC编译的,所以我在GCC中查找了相关代码。这里我以简化的伪汇编形式呈现它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
__aarch64_sync_cache_range:
  // 检查CPU是否支持自动数据到指令缓存一致性
  mrs x3, ctr_el0
  tst x3, #(1 << 29)
  b.ne .Lskip_ic
  // 循环遍历缓存行
.Lloop:
  ic ivau, x1  // 使指令缓存行失效
  add x1, x1, x2
  cmp x1, x0
  b.lo .Lloop
.Lskip_ic:
  dsb ish      // 数据同步屏障
  isb          // 指令同步屏障
  ret

现在这里是它的一个版本,其中每个粗体指令被翻译成通俗英语,我添加了注释:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
__aarch64_sync_cache_range:
  // 读取CPU的缓存类型寄存器到x3
  mrs x3, ctr_el0
  // 测试位29(DIC标志 - 数据到指令缓存一致性)
  tst x3, #(1 << 29)
  // 如果设置了DIC,跳过指令缓存失效
  b.ne .Lskip_ic
  // 循环遍历每个缓存行
.Lloop:
  // 使x1地址的指令缓存行失效
  ic ivau, x1
  // 增加x1到下一个缓存行
  add x1, x1, x2
  // 比较x1与结束地址x0
  cmp x1, x0
  // 如果x1 < x0,继续循环
  b.lo .Lloop
.Lskip_ic:
  // 等待所有内存访问完成
  dsb ish
  // 刷新CPU的指令流水线
  isb
  // 从函数返回
  ret

我使用gdb检查我机器上ctr_el0寄存器的值,这将影响__aarch64_sync_cache_range的控制流。我发现位28是1,位29是0。这意味着__aarch64_sync_cache_range在我的情况下归结为ic ivaudsb ishisb。这些中哪一个让它变慢?为了找出答案,我写了一个小小的C程序,像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <stdint.h>

void sync_cache_range(uintptr_t start, uintptr_t end) {
  __builtin___clear_cache((char*)start, (char*)end);
}

int main() {
  uintptr_t addr = 0x1000;
  for (int i = 0; i < 1000000; i++) {
    sync_cache_range(addr, addr + 64);
  }
  return 0;
}

我在我们的一台arm64 HBase服务器上编译了这个程序,然后在Linux的perf实用程序下运行它。这计算了程序中每条指令花费的CPU周期。我得到了这个结果:

1
2
3
4
Samples: 1M of event 'cycles'
  83.15%  a.out    [.] sync_cache_range
  12.41%  a.out    [.] main
   4.44%  a.out    [.] other_functions

这是一个非常奇怪的结果。83%的CPU周期花在将两个寄存器相加,并将结果存储在寄存器中!这条指令运行频率并不比它上面和下面的指令高,而且它本身很廉价,那么发生了什么?我怀疑前面的指令ic ivau所做的不仅仅是使缓存行失效,并且产生的额外工作被归因于碰巧在后面的不幸指令。

我在一台AWS i4g机器上得到了这个结果,它使用Amazon自己的Graviton2硬件。凭直觉,我在一台i8g机器上尝试了我的小C程序,它使用Graviton4硬件。它运行快了约35倍,并且add不再主导周期计数。我向AWS的Corretto项目报告了这个发现,这是OpenJDK的一个分支,旨在在AWS硬件上运行得特别好。我从Corretto团队了解到,在我展示的汇编代码下面实际上还有更多层。

Graviton2基于Arm的Neoverse N1 CPU设计。Arm报告了某些Neoverse N1变体中的错误。CPU设计有自动数据到指令缓存一致性,但由于错误,它并不总是实际工作。在这个PDF中,该错误记录为勘误号1542419。Arm建议的解决方法涉及"陷入"mrsic指令。陷入覆盖某些指令的尝试执行,并重定向CPU改为运行一些其他代码。mrs陷入内核,因此它可以返回一个位29设置为0的值,这愚弄__aarch64_sync_cache_range认为CPU不提供自动数据到指令缓存一致性,从而鼓励它使用icic陷入固件,以便固件可以"执行到任意地址的TLB内部共享失效,后跟DSB",如Arm所建议。虽然只有Amazon可以访问他们的固件以确定他们是否使用建议的解决方法,但可以观察到陷入发生,再次使用perf:

1
perf record -e exceptions:page_fault_user -g ./a.out

perf采样了超过32,000次陷入,所有这些它都归因于add指令。如果我给perf一点宽容,并建议也许陷入实际上发生在add之前,只是被错误归因,那么这是很好的证据,表明Amazon正在陷入我们的ic指令,以保持我们指令缓存的正确性。不幸的是,这种解决方法似乎也很昂贵,对HBase来说太昂贵而无法容忍。

结论

这是一段漫长的旅程,但我终于找到了HBase服务器神秘减速的根源。一开始我绝不会猜到我会查阅CPU手册。很多事情必须凑在一起,CPU bug才能影响HBase:

  • 我们使用带有即时编译器的语言,它在运行时编辑机器代码。
  • 我们使用具有弱内存模型的CPU架构的服务器,需要即时编译器发出缓存失效。
  • 由于为硬件bug安装的解决方法,缓存失效比JVM作者可能预期的要昂贵得多。

各个组件的作者都不可能预料到它们以某些方式组合在一起时会工作得多么糟糕。我不责怪堆栈任何层中涉及的人。Bug会发生,有时你运气不好,bug在非常特定的上下文中产生巨大影响。这就是软件工程有趣的地方。如果没有更多的bug,那会有什么乐趣?

缓解措施

HBase是HubSpot Graviton2服务器的唯一用例。我们现在将最关键HBase服务器从i4g和is4gen实例系列迁移走,以解决这个bug。实例系列i4i、i7ie和i8g提供类似的计算资源,没有相同的硬件bug。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计