Sequelize Model数据类型选型指南:从契约思维避坑

发布时间:2026/10/15 16:55:07
Sequelize Model数据类型选型指南:从契约思维避坑
1. 项目概述为什么“sequelize中model的数据类型”值得花一整篇来聊在用 Sequelize 做 Node.js 后端开发的第三年我接手了一个老项目——数据库字段全是 VARCHAR(255)连用户注册时间都存成字符串。上线前压测订单查询慢得像在等泡面煮熟改个时间范围筛选SQL 直接报错“无法将 2023-01-01 转为日期”。最后翻源码才发现Model 里定义的是DataTypes.STRING而实际业务里它本该是DataTypes.DATE。这不是个别现象而是大量初学者甚至中级开发者踩过的典型坑把数据类型当成“填空题”而不是“契约声明”。“sequelize中model的数据类型”表面看只是Sequelize.INTEGER、Sequelize.STRING这几个常量的罗列但背后牵扯的是三重契约关系第一层是 JavaScript 对象与数据库字段的映射契约第二层是 ORM 框架对 SQL 类型的抽象契约第三层是业务逻辑对数据语义的隐含契约。比如你写age: { type: DataTypes.INTEGER }你以为只是告诉 Sequelize “存整数”但它实际会生成INTMySQL、INTEGERPostgreSQL、NUMBER(10,0)Oracle三种不同底层类型而如果你漏掉allowNull: false它默认允许 NULL但业务上“年龄不能为空”这个约束就彻底丢失了。我带过不少刚转 Node 的前端同学他们最常问的问题不是“怎么写关联查询”而是“为什么我存了个 12.34查出来变成 12.340000000000001”——答案就藏在DataTypes.DECIMAL(10,2)和DataTypes.FLOAT的根本区别里。前者是定点数精确到小数点后两位后者是浮点数遵循 IEEE 754 标准天然存在精度漂移。这不是 Sequelize 的 bug而是开发者没理解类型背后的数学语义。这篇内容就是为了解决这个问题不罗列 API 文档不照搬官方手册而是从真实项目现场出发讲清楚每种类型“什么时候必须用”、“为什么不能乱换”、“不写清楚会出什么事故”。适合所有正在用或准备用 Sequelize 的开发者尤其是那些已经能写增删改查、但一碰复杂业务就卡壳的人。2. 数据类型设计底层逻辑为什么 Sequelize 不直接用 SQL 原生类型2.1 ORM 的核心矛盾抽象 vs 精确Sequelize 是一个 ORM对象关系映射工具它的存在价值是让开发者用 JavaScript 对象思维操作数据库而不是写满INSERT INTO users (name, created_at) VALUES (?, ?)的原始 SQL。但这个“抽象”是有代价的过度抽象会丢失数据库原生能力过度贴近又失去 ORM 意义。数据类型正是这个矛盾最尖锐的战场。举个具体例子PostgreSQL 有JSONB类型支持索引和高效查询 JSON 内部字段MySQL 8.0 才支持JSON类型且功能较弱SQLite 根本没有原生 JSON 支持只能存为 TEXT。如果 Sequelize 强制要求你写DataTypes.JSONB那代码就彻底绑定 PostgreSQL失去跨数据库能力。所以它折中设计了DataTypes.JSON—— 在 PostgreSQL 里生成JSONB在 MySQL 里生成JSON在 SQLite 里生成TEXT并加校验逻辑。这个设计不是偷懒而是权衡用统一接口屏蔽差异用运行时适配兜底风险。再看另一个经典场景时间类型。MySQL 的DATETIME默认不带时区TIMESTAMP自动转为 UTC 存储PostgreSQL 的TIMESTAMP WITHOUT TIME ZONE和TIMESTAMP WITH TIME ZONE行为完全不同而 JavaScript 的Date对象天然带时区。Sequelize 的DataTypes.DATE默认对应TIMESTAMPMySQL或TIMESTAMP WITH TIME ZONEPostgreSQL并强制在写入前调用toISOString()转为 ISO 格式字符串。这看似多此一举实则是为了解决“时区混乱”这个高频线上故障——某次我们发现订单创建时间比服务器时间快 8 小时追查发现是前端传了本地时间字符串后端没做任何转换直接存库结果 MySQL 按系统时区解析导致全表时间偏移。2.2 类型系统的三层结构JavaScript → Sequelize → SQL理解 Sequelize 数据类型必须看清它的三层映射结构JavaScript 层这是你写的 Model 定义比如{ type: DataTypes.STRING(50), allowNull: false }。这里的所有配置本质是给 Sequelize 提供“元数据描述”告诉它“这个字段在 JS 里应该是什么样”。Sequelize 层框架内部会根据这些元数据结合当前连接的数据库方言dialect生成对应的 SQL 类型定义。比如DataTypes.STRING(50)在 MySQL 中转为VARCHAR(50)在 PostgreSQL 中转为VARCHAR(50)在 SQLite 中转为TEXT因为 SQLite 是动态类型长度限制靠应用层校验。SQL 层最终执行的 DDL建表语句和 DML增删改查语句。这里才是真正的“数据存储契约”。比如DataTypes.BOOLEAN在 MySQL 中生成TINYINT(1)0/1在 PostgreSQL 中生成BOOLEANtrue/false但 Sequelize 会确保true写入后读出来还是true无论底层怎么存。这个三层结构解释了为什么不能“只看 JS 层”。我见过最离谱的案例某团队在 Model 里写status: { type: DataTypes.ENUM(active, inactive) }测试环境用 SQLite 没问题上线 MySQL 后发现 ENUM 字段无法 ALTER TABLE 修改选项紧急回滚。问题根源在于他们只看了 JavaScript 层的“枚举语法糖”没意识到ENUM是 MySQL 特有类型PostgreSQL 用CHECK约束模拟而 SQLite 的 ENUM 实际是字符串校验。正确的做法是业务关键枚举一律用整数 字典表管理非关键状态用DataTypes.STRING加validate: { isIn: [[active, inactive]] }应用层校验。2.3 选型决策树面对一个新字段如何选择最合适的类型当你拿到一个需求“用户头像 URL最长 200 字符”不要急着写DataTypes.STRING(200)。先走一遍这个决策树它是否参与计算或排序如果只是展示用不参与 WHERE 条件或 ORDER BYSTRING足够但如果要按 URL 域名分组统计如GROUP BY SUBSTRING_INDEX(url, /, 3)就得考虑索引效率可能需要冗余字段存域名。它是否有明确的业务语义边界URL 长度 200 是硬限制吗RFC 3986 规定 URL 最大长度理论上无上限但浏览器通常限制在 2000 字符。200 显然太小实际应设为DataTypes.STRING(2000)或更保守的DataTypes.TEXT。它是否会被其他系统消费如果前端要直接渲染img src{{url}}那必须保证 URL 格式合法。此时STRING不够要加validate: { isUrl: true }否则恶意用户传javascript:alert(1)就 XSS 了。它是否涉及安全敏感操作头像 URL 可能被用于 SSRF服务端请求伪造所以入库前必须做白名单校验如只允许https://开头且域名在[qiniu.com, aliyuncs.com]内这已超出数据类型范畴需在 Controller 层拦截。这个决策树说明数据类型不是孤立配置而是整个数据治理链条的第一环。我习惯在写 Model 前先画一张“字段语义图”左边列业务规则如“必填”“唯一”“长度≤200”“格式为邮箱”右边列技术约束如“索引”“默认值”“是否可为空”中间用箭头标注 Sequelize 类型及验证规则。这样写出来的 Model才是真正可维护、可审计的契约文档。3. 核心数据类型详解与避坑指南从 STRING 到 VIRTUAL3.1 字符串类型STRING、TEXT、CHAR 的实战取舍DataTypes.STRING是最常用也最容易误用的类型。它的完整签名是DataTypes.STRING(length)其中length参数在不同数据库表现迥异MySQLSTRING(50)→VARCHAR(50)最大 50 字符存储可变长节省空间。PostgreSQLSTRING(50)→VARCHAR(50)行为同 MySQL。SQLiteSTRING(50)→TEXT长度参数被忽略全靠应用层校验。但问题来了STRING(50)和STRING无参数有什么区别很多人以为后者是“无限长”其实不然。Sequelize 的STRING默认长度是 255历史原因早期 MySQLVARCHAR最大 255。这意味着name: { type: DataTypes.STRING }实际生成VARCHAR(255)而name: { type: DataTypes.STRING(100) }生成VARCHAR(100)。关键避坑点如果你的业务要求“姓名最长 100 字”必须显式写STRING(100)否则默认 255 会误导后续维护者以为可以存更长内容。DataTypes.TEXT则完全不同。它不接受长度参数在所有数据库中都映射为大文本类型MySQLTEXT64KBPostgreSQLTEXT无上限SQLiteTEXT什么时候该用TEXT我的经验是当字段内容长度不可预测且不参与索引或 WHERE 精确匹配时。比如用户反馈内容、文章正文、JSON 配置字符串。但注意TEXT字段在 MySQL 中不能直接建普通索引需指定前缀长度如INDEX(content(255))所以如果要按内容关键词搜索得用全文索引FULLTEXT或接入 Elasticsearch。DataTypes.CHAR是个陷阱型类型。它生成固定长度字符串如CHAR(10)总占 10 字节不足补空格。Sequelize 官方文档甚至不推荐使用因为JavaScript 字符串没有“固定长度”概念存a和ab都是正常字符串空格填充会导致比较异常a ≠a除极少数场景如身份证号校验码、固定位数编码几乎无优势。我唯一用过CHAR的场景是存储国家代码ISO 3166-1 alpha-2必须严格 2 位且要区分CN 和CN。但即便如此我也加了validate: { len: [2, 2] }双保险。提示STRING和TEXT的性能差异在现代数据库中已不明显优先考虑语义清晰度。宁可多写STRING(2000)也不要为省几个字节用TEXT存短文本。3.2 数值类型INTEGER、FLOAT、DECIMAL 的数学本质数值类型是线上事故高发区根源在于 JavaScript 和数据库对数字的处理哲学不同。DataTypes.INTEGER看似简单但要注意它的子类型DataTypes.TINYINTMySQL 的TINYINT-128~127 或 0~255适合状态码如 0待处理, 1已完成。DataTypes.SMALLINTSMALLINT-32768~32767适合枚举数量较多的状态。DataTypes.INTEGER默认INT-2147483648~2147483647适合 ID、计数器。DataTypes.BIGINTBIGINT-9223372036854775808~9223372036854775807必须用于主键 ID因为 JavaScript 的Number.MAX_SAFE_INTEGER只有 9007199254740991超过此值的整数会丢失精度。曾有个项目用INTEGER存雪花 ID64 位结果前端拿到12345678901234567890JS 解析成12345678901234567000差了 890订单对不上。DataTypes.FLOAT和DataTypes.REAL是浮点数遵循 IEEE 754 标准。绝对禁止用于金额、分数、任何需要精确计算的场景。我亲眼见过一个电商系统商品价格存FLOAT结算时0.1 0.2 0.30000000000000004虽然显示四舍五入为 0.3但数据库里存的就是这个近似值导致财务对账总差几分钱。正确方案是DataTypes.DECIMAL(p, d)其中p是精度总位数d是小数位数。例如DECIMAL(10,2)表示最多 10 位数字其中 2 位小数范围是-99999999.99到99999999.99。它在数据库中以字符串或定点数形式存储完全避免浮点误差。所有金额字段必须用 DECIMAL并在应用层做严格校验如validate: { min: 0.01, max: 99999999.99 }。DataTypes.DOUBLE是双精度浮点数精度比FLOAT高但仍是浮点数同样不适用于精确计算。它存在的意义是兼容某些科学计算场景日常业务几乎不用。3.3 时间与日期类型DATE、DATEONLY、NOW 的时区战争时间类型是 Sequelize 里最烧脑的部分核心矛盾是“JavaScript Date 对象的时区感知”与“数据库存储的时区中立性”之间的鸿沟。DataTypes.DATE是默认选择它映射为MySQLTIMESTAMP自动转为 UTC 存储PostgreSQLTIMESTAMP WITH TIME ZONESQLiteDATETIME关键特性写入时自动调用date.toISOString()读取时自动 new Date(string)。这意味着你传new Date(2023-01-01T12:00:000800)Sequelize 会转为2023-01-01T04:00:00.000Z存库读出来时又转回本地时区的Date对象。这解决了“服务器时区不一致”的问题但引入了新问题如果前端传的是字符串2023-01-01Sequelize 会按本地时区解析导致跨时区用户看到的时间错乱。DataTypes.DATEONLY则完全不同它只存日期不存时间。生成DATE类型MySQL/PostgreSQL或TEXTSQLite值为2023-01-01。适合生日、合同生效日等不需要时间精度的场景。但注意它不支持NOW()默认值因为NOW()返回带时间的字符串。要设默认值得用defaultValue: sequelize.fn(CURRENT_DATE)。DataTypes.TIME存时间部分HH:MM:SS极少单独使用通常和DATEONLY组合实现“日期时间”分离存储。关于默认值DataTypes.NOW是个便捷写法等价于defaultValue: sequelize.fn(NOW)。但要注意NOW()是数据库函数执行时机在 SQL 层不是 JS 层。这意味着插入时数据库生成当前时间不受 Node.js 进程时钟影响更新时如果字段没在 UPDATE 语句中出现NOW()不会触发除非显式设updatedAt: sequelize.fn(NOW)。注意DataTypes.DATE的defaultValue: sequelize.fn(NOW)和defaultValue: DataTypes.NOW效果相同但后者更语义化。我建议统一用DataTypes.NOW避免混淆。3.4 布尔与枚举类型BOOLEAN、ENUM 的跨数据库陷阱DataTypes.BOOLEAN看似无害但跨数据库实现差异巨大MySQLTINYINT(1)0和1存储Sequelize 自动转换true/falsePostgreSQL原生BOOLEANtrue/falseSQLiteTINYINT靠应用层转换。这导致一个经典问题直接写原生 SQL 查询WHERE status 1在 MySQL 正常但在 PostgreSQL 会报错因为1不是布尔值。解决方案是永远用WHERE status true或在 Sequelize 查询中用status: true让框架生成兼容 SQL。DataTypes.ENUM是另一个雷区。它的语法是DataTypes.ENUM(value1, value2)但MySQL生成ENUM(value1,value2)修改选项需ALTER TABLE锁表风险高PostgreSQL生成CREATE TYPE enum_table_field AS ENUM (value1,value2)然后字段类型为该自定义类型修改需DROP TYPE重建SQLite不支持 ENUM降级为TEXT加validate: { isIn: [...] }。我的实践原则是业务核心状态如订单状态绝不依赖数据库 ENUM非核心标签如文章分类可用 ENUM但必须配套迁移脚本。比如新增状态shipped不能只改 Model还要写queryInterface.changeColumn(orders, status, DataTypes.ENUM(pending,paid,shipped))并在部署时执行。3.5 JSON 与二进制类型JSON、BLOB、VIRTUAL 的灵活边界DataTypes.JSON是现代应用的刚需但用法有讲究。它在不同数据库的映射PostgreSQLJSONB推荐支持索引和查询MySQLJSON5.7支持路径查询SQLiteTEXT纯字符串存储。关键优势支持嵌套结构且 Sequelize 自动序列化/反序列化。比如存用户设置settings: { type: DataTypes.JSON, defaultValue: { theme: light, notifications: true } }。写入时传{ theme: dark }Sequelize 会JSON.stringify后存库读取时自动JSON.parse成对象。但陷阱在于JSON 字段无法直接在 SQL 中做高效查询。想查“所有启用了通知的用户”不能写WHERE settings.notifications trueMySQL 5.7 支持JSON_CONTAINS但语法复杂更好的方案是冗余一个notifications_enabled: DataTypes.BOOLEAN字段用钩子同步更新。DataTypes.BLOB用于存二进制数据如图片、PDF。它映射为MySQLLONGBLOBPostgreSQLBYTEASQLiteBLOB。但强烈建议不要直接存大文件到数据库。BLOB 占用大量内存备份恢复慢扩展性差。正确姿势是存文件 URL 或云存储 Key用DataTypes.STRING即可。BLOB只用于必须和记录强绑定的小数据如用户头像缩略图100KB。DataTypes.VIRTUAL是最被低估的类型。它不对应数据库字段而是 JS 层的计算属性。语法fullName: { type: DataTypes.VIRTUAL, get() { return${this.firstName} ${this.lastName}; } }。好处是不占数据库空间可以在attributes中按需包含User.findAll({ attributes: [id, fullName] })支持where条件通过getterMethods配合sequelize.where。我常用它做数据脱敏phoneMasked: { type: DataTypes.VIRTUAL, get() { return this.phone?.replace(/(\d{3})\d{4}(\d{4})/, $1****$2); } }既保护隐私又无需额外字段。4. 实操全流程从零搭建一个健壮的 User Model4.1 需求分析与字段语义建模假设我们要建一个UserModel需求来自产品文档用户名必填3~20 字符字母数字下划线唯一邮箱必填格式合法唯一密码必填加密后存长度≥60bcrypt 生成手机号可选11 位数字生日可选只存日期状态必填枚举值active/inactive/pending创建时间必填自动设为当前时间更新时间必填自动更新头像 URL可选最长 2000 字符格式为 URL元数据可选存 JSON 格式的用户偏好设置。我先画一张语义表明确每个字段的业务契约字段名业务规则技术约束Sequelize 类型验证规则username必填3~20 字符字母数字下划线唯一索引非空STRING(20)len: [3,20],is: /^[a-zA-Z0-9_]$/email必填邮箱格式唯一索引非空STRING(255)isEmail: truepassword必填加密后存非空不返回STRING(255)len: [60,60]bcrypt 固定长phone可选11 位数字索引可选STRING(11)is: /^1[3-9]\d{9}$/birthday可选只存日期无DATEONLY—status必填枚举非空ENUMisIn: [[active,inactive,pending]]createdAt必填自动设自动DATEdefaultValue: DataTypes.NOWupdatedAt必填自动更新自动DATEdefaultValue: DataTypes.NOWavatar可选URL无STRING(2000)isUrl: truemetadata可选JSON无JSONvalidate: { isValidJson: (v) typeof v object }这张表是 Model 的“宪法”后续所有代码都围绕它展开。4.2 Model 代码实现与关键配置解析基于语义表写出完整的UserModeluse strict; module.exports (sequelize, DataTypes) { const User sequelize.define(User, { id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, // 注意autoIncrement 在 PostgreSQL 中需配合 SERIALSequelize 会自动处理 }, username: { type: DataTypes.STRING(20), allowNull: false, validate: { len: [3, 20], is: /^[a-zA-Z0-9_]$/ // 正则校验 }, unique: true // 数据库唯一索引 }, email: { type: DataTypes.STRING(255), allowNull: false, validate: { isEmail: true }, unique: true }, password: { type: DataTypes.STRING(255), allowNull: false, validate: { len: [60, 60] // bcrypt hash 固定 60 字符 } }, phone: { type: DataTypes.STRING(11), validate: { is: /^1[3-9]\d{9}$/ } }, birthday: { type: DataTypes.DATEONLY, // DATEONLY 不支持 NOW()所以不设默认值 }, status: { type: DataTypes.ENUM(active, inactive, pending), allowNull: false, defaultValue: pending }, createdAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, avatar: { type: DataTypes.STRING(2000), validate: { isUrl: true } }, metadata: { type: DataTypes.JSON, defaultValue: {} // 空对象避免 null } }, { // Model 选项 tableName: users, // 显式指定表名避免复数自动转换 timestamps: false, // 关闭 Sequelize 自动管理时间戳因为我们自己定义了 underscored: true, // 字段名转蛇形命名createdAt → created_at charset: utf8mb4, // 支持 emoji collate: utf8mb4_unicode_ci, hooks: { // 密码加密钩子 beforeCreate: async (user) { if (user.password) { user.password await bcrypt.hash(user.password, 12); } }, beforeUpdate: async (user) { if (user.changed(password)) { user.password await bcrypt.hash(user.password, 12); } } } }); // 关联定义示例 User.associate function(models) { User.hasMany(models.Order, { foreignKey: userId }); }; return User; };关键配置解析timestamps: false因为我们手动定义了createdAt和updatedAt必须关掉自动时间戳否则冲突。underscored: true这是最佳实践。Sequelize 默认驼峰命名createdAt但数据库约定蛇形created_at开启后自动转换避免手写field: created_at。charset: utf8mb4MySQL 5.5.3 支持必须设否则 emoji 存不进去。hooks密码加密必须在beforeCreate和beforeUpdate中且只在密码变更时执行user.changed(password)。4.3 迁移脚本编写与跨数据库适配Model 写完要生成建表 SQL。Sequelize CLI 生成的迁移文件需手动优化use strict; module.exports { up: async (queryInterface, Sequelize) { await queryInterface.createTable(users, { id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true, // 注意autoIncrement 在 PostgreSQL 中会生成 SERIALMySQL 中是 AUTO_INCREMENT }, username: { type: Sequelize.STRING(20), allowNull: false, unique: true, // 添加索引加速查询 validate: { len: [3, 20], is: /^[a-zA-Z0-9_]$/ } }, email: { type: Sequelize.STRING(255), allowNull: false, unique: true, validate: { isEmail: true } }, password: { type: Sequelize.STRING(255), allowNull: false, validate: { len: [60, 60] } }, phone: { type: Sequelize.STRING(11), validate: { is: /^1[3-9]\d{9}$/ } }, birthday: { type: Sequelize.DATEONLY }, status: { type: Sequelize.ENUM(active, inactive, pending), allowNull: false, defaultValue: pending }, created_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.fn(NOW) }, updated_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.fn(NOW) }, avatar: { type: Sequelize.STRING(2000), validate: { isUrl: true } }, metadata: { type: Sequelize.JSON, defaultValue: {} } }, { // 表选项 charset: utf8mb4, collate: utf8mb4_unicode_ci, // 为高频查询字段加索引 indexes: [ { name: idx_users_username, fields: [username] }, { name: idx_users_email, fields: [email] }, { name: idx_users_status, fields: [status] } ] }); }, down: async (queryInterface, Sequelize) { await queryInterface.dropTable(users); } };跨数据库适配要点Sequelize.fn(NOW)在所有数据库中都有效比Sequelize.NOW更底层推荐。indexes显式定义索引避免 ORM 自动生成的索引名不一致。charset只对 MySQL 有效PostgreSQL 用ENCODING UTF8但 Sequelize 会自动处理无需手动写。4.4 测试用例设计覆盖类型边界与异常场景Model 写完必须写测试。我用 Jest Sequelize Test Utils重点覆盖类型边界describe(User Model, () { let user; beforeEach(async () { await sequelize.sync({ force: true }); // 清库重来 }); test(should reject username too short, async () { await expect( User.create({ username: ab, // 少于 3 字符 email: testexample.com, password: password123 }) ).rejects.toThrow(/Validation error/); }); test(should reject invalid email, async () { await expect( User.create({ username: validuser, email: invalid-email, password: password123 }) ).rejects.toThrow(/Validation error/); }); test(should store and retrieve DATEONLY correctly, async () { const today 2023-01-01; const user await User.create({ username: testuser, email: testexample.com, password: password123, birthday: today }); expect(user.birthday).toBeInstanceOf(Date); // DATEONLY 存的是日期字符串但 Sequelize 会转为 Date 对象 expect(user.birthday.toISOString().split(T)[0]).toBe(today); }); test(should handle JSON metadata correctly, async () { const metadata { theme: dark, lang: zh-CN }; const user await User.create({ username: testuser, email: testexample.com, password: password123, metadata }); expect(user.metadata).toEqual(metadata); // 验证数据库存的是字符串 const raw await User.findByPk(user.id, { raw: true }); expect(typeof raw.metadata).toBe(string); }); });测试要点边界值测试username长度 2、21email格式错误类型转换测试DATEONLY存2023-01-01读出来是否还是当天JSON 序列化测试metadata写对象读出来是否相等且raw: true下是否为字符串。5. 常见问题排查与独家避坑技巧5.1 典型问题速查表问题现象可能原因排查步骤解决方案插入数据时报Invalid value字段类型与值不匹配如STRING字段传了null但allowNull: false1. 查 Model 定义2. 打印插入数据3. 检查allowNull和validate显式设allowNull: true或确保传值非空查询时间字段返回nullDATE字段值为0000-00-00 00:00:00MySQL 非法时间1. 查数据库原始值2. 检查 MySQLsql_mode是否含NO_ZERO_DATE在 MySQL 启动参数中移除NO_ZERO_DATE或改用NULLENUM 字段修改后旧数据丢失直接改 Model 的ENUM选项未执行迁移1. 查数据库SHOW COLUMNS FROM users2. 对比新旧 ENUM 值写迁移脚本用changeColumn或addConstraintJSON 字段查询慢直接在 SQL 中用 WHERE