1. 为什么回调写法在Node操作MySQL时让人抓狂
刚接触Node.js操作MySQL那会儿,我写的代码基本长这样:connection.query(sql, (err, results) => { if (err) throw err; ... })。单条查询看着还行,一旦业务稍微复杂一点——比如先查用户信息,再根据用户ID查订单列表,最后还要把订单里的商品详情批量查出来——代码就会变成一层套一层的“俄罗斯套娃”。这种嵌套回调带来的问题非常具体:错误处理分散在每个回调里,缩进层级越来越深,变量作用域容易搞混,而且很难在中间步骤做统一的异常捕获。
更麻烦的是,MySQL驱动本身是异步I/O模型,查询结果什么时候返回是不确定的。如果直接用同步思维去写,比如在回调外面访问results,拿到的永远是undefined。我见过不少新手在这里卡住,以为是SQL写错了,其实是异步时序没理解到位。所以,把回调改成Promise,再进一步用async/await组织流程,不是为了“看起来高级”,而是为了解决三个实际痛点:错误集中处理、流程线性表达、并发可控编排。
提示:Node的MySQL驱动(如mysql、mysql2)默认都支持回调风格,但mysql2额外提供了Promise接口,这是后面所有写法的基础。
2. 从回调到Promise:一次彻底的重构思路
2.1 回调风格到底哪里不舒服
先看一段典型的回调代码,模拟“查询用户余额并扣减”的场景:
connection.query('SELECT balance FROM users WHERE id = ?', [userId], (err, rows) => { if (err) { console.error('查询失败', err); return; } const balance = rows[0].balance; if (balance < amount) { console.log('余额不足'); return; } connection.query('UPDATE users SET balance = balance - ? WHERE id = ?', [amount, userId], (err2) => { if (err2) { console.error('扣减失败', err2); return; } console.log('扣减成功'); }); });这段代码的问题不在于“能不能跑”,而在于错误处理重复、逻辑被切碎、无法复用。如果后面还要加“记录流水”“发送通知”,嵌套会继续加深。而且每个回调里的return只能结束当前回调,不能中断整个业务流程,容易漏掉分支。
2.2 手动封装Promise的通用套路
把回调转成Promise,核心就是new Promise((resolve, reject) => {...}),在回调成功时resolve,失败时reject。我一般会写一个通用的查询函数:
function queryPromise(sql, params = []) { return new Promise((resolve, reject) => { connection.query(sql, params, (err, results) => { if (err) reject(err); else resolve(results); }); }); }这样调用就变成了queryPromise(sql).then(...).catch(...)。但手动封装有个坑:连接对象如果被多个Promise共享,事务控制会变得很麻烦。因为事务要求同一个连接上执行多条语句,而Promise链里很容易不小心用了连接池里的不同连接。所以后面讲事务时,我会专门说怎么处理。
2.3 用mysql2的Promise接口省去手写封装
如果你用的是mysql2,可以直接这样创建连接:
const mysql = require('mysql2/promise'); const pool = mysql.createPool({ host, user, password, database });之后所有pool.query()返回的都是Promise,不需要自己包一层。实测下来,mysql2/promise在性能上和回调版几乎没差别,但代码可读性提升非常明显。需要注意的是,createPool和createConnection都支持Promise,但连接池更适合Web服务场景,因为每次查询自动获取和释放连接。
注意:
mysql2的Promise接口在错误对象上保留了code、errno等字段,做错误分类时可以直接用,不用额外解析。
3. async/await真正好用的地方:线性表达与错误捕获
3.1 把嵌套变成自上而下的步骤
用async函数重写前面的扣减逻辑:
async function deductBalance(userId, amount) { const rows = await pool.query('SELECT balance FROM users WHERE id = ?', [userId]); const balance = rows[0][0].balance; if (balance < amount) throw new Error('余额不足'); await pool.query('UPDATE users SET balance = balance - ? WHERE id = ?', [amount, userId]); return true; }这里有几个细节值得说。第一,pool.query返回的rows是一个数组,第一项才是结果集,所以取rows[0][0]。第二,throw new Error会被async函数自动转成rejected Promise,调用方用try/catch就能统一捕获。第三,整个流程是线性的,读代码时不需要在脑子里维护“回调栈”。
3.2 try/catch的边界该放在哪
很多人写async函数时,喜欢在每个await外面包一层try/catch,结果代码里全是try。我的经验是:在业务逻辑的最外层统一捕获,中间层只做必要的分支判断。比如一个HTTP接口的处理函数:
app.post('/deduct', async (req, res) => { try { await deductBalance(req.body.userId, req.body.amount); res.json({ ok: true }); } catch (err) { if (err.message === '余额不足') res.status(400).json({ msg: err.message }); else res.status(500).json({ msg: '服务器错误' }); } });这样中间的业务函数不需要关心HTTP状态码,只负责抛错。错误分类在边界层做,逻辑更干净。
3.3 并发查询:Promise.all与串行的取舍
有些场景下多条查询之间没有依赖,比如同时查用户基本信息、积分、优惠券。这时候用Promise.all并发执行,总耗时取决于最慢的那条,而不是累加:
const [user, points, coupons] = await Promise.all([ pool.query('SELECT * FROM users WHERE id = ?', [userId]), pool.query('SELECT * FROM points WHERE user_id = ?', [userId]), pool.query('SELECT * FROM coupons WHERE user_id = ?', [userId]) ]);但要注意,Promise.all里任何一条失败都会让整体reject,其他已经发出的查询不会自动取消。如果业务允许部分失败,可以用Promise.allSettled,然后根据status字段分别处理。另外,并发数不是越多越好,数据库连接池有上限,盲目并发大量查询反而会排队等待。我一般控制在连接池大小的70%左右。
4. 事务场景下async/await的坑与正确姿势
4.1 为什么事务不能直接用pool.query
事务要求BEGIN、多条INSERT/UPDATE、COMMIT或ROLLBACK都在同一个连接上执行。而pool.query每次可能从池里拿不同的连接,导致事务失效。正确做法是从池里显式获取一个连接:
const conn = await pool.getConnection(); try { await conn.beginTransaction(); await conn.query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [amount, fromId]); await conn.query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, toId]); await conn.commit(); } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }这里finally里的release非常关键,否则连接泄漏,跑一段时间后连接池就空了,所有请求都会卡住。我踩过一次这个坑:本地测试没问题,上线后并发一高就超时,排查半天才发现是某个异常分支没有释放连接。
4.2 事务中的错误分类与回滚策略
不是所有错误都需要回滚。比如“余额不足”这种业务校验,可以在扣减前先查一次,避免进入事务后才发现。但如果是数据库层面的死锁或超时,回滚后可以重试。我的做法是给错误对象加一个标记:
catch (err) { await conn.rollback(); if (err.code === 'ER_LOCK_DEADLOCK') { // 可以重试 } throw err; }mysql2的错误对象里code字段对应MySQL的错误码,常见的有ER_DUP_ENTRY(唯一键冲突)、ER_LOCK_WAIT_TIMEOUT(锁等待超时)。根据这些码做不同处理,比统一抛500要靠谱得多。
4.3 嵌套事务的替代方案
MySQL本身不支持真正的嵌套事务,但业务里经常出现“一个事务里调用另一个也开了事务的函数”。这时候可以用SAVEPOINT模拟:
await conn.query('SAVEPOINT sp1'); try { await innerLogic(conn); } catch (err) { await conn.query('ROLLBACK TO SAVEPOINT sp1'); throw err; }不过我的建议是尽量避免这种设计,把事务边界放在最外层,内部函数只接收conn参数,不自己开事务。这样职责更清晰,也少很多回滚点管理的麻烦。
5. 连接池配置与异步流程的配合细节
5.1 连接池参数怎么定
mysql2的createPool有几个关键参数:connectionLimit、queueLimit、acquireTimeout、idleTimeout。connectionLimit默认是10,对于中小型服务够用,但如果接口里有慢查询,建议适当调大,同时观察数据库的max_connections。queueLimit设为0表示不限制排队,但这样在极端情况下可能堆积大量请求,我一般设一个合理值(比如100),超过就快速失败。
const pool = mysql.createPool({ host: 'localhost', user: 'app', password: 'secret', database: 'shop', connectionLimit: 20, queueLimit: 50, acquireTimeout: 10000, idleTimeout: 60000 });acquireTimeout是获取连接的超时时间,超过就报错,避免请求无限等待。idleTimeout控制空闲连接多久被回收,太短会导致频繁重建连接,太长会占用数据库资源。实测下来,60秒是个比较平衡的值。
5.2 异步流程中连接释放的时机
用pool.query时,驱动会自动获取和释放连接,不需要手动管理。但用pool.getConnection时,必须确保每条路径都release。除了finally,还要注意如果beginTransaction本身失败,连接也要释放。我习惯把获取连接和释放封装成一个辅助函数:
async function withTransaction(fn) { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const result = await fn(conn); await conn.commit(); return result; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } }这样业务代码只需要写withTransaction(async (conn) => {...}),不用重复事务模板。
5.3 超时与重试的边界
数据库查询偶尔会因为网络抖动或锁等待超时。对于读多写少的场景,可以在应用层做一次重试。但重试要注意幂等性:SELECT天然幂等,UPDATE如果基于绝对值(如SET balance = 100)就不幂等,基于相对值(如SET balance = balance - 10)也要小心重复扣减。我的原则是:只有明确知道操作幂等时才自动重试,否则交给上层决定。
6. 几个容易忽略的异步陷阱与排查经验
6.1 forEach里用await为什么没效果
这是新手最容易踩的坑之一:
items.forEach(async (item) => { await pool.query('INSERT INTO logs SET ?', item); }); console.log('全部完成');forEach不会等待异步回调,所以console.log会先执行,而且如果某个插入失败,错误也不会被外层捕获。正确做法是用for...of:
for (const item of items) { await pool.query('INSERT INTO logs SET ?', item); }或者用Promise.all(items.map(item => pool.query(...)))并发执行。选择哪种取决于是否需要顺序保证以及并发量控制。
6.2 未处理的Promise rejection
Node默认在Promise rejection没有.catch时会打印警告,但不会让进程退出(除非配置了--unhandled-rejections=strict)。这意味着一个被遗忘的异步错误可能悄悄吞掉,导致数据不一致。我的习惯是在入口处加全局监听:
process.on('unhandledRejection', (reason) => { console.error('未处理的Promise拒绝', reason); // 根据情况决定是否退出进程 });同时在每个async函数调用处确保有try/catch或.catch。特别是定时任务和事件回调里的async函数,很容易漏掉。
6.3 连接池耗尽时的表现与定位
连接池耗尽时,新的getConnection会排队,直到acquireTimeout超时报错。表现是接口响应变慢,然后大量超时。定位方法是打印池的状态:
console.log(pool.pool._allConnections.length, pool.pool._freeConnections.length);如果_allConnections等于connectionLimit且_freeConnections为0,说明连接都被占用了。常见原因是某个慢查询长时间不释放,或者事务里忘记release。我一般会在开发环境加一个定时日志,观察连接池水位,上线前就能发现潜在泄漏。
6.4 async函数中的this指向问题
如果把async函数作为对象方法,并且里面用了this,要注意调用方式。比如:
const service = { async getUser(id) { return await pool.query('SELECT * FROM users WHERE id = ?', [id]); } }; const fn = service.getUser; await fn(1); // this丢失解构或传递方法引用时,this会丢失。解决方法是绑定service.getUser.bind(service),或者直接用箭头函数定义。这个问题在回调时代就存在,但async函数里更容易被忽略,因为错误往往表现为this是undefined,而不是明显的语法错误。
7. 从Promise到async的迁移检查清单
如果你手上有一个用回调写的旧项目,想逐步迁移到async/await,我建议按这个顺序来:
- 先统一封装查询函数:把
connection.query包成返回Promise的query,所有新代码用新函数,旧代码不动。 - 逐个模块替换:从最简单的单条查询开始,改成async函数,跑通测试后再改下一个。
- 事务模块最后改:事务涉及连接获取和释放,改动风险最大,等前面都稳定了再动。
- 加全局未处理拒绝监听:迁移期间难免有漏网的Promise,全局监听能帮你快速发现。
- 压测连接池:迁移完成后,用并发测试观察连接池水位和响应时间,确认没有泄漏。
迁移过程中,我习惯保留一份旧代码的备份分支,每改完一个模块就对比一下SQL执行次数和返回结果。有时候Promise化之后,原本串行的查询变成了并发,虽然快了,但可能改变业务语义(比如依赖前一条的插入ID),这种地方要特别小心。
8. 我个人的几条实战心得
第一,不要为了async而async。如果一段逻辑只有一条查询,直接pool.query().then()反而更简洁。async/await的价值在于多步骤流程和错误集中处理。
第二,事务里的每条SQL都要能单独说清楚为什么在这里。我见过有人在事务里查了一大堆无关数据,导致锁持有时间过长。事务应该尽量短,只包含必须原子性的写操作。
第三,连接池不是越大越好。数据库的max_connections是有限的,应用侧连接池总和超过数据库上限,反而会导致连接失败。多个服务共享一个数据库时,要统一规划连接数。
第四,日志里带上SQL和参数。异步流程出错时,光看堆栈很难定位是哪条SQL的问题。我一般在封装查询函数时,把SQL和参数打到debug日志里,出问题时直接搜。
第五,测试环境模拟慢查询。用SELECT SLEEP(5)故意制造慢查询,观察连接池和超时行为,能提前发现很多配置问题。这个技巧在压测前做一次,比上线后救火划算得多。