跳到主要内容

Express 连接 MySQL 做简单增删改查:最小 CRUD 写法、分层思路与面试答法

面试速答(30 秒版 TL;DR)

  • Express 里做 MySQL 简单 CRUD,最常见的基础组合是:express + mysql2/promise + 连接池 + 参数化 SQL
  • 增删改查对应的核心 SQL 分别是:INSERTDELETEUPDATESELECT,但真正的工程重点不是背 SQL,而是 连接池、参数绑定、错误处理、分层和事务边界
  • 对简单业务,可以走 router -> controller -> service -> repository -> MySQL 这一条链路,控制器只接请求,SQL 放到数据访问层。
  • 面试里一定要补一句:不要手拼 SQL 字符串,要用占位符防 SQL 注入;不要每个请求新建连接,要用连接池。
  • 一句话总结:Express 接 MySQL 做 CRUD 不难,难的是把“能跑”升级成“安全、可维护、可扩展”。

一、先建立心智模型:一次 CRUD 请求到底怎么走

很多人回答这个题,只会说:

  • app.get()
  • connection.query()
  • res.json()

这只能说明你写过 demo,还不能说明你理解服务端链路。

更完整的理解应该是:

这张图对应的重点是:

  1. 路由负责把请求分发到处理函数。
  2. 控制器负责解析请求和组织响应。
  3. Service 负责业务规则,不建议直接写 SQL。
  4. Repository 负责和 MySQL 交互。
  5. 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+
  • 已安装 expressmysql2
  • 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 个面试高频点:

  1. express.json() 解析 JSON 请求体。
  2. mysql2/promise 配合 async/await 写异步查询。
  3. ? 占位符做参数绑定,避免手拼 SQL。
  4. 用统一错误中间件收敛异常。

六、增删改查分别应该怎么理解

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.paramsreq.body,返回 res
  • user.service.js:做校验和业务规则
  • user.repository.js:写 SQL

这样后面要补:

  • 分页
  • 唯一性校验
  • 事务
  • 缓存
  • 日志

都会比“所有代码堆一个文件”更容易。


十、典型题与标准答法

1. Express 接 MySQL 做 CRUD,最基础要解决哪些问题

  • 路由定义
  • 请求参数解析
  • 连接池管理
  • 参数化 SQL
  • 错误处理
  • 合理的状态码返回

2. 为什么 mysql2/promise 很常见

  • 它支持 Promise 风格,和 async/await 搭配自然。
  • 对简单 CRUD 足够直接,不需要先理解一层 ORM 抽象。

3. 为什么说“简单 CRUD”也不能只会写 SQL

  • 因为真实项目里,接口正确性、安全性、连接管理、错误收敛同样重要。
  • 只会写 SQL,不代表能稳定地把接口跑在线上。

4. PUTPATCH 有什么区别

  • 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,要用 ? 占位符
  • 运行底线:不要频繁建连,要用连接池
  • 工程升级点:分页、校验、事务、日志、逻辑删除、统一错误处理