ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JS直连MySQL全指南:mysql2选型、连接池调优与SQL安全实践

JS直连MySQL全指南:mysql2选型、连接池调优与SQL安全实践 简介在JavaScript中直接访问MySQL数据库可通过JSDBCJavaScript DataBase Connector组件实现。JSDBC为Web前端开发者提供了一条绕过后台服务器直连数据库的路径省去部署Java运行环境与编写复杂JDBC调用的开销适合AJAX调试、内部工具页面和快速原型验证等场景。文档内含完整的OCX控件安装与页面引入方式并给出了connectMySQL()、insertMySQL()、execDMLMySQL()、selectMySQL()、updateMySQL()、deleteMySQL()、closeMySQL()等封装函数的JavaScript源码覆盖连接、增删改查、错误获取与关闭连接的常用操作查询结果通过分隔符解析后以数组形式返回代码中已包含行、字段分隔符的处理细节可直接复制到项目中使用或作为基础封装继续扩展。资源共1个Word文档压缩包仅31KB内容精炼实用适合熟悉HTML/JavaScript但缺少后端支持的前端工程师或需要在本地快速验证MySQL逻辑的调试场景。该资源已有6926人学习是轻量级MySQL前端访问思路的典型案例参考。1. 我先说透JS 直接访问 MySQL到底是哪一层在“直连”第一次拿到“JS 直接访问数据MySQL”这个诉求十有八九是被浏览器里的 JS 卡住了。JS 本身只是语言真正能连数据库的是运行环境。浏览器里的 JS 没有原始 TCP Socket 能力也不可能让你在地址栏里访问 3306 端口所以“直接访问”的真实形态是在 Node.js 服务端或 Node 脚本里用 mysql2 这个库建立到 MySQL 的 TCP 连接然后把 SQL 语句整个发过去。这个方案能解决的问题很实际小团队不打算引入 ORM 或网关层想用一段十几行的脚本查库、同步数据或者给前端页面快速提供数据接口。它适合两类人一类是手里只有 Node 环境、想绕过命令行写 SQL 的脚本爱好者另一类是前端转全栈、需要把第一个数据接口跑起来的人。2. 搭最小可访问链路用 mysql2 先跑通一条查询2.1 为什么选 mysql2而不是老牌的 mysql如果你去搜“JS 连接 MySQL”最先看到的可能是 mysql 这个包但我一般会直接绕开。mysql 包的问题不是不能用而是它维护节奏慢对 MySQL 8.0 默认的 caching_sha2_password 认证插件支持得比较别扭很多人在这一步就翻车。mysql2 在主线上兼容更稳而且多做了几件值得换过去的事底层用的是二进制协议而不是文本协议解析效率更高原生支持 Promise API不用再手动把它包成 Promise查询占位符和预处理语句支持完善降低 SQL 注入风险有更完整的类型映射处理对时间、小数、JSON 字段的可控性更强。下面这张表是差异最大、直接影响落地的地方对比项mysql 包mysql2MySQL 8.0 默认认证老版本容易报错直接支持Promise API需要额外封装引入mysql2/promise即可预处理语句支持一般较完整连接池参数较少更细粒度维护活跃度低高实际项目里我见过因为 mysql 包导致ER_NOT_SUPPORTED_AUTH_MODE的报错把mysql换成mysql2后就恢复了。所以这个选型不是无意义的“先进”而是为了少踩一个常见坑。2.2 最小连接代码一条 SQL 从 Node 跑到 MySQL先把环境准备好。下面的示例假设你已经装好 MySQL并且有一个能用的库和账号。代码里用mysql2/promise导入const mysql require(mysql2/promise); async function main() { const conn await mysql.createConnection({ host: 127.0.0.1, port: 3306, user: app_user, password: your_password, database: demo_db }); const [rows] await conn.query( SELECT id, name, status FROM user WHERE status ?, [1] ); console.log(rows:, rows); await conn.end(); } main().catch(err { console.error(query failed:, err); process.exit(1); });这段代码里最值得说的是createConnection的返回值不是连接本身而是一个连接对象它具备query、execute、end等方法。query返回的是一个数组第一项才是查询结果rows第二项是fields字段信息所以你看到我用const [rows] await conn.query(...)来解构。?是值占位符后面[1]会安全地替换上去不要自己在 SQL 里拼接字符串。host用127.0.0.1而不是localhost是刻意为之。原因后面排查章节会细说localhost在部分环境里会触发 MySQL 走 Unix Socket 而不是 TCP连不上时很容易绕晕。2.3 查询结果结构为什么打印出来全是 RowDataPacket第一次跑通后很多人会盯着终端里的RowDataPacket发愣。这其实是 mysql2 对查询结果行的封装对象。它看起来像普通对象能正常读取字段但不是纯粹的 plain object。看这个例子const [rows] await conn.query(SELECT id, name, created_at FROM user LIMIT 1); console.log(rows); // [ RowDataPacket { id: 1, name: 张三, created_at: 2025-01-01T10:00:00.000Z } ] console.log(rows[0].name); // 张三 console.log(Object.prototype.toString.call(rows[0])); // [object Object]这里没有隐藏 bug但有一个落地的坑有些序列化库、日志库会对对象原型敏感导致你JSON.stringify(rows)没问题但拿去发给前端再回来就有隐患。所以当我们想获得干净数据时常见做法是手动映射const rows result.map(row ({ id: row.id, name: row.name, createdAt: row.created_at }));另一个值得关注的是fields。它包含了每列的元信息比如字段名、数据类型、表名。你要做动态导出 CSV、拼接查询结果时可以用fields.map(f f.name)拿到列名列表。2.4 先装库再写代码用命令行把前置条件压到最低很多 JS 连接失败的问题根源根本不在 JS而是 MySQL 本身没装好、没启动、权限没给。所以我一般先搜一下“mysql 安装教程”把服务端装好然后用同样的账号跑一遍 mysql 命令行确认数据库本身是通的mysql -h127.0.0.1 -P3306 -uapp_user -p demo_db -e SELECT COUNT(*) FROM user;这里-e表示执行一条 SQL 后退出。如果这一步成功说明网络、端口、账号、库权限都没问题如果这一步就报错就别急着调试 Node。常见的情况有两个ERROR 1045 (28000): Access denied for user ...账号密码错误或者账号不允许从当前 IP 登录ERROR 1044 (42000): Access denied for database ...账号存在但对demo_db没有权限。权限修正可以先登录 root 账号执行GRANT SELECT, INSERT, UPDATE, DELETE ON demo_db.* TO app_user%; FLUSH PRIVILEGES;注意%是允许任何主机生产环境按需换成具体 IP。命令行验证通过后再回过来跑 Node 脚本排错范围会小很多。提示生产库里别给应用账号开ALL PRIVILEGES按操作类型给到最小权限后面出事时后悔药不好找。3. 连接池与参数调优直连脚本里那几个要命的默认值3.1 为什么不是每次 createConnection而要用 createPool用一个脚本跑一条 SQLcreateConnection没问题。但如果你在 Web 服务里每个请求都新建连接、用完再end()高并发下 MySQL 会看到大量连接频繁建立和销毁TCP 层也堆积一堆 TIME_WAIT很快会出现Too many connections。我常用的做法是换成createPoolconst mysql require(mysql2/promise); const pool mysql.createPool({ host: 127.0.0.1, user: app_user, password: your_password, database: demo_db, waitForConnections: true, connectionLimit: 10, maxIdle: 10, idleTimeout: 60000, queueLimit: 0, connectTimeout: 10000 });连接池做的事情是在内部维护多个连接对象调用方每次只是“借”一个连接用完归还池子里没有空闲连接时请求会排队等待而不是立刻新建连接。这样就避免了反复握手的开销。在实际小项目里你可以把pool定义在一个独立模块中导出所有 SQL 都走它。需要注意的是池不等于无限连接它不是 MySQL 的max_connections避风港。3.2 连接池参数怎么调不要凭感觉给 1000连接池的参数很多但真正需要优先关注的是这几个参数默认值作用常见设置connectionLimit10池里最多同时存在的连接数1050视并发maxIdle同connectionLimit最多保留多少个空闲连接不要大于connectionLimitqueueLimit0排队等待的最大请求数0 表示不限制0 或并发峰值的 2 倍waitForConnectionstrue池满时是否排队等待一般保持 trueidleTimeout60000空闲连接多久被关闭释放按业务波动定connectTimeout10000建立连接的超时时间网络差可以调大connectionLimit并不是越大越好。MySQL 自己有一个max_connections默认通常在 151 左右。你把 Node 池子调到 200可能直接把数据库打挂。安全的做法是先用 SQL 看数据库上限SHOW VARIABLES LIKE max_connections; SHOW GLOBAL STATUS LIKE Threads_connected;把connectionLimit设成max_connections的一半以内同时保证同一时刻业务并发不超过它。如果峰值高优先排查慢查询而不是盲目加连接。3.3 字符集、时区和小数类型JS 拿到的不是你以为的值这是我实践中容易被忽略的一层。第一是字符集。连接配置里必须显式写明charsetcharset: utf8mb4如果不写mysql2 有默认值但和表的字符集不一定一致很容易出现中文读出来正常写进去变成乱码的情况。而且utf8mb4和utf8mb4_general_ci是两回事前者是字符集后者是排序规则连接池配置里一般指定charset即可。第二是时区。MySQL 返回DATETIME时Node 端如果配置不对会被本地时区干扰。你可以在连接配置里显式声明timezone: 08:00或统一用 UTCtimezone: Z第三是小数。DECIMAL类型在 mysql2 里默认以字符串返回原因是 JS 的Number无法完整表达高精度小数。你如果直接做parseFloat精度就丢了。宁可让前端拿到字符串需要计算时统一用Decimal之类的精度库。3.4 连接泄漏写完 release 不到位置迟早爆池连接池最大的坑不是配置而是借了不还。下面这段代码就是反面教材// 错误示范手动 getConnection 后没有 release async function queryUserBad(uid) { const conn await pool.getConnection(); const [rows] await conn.query(SELECT * FROM user WHERE id ?, [uid]); return rows; }第一次调用它很正常第二次也还行跑到几十次池里 10 个连接全部被占满后续请求开始排队再之后queueLimit满了直接报超时。正确的写法是拿到连接后立刻进入try...finallyasync function queryUserGood(uid) { const conn await pool.getConnection(); try { const [rows] await conn.query(SELECT * FROM user WHERE id ?, [uid]); return rows; } finally { conn.release(); } }这里finally保证无论查询成功还是抛异常连接都会归还。很多人以为失败就不用还这恰恰是翻车重灾区。如果你用的是pool.query这种快捷方法它内部会自动获取并释放连接不需要也不应该再手动getConnection。4. JS 拼 SQL 的正确姿势占位符、排序与大小写边界4.1 占位符? 和 ?? 的区别必须分清“JS 直接访问 MySQL”最大的安全隐患就是把 JS 变量用模板字符串直接写进 SQL。比如const sql SELECT * FROM user WHERE name ${name};这就是给 SQL 注入敞开了门。mysql2 提供了两个占位符?是值占位符??是标识符占位符作用完全不同const name Alice; DROP TABLE user;--; // 正确值用 ? const rows await pool.query( SELECT * FROM user WHERE name ?, [name] ); // 正确表名/列名用 ?? const cols [id, name]; await pool.query( SELECT ??, ?? FROM ?? WHERE id ?, [...cols, user, 1] );?会被当成字符串值自动做转义??会被当成表名或列名。不能混淆如果把表名用?会被包成字符串字面量SQL 直接语法错误如果把值用??会被原样展开注入风险依旧。在 mysql2 里query和execute都可以用占位符。execute走更严格的预处理协议对相同 SQL 重复执行时有性能优势但批量插入时的表现不一样我会在后面的批量场景单独说。4.2 js 判断字符串是否包含不等于 SQL 的 LIKE很多前端同事写的习惯是在 JS 里用includes判断字符串包含某个关键字然后希望 SQL 也能像 JS 一样模糊匹配。到数据库侧对应的是LIKEconst keyword ali; const [rows] await pool.query( SELECT id, name FROM user WHERE name LIKE CONCAT(%, ?, %), [keyword] );这里用LIKE CONCAT(%, ?, %)既安全又避免手动拼%导致漏转义。但 JS 的includes是大小写敏感的MySQL 默认的utf8mb4_general_ci排序规则是大小写不敏感的。同一个关键字JS 判断和 SQL 判断可能结论不同。想让 SQL 表现更接近 JS可以显式加COLLATEawait pool.query( SELECT id, name FROM user WHERE name LIKE CONCAT(%, ?, %) COLLATE utf8mb4_bin, [keyword] );utf8mb4_bin按二进制比较大小写敏感更贴近 JS 的includes。反过来如果业务必须忽略大小写保持默认排序规则即可。还有个隐藏坑是用户搜索关键字里带着%或_。它们对 LIKE 是通配符必须转义function escapeLike(keyword) { return keyword.replace(/[\\%_]/g, (m) \\ m); }使用await pool.query( SELECT id, name FROM user WHERE name LIKE CONCAT(%, ?, %) ESCAPE \\, [escapeLike(keyword)] );不做这一步用户搜“10%”会发现所有以 10 开头的字符串都出来了。4.3 ORDER BY 排序表字段别直接拼进 SQL动态排序是最容易忽略注入的地方。很多人在ORDER BY上直接拼字段名因为?占位符只适合值。实际上??就是为这个准备的但要配合白名单才安全const allowFields [id, name, created_at]; const orderField allowFields.includes(reqSort) ? reqSort : id; const orderDir reqDir ASC || reqDir DESC ? reqDir : DESC; const [rows] await pool.query( SELECT id, name, created_at FROM user ORDER BY ?? ${orderDir}, [orderField] );orderDir没有用占位符因为它本身只有两个固定值直接用白名单约束。orderField用白名单校验后才交给??可以在防止注入的同时避免无效字段名。顺序上还要注意 NULL 的位置。MySQL 排序默认把 NULL 放在最前ASC业务里经常要把空值排到最后可以这样写ORDER BY (column IS NULL), column ASC这条在 JS 里拼接时列名仍然用??处理。4.4 批量插入和 ON DUPLICATE KEY UPDATEmysql2 的query支持一种比较特殊的VALUES ?写法专门用于批量插入。传参是一个二维数组const newUsers [ [zhangsan, 1], [lisi, 2] ]; const [result] await pool.query( INSERT INTO user (name, dept_id) VALUES ?, [newUsers] ); console.log(result.affectedRows); // 插入成功条数 console.log(result.insertId); // 第一条自增 id注意批量插入时用的是VALUES ?而不是VALUES (?, ?)。这也是 mysql2 文档里比较特别的一处容易踩。用execute时这个能力可能不同所以我直接用query。业务里常要求“存在则更新不存在则插入”可以追加ON DUPLICATE KEY UPDATEconst [result] await pool.query( INSERT INTO user (name, dept_id) VALUES ? ON DUPLICATE KEY UPDATE name VALUES(name), [newUsers] );这里有个值得注意的现象affectedRows在冲突更新时不是简单相加MySQL 会按两倍计数插入 1 行 更新 1 行这类逻辑返回所以拿affectedRows判断条数时别只看字面量。5. 直连报错排查从 2002 socket 到 SSL 与连接池耗尽5.1 error 2002Cant connect to local MySQL server through socket这是“JS 连不上 MySQL”里最有迷惑性的报错。完整报错长这样Error: connect ENOENT /tmp/mysql.sock Error: Cant connect to local MySQL server through socket /tmp/mysql.sock (2)现象命令行 mysql 能连Node 脚本却报找不到 socket 文件。原因是连接配置里写了host: localhost。在 Node 和 MySQL 客户端的约定里localhost通常意味着走 Unix Socket而不是 TCP。而 Linux 上 MySQL 的 socket 文件不总在/tmp/mysql.sock一旦路径不对就会出现上面的错误。解决就两招选择其一// 方案一强制走 TCP host: 127.0.0.1 // 方案二显式声明 socket 路径 socketPath: /var/run/mysqld/mysqld.sock我倾向方案一因为它让连接行为和端口一致调试也简单。如果 MySQL 启动时开启了skip-networking那就只能走 socket此时必须用方案二并且确认 Node 进程对 socket 文件有访问权限。5.2 mysql ssl 连接错误该关还是该配SSL 相关的报错五花八门常见的是Error: Server requires secure connection Error: SSL routines:WRONG_VERSION_NUMBER Error: Client does not support authentication protocol requested by server第一种现象是 MySQL 服务端配置了require_secure_transportON强制要求所有连接走 TLS。这时候你客户端没开 SSL自然被拒。解决要么在连接配置里带上 CA 证书要么和运维确认后临时放宽数据库侧校验// 内网测试环境数据库已经允许非 SSL 时 const conn await mysql.createConnection({ host: 127.0.0.1, user: app_user, password: your_password, database: demo_db, ssl: { rejectUnauthorized: false } });rejectUnauthorized: false只是跳过证书校验并不代表建立安全连接。这不是推荐做法它只适合数据库侧本来就不强制 SSL 的临时链路。生产环境应该使用云数据库提供的 CA 证书const fs require(fs); const conn await mysql.createConnection({ host: your-db.example.com, ssl: { ca: fs.readFileSync(./ca.pem) } });还有一种更隐蔽的情况MySQL 8 默认支持 TLS但服务端没有配置正确证书导致握手时协议版本对不上。这时候排查重点从 Node 代码挪到数据库服务端的 SSL 配置不要只在客户端反复试。注意如果你不需要加密连接直接把ssl相关项全部删掉不要随手写ssl: {}空对象一样会触发加密握手。5.3 连接池耗尽ETIMEDOUT 是表象当请求开始报connect ETIMEDOUT、getConnection timeout或者pool has been closed问题往往不在网络而在连接池。典型现象是服务刚启动一切正常运行一段时间后接口全部超时。原因是连接被借出但没有归还。前面提到的getConnection后没release是最常见的一种另一种是查询本身很慢池里的连接都被慢查询占着新请求只能排队排到超时就是ETIMEDOUT。排查步骤我固定是这样mysql -u root -p进入 MySQL 后执行SHOW FULL PROCESSLIST; SHOW GLOBAL STATUS LIKE Threads_connected;如果看到大量线程处于Sleep或Query状态并且数量接近connectionLimit再结合代码基本能判断是泄漏还是慢查询。临时恢复服务可以把池子调大但根治必须在代码里补finally或者给慢 SQL 加索引。5.4 MySQL 8 认证插件和中文乱码的坑MySQL 8 默认把账号认证插件改成了caching_sha2_password。如果你用的是较旧的 mysql 包或者某些可视化工具会直接报Error: ER_NOT_SUPPORTED_AUTH_MODE: Client does not support authentication protocol requested by servermysql2 对这种插件支持得很好所以第一反应是升级连接库而不是改数据库。但如果业务里确实有一些老系统无法升级临时解决方案是把账号改成旧的认证方式ALTER USER app_user% IDENTIFIED WITH mysql_native_password BY your_password; FLUSH PRIVILEGES;注意这会让账号失去新插件的部分能力不是长久之计。另一个绕不开的是中文乱码。现象命令行查询正常JS 写入后读回来变成???或读出来是乱码。原因基本是两个地方不一致连接charset没写成utf8mb4或者表和库的排序规则不是utf8mb4。先统一三层ALTER DATABASE demo_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ALTER TABLE user CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;然后在 Node 连接配置里也写上charset: utf8mb4三层统一后中文乱码的坑一般就填平了。6. 把直连封装成业务可用的 async 接口事务与重试的最后一公里6.1 一个简短的 db.js 封装不管你是写脚本还是做接口我建议把直连代码收敛成一个模块。下面是常用的最小封装核心是query和withTransactionconst mysql require(mysql2/promise); const pool mysql.createPool({ host: 127.0.0.1, user: app_user, password: your_password, database: demo_db, waitForConnections: true, connectionLimit: 10 }); async function query(sql, params) { const conn await pool.getConnection(); try { const [rows] await conn.query(sql, params); return rows; } finally { conn.release(); } } async function withTransaction(fn) { const conn await pool.getConnection(); await conn.beginTransaction(); try { const result await fn(conn); await conn.commit(); return result; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } module.exports { pool, query, withTransaction };withTransaction的回调参数是同一个连接事务里的所有查询必须走这个连接不要再去pool.query否则它们不在同一个事务里。这个细节我吃过亏并发场景下两段逻辑各拿一个连接commit 和 rollback 根本没有共同事务上下文数据就乱了。6.2 断线重试哪些查询能重试哪些不能连接偶发断开在实际运维里太常见。网络抖动、MySQL 重启、连接空闲被杀都会让应用抛ECONNRESET或PROTOCOL_CONNECTION_LOST。给查询加一层小重试很实用async function queryWithRetry(sql, params, retries 2) { for (let i 0; i retries; i) { try { return await query(sql, params); } catch (err) { const retryable [ECONNRESET, PROTOCOL_CONNECTION_LOST, ETIMEDOUT].includes(err.code); if (!retryable || i retries) throw err; await new Promise(resolve setTimeout(resolve, 200 * (i 1))); } } }读查询重试很安全但写操作要小心。INSERT在连接断开后可能已经被服务端执行客户端却因为断线没有收到结果这时重试会造成重复写入。我的习惯是只给读和幂等更新做自动重试写操作则把幂等键落到数据库里靠唯一索引兜底。这也是我用事务时要特别确认的一点不要在事务内部盲加重试否则rollback和commit的状态会被重试逻辑打乱。最后说我自己的经验一开始图省事所有getConnection都不写finally觉得脚本跑完进程退出自然会释放。后来线上接口一压测就超时半夜翻SHOW PROCESSLIST全是被占住的连接。从那天起我把每个getConnection都配一个try...finally作为默认写法成本只是两行代码收益是少熬好几个通宵。希望帮到你。本文还有配套的精品资源点击获取
返回列表