FungLeo头像
关注
成为全栈·Node 后端篇·分类与标签:多对多关系的建模与查询封面图

成为全栈·Node 后端篇·分类与标签:多对多关系的建模与查询

成为全栈·Node 后端篇·分类与标签:多对多关系的建模与查询

一篇文章属于一个分类,这好办——加个 category_id 字段就行。可一篇文章能挂五个标签、一个标签下又躺着三百篇文章,这个关系该怎么存?很多人的第一反应是"在文章表里塞个 tags 字段,用逗号拼起来",然后就掉进了查询和计数的深坑。

成为全栈·Node 后端篇·分类与标签:多对多关系的建模与查询

这一篇讲分类与标签的建模:多对多关系怎么用中间表建、怎么查才不踩 N+1 的坑、以及那个让计数虚高的"某标签下有多少篇文章"该怎么精确算,顺带说清删除时那道"引用存在即拒"的守卫。

一、多对多怎么建:junction 表

先澄清一个常见混淆:分类和标签的建模方式其实不一样

  • **标签(tag)**是典型的多对多:一篇文章可以有多个标签,一个标签也可以挂在多篇文章上。所以必须有一张中间表 article_tags(article_id, tag_id) 来存这种"关系"。
  • 分类(category)在我们的设计里是一对多:一篇文章只属于一个分类(articles.category_id 直接挂在文章行上),而一个分类下可以有多篇文章。所以分类不需要中间表,外键列就够了。

把"一对多"和"多对多"分清楚,是建模的第一步。很多人本能地给分类也建一张中间表,结果多一层无意义的 JOIN——因为业务上"一篇文章多个分类"的需求根本不存在。

article_tags 的建模简单干净:

// schema.ts 节选
export const articleTags = sqliteTable(
  'article_tags',
  { articleId: integer('article_id'), tagId: integer('tag_id'), ... },
  (table) => [uniqueIndex('uniq_article_tag').on(table.articleId, table.tagId)],
);

uniq_article_tag 保证"同一篇文章不会重复关联同一个标签"。

多对多怎么建:junction 表

顺带提一个分类自关联的坑(P-33):分类是无限级树,它自己引用自己——categories.parent_id 指向同一张表的另一行。这种自引用如果直接在 schema 里写 .references(() => categories.id),Drizzle 推导出的 TS 类型会"成环"(TS7022 循环类型引用),连锁拖垮所有 import 了 categories 的模块。所以我们的 parent_id 只是一个普通整数字段,刻意不声明自引用外键,树的逻辑(找父、找子孙、防环)全部放到 src/services/category.ts 的纯函数里用全量数据算(M1-25 专门讲)。这一刀切得很值:牺牲一点"数据库帮你保证引用存在"的能力,换来了类型系统不炸。

那实际动手时怎么判断该用一对多还是多对多?给你一条朴素的标准:问"一边的单个实例,对应另一边几个实例"。一篇文章对应"一个分类"还是"多个分类"?我们的产品决定是"一个"——所以 category_id 直接挂字段。一篇文章对应"几个标签"?显然多个——所以必须中间表。再举一个反直觉的:评论(comment)和文章(article)是一对多(一条评论只属于一篇文章),但评论和用户(user)也是一对多(一个用户发多条评论)。什么时候会滑向多对多?比如"一个用户收藏多篇文章、一篇文章被多个用户收藏",这就是经典的收藏多对多,得有 favorites 中间表(我们确实有 favorites 表)。凡是两边都能是"多个",就老老实实建 junction 表,别贪省事把 id 拼成逗号字符串塞一个字段里——那才是真正的技术债。

二、N+1 问题:别"查列表再逐条查标签"

多对多查询最容易掉进的坑就是 N+1。假设你要在列表页展示 20 篇文章,每篇下面带它的标签:

// ❌ 经典 N+1:先查 20 篇文章,再循环 20 次去查各自的标签 → 1 + 20 = 21 次查询
const articles = await queryArticles(...);
for (const a of articles.list) {
  a.tags = await getTagsOfArticle(a.id); // 每次循环一次 DB 往返
}

文章一多,数据库往返次数跟着线性暴涨,延迟直接拉垮。正确的姿势是一次把关系捞回来

  • 过滤场景(“按标签筛选文章”):直接在文章列表查询里 INNER JOIN article_tags,一次查询搞定(M1-15 的 queryArticlesif (q.tag) rowsQuery.innerJoin(articleTags, ...) 就是这么干的)。
  • 聚合场景(“每个标签下有几篇文章”):用 GROUP BY 一次算出所有标签的计数(见第三节)。

核心心法一句话:凡是"列表 + 关联数据",优先用一条带 JOIN / 聚合的 SQL 一次取回,而不是在循环里逐条发查询。这是关系型数据库相对文档型最占便宜的地方——JOIN 和聚合是它天生的强项。关于"前端 state 和数据库表"的本体论差异,我在 领域建模那一篇 也聊过,可以先去温习。

N+1 问题:别"查列表再逐条查标签"

三、articleCount 精确计数(P-35)

"某个标签下有多少篇已发布文章"这个计数,是内容站的标配(标签云、分类页都要用)。它有个非常经典的错误实现,值得每个后端开发者刻进肌肉记忆:

// ❌ 用 JSON 子串匹配:tags 以 JSON 字符串存,想找带 "js" 标签的文章就 LIKE '%js%'
const rows = await db.select().from(articles).where(like(articles.tags, '%js%')).all();

问题在哪?tags 字段存的是 ["js", "vue", "json"] 这样的 JSON 数组字符串。LIKE '%js%'误命中 jsonvue.jsajson——只要字符串里出现 “js” 子串就算数。更糟的是,一个标签名恰好是另一个的前缀时,计数就全乱了。这就是 P-35 要消灭的"JSON 子串误匹配"。

举个具体的虚高例子:假设全站有 100 篇带 json 标签的文章、30 篇带 js 标签的文章,二者本不相干。用 LIKE '%js%' 一查,会一次性返回 130 篇——把 json 文章全算成了 js。标签云里 js 的计数凭空虚高 3 倍多,前端排序、热度推荐全跟着错。而改成 article_tagstag_id = (js的id) 的精确 count(*)js 就是 30、json 就是 100,分毫不差。

我们的正解(P-35)是从关联表聚合

// tag.ts — tagArticleCounts
const rows = await getDb()
  .select({ tagId: articleTags.tagId, count: sql<number>`count(*)` })
  .from(articleTags)
  .innerJoin(
    articles,
    and(
      eq(articleTags.articleId, articles.id),
      eq(articles.status, 'published'),   // 只数已发布
      isNull(articles.deletedAt),          // 排除软删
    ),
  )
  .groupBy(articleTags.tagId)
  .all();

直接从 article_tags JOIN 已发布、未软删的文章,按 tagId 分组 count(*)。这样:

  1. 计的是精确的关系,不再受标签名子串干扰;
  2. 自动只数已发布文章——一篇还在 pending 的文章不会被算进标签云;
  3. 软删文章也被 isNull(deletedAt) 排除,计数和前台展示始终一致。

分类的计数同理(category.tsgetCategoryStats):用 LEFT JOIN articles 并带上 status='published' AND deleted_at IS NULL 条件,再 GROUP BY categories.idLEFT JOIN 而不是 INNER JOIN,是为了让"零文章的分类"也能出现在统计里(计数为 0),而不是被 JOIN 掉。

注意:文章正文里仍保留了去规范化的 tags JSON 字段(兼容旧逻辑),但精确匹配和计数一律走关联表。去规范化便于"文章详情直接带标签数组"这种读场景,关联表负责"按标签精确检索/计数"——两者各司其职,这在 M1-31 建模手艺篇会再升华。

落到读路径上分工更清楚:文章详情页要展示"这篇文章有哪些标签",直接从行里的 tags JSON 解析即可(单表单行,零 JOIN,最快);而"按标签筛选文章列表"或"标签云计数"这类需要跨文章聚合的场景,才走 article_tags 关联表。把"单文档内自包含的展示数据"和"需要跨文档关联的查询数据"分开存储,正是去规范化在真实项目里最实用的样子。

四、删除守卫:引用存在即拒(P-28)

中间表带来一个绕不开的问题:**有文章引用着这个分类/标签时,能不能删?**答案是不能——至少不能"无脑硬删"。

deleteCategory 在真删之前,要查两道引用:

// category.ts — deleteCategory
const child = (await db.select(...).from(categories).where(eq(categories.parentId, id)).limit(1).all())[0];
if (child) throw new AppError(ErrCode.CONFLICT, 409); // 还有子分类 → 拒删

const ref = (await db.select(...).from(articles)
  .where(and(eq(articles.categoryId, id), isNull(articles.deletedAt)))  // ← 排除软删
  .limit(1).all())[0];
if (ref) throw new AppError(ErrCode.CONFLICT, 409); // 还有文章归属 → 拒删

两个细节值得记:

第一,查引用必须 isNull(deletedAt) 排除软删(P-28 的核心)。否则一篇已软删(数据还在库里、只是不可见)的文章,会让它的分类"永远删不掉"——明明前台早就看不见这篇文章了,后台却因为这条僵尸引用卡住删除。排除软删,才和前台"文章已消失"的观感对齐。

第二,标签删除 deleteTag 同样先查 article_tags 里还有没有引用,有就 409(3002) 拒删。这叫"引用存在即拒",对应契约的 x-cascade:none——我们不搞级联删除(把文章连带标签一起删了是灾难),而是要求调用方先清理引用,再回来删节点。干净的引用关系,是数据不腐化的底线。

五、写入入口唯一(P-32 钩子)

最后收个尾:既然读取端都从关联表取数,那写入端也必须只有一处去维护 article_tags,否则容易出现"文章改了标签、关联表却没跟上"的数据漂移。这就是 M1-15 讲的 syncArticleTags——创建/更新文章、以及离线回填脚本,全都调它一个函数,先清旧关联再覆盖插入。读写都收敛到关联表,多对多关系才算真正"立"住了。

六、小结与前瞻

分类与标签,是关系型建模的入门考题:

  1. 一对多 vs 多对多:分类是一对多(articles.category_id 直接挂外键),标签是多对多(中间表 article_tags)。别给一对多也瞎建中间表。
  2. P-33:分类自引用 parent_id 刻意不声明 .references,避免 Drizzle TS 类型成环(TS7022);树逻辑交给纯函数。
  3. N+1 规避:列表 + 关联数据,优先一条 JOIN / 聚合 SQL 取回,绝不在循环里逐条查。
  4. P-35 精确计数tagArticleCountsarticle_tags JOIN 已发布未删文章 GROUP BY 计数;告别 LIKE '%js%' 误命中 json 的尴尬;分类用 LEFT JOIN 保留零文章分类。
  5. P-28 删除守卫:删分类/标签前查引用,isNull(deletedAt) 排除软删;引用存在即 409(3002) 拒删(x-cascade:none)。
  6. P-32 写入入口唯一syncArticleTags 是标签关联唯一写入源。

下一篇({{LINK:M1-17}})我们进"列表接口三件套":分页、筛选、排序。这里面藏着白名单防注入、SCAN_LIMIT 封顶、ORDER BY 必须显式限定基表(JOIN 后裸列会 ambiguous column 报 500)等一串实战细节。


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

成为全栈专栏订阅

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/FungLeo/article/details/164425616

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--