☰
Egg.js 数据层实战:Egg-Sequelize 配置、模型与关联查询指南
2026/10/2 13:28:02 网站建设 项目流程

搞后端开发这几年,Egg.js 一直是我在 Node.js 服务端项目里的首选框架,而提到数据层的操作,Sequelize 这个名字基本是绕不开的。说实话,我第一次接触 Egg-Sequelize 的时候,就是冲着“官方插件”这几个字去的。那时候系统里有好几张表要接,业务逻辑还在不停改,如果没有一层统一的数据访问层兜底,光是管理数据库连接和模型文件就能把人折腾够呛。今天这篇就来聊聊我对 Egg-Sequelize 的使用心得,从配置到模型定义,从增删改查到关联查询,再到实测中踩过的那些坑。内容偏向实战,适合正在用 Egg.js 做服务端开发、想把数据层理顺的朋友参考。

1. 为什么是 Egg-Sequelize:它到底帮你解决了什么

1.1 从原生 SQL 到 ORM 的转变

很多刚接触 Egg.js 的开发者会问,为什么不直接用 mysql2 写 SQL,非要套一层 Sequelize?我的看法是,直接写 SQL 本身没有错,尤其是团队里有人精通 MySQL、对查询优化了如指掌的时候,裸 SQL 往往能写出最贴合业务的语句。但问题是,当项目大了之后,你会发现大部分业务代码其实都是重复的 CRUD。模型表加个字段,你至少要去改建表语句、改查询方法、改参数校验,稍不留神就会漏掉一处。Egg-Sequelize 的价值,恰恰在于把这种重复工作量压到最低。

它做的事情说白了就是一个桥接:把数据库里的表映射成代码里的模型类,把表记录映射成对象实例,把查询条件映射成链式方法的参数。你不需要再手写大量 INSERT INTO、UPDATE xxx SET 这种语句,也不用关心连接池的创建和释放,因为 ORM 层已经把这一整套流程打包好了。更重要的是,Egg-Sequelize 作为 Egg.js 官方维护的插件,天然继承了 Egg 的“约定优于配置”思想,插件注册、配置文件加载、模型目录扫描全是自动完成的,省掉了很多手工 glue code。

1.2 约定式目录结构带来的收益

Egg-Sequelize 要求你按约定把模型文件放到 app/model 目录下,插件启动时会自动扫描这个目录,把每个文件里定义的模型注册到 app.model 上。这种约定式设计有个很实际的好处:一旦你熟悉了 Egg 项目的结构,任意一个新项目拿过来,你都能在三分钟内找到对应的数据模型和配置位置。没有各写各的、四处乱放的问题。

有一次我接手一个半成品项目,前一个人把所有 SQL 都堆在 controller 里,数据库连接信息还写在工具类的某个角落里。后期添加新接口的时候,我光是排查一个数据库连接超时就花了整整半天。后来我把项目改造成 Egg-Sequelize 的目录规范,模型统一放进 app/model,配置统一写进 config/config.default.js,团队协作的体验立刻不一样了。大家描述问题、定位 bug 的沟通成本降低了很多,因为所有人都知道去哪里找答案。

2. 基础配置与初始化:把数据源真正接入 Egg.js

2.1 安装插件和数据库驱动

安装这一步没什么特殊技巧,但有一点值得注意:Egg-Sequelize 不会自动决定你要连哪种数据库,驱动需要你自己安装。如果你用的是 MySQL,安装 mysql2 是最稳妥的选择;如果你用 PostgreSQL,就需要安装 pg。这个取决于实际业务场景。

在项目根目录执行安装命令:

npm install --save egg-sequelize npm install --save mysql2

安装完成后,第一步是到 config/plugin.js 里开启插件:

// config/plugin.js exports.sequelize = { enable: true, package: 'egg-sequelize', };

这一步相当于告诉 Egg 框架:“我需要使用 Sequelize 这个插件”。没有这行配置,后面写再多模型代码都不会生效。因为 Egg 的插件机制是基于延迟加载的,插件只有在声明启用之后才会被框架加载和初始化。

2.2 配置数据库连接参数

接下来是数据库连接信息配置,写在 config/config.default.js 文件里。这个文件会根据当前环境变量加载对应的配置,如果你有多个环境,可以通过 config.prod.js、config.test.js 做覆盖。基础配置如下:

// config/config.default.js exports.sequelize = { dialect: 'mysql', host: '127.0.0.1', port: 3306, database: 'my_egg_app', username: 'root', password: 'your_password', timezone: '+08:00', define: { freezeTableName: true, underscored: true, }, pool: { max: 10, min: 0, idle: 10000, }, };

有几个参数我特别想展开说一下。第一个是 timezone,这个参数非常容易忽略,但影响却很致命。如果配置不对,Sequelize 在读取时间字段的时候会默认按 UTC 时间处理,导致你数据库里存的是北京时间,查出来却少了 8 个小时。所以无论在服务器还是本地开发,我都习惯显式设置 timezone: '+08:00',同时在数据库连接串里也加上 timezone 参数,两端保持一致。

第二个是 define 里的 freezeTableName 和 underscored。前者用来禁止 Sequelize 自动复数化表名,比如模型名是 user,默认表名可能变成 users,如果你建表时用的就是 user,这个选项必须打开。后者则控制字段名的映射规则,开启后 your_name 这样的下划线字段会映射为 camelCase 的 yourName,让代码里的写法更符合 JavaScript 习惯。这两个选项建议从一开始就确定好,因为中途改映射规则会牵扯所有模型和查询语句,代价很大。

第三个是连接池配置。Node.js 单线程模型下,每个请求都是异步的,连接池太小容易导致并发高峰时期等待,太大又会占用数据库资源。我给中小型项目的经验值是 max 设置在 5 到 10 之间,min 设为 0,idle 设为 10000 毫秒。如果你的数据库连接数有限,可以适当调小一点,但不要小于 5,否则并发一上来,你会明显感觉到查询变慢。

2.3 验证连接是否成功

配置写完之后,可以在应用的启动文件或者某个路由里临时写一段验证逻辑,测试连接是否正常。我一般会在 app.js 里加一段简易检查:

// app.js module.exports = (app) => { app.ready(() => { app.model.sequelize.authenticate().then(() => { app.logger.info('数据库连接成功'); }).catch((err) => { app.logger.error('数据库连接失败:', err); }); }); };

authenticate 方法是 Sequelize 自带的,它会尝试执行一条简单的查询来验证连接是否可用。如果这里报了 error,先检查网络是否能访问数据库、账号权限是否正确、host 是否写成了 localhost 而数据库实际在远程。对了,还有一个小细节:如果你的数据库是 Docker 容器启动的,宿主机和容器之间通过映射端口访问,host 应该写宿主机 IP 或者 127.0.0.1,不要写成容器内部的 IP,否则外部应用连不上。

3. 模型定义与 CRUD 操作:把表和代码之间架起桥梁

3.1 模型文件的两种写法

Egg-Sequelize 支持两种定义模型的方式,一种是通过 app.model.define 方法直接定义,另一种是先导出原始定义对象,再由 Egg-Sequelize 统一加载。官方文档推荐后一种,也就是在 app/model 目录下每个文件导出一个方法。

我来演示一个用户表的模型定义,这个例子后面会反复用到:

// app/model/user.js 'use strict'; module.exports = (app) => { const { STRING, INTEGER, DATE } = app.Sequelize; const User = app.model.define('user', { id: { type: INTEGER, primaryKey: true, autoIncrement: true }, name: STRING(64), email: { type: STRING(128), unique: true }, password_hash: STRING(255), status: { type: INTEGER, defaultValue: 1 }, created_at: DATE, updated_at: DATE, }); return User; };

每个模型文件接收 app 对象作为参数,通过 app.Sequelize 拿到 Sequelize 内置的数据类型,再用 app.model.define 定义模型。这里有个容易混淆的点:define 的第一个参数是我们给这个 ORM 模型起的名字,并不是直接对应的数据库表名。真正映射到哪张表,取决于 freezeTableName 是否开启和模型的 tableName 配置。比如上面这个例子,如果 freezeTableName 为 false,默认表名会是 users;所以前面配置里我坚决建议开启 freezeTableName。

3.2 字段类型与常用约束

Sequelize 的字段类型非常丰富,常用的包括 STRING、INTEGER、BIGINT、TEXT、DATE、BOOLEAN、DECIMAL、JSON 等。每个类型在数据库底层有对应的映射,但 ORM 层会帮我们做好类型转换。使用中我特别强调三点:

第一点是字符串长度。STRING 一定要指定长度,因为 MySQL 在非 strict 模式下可能默认给一个很小的长度,导致长文本被截断;strict 模式下又会直接报错。第二点是唯一索引。unique 字段可以用在需要防重复的列上,比如邮箱、手机号、订单号。这个约束尽量在模型层定义,因为如果完全依赖业务逻辑去判断重复,并发请求时难免有漏网之鱼。第三点是 defaultValue。给 status、is_deleted 这类有预设状态的字段设置默认值,可以避免插入记录时漏传字段导致值为 null,后期查询统计时会很不方便。

我一般还会额外加一个 paranoid 配置,这是 Sequelize 对软删除的原生支持。启用后,删除记录不会真正从表里消失,而是增加一个 deleted_at 字段标记删除时间,默认查询时会自动过滤掉已删除的记录。这个方案非常适合需要保留历史数据的业务场景,比如订单、用户流水记录。

3.3 单表增删改查的常见姿势

增删改查的方法基本是 ORM 的标准操作,但 Egg-Sequelize 把它封装到了模型实例上。下面是几个高频用法。

创建一条记录,最直接的是调用 create:

const user = await ctx.model.User.create({ name: '张三', email: 'zhangsan@example.com', password_hash: hashedPassword, });

查询记录时,findByPk 适合按主键取数据,findOne 适合带条件取第一条:

const user = await ctx.model.User.findByPk(1); const activeUser = await ctx.model.User.findOne({ where: { status: 1 }, });

更新记录可以用 update 方法,它会返回一个数组,第一个元素是受影响的行数:

const [affectedRows] = await ctx.model.User.update( { status: 0 }, { where: { id: 1 } } );

删除记录用 destroy:

await ctx.model.User.destroy({ where: { id: 1 } });

如果开启了 paranoid,这里的 destroy 其实是软删除,数据本身还在表里。如果非要物理删除,需要用 destroy 的 force 参数,或者直接到数据库里操作。

用过一段时间之后,你应该会意识到,ORM 和写 SQL 的核心差别不仅仅是代码量变少,更重要的是它提供了一套类型安全的写法。查询条件里的字段名会被校验,关联关系会被预先定义,写错字段的时候会在开发阶段就暴露出来,而不是等到线上环境返回错误 SQL 才后悔。

4. 关联关系与复杂查询:多表场景的正确打开方式

4.1 一对一、一对多、多对多

真实业务里很少有只查一张表的情况。用户有订单,订单里有商品,商品属于某个分类,这些关系需要在模型层明确声明。Egg-Sequelize 里最常用的三个关联方法分别是 belongsTo、hasMany、belongsToMany。

以用户和订单为例,一个用户有多个订单时,可以这样定义:

// app/model/user.js const User = app.model.define('user', { ... }); User.hasMany(app.model.Order, { foreignKey: 'user_id', as: 'orders' }); return User;

在订单模型里,反过来声明反向关联:

// app/model/order.js const Order = app.model.define('order', { ... }); Order.belongsTo(app.model.User, { foreignKey: 'user_id', as: 'user' }); return Order;

这里的关键点是 foreignKey 要指向实际存在于表里的外键字段。如果表里用了 user_id,那就明确写上 user_id,不要依赖 Sequelize 的自动推断,因为自动推断往往和你实际的字段命名不一致。

多对多场景则要用 belongsToMany,并通过 through 指定中间模型。比如用户和角色之间的关系:

User.belongsToMany(app.model.Role, { through: app.model.UserRole, foreignKey: 'user_id', otherKey: 'role_id', as: 'roles', });

这里的 UserRole 就是中间表对应的模型。多对多查询时,Sequelize 会自动帮你 JOIN 中间表,取回关联数据。

4.2 用 include 避免 N+1 查询

N+1 查询是 ORM 使用中最常见的性能杀手。简单来说,就是你先查了 N 条订单,又怕浪费连接,于是在循环里再查用户信息,结果总共产生 N+1 次数据库查询。这种方式在小数据量时看不出问题,一旦数据量上到几百上千,接口响应时间会直线上升。

正确的做法是使用 include 预加载关联数据:

const orders = await ctx.model.Order.findAll({ include: [ { model: ctx.model.User, as: 'user', attributes: ['id', 'name'], }, ], });

注意这里 as 必须和 define 关联时写的 as 保持一致,否则 Sequelize 不知道你要关联哪个别名。include 里还可以继续嵌套 include,实现多层关联查询,但我不建议嵌套超过两层,因为生成的 SQL 会变得非常复杂且难以优化,查询效率反而可能下降。

4.3 子查询与聚合

业务里经常要统计某个时间段内的数量,Sequelize 提供了聚合函数方法。比如统计昨天注册用户数:

const count = await ctx.model.User.count({ where: { created_at: { [Op.gte]: new Date('2025-01-01 00:00:00'), [Op.lt]: new Date('2025-01-02 00:00:00'), }, }, });

Op 是 Sequelize 提供的操作符,Op.gte、Op.lt、Op.in、Op.like 都很好用。使用操作符时有一个安全性问题值得多说一句:如果你从外部传入排序字段或者操作符,一定做白名单校验,避免把拼查询条件的能力暴露给用户。之前有团队因为把 req.body 直接展开进 where 条件,导致用户可以通过传参绕过权限过滤,查到了不该查的数据。这类安全问题模型层最容易发生,防不胜防。

如果只是做一些简单的分组统计,可以这样写:

const result = await ctx.model.Order.findAll({ attributes: [ 'status', [app.Sequelize.fn('COUNT', app.Sequelize.col('id')), 'order_count'], ], group: 'status', });

这里的 attributes 既可以是普通字段,也可以是由 Sequelize.fn 拼接出来的聚合表达式。注意在跨数据库使用时,聚合函数的写法可能略有差异,比如 COUNT(DISTINCT col) 在 PostgreSQL 和 MySQL 中的方言就不同,建议先在预发环境测一遍。

5. 事务处理与性能调优

5.1 什么时候必须用事务

数据一致性要求高的场景,事务是必须的。最典型的就是转账操作,从一个账户扣钱,到另一个账户加钱,中间任何一步失败,整个操作都应该回滚。如果不用事务,可能面临用户钱扣了但对方没收到钱的 bug。

Egg-Sequelize 里使用事务非常简单,核心是获取一个 transaction 实例,然后把它传给查询方法:

const transaction = await ctx.model.transaction(); try { await ctx.model.User.update( { balance: app.Sequelize.literal('balance - 100') }, { where: { id: fromUserId }, transaction } ); await ctx.model.User.update( { balance: app.Sequelize.literal('balance + 100') }, { where: { id: toUserId }, transaction } ); await transaction.commit(); } catch (err) { await transaction.rollback(); throw err; }

monic代码里我用了一个小技巧:更新某个字段在原值基础上加减时,使用 Sequelize.literal 生成 SQL 表达式,而不是先把整个记录查出来再算好值回写。这样做的好处是不受并发影响,不会因为两次查询之间的时间差导致覆盖更新。举个生活中的例子,两个操作同时给同一个订单加积分,如果都先查出当前积分再加,最后只有一个生效;用 SQL 表达式就可以让数据库原子地完成加减。

如果你更喜欢简洁的写法,Sequelize 也提供了一种事务包裹式 API:

await ctx.model.transaction(async (t) => { await ctx.model.User.update({ status: 0 }, { where: { id: 1 }, transaction: t }); await ctx.model.Order.update({ status: 0 }, { where: { user_id: 1 }, transaction: t }); });

这里的回调函数里只要抛出异常,事务自动回滚,看起来清晰很多。

5.2 日志与慢查询定位

Sequelize 默认会把生成的 SQL 打到日志里,这在开发阶段非常有用。你可以看到执行了哪些 SQL、参数是什么、耗时多少。生产环境建议把日志级别调低或者把 SQL 输出关掉,避免大量 SQL 日志占用磁盘空间和数据泄漏风险。

如果你怀疑某个接口查询比较慢,一个务实的做法是在数据库端开启慢查询日志,然后拿真实参数去看执行计划有没有走索引、有没有全表扫描。ORM 层生成的 SQL 往往不会是最完美的,有时它生成的 JOIN 方式和你手写的 SQL 有细微差别,这时候反而需要人工介入,把热点查询改成手写 SQL 或者增加合适的索引。

我在实际项目中碰到过一次比较隐蔽的问题:某个统计接口对一张百万级记录的表做按日分组聚合,SQL 本身语法没有问题,但就是慢。排查后发现,日期字段上没有任何索引,导致每次统计都需要全表扫描。后来给日期字段加了普通索引,查询时间从 4 秒降到了 200 毫秒。所以在使用 ORM 的时候,不要忽略数据库端索引设计,ORM 只是把查询条件翻译成 SQL,最终执行效率还是数据库决定的。

6. 迁移工具与同步策略

6.1 用迁移管理表结构变更

在一个多人协作项目里,直接去数据库执行 ALTER TABLE 然后重启应用的方式是不推荐的,因为团队成员可能各改各的,线上数据库和生产数据库结构很快就不同步了。Sequelize 提供了迁移工具,可以通过代码描述表结构变更,并记录到一张专门的迁移表里。

Egg-Sequelize 搭配 sequelize-cli 使用效果比较理想。初始化迁移文件时,它会询问数据库地址和配置,我在实际使用中建议把这些信息统一放到环境变量里,不要硬编码到配置文件,否则容易泄露数据库密码。

一个典型的迁移文件大概长这样:

// migrations/20250101-create-user.js 'use strict'; module.exports = { up: async (queryInterface, Sequelize) => { await queryInterface.createTable('user', { id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, name: Sequelize.STRING(64), email: Sequelize.STRING(128), created_at: Sequelize.DATE, updated_at: Sequelize.DATE, }); }, down: async (queryInterface) => { await queryInterface.dropTable('user'); }, };

运行时只需要执行:

npx sequelize-cli db:migrate

如果要回滚最近一次迁移:

npx sequelize-cli db:migrate:undo

迁移文件的 up 和 down 方法必须成对,up 负责升级,down 负责回滚。写 down 的时候不要偷懒,否则出问题想回退就没法自动完成了。

6.2 开发环境自动同步的取舍

很多初学者会直接使用 sequelize.sync 去根据模型自动创建表,开发环境它能大大提高效率,改动模型后重启应用,表结构很快跟上。但这种模式在生产环境非常危险,因为 sync 默认可能需要删除旧表重建,这会导致数据丢失。即使配置了 alter 模式,在生产环境自动改表结构依然有隐患,字段类型不兼容、外键依赖顺序不对,都可能引发一系列连锁问题。

我的建议是:本地开发可以开 sync,方便快速迭代;测试环境用迁移来验证流程;生产环境严格走迁移,表和表的变更全部有记录、可回滚。这种做法在团队里非常受欢迎,因为出问题的时候可以明确知道是哪一次迁移导致的,而不是大家互相猜疑。

7. 常见问题与排查技巧实录

7.1 时区导致的时间偏移

这是使用 Egg-Sequelize 时最容易踩的坑。本地跑得好好的,一到服务器上,查出来的时间就少了 8 个小时。问题根源在于数据库会话时区和应用时区不一致。解决办法很简单:在 config.default.js 里设置 timezone: '+08:00',同时确认 MySQL 连接串里也有 timezone 参数。如果用的是 Sequelize 5 以上版本,还需要检查数据库驱动连接配置是否把 dateStrings 设成了 true,这会影响日期字段的序列化方式。

我建议在写完连接配置后,先通过 model 查询一条带日期字段的数据,打印到日志里看看时间是否准确。这个方法一分钟就能完成验证,能省去后面排查接口异常的大量时间。

7.2 连接时报错 SequelizeConnectionError

这个错误通常意味着数据库压根没连上。排查分几步:第一步 ping 一下数据库地址看网络是否通;第二步用数据库客户端手动连一次,确认账号密码没问题;第三步检查数据库是否开了远程访问权限。MySQL 默认绑定的地址可能只允许本地连接,如果是远程应用连不上,需要检查数据库用户对来访主机的授权。这一步操作涉及数据安全,建议在生产环境关闭远程 root 登录,使用最小权限的专用账号。

有一点容易忽略:如果你用了云数据库,安全组或防火墙需要放行对应端口。曾经有同事折腾了半天数据库配置,最后发现是云安全组没放通 3306 端口,应用始终连不上。

7.3 字段驼峰与下划线不匹配

模型里定义 camelCase 字段(比如 userName),数据库表里却是 user_name,查询结果会全是 null。这是因为 Sequelize 在做字段映射时,如果没有开启 underscored 配置,默认按字段名原样映射。所以建议在 config 里统一开启 underscored: true,并且模型字段用下划线风格书写,比如 user_name、created_at。代码里查询时,也可以通过 attributes 里的别名机制把 user_name 映射为 userName,这样既满足数据库规范,又符合代码可读性。

7.4 唯一约束冲突的处理

插入重复数据时,Sequelize 会抛出一个 UniqueConstraintError 错误。这个错误包含的错误信息相对友好,但需要在业务层做兜底处理。比如注册接口里,邮箱重复时最好返回“该邮箱已被注册”,而不是让用户看到一堆英文错误堆栈。处理方式可以是在插入前先查一遍邮箱是否存在,但这无法完全避免并发下的竞争条件,所以捕获错误之后还要做二次判断和友好提示。

7.5 日志输出 SQL 可以辅助定位

调试问题时,把 SQL 日志打开是一个非常高效的排错方式。在开发环境,你可以在 config.local.js 里这样开启:

// config.local.js exports.sequelize = { logging: (sql) => { app.logger.info(`[sequelize] ${sql}`); }, };

通过观察真实执行的 SQL,你能快速判断问题出在模型关联、条件拼写还是字段映射上。等到问题解决,再把这个日志关掉或者调整到 debug 级别,避免污染生产日志。

8. 结合个人经验的几点补充

做 Egg 开发这两年多,我逐渐形成了一个固定的套路:项目一开始就把数据库连接配置、模型目录规范、迁移策略和日志方案全部定好,后面写业务接口基本就是填代码的过程。刚开始用 Egg-Sequelize 时,我其实对它自动生成的 SQL 有顾虑,总觉得不如自己手写靠谱。后来在一次报表项目里,一张复杂多层 JOIN 的查询,我用 ORM 的 include 链式写法实现,可读性比原来的 SQL 拼接好很多,后续维护也变得非常简单,从那之后我对 ORM 的态度彻底转变了。

但也有个反例想分享:有个统计接口需要跑一个非常复杂的 UNION 子查询,ORM 的表达能力虽然强,写起来却非常别扭,而且效率不如手写 SQL。这种场景我不会强行用 ORM,而是直接在模型里定义一个自定义查询方法,通过 sequelize.query 执行原生 SQL。ORM 不是万能的,和手写 SQL 混用才是一个务实、健康的态度。

最后再分享一个小技巧:在 Egg-Sequelize 项目里,所有模型的公共字段,比如 id、created_at、updated_at、deleted_at,我通常会在一个 BaseModel 文件里统一定义,然后其他模型复制过去。减少重复字段的同时,也降低了不同模型之间字段定义不一致的概率。虽然它没有官方继承机制那么优雅,但胜在直观、容易理解。如果你有更好的组织方式,欢迎在评论区继续探讨。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询