网关选型:Go、Java、Graal 谁都不能完胜

关于网关选型,现在有三种说法,各自都有真实的数字撑腰:

  • "网关就该用 Go,Java 太重";
  • "ZGC 停顿已经进微秒了,JVM 早就不慢";
  • "上 GraalVM native image(下文简称 native),启动 68 毫秒,跟 Go 一样快"。

三句都没说错,只是各量各的:Go 那句量的是内存和启动,ZGC 那句量的是停顿,native 那句量的是启动。

所以我把三条路线放进同一批实验,用同一把尺子量七个维度,再把结果画成一张雷达图。

先给结论:七格里没有任何一条线全在圆外。 Go 拿下内存和启动,每请求 CPU 与 Java 打平;Java 拿下 p99 和 10 毫秒以上请求的占比;native 拿下最深的那段尾部,但付出了三成吞吐。

三条线谁都有明确输掉的那一格------所以严格说,谁都不能完胜,你只能选自己输得起的那格。

还有一个更硬的发现:Java 和 native 的最优 GC 都是 G1。 于是能做一个干净的对照:同一个 GC 算法,只差 JIT 在不在,代价可以逐项标出来(4.2 节)。

一、先定尺子:七个维度和它们的测法

"哪个网关快"没法直接回答。不先说清拿什么指标、怎么测,任何数字都能被解释成想要的结论。

所以先立七把尺子,每一把都对应一个真实的决策:

维度 指标 怎么测 它决定什么
吞吐 rps 2 核配额、并发 32、预热 20 s 后测 30 s 单副本的容量上限
p99 第 99 百分位延迟 同窗口约 20~30 万个请求,丢前 2 秒重算 绝大多数用户感受到的"慢"
p99.9 第 99.9 百分位延迟 同上 深处尾部是否可控
尾部占比 >10 ms 请求占比 同窗口计数(max 只作参考,不参与排名) 会被 SLO 罚钱的那部分
内存 满载 RSS 负载期 /proc 稳态值 一台机器能放几个副本
冷启动 进程起到能返回 200 每种配置 3 次取中位 扩缩容与故障重建速度
每请求 CPU cpu_s ÷ 完成请求数 配额打满时的单请求成本 同样的核算力能干多少活

三条原则先说清楚,后面所有数字都遵守:

一,不混口径。 要么"给同样的资源比服务"(吞吐、延迟、内存),要么"给同样的服务比资源"(每请求 CPU、冷启动)。两种口径的数字绝不放进同一句话。

二,每个数字都带前置条件。 不带并发数的吞吐、不带堆大小的内存、不带 CPU 配额的延迟,都不可比。这不是讲究措辞:同一个 JVM 网关、同一份负载,只改堆大小,RSS 从 351 MB 变到 4096 MB,中间几档吞吐几乎不动------同一条结论换个配置就能被读成两个故事。

三,落在噪声里的差异不叫差异。 同一份配置重复测,吞吐本身就有 ±7% 波动(基线见第二节)。所以 1.01 倍的吞吐差,本文读作"打平",不读作"Go 快 1%"。

二、让三条线量的是同一件事

先列公平性设计,这八条缺一条,后面的对比就站不住:

  1. 三层绑核:上游在核 2,被测网关在核 0-1,压测器在核 3,互不抢占。
  2. 活动处理器数对齐到 2 :Go 侧 GOMAXPROCS=2,Java 侧 -XX:ActiveProcessorCount=2 并且 加 -Dreactor.netty.ioWorkerCount=2。
  3. 职责逐项对齐:同上游地址、同 header 注入集合、同 JSON 访问日志。
  4. 日志体量对齐:见下文,这一步不做,"带职责"组的对比是假的。
  5. GC 统计只取测量窗:按日志行号差值切片,排除启动与预热期的 GC。
  6. 预热 20 s + 测量 30 s,并发 32。
  7. 延迟数字丢前 2 秒重算:见下文,这是 JVM 的专属偏差。
  8. 三条路线同批:Go、JVM(两种 GC)、native(两种 GC)由同一个脚本、连续跑完,中间不换机器不换配置。

下面三件事,任何一件没处理,结论都会反过来。

第一件:只锁 ActiveProcessorCount 是压不住线程数的。

Reactor Netty 的 io worker 默认是 max(可用处理器数, 4),有个"最小 4"的下限。按 /proc/<pid>/task/*/comm 数 reactor-http-epoll-* 线程:

JVM 参数 event loop 线程数
默认 4
-XX:ActiveProcessorCount=2 4
-XX:ActiveProcessorCount=2 -Dreactor.netty.ioWorkerCount=2 2

给了 2 个核却起 4 个事件循环,测出来的是调度器抖动,不是网关本身的能力。这一步必须显式配。

第二件:日志体量先对齐,再比"带职责"。

第一轮实测发现 Traefik 的 JSON 访问日志是 1108 字节/请求,Java 侧只有 250 字节,差 4.4 倍。补齐 Java 侧字段后重跑,最终 1108 vs 997~1008 字节/请求。

第三件:JVM 的测量窗口头部必须丢。

JVM 的第 0 秒只处理了后续秒 57~65% 的请求,那一秒的 p99 是后续的 2.4~4.8 倍 。这不是"慢 20%",是三分之一容量还没上线。

把这条修正回算到 132 个历史用例上:

实现 用例数 p99 变化中位 变化超 5% 的用例
Go 42 0.0% 0 / 42
GraalVM native 12 +0.1% 0 / 12
上游直连 4 −0.1% 0 / 4
G1 35 −17.9% 31 / 35
ZGC 38 −12.6% 31 / 38

58 个 Go / native / 直连用例里 0 个受影响,74 个 JVM 用例里 63 个降幅超过 5%。 这是 JVM 的专属偏差,不是通用降噪------不改口径,JVM 在所有 p99 对比里系统性吃亏。

下面是噪声基线,它决定了多小的差异还能算数:

  • 同一份 ZGC + 2g 配置三次独立测量:8438 / 8871 / 9066 rps,极差 7.1%
  • 1 核配额下 Traefik 三次:7200 / 7203 / 7262 rps,极差 0.9%
  • max 这种单样本极值:同配置三轮极差 106%~385% ,本文不用它做排名

最后交代一件事:本文用了三批数据,每批内部自成对照,绝不跨批比绝对值。

批次 内容 用途
主对照批 Go / JVM+G1 / JVM+ZGC / native+G1 / native+Serial,五家同批,堆 512 MB 雷达图与第四节主表
堆梯度批 ZGC,堆从 128m 到 4g 解释"内存是参数不是常数"
专项批 裸转发、四种 GC 停顿、AOT 启动、1 核方差 解释某一条轴的成因

为什么主对照批用 512 MB 而不是社区常见的 2g?因为堆梯度批已经证明:512m 那一档吞吐是 2g 的 0.96 倍,落在噪声内。选 512m 是让三方在同一个堆口径下比较------按 2g 算,内存那一格会从 5.25 倍变成 20 倍。这不是 Java 的极限:G1 压到 256m 只要 413 MB,吞吐和 p99 都不动,4.4 节给完整梯度。

三、一张图:七个维度,三条路线

把七个维度画进同一个坐标系,需要先解决"量纲不同"的问题。做法是以 Go 为原点:每个维度的分数 = 100 + 30 × lg(该实现的优势 ÷ Go 的优势)。七条轴都以 Go 为基准,所以 Go 始终落在那条虚线圆上,另外两条线凸出或凹进多少,就是它们相对 Go 的强弱。

打分公式只是为了让 rps、毫秒、兆字节、微秒能放进同一个坐标系看形状,结论请以下表的原始数字为准:

维度 Traefik(Go) SCG + G1 native + G1
吞吐(带职责,2 核,并发 32) 10392 rps 10538 rps 7138 rps
p99(丢前 2 秒) 10.370 ms 5.100 ms 7.622 ms
p99.9(丢前 2 秒) 13.551 ms 14.269 ms 13.028 ms
>10 ms 占比(丢前 2 秒) 1.265% 0.254% 0.398%
>25 ms 请求数(丢前 2 秒) 32 个 13 个 0 个
max 延迟(只作参考) 69.08 ms 28.17 ms 17.10 ms
内存(满载 RSS) 106 MB 557 MB 219 MB
冷启动(到能返回 200) 63 ms 5284 ms 75 ms
每请求 CPU(并发 32) 188 µs 187 µs(打平) 277 µs

读这张图只要三句话:

第一,每条线都有凹口,而且各自最深的那格互不相同。 Go 最深处是 p99(差 2.03 倍)和 >10 ms 占比(差 4.98 倍);Java 最深处是启动(83.9 倍)和内存(5.25 倍);native 最深处是吞吐(0.69 倍)。没有一个格子是三方都满意。

第二,native 的形状在三条里最接近正圆。 它在七格里没拉出过数量级优势,也没崩到 50 分以下------Java 的启动只有 42 分。这条线的形状说明:AOT 在交出"重"和"启动慢"的同时,也把"极端优势"一并交了出去。

第三,三家的 p99.9 只差 9.5%。 p99 那一格 Java 只有 Go 的一半,到 p99.9 却挤成 13.03 / 13.55 / 14.27。差距在更深的尾部被抹平------这条在 4.3 节展开。

四、逐条读数

4.1 吞吐:Go 和 Java 打平,native 掉三成

带完整职责(header 注入 + JSON 访问日志)时:Traefik 10392,SCG+G1 10538,SCG+ZGC 9847,native+G1 7138,native+Serial 7303。

Go 与 Java 差 1.4%,落在 ±7% 噪声内,读作打平。native 是 Go 的 0.69 倍。

但把职责摘掉,只做裸转发(专项批),差距立刻变成 18711 vs 11276 = 1.66 倍。差距去哪了?看日志这一项的成本:

网关 裸转发 带职责 日志成本
Traefik(Go) 104.8 µs 205.1 µs +100.3 µs
SCG(Java) 176.7 µs 224.3 µs +47.6 µs

这张表的"带职责"是专项批内的绝对值(205.1 / 224.3),与雷达图那批的 188 / 187 不同批------只用来看差值,不要跨批比大小。

Traefik 写一行 JSON 日志的成本是 Java 侧的 2.1 倍,而两者日志体量只差 10%。 原因是 Traefik 走 Go 标准库 encoding/json(反射序列化),Java 侧是我手写 StringBuilder 拼的。

这一格是偏袒 Java 的------真换成 Jackson 反射序列化,Java 会输得更多。

所以"网关快多少"这个问题的正确答案是:看你让它干多少活。 纯管道 1.66 倍,加了真实职责后收敛到噪声内。

4.2 p99:Java 赢了 2 倍,而且两边的最优 GC 都是 G1

这是全文最重要的一张表。同为"带职责、2 核、并发 32",五个实现,延迟数字全部丢前 2 秒重算:

实现 GC p50 p99 p99.9 >10 ms 占比 max >25 ms
Traefik(Go) GoGC 2.643 10.370 13.551 1.265% 69.08 32
SCG(JVM) G1 2.861 5.100 14.269 0.254% 28.17 13
SCG(JVM) ZGC 2.999 7.619 14.849 0.776% 39.41 43
native image G1 4.371 7.622 13.028 0.398% 17.10 0
native image Serial 4.136 11.313 14.320 1.113% 16.88 0

这张表里藏着两个结论。

第一,Java 侧换 GC 是真的有用,而且最好的是 G1,不是 ZGC。

G1 的 p99 是 5.100,ZGC 是 7.619,G1 低 33% ;>10 ms 占比 0.254% 对 0.776%,G1 只有 ZGC 的三分之一。这跟"ZGC 停顿更短所以延迟更低"的直觉相反(原因在 5.3 节)。

第二,native 侧的最优 GC 也是 G1,但它赢不了 JVM。

native+G1 的 p99 是 7.622,native+Serial 是 11.313------G1 低 33%,和 JVM 侧的幅度一模一样。但 native+G1 的 7.622 仍然比 JVM+G1 的 5.100 高 49% 。

于是我们有了一个干净的对照:同一个 GC 算法(G1),唯一的差别是 JIT 在不在。 这张定价表可以直接读出来:

项 JVM + G1 native + G1 JIT 值多少
p99 5.100 ms 7.622 ms JIT 让 p99 低 33%
吞吐 10538 rps 7138 rps JIT 让吞吐高 48%
每请求 CPU 187 µs 277 µs JIT 让单请求省 32%
内存 557 MB 219 MB JIT 要花 2.5 倍内存
冷启动 5284 ms 75 ms JIT 要花 70 倍启动时间

这就是 JIT 的账:买 33% 的 p99、48% 的吞吐、32% 的 CPU,付 2.5 倍内存和 70 倍启动。 后两条是物理成本,前三条是可优化的收益------所以这笔账没有标准答案,只有你的约束落在哪边。

再往下看曲线形状。Go 从 p99 到 p99.9 只涨 1.31 倍(10.370→13.551),JVM+G1 涨 2.80 倍(5.100→14.269),native+G1 涨 1.71 倍。曲线在 p99 与 p99.9 之间交叉:p99 处 JVM 比 Go 低一半,p99.9 处反而比 Go 高 5%。Go 是"肩宽尾短",JVM 是"肩窄尾长",native 两头都不极端。拿单个分位数下结论,等于用一个交点代表整条曲线。

4.3 尾部:Go 的 1.265% 对 Java 的 0.254%

同一个窗口超过 10 毫秒的请求占比:Go 1.265% ,Java+G1 0.254% ,native+G1 0.398%。Go 的慢请求占比是 Java 的 4.98 倍。

但换成"超过 25 毫秒"这个更狠的阈值,排序反过来了:Go 32 个,Java+G1 13 个,native 0 个。

再看 max(虽然不参与排名):Go 69.08,Java+G1 28.17,native 17.10。

三个数字拼出一个完整形状:

  • Go 的尾部是"平的" :从 p99 的 10.37 到 max 的 69.08,中间没有断崖,也没有第二个峰。29 万个请求里 32 个越过 25 ms------不是没有,是均匀地撒在 30 秒里。
  • Java 的尾部是"陡的" :p99 到 p99.9 涨 2.80 倍,到 max 涨 5.5 倍。中间更整齐,一旦掉出去就是几十毫秒。
  • native 的尾部是"截断的" :max 只有 17.10,25 毫秒以上一个没有。它把尾部整个压住了------代价是每请求多花 89 µs 的 CPU。

对 SLO 来说,这三种形状的后果完全不同:如果你的阈值是 10 ms,Java 最好;如果是 25 ms,native 最好;如果你只关心"绝大多数人",Go 的 p99 是 10.37,比另外两家都差。

4.4 内存:5.25 倍,但 Java 的内存是参数,Go 是常数

带着 512 MB 堆时:Traefik 106 MB ,SCG+G1 557 MB ,native+G1 219 MB。

Java 是 Go 的 5.25 倍,native 是 2.07 倍。

先说一个必须交代的口径。按社区对 Java 网关的常规推荐值 -Xms2g -Xmx2g,Traefik 107 MB 对 SCG+ZGC 2150 MB,是 20 倍。但这个 20 倍是配置造成的,不是代码造成的。同一个 JAR、同一份负载,只改堆(堆梯度批):

堆 吞吐 rps 相对 2g 最大停顿 常驻内存 相对 2g
128m 7462 0.85× 0.024 ms 351 MB 0.16×
512m 8455 0.96× 0.016 ms 726 MB 0.34×
2g 8791 1.00× 0.016 ms 2165 MB 1.00×
4g 7870 0.90× 0.020 ms 4096 MB 1.89×

512m 那一档吞吐是 2g 的 0.96 倍------落在 ±7% 噪声基线以内。把堆从 2 GB 砍到 512 MB,测不出吞吐损失,内存省 67%。

这张梯度表是 ZGC 的。换成 G1,同样 512m 堆的 RSS 是 557 MB(主对照批)------比 ZGC 少 170 MB。

G1 的梯度我另外单独测了一轮(NMT 开着,只在本轮内部对照):

G1 堆 RSS rps p99
128m 322 / 330 10682 / 10912 9.16 / 8.27
256m 413 10989 5.98
512m 576 / 578 10778 / 11060 6.15 / 5.81

这一轮 512m 档的 576 比主对照批的 557 高 19 MB(3.4%),差额来自 NMT 自身的记账开销与批次噪声------档间相对关系不受影响,但别把这个 576 拿去覆盖主表的 557。

G1 的拐点在 256m,不在 512m :413 MB 拿到 10989 rps、p99 5.98------比 512m 省 165 MB,吞吐和尾延迟都不动。压到 128m 才开始付 p99 的代价(退化 45%),而吞吐仍然是满的。所以 G1 不像 ZGC 那样"堆小了就掉吞吐",它只在尾延迟上让步。

三个边界要记住:

  • ZGC 的下界是 128m(吞吐掉 15%),4g 是纯浪费(吞吐掉 10%、内存翻倍)。
  • 弹性堆省不了内存 :-Xms512m -Xmx2g 实测 2147 MB,和固定 2g 的 2165 MB 几乎一样。持续高分配负载下,"按需扩展"的"需"就是满。想省只能真把 Xmx 调小。
  • 堆不是全部 :128m 堆那一档的 322 MB 里,只有 128 MB 是堆,剩下 194 MB 是与 -Xmx 无关的固定开销。它的逐项构成在 5.2 节(元空间 36、代码缓存 30、GC 结构 39、JVM 二进制 36、符号表 9、分配器碎片 26、线程栈 1.75)。

native 的 219 MB 说明另一件事:AOT 把元空间和代码缓存的账清了,但没有清干净。 同一份 native 代码,Serial GC 版只要 95 MB,G1 版要 219 MB------多出来的 124 MB 挂在 G1 这侧(这块我没拆开,native 不支持 NMT,见 5.2)。也就是说,native 只省掉了"类元数据 + JIT 代码缓存",省不掉"堆 + GC 结构 + 线程栈 + 运行时映射"。

反过来看 Go:GOMEMLIMIT 设 32 / 64 / 128 MiB 或不设,四个点全部钉在 99~108 MB 这条竖线上,压到 32 MiB 吞吐反而掉 11%。它不是可调参数,而是一道压不动的下限。

所以这一格的正确说法不是"Java 费 5 倍内存",而是:Java 的内存是参数(3.020 倍,看你给多少堆、给哪个 GC),Go 是常数(约 105 MB),native 是半参数(95219 MB,看 GC)。

4.5 冷启动:Go 63 ms、native 75 ms、JVM 5284 ms

每种配置测 3 次,取中位:Traefik 63 ms ,native+G1 75 ms ,SCG+G1 5284 ms。

native 是 Go 的 1.19 倍,基本同档;JVM 是它们的 70~84 倍。

这个差距能不能补?我测了两条路。

第一条:Project Leyden AOT cache。 2 核配额下,JVM 基线启动 5496 ms,加 AOT cache 后 4915 ms(−10.6% ),再加懒加载 4717 ms。方向对,但幅度离 70 倍差得远。

第二条:GraalVM native image。 构建 6 分 29 秒、峰值内存 4.98 GB,产出 90.0 MiB 的静态二进制。启动 68 ms(Serial GC 版),同批 Go 是 63 ms。

但代价必须一起说,否则这个结论站不住:

项 JVM + G1 native + G1
冷启动 5284 ms 75 ms
吞吐(并发 32) 10538 rps 7138 rps(JVM 的 0.68×)
每请求 CPU 187 µs 277 µs
常驻内存 557 MB 219 MB(Serial 版 95 MB)
p99 5.100 ms 7.622 ms
jcmd / jstack / jmap 可用 全部 AttachNotSupportedException
-Xmx64m 生效 静默失效

两件事值得单独看一眼。

native 的尾延迟是全场最好的(max 17.10 ms,>25 ms 一个都没有,而 Go 有 32 个)。这符合机制:没有 JIT 就没有反优化,没有大堆就没有大停顿。它输的地方全在吞吐上。

-Xmx64m 在 native 上不报错、照常服务、RSS 82 MB ------JVM 参数在 native 上是静默失效的,堆上限要改用 -XX:MaximumHeapSizePercent 或构建期参数。这类"以为配了其实没配"的坑,会一直留到线上。

换 AOT 能买回启动,但买不回吞吐,还会赔掉可观测性。 对基础设施组件来说,最后一条往往最难接受。

4.6 每请求 CPU:188 / 187 / 277

配额打满时,每请求的 CPU 成本:Traefik 188 µs ,SCG+G1 187 µs ,native+G1 277 µs。

Go 和 Java 打平,native 贵 47%。

这一格最值得琢磨的问题是:没有 JIT 的 native,为什么反而比有 JIT 的 JVM 更费 CPU?

先排除三个嫌疑(都用同批数据):

嫌疑一:GC。 JVM 侧只换 GC:G1 10538、ZGC 9847,差 6.6%,落在噪声边缘。native 侧只换 GC:G1 7138、Serial 7303,差 2.3%。两边换 GC 都动不了吞吐的量级,GC 不是主因。

嫌疑二:GC 的频率。 把 Traefik 的 GOGC 从 100 拉到 1000,GC 次数从 556 降到 60(降 89%),吞吐只涨 11%,内存却涨 2.3 倍。把 GC 砍掉近九成只换 11%,说明 Go 侧的 GC 总共也只占一成的 CPU。

嫌疑三:JIT 缺不缺失。 这条最反直觉:native 全是构建期编译好的机器码,没有 JIT,也没有 C2 的峰值优化。同批吞吐 7138,只有 JVM 版(10538)的 0.68 倍 。所以 native 更慢,不是因为"JIT 还没热起来"------恰恰相反,把 JIT 整个拿走只会更慢。

三个嫌疑都排除后,剩下的只能是编译质量与每请求的固定工作两个来源:

  • 编译质量:GraalVM 的 AOT 编译不可能做 C2 那样的激进内联与投机优化,热点路径上的指令数天生更多。这一条我没有做指令级对比,只作为机制解释。
  • 固定工作:对象图的构造与销毁、Pipeline 的适配层、Filter 链的逐层传递、两次 HTTP 解析。这部分工作没有"热点"可优化,跑多少遍都是那么多活。

这一段我只给机制,没有逐项测量。 本文能确认的只有:它不是 GC,不是 GC 频率,也不是 JIT 缺失------三者都被上面的实验单独否掉了。

有一个反向旁证值得记住:日志那一格反过来了 (专项批:Go 100.3 µs vs Java 47.6 µs)。同样是每请求的固定工作,一旦落到"反射 + 序列化"这种需要抽象的地方,Go 反而更贵。Go 的优势区间是"最短路径",不是"每个环节都更快"。

4.7 一项没进雷达图:JVM 要爬十几秒才满速

就绪不等于能按满速接流量。把网关就绪后的头 12 秒单独量出来,和之后的稳态窗口比(专项批,同一进程、同一份代码、同一份负载):

用例 头 12 s 吞吐 稳态吞吐 倍数
Traefik 裸转发 18686.6 18710.9 1.00×
Traefik 带职责 9645.7 9644.8 1.00×
SCG + G1 带职责 2089.9 9312.5 4.46×
SCG + ZGC 带职责 2225.7 8870.6 3.99×

Go 从第一个请求就是满速;JVM 要爬十几秒,爬升期的平均吞吐只有稳态的 1/4。 native 这一段我没有单独测------它的启动只有 75 ms,按同样口径截头 12 秒,窗口里已经没有"爬坡"可言。所以这一项我只对 Go 和 JVM 下结论。

这一项没有进雷达图:它只对 JVM 一家成立,放进去等于给另外两家各送一格满分,重复计分。但它对"扩容救火"这个场景是决定性的:流量涨上来的头十几秒,JVM 只能接住四分之一。

低配下更糟。0.5 核配额,把预热从 25 秒拉到 60 秒:

预热时长 SCG+ZGC @0.5 核 Traefik @0.5 核
25 s 774 rps 3360 rps
60 s 2616 rps 3326 rps

同样拉长预热,Go 只动了 1%,Java 涨了 3.4 倍;而且即便给足 60 秒,Java 在 0.5 核下也只有 Go 的 79%。低配额意味着 JIT 编译本身也在抢那点配额------"预热不够"和"配额不够"在低配下是同一件事。

4.8 雷达图上没有的一格:重复测量的方差

1 核配额下各测三次(专项批):

方案 三次 rps 三次 p99
Traefik 7200 / 7203 / 7262(±0.9% ) 12.79 / 12.77 / 12.73(±0.2%)
SCG + ZGC 6405 / 6279 / 6723(±7%) 56.28 / 57.85 / 18.35 (3.1 倍)

同一个二进制、同一份负载、同一台机器,Java 的 p99 在 18 ms 和 58 ms 之间跳。这一条在雷达图上没有轴,因为它不是一个"维度",而是一种性质:同样配置重复跑,Go 像一台机器,Java 像抽奖。

五、原理:这个形状是怎么长出来的

前面量的是"是什么"。这一节回答"为什么",而且只写能指向某一次测量的话;指不到的,我会明说没测。

5.1 启动的三段账,AOT 只能还掉第一段

JVM 启动慢常被归给"类加载"。这个解释只对了一小部分:

  • 加 Leyden AOT cache (把类加载、链接、验证的产物缓存下来)只省了 10.6% (5496 → 4915 ms)。说明类加载那一段只占一成。
  • 换成 native image (把整个应用在构建期编译成机器码,运行时没有类加载、也没有 JIT)直接到 75 ms,是 Go 的 1.19 倍。

剩下的 89% 里,"Spring 上下文装配"和"JIT 把热点方法编译到 C2"各占多少,本文没有分开测。但有一条可以确定:它不是 GC 的账------JVM 侧换 GC,启动时间落在 5103~5514 ms 之间,动不了。

这也解释了为什么 AOT 不是一个"调参"选项:你要么保留 JIT 并继续付启动与爬坡的钱,要么上 native 并把 JIT 的收益一起交出去(吞吐 0.68×)。

5.2 内存的差:堆能调,固定开销调不动

把 Xmx 从 2g 移到 512m,RSS 从 2165 掉到 726,吞吐不动------大头确实在堆上,所以它是一条可平移的线。

但这条线平移不到 Go 那里去。"平移不动的那一截"到底是什么,之前只能拿 RSS 减堆来估;这次我用 NMT(-XX:NativeMemoryTracking=summary)加 /proc/<pid>/smaps,在压测过程中直接采了一遍。

G1、-Xms = -Xmx、并发 32、带职责,128m 堆下的 322 MB 是这么构成的:

项 MB 是什么
堆 128 压测期间是满的(30 秒 508 次 GC)
类元数据(Metaspace) 35.9 10141 个类的字段布局、字节码、常量池
JIT 代码缓存 30.1 C1/C2 编译出来的机器码
GC 自己的结构 39.2 G1 的 region 元数据、记忆集、位图
JVM 二进制与库 36 libjvm.so 18.4 + CDS 归档 13.6 + 其它
符号表 8.6 11.9 万条方法名/类名
内部表 9.7 perf 数据、哈希表、编译器 arena
malloc 碎片 ~26 分配器未归还的空洞
线程栈 1.75 25 个线程,预留 25 MB,实际只提交 1.75

下面三条和直觉不一样,其中两条是我上一版写错的:

第一,线程栈根本不是大头。 25 个线程各预留 1 MB,但每个只提交约 70 KB------合计 1.75 MB,占 0.5%。我上一版把它和元空间、代码缓存并列成"三根支柱",是照着 Xss 的预留值想当然写下的。

第二,真正的大头我漏了:GC 自己的 39 MB。 G1 的 region 数组、记忆集、标记位图,不跟 JIT 有关,也不怎么跟堆大小有关(128m 时 39.2,512m 时 47.0)。

第三,libjvm.so 一个文件就 18.4 MB。 运行时自身的体积也是要付的。

所以"有 JIT 就得付"的钱是 类元数据 36 + 代码缓存 30 + 符号表 9 = 75 MB。剩下 119 MB 里,39 MB 是 GC 的账,36 MB 是 JVM 二进制的账,26 MB 是分配器碎片。(上一版写的是 219------那是 ZGC 在 128m 档的数,混进了 ZGC 自己的 149 MB。)

这 194 MB 与堆无关,是可复现的。 同一份代码换三档堆,NMT 记的堆外 committed 分别是 150 / 154 / 159 MB------几乎不动。

注意别用 RSS 减堆去算这块固定开销:这样算 128m 档是 194、256m 档是 157、512m 档只有 66。变小的不是固定开销,是堆的驻留率(100% / 86% / 75%)------region 分配了但没写过,就不占物理页。

再往下,两个 GC 在这 194 MB 上走向不同:

G1 到 256m 才见拐点。 128m 堆时 rps 10682 / 10912,和 512m 的 10778 / 11060 一样;但 p99 从 5.8 退化到 8.3~9.2。到 256m 就都正常了:413 MB、10989 rps、p99 5.98------比 512m 省 165 MB,吞吐和尾延迟都不动。

ZGC 要在这 194 MB 上再叠 149 MB,而且这笔钱是隐形的。 同为 512m 堆,ZGC 的 RSS 是 727,G1 是 578。可 NMT 报表里 ZGC 的 GC 类别只有 7.1 MB(G1 是 47.0),元空间、代码缓存、符号表三项逐 MB 相同,committed 总量 ZGC 的 642 还比 G1 的 671 少。

那 149 MB 去哪了?它落在 smaps 的匿名段里,NMT 没有一个类别肯认这笔账。它是 ZGC 的标记位图、转发表、多视图重映射的页表------JVM 不把它记在 GC 名下。 按内存给容器配额时,这块会被漏算。

native 也印证了这一点:AOT 清掉了类元数据和代码缓存,RSS 从 557 掉到 219,但掉不到 Go 的 106------堆、GC 结构、运行时映射都还在,跟 JIT 无关(完整数字见 4.4 节)。

同一份 native 代码,GC 从 Serial 换成 G1,RSS 从 95 涨到 219,多付 124 MB。这 124 MB 挂在 G1 这一侧,但我没有把它拆开 ------native image 不支持 NMT,jcmd VM.native_memory 直接报 AttachNotSupportedException。能说的是:同机 JVM 侧的 G1 结构实测只有 39~47 MB,124 MB 明显超出这个量级,说明 native 上的 G1 还有本文没测到的额外开销。

Go 那边反过来:静态链接、运行时不加载类元数据也不生成机器码,所以它的 105 MB 是一个常数。GOMEMLIMIT 只能在这个常数附近微调,压过头只会让 GC 更勤快(32 MiB 时吞吐掉 11%)。

一句话:Java 网关光固定开销就 194 MB,而 Go 整个进程才 106 MB------这一块比对手的全部还多 83%。 这个差距不是选错 GC 造成的,也不是堆给多了,它在你决定"要有类加载器、要 JIT、要 GC 元数据"的那一刻就定死了。

还有一个容易被忽略的差别:负载停了之后。 同一组实验里,Go 把 RSS 从 106 MB 还到 38 MB;ZGC 从 743 MB 只回到 723 MB,G1 从 542 MB 回到 517 MB。堆提交出去就不还------这条在容器里意味着 limit 只能按峰值设。

5.3 p99 的差距,不是 GC 停顿带来的

这是全文最反直觉的一格,先把四家的牌摊开。

专项批(堆 2g)里有四个实现,各自的最大单次 GC 停顿和 p99:

实现 最大单次停顿 p99(丢前 2 秒)
Parallel 7.452 ms 7.292
G1 24.569 ms 7.637
ZGC 0.016 ms 7.812
Go 0.244 ms 10.798

停顿最长的 G1(24.569 ms)p99 是 7.637;停顿最短的 ZGC(0.016 ms)p99 是 7.812。两者停顿差 1536 倍,p99 只差 2.3%,而且方向反了。

p99 最低的一档是 Parallel------7.292,它的最大停顿是 ZGC 的 466 倍。

停顿和 p99 之间,没有单调关系。

主对照批(512m)复现了同样的形态:G1 的 p99 是 5.100,ZGC 是 7.619------停顿短的那个反而 p99 更高。

为什么?先算一个数:一次请求在网关里的平均驻留时间。

复制代码
并发 ÷ 吞吐 = 32 ÷ 10392 rps ≈ 3.08 ms

也就是说,一个请求从进来到出去,平均要排 3 毫秒的队。而 Go 的 GC 停顿是 0.244 毫秒------占驻留时间的 8% 。

再看 p99 那一格的实际差距:10.370 − 5.100 = 5.27 ms 。就算把 Go 那 0.244 ms 的停顿全额记到 p99 头上,也只够解释 4.6% 。

这里可以提炼出一条更一般的规律:停顿只在"分位阈值和停顿时长同量级"的地方留下痕迹。

  • p99 的阈值在 5~10 ms,而 ZGC(0.016 ms)和 Go(0.244 ms)的停顿都小于 0.3 ms------差一到两个数量级,什么也看不见;
  • 25 ms 这个阈值,和 G1 的 24.569 ms 停顿是同一量级------所以 G1 在那里露了头(专项批里 G1 有 373 个 >25 ms,Go 只有 1 个)。

换句话说,不是"停顿没用",是"停顿的量级要和你想改善的那个阈值对上"。 想改善 p99,得先有毫秒级的停顿;而毫秒级的停顿,恰恰是 ZGC 已经消灭掉的东西------它想改善的是 p99,可 p99 的阈值比它的停顿高了两个数量级。

5.4 p99 领先的那 5.27 ms 从哪来,我只测到一半

主对照批里,JVM+G1 的 p99 比 Go 低 5.27 ms,比 native+G1 低 2.52 ms。能排除的是:

  • 不是 G1 的停顿:G1 的停顿(24.6 ms)比 Go(0.244 ms)长两个数量级,p99 却更低。停顿的方向和 p99 的方向是反的。
  • 不是 cgroup 冻结:这一批用的是绑核(核 0-1),不是 cfs 配额,不存在周期性冻结。
  • 不是内存压力:ZGC 30 秒内只有 37 次 GC、0 次 allocation stall。

剩下的候选方向是 JVM 的非 GC 停顿(safepoint、去优化、代码缓存、页错误)与运行时调度差异。要定论需要 JFR 的 safepoint 事件或 async-profiler 的 wall-clock 采样------那是下一轮实验的事。这一格我只报数,不报原因。

六、这张图怎么用

七个维度不是让你取平均分,而是让你找到自己的约束落在哪一格:

你的处境 该选谁 依据
网关是边缘入口/纯转发,副本几十上百 Go 内存 106 MB、启动 63 ms 都是第一,每请求 CPU 与 Java 并列
延迟 SLO 卡在 10 ms 以内,且扩容不频繁 Java + G1 p99 领先 2.03 倍,>10 ms 占比只有 Go 的 1/5
要秒级弹性、按内存计费、故障要能重建 Go 或 native 63 ms 与 75 ms,比 JVM 快 70 倍以上
又要快启动、又要深尾部可控,能接受吞吐降三成 native + G1 启动 75 ms、max 17.1 ms、>25 ms 零个;代价是 rps 0.69×
团队全栈 Java,要与 Spring 生态深度集成 Java 语言切换的隐性成本大于任何基准差异
网关里塞了大量业务逻辑(鉴权、聚合、灰度) Java 逻辑越重 JIT 越有用,本文结论越不适用
需要 JFR / APM 的持续可观测 Java native image 下 jcmd/jstack 全失效

还有一条雷达图量不到、但同样真实的差别:运维成本。 Go 网关是一个静态二进制加两个 GC 旋钮;JVM 网关要决定堆大小、选 GC、看元空间、考虑 GC 日志与 OOM dump,还要记住 -Dreactor.netty.ioWorkerCount 这个默认 4 的坑。native 则是第三种麻烦:构建 6 分半、要吃 5 GB 内存,参数还静默失效------它把运行时的复杂度搬到了构建期。

最后要说清楚本文的边界:这里测的是"转发型网关" ------请求进来、改几个头、写一行日志、出去。业务逻辑越重,Java 的长期优化能力越能发挥,本文的结论越不适用。这不是"Java 不适合做网关",而是"转发型负载下,三条路线各有一条明显短板,谁也没法全拿"。

七、带走三句话

第一,七格里没有一条线全在圆外,三处最深的凹口还落在不同的格子上。 Go 凹在 p99(2.03 倍)和 >10 ms 占比(4.98 倍);Java+G1 凹在启动(83.9 倍)和内存(5.25 倍);native+G1 凹在吞吐(0.69 倍)。选型就是选你愿意在哪一格认输。

第二,JIT 的账这次单独标了价。 同一个 G1,JIT 在不在,p99 差 33%、吞吐差 48%、CPU 差 32%、内存差 2.5 倍、启动差 70 倍。前三条买得到,后两条买不到------这就是为什么"用 native 换启动"不是免费的。

第三,停顿改善 p99 这件事,需要量级对得上。 停顿 0.016 ms 的 ZGC 和停顿 24.569 ms 的 G1,p99 分别是 7.812 和 7.637;主对照批里 G1 的 p99 甚至比 ZGC 低 33%。落到主对照批那 5.27 ms 的差距上(10.370 − 5.100),Go 自己的停顿最多只能解释 4.6%。想靠压停顿改善 p99,先看看你的停顿和阈值差几个数量级。

选型不是挑"哪个指标更漂亮",而是问一句"我的约束落在哪一格,那一格差多少倍"。七个维度量一遍,答案通常比直觉朴素得多。


测试环境:OpenCloudOS 8.10 / 4 vCPU / 7 GB / kernel 5.4.241,CPU 型号被虚拟化隐藏为 General Processors(支持 AVX2 + FMA,无 AVX-512)。

工具链:go1.27.1;Temurin JDK 25.0.4+7(编译目标 21);GraalVM JDK 25.0.4+7(native image 专项)。

被测:Traefik 3.7.14(GOMAXPROCS=2);Spring Cloud Gateway 4.3.5 + Spring Boot 3.5.16(G1 / 分代 ZGC / Parallel);GraalVM native image(Serial GC / G1 两个版本)。JVM 与 native 均设 -XX:ActiveProcessorCount=2 且 -Dreactor.netty.ioWorkerCount=2。

方法:三层绑核(上游=核 2、网关=核 0-1、压测器=核 3);两侧活动处理器数与 event loop 线程数均对齐到 2(按 /proc//task/ /comm 的 reactor-http-epoll- 线程名实测核对);职责逐项对齐(同上游、同 header 注入、同 JSON 访问日志,日志体量 1108 vs 9971008 字节/请求);预热 20 s + 测量 30 s,并发 32;GC 统计只取测量窗内新增日志;所有延迟数字丢前 2 秒重算(依据:132 个历史用例中 74 个 JVM 用例有 63 个降幅超 5%,58 个 Go/native/直连用例 0 个受影响);并发 32 的全部用例两侧 CPU 均打满 1.921.98 核,故 rps 比值即每请求 CPU 成本比值。

主对照批:五家(Traefik / SCG+ZGC / SCG+G1 / native+Serial / native+G1)同一脚本连续跑完,堆统一 -Xms512m -Xmx512m。

噪声基线:同配置三次 8438 / 8871 / 9066 rps(极差 7.1%),1 核配额三次 7200 / 7203 / 7262(0.9%),max 同配置三轮极差 106%~385%(故不用于排名)。

完整原始数据与复现脚本:drafts/gw-bench/(RESULTS.md · rungw.sh · run_native_cmp.sh · run_heap.sh · mem_decomp.sh · run_aot.sh · run_dim2.sh · baseline_noise.sh · recalc_head.py · analyze**.py · results/ · conf/ · scgw/ · upstream/)

参考:xpbob.site

相关推荐
用户02478365309730 分钟前
从屎山到防线:14 行状态迁移表,给任务系统装上防瞎跳闸门
后端
CQU_JIAKE44 分钟前
11.13[体系结构】指标,五级流水线,八级流水线,虚实地址思考
后端
panbook3 小时前
从0到1实现一个分布式时序数据库(三):主从复制、故障检测与切换
后端
上线再说3 小时前
图片从 8.2MB 压到 296KB:我把小程序图片链路的账单拆了一遍
后端
dpharness3 小时前
我原以为 MCP 装完就能用,直到工具列表是空的
后端
dpharness3 小时前
153 个 MCP 服务,我把工具数一个个数了一遍
后端
打工仔折腾 AI5 小时前
Changedetection.io 网页变动监控:Docker 部署、通知配置与公网暴露实践
运维·人工智能·后端·python·docker·容器·性能优化
SomeOtherTime6 小时前
Rust 闭包语法和原理(AI回答)
开发语言·后端·rust