第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:自动记录创建时间

配图1 - 配图1

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, '行');
}
);

配图2 - 配图2

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/promiseasync/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 分钟):连接测试
- 输入:修改连接参数让你的数据库连接成功
- 预期输出:打印"✅ 数据库连接成功!"
- 提示:hostlocalhost,确保 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!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。