对应官方文档:Model Instances
完成本章后,你应该能够:
build()、save() 和 create();set()、save()、update()、reload() 和 destroy();本章沿用上一章的 Sakila Actor 模型,并补充 World City 模型用于数值增减示例。
本文带 await 的片段默认写在 async 函数、Express 异步路由或服务层方法中。当前工程使用 CommonJS 编译模式,不应把它们直接作为文件顶层的 await 语句。
当 Sequelize 查询到 actor 表的一行数据时,默认不会只返回一个普通对象,而是返回 Actor 类的实例:
const actor = await Actor.findByPk(1);
如果查询成功,actor 中既有数据,也有实例方法:
if (actor !== null) {
console.log(actor.firstName);
await actor.reload();
await actor.update({ lastName: "SMITH" });
}
实例通常包含:
save()、update()、destroy() 等持久化方法;因此,不建议通过 console.log(actor) 判断所有业务数据,也不要依赖 _previousDataValues 等内部属性。需要普通对象时使用公开 API。
build():只在内存中构造const actor = Actor.build({
firstName: "ALICE",
lastName: "CHEN",
});
这一步不会执行 INSERT。对象只存在于当前 Node.js 进程内存中:
console.log(actor.isNewRecord); // true
只有保存后才会写入 MySQL:
await actor.save();
console.log(actor.isNewRecord); // false
console.log(actor.actorId); // MySQL 返回的自增主键
大致 SQL:
INSERT INTO `actor` (`actor_id`, `first_name`, `last_name`, `last_update`)
VALUES (DEFAULT, 'ALICE', 'CHEN', CURRENT_TIMESTAMP);
注意:实际 SQL 可能使用参数绑定,并因模型默认值和方言细节而不同。
create():build() 与 save() 的快捷方式const actor = await Actor.create({
firstName: "BOB",
lastName: "WANG",
});
概念上等价于:
const actor = Actor.build({
firstName: "BOB",
lastName: "WANG",
});
await actor.save();
区别是 create() 一次完成构造和持久化,适合普通新增;build() 适合保存前还要执行多步同步处理、调用实例方法或检查实例状态的情况。
fields 建立写入白名单假设 HTTP 请求体是:
interface CreateActorBody {
firstName: string;
lastName: string;
actorId?: number;
}
不要未经筛选就把整个请求体传给模型。可以明确允许写入的字段:
const body: CreateActorBody = req.body;
const actor = await Actor.create(
{
firstName: body.firstName,
lastName: body.lastName,
},
{
fields: ["firstName", "lastName"],
},
);
这既使代码意图明确,也能降低客户端越权修改主键、权限或审计字段的风险。字段白名单不能取代请求校验和权限判断。
const actor = await Actor.findByPk(1);
if (actor !== null) {
console.log(actor.actorId);
console.log(actor.firstName);
}
这是 TypeScript 项目中最直观的方式。
get() 与 toJSON()if (actor !== null) {
const firstName = actor.get("firstName");
const plainActor = actor.get({ plain: true });
const jsonActor = actor.toJSON();
console.log(firstName, plainActor, jsonActor);
}
get("firstName") 获取单个属性,并会执行已定义的 getter;get({ plain: true }) 返回普通对象;toJSON() 返回适合 JSON 序列化的公开数据表示。Express 中可以执行 res.json(actor),因为 JSON 序列化会调用 toJSON();但在服务层显式转换为 DTO 往往更安全,因为可以稳定控制对外字段。
interface ActorDto {
id: number;
fullName: string;
}
function toActorDto(actor: Actor): ActorDto {
return {
id: actor.actorId,
fullName: `${actor.firstName} ${actor.lastName}`,
};
}
不要把 Sequelize 实例直接写入 Redis。若以后需要缓存查询结果,应缓存稳定的普通 DTO/JSON,并在成功提交 MySQL 更新后失效或更新缓存。
save()可以先修改实例属性,再调用 save():
const actor = await Actor.findByPk(1);
if (actor !== null) {
actor.firstName = "PENELOPE";
await actor.save();
}
Sequelize 会追踪哪些属性发生了变化,通常只更新变化的列。大致 SQL:
UPDATE `actor`
SET `first_name` = 'PENELOPE'
WHERE `actor_id` = 1;
Sakila 的 last_update 是否自动变化,取决于真实 MySQL DDL、模型设置以及数据库默认行为。应以日志和数据库结构为准。
if (actor !== null) {
actor.lastName = "GUINESS";
console.log(actor.changed("lastName")); // true
console.log(actor.previous("lastName")); // 上一次持久化时的值
await actor.save();
console.log(actor.changed("lastName")); // 通常为 false
}
changed() 和 previous() 适合调试、审计逻辑或有条件地执行副作用,但不要依赖未公开的内部存储结构。
save() 更新字段if (actor !== null) {
actor.firstName = "MARY";
actor.lastName = "JONES";
await actor.save({ fields: ["firstName"] });
}
这次只持久化 firstName。内存中的 lastName 与数据库可能暂时不同,随后应谨慎处理,必要时调用 reload()。
set():批量设置实例属性if (actor !== null) {
actor.set({
firstName: "NICK",
lastName: "WAHLBERG",
});
await actor.save();
}
set() 默认只改内存,不会自动执行 SQL。它与 save() 是两个步骤。
如果数据来自 HTTP 请求,仍应先做校验和字段挑选:
actor.set({
firstName: body.firstName,
lastName: body.lastName,
});
不推荐 actor.set(req.body)。这种批量赋值很容易把以后新增的敏感字段暴露给客户端。
update():设置并立即保存const actor = await Actor.findByPk(1);
if (actor !== null) {
await actor.update({
firstName: "ED",
lastName: "CHASE",
});
}
实例方法 update() 会修改实例并持久化。可以把它理解为常见场景下 set() + save() 的快捷方式。
请区分:
await actor.update({ firstName: "ED" }); // 实例方法,通常带主键条件
await Actor.update(
{ firstName: "ED" },
{ where: { actorId: 1 } },
); // 静态批量方法,必须认真编写 where
静态 Model.update() 返回的不是更新后的模型实例。在 MySQL 中通常可从返回值获得受影响行数;如果需要最新行,应再查询或在已有实例上使用实例方法并按需 reload()。
reload():以数据库为准重新读取const actor = await Actor.findByPk(1);
if (actor !== null) {
actor.firstName = "NOT_SAVED";
await actor.reload();
console.log(actor.firstName); // 恢复为 MySQL 中的值
}
reload() 会重新执行 SELECT,并用数据库结果覆盖当前实例。常见用途:
它并不能解决并发写覆盖问题。并发修改需要根据业务使用事务、行锁或乐观锁等方案。
save() 的校验与保存范围save() 默认运行模型校验。若校验失败,SQL 不会执行:
try {
await actor.save();
} catch (error: unknown) {
console.error("保存失败", error);
}
可以通过 validate: false 跳过模型校验,但普通业务代码很少应该这样做:
await actor.save({ validate: false });
即便 Sequelize 校验通过,MySQL 仍可能因唯一约束、外键约束、非空约束或死锁而拒绝写入,所以数据库错误处理不可省略。
destroy():删除当前实例对应的记录const actor = await Actor.findByPk(201);
if (actor !== null) {
await actor.destroy();
}
大致 SQL:
DELETE FROM `actor` WHERE `actor_id` = 201;
如果该演员已经被 film_actor 外键引用,MySQL 可能拒绝删除。这正是关系数据库约束在保护数据完整性。
工程中应先明确:
不要为了“让删除成功”而随意关闭外键检查。
increment() 与 decrement()World 数据库的 city.Population 是适合演示原子增减的数值列。简化模型如下:
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
} from "sequelize";
import { sequelize } from "./sequelize";
class City extends Model<
InferAttributes<City>,
InferCreationAttributes<City>
> {
declare id: CreationOptional<number>;
declare name: string;
declare countryCode: string;
declare district: string;
declare population: number;
}
City.init(
{
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true,
field: "ID",
},
name: { type: DataTypes.STRING(35), allowNull: false, field: "Name" },
countryCode: {
type: DataTypes.CHAR(3),
allowNull: false,
field: "CountryCode",
},
district: {
type: DataTypes.STRING(20),
allowNull: false,
field: "District",
},
population: {
type: DataTypes.INTEGER,
allowNull: false,
field: "Population",
},
},
{
sequelize,
tableName: "city",
timestamps: false,
},
);
执行增减:
const city = await City.findByPk(1);
if (city !== null) {
await city.increment("population", { by: 1000 });
await city.reload();
console.log(city.population);
}
大致 SQL 使用列自身计算:
UPDATE `city`
SET `Population` = `Population` + 1000
WHERE `ID` = 1;
这比“先在 Node.js 中读取数值,再计算并保存”更能避免并发请求互相覆盖。decrement() 的用法相同。
不同数据库方言对 increment() 后返回值的处理可能不同。在 MySQL 中,如果后续逻辑依赖最新值,明确调用 reload() 更容易理解。
虽然 Sakila 和 World 的核心表没有典型 JSON 列,但实际项目经常使用 MySQL JSON。假设实例属性 settings 是对象,直接修改内部属性可能无法被识别为变化:
user.settings.theme = "dark";
await user.save();
更稳妥的方法是替换整个对象:
user.settings = {
...user.settings,
theme: "dark",
};
await user.save();
也可以在明确理解后手动标记:
user.changed("settings", true);
await user.save();
这是因为 ORM 的变更追踪通常更容易识别顶层引用变化,而不是任意深度的对象突变。
单个实例 save() 只保证一条写入的数据库行为。若业务需要“新增租赁记录并更新库存状态”同时成功,就必须使用同一个事务:
await sequelize.transaction(async (transaction) => {
const rental = await Rental.create(
{
rentalDate: new Date(),
inventoryId,
customerId,
staffId,
},
{ transaction },
);
await inventory.update(
{ available: false },
{ transaction },
);
console.log(rental.rentalId);
});
关键点:事务对象必须传给事务内的每一个 Sequelize 操作。漏传一次,该 SQL 就会使用事务外的连接,导致“部分提交”。模型实例本身不会自动让多个 save() 形成一个事务。
实例只代表某次读取时的数据库状态。不要在全局变量中长期保存它,否则内容会过期,也可能意外保留大量关联数据。
const city = await City.findByPk(1);
city.population += 1;
await city.save();
两个请求同时执行时可能丢失更新。简单计数使用 increment();复杂一致性逻辑使用事务和合适的锁或乐观锁。
save() 不是“保存整个 JavaScript 对象”它只会按照模型定义、变更状态和 fields 选项生成 SQL。未定义属性不会自动成为数据库列。
模型实例可能包含密码哈希、令牌、内部备注等字段。日志和 API 响应都应该输出经过挑选的 DTO,而不是整个实例。
build() 不执行 SQL,而 create() 会执行 INSERT?set() 只改内存,update() 会持久化?changed()、previous() 和 reload() 判断实例状态?increment() 或事务,而不是简单读改写?Actor.build() 构造一名 Sakila 演员,在保存前后分别检查 isNewRecord 和 actorId,并记录执行了几条 SQL。Actor.create() 新增一名演员,只允许写入 firstName 和 lastName。尝试在输入中加入 actorId,验证字段白名单的效果。save({ fields: [...] }) 保存其中一项;随后使用 reload() 比较内存值和数据库值。changed() 和 previous(),记录修改前、保存前、保存后三个阶段的结果。City 模型,将某城市人口原子增加 500,再 reload() 并验证结果。不要使用“读取后 + 500 再保存”的写法。city 记录,将其转换为只包含 id、name、population 的普通 DTO,并说明为什么这个对象比模型实例更适合返回给前端。rental 表设计一个“归还影片”的实例更新流程:设置 returnDate,保存后重新读取;列出并发归还时可能出现的业务问题。payment 后更新相关客户统计字段(统计字段可放在自建练习表),故意让第二步失败,验证第一步是否回滚。