同一个订单服务我写了三遍:Spring Boot、NestJS、Go 的真实体感,以及 12 个没人告诉你的坑

🔑 关键词:后端框架选型, Spring Boot, NestJS, Go Gin, 框架性能对比

📖 摘要:把日请求 400 万的订单状态同步服务用 Spring Boot 3.2、NestJS 10、Go 1.22 各重写一遍,同机器同压测,记录内存、P99、崩溃模式和实际踩坑,顺便聊聊选框架时比性能更该看的两个指标。

先说这服务是干嘛的

图片

2021 年我接手了一个内部订单状态同步服务,日均 400 万次请求,读写比大概是 1:9。最初的实现是 Django 2.2 + uWSGI,跑在 4 台 4C8G 的机器上,Python 3.7。2024 年初的时候 Python 3.7 已经 EOL 两年了,Django 那块没人敢动——原作者离职三年,测试覆盖率 11%,唯一的文档是一个 Confluence 页面,最后更新日期是 2020 年 8 月。

所以这次我的任务不是"优化",是"重写",而且要能灰度。我给自己定了个规矩:同一个服务,用三套框架各写一遍,跑同样的压测,看同样的 Grafana 面板,谁崩了怎么崩的都记下来。三套是 Spring Boot 3.2(Java 21)、NestJS 10(底层换成 Fastify)、Go 1.22 + Gin。FastAPI 我也起了个头,第三周停了,后面单独说为什么。

业务逻辑不复杂:读一次 Redis 拿当前状态,跑状态机校验,写一条 PG 记录,投一条 Kafka。三种框架写出来的代码量差不多,都是 800 行上下,唯一显著的区别是 Go 那份我多写了 200 行的错误处理。

性能数字,以及为什么我不建议你太当真

压测机器是阿里云 ecs.c7.xlarge,4C8G,PostgreSQL 15 在同 VPC 的另一台机器上,Redis 7.0 单实例。用 wrk2 打,5 分钟一轮,目标速率固定 1200 RPS,这个数字接近我们真实峰值。

结果大致是这样:

图片

  • Go + Gin:常驻内存 18MB,P99 8ms,CPU 峰值 41%。
  • Spring Boot 3.2 + JVM:堆上限设 512M,实际常驻 380MB 左右,P99 22ms,CPU 峰值 63%。启动时间 2.1 秒(我数过,冷启动到 ready 大概 2 秒多一点)。
  • NestJS 10 + Fastify:常驻 130MB,P99 31ms,CPU 峰值 55%。
  • Django 老版本(对照组):常驻 260MB,同样速率下 P99 是 110ms,而且 4 台机器跑起来 CPU 就上到 80% 了。

我不建议你把这张表当成选型依据,原因很直接:这里面的差异,在加了真实业务之后被稀释得厉害。我试过把 PG 的查询从一个走索引的变成全表扫,P99 立刻从 8ms 飙到 140ms,不管是 Go 还是 Java 都一样。数据库和网络 I/O 会把框架层面的性能差距抹平到几乎看不见,除非你在做网关、做实时竞价这种单请求极轻的活儿。

TechEmpower 的 Web Framework Benchmarks 每隔一段时间会更新一轮,那个榜单上 Go 和 Rust 系常年霸榜,Java 的 Vert.x 也能挤进前十,但你去看看它测的是什么——大部分场景就是返回一个 JSON 或者查一条固定记录。那是个微基准,不是你的业务。

比性能更该看的第一个指标:锁死半径

我造了个词,叫"锁死半径"——假设三年后你要换掉这个框架,你需要动多少东西。

Spring Boot 的锁死半径是最大的,但方向跟你想的不一样。它不锁你的业务代码(Controller 里那点东西换个框架也就重写两天),它锁的是 Spring Security、Spring Data JPA、Spring Cloud 那一整套周边。我在上一家公司试过把一个 Spring Cloud 微服务拆出来换成 Go,业务逻辑两天就搬完了,但是配置中心、服务发现、链路追踪、熔断降级这套东西,我花了三周才在 Go 生态里找到等价的组合,而且还不完全等价。

图片

NestJS 的锁死半径在编程范式上。装饰器 + 依赖注入 + 模块系统,这套东西一旦团队吃透了,写起来是真快。问题是你很难在 NestJS 项目里只用一个库——你会不自觉地用它的 Module、它的 Interceptor、它的 Guard,两年后回头看,代码库里已经没有"纯 TypeScript"了。

Go 的锁死半径几乎为零,代价是你得自己写一切。我在这个服务上用 Go 重写了三次中间件才找到一个能接受的日志方案。slog 是 1.21 才进标准库的,在那之前大家用的 zap、zerolog、logrus 各占山头,你选哪个就是哪种风格的日志。

第二个指标:崩溃模式

这个我觉得比性能重要得多,但几乎没人在选型的时候考虑。

Java 的崩溃模式是"缓慢腐烂"。它会先内存涨、GC 频率变高、P99 慢慢从 20ms 爬到 200ms,你有大概十几分钟的时间去看 Arthas 或者 jstack。这种崩溃其实挺友好的,虽然难看,但你能定位。我用 Arthas 追过一次线程池耗尽,thread 命令一敲,哪个线程卡在哪行代码一目了然。

Go 的崩溃模式是"当场去世"。panic 没被 recover 就是整个进程挂掉,Kubernetes 重启它,日志里留一份堆栈。好处是干脆,坏处是你得写好 recover 中间件,而且 goroutine 泄漏这种东西,pprof 不看是真发现不了。我踩过一次:Kafka 投递失败以后重试的 goroutine 没有超时控制,跑了 6 个小时积累了 4 万个 goroutine,内存从 18MB 涨到 700MB。

Node 的崩溃模式最尴尬,是"半死不活"。事件循环被一个同步操作堵住,进程还在,健康检查还过,但是所有请求都卡住了。NestJS 里一个不小心写成同步的 JSON 解析(比如在业务代码里对一个大对象反复 JSON.parse),整个服务就僵住了。Node 20 之后 async 的堆栈追踪好了很多,这个要承认。

图片

FastAPI 我为什么写到一半停了

不是因为性能,FastAPI 在这块没问题。是因为 Pydantic 的模型和 SQLAlchemy 的模型之间的转换层,在一个有 40 多个状态转换的业务里,心智负担比我预期的高。

具体说:我有 6 个状态机分支,每个分支对应的请求体字段部分重叠但不完全相同。用 Pydantic 我要么写 6 个模型加一个联合类型,要么写一个大模型加一堆 Optional。SQLAlchemy 那边还有一套 ORM 模型,两边字段名还得手动对齐——现在我知道可以用 model_config 和别名,但当时确实卡住了。

还有个更朴素的原因:团队里没人写过生产环境的 async Python。FastAPI 的 async 是真 async,但只要在 async 函数里不小心调了一个同步的库,整个 worker 就堵住了。这个坑我们没人有把握不踩。

所以我停了。不是因为 FastAPI 不好,是因为我们这个团队的实际情况不支持。

我最后选的,和为什么

图片

最后上生产的是 Go + Gin 那一版。

理由不是性能,是运维成本。原来 4 台 4C8G,现在 2 台 2C4G,一年省下来的钱大概够买一台不错的显示器。真正让我下决心的其实是另一个小事:Go 编译出来是一个 15MB 的静态二进制,扔进 FROM scratch 的镜像里就能跑,镜像 20MB。我们 CI 里拉镜像的时间从 40 秒变成了 3 秒。

但你千万别看到这个就去选 Go。我们团队一共 5 个人,2 个写过 Go,另外 3 个是 Java 背景。上线前一个月,我花了大概 12 个小时给团队讲 Go 的错误处理和 context 传递。如果团队里超过一半是 Java 背景,我可能就选 Spring Boot 了——它的可观测性是开箱的,Actuator 一加,Prometheus 端点就有了,Go 那边你得自己接 promhttp。

几个具体的坑,能省你几天

Spring Boot 3.2 的虚拟线程别急着开。Java 21 的虚拟线程(JEP 444)是正式的,但 Spring Boot 里 spring.threads.virtual.enabled=true 之后,你要小心用了 synchronized 锁的第三方库——虚拟线程碰到 synchronized 会 pin 住载体线程,性能反而下降。我当时测下来 QPS 掉了 15%,后来把 synchrnized 换成了 ReentrantLock 才回来。

NestJS 换 Fastify 记得装 @nestjs/platform-fastify,然后你的文件上传拦截器可能会失效,因为 Fastify 的 multipart 用的是 @fastify/multipart,跟 Express 的 multer 不是一个东西。这个坑我花了半天。

图片

Go 的 Ginc.Request.Body 只能读一次。如果你要在中间件里打日志读了一遍,到 handler 里就是空的。解法是 io.NopCloser(bytes.NewBuffer(body)) 塞回去,或者用 c.ShouldBindBodyWith

所有框架都一样的一条:健康检查接口别只返回 200。要真的 ping 一下数据库和 Redis,不然 K8s 会把流量打到一个数据库连不上的实例上。我见过两次。

灰度方案:我是用 Nginx 按 header 分流,10% 走新服务,观察一周。这个成本很低,值得做。如果你重写的是核心链路,千万别一把梭。

一句话的选型建议

如果你的团队是 Java 背景、业务复杂、需要一堆企业级能力——Spring Boot,别折腾。 如果团队是前端转全栈、业务 CRUD 为主、需要快速迭代——NestJS 挺好。 如果你要的是低运维成本、高并发、能接受自己造轮子——Go。 如果你在写数据分析和 ML 相关的接口——Python 生态绕不开,FastAPI 或者 Litestar 都行,但先把 async 那套想清楚。

框架没有对错,只有合不合适。这话听着像废话,但我用了三年才真明白。

🏷️ 标签: