在 Express 路由中处理嵌套异步查询时,最常见的故障不是语法错误,而是响应被发送了多次,或者错误已经返回给客户端但后续代码继续执行。出现这类现象,通常是因为开发者在多个分支里分别调用 res.json,却没有在所有异步路径上正确 return。async/await 让代码看起来是同步的,但它本质上仍然是 Promise,一旦某个 await 后面的逻辑在错误分支后继续向下运行,就可能撞上已经发送过的响应。要写出稳定的 JSON 返回,需要先理解执行顺序,再把数据查询、错误处理和响应发送拆成清晰的阶段。

一、async/await 路由中的执行顺序与响应时机
Express 4.x 的路由处理函数如果被声明为 async,它会返回一个 Promise。Express 本身不会捕获这个 Promise 的 rejection,也就是说,当 await 的查询抛错时,如果不手动 try/catch,请求会一直挂起,进程还可能抛出 UnhandledPromiseRejection。所以第一步不是急着写嵌套查询,而是给 async 路由建立错误边界。
下面这个示例包含用户查询和订单查询两次 await。所有错误被 try/catch 兜底,分支里使用 return 阻止后续代码继续执行。
const express = require('express');
const app = express();
app.get('/users/:id/orders', async (req, res) => {
try {
const user = await db.query('SELECT id, name FROM users WHERE id = $1', [req.params.id]);
if (!user) {
return res.status(404).json({ message: '用户不存在' });
}
const orders = await db.query('SELECT id, total FROM orders WHERE user_id = $1', [user.id]);
res.json({ user, orders });
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器内部错误' });
}
});
这段代码的关键点在于:res.status(404).json 前面加了 return,catch 分支是最后一个出口。只要每个分支都能终止函数执行,就不会出现响应发送两次的问题。但这里仍然存在一个性能隐患:查询用户和查询订单是严格串行的。如果两个查询之间没有数据依赖,串行会白白增加接口耗时。这就是下一节要解决的嵌套查询编排问题。
二、嵌套查询用 Promise.all 减少等待并保证数据完整
很多嵌套查询并不是天生必须串行。比如已经拿到用户 ID 后,可以同时查订单、查收货地址、查用户偏好,而不是等订单返回再查地址。Express 里的异步查询如果全是 await 顺序写,接口响应时间会随着查询数量线性增加。用 Promise.all 可以让没有依赖关系的查询并发发出,总耗时接近最慢的那个查询。
app.get('/users/:id/orders', async (req, res) => {
try {
const user = await db.query('SELECT id, name FROM users WHERE id = $1', [req.params.id]);
if (!user) {
return res.status(404).json({ message: '用户不存在' });
}
const [orders, addresses] = await Promise.all([
db.query('SELECT id, total FROM orders WHERE user_id = $1', [user.id]),
db.query('SELECT id, city, detail FROM addresses WHERE user_id = $1', [user.id])
]);
res.json({ user, orders, addresses });
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器内部错误' });
}
});
Promise.all 返回的数组顺序和传入的 Promise 顺序一致,因此解构时不会错位。这里有一个容易忽略的点:如果其中一个查询失败,Promise.all 会立即 reject,其他已经发出的查询不会被自动取消。对普通读接口来说,这通常可以接受;但如果后一个查询要写库,就要谨慎考虑是否应该并行,避免部分成功造成数据不一致。
真正需要顺序执行的场景,是后一个查询依赖前一个查询的结果。比如先查订单列表,再根据订单 ID 查每个订单的商品明细。此时不能简单把第二个查询放进 Promise.all 里,因为订单 ID 还没拿到。常见的错误写法是在 for 循环里 await 查询,导致 N+1 查询。
// 不推荐:串行查询每个订单的商品明细
for (const order of orders) {
const items = await db.query('SELECT * FROM order_items WHERE order_id = $1', [order.id]);
order.items = items;
}
更合理的做法是先把所有订单 ID 收集起来,用一次 IN 或 ANY 查询批量取回商品明细,再在内存里按订单 ID 分组。这样无论订单数量多少,都只需要两次数据库往返。代码虽然多几行,但接口性能会好很多。
const orders = await db.query('SELECT id, total FROM orders WHERE user_id = $1', [user.id]);
const orderIds = orders.map(order => order.id);
const items = await db.query(
'SELECT * FROM order_items WHERE order_id = ANY($1)',
[orderIds]
);
const orderMap = new Map(orders.map(order => [order.id, { ...order, items: [] }]));
for (const item of items) {
orderMap.get(item.order_id).items.push(item);
}
res.json({ user, orders: Array.from(orderMap.values()) });
这样处理后,数据库查询次数从原来的 1 + N 次降到固定三次:查用户、查订单、查所有订单明细。对于订单数量较多的接口,性能提升非常明显。
三、统一错误处理与防止重复响应
每个路由里都写 try/catch 会让代码重复。Express 支持错误处理中间件,可以把 async 路由包装一层,让 reject 自动交给 next,再由统一错误中间件返回 JSON。常用的 asyncHandler 包装函数如下。
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get(
'/users/:id/orders',
asyncHandler(async (req, res) => {
const user = await db.query('SELECT id, name FROM users WHERE id = $1', [req.params.id]);
if (!user) {
return res.status(404).json({ message: '用户不存在' });
}
const orders = await db.query('SELECT id, total FROM orders WHERE user_id = $1', [user.id]);
res.json({ user, orders });
})
);
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ message: '服务器内部错误' });
});
注意错误处理中间件必须放在所有路由之后,并且保持四个参数。这样任何一个 async 路由中抛出的异常都会流入这个中间件,不会再出现 UnhandledPromiseRejection。同时路由内部只需要处理业务分支,比如用户不存在时返回 404,系统错误统一交给中间件返回 500。
重复响应的问题通常来自两个原因:一是某个分支忘了 return,二是在循环内部多次调用 res.json。前者我们已经通过提前 return 解决;后者需要特别注意,Express 的 res.json 内部会调用 res.send,而 res.send 会结束响应。一旦响应结束,再调用 res.json 就会抛出 Cannot set headers after they are sent to the client。比如下面这种写法就是错误的。
// 错误:循环内多次响应
app.get('/bad', async (req, res) => {
const list = [1, 2, 3];
for (const id of list) {
const row = await db.query('SELECT * FROM items WHERE id = $1', [id]);
res.json(row); // 第一次执行后响应已经结束
}
});
正确做法是把所有查询结果收集到一个数组里,循环结束后再一次性 res.json。这也是为什么前面推荐先用批量查询再组装 Map,而不是在循环里直接发送响应。
四、响应数据组装与安全脱敏
嵌套查询返回的 JSON 结构应该由后端明确控制,而不是直接把数据库行原样丢给客户端。数据库里往往包含密码、token、内部状态等字段,这些不能出现在接口返回值里。可以在查询时只选择需要的列,也可以在组装时手动删除敏感字段。显式指定返回字段是更稳妥的方式,即使表结构发生变化,也不会意外暴露新加的敏感列。
const user = await db.query( 'SELECT id, name, email, created_at FROM users WHERE id = $1', [req.params.id] );
如果使用 ORM 查出了完整实体,返回前应该做一次浅拷贝并删除敏感属性。不要直接修改 ORM 返回的实体,避免影响后续逻辑或缓存。
const safeUser = { ...user };
delete safeUser.password;
delete safeUser.reset_token;
res.json({ user: safeUser, orders });
除了脱敏,响应结构也应该保持稳定。建议不要在成功和失败时混用不同的数据形状,例如成功时返回 { data: ... },失败时返回 { message: ... },这会让前端处理变得困难。统一格式有助于降低协作成本。比如可以约定成功统一为 { code: 0, data: ... },失败为 { code: 500, message: ... }。这个约定不是 Express 强制要求,但在实际项目中能减少很多沟通问题。
最后还要注意 JSON 序列化时的特殊值。如果查询结果里包含 undefined、函数、Symbol,JSON.stringify 会静默丢弃它们;包含 BigInt 则会直接抛出 TypeError。当使用 PostgreSQL 的 bigint 类型或 JavaScript 新特性时,最好先把 BigInt 转成字符串再返回。处理响应时保持字段类型的一致性,能给前端省去很多类型判断的麻烦。