MySQL 简介

在 Node.js 中操作 MySQL

学会用 mysql2 连接池在 Node.js 中增删改查 MySQL,并用参数化查询防范 SQL 注入。

🎯 引言

前面几篇我们都是在终端里手写 SQL 操作数据库。实际项目里,SQL 是由后端代码执行的:用户发起请求,Node.js 服务连上 MySQL,查出数据再返回给浏览器。

学完这篇文章,你能用 mysql2 这个库在 Node.js 中连接 MySQL,掌握连接池的用法,写出完整的增删改查代码,并知道如何用参数化查询防范 SQL 注入。这是把数据库知识和后端开发真正打通的关键一步。


🧱 安装 mysql2 并准备数据

mysql2 是 Node.js 中主流的 MySQL 客户端库,提供了连接 MySQL、执行 SQL 的能力,API 风格友好且性能出色,是 Node.js 生态里访问 MySQL 的常用选择。

本文及本课程基于 Node.js v22 LTSmysql2 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 是你插入数据时的实际时间):

idusernameagecitycreated_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:创建连接池,池子里预先备好一批连接,用完归还而不是销毁,下次请求直接复用。

可以打个比喻:单个连接像每次出门都买一辆新车,用完就扔掉;连接池像共享单车,骑完还到原地,下一个人扫码就能用。实际项目一律推荐连接池,因为建立一次数据库连接是有开销的,频繁建连会拖慢接口响应,还可能把数据库连接数耗尽。

创建连接池的代码如下:

db.js
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 返回的是所有匹配的行:

crud.js
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 行数据。

注意写法规律:SQL 里凡是来自用户的值,都不要直接拼进字符串,统一写 ? 占位符,把真实值放进第二个参数的数组里。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 返回用户列表。

server.js
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

crud.js
const pool = require('./db');

async function getUserById(id) {
    // 请在这里编写代码
}

练习二:写一篇 SQL 注入自查,把下面这条拼接 SQL 改成参数化查询写法。

// 改造前
const sql = `SELECT * FROM user WHERE city = '${city}'`;

// 请在这里编写代码

🎉 恭喜你已经掌握在 Node.js 中操作 MySQL 的技能啦!下一篇我们聊聊索引与查询优化,看看数据量大起来之后,怎样让查询跑得又快又稳。