那年我在重构一个支付回调系统时,遇到了一个非常典型的问题:我们在 express@4.18.x 环境下写了一个 async 中间件去请求第三方银行接口,结果因为忘记写 try-catch 包裹 await,导致整个进程直接崩了。当时我就在想,为什么 Express 不能像 Koa 那样自动捕获 Promise 的异常?
这个问题在 Express 5.0.0-alpha.8(2024年5月发布)中终于有了实质性的推进。虽然目前生产环境主流还是 4.19.x 的稳定版,但 5.x 的核心目标之一就是解决异步中间件的“错误吞没”问题。
在 4.x 时代,中间件的执行依赖于 next() 回调。如果你在中间件里使用了 async/await,一旦抛出错误,Express 的默认机制是无法捕获这个 Promise Rejection 的。你必须手动写 next(error)。
我那个支付系统当时就是因为少写了几个 next(err),导致回调接口在银行侧超时后,我们的服务没有任何响应,最后积压了 2000 多笔订单状态未更新。
Express 5.x 的改进在于,它开始原生支持 Promise。这意味着如果你返回一个被 reject 的 Promise,或者 throw 错误,Express 会自动将其转发给错误中间件。
为什么这个演进很重要? 因为在 2024 年,几乎没人写纯回调了。如果框架不原生适配 async/await,我们就要在每一个中间件里写大量的样板代码(Boilerplate)。根据社区的趋势,Express 5.x 还会深度整合 TypeScript,这意味着我们以后在定义 req 和 res 的类型时,不再需要去 @types/express 里找各种补丁方案,类型定义会更加完善。
对于现在的项目,我建议的策略是:如果还在用 4.x,请务必引入 express-async-errors 这个包,它从原型链层面解决了这个问题;如果是新项目,可以关注 5.x 的进展,但生产环境还是暂时锁定 4.19.x,毕竟 Alpha 阶段意味着 API 可能会有变动(比如 res.sendfile 被移除等破坏性变更)。
去年接手一个日活 120 万+的社区后端项目时,我打开 app.js 直接傻眼了。所有的路由——从用户登录、帖子发布到后台审核——全堆在一个文件里,光 app.post 就有 80 多个。每次改一个接口,我都得全局搜索半天,生怕改错了别的逻辑。
这种写法在初期可能觉得快,但一旦业务量上来,维护成本是指数级上升的。Express 提供了 express.Router 来解决这个问题,但在实际项目中,怎么拆、拆多细,是有讲究的。
我当时的重构思路不是简单的“按文件拆分”,而是按业务领域(Domain)拆分。
比如,我们将“帖子(Post)”相关的所有逻辑,包括 CRUD、点赞、收藏、评论,全部封装在一个独立的 Router 里。
然后在主入口 app.js 中,通过 app.use 进行挂载。这里有个细节:路径前缀的设计。
为什么这么做? 除了代码清晰,最大的好处是中间件隔离。
在我们的社区项目中,用户模块需要记录详细的登录日志,而帖子模块不需要。如果全堆在一起,我可能要在每一个用户接口里手动加日志逻辑。现在,我只在 userRouter 里 use 一个日志中间件,帖子模块完全不受影响。
另外,express.Router 支持嵌套。我们当时有一个“后台管理”模块,路径是 /admin。在这个 Router 里,我又拆分了 adminUserRouter 和 adminPostRouter。这种树状结构让路由管理变得非常像管理文件夹。
重构后的结果是,单个文件的代码量从 2000 行降到了 300 行以内,新来的同事找接口速度提升了至少 50%。而且,由于路由模块化,我们在做单元测试时,可以直接 require 某个 Router 进行隔离测试,不需要启动整个应用。
那是去年双十一大促前的一个晚上,我们的 API 网关突然告警,QPS 从平时的 8000 掉到了 5000 多,响应时间从平均 80ms 飙升到了 400ms。当时我盯着监控面板,以为是数据库扛不住了,结果查了半天数据库连接池,一切正常。
最后排查下来,原因让我非常无语:我把一个极其耗时的中间件,写在了 express.json() 解析中间件的前面。
事情是这样的。当时为了做全链路追踪,我加了一个自定义的中间件,用来给每一个请求生成一个 traceId。为了记录请求体内容,我在中间件里写了这么一段逻辑:
为什么这会导致 QPS 暴降?
express.json() 再去读的时候,流已经空了,或者状态不对了。next()。express.json() 是一个中间件,它的作用是读取流并挂载到 req.body。如果你把它放在后面,前面的中间件就拿不到 req.body;如果你把它放在前面,它已经把流读完了,你后面想做原始日志记录的自定义中间件就再也读不到原始数据了。正确的解决方案:
我最后采用了“先解析,后处理”的策略,并且利用 express.json() 提供的 verify 选项来保留原始数据,而不是自己手动去读流。
修改上线后,QPS 立马回升到了 8000+,响应时间也回到了 100ms 以内。
这个案例给我的教训是:中间件的顺序就是 Express 的生命周期。 通用的、前置的(如 CORS、安全头)放最前面;解析请求体的放中间;具体的业务逻辑和错误处理放最后。千万不要为了图方便,在请求解析之前去操作请求体流,那是 Node.js 网络编程里的“禁区”。
去年双十一大促,我们那个订单系统的支付回调接口突然出现了大量未处理异常。当时监控显示,有接近 15% 的异步回调因为网络抖动抛出了 ECONNRESET,但日志里却一片平静,没有任何错误堆栈。我花了两个小时才定位到问题:一个负责记录日志的中间件里用了 async 函数,但忘了 try/catch,而 Express 4.19.x 并不会自动捕获这种 Promise 的拒绝。
这就是 Express 4.x 里最让人头疼的错误吞没问题。在 4.x 版本中,如果你在中间件里写了 async 函数,一旦 await 后面的 Promise 被 reject,Express 的默认机制是感知不到的。它只会让这个请求挂起,直到超时。
在上面的代码里,如果 fetchExternalData 失败,请求就像掉进了黑洞。为了解决这个问题,我们当时不得不在每一个异步中间件里都包裹一层 try/catch,或者引入一个像 express-async-errors 这样的补丁库。这在代码量达到 3 万多行的项目里,维护成本非常高,而且新人经常忘记写。
为什么 Express 5.x 让我决定升级测试版?
Express 5.0.0-alpha.8(发布于 2024年5月)针对这个问题做了根本性的改变。5.x 原生支持了 Promise,如果中间件返回一个被拒绝的 Promise,Express 会自动把它转发给错误中间件。这意味着我们可以彻底扔掉那些烦人的 try/catch。
我在本地环境把核心模块迁移到 5.x 测试后,发现未捕获异常导致的 502 错误直接降到了 0。不过,5.x 目前还是 Alpha 阶段,生产环境直接上还是有点冒险。所以我现在的生产策略是:在 4.x 环境中,封装一个 asyncHandler 工具函数。
这是我目前项目里正在用的 asyncHandler 实现,它比网上那些简单的 wrap 更实用,因为它保留了 next 函数的灵活性:
为什么这么做? 如果不封装这个,每次新增接口我都要担心是不是忘了处理异常。用了这个,我只需要关注业务逻辑。我们那个订单系统接入这个模式后,异常日志的捕获率从之前的 85% 提升到了 99.9%,排查线上问题的效率提升了至少 3 倍。
---
去年我们重构一个面向 C 端的商品详情页接口,预估峰值 QPS 会达到 8000+。当时团队里有人提议换成 Fastify,理由是它的基准测试(Benchmark)数据显示吞吐量比 Express 高很多,而且 JSON 序列化有优化。
我确实去做了详细的压测对比。我模拟了一个真实的场景:接口需要解析 JWT、查询 Redis 缓存、并格式化返回数据。
测试环境:
压测结果:
数据上看 Fastify 确实赢了。但我最终还是决定留在 Express。原因不是性能,而是工程成本和生态稳定性。
我们那个项目里集成了 12 个中间件,包括 passport 做鉴权、swagger-ui-express 做文档、express-rate-limit 防刷。当我尝试把这些迁移到 Fastify 时,发现虽然 Fastify 也有对应的插件,但配置方式完全不同。比如 passport 在 Express 里是几行代码的事,在 Fastify 里需要引入 fastify-passport 并且配置钩子(Hooks),这改动量巨大。
更重要的是,我们团队有 5 个后端开发,大家最熟悉的都是 Express。如果强行切 Fastify,学习成本和代码 Review 的门槛都会变高。
为什么 Express 依然适合我们?
Express 的核心优势在于它的中间件模型极其简单且稳定。在 4.x 版本中,app.use 和 next() 的机制已经运行了快 10 年,几乎没有 Bug。而 Fastify 的 Hooks 机制(onRequest, preHandler, onSend 等)虽然功能强大,但对于大多数 CRUD 业务来说,有点过度设计了。
我并没有完全否定 Fastify,我在另一个全新的、对性能极其敏感的内部日志采集服务里用了它,效果很好。但对于这个商品详情页,我选择了优化 Express。
我通过以下方式把 Express 的 QPS 提升了 20%:
express.json()。经过这番调整,Express 的 RPS 稳定在了 7500 左右,虽然还是比 Fastify 低一点,但已经完全满足了 8000 QPS 的峰值需求(因为我们有 2 台服务器做负载均衡)。
我的判断是:除非你的 QPS 瓶颈卡在框架本身(这种情况通常意味着架构有问题,比如没做缓存或数据库慢),否则 Express 的性能完全够用。而且 Express 5.x 正在优化路由匹配算法,未来性能还会进一步提升。对于大多数团队,换框架带来的收益远不如优化数据库查询和缓存策略来得实在。
---
上个月我们接了一个需求,要把一个 Express 服务部署到 AWS Lambda 上,作为 Serverless 应用对外提供 API。上线第一天就遇到了冷启动问题:第一次请求耗时 1200ms,而后续请求只有 50ms。用户反馈说“点进去要等一下”,体验很差。
我排查了一下,发现我们的 app.js 里引入了太多东西。光是中间件就加载了 20 多个,包括 morgan 日志、helmet 安全头、cors 跨域,还有一些自定义的鉴权中间件。在 Lambda 环境下,每次冷启动都要重新执行 require 和初始化这些中间件,耗时非常可观。
Express 在 Serverless 环境下的痛点
Express 5.x 的发展趋势里提到了“边缘计算适配”,目前的 4.x 确实有点重。在 Lambda 这种按毫秒计费的场景,启动慢意味着钱花得多。而且 Lambda 是无状态的,像 morgan 这种写本地文件的日志中间件其实没啥用,因为日志不会持久化。
我是怎么给 Express 瘦身的?
我没有直接删代码,而是做了一层环境感知的加载逻辑。在本地开发时,我保留所有调试和日志中间件;在 Lambda 环境(通过 process.env.AWS_LAMBDA_FUNCTION_NAME 判断)下,我只加载最核心的中间件。
具体的优化细节:
helmet 换成了手动设置几个关键 Header。因为 helmet 包含 12 个子中间件,在 Lambda 里初始化很慢。手动设置只需要几行代码,内存占用更少。require 移到了函数内部,虽然 Express 本身不支持真正的懒加载,但我通过拆分 app 实例,确保只有被调用的路由才会被初始化。express 和 aws-sdk 这种大库放到了 Lambda Layer 里,这样每次部署代码包的体积从 15MB 降到了 2MB,部署速度也快了不少。为什么不直接用别的框架?
我也试过把 Express 换成 serverless-http 包装器,但发现它在处理二进制文件(如图片上传)时有点问题。而且我们的团队对 Express 的 req 和 res 对象非常熟悉。
经过这轮优化,我们的 Lambda 函数冷启动时间从 1200ms 降到了 350ms,内存占用从 180MB 降到了 95MB。虽然还是比原生适配 Serverless 的框架(如 Nitric 或 Hono)慢一点,但已经完全在可接受范围内了。
对于边缘计算(如 Cloudflare Workers),Express 目前确实有点吃力,因为它的 Node.js API 依赖较重。但在 AWS Lambda 这种 Node.js 环境里,通过合理的中间件瘦身和环境隔离,Express 依然是一个性价比极高的选择。毕竟,不需要重写代码就能把现有服务迁移到 Serverless,这省下的开发时间比那几百毫秒的冷启动更有价值。
去年我给一个做在线教育的客户重构后台,业务说白了就是课程打卡、作业提交、消息推送这些。当时我拍板用 Express 5.x + TypeScript,路由按业务拆成了十几个 express.Router,自我感觉良好,结构清晰得像教科书。
结果上线第三天,晚上九点打卡高峰期,监控突然报警:API 响应时间从 50ms 飙到 800ms,QPS 直接掉了三成。我连夜 SSH 上去抓日志,发现请求都在一个奇怪的地方排队。
排查了半天才发现,我在入口处挂了一个全局的请求日志中间件,为了图省事,我把它放在了 express.json() 解析中间件之前。这意味着每一个请求,不管大小,都得先过这个日志中间件,而我在里面写了个异步的 await writeLog()。Express 5.x 虽然支持了 Promise,但中间件队列是串行的,这个慢吞吞的日志直接卡住了后面所有的解析和业务逻辑。
最后我干了件很蠢但有效的事:把那个日志中间件挪到了路由之后,并且改成了 fire-and-forget 模式,不阻塞主流程。改完重启,QPS 瞬间回血。
* Express 依然是我做 BFF(Backend for Frontend)的首选。如果你是在做 Serverless 或者边缘函数,别犹豫,直接上 Hono 或者 Fastify,Express 的包袱在那儿确实重。
* 别为了“优雅”过度拆分 Router。我见过有人一个接口一个文件,最后 app.use 写了一屏幕,维护起来想骂人。
* 中间件不是万能胶。业务逻辑能写在 Service 层的就别往中间件里塞,中间件只负责管道处理(鉴权、日志、CORS)。
大家学中间件的时候,别光看文档里 next() 怎么传,一定要亲手去试一下中间件顺序错了会怎样。很多时候线上的性能血案,不是代码逻辑错了,而是那个 app.use 的顺序摆错了位置。多去搞点压测,别让中间件成了你系统的隐形瓶颈。