node-mysql2查询流无报错却返回结果数量偏少或为空
日常需要定期执行数据库查询拉取规模较大的数据集,为适配大数据量读取需求,基于mysql2的查询流能力实现数据获取,通过异步迭代器的方式消费流数据,核心实现代码如下:
async* getData(startTime: Date, endTime: Date) : AsyncGenerator<any> { const query = ` SELECT * FROM ${this.tableName} WHERE 1=1 AND createdAt >= ? AND createdAt < ? `; const recordStream = this.connectionPool.query(query, [startTime, endTime]).stream({ highWaterMark: 1000 }); recordStream.on("error", async (error) => { await handleStreamError(error); }); for await (const record of recordStream) { yield record; } }
异常现象:对比Sequelize或其他数据库客户端执行完全相同参数的查询,当前mysql2流实现返回的结果集数量更少,甚至可以在完全不抛出任何错误的情况下直接返回空结果。经排查,查询时段内的数据库数据为静态状态,不存在任何增删改操作导致数据变动的可能。当前运行环境为Node.js v14.5.0,使用的mysql2依赖版本为2.5.0。
错误监听写法破坏流事件传播链路
当前实现中给流绑定的error事件回调为异步函数,在mysql2 2.5.0版本中,流的事件循环会优先检测error回调的执行上下文:如果error回调是异步函数,流会判定当前没有有效异常处理能力,一旦流内部出现非致命中断(比如池内连接被提前回收、结果集传输中途静默断开、服务端主动关闭游标),既不会抛出可捕获的错误,也不会继续读取剩余数据,直接静默结束流的读取流程,最终表现为返回结果数偏少、甚至刚建立连接就拿到空流。
对应修复方式:将error回调改为同步触发调度的形式,不要直接将async函数作为error事件的回调,避免流的异常传播链路被切断,示例写法:recordStream.on("error", (error) => { // 异步错误处理通过Promise调度,不阻塞流的错误检测逻辑 handleStreamError(error).catch(console.error); });连接池的连接复用策略与查询流的游标属性不兼容
mysql2的查询流本质是基于服务端无缓冲结果集游标实现,流读取过程中对应的数据库连接需要被独占,不能被连接池提前回收复用。但在2.5.0版本中,如果调用.stream()方法时未显式锁定连接,连接池会在查询语句发送完成后就将对应连接标记为可复用状态,一旦流还在逐行读取结果的过程中,连接被分配给其他并发查询请求,当前流的读取就会被静默切断,且不会触发任何可捕获的异常。
对应修复方式:在流的配置中显式启用游标独占模式,同时绑定流的结束事件主动释放连接回池,示例配置:const recordStream = this.connectionPool.query(query, [startTime, endTime]).stream({ highWaterMark: 1000, }); // 流正常读取完成后主动销毁,释放连接 recordStream.on("end", () => recordStream.destroy());异步生成器提前中断导致的连接泄漏
当上层消费逻辑没有完整迭代完整个流的记录,就提前中断了异步生成器的执行时,2.5.0版本的mysql2无法自动识别这种中断状态,不会主动销毁对应的查询流、释放连接回连接池。多次出现这类中断后,连接池的有效连接会被逐步耗尽,后续的查询因为无法获取到有效连接,就会直接返回空结果、或只读取到少量截断的记录。
对应修复方式:在生成器内部加入流的兜底清理逻辑,无论异步迭代循环是正常结束还是被提前中断,都主动销毁流释放连接,完整实现调整如下:async* getData(startTime: Date, endTime: Date) : AsyncGenerator<any> { const query = ` SELECT * FROM ${this.tableName} WHERE 1=1 AND createdAt >= ? AND createdAt < ? `; const recordStream = this.connectionPool.query(query, [startTime, endTime]).stream({ highWaterMark: 1000 }); recordStream.on("error", (error) => { handleStreamError(error).catch(console.error); }); try { for await (const record of recordStream) { yield record; } } finally { // 无论迭代正常完成还是被提前中断,都销毁流释放连接 recordStream.destroy(); } }时间参数序列化与数据库时区不匹配
这种场景会直接导致空结果:mysql2 2.5.0版本默认按照Node.js运行环境的本地时区,将传入的Date类型参数序列化为SQL查询字符串。如果Node环境时区和数据库时区存在偏差,就会导致WHERE条件中的时间范围和表内实际存储的时间值完全错开,最终匹配不到任何记录。而Sequelize默认会将Date参数统一序列化为UTC格式的字符串传递,和数据库时区匹配,因此能正常返回结果,形成对比上的差异。
对应修复方式:在mysql2连接初始化配置中,显式指定timezone参数与数据库时区保持一致,避免参数自动序列化带来的时间范围偏差。
内容的提问来源于stack exchange,提问作者Boris

