
1. Egg里接Sequelize到底在解决什么问题后端开发做久了都会有一个感受Node.js生态里写SQL太自由了自由到每个人写出来的数据库访问代码风格都不一样。有人习惯用字符串拼接SQL有人喜欢封装一层函数有人干脆把SQL全堆在service里。刚开始项目小还无所谓等表一多、关联一复杂、需求频繁变动的时候这种各自为战的写法会拖垮整个项目。Egg.js作为基于Koa封装的企业级框架主打的是“约定优于配置”它对路由、中间件、定时任务都有非常清晰的组织方式。但框架本身是不管数据库的怎么连库、怎么建模、怎么查询全得自己定一套规范。这时候把Sequelize引进来本质是为了让整个项目的数据访问层有统一的书写范式——模型定义在专门的目录里查询统一走模型方法事务处理有明确边界迁移和初始化能自动完成。这套组合在Egg社区里几乎成了标准答案不是因为它有多花哨而是它刚好把Egg的工程化基因和ORM的建模能力接在了一起。适合谁来用这套东西只要你的Egg项目需要持久化存储不管是MySQL还是PostgreSQL都值得把Sequelize作为首选方案。特别是团队协作的场景统一的数据层规范比任何代码规范文档都管用。你不需要在code review的时候反复解释某条SQL为什么要这么写因为ORM已经在模型层把大部分规则固化下来了。2. 项目接入与配置从零到能跑的完整过程2.1 依赖安装与初始化顺序先说安装Egg接Sequelize需要装三个层面的东西。基础框架egg-sequelize插件它负责把Sequelize实例挂载到app对象上Sequelize核心库以及对应数据库的驱动MySQL用mysql2PostgreSQL用pg。三个缺一不可很多人只装了egg-sequelize就跑去配置启动直接报错找不到Sequelize。npm install --save egg-sequelize npm install --save sequelize npm install --save mysql2装完之后在config/plugin.js里开启插件// config/plugin.js exports.sequelize { enable: true, package: egg-sequelize, };这里有一个容易被忽略的点插件加载顺序。egg-sequelize只是把Sequelize初始化好真正做模型加载的其实是Egg内置的loader。如果你同时用了其他依赖数据库的插件注意egg-sequelize要在这些插件之前被加载否则那些插件启动时访问app.model会拿到undefined。2.2 配置文件里那些容易踩的细节配置写在config/config.default.js或config.prod.js里格式是标准的sequelize连接参数// config/config.default.js exports.sequelize { dialect: mysql, host: 127.0.0.1, port: 3306, database: egg_example, username: root, password: your_password, timezone: 08:00, define: { freezeTableName: true, underscored: true, timestamps: true, createdAt: created_at, updatedAt: updated_at, }, pool: { max: 10, min: 0, idle: 10 * 1000, }, };timezone这个字段建议从一开始就设置成08:00。Sequelize在序列化时间戳的时候默认会按UTC处理如果你不用MySQL服务端的CURRENT_TIMESTAMP而是默认让字段带默认值很可能出现数据库中存的时间和前端拿到的时间差八个小时的问题。define下的freezeTableName建议设为true。默认情况下Sequelize会把模型名转成复数作为表名比如User模型对应users表Person对应people表这功能听着贴心实际用起来很坑。很多团队的建表规范是单数形式或者表名是某个约定好的业务名不会跟着模型名的复数走。设了freezeTableName之后模型名和表名一一对应省掉一堆ugly的映射。underscore和timestamps配套一起说。数据库规范一般用snake_caseJS代码习惯用camelCaseSequelize可以在模型层自动做这个映射。timestamps开启后Sequelize会帮你维护created_at和updated_at这两个字段免去每次插入更新都要手动set时间的重复劳动。2.3 模型目录加载顺序与模型间依赖Egg的模型目录约定在app/model下loader会自动加载所有js文件并把模型挂载到app.model对象上。但这里有一个坑如果你的模型之间有关联关系belongsTo、hasMany这些关联定义不能写在模型文件里直接执行因为加载顺序是不确定的——A模型在定义关联时可能B还没被加载。正确的做法有两种。第一种是用关联钩子在app.js里等所有模型加载完成后统一注册关联关系第二种是把关联定义写到模型类的类方法里通过init函数显式初始化// app/model/user.js module.exports app { const { STRING, INTEGER } app.Sequelize; const User app.model.define(user, { id: { type: INTEGER, primaryKey: true, autoIncrement: true }, name: STRING(50), email: STRING(100), }); User.associate function() { app.model.User.hasMany(app.model.Post, { as: posts, foreignKey: user_id }); }; return User; };然后在app.js里统一初始化关联// app.js module.exports app { app.beforeStart(async () { const { model } app; Object.values(model).forEach(m { if (typeof m.associate function) { m.associate(); } }); }); };这么做的好处是关联关系集中管理不会有加载顺序问题出bug也好排查。3. 数据模型定义与表结构同步策略3.1 模型字段类型怎么选Sequelize提供了一套类型系统对应数据库里的各个列类型。写模型的时候注意和数据库类型映射关系别看着差不多就随便填。Sequelize类型对应MySQL类型使用建议STRING(n)VARCHAR(n)短文本给明确长度别用STRING预防万一TEXTTEXT长文本超过255字符用INTEGERINT整数主键、计数类字段BIGINTBIGINT雪花ID、大数字场景DECIMAL(p, s)DECIMAL金额类千万别用FLOATBOOLEANTINYINT(1)开关状态DATEDATETIME时间字段注意timezone配置JSONJSON存储结构化的非核心字段这里特别说下DECIMAL。金额字段如果用FLOAT等数据量大了之后可能会出现精度丢失的问题比如0.10.2不等于0.3这在财务系统里是致命的。Sequelize里正确写法是price: { type: app.Sequelize.DECIMAL(10, 2), allowNull: false, defaultValue: 0, }表示最多十位数字小数点后保留两位这样的话最大能存99999999.99一般业务够用了。3.2 同步数据库sync和migration怎么选新手刚接触Egg-Sequelize最爽的一刻就是写完模型直接跑同步数据库表自动生成了。egg-sequelize插件默认情况下每次应用启动都会执行一次sequelize.sync()但注意它只是在表不存在的时候创建表不会对已有的表做alter操作。sync的便利背后藏着很大的坑如果你的项目还在早期探索阶段改模型字段、加列、删列sync不会帮你改表结构你只能手动drop表重新建数据全没了。对生产环境来说这显然不行。更靠谱的做法是引入migration机制。egg-sequelize官方推荐配合sequelize-cli使用通过npm script管理迁移文件# 安装sequelize-cli npm install --save-dev sequelize-cli # 生成迁移文件 npx sequelize migration:generate --namecreate-user-table # 执行迁移 npx sequelize db:migrate迁移文件长这样use strict; module.exports { up: async (queryInterface, Sequelize) { await queryInterface.createTable(user, { id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true, }, name: Sequelize.STRING(50), email: Sequelize.STRING(100), created_at: Sequelize.DATE, updated_at: Sequelize.DATE, }); }, down: async (queryInterface, Sequelize) { await queryInterface.dropTable(user); }, };我的经验是开发早期可以用sync偷懒但只要你开始接手一个会被长期维护、多人协作的Egg项目migration早晚要用起来。否则上线之后的每一次表结构变更都是一场灾难。4. 查询层的常用姿势和容易翻车的操作模型定义好接下来就是怎么查数据。Sequelize的API封装得非常好用但正因为好用很多人写出了一堆性能拉胯的查询而不自知。4.1 常用查询一网打尽基础查询就不多说了findByPk、findOne、findAll这几个是日常主力。直接上一些有代表性的写法// 根据主键查询 const user await ctx.model.User.findByPk(1); // 带条件查询只会返回一条 const user await ctx.model.User.findOne({ where: { email: testexample.com }, }); // 列表查询分页排序 const { rows, count } await ctx.model.User.findAndCountAll({ where: { status: 1, name: { [Op.like]: %张% }, }, offset: 0, limit: 20, order: [[created_at, DESC]], });findAndCountAll这个组合拳很有用返回result的同时也把总数给出来做分页接口的时候不需要再单独跑一条count查询了。4.2 别在查询里原地起飞字段排除与裸属性很多人在findAll的时候不指定attributes直接把整行所有列都捞出来。如果表里有TEXT类型的大字段、JSON类型的大对象一次查询拉十条数据网络带宽和内存消耗都会很难看。规范一点的写法是明确列出需要的字段const users await ctx.model.User.findAll({ attributes: [id, name, email], where: { status: 1 }, });排除某些字段也行const users await ctx.model.User.findAll({ attributes: { exclude: [password, secret_key] }, });这个在用户表里特别实用密码这些敏感字段不该出现在查询结果里一定要在模型层就做到别等数据返回到controller层再手动删除。4.3 批量操作删数据和慢查询的取舍Sequelize提供了destroy接口但是批量删除的时候要格外小心。因为内置的hook会在每条记录上执行独立操作数据量上去了非常慢。如果你只是要清空一个大表别用Model.destroy一条条删直接用bulkDelete或者原生SQL会快得多。// 这是单条删除只删一条 await ctx.model.User.destroy({ where: { id: 1 } }); // 批量删除如果数量巨大考虑批量执行或找DBA帮忙 await ctx.model.User.destroy({ where: { status: 0 } });同理bulkCreate在批量插入的时候也有优化的余地。默认情况下bulkCreate是一条条insert而你传入的数组很长时可以设置updateOnDuplicate实现MySQL的insert ... on duplicate key update语义既能批量写入又能处理冲突。5. 关联建模和联表查询的正确姿势5.1 一对一、一对多、多对多怎么定义Sequelize里关联关系看着就三对APIhasOne/belongsTo、hasMany/belongsTo、belongsToMany。但要搞清楚谁是谁的谁还是有点绕。我一般用一个简单的心法去理解定义在谁身上谁就是“源模型”它的外键字段放在哪里是决定用哪个API的关键。一对一用户和用户资料。用户是主体资料表带user_id外键那么就是User.hasOne(Profile)Profile.belongsTo(User)。一对多用户和文章。文章表里有user_id那么User.hasMany(Post)Post.belongsTo(User)。多对多用户和标签走中间表。User.belongsToMany(Tag, { through: user_tag })反向也写一遍。定义好关联后查询时配合include就能自动join。注意include里可以用as指定别名这个别名要和关联定义里的as保持一致否则Sequelize会报错找不到关联。// 查用户的同时带出他的文章 const users await ctx.model.User.findAll({ include: [{ model: ctx.model.Post, as: posts, attributes: [id, title], }], });这个查询默认是left join如果你只想要那些发过文章的用户可以把include里的required设为true就变成了inner join。5.2 联表查询的性能教训联表查询最怕的是一口气全查出来。比如用户表带文章文章又带评论评论又带图片一层层include下去最终拿回来的可能是一个超级深的JSON对象。对前端来说看着好像很爽但数据库那边跑的联表次数和返回的数据量可能已经非常夸张了。建议接口需要什么就include什么能查两次然后代码里合并就优先考虑。比如列表页只需要显示用户的基本信息和文章数那完全可以用group加count的方式查一个聚合结果不需要真的把文章详情都带出来const result await ctx.model.Post.findAll({ attributes: [ user_id, [app.Sequelize.fn(COUNT, app.Sequelize.col(id)), post_count], ], group: [user_id], });聚合查询要用Sequelize.fn和Sequelize.col这俩是写聚合函数的入口。用好的话能写出非常复杂的报表SQL同时保持代码可读性。6. 事务处理Egg里最容易出错的一环6.1 为什么必须显式管理事务Sequelize默认是自动提交的事务模式单条SQL出错了自动回滚但多条SQL之间没有原子性。比如用户下单的流程扣库存、生成订单、减余额这三步要是一个成功一个失败数据就全对不上了。正确的做法是先开启一个事务把后续操作都绑定到这个事务上全部成功再提交任何一个环节出错就回滚。Sequelize提供了两种风格一种是基于回调的transaction方法一种是手动创建事务对象。const t await ctx.model.transaction(); try { await ctx.model.Inventory.decrement({ stock: 1 }, { where: { id: goodsId }, transaction: t }); await ctx.model.Order.create({ userId, goodsId, amount }, { transaction: t }); await t.commit(); } catch (err) { await t.rollback(); throw err; }上面这种手动方式看着简单但有个致命问题如果你在事务中间某个步骤抛异常了而你没把错误抛干净那么事务对象还是开着的连接池里就会一直占着一个基础连接高并发下连接池直接被打满。Egg里推荐的方式是配合ctx.model.transaction的自动管理模式const result await ctx.model.transaction(async t { // 在回调里的所有模型操作都要传 { transaction: t } const order await ctx.model.Order.create({ userId, amount }, { transaction: t }); await ctx.model.Inventory.decrement({ stock: 1 }, { where: { id: goodsId }, transaction: t }); return order; });这种写法如果回调里抛了异常事务会自动回滚你不需要手动处理rollback也不会有连接泄漏问题。6.2 事务必须在service层别放controller这是个非常典型的错误。有人图省事直接在controller里用ctx.model.transaction包了一段逻辑controller就跑通了。看着没问题但controller的基本职责是参数校验和结果返回事务这种业务逻辑应该下沉到service层。Egg对目录的管理虽然不强制但Service是官方认定的业务逻辑层。事务放错位置后续做单元测试的时候会特别痛苦因为你必须mock controller里的上下文。建议统一规范所有涉及多个写操作的事务逻辑只在service层里写controller里的代码保持精简。这个规范最好在项目一开始就定下来。6.3 嵌套事务和锁的处理Sequelize 6对嵌套事务的处理比较友好它内部会把嵌套的事务转成savepoint外层回滚时内层的savepoint不会生效。很多业务里会出现一个service方法调用另一个service方法两个方法各自开了事务这时候嵌套事务就很重要。如果想让内层事务真正独立提交需要用{ transaction: null }来指定这样内层事务就会在当前事务之外执行。另外查询的时候加锁也是事务里常见的需求。悲观锁就一行const account await ctx.model.Account.findOne({ where: { id: uid }, lock: t.LOCK.UPDATE, transaction: t, });这个操作等同于SELECT ... FOR UPDATE对目标行加排他锁其他事务在锁定期间不能修改或查询这一行。这个在高并发秒杀场景下非常有用但要注意加锁之后事务内要尽快提交否则容易造成锁等待超时。7. 日志、性能诊断和运维经验7.1 打开SQL日志定位问题快人一步开发阶段务必把logging打开这样每次Sequelize执行SQL的时候都会把语句打印到日志里。这个对排查问题太重要了。有时候你在业务代码里看不出问题在哪儿打开日志一看发现它偷偷跑了十条SQL那性能问题一目了然。exports.sequelize { // 开发环境打印SQL生产环境可以关掉 logging: (sql, timing) { if (process.env.NODE_ENV development) { console.log(sql, 耗时 ${timing}ms); } }, };timing是查询耗时单位ms。这个参数用来发现慢查询很有用一条SQL跑了500ms以上基本就是索引没走到或者join的表太大。还有一个很实用的logging技巧可以把SQL和对应的操作绑定起来。比如你开启debug日志的时候在业务代码里console.log一个自定义标识日志里就能看到这是哪个接口触发的哪条SQL排查起来特别顺手。7.2 慢查询与连接池的运维姿势生产环境里如果接口响应变慢第一件事先看数据库的慢查询日志。很多慢查询不是Sequelize本身的问题而是业务代码写得不合理。最常见的场景就是用循环去查数据库// 错误示范N1查询 for (const order of orderList) { const user await ctx.model.User.findByPk(order.userId); }十笔订单就要查十次用户表一百笔就要查一百次。这个问题的标准解法就是把批量数据一次性查出来然后内存里做映射const orderList await ctx.model.Order.findAll({ where: { status: 1 } }); const userIds orderList.map(o o.userId); const users await ctx.model.User.findAll({ where: { id: { [Op.in]: userIds } }, }); const userMap users.reduce((map, user) { map[user.id] user; return map; }, {});我们把N1查询降成了两次查询速度提升非常直观。写代码的时候养成习惯无论如何不要在循环里执行await。连接池方面egg-sequelize默认的配置是基于sequelize缺省值的池大小是10。如果业务并发量上去了有时候会看到ECONNRESET或者连接获取超时的报错可以根据实际压力调整pool参数。注意pool.max不是越大越好每个连接都是数据库的资源开太多反而会拖垮MySQL实例。8. 常见问题速查与解决报错或现象原因解决方案SequelizeConnectionError: Client does not support authentication protocolmysql2版本和MySQL 8默认认证插件不兼容升级mysql2到2.x以上Unknown column user.createdAt模型中字段用了camelCase数据库列名是snake_case但define里没配underscore检查define配置开启underscored映射Maximum call stack size exceeded模型关联配置成循环引用了检查两个模型间的关联是否成环避免互相belongsTo对方时分秒变UTC时间对不上timezone没配或者配置成了00:00配置文件里加timezone: 08:00并且确保MySQL连接参数也一致表已存在但模型同步失败sequelize.sync只建表不改结构或者表名和模型名不一致用migration管理表结构确认freezeTableName配置Cannot read property define of undefinedmodel文件导出方式不对或者插件没启动检查model文件是否正确导出函数plugin.js里是否开启egg-sequelizeER_KEY_COLUMN_DOES_NOT_EXITS外键约束字段名填错了检查关联定义里的foreignKey是否对应真实的数据库列名Query timeout查询没加limit或者join的表数据量过大确认SQL是否走了索引必要时拆分查询第七项表名映射的问题我再展开细说一下。很多项目从老系统迁移过来表是别人建好的命名风格各不相同。比如某张表叫t_user_info模型叫UserInfo你光看模型找不到这张表肯定报错。这时定义模型的时候可以显式指定表名const UserInfo app.model.define(user_info, { // ... }, { tableName: t_user_info, freezeTableName: true, });模型名的第一个参数和tableName的区别是第一个参数是Sequelize内部标识tableName指定实际的数据库表名。多个模型共用一个表名也行这在某些权限模型拆分的场景下会用到但不建议随便用容易造成语义混乱。还有model文件的问题Egg的app/model目录下所有js文件都会自动加载但如果你在model文件里写的不是module.exports app {...}这种标准导出结构Egg直接启动失败。严格按这个格式来就不会有“模型找不到”的问题。9. 回到工程本身一些维护上的心里话Egg和Sequelize这一套搭配用了两年多下来最大的感受是它把“项目初期最大方方便、中后期最好约束”这个需求平衡得很好。刚开始写模型是有点繁琐字段一个个对应上去但等你上了几十张表之后所有的数据访问都遵循同样的模式和路径排查问题的效率会高很多。如果项目还在前期规划阶段我建议从第一天就把这些规矩立起来模型目录只放定义service里写事务controller只做参数校验和返回所有查询需要的字段用attributes明确指定关闭freezeTableName把表名固定好迁移文件从第一次建表就用起来。这些习惯前期不起眼等哪天上线了再做改造成本就高了。顺便说一句不管你的业务是什么形态数据库结构永远是系统的地基。ORM只是把操作地基的工具变顺手了不代表你可以放弃对索引和SQL执行计划的理解。Sequelize能帮你解决90%的重复工作剩下的10%涉及复杂报表、海量数据、性能优化时还是那句话把SQL日志打开把执行计划看清楚你会感谢自己当初没有因为懒而跳过这一步。