成为全栈·Go 后端篇·分类树与标签关系:目录变更为何需要协调
分类和标签看似是两张简单的字典表,但文章既保存外键关系,也保留面向列表的冗余展示字段。目录改名、移动和删除会触及文章数据,不是单行 CRUD。

本文代码快照:提交 ceac4e0。分类最多四级;标签和文章关联依据冻结契约与现有关系表。
前言:树结构的数据约束会随着修改传播
动手写分类管理时,我在纸上画了一遍"把 A 挂到 A 的孩子下面"会发生什么,画到第三层就停了------整棵树成了环,页面根本渲染不出来。
分类是有父子关系的目录,契约限制最多四级。管理员把一个节点移动到另一个父级时,不仅要避免把它移到自己的后代造成环,也要检查它整棵子树的新深度是否超过上限。分类改名或 slug 后,文章表里为了展示保存的 category_name/category_slug 需要同步更新。
标签也有两种表示:article_tags 是可查询的真实关系;文章的 tags JSON 字段是既有响应所需的展示投影。修改标签目录项,需要维护已有文章的投影;删除仍被引用的标签则应拒绝。两种数据职责不同,变化必须在同一事务中协调。
M6 将分类、标签管理放在 taxonomy 包,通过事务更新实体和文章投影,并在单个 Service 实例内串行化目录写入。本文讲清楚这套实现保护什么,以及进程 mutex 的边界在哪里。
一、把最大深度和无环约束写成校验
validateTree 先把所有分类按 ID 建索引,再从每个节点沿 ParentID 向上走。遍历过程中使用 seen 记录已经访问的祖先;再次遇到同一 ID 就说明形成环。每向上一层增加 depth,超过 4 就拒绝。
校验函数本体如下:
go
// internal/taxonomy/categories.go(validateTree)
func validateTree(rows []model.Category) error {
by := map[int64]model.Category{}
for _, c := range rows {
by[c.ID] = c
}
for _, c := range rows {
depth := 1
seen := map[int64]bool{c.ID: true}
p := c.ParentID
for p != nil {
if seen[*p] {
return fault.New(fault.Conflict)
}
seen[*p] = true
parent, ok := by[*p]
if !ok {
return fault.New(fault.NotFound)
}
depth++
if depth > 4 {
return fault.New(fault.Conflict)
}
p = parent.ParentID
}
}
return nil
}
by 是 ID 到节点的索引,把父链查找从逐个扫描压成一次哈希查找。外层遍历每个节点,内层以它为起点沿 ParentID 一路向上。seen 装的是这条路径上已经走过的 ID,起始值包含节点自己;向上走的过程中如果再遇到 seen 里的 ID,说明父链绕回了自己,直接返回冲突。
depth 从 1 开始,每上升一层加一,超过 4 立刻拒绝。这里用 4 而不是别的数字,是因为它来自冻结契约;界面可能只展示四层导航,但服务端校验的是数据本身,直接调 API 的请求同样要被挡住。
注意 validateTree 是对整份快照做检查,而不是只检查被改动的那个节点。调用方把调整后的节点放进快照再整体验证,这样"移动某个子树导致其后代超过深度限制"这类间接影响也能被发现。
几种非法输入会落在不同的检查点上:
| 输入情形 | 命中的检查 | 返回 |
|---|---|---|
| 父链绕回自身 | seen 判重 |
冲突 |
| 移动子树后后代超过四层 | depth > 4 |
冲突 |
parentId 指向不存在的分类 |
by 查不到 |
不存在 |
| 新建节点造成第五层 | 临时节点入快照后校验 | 冲突 |
| 合法移动,深度与层级都不变 | 全部通过 | 写入 |
更新父级时,仅检查"新 parent 不是当前节点"还不够。例如 A 的子节点 B 移入深度为 4 的 C 下面,B 自己可能是第 2 层,但它的后代会超过深度限制。对全树逐节点重新验证,覆盖了变更影响到的所有后代路径。
创建新分类时,服务把待创建节点临时放进待验证集合,使用临时 ID 表达其树位置;通过后才执行 INSERT。更新操作则将修改后的节点放入当前树快照验证,再写回数据库。数据库约束与业务校验共同保护数据,但环和最大深度是跨行拓扑规则,单一行 CHECK constraint 不够表达。
最大深度来自冻结契约。界面可能只显示四层导航,但服务端仍要验证直接 API 请求,不能把客户端组件当成数据完整性门槛。

二、树查询与面包屑是不同投影
Categories 返回平面目录;Tree 根据 sortOrder、ID 稳定排序,再递归组装 children。叶子 children 是空数组而不是 null。Breadcrumb 则从当前节点向父链回溯,再按根到叶子顺序输出 id/name/slug。公开分类列表还可能包含文章数等统计字段。
这些输出是同一分类实体的不同投影。树结构依赖 ParentID,排序和展示依赖名称、slug、sortOrder,文章数依赖公开且未删除文章。不能把一个全量 Category ORM struct 直接序列化后声称它适用于所有接口。
树投影的组装逻辑是这样:
go
// internal/taxonomy/categories.go(Tree 的排序与递归,节选)
sort.Slice(rows, func(i, j int) bool {
if rows[i].SortOrder != rows[j].SortOrder {
return rows[i].SortOrder < rows[j].SortOrder
}
return rows[i].ID < rows[j].ID
})
seen := map[int64]bool{}
var build func(*int64) []map[string]any
build = func(parent *int64) []map[string]any {
out := []map[string]any{}
for _, c := range rows {
match := parent == nil && c.ParentID == nil || parent != nil && c.ParentID != nil && *parent == *c.ParentID
if match && !seen[c.ID] {
seen[c.ID] = true
n := Category(c)
delete(n, "parentId")
n["children"] = build(&c.ID)
out = append(out, n)
}
}
return out
}
return build(nil), nil
排序用了两个键:先比 SortOrder,相同时再比 ID。第二个键保证结果确定------只按 SortOrder 排序时,两个排序值相同的节点可能在不同数据库或不同查询计划下换位,接口输出就会抖动。
build 是递归闭包,没有把"根"写死成某个具体 ID,而是用 parent == nil 表示顶层。match 那个长条件把"父为 NULL 的顶层节点"和"父等于传入 parent 的节点"合并成一次判断。seen 在这里同样有用:写入端的 validateTree 已经不允许环,读取端仍带着已访问集合,万一库中残留历史坏数据,递归会在绕圈后停下,而不是无限展开把请求挂死。
delete(n, "parentId") 是有意为之:树形输出用 children 表达层级,再带一个 parentId 会形成两套冗余的层级信息。叶子节点的 children 是空切片而不是 nil,因为 out := []map[string]any{} 一开始就把它初始化成了非 nil 的数组。
Tree 构造使用 seen 以避免意外循环导致无限递归,即使写入校验本来不允许环。读取端防御和写入端约束作用不同:前者避免坏数据造成请求卡死,后者阻止坏数据进入数据库。
三、category_id 与名称投影要一起改变
文章保存 category_id 外键,也保存 category_name 与 category_slug,便于列表投影和既有契约输出。更新文章分类时,服务从事务里的分类表读取最新名称/slug,并和 category_id 一起更新。分类自身改名时,saveCategoryTx 在同一事务内更新所有未软删除且关联该分类的文章投影。
改名传播的落点就是这段连续写入:
go
// internal/taxonomy/categories.go(saveCategoryTx 的投影同步,节选)
if err := tx.Model(&model.Category{}).Where("id = ?", id).Updates(map[string]any{
"name": c.Name,
"slug": c.Slug,
"description": c.Description,
"parent_id": c.ParentID,
"sort_order": c.SortOrder,
"updated_at": c.UpdatedAt,
}).Error; err != nil {
return err
}
return tx.Model(&model.Article{}).
Scopes(database.ActiveArticles).
Where("category_id = ?", id).
Updates(map[string]any{
"category_name": c.Name,
"category_slug": c.Slug,
}).Error
两次 Updates 在同一个事务里先后发生。第一次改分类目录本身:名称、slug、描述、父级和排序值。第二次改文章表里的投影,条件是 category_id = ?,并套了 ActiveArticles scope,因此只触及未软删除的文章。更新的两列直接取新的 c.Name 与 c.Slug。
两次写入之间没有提交点,所以不会出现"目录已经改名、文章仍是旧名"的中间状态被其他请求读到。如果第二次 Updates 返回错误,函数把错误交给 Save 里的事务回调,整个事务回滚,第一次的目录改动也一并撤销。
已软删除的文章不在更新范围内,它们本来就不会出现在任何列表中,没必要跟着改投影。
如果只改分类目录,文章列表会继续展示旧名称;如果只更新文章的 category_id,其他请求可能见到新 ID 搭配旧 category_name。事务将实体目录与冗余展示字段维持一致。若后续取消冗余设计,需要迁移与查询性能评估,而不是只删除两个列。
删除分类前检查是否仍有子节点,且没有未软删除文章引用。存在引用时返回冲突,不静默把文章改成未分类,也不绕过关系约束。
四、article_tags 是标签关系,JSON 是兼容投影
标签关系通过 article_tags 精确存储,可用于 EXISTS 筛选和引用约束。文章 tags JSON 则保留名称或 slug 文本,供既有响应表示。查询与筛选不应把文本 JSON 当成可靠关系来源,因为改名、重复值和数据库 JSON 能力会增加一致性难度。
两种表示的分工可以并排看:
| 表示 | 存储位置 | 承担的作用 | 变更时的处理 |
|---|---|---|---|
| 真实关系 | article_tags 表 |
筛选、引用计数、删除保护 | 事务内先清后写 |
| 展示投影 | articles.tags JSON 列 |
既有响应中的标签文本 | 改名时逐篇替换 |
article_tags 是关系事实,写入时用"先删后插"保证它与提交的标签集合一致;articles.tags 是投影,只在标签改名时做文本替换,它本身不代表新关系,只是让响应不必每次都联表。判断某个标签能不能删,依据的是 article_tags 里的引用数,而不是 JSON 里有没有出现过这个字符串;判断文章该展示哪些标签,才去看 JSON 投影。两条路径互不代替。
改名标签时,服务记录 oldName 与 oldSlug,更新标签表,然后沿 article_tags 找到关联文章,把其 JSON 投影里匹配到的旧名称或旧 slug 替换为新值。整个过程在同一事务中。JSON 解析失败时当前实现跳过该项;这类历史数据异常要靠专项数据核查和修复处理,不应误称关联表不存在。
投影替换的实现如下:
go
// internal/taxonomy/tags.go(saveTagTx 的投影同步,节选)
var articles []model.Article
if err := tx.Joins("JOIN article_tags ON article_tags.article_id=articles.id").
Where("article_tags.tag_id = ?", id).
Find(&articles).Error; err != nil {
return err
}
for _, a := range articles {
if a.Tags == nil {
continue
}
var names []string
if json.Unmarshal([]byte(*a.Tags), &names) != nil {
continue
}
for i, n := range names {
if n == oldName {
names[i] = t.Name
} else if n == oldSlug {
names[i] = t.Slug
}
}
b, _ := json.Marshal(names)
if err := tx.Model(&model.Article{}).
Where("id = ?", a.ID).
Update("tags", string(b)).Error; err != nil {
return err
}
}
return nil
查找范围通过 article_tags 这个关系表确定,而不是去扫所有文章的 JSON------后者既慢又不可靠。找到文章后,每篇的 tags 字段解析成字符串切片逐个比对:等于 oldName 的换成新名称,等于 oldSlug 的换成新 slug。两个比较都做,是因为文章可能用名字也可能用 slug 记录标签。
两处 continue 是防御性写法。a.Tags == nil 表示这篇没有投影,json.Unmarshal 返回非 nil 表示投影是坏数据。两种情况都跳过当前文章、继续处理其余文章,不会因为一条历史脏数据让整个改名操作失败。跳过不等于问题不存在------坏投影需要专项核查修复。
b, _ := json.Marshal(names) 忽略了错误,因为 names 本身是从合法 JSON 解析出来的字符串切片,重新序列化不会失败。
删除标签先统计 article_tags 引用,仍有关系时拒绝删除。管理员需要先通过文章编辑流程解除关系,之后再删标签。这样的约束比删除标签后留下孤儿关系更容易维护。

五、进程 mutex 的保护范围
taxonomy.Service 的分类和标签写操作用同一个 sync.Mutex 串行化。它保护同一个 Go service 实例中进行的拓扑校验和目录修改,减少两个并发写操作都基于旧树校验、最后互相覆盖的风险。
这把锁就长在结构体里:
go
// internal/taxonomy/categories.go(节选)
// Service 负责分类树和标签目录;分类写操作在本进程互斥。
type Service struct {
DB *gorm.DB
Now func() time.Time
mu sync.Mutex
}
// New 创建分类标签服务和默认时钟。
func New(db *gorm.DB) *Service { return &Service{DB: db, Now: time.Now} }
mu 是小写,属于包内私有字段,包外既拿不到也无法绕过。DB 与 Now 是导出的,前者供装配根注入连接,后者供测试替换时钟。这个结构决定了锁的生命周期------它与 Service 实例同生共死,而实例是在进程启动时由装配根创建并持有的。所以 mu 的保护范围就是"这个进程里的这一个实例",一句话就能说清边界。
它不能保护第二个进程或副本。服务扩容后,每个实例都拥有独立 mutex;如果允许多个副本同时修改目录,当前约束就不再足够。需要在数据库层引入事务隔离/锁定方案、版本字段条件更新或其他分布式协调机制,并通过多实例并发测试证明。
SQLite 单实例也受文件库单写模型约束。进程内 mutex 和数据库写锁是不同层级的措施,不能互相替代。工程部署拓扑是数据一致性设计的一部分。
六、事务失败要恢复整个投影
分类改名涉及 categories 和多条 articles 更新;标签改名涉及 tags 和多个文章 JSON 字段。若中途一条文章更新失败,事务应让分类/标签目录和之前已更新的投影全部回滚。删除冲突则在写入之前阻止操作。
测试应覆盖:四层内增加子节点成功;第五层失败;把节点移入其后代形成环失败;移动整棵子树导致深度超限失败;分类名称变更同步到文章摘要;被引用分类不能删除;标签改名同步所有关系文章;仍被引用标签不能删除;中途写失败后目录和投影保持旧值。
并发测试要区分同一个 Service 的 mutex 串行和多 Service / 多进程竞争。单实例单测通过不能证明分布式安全。文档和部署承诺应精确说明这个边界。
小结:目录是业务实体,变更有传播半径
M6 通过全树校验维护分类无环和最多四级,通过事务将目录变更传播到文章展示字段;article_tags 保存标签关系,文章 JSON 保存兼容投影;仍被引用的目录项不允许删除。进程 mutex 只协调单个服务实例的写入。
当表中保存树、反范式投影或多对多关系,更新操作就要按传播半径设计。先找出真实关系和缓存/投影,再定义事务和删除规则;最后通过并发与失败注入观察数据库实际状态。
延伸阅读
- 分类树:无限级分类的存储、查询与环检测
- 领域建模:一个文章系统有哪些实体、什么关系
- Node 后端分层架构:Controller、Service、Repository 的边界
- 分类与标签:多对多关系的建模与查询
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
