在 Node.js 中操作 MySQL
🎯 引言
前面几篇我们都是在终端里手写 SQL 操作数据库。实际项目里,SQL 是由后端代码执行的:用户发起请求,Node.js 服务连上 MySQL,查出数据再返回给浏览器。
学完这篇文章,你能用 mysql2 这个库在 Node.js 中连接 MySQL,掌握连接池的用法,写出完整的增删改查代码,并知道如何用参数化查询防范 SQL 注入。这是把数据库知识和后端开发真正打通的关键一步。
🧱 安装 mysql2 并准备数据
mysql2 是 Node.js 中主流的 MySQL 客户端库,提供了连接 MySQL、执行 SQL 的能力,API 风格友好且性能出色,是 Node.js 生态里访问 MySQL 的常用选择。
本文及本课程基于 Node.js v22 LTS、mysql2 3.x(当前为 3.11.x)编写和验证。新建一个项目目录,初始化后安装:
mkdir node-mysql && cd node-mysql
npm init -y
npm install mysql2
在开始写代码前,先确认 MySQL 容器还在运行,并准备好本文要用到的用户表和数据:
CREATE DATABASE IF NOT EXISTS blog_demo;
USE blog_demo;
CREATE TABLE IF NOT EXISTS user (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
age INT,
city VARCHAR(50),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO user (username, age, city) VALUES
('小明', 20, '北京'),
('小红', 25, '上海'),
('小刚', 30, '广州');
插入完成后,执行下面的查询确认数据:
SELECT * FROM user;
执行结果如下(created_at 是你插入数据时的实际时间):
| id | username | age | city | created_at |
|---|---|---|---|---|
| 1 | 小明 | 20 | 北京 | 2026-07-20 13:30:00 |
| 2 | 小红 | 25 | 上海 | 2026-07-20 13:30:00 |
| 3 | 小刚 | 30 | 广州 | 2026-07-20 13:30:00 |
后面的 Node.js 示例都基于这 3 行数据展开。
user 表展开,字段保持一致。✨ Promise API 与连接池
mysql2 提供两种风格的 API:回调和 Promise。我们统一使用 Promise 版本,配合 async/await 写法和你在 Express、Egg.js 里的习惯一致,从 mysql2/promise 引入即可。
连接数据库也有两种方式:
createConnection:创建单个连接,用完要手动关闭。适合临时脚本,比如一次性导入数据。createPool:创建连接池,池子里预先备好一批连接,用完归还而不是销毁,下次请求直接复用。
可以打个比喻:单个连接像每次出门都买一辆新车,用完就扔掉;连接池像共享单车,骑完还到原地,下一个人扫码就能用。实际项目一律推荐连接池,因为建立一次数据库连接是有开销的,频繁建连会拖慢接口响应,还可能把数据库连接数耗尽。
创建连接池的代码如下:
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: '123456',
database: 'blog_demo',
});
module.exports = pool;
四个配置分别对应:数据库地址、用户名、密码、要使用的数据库名。
.env 文件配合 process.env 读取),避免提交到代码仓库泄露。学习阶段写死即可,心里有这根弦就行。🛠 用连接池完成增删改查
执行 SQL 统一用 pool.query(),它的返回值是一个数组,第一个元素是查询结果。下面四个例子覆盖了日常开发中的完整 CRUD。
查询用户列表,SELECT 返回的是所有匹配的行:
const pool = require('./db');
async function main() {
// 查询用户列表
const [rows] = await pool.query('SELECT * FROM user');
console.log(rows);
// 按 id 查询单个用户,? 是占位符
const [users] = await pool.query('SELECT * FROM user WHERE id = ?', [1]);
console.log(users[0]);
// 新增用户
const [result] = await pool.query(
'INSERT INTO user (username, age, city) VALUES (?, ?, ?)',
['小美', 22, '深圳']
);
console.log('新增成功,id =', result.insertId);
// 更新用户
await pool.query('UPDATE user SET age = ? WHERE id = ?', [23, result.insertId]);
// 删除用户
await pool.query('DELETE FROM user WHERE id = ?', [result.insertId]);
}
main();
运行 node crud.js,就能看到查询、新增、更新、删除依次生效。几个关键返回值对应前文的 3 行初始数据:第一条 SELECT 查出小明、小红、小刚共 3 行;users[0] 是 id 为 1 的「小明」;新增的「小美」是第 4 行,所以打印出 新增成功,id = 4;随后的更新和删除都作用在 id = 4 这一行上,整个脚本跑完后,user 表仍然是最初的 3 行数据。
? 占位符,把真实值放进第二个参数的数组里。INSERT 的结果通过 result.insertId 拿到新行的自增 id。🪤 SQL 注入:新手必须避开的坑
假设你写一个登录查询,用户名和密码来自用户输入。如果直接用字符串拼接 SQL:
// 错误示范:字符串拼接 SQL,请勿在项目中使用
const sql = `SELECT * FROM user WHERE username = '${username}'`;
const [rows] = await pool.query(sql);
当用户正常输入 小明 时,拼出的 SQL 没问题。但如果有人恶意输入 ' OR '1'='1,拼出来就变成了:
SELECT * FROM user WHERE username = '' OR '1'='1'
'1'='1' 永远成立,OR 让条件对每一行都为真,结果是整个 user 表被全部查出来。这种攻击方式就叫 SQL 注入:攻击者通过输入框往你的 SQL 里"注入"额外语句,轻则泄露数据,重则删库。
正确做法就是上一节的参数化查询:值通过 ? 占位符单独传给数据库,数据库把它当作"纯粹的数据"处理,不会解析成 SQL 语法,恶意输入自然失效:
// 正确做法:参数化查询
const [rows] = await pool.query('SELECT * FROM user WHERE username = ?', [username]);
可以打个比喻:拼接 SQL 像把客人的话原样写进合同,客人写什么就是什么;参数化查询像让客人填表,只能往固定格子里填内容,永远改变不了合同的条款。
⚡ 为什么参数化查询就安全了
你可能会冒出这个疑问:参数化查询不也是把 ' OR '1'='1 传进去了吗,难道换个传法就不会注入了?真的会失效,关键在于值和 SQL 是分开到达数据库的。
两种写法的本质区别:
- 拼接 SQL:用户输入先被当作 SQL 文本拼成一条完整语句,数据库拿到后才解析。此时
' OR '1'='1已经是语句的一部分,数据库只能照着解析,OR '1'='1'就成了语法里的条件。 - 参数化查询:带
?的 SQL 模板先发给数据库编译,执行计划已经确定,值是之后单独传过去的。数据库只把传入内容当作一个字符串值,永远不会再把它解析成 SQL 语法。
用同一个恶意输入对比,两种写法各自等价于:
-- 拼接写法,等价于:
SELECT * FROM user WHERE username = '' OR '1'='1';
-- 条件恒为真,查出 user 表全部 3 行:小明、小红、小刚
-- 参数化写法,等价于:
SELECT * FROM user WHERE username = '\' OR \'1\'=\'1';
-- 只是匹配一个名字恰好叫 ' OR '1'='1 的用户
参数化那条的意思变成了:找一个 username 恰好等于 ' OR '1'='1 这整串字符的用户。表里显然没有叫这个名字的人,所以一行都查不到,攻击自然落空。
? 占位符,绝不拼字符串。这是后端开发的基本安全意识。💡 结合 Express 写一个接口
最后把知识串起来,写一个简洁的 Express 接口:访问 GET /users 返回用户列表。
const express = require('express');
const pool = require('./db');
const app = express();
app.get('/users', async (req, res) => {
const [rows] = await pool.query('SELECT * FROM user');
res.json(rows);
});
app.listen(3000, () => {
console.log('服务已启动:http://localhost:3000');
});
运行 node server.js,浏览器访问 http://localhost:3000/users,就能看到 JSON 格式的用户列表。到这里,浏览器、Node.js、MySQL 这条完整链路就打通了。
🧾 小节总结
- mysql2 是 Node.js 中主流的 MySQL 客户端库,使用
mysql2/promise可以配合async/await编写。 createConnection创建单个连接用完即关,createPool创建连接池复用连接,实际项目推荐连接池。- 连接配置包含 host、user、password、database,真实项目中密码要放到环境变量里。
- 用
pool.query(sql, [参数])执行 SQL,SELECT结果在返回数组的第一个元素中,INSERT可通过result.insertId拿到新行 id。 - SQL 注入会让恶意输入改变查询语义,防范方法是一律用
?占位符做参数化查询,不拼字符串。
❓ 知识问答
Q1:mysql 和 mysql2 两个包有什么区别?
mysql 是老一代的库,更新较少;mysql2 是它的改进版,性能更好、支持 Promise 风格,对 MySQL 8.x 的新特性兼容也更好。新项目直接用 mysql2 即可。
Q2:连接池里的连接需要我手动关闭吗?
不需要。pool.query() 用完会自动把连接归还到池子里,这正是连接池的价值。只有程序退出时才需要调用 pool.end() 统一关闭。
Q3:为什么查询结果要写成 const [rows] = await pool.query(...)?
pool.query() 返回一个数组,第一个元素是数据行,第二个元素是字段元信息。用解构取出第一个元素,就不用每次都写 result[0] 了。
Q4:参数化查询如果也传入 ' OR '1'='1,为什么不会注入?
因为值和 SQL 模板是分开传给数据库的:模板先编译,值只被当作一个字符串去匹配,不会再被解析成 SQL 语法。此时条件等价于找一个 username 恰好等于 ' OR '1'='1 的用户,查不到就返回空。要注意表名、字段名不能走 ? 占位符,这类场景要自己在代码里做白名单校验。
Q5:密码写在代码里有什么风险?
代码一旦提交到 Git 仓库或被他人看到,数据库密码就泄露了。真实项目要用环境变量管理,并确保 .env 文件在 .gitignore 中。
🧪 小练习
练习一:参照本文示例,写一个 getUserById 函数,接收 id 参数,用参数化查询返回对应用户,查不到时返回 null。
const pool = require('./db');
async function getUserById(id) {
// 请在这里编写代码
}
练习二:写一篇 SQL 注入自查,把下面这条拼接 SQL 改成参数化查询写法。
// 改造前
const sql = `SELECT * FROM user WHERE city = '${city}'`;
// 请在这里编写代码
🎉 恭喜你已经掌握在 Node.js 中操作 MySQL 的技能啦!下一篇我们聊聊索引与查询优化,看看数据量大起来之后,怎样让查询跑得又快又稳。
