Java 虚拟线程、Go goroutine、Node 事件循环,同一个订单服务我写了三遍,踩的坑一次比一次阴

🔑 关键词:虚拟线程,goroutine,UV_THREADPOOL_SIZE,pinning,后端并发选型

📖 摘要:同一个登录/订单服务用 Node.js、Go、Java 21 虚拟线程各实现一遍,记录三次生产事故的真实排查过程:libuv 线程池被 bcrypt 占满、goroutine 泄漏、虚拟线程被 synchronized 钉住,以及为什么这些都不是真正的瓶颈。

先说 Node 那版,崩得最没道理

图片

去年冬天一个周二,凌晨两点多,登录服务开始大面积超时。监控上 QPS 没变,CPU 只有 20% 出头,但 P99 从 60ms 涨到了 2.8 秒。服务是 Node.js 18 + Express,接口里干的事很简单:查用户,然后 bcrypt 比对密码。

问题不在 bcrypt 本身,而在 libuv 的那个线程池。它的默认大小是 4,只能通过环境变量 UV_THREADPOOL_SIZE 改。这个池子不只服务 bcrypt 的密码哈希,fs.readFilefs.statdns.lookupcrypto.pbkdf2 全挤在里面。登录高峰期每秒几百次哈希进来,4 个线程瞬间排满,后面的请求连 DNS 解析都得排队。当时的现场很好验证:strace -f -e trace=clone 数一遍,只有 4 个线程在干活,其余全在 futex 上睡着。

我们当时把 UV_THREADPOOL_SIZE 调到 64,又用 PM2 cluster 起了 8 个进程,等于把池子摊到 8 份。但这只是续命。真正的坑是很多人不知道:dns.lookup 走线程池,dns.resolve 走 c-ares 异步,而大量 HTTP 客户端库默认用前者。你写 await fetch(...),以为自己是异步非阻塞,实际在 4 个线程后面排队。这一条我后来在三个团队里都讲过,每次讲完都有人回去翻自己的代码。

图片

换成 Go 之后,问题变得更隐蔽

后来我把这个服务用 Go 重写了一遍,主要原因就是不想再跟人解释 UV_THREADPOOL_SIZE 是怎么回事。重写完第一版跑得挺顺,直到三个月后,某台机器内存慢慢涨到 6G,重启就好,过几天又涨。

go tool pprof http://ip:6060/debug/pprof/goroutine?debug=2 拉下来一看,11 万个 goroutine 卡在 net/http.(*persistConn).readLoop。原因朴素得让人想笑:我们有个下游服务假死,只收连接不返响应头,而我们自己 new 了一个 http.TransportResponseHeaderTimeout 是零值,也就是无限等。keep-alive 连接一直挂着,goroutine 就一直等。默认的 DefaultTransport 好歹还有 IdleConnTimeout: 90 * time.Second,我们自己造的那个什么都没有。

图片

Go 的 goroutine 初始栈是 2KB,从 Go 1.4 起就是(之前是 8KB),11 万个也就两三 G,表面看着不致命。但那台机器 GOGC 是默认的 100,堆一大,GC 扫描压力跟着上去,延迟毛刺就出来了。我学到的教训是:goroutine 泄漏比线程泄漏难发现得多,因为它太便宜了,便宜到你潜意识里觉得多开点没事。后来我们把 runtime.NumGoroutine() 打进 Prometheus,规则很简单——图上只要是一条持续上升的斜线,就一定有地方忘了 cancel context。

Java 21 虚拟线程,第一版让我兴奋,第二版让我清醒

今年年初我用 Java 21 把同一个服务又写了一遍,动机很单纯:想试试虚拟线程值不值得把团队从 Go 拉回来。第一版结果非常漂亮,Executors.newVirtualThreadPerTaskExecutor(),同样的压测场景,P99 比 Go 版还低一截,代码也好写,同步的写法,从上到下读下来就是正常人的思维路径。我一度觉得这就是终局了。

图片

然后我撞上了 pinning。JDK 21 里,虚拟线程如果在 synchronized 块里阻塞(比如块里调了一次 HTTP),它不会被卸载,会一直占着底下的 carrier 线程不放。carrier 的数量默认等于 CPU 核数,可以用 jdk.virtualThreadScheduler.parallelism 调。8 核的机器上就 8 个 carrier,只要有几个虚拟线程被钉住,整个调度器就开始瘸,延迟曲线会出现那种很难解释的台阶。

我们踩中的是一个第三方 SDK 里的 synchronized 方法,里面做了网络调用。诊断方式是加 -Djdk.tracePinnedThreads=full 重启,日志会把钉住的栈打出来,很长但一眼能认出来。解法就是换 ReentrantLock。顺带说一句,这事要到 JDK 24 的 JEP 491 才算从语言层解决,JDK 21 到 23 都得自己盯着。

另一个坑是 ThreadLocal。我们有个地方拿它缓存 SimpleDateFormat,平台线程池 200 个线程,最多 200 份副本;换成虚拟线程之后,一百万个虚拟线程就是一百万份。这不是理论问题,我们那个 8G 堆的服务因为这个 OOM 过一次,dump 出来一看全是格式化的临时对象。

图片

写着写着,我发现并发模型根本不是瓶颈

三遍写完,我最大的感受是:这几套模型在「处理并发连接」这件事上的差距,早就不是瓶颈了。真正卡住你的东西全在下面几层。

我们那个订单库 max_connections 是 200,HikariCP 的 maximumPoolSize 我设的 20,前面挂着 PgBouncer,transaction 模式,default_pool_size 我设的 50。这四个数字加起来决定了服务真实的天花板,跟上层开 1 万个还是 100 万个并发单元完全无关。第一次压测的时候我特别兴奋,看着 5 万个虚拟线程跑起来,结果 20 个数据库连接全在 wait,QPS 死死卡在 1200,怎么加虚拟线程都不动。

图片

所以现在我做选型,第一个问题不再是「用 Go 还是用 Java」,而是「这个服务的瓶颈预计会落在哪」。瓶颈在数据库连接,选什么语言都一样,还不如把那 20 分钟的争论时间拿去看一眼慢查询日志。瓶颈在下游 RPC 超时,要解决的是超时、重试、熔断策略,跟并发模型也不沾边。只有当你确认吞吐被并发单元本身卡住了——比如一个服务要维持 2 万个长连接,每个连接都要独立阻塞读——这时候模型选择才真的开始起作用。

如果一定要我给个粗糙的判断:连接数在几千以内、IO 比重高、团队 Java 背景重,虚拟线程的性价比最高,但记得查 synchronizedThreadLocal;长连接多、要极致控制内存、团队能接受显式错误处理,Go 更合适,但必须把 NumGoroutine 和 context 超时当硬性规范写进 CI;Node 我现在的定位很明确,它就是做 BFF 和 IO 编排的,别让它碰任何 CPU 密集的活儿,包括你以为很轻的密码哈希。

最后说句可能不太受欢迎的:我见过太多团队把选型的精力花在语言和运行时上,花在「Go 是不是比 Java 快」这种问题上,然后上线第一天被一个没设超时的 HTTP 客户端打穿。三个模型我都写过生产代码,它们都很好,也都能被一个 Timeout 没配的下游拖死。

🏷️ 标签: