后端框架选型对比:Spring Boot 3.2 / Go Gin / FastAPI,我调了三天 p99 发现锅不在框架上

🔑 关键词:后端框架选型,Go Gin,Spring Boot 3.2,FastAPI,数据库连接池

📖 摘要:从一个真实 p99 事故出发,对比 Spring Boot 3.2、Go Gin、FastAPI 的并发模型、内存占用和默认参数,包含 Go database/sql 的坑、JDK 21 虚拟线程 pin 问题,以及一套可以照抄的排查步骤。

上周三凌晨一点,我盯着 Grafana 上那条从 80ms 爬到 1.2s 的 p99 曲线,CPU 只有 30%,内存没爆,日志里连个 error 都没有。这套服务是半年前从 Django 迁到 Go Gin 的,当时的理由特别朴素:同一台 4C8G 的机器,Django 单接口压到 600 QPS 左右就开始飘,Gin 随手就能上 9000。结果上线三个月,性能事故反而比用 Django 那会儿多了。那天晚上我在 pg_stat_activity 里看到 180 多个来自同一个 Pod IP 的连接,而 PostgreSQL 的 max_connections 默认是 100。说实话第一反应是「PG 太弱了」,第二反应才是翻回去看 Golang 那边的 sql.DB 配置,然后就安静了。

图片

三个框架的并发模型,一张表真塞不下

先说能查证的默认值。Spring Boot 3.2 的 Tomcat,默认 maxThreads=200、acceptCount=100,意思是同一时刻最多 200 个请求在跑,超出的排进 100 长度的等待队列,再满就直接拒绝。每个平台线程在 Linux x64 上的栈默认 1MB,由 -Xss 控制,这是虚拟内存不是 RSS,但你也别真当它不存在。Java 21 之后可以打开 spring.threads.virtual.enabled=true,把请求丢给虚拟线程处理,载体线程池的默认大小等于 CPU 核数,注意是核数,不是 200。

Go 那边完全是另一套逻辑。goroutine 初始栈 2KB,按需增长到 1GB 上限,几十万个同时挂着很正常。所以「Go 高并发」这句话是对的,但它高的是——能同时挂着很多个请求,不等于每个请求跑得快。Gin 的路由和 Context 对象确实做得好,但你的请求最终快不快,取决于 handler 里干了什么。

FastAPI 或者任何 ASGI 框架,本质是单进程事件循环。uvicorn --workers 4 就是四份独立进程,每个进程一个循环。你要是在 async def 里写了 requests.get(),或者用了 psycopg2 的同步查询,整个 worker 就被钉死,其他请求干等着。我见过一个团队把 Django 的同步代码直接搬进 async def,随手加了几个 await 关键字,然后说 FastAPI 性能不如 Django,这事真不能怪 FastAPI。

图片

内存和启动时间:我本地跑的一组数

Docker 里测的,4C8G,JDK 21.0.3、Go 1.22.2、Python 3.11.8,接口只返回一个 JSON,不碰数据库。wrk 的客户端和被压服务在同一台机器上,所以绝对 QPS 没什么参考价值,看相对关系就行:

框架 启动到可服务 稳定后 RSS 相对吞吐
Spring Boot 3.2.5(平台线程,-Xmx256m) 约 2.1s 约 340MB 1x
Spring Boot 3.2.5(虚拟线程开启) 约 2.2s 约 360MB 1.3x
Go 1.22 + Gin 约 12ms 约 14MB 3.5x 到 4x
FastAPI + uvicorn 单 worker 约 0.9s 约 75MB 0.8x

注意最后那一列我给了个区间而不是精确值,因为同机压测测到后面客户端自己就成了瓶颈,数字抖得厉害。但有一点很明确:这些差距在接入真实数据库之后会被大幅吃掉。我后来拿同一套 SQL、同一份数据做了对照,Gin 版本和 Spring Boot 版本的 p95 只差 20ms 上下。

图片

真正决定 p99 的是排队,不是框架

复盘那晚的 1.2s,来龙去脉是这样的:Go 的 database/sql 有两个特别反直觉的默认值——SetMaxOpenConns 默认是 0,含义是「不限制」,SetMaxIdleConns 默认是 2,SetConnMaxLifetime 默认也是 0(永不过期)。于是流量一上来,我的服务开了 180 多条连接,PG 那边 max_connections=100 根本接不住,建连本身有开销,加上超时重试,单个请求的延迟被拉长;延迟一拉长,按小定律 L = λW,需要同时在处理的请求数就变多,又要更多连接,正反馈,直到彻底饱和。

小定律量化一下更直观:500 QPS、平均延迟 20ms,同时在处理的请求是 10 个。同样是 500 QPS,延迟涨到 200ms,同时在处理的就变成 100 个。这就是为什么 p99 一恶化,你总会看到连接数暴涨——它在很多时候是结果,不完全是原因。

我把 MaxOpenConns 改成 20、MaxIdleConns 改成 20、ConnMaxLifetime 设成 30 分钟,p99 从 1.2s 掉到 90ms 左右。为了验证框架到底占多少权重,我又用 Spring Boot 3.2 重写了一版同样的业务,HikariCP 默认 maximumPoolSize=10,p99 稳定在 110ms 上下。框架换了,差 20ms;连接池参数没调,差 1.1 秒。

虚拟线程那个坑,跟 synchronized 有关

图片

我们有个风控服务试过切虚拟线程,spring.threads.virtual.enabled=true 一行搞定。压测结果很打脸:p99 从 70ms 涨到 400ms。加了 -Djdk.tracePinnedThreads=full 启动,日志里刷出一堆 pin 住的栈,最后定位到一个内部 SDK,方法上挂着 synchronized,里面还有个几十毫秒的网络调用,x 秒级的写文件。

原理不复杂:JDK 21 里 synchronized 会让虚拟线程 pin 在载体线程上,而载体线程只有 CPU 核数那么几个,pin 一个少一个,很快就排满。这个问题在 JDK 24 的 JEP 491 里才算解决,synchronized 不再 pin 载体线程。所以如果你现在跑在 JDK 21 上开虚拟线程,先去依赖里搜一遍 synchronized 和 Object.wait,ReentrantLock 才是安全的那一类。

我现在怎么选

判断标准被那次事故改成了这样一句话:先数清楚请求路径上有几个阻塞点,再看这些阻塞点能不能被池化复用。

图片

IO 密集、下游就一两个数据库——连接池调好比换框架重要得多,Go、Java 虚拟线程、Node 都能打。业务逻辑重、团队五个人以下、要求快速迭代——Spring Boot 的生态值那份内存,别为了省 300MB 去重造轮子。CPU 密集型的场景,比如加解密、图像处理、风控规则引擎,框架基本无所谓,语言和 JIT 才是变量,这时候 Go 和 Java 的差距反而比 W 和 G 之间小。需要快速验证、模型部署、数据处理脚本化——FastAPI 的开发速度没得说,但你得接受它单 worker 的天花板。

排查步骤,照着敲就行

数据库侧先看清楚水位:

SHOW max_connections;
SELECT state, count(*) FROM pg_stat_activity
WHERE datname = current_database() GROUP BY state;

图片

Go 这边,db.Stats() 里最容易被忽略的是 WaitCount 和 WaitDuration,它们是连接池排队次数和排队总时长,比 CPU 指标有用得多:

db.SetMaxOpenConns(20)
db.SetMaxIdleConns(20)
db.SetConnMaxLifetime(30 * time.Minute)
stats := db.Stats()
log.Printf("open=%d inUse=%d idle=%d waitCount=%d waitDur=%s",
    stats.OpenConnections, stats.InUse, stats.Idle, stats.WaitCount, stats.WaitDuration)

Java 侧打开 actuator,看 hikaricp.connections.pending 和 hikaricp.connections.acquire 两个指标,pending 一旦持续大于 0,说明请求在等连接,加机器没用,先调池子。

最后说个我到现在也没完全想明白的点:那次改完参数后,我又把 MaxOpenConns 从 20 调到 50 试了一遍,p99 反而涨了 15ms。PG 的连接不是免费的,每个连接在后端都是一个进程,上下文切换的成本是实打实的。所以「连接池越大越好」这句话,跟「框架越快越好」一样,都是没被数据打过的脸。

🏷️ 标签: