☰
HBase RegionServer GC调优实战:从CMS到G1的排查与优化
2026/10/13 1:26:30 网站建设 项目流程

事情的起因特别普通:监控平台上突然弹出一条告警——某个生产集群里的一个 RegionServer(下称 RS)在 15:14 被判定下线。当时我还没意识到,这次看似普通的抖动,会带我们沿着 GC 这条线一路排查,最后把 JVM 参数、HBase 配置乃至业务查询方式都翻了一遍。整个过程持续了将近两周,最终的效果也让我对 RegionServer 的 GC 调优有了新的认识。

先交代一下背景。这套 HBase 集群规模不大不小,核心业务节点 30 多个,每台机器 128G 内存、48 核 CPU,RegionServer 堆设了 24G,跑的还是老牌的 JDK 1.8,用的是传统 CMS 收集器。平时集群还算稳定,日均读写量也谈不上极端,所以当 RS 第一次掉线时,绝大多数人的第一反应都是——网络抖动或物理机负载波动,先观察一下再说。

但接下来的情况显然不对:同一个节点在 20 分钟内连续出现了三次“下线—重连—再下线”的过程。业务侧开始出现 RegionServerException,上游任务重试,虽然重试后都能恢复,但这绝对不是正常的网络问题能解释的。更重要的是,我们打开 RS 的日志一看,没有任何 OOM,没有任何线程异常,日志最后一段还在正常处理 RPC 请求。这种“没有报错、但持续抖动”的现象,最磨人。

1. 事件经过:一次毫无征兆的 RegionServer 下线

1.1 报警时间线与第一印象

先把时间线拉出来会更直观。第一次告警发生在 15:14:07,集群管理界面里该节点的状态变成了 OFFLINE;15:15:02,HMaster 重新把它拉回 ONLINE;但 15:16:40 又开始掉线,这一次持续了 4 分多钟;15:21:33 状态恢复,第三次是 15:24:18。每次都伴随着几百个 RPC 超时,最终由客户端重试吞掉了错误。

第一印象几乎都会指向网络。毕竟下线意味着 RegionServer 和 ZooKeeper、HMaster 之间的会话断了,而会话又非常依赖网络质量。所以我们当时先检查了物理机的网卡、交换机端口、丢包率,甚至请网络团队去查了对端交换机的日志,结果一切正常。再查系统负载:CPU 利用率当时只有 20% 左右,IO 延迟没有抖动,磁盘没有写满,负载均衡器也没异常。排在最后面的,才是 GC。

这里有个经验可以分享:当 RegionServer 反复上线下线的时候,不要只盯着 GC 日志看,先花几分钟把网络、存储、操作系统层排除干净。因为网络和 GC 两类问题在外观上几乎一模一样,但处理思路完全不同。如果一上来就冲去调 GC,最后发现是网卡固件问题就非常尴尬。我们是先排除了外部因素,才把注意力收回到 JVM 内部。

1.2 表面排查:没有 OOM 不代表堆没问题

确认网络和系统层没问题之后,我打开 RS 日志,从掉线前 5 分钟开始翻。日志里没有 OutOfMemoryError,没有死锁,没有线程 dump 里的 BLOCKED 一大片,甚至没有任何 ERROR 级别的异常。日志最后一条是在处理一次正常的 put 请求。看起来像是“毫无征兆地掉线”。

这里必须留个心:日志里没有 OOM,不代表堆就健康。CMS 和 G1 这类收集器在回收不及时的时候,并不会立刻抛出 OOM,而是进入一种“频繁 Full GC 但每次都没有回收干净”的状态。此时进程还能继续跑,但业务线程的响应时间已经严重劣化,心跳这种低优先级任务更是会被搁置到一边。

我习惯用 jstat 先做快照观测,连续采样了 5 次。

jstat -gcutil 28714 2000 5

看到的结果触目惊心:Eden 区使用率在 20% 到 90% 之间反复跳,Survivor 区几乎满,老年代稳定在 92% 左右。YGC 次数还在涨,但每次 YGC 之后老年代不降反升。这个状态说明 RPC 线程的瞬时分配特别快,同时有大量对象在 YGC 后晋升到老年代,要么是对象太大,要么是 Survivor 区放不下的批对象。

到这里,问题基本上锁定在 GC 上了,但没有看到具体停顿数值,还不能确定就是 GC 导致的掉线。

2. 深入排查:把 GC 日志从幕后拉到台前

2.1 RegionServer 下线机制:为什么 GC 停顿会导致掉线

要理解这次问题的根因,得先搞清楚 RegionServer 是怎么被判定下线的。HBase 的架构里,RegionServer 承担实际读写,HMaster 负责调度。RegionServer 需要周期性向 ZooKeeper 汇报心跳和状态,HMaster 也会基于节点状态做判断。一旦 ZooKeeper 会话超时,或者 RegionServer 与 HMaster 之间的心跳间隔超过了阈值,HMaster 就会把这个 RegionServer 标记为不可用,并把它持有的 Region 重新分配给其他节点,这就是我们看到的“下线再上线”的过程。

GC 为什么会跟心跳抢时间?因为一次 Full GC 或长时间的新生代 GC,会让整个 JVM 进入 Stop-The-World 状态。别说不重要的心跳线程,所有业务线程都得停下来等 GC 完成。如果这个停顿持续时间超过 ZooKeeper 会话超时时间——我们的配置是 90 秒,理论上不太容易超——但别忘了,在业务高峰时段,客户端的大量重试请求会像叠罗汉一样压在 RS 上,线程池被占满,心跳线程调度不到,最终在 ZooKeeper 侧看到的会话已经过期。

所以,严格意义上讲,这次掉线的最直接原因不是 JVM 停顿那几秒钟本身,而是长时间的 GC 压力导致可用线程资源不足,进而让心跳处理被饿死。这一点特别容易误判。我之前也经历过类似情况,只看到 GC 停顿没有超过 ZK 超时就去查别的,结果漏掉了线程池饱和这个放大因素。

2.2 GC 停顿数据量化:最高 16.4 秒

确认方向之后,我打开线上已有的 GC 日志。我们当时启动了这几个收集日志的 JVM 参数:

-Xloggc:/data/logs/gc.log -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps

分析 GC 日志不能只看末尾几次停顿,而是要按时间维度聚合。我先把 gc.log 拉到本地,用 GC 日志解析工具做统计,按分钟聚合出 YGC 平均停顿、最大停顿、Full GC 次数三项指标。

结果如下:

  • 高峰期每分钟 YGC 超过 8 次,平均停顿 1.2 秒,最大一次为 4.6 秒;
  • 期间发生 2 次 Full GC,最长一次 16.4 秒;
  • 掉线发生的前 30 秒内,有连续的 3 次 YGC,平均停顿都在 2 秒以上。

说实话,16.4 秒这个数字出来以后,大家都不奇怪为什么节点会掉线了。这已经不仅仅是“堆偏大”那种模糊问题,而是堆里存在明显的垃圾回收障碍。

再往细里看 GC 日志,两个模式非常典型:

一是 YGC 后老年代占用率非常高。当时跑的还是 CMS,CMS 的并发初始标记和重新标记在对象特别多的时候,停顿也不低。二是 CMS 的 Concurrent Mode Failure 出现了至少 4 次,这意味着晋升的对象速度超过了 CMS 后台线程回收的速度,CMS 被迫转为 Full GC,也就是 Serial Old 的全停顿回收,最长的 16.4 秒就是这时候产生的。

这一下,问题就很清晰了:堆内存里存在大量高龄对象,且分配速率快,老年代在并发回收还没做完之前就被填满,导致 CMS 频繁退化。CMS 的架构决定了它在“存活率过高、分配过快”的场景下会非常难受,这也是后来我们换掉 CMS 的直接理由。

2.3 大对象与 MemStore 的嫌疑:jmap 给出的线索

停顿数据和回收模式已经说明问题了,但我不想停在“堆太大、调参数”这种表面结论,还想搞清楚这些对象是从哪来的。于是找一个相对低峰的时间窗口,对掉线节点做了一次堆转储快照分析:

jmap -dump:live,format=b,file=/data/dump.hprof 28714

用 MAT 打开快照,先看支配树,最大的几个对象类型非常集中:

  • 字节数组 byte[] 占了堆总大小的 55% 以上;
  • 相当大比例是 HBase KeyValue 相关的结构;
  • 有多个几十 MB 级别的对象集中在某一张表的 Result 缓存里。

字节数组占比这么高,通常指向两类来源:一类是 RPC 请求和响应中的数据传输缓冲,另一类是 MemStore 内部的写入缓冲。顺着这个方向,我们去看业务接入方当时的查询行为,果然发现了问题:某一个数据同步任务在那个时间段内发起了一批全表扫描请求,单次请求返回的行数没有上限,RegionServer 在服务端需要把扫描结果全部读取后一次性编码返回。请求的并发一上来,RegionServer 堆里立刻堆起大量的字节数组结果集。

这里顺便补一个机制背景:RegionServer 的 RPC 请求处理,本质上是在堆内为每个请求分配临时缓冲区。HBase 的老版本对最大返回行数没有强制保护,完全依赖业务侧自律。一旦某个业务方写了一个“从第一天读到今天”的全表扫描,再把结果全量返回,对 RegionServer 来说就等于一场循环 GC 压力测试。

除了扫描,写入侧的 MemStore 压力也要算进去。那个时间点有一个批处理任务在批量写入,写入行主要集中于两张大表。MemStore 在堆内的占用本来就高,再加上大扫描结果的临时对象,老年代空间被迅速耗尽就成了必然。

3. 调优方案:不换药只换方

方案分为三层:JVM 层面换掉 CMS、调整堆参数;HBase 层面调整 MemStore 和 BlockCache 比例、控制 flush 行为;业务层面治理全表扫描和高并发写入。这三层缺一不可,只调 JVM 参数治标,只改业务不调参数也撑不住。

3.1 JVM 层:从 CMS 迁到 G1 并明确停顿目标

我们当时用的是 JDK 1.8,所以 G1 是完全可以启用的。团队内部其实早就讨论过要不要用 G1,但一直担心 G1 的 CPU 开销,这次正好有充足的理由做了切换。

调整前参数(简化版):

-Xms24g -Xmx24g -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnly

调整后参数:

-Xms24g -Xmx24g -XX:+UseG1GC -XX:MaxGCPauseMillis=150 -XX:G1HeapRegionSize=16m -XX:G1NewSizePercent=5 -XX:G1MaxNewSizePercent=15 -XX:InitiatingHeapOccupancyPercent=45 -XX:ConcGCThreads=8 -XX:+ParallelRefProcEnabled -XX:+UseStringDeduplication

逐条解释一下为什么这么调。

首先是堆大小没有动,还是 24G。很多时候大家觉得 GC 出问题就加堆,但加堆不一定能解决,尤其是在对象分配速率本身不受控的情况下,堆越大只会让 Full GC 的停顿时间越长。24G 对这几个 RegionServer 的负载来说基本够用,真正的问题是回收策略。

启用 G1 的核心原因是它的停顿可控性。G1 把堆切成大小相等的 Region,设计目标就是尽量把回收停顿控制在 MaxGCPauseMillis 指定范围以内。这里我设的是 150ms,并不是越低越好。停顿目标设得太小,G1 会每次只回收一小部分年轻代,导致 GC 频率上升,整体吞吐量下降。150ms 是一个相对合理的业务容忍线。

G1HeapRegionSize 设为 16m,主要考虑到大对象问题。如果 Region 太小,大对象会直接以 humongous 形式分配,分配效率和回收效率都会变差;如果 Region 太大,G1 的最小回收粒度变大,停顿又难控制。16m 对常见的 HBase KeyValue 和 RPC 缓冲对象来说比较合适。

InitiatingHeapOccupancyPercent 这个参数保持默认的 45 即可,它的作用是让 G1 在老年代占用达到 45% 时就启动并发标记,而不是等堆快写满了才触发。配合并发标记线程数 ConcGCThreads 定为 8,机器是 48 核,这个线程数既不会占用太多业务资源,又能保证标记速度跟上分配速度。

ParallelRefProcEnabled 是我特别想提醒的参数。Reference 处理在很多 GC 阶段是单线程的,HBase 内部有大量对象引用,开启这个参数后,停顿时间里对引用对象的处理会并行执行,实测能减少约 10% 到 15% 的 Remark 停顿时间。

UseStringDeduplication 是 G1 的特性,可以去掉字符串内部的冗余字符数组。HBase 的表名、列族名、列名在内存里会频繁创建字符串对象,这个参数可以有效压低堆中的重复字符串,虽然对 GC 停顿的改善不如前几个参数直接,但它对老年代占用率有正面贡献。

3.2 HBase 层:调整 MemStore 比例与 Flush 行为

JVM 参数改完,只能说让 GC 回收效率上了一个台阶,但 HBase 内部的堆分区策略不配合的话,问题还会回来。

RegionServer 的堆在逻辑上主要分成三块:用于写入缓存的 MemStore、用于读缓存的 BlockCache、以及供 RPC 和临时对象使用的剩余部分。默认配置下,MemStore 和 BlockCache 各占 40%,听起来合理,但对于我们这种读写 6:4 的场景,这个默认比例并不理想。我们把它调整为:

  • hbase.regionserver.global.memstore.size = 0.38
  • hfile.block.cache.size = 0.42
  • hbase.regionserver.global.memstore.size.lower.limit = 0.85

MemStore 稍微降一点,把空间让给 BlockCache,原因是我们的读请求有比较多重复命中的热点表,提高块缓存命中率可以直接降低扫描时对磁盘和堆的依赖。lower.limit 从默认的 0.5 提到 0.85,是为了允许 MemStore 在达到全局强制刷写阈值之前,多攒一会儿再批量刷写,减少小文件碎片。

另外调大了单个 Region 的刷写阈值:

<property> <name>hbase.hregion.memstore.flush.size</name> <value>268435456</value> </property>

也就是默认 128MB 提到 256MB。这个动作有风险,因为增大阈值意味着单个 RegionServer 的 MemStore 会更容易积压,GC 压力也可能变大。但当时我们考虑到业务是批量写入为主,且底层盘是 SSD,刷写成本可控,所以值得一试。这个参数需要结合自身硬件情况和写入模型来决定,不能直接照抄。

还有一个容易被忽略的配置是开启 MemStore 的 MSLAB 内存分配器。默认情况下 MemStore 使用常规堆分配,大量小而频繁的 KeyValue 写入会制造严重的内存碎片;MSLAB 的原理是把内存按 2MB 的单元格预分配,对象在线性分配缓冲里连续存放,从根上减少碎片。这个参数在较新的 HBase 版本里默认是开启状态,但如果你的版本比较旧、没有开,务必打开:

<property> <name>hbase.hregion.memstore.mslab.enabled</name> <value>true</value> </property>

3.3 业务层:治理全表扫描与高并发写入

参数调完,如果业务方第二天继续发起全表扫描,GC 大概率还是要出问题。所以我们把业务侧也一起捋了。

全表扫描的问题最直接。当时的同步任务脚本里,扫描动作没有设置 limit 和 batch,RegionServer 会一次性把整张表的数据读进内存。我们的处理方案:

  • 对所有 scan 请求强制要求设置批次大小和起始/结束行键,禁止使用无范围的 scan;
  • 增加扫描结果行数上限,单次 RPC 返回不超过 5000 行;
  • 对于确实需要全量导出的场景,改成按 startRow/stopRow 分段并发,而不是单请求全表读;
  • 开启 HBase 的慢查询日志,把超过 3 秒的请求单独采集出来,持续两周,揪出了另外 3 个隐蔽的全表扫描调用。

写入侧,批处理任务的高并发是正常的,但也要控制单台 RegionServer 的写入压力。我们做法比较朴素:

  • 给批处理客户端加了写入速率限制,控制单 RegionServer 的写入流量在阈值以下;
  • 把批量 put 的批次大小从 5000 调整为 1000,有效降低了单次写入请求在 RegionServer 端分配的内存;
  • 对热点 Region 做了预分区和 rowkey 散列调整,避免写入全都打在两个 Region 上。

这一套三层方案,从 JVM 回收策略、HBase 内部堆分区、业务请求行为三个维度同时发力。单独做任何一层,效果都不会太稳。

4. 调优效果与验证

4.1 优化前后关键指标对比

参数和配置上线之后,我们保留了原先的监控项,盯了整整 14 天。这里我贴一下优化前和优化后的关键指标对比,方便大家有个直观感受:

指标优化前优化后
每分钟 YGC 次数(高峰)8 次1.5 次
YGC 平均停顿1.2 秒0.2 秒
YGC 最大停顿4.6 秒0.8 秒
Full GC 频率每天 2-3 次0 次
最大 GC 停顿16.4 秒0.9 秒
RegionServer 下线次数每天 1-3 次0 次
RPC 超时率7%0.02%
老年代占用率稳定 90% 以上60%-70% 之间震荡

数据最直观的变化在下线和 Full GC 上:Full GC 归零。老年代占用率降到 70% 以下以后,即使偶尔有扫描峰值,也不会再触发 Concurrent Mode Failure,节点稳定性完全上了一个台阶。

另外我多说一句,YGC 平均停顿能从 1.2 秒降到 0.2 秒,很多人第一反应是 G1 的功劳,但实际上有一半功劳要归给业务治理——全表扫描消失后,堆里的临时对象量级大幅下降,GC 的工作量轻了,停顿自然就小了。别把所有功劳都记在收集器头上。

4.2 14 天稳定性验证与压力观察

优化后的前两天,一切都很平稳,但我并没有急着下结论。真正让我放心的,是第三天下午又来了一次同步任务峰值,流量水平和出事时几乎一样,而这回 RegionServer 的老年代最高冲到 78%,YGC 最大停顿 0.8 秒,全程没有一次下线,也没有一个 RPC 超时。

那个时间段我一直在盯 jstat 和监控面板,后来又把 G1 的 Mixed GC 日志拉出来看了一眼,发现并发标记周期能够正常完成,不需要退化到 Full GC。这说明堆内对象存活率已经降到了合理的水平,G1 可以按部就班地推进回收周期。

这里有一个经验值得强调:调优完成后,一定要挑一个和故障场景同等量级的业务高峰去验证,否则很容易得出“调好了”的假结论。如果优化之后的高峰流量远低于故障当天,那你根本不知道问题是真的解决了,还是只是因为流量小了所以没暴露。

5. 经验、工具与避坑指南

5.1 几个容易踩的坑和我的建议

这次排查下来,我个人最大的感触是:GC 调优最怕的不是参数复杂,而是定位不到根本原因。

第一个坑是迷信“堆大就是稳”。堆设置得大,确实能给程序更多喘息空间,但对于 RegionServer 这种既有低频大查询、又有高频批量写的场景,无脑扩堆只会放大停顿。我们的堆始终是 24G,没有加,问题解决是靠回收策略和业务行为的调整。

第二个坑是只看 Full GC,忽视 YGC 频率。CMS 时代有个误区,觉得只要 Full GC 不常发生就没问题。但这次告诉我们,高频 YGC 如果每次都把大量对象推进老年代,老年代迟早爆。优化前的 YGC 频率每分钟 8 次,几乎是把老年代当成垃圾桶在倒。

第三个坑是忽略线程池饱和这个中间环节。GC 停顿最多十几秒,ZooKeeper 会话超时是 90 秒,表面上看不会被判定为下线。但线程池被 GC 后的 RPC 请求压满时,心跳线程一直接不到调度机会,最终节点照样下线。排查的时候,要在 GC 停顿和节点下线之间多问一层“为什么没有容错”,不要只看表面时间关系。

第四个坑是调参的时候只改一个变量,然后等结果。GC 调优本质是个联动的系统,JVM 参数、HBase 配置、业务行为互相影响。只改一个参数,可能当前问题还能压住,一旦流量形状变化就崩溃。这次我们把三层方案放在一起,后续两周里逐步微调,才稳定下来。

5.2 常用分析工具复盘

再总结一下这次用到的工具,希望对大家排查同类问题有帮助。

  • 在线 GC 日志分析:把 gc.log 上传到在线分析平台,它会自动标注问题类型,比如 Concurrent Mode Failure、Humongous Allocation、Allocation Failure,虽然不绝对精准,但能快速给出方向。
  • MAT 负责堆转储分析。重点看支配树和 Leak Suspects,能直接找到哪个对象占用了最多内存。
  • jstat 负责实时观测,适合故障发生时快速判断老年代趋势。
  • jcmd 可以动态打印 GC 相关数据,比 jstat 更精细,适合需要并发确认的场景。

排查 GC 问题时,建议给所有 Java 服务提前开好 GC 日志,这个成本极低,但关键时刻能救命。没有 GC 日志,你面对“掉线”就只剩猜测。

5.3 对后续改进的思考

这次事件之后,我做的第一件事,是把扫描请求的前置治理固化成平台规则。现在所有接入方的新查询脚本上线前,都要过一遍请求行为检查,凡是无限制的全表扫描,直接拒绝。这比每次出问题时再去沟通高效得多。

同时,也把 JVM 参数的变更纳入了配置管理系统,调整前后可以做自动化对比,避免上线的参数在某次重启后丢失。这类基础设施层面的细节,往往比一次调优的收益更长久。

最后再说一点这次踩坑之后的真实体会。RegionServer 掉线这种看似“基础设施故障”的问题,最后往往能挖出一整条链路里的隐患。如果当时只把注意力放在调整参数、把 GC 停顿压下去,而不去管全表扫描和线程池饱和,最多过一个星期,问题就会以另一种形式冒出来。真正的调优,不是把 GC 日志调到好看,而是让整个读写链路回到它本来的稳定状态。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询