先说个凌晨两点的故事
前年 11 月我接了个电商后台的活,技术栈是我自己拍的:NestJS + TypeORM + PostgreSQL。理由很正当——团队三个人都是前端出身,一套 TypeScript 写到底,学习成本低。前三个月确实爽,标准 CRUD 一天出五个接口。
转折出现在「用户列表,要带每个人最后一次下单时间」这个需求上。SQL 本身六行就完了:
SELECT u.id, u.name, o.created_at
FROM users u
LEFT JOIN LATERAL (
SELECT created_at FROM orders
WHERE user_id = u.id ORDER BY created_at DESC LIMIT 1
) o ON true;
TypeORM 的 QueryBuilder 表达不了 LATERAL,用 relations 加载会把该用户历史上所有订单全拉回来——有个大客户下了 3 万多单,那个接口直接把 P95 从 120ms 干到 1.6 秒。最后我退到 dataSource.query(),返回 any[],TypeScript 在这里彻底断了链。
这件事让我想明白一个之前没认真想过的问题:评估一个后端框架,不该看它做容易的事有多轻松,要看它做难的事有多贵。
我到底测了什么
网上那些排行(TechEmpower 之类)我不是不信,是跟我的场景对不上。所以我自己跑了一轮,环境写清楚,你要复现也就半小时:
- 机器:阿里云 ecs.c7.xlarge,4 核 8G,Ubuntu 22.04,Docker 24.0
- 压测:wrk -t4 -c256 -d60s,每个跑三轮取中位数
- 接口:
GET /ping返回{"ok":true},不碰数据库,纯框架开销
结果(req/s 和 P99):
| 框架 | 吞吐 | P99 | 空闲 RSS | 冷启动到首个响应 |
|---|---|---|---|---|
| Axum 0.7 / Rust 1.76 | ~118,000 | 4.2ms | 9MB | 0.04s |
| Gin 1.9 / Go 1.22 | ~96,000 | 6.8ms | 14MB | 0.03s |
| Fastify 4 / Node 20 | ~44,000 | 11ms | 78MB | 0.18s |
| NestJS 10 + Fastify 适配器 | ~31,000 | 15ms | 112MB | 0.9s |
| Spring Boot 3.2 / Java 21 平台线程 | ~22,000 | 19ms | 310MB | 1.8s |
| Spring Boot 3.2 / Java 21 虚拟线程 | ~26,000 | 17ms | 320MB | 1.9s |
| NestJS 10 + 默认 Express | ~8,700 | 34ms | 105MB | 0.8s |
几个我自己也没想到的点:
第一,NestJS 默认用 Express,和换成 Fastify 适配器差了 3.5 倍。很多人吐槽 NestJS 慢,其实慢的是 Express 那一层,换适配器只改两行代码(main.ts 里 NestFactory.create 换成 FastifyAdapter),但这一改很多基于 Express 的中间件就得重写。
第二,Spring Boot 开虚拟线程只涨了 18%,没有传说中那么神。因为我的测试接口根本没阻塞操作,虚拟线程解决的是 IO 等待,不是 CPU 计算。你要是压测里塞个 Thread.sleep(50),数字会完全反过来。
第三,Axum 和 Gin 差了 20% 左右,但这个差距在你接上数据库之后基本就消失了——一个 Postgres 查询轻松就是 2-5ms,抵得上几十万 QPS 的框架开销。
两个被严重低估的指标:内存和冷启动
上面表格里我最在意的其实是后两列。
内存:Spring Boot 一个空服务 310MB,NestJS 112MB,Go 14MB,Rust 9MB。你如果上 Kubernetes,按 8 个副本算,Java 那套光空闲就要吃掉 2.5GB,Node 那套 900MB,Go/Rust 加起来不到 200MB。按 4C8G 每台 300 块一个月算,这个差别一年是几千到上万块。对小团队来说这比 QPS 实在。
冷启动:Serverless 场景这是生死线。AWS Lambda 上,Java 冷启动常态 800ms-3s,Node 100-400ms,Go 通常 <50ms。Spring Boot 3.2 之后可以用 GraalVM native image 把启动压到 90ms 左右,但代价是构建时间从 40 秒涨到 3 分 20 秒,而且反射、动态代理这些要一个个写配置——我那个项目有 6 个 starter 需要额外处理,折腾了整整两天。
「逃生舱测试」:我现在评估框架只用这一套
踩坑踩多了,我总结了一个土办法。核心就一句话:别测框架帮你做的事,测框架不让你做的事。具体三步:
第一步,查询层测试。拿你业务里最变态的那条 SQL(带 CTE、窗口函数、LATERAL 的那种),分别用框架推荐的 ORM 路径写一遍。记录三件事:写了几行、丢没丢类型、能不能进单元测试。
Spring Data JPA 我给的评分是 6 分:JPA 表达不了的时候有 @Query 和 nativeQuery 兜底,但分页 + 动态条件拼起来很难看。TypeORM 5 分,queryRaw 一用类型就没了。Prisma 7 分,$queryRaw 有类型但那套 Tagged Template 得重新学。Go 的 sqlc 我给 9 分——SQL 即源码,生成类型,比任何 ORM 都诚实。
第二步,事务层测试。设计一个场景:写两张表 + 一次外部 HTTP 调用,要求要么全成功要么全回滚。看框架给你几条路。这里 Spring 的 @Transactional 要注意自调用失效(同类内部方法调用不走代理),我见过至少三个项目栽在这上面,而且不报错,静默不回滚。
第三步,协议层测试。想象一个不符合 REST 规范的接口:SSE 长连接 + 自定义 header + 分块响应。Go 和 Rust 直接写,Node 要用 res.raw,Spring 要返回 SseEmitter 或者干脆上 WebFlux。这一步最能看出框架的边界在哪。
三步跑完,你手里就有了一张「抽象泄漏成本表」,比看一百篇对比文章都管用。
一个反直觉的结论
我的结论可能跟你想的相反:框架的技术债不是负债,是期权。
你选 Spring Boot,买的不是它好写,是「以后遇到任何问题都能在 StackOverflow 上找到 2013 年的答案」这个权利。你选 Axum,是拿生态成熟度去换运行时确定性。你自研框架,相当于卖了一张行权价无穷大的期权。
所以选型的关键问题不是「哪个快」,是「半年后那个接手代码的人,第一次提交生产代码要多久」。这个问题有个操作性很强的近似答案:看这个框架的官方文档,能不能在 20 分钟内让你写出一条带事务、带校验、带错误处理的完整接口。Spring Boot 我用了两年,写第一条接口花了 40 分钟(光搞清楚 starter 依赖就够呛)。Fastify 我没用过,照着文档 12 分钟跑通了带 JSON Schema 校验的接口。
那到底怎么选
写个决策流程,你可以直接抄:
- 团队 ≤5 人,业务规则复杂,交付期紧 → Spring Boot 或 Django。慢没关系,业务复杂度才是主要矛盾,别在基础设施上省时间。
- 做 BFF、边缘聚合、或者要上 Serverless → Fastify 或 Hono。冷启动和内存优势明显,Node 生态的东西拿来就用。
- 团队有 3 年以上 Go 经验,服务边界清晰 → Gin / Echo。部署简单到离谱,一个二进制扔上去就跑,内存占用低到可以忽略。
- 需要极低尾延迟(金融、实时);或者团队能接受编译期心智负担 → Axum。但要提前想清楚:你会为 trait bound 报错过至少两周。
附:我踩过的 5 个坑和排查命令
- Spring Boot 启动 4 秒以上:
java -jar app.jar --debug,看 auto-configuration report,把没用到的 starter 一个个删。我删掉 spring-boot-starter-data-redis 之后启动从 4.2s 降到 2.1s。 - NestJS 内存涨到 1G 不降:
node --inspect挂 chrome://inspect,做两次 heap snapshot 对比。我上次是 EventEmitter 上的 listener 没解绑。 - N+1 查询:Django 用 select_related / prefetch_related,Spring 用 @EntityGraph 或 join fetch。别凭感觉,开
spring.jpa.show-sql=true数 SQL 条数。 - Fastify 启动慢:JSON Schema 是 JDK 式按需编译的,schema 多的时候首启动会慢,可以预热一次请求。
- Go 里 goroutine 泄漏:
go tool pprof http://localhost:6060/debug/pprof/goroutine,看哪些协程卡在 channel 上。
最后说句实话:性能测试看看就行,别当真。真正让项目失败的从来不是 p99 多了 5ms,是三个月后没人敢改那段代码。选框架的时候,多想想那个人。