简介:在JavaScript中直接访问MySQL数据库,可通过JSDBC(JavaScript 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 包 | mysql2 |
|---|---|---|
| MySQL 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
用一个脚本跑一条 SQL,createConnection没问题。但如果你在 Web 服务里,每个请求都新建连接、用完再end(),高并发下 MySQL 会看到大量连接频繁建立和销毁,TCP 层也堆积一堆 TIME_WAIT,很快会出现Too many connections。我常用的做法是换成createPool:
const 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
连接池的参数很多,但真正需要优先关注的是这几个:
| 参数 | 默认值 | 作用 | 常见设置 |
|---|---|---|---|
connectionLimit | 10 | 池里最多同时存在的连接数 | 10~50,视并发 |
maxIdle | 同connectionLimit | 最多保留多少个空闲连接 | 不要大于connectionLimit |
queueLimit | 0 | 排队等待的最大请求数,0 表示不限制 | 0 或并发峰值的 2 倍 |
waitForConnections | true | 池满时是否排队等待 | 一般保持 true |
idleTimeout | 60000 | 空闲连接多久被关闭释放 | 按业务波动定 |
connectTimeout | 10000 | 建立连接的超时时间 | 网络差可以调大 |
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 拿到的不是你以为的值
这是我实践中容易被忽略的一层。第一是字符集。连接配置里必须显式写明charset:
charset: 'utf8mb4'如果不写,mysql2 有默认值,但和表的字符集不一定一致,很容易出现中文读出来正常,写进去变成乱码的情况。而且utf8mb4和utf8mb4_general_ci是两回事,前者是字符集,后者是排序规则,连接池配置里一般指定charset即可。
第二是时区。MySQL 返回DATETIME时,Node 端如果配置不对,会被本地时区干扰。你可以在连接配置里显式声明:
timezone: '+08:00'或统一用 UTC:
timezone: '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...finally:
async 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 一样模糊匹配。到数据库侧,对应的是LIKE:
const 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,可以显式加COLLATE:
await 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 UPDATE
mysql2 的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 UPDATE:
const [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 2002:Can't connect to local MySQL server through socket
这是“JS 连不上 MySQL”里最有迷惑性的报错。完整报错长这样:
Error: connect ENOENT /tmp/mysql.sock Error: Can't 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_transport=ON,强制要求所有连接走 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和withTransaction:
const 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作为默认写法,成本只是两行代码,收益是少熬好几个通宵。希望帮到你。
本文还有配套的精品资源,点击获取