本节摘要:Koa 把中间件写成 async 函数,await next 让洋葱模型的「回程」第一次变得可编程;数据层则承接框架之下的稳定输出——连接池异步借还、慢查询监控、SQL 注入防线与 ORM 取舍。本节把框架选型与数据访问放在一节,因为它们面对的是同一个问题:如何让 I/O 在事件循环上跑得又稳又快。
Koa 出自 Express 原班人马,动机很直接:Express 诞生时 async/await 还不存在,错误处理靠 next(err) 接力;Koa 从第一行代码就假设你写 async 函数。中间件长这样:
const Koa = require('koa'); const app = new Koa(); app.use(async (ctx, next) => { const start = Date.now(); await next(); // 等待管道后半程全部完成 const ms = Date.now() - start; console.log(`${ctx.method} ${ctx.url} - ${ms}ms`); }); app.use(async ctx => { ctx.body = { hello: 'koa' }; }); app.listen(3000);
await next() 把 Express 里被遗忘的「回程」变成了显式可控的代码:计时中间件不需要监听 finish 事件,await 之后天然是响应完成的位置。更妙的是错误处理——整条管道被包在一个大 try/catch 里,任何中间件抛错(包括 await 的异步拒绝)都会落到最外层的 error 事件:
app.on('error', (err, ctx) => { console.error('服务器错误', ctx.path, err.message); });
对比一眼见分晓:
| 维度 | Express | Koa |
|---|---|---|
| 中间件形态 | 回调 + next | async + await next |
| 异步错误 | try/catch 后 next(e) | 中间件内直接 throw |
| 回程可编程 | 常被遗忘 | await 后显式 |
| 内置能力 | 路由、静态等齐备 | 极简核心,全靠生态装配 |
| 心智模型 | 流水线 | 洋葱 |
Koa 的「极简」是哲学也是代价:路由、体解析都要另装。我的选型口径:重生态、快交付选 Express;想要干净的控制流、团队 async 熟练度高选 Koa。两者性能差异在绝大多数业务里可以忽略——瓶颈几乎总在数据库,这正好引出下半节。
每个查询前新建连接、查完拆掉?TCP 握手加认证动辄几十毫秒,高并发下数据库会被连接风暴打死。连接池的模型:启动时建好 N 条连接,请求来了「借」一条,用完「还」回去。因为借与还都发生在异步等待之后,池天然就是事件循环友好的设计。
const { Pool } = require('pg'); // 以 PostgreSQL 为例 const pool = new Pool({ max: 20, // 池上限:不是越大越好 idleTimeoutMillis: 30000, // 空闲连接 30 秒后回收 connectionTimeoutMillis: 5000, // 借不到连接最多等 5 秒,宁可报错别堆积 }); app.get('/api/users/:id', async (req, res, next) => { try { const { rows } = await pool.query( 'SELECT id, name FROM users WHERE id = $1', [req.params.id] ); res.json(rows[0] || null); } catch (e) { next(e); } });
池大小的调法有共识可循:池上限参考数据库能承受的并发连接数除以服务进程数。第 7 章会讲 cluster 开 4 个进程,那么单进程池上限就该是总配额的四分之一——四个进程各 20 条连接打满一个百连接上限的库,正好。
⚠️ 常见坑:连接借出不还。任何查询路径都要保证 release 被执行,用池的 query 快捷方法(自动借还)或 try/finally 手动管理,别用事务时忘了异常分支的释放。
接口慢的两大来源,一个是第 2 章讲的主线程被同步计算卡住,另一个就是慢查询——它不卡事件循环(等待是异步的),但会把连接池占满,后续请求全堵在「等连接」上,症状是整体超时。三道防线:
// 1. 给每条查询加超时,别让一条慢 SQL 拖死一个池 const { rows } = await Promise.race([ pool.query(sql, params), new Promise((_, rej) => setTimeout(() => rej(new Error('查询超时')), 5000)), ]); // 2. 记录慢查询日志(耗时阈值按业务定) const t = Date.now(); const result = await pool.query(sql, params); if (Date.now() - t > 200) console.warn('慢查询', Date.now() - t, 'ms', sql.slice(0, 80)); // 3. 给高频查询字段建索引,并定期看执行计划
还有一类典型的「不知道自己在慢」——N+1 查询:列表接口查出 100 个用户,再逐个查每个用户的最新订单,101 条查询在事件循环上排队。解法是 join 或批量 IN 查询,一次取回。症状特征:页面数据量越大越慢,且慢得线性。
字符串拼接 SQL 是永远的红线。参数化查询(占位符 + 参数数组)让「输入」永远只当数据不当代码:
// 危险:用户输入直接拼进 SQL const bad = "SELECT * FROM users WHERE name = '" + input + "'"; // input 为 ' OR '1'='1 时,全表泄露 // 安全:占位符 const good = await pool.query('SELECT * FROM users WHERE name = $1', [input]);
要不要上 ORM(Sequelize、Prisma 一类)?我的取舍表:CRUD 为主、模型关系复杂、团队规模大 → ORM 的类型安全与迁移工具值得;报表查询多、性能敏感路径、SQL 已经过 DBA 优化 → 裸 SQL 加一层薄的 DAO 函数更直接。混用也是常态:ORM 管常规读写,复杂查询留逃生舱。判断标准只有一条——ORM 生成的 SQL 你要能看懂并解释,看不懂的抽象迟早变成事故。
💡 关键直觉:框架层的选择可以随团队口味换,数据层的纪律(池化、超时、参数化、慢日志)一条都不能省——事件循环不会替你兜住数据库的坑。