事情的起因特别普通:监控平台上突然弹出一条告警——某个生产集群里的一个 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 日志调到好看,而是让整个读写链路回到它本来的稳定状态。