主线环境:Node.js、TypeScript、Sequelize v6、MySQL、
mysql2。
关联的本质仍然是 MySQL 外键和 JOIN;Sequelize 负责描述关系、生成关联方法并组织查询结果。
完成本章后,你应该能够:
hasOne、belongsTo、hasMany、belongsToMany;foreignKey 和 through;include 进行预加载,避免 N+1 查询;以 Sakila 为例:
country 1 ─── N city 1 ─── N address 1 ─── N customer
actor N ─── N film
film_actor
city.country_id 存在于 city 表,因此:
Country.hasMany(City);City.belongsTo(Country)。film_actor 同时保存 actor_id 和 film_id,因此 Actor 与 Film 是多对多关系。
最重要的判断方法不是背 API,而是先问:外键实际存在哪张表?
| Sequelize API | 关系 | 外键位置 |
|---|---|---|
A.hasOne(B) |
A 一对一拥有 B | B 表 |
A.belongsTo(B) |
A 属于 B | A 表 |
A.hasMany(B) |
A 一对多拥有 B | B 表 |
A.belongsToMany(B) |
A 与 B 多对多 | 中间表 |
hasOne 和 belongsTo 都能表达“一对一”,区别是外键方向。hasMany 与 belongsTo 通常成对出现。belongsToMany 通常也要在双方各定义一次。
下面只保留本章相关字段:
import {
Association,
CreationOptional,
DataTypes,
ForeignKey,
HasManyGetAssociationsMixin,
InferAttributes,
InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
} from "sequelize";
const sequelize = new Sequelize("world", "app_user", "password", {
host: "127.0.0.1",
dialect: "mysql",
});
class Country extends Model<
InferAttributes<Country, { omit: "cities" }>,
InferCreationAttributes<Country, { omit: "cities" }>
> {
declare code: string;
declare name: string;
declare cities?: NonAttribute<City[]>;
declare getCities: HasManyGetAssociationsMixin<City>;
declare static associations: {
cities: Association<Country, City>;
};
}
class City extends Model<
InferAttributes<City, { omit: "country" }>,
InferCreationAttributes<City, { omit: "country" }>
> {
declare id: CreationOptional<number>;
declare name: string;
declare countryCode: ForeignKey<Country["code"]>;
declare population: number;
declare country?: NonAttribute<Country>;
}
Country.init(
{
code: {
type: DataTypes.CHAR(3),
primaryKey: true,
field: "Code",
},
name: {
type: DataTypes.STRING(52),
allowNull: false,
field: "Name",
},
},
{
sequelize,
tableName: "country",
timestamps: false,
},
);
City.init(
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
field: "ID",
},
name: {
type: DataTypes.STRING(35),
allowNull: false,
field: "Name",
},
countryCode: {
type: DataTypes.CHAR(3),
allowNull: false,
field: "CountryCode",
},
population: {
type: DataTypes.INTEGER,
allowNull: false,
field: "Population",
},
},
{
sequelize,
tableName: "city",
timestamps: false,
},
);
Country.hasMany(City, {
as: "cities",
foreignKey: "countryCode",
sourceKey: "code",
});
City.belongsTo(Country, {
as: "country",
foreignKey: "countryCode",
targetKey: "code",
});
这里有几个 TypeScript 关键点:
ForeignKey<Country["code"]> 表示该属性是指向 Country 主键的外键;NonAttribute<City[]> 表示 cities 是关联加载出的数据,不是 Country 表中的列;cities? 是可选的,因为普通查询不一定加载关联;CreationOptional<number> 表示自增 ID 创建时可以省略;HasManyGetAssociationsMixin<City> 描述 Sequelize 动态添加的 getCities() 方法。Country.hasMany(City) 不会自动等价于 City.belongsTo(Country)。如果两个方向都要查询和生成关联方法,应明确把两边都定义出来。
sourceKey、targetKey 和 foreignKey在上例中:
foreignKey: "countryCode" 指向 City 模型上的外键属性;sourceKey: "code" 表示 hasMany 从 Country 的哪个键出发;targetKey: "code" 表示 belongsTo 指向 Country 的哪个键。主键关联时 Sequelize 通常能推断 sourceKey/targetKey,显式书写有助于初学者理解,也适用于关联唯一非主键字段的情况。
注意区分模型属性名和数据库列名:关联配置通常使用模型属性 countryCode,而不是物理列名 CountryCode。列名转换由 field 负责。
hasOne 与 belongsTo假设业务表设计如下:
CREATE TABLE user_profile (
user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
bio VARCHAR(500) NULL,
CONSTRAINT fk_profile_user
FOREIGN KEY (user_id) REFERENCES app_user(id)
);
外键在 user_profile,因此:
User.hasOne(UserProfile, {
as: "profile",
foreignKey: "userId",
});
UserProfile.belongsTo(User, {
as: "user",
foreignKey: "userId",
});
若要保证真正的一对一,MySQL 层的外键列还必须有 PRIMARY KEY 或 UNIQUE 约束。仅在 Sequelize 中调用 hasOne,不等于数据库已经能阻止同一个用户插入多份 profile。
import {
BelongsToManyGetAssociationsMixin,
ForeignKey,
NonAttribute,
} from "sequelize";
class Actor extends Model<
InferAttributes<Actor, { omit: "films" }>,
InferCreationAttributes<Actor, { omit: "films" }>
> {
declare actorId: CreationOptional<number>;
declare firstName: string;
declare lastName: string;
declare films?: NonAttribute<Film[]>;
declare getFilms: BelongsToManyGetAssociationsMixin<Film>;
}
class Film extends Model<
InferAttributes<Film, { omit: "actors" }>,
InferCreationAttributes<Film, { omit: "actors" }>
> {
declare filmId: CreationOptional<number>;
declare title: string;
declare actors?: NonAttribute<Actor[]>;
}
class FilmActor extends Model<
InferAttributes<FilmActor>,
InferCreationAttributes<FilmActor>
> {
declare actorId: ForeignKey<Actor["actorId"]>;
declare filmId: ForeignKey<Film["filmId"]>;
}
各模型的 init() 省略后,关联定义如下:
Actor.belongsToMany(Film, {
through: FilmActor,
as: "films",
foreignKey: "actorId",
otherKey: "filmId",
});
Film.belongsToMany(Actor, {
through: FilmActor,
as: "actors",
foreignKey: "filmId",
otherKey: "actorId",
});
through 指定中间模型;当前调用方在中间表中的键是 foreignKey,另一方是 otherKey。反向定义时二者对调。
Sakila 的 film_actor 使用 (actor_id, film_id) 复合主键,能够防止相同演员和电影关系重复。生产项目中,中间表应至少具有联合唯一约束,不能只依靠应用代码防重。
如果中间表只有两侧外键,through 可以使用名称让 Sequelize 创建;但已有数据库和生产项目更建议显式模型。以下情况尤其需要显式模型:
role、sort_order、created_at 等业务字段;定义关联后,Sequelize 会在实例上添加方法。以 Country.hasMany(City, { as: "cities" }) 为例,常见方法包括:
country.getCities()country.countCities()country.hasCity(city) / country.hasCities(cities)country.setCities(cities)country.addCity(city) / country.addCities(cities)country.removeCity(city) / country.removeCities(cities)country.createCity(values)方法名称受关联别名的单复数影响。TypeScript 不会自动知道运行时新增的方法,需要使用 Sequelize 提供的 mixin 类型逐项声明。
不要为了省类型声明而把模型写成 any。可以只声明当前业务实际调用的方法,不必一次性声明全部 mixin。
include 生成 JOINconst countries = await Country.findAll({
attributes: ["code", "name"],
include: [
{
model: City,
as: "cities",
attributes: ["id", "name", "population"],
required: false,
},
],
where: {
code: ["CHN", "JPN"],
},
order: [
["code", "ASC"],
[{ model: City, as: "cities" }, "population", "DESC"],
],
});
近似 SQL:
SELECT ...
FROM country AS Country
LEFT OUTER JOIN city AS cities
ON Country.Code = cities.CountryCode
WHERE Country.Code IN ('CHN', 'JPN')
ORDER BY Country.Code ASC, cities.Population DESC;
required: false 通常生成 LEFT OUTER JOIN;required: true 通常生成 INNER JOIN,只保留有关联行的父记录。如果 include 自身设置 where,Sequelize 通常会把它视为必需关联,除非显式设置 required: false。
关联定义用了 as: "cities",查询时也必须使用同一个别名:
include: [{ model: City, as: "cities" }]
别名拼错是初学者最常遇到的关联错误之一。一个模型与同一目标模型有多条关系时,别名更是必需的,例如订单同时具有 buyer 和 seller。
下面的代码会先查国家,再为每个国家单独查一次城市:
const countries = await Country.findAll({ limit: 20 });
for (const country of countries) {
const cities = await country.getCities();
console.log(country.name, cities.length);
}
如果有 20 个国家,就可能执行 21 次 SQL,这就是常见的 N+1 查询。
改进方式通常是:
const countries = await Country.findAll({
limit: 20,
include: [{ model: City, as: "cities" }],
});
但 include 也不是越多越好。多个一对多关联同时 JOIN 会造成行数乘法膨胀。应查看生成 SQL和返回数据量,必要时拆成少量批量查询,而不是在循环中逐条查询。
父表与一对多子表 JOIN 后,一条父记录会展开为多行。limit 20 到底限制父记录还是 JOIN 后的行,会影响分页结果。Sequelize 可能使用子查询保证父模型分页,但复杂 include 下仍应检查生成 SQL。
生产建议:
distinct: true 就认为所有计数问题都解决。findAndCountAll 与一对多 include 搭配时尤其要测试 count 是否符合“父记录数量”的业务定义。
创建电影并添加演员属于多步写操作,应放进事务:
await sequelize.transaction(async (transaction) => {
const film = await Film.create(
{
title: "SEQUELIZE PRACTICE",
},
{ transaction },
);
const actors = await Actor.findAll({
where: {
actorId: [1, 2],
},
transaction,
});
await film.addActors(actors, { transaction });
});
示例假设 Film 已声明相应的 addActors mixin,且创建所需的其他 Sakila 必填字段已按实际模型补齐。
必须把同一个 transaction 传给创建、查询和关联写入。数据库外键、唯一约束会在并发场景中提供最终保护。
关联选项可以涉及:
Country.hasMany(City, {
foreignKey: {
name: "countryCode",
allowNull: false,
},
onUpdate: "CASCADE",
onDelete: "RESTRICT",
});
不要机械使用 CASCADE:
RESTRICT,避免误删扩大;CASCADE;SET NULL;sequelize.sync({ alter: true }) 适合学习实验,不应作为生产数据库变更方案。生产环境应使用迁移脚本,代码评审后明确创建外键与索引。
MySQL InnoDB 通常要求外键列有索引,但业务查询还可能需要复合索引。例如:
SELECT *
FROM city
WHERE CountryCode = 'CHN'
ORDER BY Population DESC
LIMIT 20;
可评估 (CountryCode, Population) 复合索引,而不只是分别创建两个单列索引。是否需要必须结合数据分布和 EXPLAIN。
关联性能排查顺序:
EXPLAIN,不要仅凭感觉加索引。真实项目常把模型拆成多个文件。如果每个模型文件导入另一个模型并立即创建关联,容易形成 JavaScript 循环依赖。
适合学习项目的简单做法:
这不是要求引入复杂架构,而是把“定义字段”和“连接模型关系”分成两个清楚阶段。
关联数据的事实来源仍是 MySQL。若以后缓存国家及城市等聚合 DTO:
关联越复杂,缓存失效范围越难判断。因此先优化 SQL、索引和 N+1,再决定是否引入 Redis。
include;as;foreignKey、otherKey 没有对调;sync({ alter: true }) 当作生产迁移工具;hasOne、belongsTo、hasMany 的方向;include 用于预加载,但必须关注 JOIN、分页和行数膨胀;ForeignKey、NonAttribute 和 mixin 描述关联;以下练习使用 MySQL Sakila 或 World 示例数据库:
Country 与 City 定义双向一对多关联,要求显式配置别名、外键和目标键,并补充必要的 TypeScript 关联属性。Customer 与 Address 定义关联。先说明外键在哪张表,再选择 belongsTo 或 hasOne,最后定义双向关系。include 查询 World 中的国家及城市,只返回人口大于 100 万的城市;分别尝试 required: true 和 required: false,观察国家结果的差异。Actor、Film、FilmActor 定义多对多关系,确保两边的 foreignKey 和 otherKey 正确对调。limit 和 count 的影响,提出可靠分页方案。EXPLAIN,评估 (CountryCode, Population) 复合索引是否有帮助。