尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Express + Sequelize + MySQL + Docker 实战:从零搭建可部署的 Node.js API 服务

发布时间:2026/9/29 2:36:25

资讯中心
01
ARTICLE

Express + Sequelize + MySQL + Docker 实战:从零搭建可部署的 Node.js API 服务

Express + Sequelize + MySQL + Docker 实战:从零搭建可部署的 Node.js API 服务
1. 为什么选了这套组合技术选型的底层逻辑先聊一下技术选型。很多新手会纠结现在 Node.js 后端框架一堆NestJS、Koa、Fastify为什么还选 Express数据库也是一样MongoDB、PostgreSQL为什么偏偏是 MySQL我的答案很简单不是因为它们最酷而是因为它们最不容易出错。Express 是 Node.js 生态里历史最悠久、社区最庞大的框架它的中间件机制足够成熟文档和现成方案极其丰富。用 Express 搭 API你遇到任何问题几乎都能在国内外的技术社区找到现成答案。NestJS 确实很优雅但对一个从零开始的 API 项目来说它的依赖注入、装饰器那一套学习曲线有点陡如果你只是想快速搭一个可用、可维护的接口层Express 反而是务实的选择。Koa 更轻量但生态里很多中间件还是为 Express 设计的真要较真Express 踩坑的几率更低。Sequelize 也是如此。它是我用过的 ORM 里天花板最低的意思是它不会让你写出特别炫技的代码但非常稳。它把常见的增删改查、关联查询、事务操作都封装好了语法直白到几乎不需要查文档。对于 MySQL 这种传统关系型数据库Sequelize 的支持比 TypeORM 更顺滑特别是在处理复杂关联一对多、多对多的时候它的 API 设计反而更接近直觉。MySQL 本身就不多说了它是互联网应用最常用的关系型数据库性能和稳定性经历过无数生产环境验证。配合 Sequelize 这样的 ORM你不需要手写 SQL同时还能享受关系型数据库的完整能力——事务、外键约束、复杂查询。Docker 是最后一块拼图。如果你只是本地开发MySQL 装在系统里也没什么问题但一旦涉及团队协作、部署上线Docker 的优势就体现出来了你只需要一个 docker-compose.yml就能把整个项目Node 应用 MySQL Nginx一键拉起来其他人不需要在本地装 MySQL、配环境变量省掉的是大量在我电脑上明明是好的这类问题。这套组合适合谁来参考如果你是想写个人博客、小工具后台、小程序接口或者在公司做一个轻量级服务端这套方案完全够用。如果你是第一次接触前后端分离开发这个项目也能帮你把前端调接口这条链路彻底走通。2. 项目骨架设计目录结构与环境准备2.1 初始化项目与依赖安装假设你用的是 Node 18先建一个项目目录并初始化mkdir express-sequelize-mysql cd express-sequelize-mysql npm init -y接着安装核心依赖npm install express sequelize mysql2 dotenv npm install --save-dev nodemon这里有几个细节值得说。mysql2 是必须的不是 mysql。早期 Sequelize 文档里用的是 mysql 包但那个包的维护状态不太好mysql2 提供了更好的性能、更强的预处理语句支持和更完善的安全性Sequelize 官方也推荐使用 mysql2。既然选了 Sequelize那就直接配 mysql2别走老路。nodemon 是开发时用的热重载工具改完代码自动重启服务省去手动重启的麻烦。你把它放到 devDependencies 里因为生产环境不需要它。package.json 里的 scripts 配置也顺手写好{ scripts: { dev: nodemon src/index.js, start: node src/index.js } }2.2 目录结构从一开始就不要乱我见过很多半路出家的项目文件全堆在根目录300 行写在 index.js 里找东西全靠 CtrlF。这种项目前期爽后期想死。从零搭项目目录规范是最便宜的维护成本。推荐的结构是这样project-root/ ├── src/ │ ├── config/ │ │ └── database.js │ ├── models/ │ │ ├── index.js │ │ └── user.js │ ├── routes/ │ │ └── userRoutes.js │ ├── controllers/ │ │ └── userController.js │ └── index.js ├── docker/ │ └── mysql/ ├── Dockerfile ├── docker-compose.yml ├── .env └── .env.example为什么按 model / route / controller 分因为职责分离。Routes 只做路由分发Controller 处理业务逻辑Model 负责数据定义和查询。以后项目变大了加中间件、加服务层也是在各自目录里扩展不会动到别的地方。2.3 环境变量管理不要硬编码密码这是新手最容易忽略的问题。数据库密码、端口号、JWT 密钥这些不该写进代码里。一旦代码提交到 Git 仓库密码就是公开的。用 dotenv .env 文件解决// src/config/database.js require(dotenv).config(); module.exports { database: { host: process.env.MYSQL_HOST || localhost, port: process.env.MYSQL_PORT || 3306, username: process.env.MYSQL_USER || root, password: process.env.MYSQL_PASSWORD || , dbName: process.env.MYSQL_DB_NAME || myapp, }, app: { port: process.env.APP_PORT || 3000, } };同时提供一个.env.example提交到仓库让别人知道需要配置哪些变量# .env.example MYSQL_HOSTmysql MYSQL_PORT3306 MYSQL_USERroot MYSQL_PASSWORDyour_password MYSQL_DB_NAMEmyapp APP_PORT3000注意.env文件必须加到.gitignore里千万别提交到仓库。这算是一条写入血泪的教训。3. 数据库建模与 Sequelize 核心用法3.1 连接数据库必须先有库很多新手在这里踩坑。Sequelize 负责管理连接和模型但数据库本身不会自动创建除非你开启了db.createDatabase()这样的逻辑。通常的做法是在启动应用之前确保数据库已存在。最简单的方式是先用 Docker 启动 MySQL然后手动建库或者用一条 SQL 脚本。后面我会展示 docker-compose 里怎么配置自动建库现在先讲 Sequelize 连接// src/models/index.js const { Sequelize } require(sequelize); const config require(../config/database); const sequelize new Sequelize( config.database.dbName, config.database.username, config.database.password, { host: config.database.host, port: config.database.port, dialect: mysql, logging: false, // 本地开发可以开 true 看 SQL 日志 pool: { max: 10, min: 0, acquire: 30000, idle: 10000 } } ); module.exports sequelize;这里有个参数值得解释连接池 pool。连接池解决的是频繁创建/销毁数据库连接带来的性能损耗问题。每次 SQL 请求都新建一个连接在高并发下会很快耗尽 MySQL 的最大连接数。连接池的思想是预先建立一批连接用完了放回去下次继续用。max 设为 10 表示连接池最多保持 10 个连接acquire 表示获取连接的最长等待时间毫秒。这个值不是越大越好要根据你的并发量调整但前期设一个合理的默认值就够了。3.2 定义第一个模型用户表有了连接实例接下来定义模型。以用户表为例// src/models/user.js const { DataTypes } require(sequelize); const sequelize require(./index); const User sequelize.define(User, { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, username: { type: DataTypes.STRING(50), allowNull: false, unique: true, validate: { len: [3, 50] } }, email: { type: DataTypes.STRING(100), allowNull: false, unique: true, validate: { isEmail: true } }, password_hash: { type: DataTypes.STRING(255), allowNull: false }, status: { type: DataTypes.TINYINT, defaultValue: 1, comment: 1: 激活 0: 禁用 } }, { timestamps: true, underscored: true, tableName: users }); module.exports User;几个细节说一下timestamps: true会自动添加created_at和updated_at两个字段。配合underscored: true生成的是created_at而不是createdAt这样数据库字段命名风格统一不会一会儿下划线一会儿驼峰。validate里的校验规则是 Sequelize 内置的在save之前自动执行不满足会抛错。这比在 Controller 里手动判断要干净得多。unique: true在模型层定义后Sequelize 同步表结构时会创建唯一索引防止同一个邮箱注册两次。3.3 模型同步sync 还是 migrateSequelize 提供了两种同步方式sync()和migrate。sync()会根据模型定义自动创建表如果模型变了还会修改表结构ALTER TABLE。它简单粗暴但不适合生产环境因为一不小心可能改错列类型造成数据丢失。开发阶段用sequelize.sync()是没问题的但上线前一定要改用迁移migration方案。迁移的核心思路是把每次表结构变化记录成一个独立的版本文件谁都能在任何环境按顺序执行保证表结构完全一致。这就是为什么我在目录里没有直接放 migration 文件夹——对于教学项目sync 能让你最快跑通但你自己做正式项目时务必去了解 sequelize-cli 的迁移用法。在启动文件里先同步再启动服务// src/index.js const express require(express); const sequelize require(./models); const userRoutes require(./routes/userRoutes); const config require(./config/database); const app express(); app.use(express.json()); app.use(/api/users, userRoutes); const startServer async () { try { await sequelize.authenticate(); console.log(MySQL connection has been established successfully.); await sequelize.sync({ alter: true }); console.log(Database synced); app.listen(config.app.port, () { console.log(Server running at http://localhost:${config.app.port}); }); } catch (error) { console.error(Unable to connect to the database:, error); } }; startServer();alter: true是开发模式下的好帮手模型改一版表结构自动跟着改。但上线前务必关掉它改用迁移脚本。想不清楚为什么的话想象一下线上数据库里有几十 GB 数据模型改动触发 ALTER TABLE一旦锁表几十分钟整个服务就挂了。4. API 路由与业务逻辑实现4.1 Controller 层把逻辑从路由里剥离路由层如果堆满了业务代码会变得非常臃肿。正确的做法是路由只负责定 URLController 里放具体逻辑。以用户注册为例// src/controllers/userController.js const User require(../models/user); const bcrypt require(bcryptjs); const register async (req, res) { try { const { username, email, password } req.body; // 基本的参数校验 if (!username || !email || !password) { return res.status(400).json({ message: 缺少必要参数 }); } // 密码加密存储不能存明文 const saltRounds 10; const passwordHash await bcrypt.hash(password, saltRounds); const user await User.create({ username, email, password_hash: passwordHash }); res.status(201).json({ id: user.id, username: user.username, email: user.email, createdAt: user.createdAt }); } catch (error) { console.error(Error creating user:, error); if (error.name SequelizeUniqueConstraintError) { return res.status(409).json({ message: 用户名或邮箱已存在 }); } if (error.name SequelizeValidationError) { return res.status(400).json({ message: error.errors.map(e e.message) }); } res.status(500).json({ message: 服务器内部错误 }); } }; module.exports { register };注册这个接口虽然简单但已经把几个关键点都覆盖了密码绝对不能用明文存。这里用 bcryptjs 做哈希加盐saltRounds 10代表计算复杂度越高越安全但也会越慢。10 是当前比较均衡的值生产环境可以用 12。错误处理要分层。唯一约束冲突SequelizeUniqueConstraintError返回 HTTP 409表示资源冲突参数校验错误返回 400其他未知名错误统一返回 500不把内部错误细节暴露给客户端。4.2 路由注册与中间件基础// src/routes/userRoutes.js const { Router } require(express); const userController require(../controllers/userController); const router Router(); router.post(/register, userController.register); router.get(/:id, userController.getUserById); module.exports router;建议在正式做复杂功能前先把路由结构跑通再加中间件。比如用户登录后需要身份认证这就要引入 JWTjsonwebtoken和相应的中间件const authMiddleware (req, res, next) { const token req.headers.authorization?.split( )[1]; if (!token) { return res.status(401).json({ message: 未提供认证令牌 }); } try { const decoded jwt.verify(token, process.env.JWT_SECRET); req.userId decoded.userId; next(); } catch (error) { return res.status(401).json({ message: 令牌无效或已过期 }); } };分层的好处在这里完全体现出来了authMiddleware 放在路由层面被保护的接口统一过滤Controller 里根本不需要关心当前请求是否经过了身份验证。如果以后换成 OAuth 或其他认证方式也只需要改动中间件Controller 代码零改动。4.3 接口设计的一个朴素原则做 API 接口最容易犯的错是过度设计。我最初做的时候也是这样恨不得每个接口都做得非常灵活加了无数个可选参数、万物参数化。结果呢代码难读、测试复杂还容易出 bug。后来悟了接口是为业务服务的不是为显得专业服务的。一个真实的业务接口只需要回答三个问题这个接口是做什么的补充或修改什么资源它需要哪些输入参数请求体 / 路径参数 / 查询参数成功和失败分别返回什么HTTP 状态码 JSON 结构把这三个问题想清楚写代码几乎是流水线工作。你甚至可以先把所有接口的骨架写出来只返回占位数据让前端能先联调然后再逐个实现真正的逻辑这样前后端并行开发效率高很多。5. Docker 部署实战5.1 为什么后端项目也要容器化我见过不少开发者本机服务跑得好好的一到服务器部署就各种出问题。比如对方服务器 MySQL 版本跟本地不一样、系统缺少某个系统依赖、Node 版本差异导致兼容性问题。Docker 的核心价值就是把应用 运行环境打成一个独立的镜像在任何装了 Docker 的机器上跑起来的行为完全一致。团队协作时Docker 更是省心。新人加入项目不再需要手动装 MySQL、配数据库用户、初始化表结构只需要执行一条命令依赖全部就位。这节省的时间足以覆盖学习 Docker 的投入。5.2 编写后端服务的 Dockerfile后端项目镜像的核心阶段写得很简单但每个选择都有讲究FROM node:18-alpine AS builder WORKDIR /app # 先复制 package.json 和 package-lock.json COPY package*.json ./ RUN npm install # 再复制源码 COPY . . EXPOSE 3000 CMD [node, src/index.js]注意顺序先把 package.json 复制进去执行 npm install再复制源码。这是镜像构建缓存的核心技巧。事情的本质是Docker 每一行指令都对应一层缓存如果源码变了但 package.json 没变npm install这一层仍然命中缓存不需要重新下载依赖。如果你把COPY . .放在RUN npm install前面那么任何一个源码文件的变更都会导致整个依赖安装层失效构建速度会慢非常多。如果你在意镜像体积可以用多阶段构建FROM node:18-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci --onlyproduction FROM node:18-alpine WORKDIR /app COPY --frombuild /app/node_modules ./node_modules COPY . . EXPOSE 3000 CMD [node, src/index.js]npm ci适合 CI 环境它会严格按照 package-lock.json 安装速度比 npm install 快且不会修改锁文件。多阶段构建的最后一张镜像只包含运行时必要的文件体积小、更安全。5.3 docker-compose一条命令拉起整套服务单独跑后端容器没意义因为 API 服务离不开数据库。docker-compose 的核心价值在于用 YAML 声明一堆服务的依赖关系一键编排启动。看这个配置# docker-compose.yml version: 3.8 services: mysql: image: mysql:8.0 container_name: express_mysql restart: always environment: MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD} MYSQL_DATABASE: ${MYSQL_DB_NAME} TZ: Asia/Shanghai command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci ports: - 3306:3306 volumes: - mysql_data:/var/lib/mysql api: build: . container_name: express_api restart: always ports: - 3000:3000 depends_on: - mysql environment: MYSQL_HOST: mysql MYSQL_PORT: 3306 MYSQL_USER: root MYSQL_PASSWORD: ${MYSQL_PASSWORD} MYSQL_DB_NAME: ${MYSQL_DB_NAME} APP_PORT: 3000 volumes: mysql_data:几个关键点拆开讲MYSQL_HOST: mysql这里的主机名是 compose 服务的名称不是localhost。因为后端容器和 MySQL 容器不在同一个网络命名空间在容器内访问localhost访问不到 MySQL必须通过 Docker 内部网络中的服务名mysql来解析。这也是新手最容易困惑的地方明明本地直接连localhost没问题到容器里怎么就连不上了。volumes配置了命名卷mysql_data数据库文件存放在 Docker 管理的卷里。这样重启容器或重新 build 镜像数据不会丢。MySQL 镜像的command里指定了utf8mb4字符集。这是必选项不是可选项。否则默认字符集在插入中文时很容易出现Incorrect string value乱码报错。utf8mb4 是超级字符集完整支持中文、emoji 和其他 Unicode 字符。启动只需要docker-compose up -d然后检查容器状态docker-compose ps看到 mysql 和 api 都是 Up 状态就说明整套服务已经跑起来了。访问http://localhost:3000/api/users/register测试接口即可。5.4 数据持久化与连接问题排查Docker 化部署最需要操心的就是数据持久化和网络。数据持久化已经通过 volumes 解决了但容器一旦出问题如果你对 Docker 网络不够了解排查起来会非常痛苦。排查思路排序大概是docker-compose logs api看应用日志有没有报错docker-compose logs mysql看数据库启动是否正常docker exec -it express_api sh进入 API 容器尝试curl连接 MySQL 测试确认.env文件是否存在且变量名拼写正确如果你发现 API 容器报connect ECONNREFUSED 127.0.0.1:3306十有八九是因为配置里的MYSQL_HOST写成了localhost。改成mysqlcompose 服务名即可解决。还有个小技巧在 compose 文件里给 API 容器临时加一个command: sleep infinity这样容器启动后不会退出方便你进入容器内部做排查。排查完再改回来不影响其他配置。6. 常见问题与排查技巧实录6.1 MySQL 时区问题数据库时间比本地慢 8 小时这几乎是必踩的问题因为 MySQL 默认使用服务器时区容器环境默认是 UTC而你在国内本地时间比 UTC 快 8 小时。你插入一条记录created_at显示的时间比实际时间少 8 小时。解决方式有三层我建议同时做在 docker-compose 的环境变量里加TZ: Asia/Shanghai连接 Sequelize 时配置timezone: 08:00创建表结构时时间字段用Sequelize.DATE原生的 DATETIME 类型不用字符串类型const sequelize new Sequelize(..., { timezone: 08:00 });timezone这个配置会告诉 Sequelize 在读写时间时按 8 小时处理否则读取出来的时间可能被当成 UTC 时间转换出现错乱。6.2 中文乱码问题除了上面提到的数据库字符集设置有时候即使建库时用了 utf8mb4连接池里仍可能出现乱码。解决方法是在 Sequelize 连接配置里显式指定define: { charset: utf8mb4, collate: utf8mb4_unicode_ci }MySQL 8.0 默认字符集已经是 utf8mb4但 Sequelize 和 mysql2 在连接时有可能没有正确设置连接字符集所以显式声明更保险。简体中文场景下 utf8mb4_unicode_ci 足够用如果你有特殊排序需求再考虑 utf8mb4_general_ci。6.3 Docker Desktop 启动失败虚拟化不支持这是 Windows 用户常遇到的问题。Docker Desktop 依赖底层虚拟化技术WSL2 或 Hyper-V如果电脑没开启硬件虚拟化启动时会直接报错。排查顺序重启电脑进入 BIOS/UEFI找到 Intel Virtualization Technology 或 AMD SVM Mode确保为 Enabled在 Windows 功能中开启适用于 Linux 的 Windows 子系统和虚拟机平台已安装旧版 Docker Toolbox 的卸载干净再装 Docker Desktop装好之后打开 PowerShell 执行wsl --status检查 WSL 状态是否正常。如果提示没有安装发行版先执行wsl --install安装一个默认发行版。6.4 Sequelize 同步建表报错Table already exists这个报错通常发生在修改了模型定义又改用sync({ force: true })的场景下。force: true会先 DROP TABLE 再重建所以报 already exists 大概率不是你用了 force而是之前的表结构还留在库里且sync的alter逻辑没有正确匹配到变更。解决方式很直接开发阶段确认可以删数据的话手动在 MySQL 里 DROP 掉旧表再重新 sync或者用sync({ alter: true })让 Sequelize 自动做表结构比对和变更。但要再次强调表结构的变更在生产环境一定要走迁移工具alter: true只在开发阶段是可控的。否则你可能在某个周一早上发现生产库里的表被自动加了一个不是你预期的索引。6.5 端口被占用Express 默认跑在 3000你本机可能同时有前端开发服务器占用这个端口。Docker 映射的端口也一样。改端口很简单本地开发.env里改APP_PORTDocker 环境docker-compose 里改3001:3000宿主机的端口映射调整容器内部仍用 3000如果不想改项目配置也可以直接杀掉占用端口的进程。Windows 下用netstat -ano | findstr :3000 taskkill /PID 你的PID /F6.6 API 返回 401认证令牌失效如果你在接口调试时收到 401通常不是代码逻辑问题而是 token 过期、被篡改、或者 Authorization 头格式不对。一个容易被忽略的点是req.headers.authorization里除了 Bearer token 外可能还多了空格或其他前缀。如果你用 Postman 或 Apifox 测试建议先打印一下实际收到的 header 内容来确认格式。我后来养成了一个习惯任何涉及登录态的调试问题先在代码入口处console.log(req.headers)打印全部请求头。这一步几乎能解决一半的 401 问题——你看到格式不对马上就知道是客户端的问题而不是服务端的问题。实操总结与个人体会这个项目从头搭一遍前后不过几百行代码但每一个环节都值得理解透彻。技术选型别贪心Express Sequelize MySQL 这套组合没有什么惊艳之处却足够覆盖大部分业务场景而且是经过无数项目验证过的稳定组合。Docker 部署更是从本地能跑到谁都能一键跑的分水岭值得投入时间专门掌握。我在实际项目中最大的体会是初期稍微多花一点时间在目录结构和配置规范上后期至少省回十倍时间。很多人急着写业务代码把数据库密码直接写在代码里、把所有逻辑堆在一个文件里、不设置字符集结果上线当天一地鸡毛。如果你顺着这篇教程从头到尾自己敲了一遍下一步可以尝试扩展给项目加上简单的 JWT 登录认证、写一个通用分页插件、增加文件上传接口或者把 Docker 部署扩展到 Nginx 反向代理 HTTPS。每加一块你对整套生态的理解就更深一层。持续更新这个项目是值得的因为只有你自己亲手踩过的坑才会真正长成经验。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。