第5章 5.1 MySQL 与 mysql2
🎯 开场 3 分钟:为什么要学这个?
上一章我们用手写的 JSON 文件给博客搭了个"数据仓库",但你肯定感觉到了——每次重启程序数据就丢了,查个文章还得自己写遍历,写个搜索要从头遍历到尾。这就像用笔记本记流水账:记的时候方便,找的时候就头大了。
痛点来了:
- 你是否遇到过"网站一重启,用户的注册信息全没了"?
- 你是否写过
for循环找了半天,就为了查一个用户的订单?
MySQL 就是来解决这个问题的——它是一个专门存数据、查数据的超级仓库管理员,而 mysql2 是 Node.js 里连接这个仓库的"钥匙"。
学完这章,你能:自己用 Node.js 连接 MySQL、完成增删改查、还能避免最常见的 SQL 注入漏洞。
🧱 基础 25 分钟:核心概念(小白视角)
5.1.1 MySQL 是什么?
类比时间: 把 MySQL 想象成医院的药房。
- 病人(数据)来了,药房有固定货架(表)、有处方本(索引)、有严格的拿药流程(SQL 语句)
- 你告诉药房"我要治咳嗽的药",不用自己翻箱倒柜,药房会帮你找
- 数据存在药房里比存在你口袋里(JSON 文件)安全多了——断电了也不丢
5.1.2 先装好 MySQL 和 mysql2
macOS 用户(Homebrew):
brew install mysql
brew services start mysql # 开机自启
mysql_secure_installation # 初始化设置密码
Windows 用户: 去 mysql.com 下载安装包,一路下一步。
Node.js 里装驱动:
npm init -y
npm install mysql2
5.1.3 第一次连接数据库
连接数据库就像打电话——你得知道对方的号码(主机)、用户名、密码。
const mysql = require('mysql2');
const connection = mysql.createConnection({
host: 'localhost',
user: 'root',
password: '你的密码',
database: 'blog_db' // 先创建一个空数据库
});
连接测试:
connection.connect(err => {
if (err) {
console.error('连接失败:', err.message);
return;
}
console.log('✅ 数据库连接成功!');
});
运行后看到"数据库连接成功",说明你和仓库管理员接上头了。
5.1.4 创建人生第一张表
数据库是仓库,表就是货架。创建一个 users 货架:
const createTableSQL = `
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
email VARCHAR(100) UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`;
connection.execute(createTableSQL, (err, results) => {
if (err) throw err;
console.log('📦 users 表创建成功!');
});
每行在干嘛:
- INT AUTO_INCREMENT PRIMARY KEY:自增 ID,主键(唯一标识)
- VARCHAR(50):可变长度字符串,最多 50 字符
- TIMESTAMP DEFAULT CURRENT_TIMESTAMP:自动记录创建时间

5.1.5 CRUD 四大操作
CRUD = Create(增)、Read(查)、Update(改)、Delete(删)。记住这四个字母就够了。
插入数据(Create):
// 插入一条
connection.execute(
'INSERT INTO users (name, email) VALUES (?, ?)',
['张三', 'zhangsan@example.com'],
(err, results) => {
if (err) throw err;
console.log('插入成功,ID:', results.insertId);
}
);
// 批量插入
const users = [
['李四', 'lisi@example.com'],
['王五', 'wangwu@example.com']
];
connection.query(
'INSERT INTO users (name, email) VALUES ?',
[users],
(err, results) => {
console.log('批量插入', results.affectedRows, '条');
}
);
查询数据(Read):
// 查所有
connection.execute('SELECT * FROM users', (err, rows) => {
console.log('所有用户:', rows);
});
// 按条件查
connection.execute(
'SELECT * FROM users WHERE name = ?',
['张三'],
(err, rows) => {
console.log('张三的信息:', rows[0]);
}
);
// 只查特定字段
connection.query(
'SELECT id, name, email FROM users WHERE id > ?',
[1],
(err, rows) => {
rows.forEach(u => console.log(`${u.id}: ${u.name}`));
}
);
更新数据(Update):
connection.execute(
'UPDATE users SET email = ? WHERE name = ?',
['newemail@example.com', '张三'],
(err, results) => {
console.log('影响了', results.affectedRows, '行');
}
);
删除数据(Delete):
connection.execute(
'DELETE FROM users WHERE id = ?',
[3],
(err, results) => {
console.log('删除了', results.affectedRows, '行');
}
);

5.1.6 为什么要用连接池?
想象一下:每次查数据都重新拨号打电话,忙的时候电话线就堵了。
连接池就是养一群接线员,用完了放回去复用,不用每次重建连接。
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: '你的密码',
database: 'blog_db',
waitForConnections: true, // 没连接时等待
connectionLimit: 10, // 最多10个连接
queueLimit: 0 // 队列无限长
});
// 用 pool.execute 代替 connection.execute
pool.execute('SELECT * FROM users WHERE id = ?', [1])
.then(([rows]) => console.log(rows))
.catch(err => console.error(err));
连接池的好处:快(复用连接)、稳(限制并发)、省(自动回收)。
🔥 实战 35 分钟:3 个递进的小项目
项目 1:5 分钟 → 博客评论系统(CRUD 基础)
场景: 给博客文章加评论功能,需要存评论者昵称、评论内容、关联的文章 ID。
完整代码:
const mysql = require('mysql2/promise'); // 用 promise 版本方便 async/await
async function main() {
// 1. 创建连接池
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: '123456',
database: 'blog_db'
});
// 2. 创建 comments 表
await pool.execute(`
CREATE TABLE IF NOT EXISTS comments (
id INT AUTO_INCREMENT PRIMARY KEY,
article_id INT NOT NULL,
nickname VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
console.log('✅ comments 表就绪');
// 3. 插入一条评论
const [result] = await pool.execute(
'INSERT INTO comments (article_id, nickname, content) VALUES (?, ?, ?)',
[1, '小明', '写得真好,支持一下!']
);
console.log('📝 发表评论成功,ID:', result.insertId);
// 4. 查看某文章的所有评论
const [comments] = await pool.execute(
'SELECT * FROM comments WHERE article_id = ? ORDER BY created_at DESC',
[1]
);
console.log('文章1的评论:');
comments.forEach(c => console.log(` ${c.nickname}: ${c.content}`));
// 5. 删除评论
await pool.execute('DELETE FROM comments WHERE id = ?', [result.insertId]);
console.log('🗑️ 评论已删除');
await pool.end();
console.log('数据库连接关闭');
}
main().catch(console.error);
预期输出:
✅ comments 表就绪
📝 发表评论成功,ID:1
文章1的评论:
小明: 写得真好,支持一下!
🗑️ 评论已删除
数据库连接关闭
一句话解释: 用 mysql2/promise 的 async/await 写法比回调地狱清晰多了,评论的增删改查都是套路。
项目 2:15 分钟 → CSV 数据导入 MySQL
场景: 你有一个 users.csv 文件,里面有 1000 条用户数据,要导入数据库去重。
users.csv 示例内容:
name,email,age
张三,zhangsan@example.com,25
李四,lisi@example.com,30
王五,wangwu@example.com,28
完整代码:
const mysql = require('mysql2/promise');
const fs = require('fs');
const readline = require('readline');
async function importCSV() {
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: '123456',
database: 'blog_db',
waitForConnections: true,
connectionLimit: 10
});
// 1. 创建表
await pool.execute(`
CREATE TABLE IF NOT EXISTS imported_users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
email VARCHAR(100) UNIQUE,
age INT,
import_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// 2. 读取 CSV
const fileStream = fs.createReadStream('./users.csv');
const rl = readline.createInterface({ input: fileStream });
let isHeader = true;
let importedCount = 0;
let skippedCount = 0;
for await (const line of rl) {
if (isHeader) { isHeader = false; continue; } // 跳过表头
const [name, email, age] = line.split(',');
try {
await pool.execute(
'INSERT INTO imported_users (name, email, age) VALUES (?, ?, ?)',
[name.trim(), email.trim(), parseInt(age)]
);
importedCount++;
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
skippedCount++; // 邮箱重复,跳过
} else {
console.error('插入失败:', err.message);
}
}
}
console.log(`📊 导入完成:成功 ${importedCount} 条,跳过 ${skippedCount} 条(重复)`);
// 3. 验证结果
const [rows] = await pool.execute('SELECT COUNT(*) as total FROM imported_users');
console.log('数据库现有用户:', rows[0].total, '条');
await pool.end();
}
importCSV().catch(console.error);
预期输出:
📊 导入完成:成功 3 条,跳过 0 条(重复)
数据库现有用户:3 条
一句话解释: 用 ? 占位符配合参数数组,能自动处理特殊字符,有效防止 SQL 注入。
项目 3:15 分钟 → 命令行待办清单(Todo CLI)
场景: 做一个命令行待办清单工具,数据存 MySQL,支持添加、查看、完成、删除。
完整代码:
const mysql = require('mysql2/promise');
const readline = require('readline');
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: '123456',
database: 'blog_db'
});
// 初始化表
async function init() {
await pool.execute(`
CREATE TABLE IF NOT EXISTS todos (
id INT AUTO_INCREMENT PRIMARY KEY,
task VARCHAR(200) NOT NULL,
done BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
}
// 帮助信息
function printHelp() {
console.log(`
📋 待办清单命令:
add <任务内容> - 添加新任务
list - 查看所有任务
done <ID> - 标记任务完成
delete <ID> - 删除任务
quit - 退出
`);
}
// 主程序
async function main() {
await init();
printHelp();
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const ask = () => {
rl.question('\n🎯 请输入命令:', async (cmd) => {
const [action, ...args] = cmd.trim().split(' ');
const param = args.join(' ');
switch (action) {
case 'add':
await pool.execute(
'INSERT INTO todos (task) VALUES (?)',
[param]
);
console.log('✅ 任务已添加');
break;
case 'list':
const [rows] = await pool.execute(
'SELECT * FROM todos ORDER BY done, created_at DESC'
);
if (rows.length === 0) {
console.log('📭 暂无任务');
} else {
rows.forEach(r => {
const status = r.done ? '✅' : '⬜';
console.log(`${status} [${r.id}] ${r.task}`);
});
}
break;
case 'done':
await pool.execute(
'UPDATE todos SET done = TRUE WHERE id = ?',
[parseInt(param)]
);
console.log('✅ 任务已标记完成');
break;
case 'delete':
await pool.execute('DELETE FROM todos WHERE id = ?', [parseInt(param)]);
console.log('🗑️ 任务已删除');
break;
case 'quit':
console.log('👋 再见!');
await pool.end();
rl.close();
return;
default:
console.log('❓ 不认识这个命令,输入 help 查看');
}
ask(); // 继续循环
});
};
ask();
}
main().catch(err => {
console.error('出错了:', err.message);
pool.end();
});
运行效果:
📋 待办清单命令:
add <任务内容> - 添加新任务
list - 查看所有任务
done <ID> - 标记任务完成
delete <ID> - 删除任务
quit - 退出
🎯 请输入命令:add 买牛奶
✅ 任务已添加
🎯 请输入命令:add 写周报
✅ 任务已添加
🎯 请输入命令:list
⬜ [2] 写周报
⬜ [1] 买牛奶
🎯 请输入命令:done 1
✅ 任务已标记完成
🎯 请输入命令:list
⬜ [2] 写周报
✅ [1] 买牛奶
🎯 请输入命令:quit
👋 再见!
一句话解释: 把数据库操作封装成几个简单函数,CLI 只负责读取用户输入和格式化输出,逻辑就清晰了。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:密码里有特殊字符
❌ 错误写法:
const pool = mysql.createPool({
password: 'p@ss#word!' // 特殊字符可能导致解析错误
});
✅ 正确写法:
const pool = mysql.createPool({
password: 'p@ss#word!',
charset: 'utf8mb4' // 明确指定字符集
});
坑 2:回调地狱
❌ 错误写法(嵌套地狱):
connection.execute('INSERT...', (err, result) => {
connection.execute('SELECT...', (err, rows) => {
connection.execute('UPDATE...', (err) => {
// 噩梦
});
});
});
✅ 正确写法(async/await):
const [insertResult] = await pool.execute('INSERT...');
const [rows] = await pool.execute('SELECT...');
await pool.execute('UPDATE...');
坑 3:SQL 注入
❌ 危险写法(用户输入直接拼接):
const sql = `SELECT * FROM users WHERE name = '${name}'`;
// 如果 name = "'; DROP TABLE users; --" 整个表就没了
✅ 正确写法(参数化查询):
pool.execute('SELECT * FROM users WHERE name = ?', [name]);
// mysql2 会自动转义,恶意输入变成普通字符串
坑 4:忘记关闭连接
❌ 错误写法:
async function query() {
const pool = mysql.createPool({...});
await pool.execute('SELECT...');
// 函数结束,pool 没人管,连接泄漏
}
✅ 正确写法:
const pool = mysql.createPool({...}); // 全局创建一次
async function query() {
const [rows] = await pool.execute('SELECT...');
return rows;
}
process.on('exit', () => pool.end()); // 程序结束时关闭
坑 5:查大量数据没分页
❌ 危险写法:
// SELECT * FROM orders 返回 100万条,内存爆炸
✅ 正确写法:
// 分页查询
const page = 1, limit = 20;
const [orders] = await pool.execute(
'SELECT * FROM orders LIMIT ? OFFSET ?',
[limit, (page - 1) * limit]
);
性能小贴士:预处理语句
mysql2 支持预处理语句(Prepared Statements),重复执行的 SQL 会更快:
// 预处理一次,多次执行
const stmt = await pool.prepare('SELECT * FROM users WHERE age > ?');
const [young] = await stmt.execute([20]);
const [old] = await stmt.execute([60]);
// 用完关闭
stmt.close();
调试技巧:开启查询日志
const pool = mysql.createPool({
// ...
debug: true, // 打印所有 SQL 语句
logger: console.log // 用自己的日志
});
✏️ 练习题 + 作业题
练习题(10 分钟)
练习 1(2 分钟):连接测试
- 输入:修改连接参数让你的数据库连接成功
- 预期输出:打印"✅ 数据库连接成功!"
- 提示:host 填 localhost,确保 MySQL 服务已启动
练习 2(2 分钟):加个判断
- 输入:在项目 1 代码中,插入评论前加个判断,只允许昵称长度 > 2
- 预期输出:短昵称被拒绝,长昵称插入成功
- 提示:用 if (nickname.length <= 2) 判断
练习 3(2 分钟):换个数据源
- 输入:用项目 2 的方法,从另一个 CSV(比如 products.csv)导入数据
- 预期输出:控制台显示导入成功 X 条
- 提示:建表时改一下字段名即可
练习 4(2 分钟):串联项目
- 输入:把项目 2 的数据导入和项目 3 的 CLI 结合,导入后能查
- 预期输出:导入后 list 命令能看到导入的数据
- 提示:导入时用 pool.execute,查询时也用同一个 pool
练习 5(2 分钟):读懂报错
- 输入:运行以下代码,说出哪里错了
pool.execute('SELECT * FORM users', (err, rows) => {
// ...
});
- 预期输出:说出错误原因和怎么改
- 提示:检查 SQL 拼写,"FORM" 应该是 "FROM"
作业题(30 分钟 - 2 小时)
作业:做一个「个人书签收藏夹」
- 需求描述:做一个命令行书签管理器,能添加网址、分类、查看列表、按分类筛选
- 功能点:
1.add <网址> <分类>- 添加书签
2.list [分类]- 列出所有书签,或按分类筛选
3.delete <ID>- 删除书签
4. 数据持久化到 MySQL - 加分项:
1. 用连接池而不是单连接
2. 防止 SQL 注入(用参数化查询)
3. 加个count命令统计各分类数量 - 验收标准:能跑起来 + 增删查都能用 + 代码有注释
- 提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
一句话总结: 今天学了 MySQL 数据库连接、CRUD 四大操作、连接池、以及最重要的——用 ? 参数化查询防 SQL 注入。
延伸学习:
1. mysql2 官方文档 - 英文,但例子清晰
2. 《MySQL 必知必会》- 薄薄一本,专门讲 SQL 语法
3. MySQL Workbench - 可视化工具,帮你看表结构
互动钩子: 你在项目里有没有遇到过"数据莫名消失"的坑?当时是怎么解决的?评论区聊聊,老粉优先回复!
📌 下章预告:写原生 SQL 虽然灵活,但表多了、维护多了就头疼了。下一章我们会认识一个叫 Sequelize 的工具,它能让你像操作 JavaScript 对象一样操作数据库,CRUD 不用写一句 SQL!

评论(0)