Express 连接 MySQL 做简单增删改查:最小 CRUD 写法、分层思路与面试答法
面试速答(30 秒版 TL;DR)
- Express 里做 MySQL 简单 CRUD,最常见的基础组合是:
express + mysql2/promise + 连接池 + 参数化 SQL。 - 增删改查对应的核心 SQL 分别是:
INSERT、DELETE、UPDATE、SELECT,但真正的工程重点不是背 SQL,而是 连接池、参数绑定、错误处理、分层和事务边界。 - 对简单业务,可以走
router -> controller -> service -> repository -> MySQL这一条链路,控制器只接请求,SQL 放到数据访问层。 - 面试里一定要补一句:不要手拼 SQL 字符串,要用占位符防 SQL 注入;不要每个请求新建连接,要用连接池。
- 一句话总结:Express 接 MySQL 做 CRUD 不难,难的是把“能跑”升级成“安全、可维护、可扩展”。
一、先建立心智模型:一次 CRUD 请求到底怎么走
很多人回答这个题,只会说:
app.get()connection.query()res.json()
这只能说明你写过 demo,还不能说明你理解服务端链路。
更完整的理解应该是:
这张图对应的重点是:
- 路由负责把请求分发到处理函数。
- 控制器负责解析请求和组织响应。
- Service 负责业务规则,不建议直接写 SQL。
- Repository 负责和 MySQL 交互。
- MySQL 连接通常来自连接池,而不是临时直连。
二、为什么简单 CRUD 也建议分层
如果项目刚起步,你当然可以把所有代码都写在一个文件里。
但从面试和工程视角,更推荐至少拆成下面 4 层:
router -> controller -> service -> repository
这样拆的原因是:
- 路由层只关心 URL 和 HTTP 方法。
- 控制器层只关心请求和响应。
- 业务规则放在 Service,避免以后控制器越来越胖。
- SQL 放在 Repository,后续换 ORM、补事务、做缓存都更容易。
面试里可以直接说:
小项目可以先轻量分层,但不要让路由处理函数直接堆 SQL 和业务判断,否则后面维护成本会很高。
三、最小可运行方案怎么选
如果题目是“Express 里 MySQL 简单增删改查怎么写”,最朴素、最好讲的一套基线是:
- Web 框架:
express - MySQL 驱动:
mysql2/promise - 数据访问:原生参数化 SQL
- 连接管理:
createPool - 返回格式:统一 JSON
为什么这里优先讲 mysql2/promise 而不是 ORM:
- 面试官问的是“简单 CRUD”,先用最少抽象讲清本质更稳。
- 原生 SQL 更容易说明
SELECT / INSERT / UPDATE / DELETE分别怎么落地。 - 讲清楚这层之后,再补一句“复杂项目也可以演进到 Prisma / Sequelize / TypeORM”会更完整。
四、先给一个最小表结构
下面用一个最常见的 users 表举例:
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
email VARCHAR(200) NOT NULL UNIQUE,
age INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
这个表足够演示:
- 查单个用户
- 查列表
- 新增用户
- 更新用户
- 删除用户
五、最小 CRUD 示例
下面这个例子故意不拆太多文件,只保留最关键能力,方便先把机制讲清楚。
运行前提:
- Node.js 20+
- 已安装
express、mysql2 - MySQL 已创建好
users表
const express = require("express");
const mysql = require("mysql2/promise");
const app = express();
app.use(express.json());
const pool = mysql.createPool({
host: "127.0.0.1",
port: 3306,
user: "root",
password: "123456",
database: "demo",
waitForConnections: true,
connectionLimit: 10,
});
app.get("/users", async (req, res, next) => {
try {
const [rows] = await pool.query(
"SELECT id, name, email, age, created_at, updated_at FROM users ORDER BY id DESC"
);
res.json({ code: 0, data: rows });
} catch (error) {
next(error);
}
});
app.get("/users/:id", async (req, res, next) => {
try {
const id = Number(req.params.id);
const [rows] = await pool.query(
"SELECT id, name, email, age, created_at, updated_at FROM users WHERE id = ?",
[id]
);
if (rows.length === 0) {
return res.status(404).json({ code: 404, message: "用户不存在" });
}
res.json({ code: 0, data: rows[0] });
} catch (error) {
next(error);
}
});
app.post("/users", async (req, res, next) => {
try {
const { name, email, age = 0 } = req.body;
const [result] = await pool.query(
"INSERT INTO users (name, email, age) VALUES (?, ?, ?)",
[name, email, age]
);
res.status(201).json({
code: 0,
data: {
id: result.insertId,
name,
email,
age,
},
});
} catch (error) {
next(error);
}
});
app.put("/users/:id", async (req, res, next) => {
try {
const id = Number(req.params.id);
const { name, email, age } = req.body;
const [result] = await pool.query(
"UPDATE users SET name = ?, email = ?, age = ? WHERE id = ?",
[name, email, age, id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ code: 404, message: "用户不存在" });
}
res.json({ code: 0, message: "更新成功" });
} catch (error) {
next(error);
}
});
app.delete("/users/:id", async (req, res, next) => {
try {
const id = Number(req.params.id);
const [result] = await pool.query("DELETE FROM users WHERE id = ?", [id]);
if (result.affectedRows === 0) {
return res.status(404).json({ code: 404, message: "用户不存在" });
}
res.json({ code: 0, message: "删除成功" });
} catch (error) {
next(error);
}
});
app.use((error, req, res, next) => {
console.error(error);
res.status(500).json({ code: 500, message: "服务器内部错误" });
});
app.listen(3000, () => {
console.log("server is running at http://localhost:3000");
});
这个例子体现了 4 个面试高频点:
- 用
express.json()解析 JSON 请求体。 - 用
mysql2/promise配合async/await写异步查询。 - 用
?占位符做参数绑定,避免手拼 SQL。 - 用统一错误中间件收敛异常。
六、增删改查分别应该怎么理解
1. 查:SELECT
最基础有两类:
- 查列表:
SELECT ... FROM users ORDER BY id DESC - 查详情:
SELECT ... FROM users WHERE id = ?
这里要注意:
- 列表接口后续通常会加分页,不能永远全表查。
- 详情接口最好查明确字段,不要一上来就
SELECT *。
2. 增:INSERT
典型写法:
INSERT INTO users (name, email, age) VALUES (?, ?, ?)
插入成功后,常见会返回:
insertId- 新建资源的关键字段
- 或者再查一次数据库返回完整对象
3. 改:UPDATE
典型写法:
UPDATE users
SET name = ?, email = ?, age = ?
WHERE id = ?
这里面试常问两个点:
affectedRows === 0说明目标记录不存在,或更新值与数据库状态无变化时要结合驱动行为判断。- 更新时要区分“全量更新”和“部分更新”。
如果是部分更新,更接近 PATCH 语义,不应该强制要求前端把所有字段都传齐。
4. 删:DELETE
典型写法:
DELETE FROM users WHERE id = ?
但工程里要会补一句:
- 真正业务系统经常不会物理删除,而是做逻辑删除,比如加
is_deleted字段。 - 因为订单、审计、风控、追溯数据通常不能随便物理删除。
七、为什么一定要用参数化查询
很多初学者会这样写:
const sql = `SELECT * FROM users WHERE id = ${req.params.id}`;
这类写法的问题是:
- 可能造成 SQL 注入。
- 数字、字符串、日期拼接规则容易出错。
- 可读性和可维护性都差。
更稳的写法是:
const [rows] = await pool.query(
"SELECT id, name, email FROM users WHERE id = ?",
[id]
);
面试里这句话可以直接背:
使用占位符和参数绑定,本质上是把 SQL 模板和用户输入分开,降低注入风险,也让类型处理更稳定。
八、为什么不要每次请求都新建数据库连接
错误思路通常是:
- 来一个请求就
createConnection() - 用完再关
这样的问题是:
- 连接创建有成本
- 并发一高容易把数据库连接数打满
- 应用层和数据库层都会产生额外开销
更合理的方式是使用连接池:
const pool = mysql.createPool({
host: "127.0.0.1",
user: "root",
password: "123456",
database: "demo",
connectionLimit: 10,
});
连接池的价值是:
- 复用连接
- 控制最大并发连接数
- 降低频繁建连/断连开销
九、如果按工程化方式拆文件,通常怎么拆
面试里讲“简单 CRUD”时,不必把工程说得太重,但你最好知道能怎么演进。
src/
app.js
db/mysql.js
routes/user.route.js
controllers/user.controller.js
services/user.service.js
repositories/user.repository.js
职责可以这样理解:
db/mysql.js:创建连接池user.route.js:定义/users路由user.controller.js:读req.params、req.body,返回resuser.service.js:做校验和业务规则user.repository.js:写 SQL
这样后面要补:
- 分页
- 唯一性校验
- 事务
- 缓存
- 日志
都会比“所有代码堆一个文件”更容易。
十、典型题与标准答法
1. Express 接 MySQL 做 CRUD,最基础要解决哪些问题
- 路由定义
- 请求参数解析
- 连接池管理
- 参数化 SQL
- 错误处理
- 合理的状态码返回
2. 为什么 mysql2/promise 很常见
- 它支持 Promise 风格,和
async/await搭配自然。 - 对简单 CRUD 足够直接,不需要先理解一层 ORM 抽象。
3. 为什么说“简单 CRUD”也不能只会写 SQL
- 因为真实项目里,接口正确性、安全性、连接管理、错误收敛同样重要。
- 只会写 SQL,不代表能稳定地把接口跑在线上。
4. PUT 和 PATCH 有什么区别
PUT更偏全量替换资源。PATCH更偏局部更新。- 实际项目里很多团队会弱化这个区别,但面试里最好知道标准语义。
5. 什么时候要用事务
- 当一次业务操作要修改多张表,且这些修改必须同时成功或同时失败时。
- 例如“创建订单 + 扣库存 + 写支付流水”。
常见追问
1. 为什么 CRUD 示例里通常不直接上 ORM
- 因为原生 SQL 更容易把数据库交互本质讲清楚。
- ORM 更适合在你已经理解 SQL 和数据模型之后提升开发效率。
2. 简单查询是不是就可以 SELECT *
- demo 可以,但真实项目不推荐。
- 查明确字段能减少不必要的数据返回,也更利于接口边界控制。
3. 删除为什么经常做成逻辑删除
- 因为很多业务有审计、恢复、追溯需求,直接物理删除风险高。
4. 新增用户时为什么常常要校验邮箱唯一
- 因为数据库唯一索引是最终兜底,但业务层最好给出更明确的错误语义。
易错点
- 把 SQL 直接写在路由处理函数里,导致后面越来越难维护。
- 用字符串拼接 SQL,而不是参数化查询。
- 忽略数据库连接池,每个请求都新建连接。
- 所有报错都返回
200,只在 JSON 里塞一个失败标记。 - 更新和删除后不检查
affectedRows。 - 列表接口不加分页,数据一多马上拖垮查询。
- 把“能跑通 demo”误以为“已经具备工程可用性”。
速记要点
- 基础组合:
Express + mysql2/promise + MySQL 连接池 + 参数化 SQL - 推荐链路:
router -> controller -> service -> repository -> MySQL - 查改删新增分别对应:
SELECT / UPDATE / DELETE / INSERT - 安全底线:不要手拼 SQL,要用
?占位符 - 运行底线:不要频繁建连,要用连接池
- 工程升级点:分页、校验、事务、日志、逻辑删除、统一错误处理