第16章 · 评论与用户关系服务(正式版)

2023-03-17T14:11:02+08:00 | 89分钟阅读 | 更新于 2023-03-17T14:11:02+08:00

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 16 章。从这一章起,全书进入第四阶段——高阶业务:前十五章解决的是「怎么把系统搭起来、拆开、治理好」,接下来要解决的是「内容社区最难做的几个业务」。本章啃两块硬骨头:评论和用户关系。

这两块业务的代码量其实不大,难点全在数据模型设计上:评论是一棵「回复的回复还有回复」的树,怎么塞进一张平面的数据库表?关注是「A 认识 B、B 也可能认识 A」的网状关系,一百万个用户能产生上亿条关注记录,索引建错一个方向,线上就是全表扫描。可以说:社区类产品的核心竞争力,就在数据模型设计。

上一章结尾埋的「提现」伏笔,本质是把支付链路反着再用一次(钱从平台流向创作者),积木你已经集齐了——等真实业务需要时自然能拼出来,本章先把「社区关系」这块骨架搭好。

这一版做了三件事:

  1. 每个概念先补课再使用。树形结构、邻接表、最左前缀、游标分页、窗口函数、Upsert……全部就地补「补课胶囊」,不让你去搜。
  2. 用类比先让你看见。家谱、通讯录、便利店补货、图书馆索引——数据模型的事生活里全有原型。
  3. 结尾有 15 道面试问答。按「基础 → 进阶 → 追问」三档,每题告诉你一句话结论、怎么展开、面试官想听什么词、新手最容易答错在哪。

如果你想要「高密度速查版」,可以看原版第 16 章 ;两版知识点完全一致,只是讲法不同。


一、本章导读

先看两个真实的性能事故现场,感受一下这一章在解决什么问题:

事故一:某内容平台的评论列表接口,每次打开文章都要执行。开发同学用最直觉的方式存评论——每条评论记一个 parent_id 指向父评论。上线后发现:要展示「一条一级评论下的所有回复」,得先查它的直接子评论,再查子评论的子评论……一层层递归,热帖 5000 条回复要查几十次数据库,接口耗时直接飙到 2 秒。

事故二:用户关系表建了一个 (follower, followee) 联合唯一索引,开发同学以为「一个索引管两个方向」。结果「查看我的粉丝列表」这个接口 WHERE followee = ? 根本不走索引,全表扫描,粉丝多的用户一打开粉丝页,数据库 CPU 直接打满。

这两个事故的根源都不是「代码写错了」,而是数据模型没设计对。本章会带你彻底避开这两类坑:

  • 评论侧:树形结构的四种存储方案(邻接表 / 路径枚举 / 闭包表 / 嵌套集)各自怎么存、优缺点是什么、为什么本课程选「邻接表 + root_id 冗余」;然后实现评论的增删查,包括级联删除、minID 游标分页、楼中楼 Top N 查询;
  • 关系侧:关注 / 粉丝的表怎么设计(最左前缀原则是本章最重要的索引知识点)、关注接口怎么用 Upsert + 事务保证「关系和计数一起变」、Redis 的 Pipeline / Transaction / TxPipeline 到底差在哪;
  • 两侧共用的武器:缓存命中率分析(什么数据值得缓存、什么不值得)、接口分级与降级(哪些功能挂了可以「直接关掉」)、以及 COUNT 查询慢的四种优化思路。

上一章你已经学过幂等、状态机、唯一索引、Kafka、降级——本章会让这些武器在新的战场继续服役,同时引入本章真正的三个新核心概念:树形评论存储、关注关系表、缓存命中率。

学完这一章,你会亲手搭出两个微服务:comment-service(树形评论)和 relation-service(关注 / 粉丝 / 共同关注),并且能在面试里把「B 站评论区是怎么设计的」这类题讲得有理有据。

开读前先看一眼全局:本章一口气搭两个微服务,各自一张小图——comment-service 的难点是"树形评论怎么存、怎么缓存",relation-service 的难点是"关注关系怎么判得快"。两幅图看完,正文的每个小节你都知道它在填哪一块。

flowchart TB
    subgraph CS["comment-service:树形评论"]
        A["用户发表评论"] --> B["AC 自动机敏感词过滤"]
        B --> C["落库:root / parent 两级模型"]
        C --> D["写缓存:zset 存评论 ID 列表"]
        E["读评论列表"] --> F{"EXPIRE 原子判定"}
        F -->|"缓存存在"| G["zset 取 ID + 回表拼装"]
        F -->|"缓存不存在"| H["回源 MySQL
重建 zset"] end subgraph RS["relation-service:关注关系"] I["关注 / 取关"] --> J[("MySQL relation 表")] J --> K["Redis 缓存
关注 / 粉丝列表"] L["是否关注 / 共同关注"] --> K end

这张图怎么读:上下两个子图对应本章要搭的两个微服务。上半 comment-service 写读分离:写路径从「用户发表评论」先过 AC 自动机敏感词过滤,再按 root / parent 两级模型落库,最后把评论 ID 写进 zset 缓存;读路径从「读评论列表」先用 EXPIRE 做原子判定,缓存存在就直接 zset 取 ID + 回表拼装,不存在则回源 MySQL 重建 zset,再返回。下半 relation-service 里,「关注 / 取关」先写 MySQL relation 表,再同步到 Redis 的关注 / 粉丝列表缓存,「是否关注 / 共同关注」这两类判断则直接读这份缓存,不碰数据库。


二、本章知识点大纲

先把这张地图看一遍,知道这一章要往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 说出树形结构在关系型数据库里的四种设计方案(邻接表 / 路径枚举 / 闭包表 / 嵌套集),画出各自的表结构示意,并讲清选型结论「邻接表 + root_id」的理由。
  2. 写出 Comment 表模型:parent_id 记父亲、root_id 记根;实现创建评论时 RootID 的继承规则(回复沿父评论的 root_id 继承),并用一条 WHERE root_id = ? 捞出整棵子树。
  3. 写出 minID + limit 游标分页查询,讲清它解决了 offset 分页在并发写入下的什么问题。
  4. 设计 Follow 关注关系表:(follower, followee) 唯一索引 + followee 单独索引,并解释最左前缀原则为什么逼你建第二个索引。
  5. 实现关注 toggle 接口:一个事务里完成「关系表 Upsert + 双方计数增减」,Redis 侧用 TxPipeline 成对更新计数。

理解(能讲清原理和取舍,不要求背代码)

  1. 级联删除的两种做法(应用层手动级联 vs 数据库外键级联),以及大厂不用外键的四个理由。
  2. 评论缓存的三层设计:Kafka 通知预热(从 0 到 1)→ 秒级定时刷新(从 1 到 1 持续保鲜)→ 本地缓存(连 Redis 都不碰),以及长尾资源不缓存的判断。
  3. 缓存命中率分析:同一份数据在不同场景下命中率差异巨大(Feed 流读关注列表 vs 用户翻自己的关注列表),「值得缓存」的判断标准。
  4. 接口分级思想:核心接口(一级评论)全力保、附属接口(楼中楼、加载更多)可以降级甚至直接关闭。
  5. COUNT 查询慢的四种优化思路:Redis 维护结果、索引优化、EXPLAIN 估计值、异步刷新对表。
  6. Pipeline / Transaction / TxPipeline 三者的区别:省网络 vs 保连续执行,以及「INCRBY 会漂移、需要定时全量校准」的原因。
  7. 共同关注的双路径设计:Redis SINTER 求交集(快)+ DB 回源求交集(兜底),以及「空结果不可信必须先 EXISTS」的坑。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. Tablestore(OTS):海量关系存储的可替换方案,「不到逼不得已不要提早用」,通过 Repository 接口预留切换能力。
  2. 平台化思维:统一树形结构方案(强制 TreeBase 组合)、「解决一类问题而不是一个问题」的晋升思维。
  3. MongoDB 事务:单文档操作天然原子、多文档事务要挂 session、4.0/4.2 版本门槛、单机模式不支持。

前置依赖与补课清单

需要你已掌握在哪章学过本章还会补补在哪一节
Gin 路由、GORM 模型与事务第 02 章❌ 不补—
Redis 基本命令、缓存三大问题第 06 章❌ 不补(本章深化「命中率」)—
Kafka 生产消费、按 key 分区保有序第 07 章❌ 不补—
唯一索引挡重复、Upsert(点赞)第 07 章❌ 不补(本章直接复用)—
雪花 ID 生成器第 06 章❌ 不补—
ETCD 服务注册、优雅停机第 12 章❌ 不补—
降级、限流的思想第 14 章❌ 不补(本章「接口分级」是它的延伸)—
状态机、幂等接口第 15 章❌ 不补—
什么是树形结构(数据结构意义的树)未讲过✅ 补3.1 节
什么是游标分页未讲过✅ 补3.6 节
什么是窗口函数(ROW_NUMBER)未讲过✅ 补3.6 节
什么是最左前缀原则未讲过✅ 重点补3.8 节
什么是 Upsert第 07 章用过没细讲✅ 补3.9 节
Pipeline / Transaction / TxPipeline未讲过✅ 补3.9 节
MongoDB 基本用法第 06 章✅ 补「事务」部分3.11 节

不需要你提前懂的:B 站评论区的产品细节(本章只借用它的「楼中楼」交互模式)、Tablestore 的具体 API(知道定位即可)、MongoDB 分片集群的搭建(知道版本门槛即可)。

预计时长与学习成果

  • 预计时长:4 小时(精读 + 跟着敲两个服务);如果只求主干,2 小时可过一遍概念与表设计。

  • 学完你能跑出来的东西(这是本章的验收标准,做到才算过关):

    两个微服务:comment-service(HTTP 8200)和 relation-service(HTTP 8300)。用 curl 发一条一级评论、一条回复、一条「回复的回复」,SELECT id, parent_id, root_id 确认三层评论的 root_id 全等于一级评论的 id;关注同一个用户两次完成「关注 → 取关」,user_stats 表和 Redis 计数同步加减;两个用户各自关注 100 和 200 后,/api/relations/common 返回共同关注 [200, 100],和 redis-cli SINTER 的结果一模一样。

本章的学习路线(先评论、后关系,最后加餐):

flowchart LR
    A["3.1-3.2 评论需求
与树形四方案"] --> B["3.3-3.5 评论表
增·删"] B --> C["3.6 查询
游标分页·楼中楼"] C --> D["3.7 缓存与高可用
三层缓存·读写分组"] D --> E["3.8-3.9 用户关系
表设计·关注事务"] E --> F["3.10 设计判断
拆表·Tablestore·COUNT"] F --> G["3.11 加餐
MongoDB 事务"] G --> H["第四节 跟着写
两个微服务"] H --> I["第七节 面试问答"]

这张图怎么读:一条从左到右的单向学习路线,没有分支和回退。先在 3.1-3.2 看清评论需求并从树形四方案里选定「邻接表 + root_id」,3.3-3.5 据此建表并完成增、删,3.6 补查询主战场(游标分页、楼中楼 Top N),3.7 上缓存与高可用三板斧,3.8-3.9 切到用户关系侧(表设计、最左前缀、关注事务),3.10-3.11 收设计判断与 MongoDB 事务两道加餐,最后进第四节跟着写两个微服务,以第七节面试问答收尾。

评论与关系服务评论系统树形存储·增删查·缓存树形四方案邻接表 + root_id用户关系关注/粉丝/共同关注计数与缓存TxPipeline·命中率加餐与进阶MongoDB 事务·Tablestore

三、正文

3.1 评论系统全景:打开一个 B 站视频时发生了什么

先补课:评论是一棵「树」

补课胶囊 · 树形结构(tree) 一句话:像家谱一样的数据组织方式——每个节点只有一个「父亲」,但可以有多个「孩子」。 类比:家谱。爷爷是一级评论(根节点),爸爸是回复爷爷的评论,你又是回复爸爸的评论——「回复的回复」一层层长下去,就是一棵树。 最小示例:一条视频下的评论区——「写得太好了」(一级评论)下面挂着「+1,第二节讲到我心里了」(回复),这条回复下面又挂着「同感!」(回复的回复)。 回到主线:评论天然是树形结构,难点是怎么把一棵树塞进关系型数据库那种「一行一行」的平面表里——这正是 3.2 节要回答的问题。

评论功能要满足什么需求

评论系统是内容平台的核心模块——它既能提升用户粘性,自己也能产出优质内容(神评论本身 就是被消费的内容)。以 B 站评论为例,从功能和查询两个维度拆解需求:

功能维度(用户能做什么):

  • 直接针对某个资源(文章 / 视频)发表评论;
  • 回复某条评论(评论的评论);
  • 评论本身可被点赞。

查询维度(系统要扛住什么——这才是压力所在,因为打开任何资源都要加载评论):

  1. 用户打开资源时,加载该资源下的第一页一级评论;
  2. 同步加载每条一级评论的前几条子回复(就是 B 站的「楼中楼」摘要——每条一级评论下面默认露出两三条回复,想看全部再点开);
  3. 用户点击「加载更多」时,拉取某条评论下的全部回复。
flowchart TD
    O[打开资源] --> L[加载第一页一级评论]
    L --> T[每条一级评论加载 Top N 子回复 楼中楼]
    T --> M[点击加载更多 拉取某条下全部回复]

层级设计的第一道取舍

评论的嵌套有两种做法:

  • 完整树形:允许任意深度嵌套,「回复的回复的回复」一直长下去;
  • 两级结构:只有「资源 → 评论 → 子评论」三层,再深的回复都拍平挂在子评论层。

两级结构实现简单,但表达力有限(你没法精确表达「我回复的是楼里的哪一位」)。本课程采用完整树形结构——代价是存储和查询都要多动脑筋,而这些功夫正是本章的价值所在。

复习提示:工程提示:层级越深,查询与维护成本越高。B 站、微博等大多采用「两级显示 + 通过 root_id 关联」的折中方案——数据上还是完整树,但展示上只分「一级评论 / 楼中楼」两层。它的本质是邻接表 + root_id 字段的组合,也就是 3.2 节我们的最终选型。

小收口:这一节明确了评论系统的三个查询场景(第一页 / 楼中楼 Top N / 加载更多)和「完整树」的设计立场。下一步就是本章第一个核心概念:这棵树在数据库里怎么存。


3.2 树形结构怎么存:四种方案与一次选型

先抛问题:数据库表是平面的(一行一行),树是立体的(一层一层)。把树塞进平面表,怎么存才能既写得起、又查得快?存法选错了,事故一(递归查几十次数据库)就会在你身上重演。

给直觉:想象你要在 Excel 里记录一个大家族的族谱。最省事的做法是每人一行、多加一列「我爹是谁」;也可以记「完整路径」(爷爷/爸爸/我);甚至给每个人编号并规定「编号在我开区间内的都是我后代」……每种记法查起来的方便程度完全不同。数据库存树,就是在这几种记法里做选择。

方案一:邻接表(Adjacency List)——记「我爹是谁」

最常见的设计。表里加一个 parent_id 列,指向父节点的主键;根节点的 parent_id 设为 NULL(或 0)。

id  | content       | parent_id
----|---------------|----------
1   | 一级评论A      | NULL
2   | 一级评论B      | NULL
3   | 回复A的评论    | 1
4   | 回复3的评论    | 3
  • 优点:结构最简单,插入方便;查直接子节点用 WHERE parent_id = ? 就能命中索引;
  • 缺点:查整棵子树需要递归或多次查询——正是开头事故一的根源。

方案二:路径枚举(Path)——记「我从哪来」

增加一个 path 列,保存从根到当前节点的路径,例如 1/3/4。

id  | content       | path
----|---------------|-------
1   | 一级评论A      | 1
3   | 回复A的评论    | 1/3
4   | 回复3的评论    | 1/3/4
  • 优点:查子树用 WHERE path LIKE '1/3/%' 一条 SQL 搞定,级联删除同样简单;
  • 缺点:LIKE 前缀匹配在数据量大时性能不如等值查询(MySQL 高版本配合索引下推可缓解,但天花板摆在那里)。

方案三:嵌套集(Nested Set)——给每个人发「管辖区间」

维护 nsleft 和 nsright 两列,本质是深度优先遍历的顺序号:nsleft 小于所有子节点的 nsleft,nsright 大于所有子节点的 nsright。

  • 优点:查子树通过范围比较(nsleft BETWEEN 父left AND 父right),一次查询即可;
  • 缺点:插入 / 删除时要更新大量节点的左右值——楼层中间插一条新回复,后面所有兄弟节点的区间全要挪。维护代价极高,不推荐使用。

方案四:闭包表(Closure Table)——把「谁是谁的祖先」全记下来

额外建一张关系表,记录每个节点与其所有祖先 / 后代的两两关系:

TreePaths
ancestor | descendant
---------|-----------
1        | 1
1        | 3
1        | 4
3        | 3
3        | 4
4        | 4
  • 优点:查询任意层级关系都很快(WHERE ancestor = 1 直接拿到整棵子树);
  • 缺点:数据膨胀严重,N 个节点最坏要产生 N² 级别的记录——一条 10 万楼的热帖,闭包表可能要存几十亿行。

选型结论:邻接表 + root_id

把四种方案放进一张表对比:

方案写入查直接子节点查整棵子树主要短板
邻接表最简单等值查询,快要递归深层查询多轮交互
路径枚举简单不方便LIKE 前缀LIKE 性能天花板
嵌套集极贵(挪区间)不方便范围比较,快插入删除不可用
闭包表中等(多写一张表)快快记录数膨胀到 N²

评论系统的主要查询场景是「查某个节点的直接子节点」(楼中楼就是查一级评论的子回复),邻接表的等值查询性能最好;「查整棵子树」的短板用一个冗余字段 root_id 补上(3.3 节展开)。因此本课程的结论是:邻接表 + 额外的 root_id 字段。

复习提示:⚠️ 新手必踩的坑:觉得 Nested Set「高级」就选它。四种方案里 Nested Set 的论文出处最漂亮、听起来最学术,但它的写入代价在评论区这种「随时有人插楼」的场景下是灾难。技术选型的第一问永远是「我的场景里什么操作最频繁」,而不是「哪个方案更高级」——评论场景写多读也多、但「查直接子节点」占绝对大头,所以邻接表赢。

小收口:选型落定——用邻接表存父亲,用 root_id 补子树查询的短板。下一节把这张表真的建出来。


3.3 评论表设计:root_id 是点睛之笔

顺着选型结论,评论表的模型长这样:

// Comment 评论表对应模型
// 关键字段:UID(评论者)、Biz/BizID(被评论的资源)、PID(父评论)、RootID(根评论)
type Comment struct {
    ID      int64          `gorm:"primaryKey;autoIncrement;comment:评论ID"`
    UID     int64          `gorm:"index;comment:评论者ID"`
    Biz     string         `gorm:"type:varchar(128);not null;comment:业务标识"`
    BizID   int64          `gorm:"not null;comment:业务ID"`
    Content string         `gorm:"type:text;comment:评论内容"`
    PID     int64          `gorm:"index;comment:父评论ID,根评论为0"`
    RootID  int64          `gorm:"index;comment:根评论ID,方便加载整棵子树"`
    Ctime   int64          `gorm:"index;comment:创建时间"`
    Utime   int64          `gorm:"comment:更新时间"`
}

先解释两个「资源」字段:Biz 是业务标识(这条评论挂在什么类型的东西下面——"article" 文章、"video" 视频),BizID 是具体哪一条(文章 42)。两个字段合起来,一张评论表就能服务所有资源的评论区——这叫「业务泛化」,后面第 17 章搜索、第 19 章 Feed 流还会反复用到这个思路。

索引设计要点(每个索引都对应一个查询场景):

// 索引设计要点:
// 1. (biz, biz_id) 联合索引 —— 加载资源的评论列表
// 2. pid 单列索引 —— 加载某条评论的子评论
// 3. root_id 单列索引 —— 加载某条评论下的全部回复(楼中楼展开)
// 4. uid 单列索引(可选)—— 用户查询自己发表过的评论
// 5. ctime 索引 —— 按时间排序

root_id 到底解决了什么

先抛问题:只有 pid(邻接表)的话,想加载「一级评论 A 下的所有回复」怎么办?——查 parent_id = A 拿到直接子节点,再对每个子节点查它们的子节点……递归到叶子。楼层越深,数据库交互次数越多。

给直觉:root_id 相当于给同一棵树里的所有成员发了一枚统一的家族徽章——不管你辈分多低,徽章上都刻着老祖宗的 ID。想召集全家?不用一层层传话,喊一声徽章号就行。

flowchart LR
    subgraph 只用pid
        A1[一级评论] --> B1[回复]
        B1 --> C1[回复的回复]
        Q1[查全部回复] -->|需递归| A1
    end
    subgraph pid+root_id
        R[RootID 冗余] -->|一次 WHERE root_id=?| ALL[整棵子树]
    end

引入 root_id 后,WHERE root_id = ? 一次就能拿到整棵子树,大幅减少数据库交互。

复习提示:为什么需要 RootID? 仅靠 pid 想加载某条一级评论下的所有回复,需要递归查询。引入 root_id 后,WHERE root_id = ? 一次就能拿到整棵子树,大幅减少数据库交互。这是典型的「用空间换时间」的冗余字段设计——每行多存一个 int64,换来查询从 N 次交互降到 1 次。

小收口:表结构定型——pid 管「我是谁的直接孩子」,root_id 管「我属于哪棵树」。下一节写第一个接口:发表评论。这里有个必须想清楚的问题:回复的 root_id 该填什么?


3.4 写评论:root_id 继承与异步削峰

创建评论:一行继承规则是核心

// CreateComment 创建评论
// 如果传入 PID > 0,则代表回复某条评论;
// 如果是回复,需要同步设置 RootID(沿用父评论的 RootID,若父评论本身就是根,则 RootID = PID)
func (s *CommentService) CreateComment(ctx context.Context, c domain.Comment) (int64, error) {
    // 步骤 1:校验父评论存在性(如果传了 PID),并继承 RootID
    if c.PID > 0 {
        parent, err := s.repo.FindById(ctx, c.PID)
        if err != nil {
            return 0, err
        }
        // 沿用父评论的 RootID;若父评论本身是根,则 RootID 就是父评论 ID
        if parent.RootID > 0 {
            c.RootID = parent.RootID
        } else {
            c.RootID = parent.ID
        }
    }
    // 步骤 2:落库
    return s.repo.Create(ctx, c)
}

逐行看关键分支:

  • if c.PID > 0:PID 是 0 表示一级评论(直接评论资源),此时不用查父评论;PID 大于 0 表示这是一条回复,需要先确认「被回复的那条评论」真的存在;
  • 继承规则的两种情况:
    • 我回复的是一条普通回复(它的 root_id 已经指向树根)→ 我的 root_id 沿用它的——这样不管楼盖多高,全楼的 root_id 都等于一楼;
    • 我回复的是一级评论本身(它没有 root_id,或者说 root_id 为 0)→ 我的 root_id 就是它的 ID。
复习提示:⚠️ 新手必踩的坑:继承规则写成 c.RootID = parent.ID。这样「回复的回复」的 root_id 就指向了中间楼层而不是一楼,同一棵树被拆成好几段——WHERE root_id = 一楼ID 只能捞到第二层,楼中楼展示直接缺楼。记住口诀:root_id 永远指向「整棵树的根」,不是「我的直接父亲」(指向直接父亲的那是 pid)。

接口设计:统一还是拆分?

创建评论的接口有两种思路:

  • 统一接口:一个 CreateComment 同时处理一级评论和回复,靠 PID 是否大于 0 区分(本课程采用)——调用方最简单;
  • 拆分接口:在 gRPC 层面提供 CreateComment 和 ReplyComment 两个接口,语义更清晰——调用方不容易传错参数。

两种都对,看团队对「接口语义明确性」和「调用方心智负担」的取舍。本章跟写部分用统一接口。

高并发写:先写 Kafka,再落库

先抛问题:微博 / B 站这种体量,一条热帖发出后每秒可能涌进几千条评论。直接同步写数据库,数据库连接池瞬间被打满,连带读接口一起完蛋。怎么办?

给直觉:这和第 07 章点赞收藏遇到的写入洪峰是同一个问题,武器也是同一件——Kafka 削峰。就像洪水来了先蓄进水库(Kafka),再按下游河道(数据库)的承受能力匀速放水。

// CreateCommentAsync 异步写评论的简化示例
// 生产者:将评论序列化后发送到 Kafka
func (s *CommentService) CreateCommentAsync(ctx context.Context, c domain.Comment) error {
    // 步骤 1:序列化评论
    msg, err := json.Marshal(c)
    if err != nil {
        return err
    }
    // 步骤 2:用 biz_id 作 key,保证同一资源下的评论顺序写入同一分区
    return s.producer.SendMessage(ctx, "comment_write", strconv.FormatInt(c.BizID, 10), msg)
}

// 消费者:从 Kafka 拉取评论并落库
func (s *CommentConsumer) Consume(ctx context.Context, msg kafka.Message) error {
    // 步骤 1:反序列化
    var c domain.Comment
    if err := json.Unmarshal(msg.Value, &c); err != nil {
        return err
    }
    // 步骤 2:落库(复用同步写逻辑)
    _, err := s.svc.CreateComment(ctx, c)
    return err
}

注意生产者那行 SendMessage(ctx, "comment_write", strconv.FormatInt(c.BizID, 10), msg)——消息的 key 用的是 biz_id。回忆第 07 章:Kafka 按 key 哈希分区,相同 key 的消息进同一分区、天然有序。这样「同一篇文章下的评论」写入顺序和用户发送顺序一致,楼中楼不会出现「后发的回复跑到前面」。

复习提示:⚠️ 新手必踩的坑:别用 string(c.BizID) 转 key。BizID 是 int64,string(42) 在 Go 里不是转成字符串 "42",而是把 42 当成 Unicode 码点转成一个字符("*")。同一个 BizID 的评论会被打进同一个「错误」分区,虽然顺序碰巧仍一致,但 key 的语义全错了,而且一旦未来按 key 做跨分区路由、或和其他语言(按十进制字符串哈希)混合消费,分区就会对不上。正确做法是 strconv.FormatInt(c.BizID, 10) 得到十进制字符串(记得 import strconv)。
flowchart LR
    U[用户发评论] --> K[Kafka comment_write]
    K -->|同一 biz_id 同分区| C[消费者落库]
    C --> DB[(评论表)]

实际生产更常用复合策略:平时评论量不大,直接写数据库(链路短、延迟低);监控发现触发性能瓶颈时,一键切换为异步写(写入服务内部队列或 Kafka)。同步与异步两条路径的落库逻辑是同一段代码(就像上面 Consume 复用 CreateComment),切换开关不改变业务语义。

复习提示:异步写的代价要想清楚:评论发出去到真正可见之间有几秒延迟(用户一般感知不到,前端可以本地先渲染一条「评论中」)。凡是「最终一致(eventual consistency,延迟几秒后达成一致)」对业务可接受的写场景,都可以考虑这招——反之,支付这种强一致场景绝不能这么干(第 15 章讲过的教训)。

小收口:写路径完成——同步写管简单场景,Kafka 削峰管洪峰场景,root_id 继承规则是两者的公共核心。下一节处理最容易被低估的操作:删除。


3.5 删评论:级联删除与「大厂不用外键」

先抛问题:删除一条一级评论,它名下的几百条回复怎么办?留着——楼中楼里挂着一个「指向已删除评论」的孤儿楼层;删掉——就要一次性清理整棵子树。这叫级联删除(cascade delete,删父节点时连同所有后代一起删)。

方案 A:应用层手动级联(邻接表方案的做法)

删除节点 4 的步骤:
1. DELETE FROM comments WHERE id = 4;
2. 查找 parent_id = 4 的节点(5、6),删除;
3. 查找 parent_id IN (5,6) 的节点(7),删除;
4. 递归直到没有子节点。

层级越深,查找 + 删除的轮数越多。用我们本章的表结构,这整件事被 root_id 一行 SQL 化解:DELETE FROM comments WHERE root_id = ?——又一个冗余字段的红利。

方案 B:借助数据库外键级联

type Comment struct {
    ID           int64     `gorm:"primaryKey;autoIncrement"`
    Content      string    `gorm:"type:text"`
    // 通过 ForeignKey 指定 PID 关联到自身 ID,并设置级联删除策略
    ParentComment *Comment `gorm:"ForeignKey:PID;AssociationForeignKey:ID;constraint:OnDelete:CASCADE"`
    PID           int64    `gorm:"index"`
}

声明外键后,删除父节点时数据库自动删除所有子节点——看起来省心,但大厂基本不用。

复习提示:

为什么大厂不推荐使用外键?

  1. 性能开销:外键约束要求每次插入 / 删除都额外检查一次引用完整性;
  2. 分库分表后失效:级联删除无法跨库生效——评论表按 biz_id 拆到 16 个库后,父子评论可能不在同一个库,外键根本管不到;
  3. 死锁风险:高并发下外键检查会扩大锁的持有范围,死锁概率上升;
  4. 阻碍运维:数据迁移、数据修复时外键约束处处挡路。

实践中通常在应用层维护数据一致性(手动级联 + 事务包裹),把「删哪些行」的控制权握在自己手里。

复习提示:⚠️ 新手必踩的坑:以为「不用外键 = 不保证一致性」。不用外键,一致性的责任转移到了应用层——删除逻辑必须包在事务里(跟写部分要么全成功要么全回滚),漏了事务,删到一半进程崩了照样留孤儿。外键是数据库替你把关,应用层级联是你自己把关,两者必居其一,不能都没有。

小收口:删除的关键决策只有两个——是否级联(主流是删)、谁来级联(应用层,不用外键)。评论侧的增删都齐了,下一节是评论系统真正的性能主战场:查询。


3.6 读评论:游标分页与楼中楼 Top N

先补课:为什么 offset 分页在评论区会翻车

补课胶囊 · 游标分页(cursor pagination) 一句话:不用「第几页」,而是用「上一页最后一条的 ID」当书签,下一页从书签之后接着取。 类比:读一本还在连载的小说。你不会记「我读到第 137 页」(页码会因新章节插入而漂移),而是把书签夹在「看到的那一行」——不管前面插了多少新内容,书签位置永远准。 最小示例:SELECT * FROM comments WHERE biz = 'article' AND biz_id = 42 AND id < 73681900 ORDER BY id DESC LIMIT 20——id < 73681900 里的 73681900 就是书签(上一页最后一条的 ID)。 回到主线:评论区是典型的「持续有人往顶部插新数据」的列表,offset 分页会重复 / 跳过,游标分页不会。

offset 的问题具体长什么样:你查第 2 页(OFFSET 10 LIMIT 20,跳过前 10 条取第 1130 条)。可就在你翻页的这半秒里,别人发了 2 条新评论插到了最前面——原来的第 11、12 条被顶到了第 13、14 位。你取到的「第 1130 条」实际是原来的第 13~32 条:第 11、12 条被跳过了。反过来,如果翻页期间有评论被删除,还会出现重复。minID 游标分页只认「上一页最后一条的 ID」,新插入的评论 ID 更大、天然不进 id < minID 的结果集,怎么刷都不串页。

三个查询接口逐个实现

接口一:加载资源的第一页一级评论(minID 游标分页):

// FindByBiz 按资源加载第一页评论
// 关键:使用 minID + limit 的游标分页,而非传统 offset + limit
func (r *CommentRepository) FindByBiz(ctx context.Context, biz string, bizID, minID, limit int64) ([]domain.Comment, error) {
    // 步骤 1:构造基础查询
    var res []domain.Comment
    db := r.db.WithContext(ctx).
        Where("biz = ? AND biz_id = ?", biz, bizID)
    // 步骤 2:minID > 0 时只查比 minID 更早(ID 更小)的评论
    if minID > 0 {
        db = db.Where("id < ?", minID)
    }
    // 步骤 3:按 ID 倒序取前 limit 条
    err := db.Order("id DESC").
        Limit(int(limit)).
        Find(&res).Error
    return res, err
}

第一次请求 minID = 0(不要这个条件),拿到第一页后把最后一条的 ID 作为下一次的 minID 传回来。注意这里用 id DESC 排序——配合第 06 章的雪花 ID(趋势递增),ID 倒序就是时间倒序,新评论排最前,还免了 ORDER BY ctime 需要的额外索引。

接口二:楼中楼摘要——每条一级评论带前几条回复。这里有个新手必经的坑要先补课:

补课胶囊 · N+1 查询问题 一句话:循环里逐条查数据库——查 1 次列表 + N 次逐条补充,共 N+1 次数据库交互。 类比:班里 40 个同学每人要一张成绩单,你去教务处跑 40 趟——每趟只拿一张。正确做法是拿着名单去一趟,一次全拿回来。 最小示例:for _, root := range roots { replies := query("WHERE root_id = ?", root.ID) }——20 条一级评论就是 21 次查询;改成 WHERE root_id IN (?) 一次批量拿回、内存分组,只有 2 次查询。 回到主线:楼中楼必须批量查,而且「每棵树只要最新 3 条」还得在批量的同时分组限量——窗口函数正是干这个的。

补课胶囊 · 窗口函数(ROW_NUMBER) 一句话:SQL 里给每行发编号的函数,可以「按组分别编号」,从而实现「每组取前 N 条」。 类比:运动会按班级分组报数——每个班都从 1 开始报,喊「每班前 3 号出列」,就是「每组取前 3 条」。 最小示例:ROW_NUMBER() OVER (PARTITION BY root_id ORDER BY id DESC) AS rn——按 root_id 分组、组内按 id 倒序编号,外层再 WHERE rn <= 3 就拿到每棵树最新的 3 条。 回到主线:B 站楼中楼的「每条一级评论默认展示最新 3 条回复」,一条 SQL 搞定。

// FindTopReplies 批量查询多条一级评论的 Top N 回复
// 在 repository 层完成聚合,避免 N+1 查询问题
func (r *CommentRepository) FindTopReplies(ctx context.Context, rootIDs []int64, limit int64) (map[int64][]domain.Comment, error) {
    var comments []domain.Comment
    // 步骤 1:用窗口函数为每个 root_id 取最新 limit 条
    err := r.db.WithContext(ctx).
        Raw(`
            SELECT * FROM (
                SELECT *, ROW_NUMBER() OVER (PARTITION BY root_id ORDER BY id DESC) AS rn
                FROM comments
                WHERE root_id IN (?)
            ) t WHERE rn <= ?
        `, rootIDs, limit).
        Scan(&comments).Error
    if err != nil {
        return nil, err
    }
    // 步骤 2:按 root_id 分组返回,避免 N+1
    res := make(map[int64][]domain.Comment, len(rootIDs))
    for _, c := range comments {
        res[c.RootID] = append(res[c.RootID], c)
    }
    return res, nil
}

WHERE root_id IN (?) + PARTITION BY root_id 组合拳:一趟数据库把 20 棵树的 Top 3 全部拿回,内存里按 RootID 分桶装好——两次查询,绝无 N+1。

复习提示:容错设计:加载子评论是「可选」步骤,如果触发了降级(第 14 章的降级开关),直接跳过这一步,只返回一级评论。这属于「核心功能保住、附属功能可丢」的典型降级思路——3.7 节会把这个思想展开成完整的「接口分级」。

接口三:加载更多——拉取某条一级评论下的全部回复:

// FindMoreReplies 点击「查看更多回复」时按 root_id 翻页
func (r *CommentRepository) FindMoreReplies(ctx context.Context, rootID, minID, limit int64) ([]domain.Comment, error) {
    // 步骤 1:按 root_id 查
    var res []domain.Comment
    db := r.db.WithContext(ctx).Where("root_id = ?", rootID)
    // 步骤 2:minID 游标分页
    if minID > 0 {
        db = db.Where("id < ?", minID)
    }
    // 步骤 3:倒序取前 limit
    err := db.Order("id DESC").Limit(int(limit)).Find(&res).Error
    return res, err
}

看到没有——WHERE root_id = ? 一条等值查询,3.3 节埋的冗余字段在这里兑现:不管楼层多深,整棵树一条 SQL 捞完,翻页仍然用 minID 游标。

小收口:三个查询接口分别对应 3.1 节的三个查询场景,两个关键武器是 minID 游标(防串页)和窗口函数(防 N+1 的分组 Top N)。查询有了,下一步解决「高并发读」——缓存。


3.7 评论缓存与高可用:三层缓存、读写分组、接口分级

评论是「读」远大于「写」的模块——热门视频每秒上万次打开,每次都查库必挂。缓存是第一道防线,但缓存不是无脑加,先算一笔账。

核心概念三:缓存命中率——不是所有数据都值得缓存

补课胶囊 · 缓存命中率(cache hit rate) 一句话:请求到达时能直接从缓存拿到数据的比例。命中 90% 意味着 100 个请求里 90 个不碰数据库。 类比:便利店把「最畅销的 10 款饮料」摆在门口冰柜(缓存),顾客进门拿了就走;冷门商品还得去仓库翻(数据库)。畅销款占比越高(命中率越高),去仓库的次数越少。 回到主线:命中率取决于「同一份数据短期内是否被重复访问」——评论恰恰是两极分化最严重的场景。

评论缓存的设计比较微妙,因为数据冷热差异极大:

  • 长尾资源(非热门文章 / 视频)很少被打开,缓存命中率低——为一个「一年被打开两次」的页面做缓存,写进去的第二天就过期了,纯属浪费;
  • 热门资源被高频访问,值得重点保护,采用「预加载 + 定时刷新 + 本地缓存」的三层组合策略。

三层缓存链路:五个组件配合

把「预加载 + 定时刷新」这条链路拆开看,它需要多个组件配合才能跑起来:

  1. 热榜计算方(大数据平台或运营后台)算出「当前最热的 N 个资源」;
  2. 结果通过 Kafka 广播出去——热榜方不知道、也不需要知道评论服务有几个实例,两边完全解耦(第 07 章的解耦思想);
  3. 评论服务监听通知,把热榜资源的评论提前灌进 Redis——这一步叫预热 / 预加载,解决「从 0 到 1」的问题(等用户来访问时缓存已经就位,而不是第一个用户去撞数据库);
  4. 评论服务内部再跑一个秒级定时任务,异步刷新热榜资源的缓存——解决「从 1 到 1」的持续保鲜问题,避免缓存过期瞬间大量请求同时回源 DB(第 06 章讲过的缓存过期雪崩);
  5. 在 Redis 之前再叠一层本地缓存(进程内存,如 freecache、bigcache),热门资源的读请求连 Redis 都不用碰。
flowchart LR
    H[热榜计算] -->|Kafka 通知| L[评论服务监听并预热]
    T[秒级定时刷新任务] -->|持续保鲜| R[(Redis)]
    L --> R
    R --> C[本地缓存 freecache/bigcache]
复习提示:类比:这就像便利店补货——热榜通知是「总部告诉你这 10 款商品明天要爆单」(提前把货架补满 = 预加载),秒级巡检是「店员时不时看一眼货架缺没缺」(定时刷新),本地缓存则是「把最畅销的几款摆在收银台边」(伸手就拿)。三层各管一段,缺了哪层都会在某个环节「断货」。

限流与降级:接口分级是前提

第 14 章学的熔断限流降级,在评论区这样落地:

  • 限流分级:热门资源限流阈值设高(如 400 QPS),非热门设低(如 100 QPS)——保护能力向热数据倾斜;
  • 降级取舍:触发降级时,只查询热门资源的评论,非热门直接返回空或错误。

这里有一个比「怎么降级」更重要的前置问题——接口分级:哪些接口是核心(打开资源必须看到一级评论)、哪些是附属(楼中楼摘要、加载更多)?分好级之后,「降级 = 关掉附属功能」这个选项才存在;不分级,你只能眼睁睁看着整个评论服务被拖垮。

「加载更多」接口(FindMoreReplies)就是一个绝佳样本,从两个维度看它:

缓存维度——顺着用户行为推。这个接口的缓存效果天然不会好:大部分用户根本不点开楼中楼,为不存在的访问做缓存就是浪费。但有一个有意思的用户行为推定思路:

  • 大部分用户不点「加载更多」,所以第一页之后的评论不值得默认缓存;
  • 但一旦某个用户点了「加载更多」,大概率说明他想把这条评论下的回复全部看完;
  • 所以可以在用户第一次点「加载更多」时,顺手把该 root_id 下的剩余回复批量预加载进缓存,后续翻页直接走缓存。
flowchart LR
    U[用户点加载更多] --> J{首次点击?}
    J -- 是 --> P[预加载剩余回复进缓存]
    J -- 否 --> C[直接读缓存]
    P --> C

容错维度——非关键接口,直接关闭也没关系。最简单有效的容错措施是:触发限流、或发现本节点资源不足(CPU / 内存吃紧)时,直接关闭这个功能——前端隐藏「查看更多回复」入口即可,完全不影响一级评论的正常展示。

复习提示:新手容易想反的一点:很多人觉得「每个接口都要尽力保证可用」。实际上可用性设计的第一步是给接口分级——核心接口(打开资源必须看到一级评论)全力保,附属接口(楼中楼摘要、加载更多)可丢可关。把「可以关掉什么」想清楚,你的系统才有主动「断尾求生」的能力。

读写分组部署:最后一道容量防线

将查询接口(读组)与增删接口(写组)分离部署:

  • 读组 100 个实例,写组 20 个实例(读流量是写流量的几十倍,实例配比跟着流量走);
  • 客户端通过 read_weight / write_weight 做流量调度(第 13 章客户端负载均衡的权重思想);
  • 读组故障时,可将部分读流量导入写组,保证核心读可用。
flowchart LR
    C[客户端] -->|read_weight| R[读组 100实例]
    C -->|write_weight| W[写组 20实例]
    R -->|故障导流| W

小收口:评论系统的三板斧——三层缓存(预热 / 刷新 / 本地)挡住 99% 的读、限流降级分级保核心、读写分组扛容量。至此评论侧讲完,接下来进入本章第二个主战场:用户关系。


3.8 用户关系:从 CRUD 到最左前缀

需求分析:关系系统长什么样

关注、粉丝、拉黑、屏蔽,统称为用户关系系统。从系统设计角度,这些功能没有本质区别——都是「用户 A 与用户 B 之间建立 / 解除某种关系」。

关注类型细分:部分平台区分「普通关注」和「特殊关注」(如 B 站的「特别关注」),可以抽象为「关注类型」或「关注优先级」字段,影响第 19 章 Feed 流的优先级排序。

核心使用场景:

  • 关注 / 取消关注某个人;
  • 打开文章 / 视频时,判断是否已关注创作者(每次打开都要判,高频!);
  • 查看自己的关注列表 / 粉丝列表;
  • 辅助功能:给被关注者打标签、分组(本质都是 CRUD)。

设计难点:小项目是 CRUD,大平台是灾难

小型应用中,用户关系就是普通 CRUD。但用户量级上来后,会面临两个放大器:

  • 高并发:每次打开文章都要判定「是否关注创作者」;第 19 章会看到,Feed 流的生成也重度依赖关注列表——它是被「顺带读取」最多的数据之一;
  • 大数据量:用户关系数据量是用户数量的数量级放大。假设每用户平均关注 100 人,关注表行数 = 用户数 × 100——一千万用户就是十亿行。

表结构设计:中间表表达多对多

补课胶囊 · 中间表(多对多关系) 一句话:A 和 B 是多对多关系(一个人关注多人、一人被多人关注)时,单独建一张表存「谁和谁发生了关系」。 类比:选课系统。学生和课程是多对多——既不能在学生表里塞一列课程,也不能在课程表里塞一列学生,所以有了第三张「选课记录表」,一行 = 一个学生选了一门课。 最小示例:follows 表一行 follower=1, followee=100,就表示「用户 1 关注了用户 100」。 回到主线:关注 / 拉黑 / 屏蔽,全都是中间表结构,差别只在语义。

// FollowRelation 用户关系表
// 约定:A 关注 B,则 follower=A,followee=B
type FollowRelation struct {
    ID         int64  `gorm:"primaryKey;autoIncrement"`
    Follower   int64  `gorm:"index:idx_follower_followee,unique,priority:1;comment:关注者"`
    Followee   int64  `gorm:"index:idx_follower_followee,unique,priority:2;index:idx_followee;comment:被关注者"`
    Status     uint8  `gorm:"comment:状态 1=已关注 0=已取消"`
    Ctime      int64
    Utime      int64
}

// 关键索引设计:
// 1. 唯一索引 (follower, followee) —— 用于"查询某人关注了谁"场景,WHERE follower = ? 命中索引
// 2. 单独在 followee 上创建普通索引 —— 用于"查询某人的粉丝列表"场景
//    因为唯一索引列顺序是 (follower, followee),WHERE followee = ? 无法命中索引
erDiagram
    follow_relation {
        bigint id PK
        bigint follower "关注者"
        bigint followee "被关注者"
        uint8 status "1关注 0取消"
    }

本章最重要的索引知识:最左前缀原则

补课胶囊 · 最左前缀原则 一句话:联合索引 (a, b) 只对「从 a 开始连续匹配」的查询生效——WHERE a = ? 走索引,WHERE b = ? 不走。 类比:电话簿按「姓 → 名」排序。知道姓(王)可以二分定位;只知道名(小明)只能从第一页翻到最后一页——排序规则对「名」单独不生效。 最小示例:索引 (follower, followee) 相当于先按 follower 排、follower 相同再按 followee 排。WHERE follower = 5 能直接跳到「5 的区间」;WHERE followee = 5——记录根本没按 followee 排,只能全表扫。 回到主线:关注表恰好要服务两个方向的查询,一个联合索引只够一个方向——这就是开头事故二的根源。

复习提示:

索引顺序的坑:唯一索引 (follower, followee) 在 WHERE follower = ? 时走索引;但 WHERE followee = ? 不走索引(最左前缀原则)。所以查询粉丝列表时,必须额外为 followee 创建索引。

⚠️ 新手必踩的坑:以为一个联合唯一索引能同时服务两个方向查询。很多同学建了 (follower, followee) 就以为「查粉丝列表」也走索引,结果线上 WHERE followee = ? 全表扫描。记住最左前缀——查粉丝必须单独给 followee 建索引。

小收口:关系表的结构极简(两列关系 + 一列状态),但它服务两个方向的查询——「我关注了谁」和「谁关注了我」,索引必须双向各备一把。下一节写关注接口,会引入一个新的老朋友:Upsert。


3.9 关注接口:Upsert、双计数与 TxPipeline

先补课:Upsert——插入和更新二合一

补课胶囊 · Upsert(INSERT … ON DUPLICATE KEY UPDATE) 一句话:一条 SQL 做到「行不存在就插入、已存在就更新」。 类比:酒店前台登记。你是新客就开房(INSERT);你已有房间但换了需求,就改登记信息(UPDATE)——前台不会因为你「已经住过」就报错。 最小示例:INSERT INTO follows (follower, followee, status) VALUES (1, 100, 1) ON DUPLICATE KEY UPDATE status = 1——第一次执行插入新行;第二次执行撞 (follower, followee) 唯一索引,转而更新 status。第 07 章点赞接口用的就是这招。 回到主线:关注 / 取关的天然形态就是 Upsert——「关注 100」执行一万次,表里也只该有一行,只是 status 反复横跳。

关注 / 取消关注接口

借鉴第 07 章点赞接口的 Upsert 思路:冲突时更新状态字段。

// Follow 关注或取消关注
// status=1 表示关注,status=0 表示取消关注
func (r *FollowRepository) Follow(ctx context.Context, follower, followee int64, status uint8) error {
    // 步骤 1:开启事务(关系表与计数表必须一致)
    now := time.Now().UnixMilli()
    return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        // 步骤 2:INSERT ... ON DUPLICATE KEY UPDATE 实现 Upsert
        err := tx.Clauses(clause.OnConflict{
            Columns: []clause.Column{{Name: "follower"}, {Name: "followee"}},
            DoUpdates: clause.Assignments(map[string]interface{}{
                "status": status,
                "utime":  now,
            }),
        }).Create(&FollowRelation{
            Follower: follower, Followee: followee,
            Status: status, Ctime: now, Utime: now,
        }).Error
        if err != nil {
            return err
        }
        // 步骤 3:同步更新计数表(详见下方两个方案),保证关系表与计数表一致
        return nil
    })
}

注意步骤 3 只是占位——「计数怎么更新」正是下面的重头戏。另外,关注 / 取消关注都是高并发场景,同样可以引入 Kafka 异步处理,与评论写入(3.4 节)同理。

关注 / 粉丝数量:两个方案

打开个人主页要显示「关注 128 / 粉丝 3.2 万」——数字从哪来?直接 COUNT(*) 数据库要一行行数,十亿行的表直接数到天荒地老。两个方案:

方案一:维护计数表 + 缓存(类似第 07 章点赞计数)

type FollowStatics struct {
    UID         int64 `gorm:"primaryKey"`
    FollowCount int64 `gorm:"comment:关注了多少人"`
    FanCount    int64 `gorm:"comment:有多少粉丝"`
}

关键点:A 关注 B 时,事务内同时增加 A 的 FollowCount 和 B 的 FanCount——两个用户的计数、两个方向的数据,一次业务操作要一起变。

缓存更新必须借助 Lua 脚本,确保「判定缓存是否存在 + 更新」的原子性,否则会出现缓存未命中却更新了缓存的问题(第 07 章点赞计数踩过的坑,忘了就回去翻)。

方案二:Redis + 兜底 Count

不引入计数表,缓存中直接维护数量:

  • 缓存命中:直接返回 Redis 数据,并在写操作时同步更新;
  • 缓存未命中:通过 COUNT 查询数据库,并回写缓存。
// Cache 实现四个接口:FollowCount / FanCount / IncrFollow / IncrFan
// 关键:使用 TxPipeline(Redis 事务)保证 A 关注 B 时同时更新 A 的关注数和 B 的粉丝数
func (c *FollowCache) IncrFollowAndFan(ctx context.Context, follower, followee int64) error {
    // 步骤 1:开启 Redis 事务管道
    pipe := c.client.TxPipeline()
    // 步骤 2:两条 INCRBY 原子执行
    pipe.IncrBy(ctx, followKey(follower), 1)
    pipe.IncrBy(ctx, fanKey(followee), 1)
    // 步骤 3:提交
    _, err := pipe.Exec(ctx)
    return err
}
flowchart LR
    A[用户 A 关注 B] --> T[Redis 事务]
    T -->|INCRBY| F[A 的关注数+1]
    T -->|INCRBY| G[B 的粉丝数+1]
复习提示:

数据库兜底 Count 的注意点:

  • 不需要 COUNT DISTINCT,因为唯一索引已去重((follower, followee) 唯一,同一对关系最多一行);
  • WHERE follower = ? 命中索引;WHERE followee = ? 需额外建索引(3.8 节最左前缀的坑,数粉丝数时它又出现了);
  • 大公司较少用此方案,因为 Count 不命中缓存时性能差,可作为降级 / 限流时跳过的查询路径。

必须分清的三个 Redis 名词:Pipeline / Transaction / TxPipeline

本章用了 TxPipeline,很多新手分不清 Pipeline、Transaction、TxPipeline 三个词——这也是讲义点名的面试题,值得一次补透。

补课胶囊 · Pipeline / Transaction / TxPipeline 一句话:Pipeline 把多条命令打包成一次网络往返(省时间);Transaction(MULTI/EXEC)让命令连续执行不被插队(保成对);TxPipeline 两者都要。 类比:Pipeline 是「把 10 封信塞进一个信封一次寄出」——省邮费,但邮局处理时可能先插进别人的信;Transaction 是「预约一个不被打断的窗口」——你的命令一口气执行完,别人插不进来。一句话:只求快用 Pipeline,要求成对生效用 Transaction / TxPipeline。 最小示例:更新 A 的关注数 + B 的粉丝数,这两条 INCRBY 必须一起生效——单独 Pipeline 可能出现「A 加了 B 没加」的中间态被并发请求读到;用 TxPipeline 则保证连续执行。 回到主线:IncrFollowAndFan 用的就是 TxPipeline。

对比项PipelineTransaction(MULTI/EXEC)TxPipeline
减少网络往返(RTT)✅❌(逐条入队)✅
命令连续执行(隔离)❌✅✅
典型用途批量读写互不依赖的数据成对更新(计数、扣减)成对更新 + 高并发

一个容易忽略的细节:Redis 事务不回滚——某条命令执行出错,其余命令照样执行完(和 MySQL 事务「一错全退」完全不同)。所以用 TxPipeline 时,命令本身要能容错(INCRBY 一个不存在的 key 会自动从 0 开始加,这正是我们要的语义)。

缓存命中率分析:同一份数据,值不值得缓存看场景

3.7 节建立了「命中率」的概念,这里用用户关系数据做一次完整分析——同一份数据在不同场景下命中率天差地别:

场景命中率是否缓存
用户查看自己的关注列表高(数据不变、翻页来回读)推荐缓存
Feed 流生成时读取关注列表低(一次只读一个用户的列表,集合太大、重复访问少)不缓存
判断「A 是否关注 B」(A、B 都是小透明)低不缓存
判断「用户是否关注大 V」高(成千上万人反复问同一个问题)推荐缓存

核心原则:同一个资源短期内会被重复访问才值得缓存;缓存过期时间应该根据用户使用习惯确定。「用户 A 是否关注了用户 B」这种组合有几亿亿种,绝大多数组合一辈子只被问一次——缓存它们纯属浪费内存;而「是否关注大 V」是万人在问同一个问题——不缓存才是浪费。

小收口:关注接口的完整拼图——关系表 Upsert(幂等)+ 事务内双计数(一致)+ TxPipeline(成对)+ 兜底 COUNT(未命中回源)。接下来聊三个「判断题」:这些设计在大规模下要怎么演化。


3.10 三个设计判断:拆表、海量存储与平台化

判断一:关注 / 拉黑 / 屏蔽要不要用一张表?

答案是 不要。

反例(不推荐):
| follower | followee | is_follow | is_block | is_mute |

原因:
1. 关注的量级远大于拉黑、屏蔽(关注:100/人,拉黑:可能 0-5/人)
2. 拆开后单表数据量小,扫描快,索引维护成本低
3. 不同业务的查询模式不同,拆表后可以独立优化

直觉上「都是关系,一张表多省事」,但三个字段的数据量级差了两个数量级——一张表里 99.99% 的行 is_block 都是 0,白白让拉黑查询背上十亿行的包袱。按量级和查询模式拆表,是数据模型设计的基本功。

判断二:数据量真到十亿行了,怎么办——Tablestore

分库分表方案存在的问题:以 follower 作为分片键(第 11 章讲过的数据迁移分片思想),「查询某人关注列表」没问题(同一 follower 的行落在同一个库);但「查询某人粉丝数量 / 粉丝列表」时,同一个 followee 的粉丝散落在所有分片上——跨库聚合,性能急剧恶化。

阿里推出的 Tablestore(OTS)是一种海量结构化数据存储,适合用户关系这类大数据量场景:

  • 插入数据:使用 Update 而非 Insert,达成 Upsert 语义;
  • 更新数据:使用结构化更新语句(非 SQL UPDATE);
  • 查询数据:SQL 形态最简单,但 Tablestore 不支持参数化查询,只能拼接 SQL,需小心 SQL 注入(第 02 章讲过的安全问题,在这里格外要命)。
复习提示:实践建议(来自讲义原话):「不到逼不得已,不要提早用 Tablestore,付钱 + API 贼难用」。正确的姿势是通过定义接口(如 FollowRepository),在 MySQL 与 Tablestore 之间自由切换——与第 06 章文章服务切换 MongoDB / MySQL 的思路一致。存储可以换,接口不变,业务代码一行不用动。

判断三:平台化思维——解决一类问题,而不是一个问题

公司内部可推广一套规范:所有树形结构表必须包含 PID(或 Path)字段,强制业务方组合 TreeBase 结构体;同时提供树形结构通用 CRUD 与「内存中还原树形」的工具方法。

这样后续业务方(部门层级、商品分类、评论、私信回复……)无需重复造轮子,且不易出错。「解决一类问题,而不是解决一个问题」——这是从「写代码的」晋升到「做架构的」的关键思维转变。

顺手的面试高频题:COUNT 查询怎么优化?

用户关系系统反复出现「展示关注数 / 粉丝数」这类需求,本质都是 COUNT 查询。数据量一大 COUNT 就会变慢(InnoDB 要真的一行行去数——它不像 MyISAM 把总数存在表元信息里),面试常问「COUNT 慢怎么办」。四种思路,按常用程度排列:

思路做法适用场景
① Redis 直接维护结果写操作时用 INCRBY 维护计数,彻底不再 COUNT读多写多的计数场景(本章 3.9 方案二)
② 索引优化确保 COUNT 的 WHERE 条件命中索引,尽量走覆盖索引不回表没有缓存层时的兜底手段
③ 估计值用 EXPLAIN 拿到优化器估计的行数(rows 列),前端展示「约 1.2 万粉丝」允许不精确的展示场景
④ 异步刷新总数缓存在 Redis,由异步任务定时 COUNT 后覆盖回写对实时性要求不高的统计

思路 ③ 的白话:EXPLAIN SELECT ... 不执行查询,只给出执行计划,其中 rows 列是优化器基于统计信息估出来的行数——误差可能有 30%~50%,但「约 1.2 万粉丝」这种展示根本不需要精确。

思路 ④ 的骨架代码:

// 思路 ④ 的骨架:定时任务异步刷新计数缓存
func (s *FollowService) refreshFollowCount(ctx context.Context, uid int64) error {
    // 步骤 1:COUNT 数据库,WHERE follower = ? 命索引(别忘 3.8 节的最左前缀)
    cnt, err := s.repo.CountFollowing(ctx, uid)
    if err != nil {
        return err
    }
    // 步骤 2:SET 直接覆盖写回 Redis —— 以 DB 的真实值为准,顺便纠偏 INCRBY 的累计误差
    return s.cache.SetFollowCount(ctx, uid, cnt)
}
复习提示:为什么 INCRBY 会累计误差、需要「对表」? 极端情况下(Redis 重启丢数据、某次更新失败后重试多加了一次),INCRBY 出来的计数会和真实值越漂越远。异步刷新定时用 DB 的 COUNT 覆盖缓存,相当于给漂移的计数「对表」。这正是工程上经常增量更新 + 定时全量校准混用的原因:平时走增量(快),定时对总量(准)。

小收口:三个判断题共同指向一个心法——数据模型跟着「量级」和「查询模式」走:量级差异大的拆开、量级爆炸的换专用存储、重复出现的抽象成平台能力。最后还有一道加餐:第 06 章用过的 MongoDB,它的事务和 MySQL 有什么不同。


3.11 加餐:MongoDB 的事务

第 06 章文章服务用过 MongoDB(存大文本),而「MongoDB 要不要开事务」又是新手高频疑问,值得单独补一节。

单文档操作天然原子

MongoDB 对单个文档的一次操作(哪怕这次更新同时改了多个字段、甚至嵌套数组)天然是原子的——要么整个成功、要么整个失败,不需要任何显式事务代码:

// 一次 UpdateOne 同时做三件事:扣库存 + 追加买家 + 更新时间
// 单文档原子性由 MongoDB 自己保证
_, err := r.coll.UpdateOne(ctx,
    bson.M{"_id": itemID, "stock": bson.M{"$gt": 0}}, // 条件里带上 stock>0,防止超扣
    bson.M{
        "$inc":  bson.M{"stock": -1},                  // 原子自减
        "$push": bson.M{"buyers": uid},                // 原子追加到数组
        "$set":  bson.M{"utime": time.Now().UnixMilli()},
    },
)

一条命令同时改三个地方,MongoDB 保证「要么都生效、要么都不生效」——这相当于 MySQL 里「三个字段的更新放进一个事务」,但一个字都不用写。

复习提示:设计哲学:MongoDB 鼓励把「需要一起变」的数据放进同一个文档(嵌套文档 / 数组),用一次单文档更新搞定一致性。这是它和关系型数据库「拆表 + 事务」思路的根本分歧。

什么时候才需要多文档事务?

当一次业务操作必须同时修改多个文档或多个集合时,单文档原子性就帮不上忙了。经典例子是转账:扣 A 的余额、加 B 的余额,是两个文档的两次更新——必须都成功或都失败。

MongoDB 的事务必须挂在 session(会话) 上,有两种用法:

  • 手动方式:StartTransaction → 执行操作 → 成功则 CommitTransaction、失败则 AbortTransaction。步骤多、容易忘回滚,一般不推荐;
  • 推荐方式:WithTransaction——把整个流程包成一个回调,返回 nil 自动提交、返回 error 自动回滚,并且对瞬时错误(网络抖动、写冲突 WriteConflict)内置重试,比手写省心得多。
// TransferPoints 用多文档事务实现积分转账:从 from 扣 points,给 to 加 points
// 写法基于官方 mongo-driver v1(go.mongodb.org/mongo-driver)
func (r *AccountRepo) TransferPoints(ctx context.Context, from, to, points int64) error {
    // 步骤 1:创建 session —— MongoDB 的事务必须挂在 session 上
    sess, err := r.client.StartSession()
    if err != nil {
        return err
    }
    // 步骤 2:defer 结束 session,释放底层连接资源
    defer sess.EndSession(ctx)

    // 步骤 3:WithTransaction 执行事务回调
    // 回调返回 nil → 自动 Commit;返回 error → 自动 Abort
    _, err = sess.WithTransaction(ctx, func(sc mongo.SessionContext) (interface{}, error) {
        // ⚠️ 事务内的所有操作必须用 sc(session 上下文),
        //    误用外层 ctx 的话操作会跑在事务外面,事务形同虚设!
        // 步骤 4:扣减 from 的积分;filter 带余额条件,防止超扣
        res, err := r.coll.UpdateOne(sc,
            bson.M{"_id": from, "points": bson.M{"$gte": points}},
            bson.M{"$inc": bson.M{"points": -points}},
        )
        if err != nil {
            return nil, err // 出错 → 整个事务回滚
        }
        if res.ModifiedCount == 0 {
            return nil, ErrInsufficientPoints // 余额不足(没匹配到)也回滚
        }
        // 步骤 5:给 to 加积分
        if _, err := r.coll.UpdateOne(sc,
            bson.M{"_id": to},
            bson.M{"$inc": bson.M{"points": points}},
        ); err != nil {
            return nil, err
        }
        return nil, nil // 全部成功 → 自动 Commit
    })
    return err
}

事务的局限:版本与部署形态

多文档事务不是「装个 MongoDB 就有」的,它有明确的版本和部署门槛:

部署形态多文档事务支持
单机(standalone)❌ 不支持
副本集(Replica Set)MongoDB 4.0 起支持
分片集群(Sharded Cluster)MongoDB 4.2 起支持
复习提示:⚠️ 新手必踩的坑:本地单机模式开事务直接报错。本地 docker run mongo 起的是单机模式,一开事务就抛 “Transaction numbers are only allowed on a replica set member or mongos”。想在本地练事务,要起单节点副本集:mongod --replSet rs0 启动后再执行一次 rs.initiate()。

另外两个高频坑:

  • 事务默认最长 60 秒(服务端参数 transactionLifetimeLimitSeconds),超时自动 abort——事务里千万别做慢查询、更别调外部 HTTP 接口;
  • 事务里只能操作已经存在的集合:不能在事务中创建集合、建索引(对不存在集合的首次插入会隐式建集合,在事务里同样不允许),要先把集合建好再开事务。

适用场景与替代方案

事务有锁与协调开销,冲突率高时吞吐骤降,所以「能用别的方式就别开事务」:

方案适用场景说明
$inc / $set 等原子更新符单个计数器、状态字段单文档原子,最快
嵌套文档建模把「要一起变」的数据塞进一个文档MongoDB 的设计哲学,优先考虑
多文档事务真正的跨文档一致性(转账、跨集合联动)4.0+,注意 60s 超时与集合须预先存在
复习提示:一句话总结:MongoDB 的一致性策略是「能用单文档原子性就绝不开事务」——先想建模(数据放进一个文档),再想 $inc 这类原子操作符,最后才是多文档事务。这和 MySQL 世界「拆表 + 事务」的肌肉记忆正好相反,从关系库转过来的同学要刻意扭转一下思路。

小收口:加餐到此——MongoDB 的世界「建模优先于事务」,和 MySQL 的「事务兜底一切」是两种哲学,面试聊到 NoSQL 时这个对比就是你的得分点。正文全部讲完,下面进入动手环节。

3.12 补充考点:评论缓存的进阶设计——zset 排序与 EXPIRE 原子判定

先抛问题。3.7 节的三层缓存管住了「第一页根评论 + 其子评论」这个热点面。但评论区还有两类高频读没有着落:按热度 / 时间排序的完整评论列表(翻页翻得深)、楼中楼的追加写入(新回复要实时出现在列表里)。它们的缓存怎么设计?

给直觉。工业方案是:评论 id 列表用 Redis zset 缓存,评论内容用 string 单独缓存,各管一段:

  • 根评论列表:key 挂在「评论区」维度(如 comment:root:{biz}:{bizId}),member=评论 id,score=点赞数(热度排序);要时间序就换 score=时间戳;
  • 二级评论列表:key 挂在「根评论」维度(comment:sub:{rootId}),score=时间——楼中楼天然按时间线走;
  • 评论内容:每个评论 id 一个 string key,命中 id 列表后批量取内容拼装(06 章列表拼装的套路)。

追加写入时 ZADD 新评论 id 进 zset 即可,翻页用 ZREVRANGE 带 score 游标——你在 3.6 节写过的游标分页,这里是它换了个数据结构的地板。

本节的真考点:EXPIRE 当存在性判定用。楼中楼高频追加场景下有个竞态:消费者先判断「zset 存在吗」,存在才 ZADD——如果判断和追加之间 key 恰好过期被删,这条追加就永远丢了(列表缺一条)。解法朴素而精妙:不用 EXISTS 判断,直接用 EXPIRE key ttl 的返回值判断——key 存在,EXPIRE 返回 1(顺手续期);key 不存在,返回 0(此时走回源重建整表再追加)。把 Redis 单命令的原子性,变成了「判定存在 + 续期 + 追加」的原子保护伞;更复杂的序列用 Lua 把「判定 + 追加」包成一个脚本(06 章 Lua 的用法)。

复习提示:工程提示:EXPIRE 返回 0 走「回源重建」分支时,记得套上 singleflight(或分布式锁)——判定不存在的瞬间并发涌进来的请求会一起砸向 DB,把「竞态丢数据」升级成「缓存击穿」。两道防线一起上,这个分支才算写完整。

写路径的完整链路:DB 更新后删缓存而不是更新缓存(并发写会互相覆盖,06 章 3.22 的结论),删除引入击穿风险 → 本地缓存 + 热点探测兜底(09 章 3.12 的五步流程在这里落地)。而「DB 变了怎么通知缓存」:binlog → MQ,并且分片 key 选「评论区 id」——保证同一评论区的变更消息落同一分区、被顺序消费(07 章 3.7 的局部顺序),消费者按序执行 zadd / zrem,列表不会乱。

常见坑:热门评论区的 zset 也是潜在大 key(百万评论),治理手段照抄 06 章 3.26:分段 + 只缓存前 N 页。

小收口:评论缓存三层拼图 = zset 管 id 列表与排序、string 管内容、EXPIRE 返回值管存在性判定;写路径删缓存 + binlog 串行同步。到这里,「读」的最后一公里也通了。

3.13 补充考点:敏感词过滤——AC 自动机与组合敏感词

先抛问题。评论要过审核,词库里几十万上百万个敏感词,一条评论来了怎么判断有没有命中?逐个词 strings.Contains 扫一遍,百万词 × 千字评论,性能直接起飞(往坏的方向)。

给直觉。课程的审核流水线是四步:接收文本 → 黑 / 白名单(直接放行或拒绝)→ 名单匹配 → 敏感词匹配 → AI 审核。我们的重心在敏感词匹配,主流答案是 AC 自动机(Aho-Corasick)——先看它站在谁的肩膀上:

  • Trie(前缀树):把词库建成一棵树,「澳门」「澳门豆捞」共享前缀路径,匹配时沿树走,避免重复比较——但纯 Trie 每次失配要回退重来;
  • AC 自动机 = Trie + fail 指针:为每个节点补一条「失配后跳哪」的指针(相当于 KMP 的 next 数组推广到多模式),文本只扫一遍,走到哪都能自动跳到最长的可续前缀继续匹配,时间复杂度 O(文本长度 + 命中次数),与词库大小无关。

为什么选 AC 而不是别的:敏感词词库百万级、模式串长短不一、没有统一前缀——AC 自动机匹配效率高、词库加载不频繁(启动时建一次,之后只做热更新)、内存充足时可以任性。WM 算法这类对模式串长度 / 前缀有要求的方案在这里不占优。Go 生态可以用 importcjj/sensitive 这类现成库,但面试更好使的是能说清原理甚至手写 Trie + fail 指针:

// pkg/sensitive/trie.go —— AC 自动机骨架(简化示意)
type node struct {
    children map[rune]*node
    fail     *node // 失配指针:AC 自动机的灵魂
    isEnd    bool  // 是否命中一个完整敏感词
}

func (root *node) insert(word string) { /* 沿 children 建 Trie,末节点标 isEnd */ }

// BFS 建 fail 指针:子节点的 fail = 父节点 fail 的同名子节点(没有则沿 fail 链回溯到根)
// 匹配:文本逐字走 Trie,走不通就沿 fail 链回跳,途经 isEnd 节点即命中

两个必考变体:

  1. 组合敏感词:"澳门" + "博彩" + "网站" 三个词单独出现都是正常内容,连起来才是。做法:把单个词都加入 Trie 正常匹配 → 命中后按预先配置的映射关系归组(澳门 → 组A,博彩 → 组A,网站 → 组A)→ 文本扫完后判断组A是否全部命中,全命中才算违规;
  2. 拼音变体:bo cai、bocai 绕文字检测——用 mozillazg/go-pinyin(Java 世界 pinyin4j 的对应物)把文本和词库都转拼音后再过一遍自动机。顺带把全半角、大小写、特殊字符插入(赌.博)先做归一化清洗,再进匹配。
复习提示:为什么审核流水线第一步是黑白名单? 因为绝大多数文本是无害的:命中白名单直接放行、命中黑名单直接拒绝,都省掉了后面自动机匹配和 AI 审核的开销——先筛薄流量,贵的能力只处理真正存疑的少数。这是所有"多级过滤"系统的通用设计观,网关限流、监控采样都是同一个思想。

常见坑:词库热更新时直接清空重建会有瞬时空窗(这条评论漏审),用双 buffer 原子切换(新词库建好,atomic 换指针);归一化没做全是最常见的漏判原因,测试用例里务必放几个变体。

小收口:敏感词系统的面试叙事 = 审核流水线四步 → AC 自动机(Trie + fail 指针,一遍扫描)→ 组合词归组判定 → 拼音归一化。它和 3.12 的缓存设计合起来,就是评论系统「读得快 + 管得住」的完整故事。


四、跟着写:分步实现 comment-service 与 relation-service

上一章我们把支付服务拆成了独立微服务,发帖、互动、支付的主链路已经跑通。但用户在帖子下面还只能「干瞪眼」——既不能评论、也不能关注作者,社区的「关系」还没建立起来。本节动手补上两块数据骨架:comment-service(树形评论)与 relation-service(关注 / 粉丝 / 共同关注)。

两个都是独立微服务,老规矩不变:GORM 建模、Gin 暴露 HTTP、zap 打日志、viper 读配置、复用第 12 章的 pkg/registry 注册到 ETCD。树形方案选型(3.2 节)、root_id 冗余(3.3 节)、最左前缀索引(3.8 节)正文已讲透,不再重复,直接把代码落下来;Redis 只保留两处必需用法(计数 Hash、共同关注 Set),Kafka 通知以 TODO 注释从简(生产者 / 消费者的完整写法第 07 章已练过)。

步骤总览:

  1. 建库建表:comment_db 的 comments 表、relation_db 的 follows / user_stats 表;
  2. comment-service:Comment 模型与发表评论——parent_id > 0 时继承 root_id;
  3. comment-service:软删除 + 树形查询(一级评论列表附前 3 条回复、回复列表);
  4. comment-service:HTTP 路由与启动入口(注册 ETCD,跑通「发表 → 回复 → 看树」);
  5. relation-service:Follow / UserStat 模型与关注 toggle——事务内改关系 + 双方计数;
  6. relation-service:关注 / 粉丝列表与关系状态接口;
  7. 共同关注:关注写入同步 Redis Set,查询用 SINTER 求交集。

第 1 步:建库建表

DDL 与数据模型逐字段对应。注意一个命名差异:正文 3.3 节讲概念时用 Biz(字符串)+ BizID,这里统一改成 target_type(数字型)+ target_id——一张表就能服务多种资源(1=帖子、2=预留),语义相同、存储更省。

-- scripts/comment.sql
CREATE DATABASE IF NOT EXISTS comment_db;
USE comment_db;

CREATE TABLE comments (
    id          BIGINT   NOT NULL COMMENT '评论ID(雪花ID,应用侧生成)',
    target_type TINYINT  NOT NULL DEFAULT 1 COMMENT '1=帖子 2=预留',
    target_id   BIGINT   NOT NULL COMMENT '被评论的资源ID',
    user_id     BIGINT   NOT NULL COMMENT '评论者',
    content     TEXT     NOT NULL COMMENT '评论内容',
    parent_id   BIGINT   NOT NULL DEFAULT 0 COMMENT '父评论ID,0=一级评论',
    root_id     BIGINT   NOT NULL DEFAULT 0 COMMENT '根评论ID:一级评论=自己;回复=所在树的根',
    like_count  INT      NOT NULL DEFAULT 0 COMMENT '点赞数冗余(第07章互动服务维护)',
    reply_count INT      NOT NULL DEFAULT 0 COMMENT '回复数冗余',
    status      TINYINT  NOT NULL DEFAULT 1 COMMENT '1=正常 2=已删除 3=已屏蔽',
    created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (id),
    KEY idx_target (target_type, target_id, created_at), -- 查某资源的一级评论
    KEY idx_root (root_id)                               -- 查某棵评论树的全部回复
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
-- scripts/relation.sql
CREATE DATABASE IF NOT EXISTS relation_db;
USE relation_db;

CREATE TABLE follows (
    id           BIGINT  NOT NULL COMMENT '雪花ID',
    follower_id  BIGINT  NOT NULL COMMENT '关注者',
    followee_id  BIGINT  NOT NULL COMMENT '被关注者',
    status       TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 2=已取消',
    created_at   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_follower_followee (follower_id, followee_id), -- 防重复关注,toggle 的判定依据
    KEY idx_followee (followee_id, created_at)                  -- 粉丝列表专用(为什么必须单建见 3.8 节)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE user_stats (
    user_id         BIGINT NOT NULL COMMENT '用户ID',
    following_count BIGINT NOT NULL DEFAULT 0 COMMENT '关注数',
    follower_count  BIGINT NOT NULL DEFAULT 0 COMMENT '粉丝数',
    updated_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (user_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

对照正文检查一遍这两份 DDL,三个设计决策都兑现了:comments 表的 idx_root(root_id 冗余换一次查树)、follows 表的 uk_follower_followee 唯一索引(挡重复关注 + Upsert 依据)、idx_followee 单独索引(最左前缀的坑,这里直接避开)。另外注意 status 字段——1=正常 / 2=已删除 / 3=已屏蔽,这正是第 15 章状态机思想在评论审核上的翻版。

验证:

mysql -h127.0.0.1 -uroot -p < scripts/comment.sql
mysql -h127.0.0.1 -uroot -p < scripts/relation.sql
mysql -h127.0.0.1 -uroot -p -e "SHOW DATABASES LIKE '%_db';"
# 预期输出:结果集里出现 comment_db 与 relation_db 两行

第 2 步:comment-service——Comment 模型与发表评论

模型字段与 DDL 一一对应。发表评论的核心就是 3.4 节那句话:「如果 parent_id > 0,是回复,需要设置 root_id(查父评论的 root_id)」。

// internal/comment/repository/model.go
package repository

import "time"

// Comment 评论表模型(字段与第 1 步 DDL 一一对应)
type Comment struct {
    ID         int64     `gorm:"primaryKey;autoIncrement:false"` // 雪花ID,插入前生成
    TargetType int8      // 1=帖子
    TargetID   int64     // 被评论的资源ID
    UserID     int64     // 评论者
    Content    string    `gorm:"type:text"`
    ParentID   int64     // 父评论ID,0=一级评论
    RootID     int64     // 根评论ID:一级评论=自己;回复=所在树的根
    LikeCount  int32     // 点赞数冗余
    ReplyCount int32     // 回复数冗余
    Status     int8      // 1=正常 2=已删除 3=已屏蔽
    CreatedAt  time.Time
    UpdatedAt  time.Time
}

func (Comment) TableName() string { return "comments" }

接着写发表评论的 Repository。和正文 3.4 节的简化版相比,这里多做了两件事:root_id 继承和回复数 +1 放进同一个事务(要么都成、要么都不成);顺手给根评论的 reply_count 冗余计数 +1(楼中楼要显示「共 35 条回复」,总不能每次现数)。

// internal/comment/repository/comment.go —— 发表评论:root_id 继承 + 回复数冗余
package repository

import (
    "context"
    "errors"
    "fmt"

    "github.com/bwmarrin/snowflake"
    "gorm.io/gorm"
)

var (
    ErrParentNotFound  = errors.New("父评论不存在或已删除")
    ErrCommentNotFound = errors.New("评论不存在")
)

type CommentRepository struct {
    db   *gorm.DB
    node *snowflake.Node // 第06章引入的雪花ID生成器,构造函数注入
}

func NewCommentRepository(db *gorm.DB, node *snowflake.Node) *CommentRepository {
    return &CommentRepository{db: db, node: node}
}

// Create 发表评论:parentID>0 是回复,在同一事务里继承 root_id 并给根评论回复数 +1
func (r *CommentRepository) Create(ctx context.Context, c *Comment) (int64, error) {
    c.ID = r.node.Generate().Int64() // 雪花ID由应用侧生成,不依赖 DB 自增
    err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        if c.ParentID == 0 {
            c.RootID = c.ID // 一级评论:root_id = 自己
            return tx.Create(c).Error
        }
        // 回复:查父评论(已删除的不许回复),root_id 沿用父评论的 root_id
        var parent Comment
        if err := tx.Where("id = ? AND status = 1", c.ParentID).
            First(&parent).Error; err != nil {
            return fmt.Errorf("查父评论: %w", ErrParentNotFound)
        }
        c.RootID = parent.RootID
        if err := tx.Model(&Comment{}).Where("id = ?", c.RootID).
            UpdateColumn("reply_count", gorm.Expr("reply_count + 1")).Error; err != nil {
            return err
        }
        return tx.Create(c).Error
    })
    if err != nil {
        return 0, err
    }
    // TODO:发 Kafka 事件更新帖子评论数冗余、给被回复者发通知(复用第07章 producer,本章从简)
    return c.ID, nil
}
复习提示:为什么 c.RootID = parent.RootID 而不是 parent.ID? 「回复的回复」(三楼及以上)时,父评论本身就是回复、它的 root_id 才是整棵树的根。正文 3.4 节的实现里父评论允许 root_id=0(一级评论不回填),所以要 if 判断;本章的约定是一级评论的 root_id 一律等于自己,继承就简化成一行——两种约定二选一,别混用,否则查树会漏回复。

验证:go build ./... 无输出即通过(本步只有模型与写路径,接口在第 4 步联调)。

第 3 步:comment-service——软删除与树形查询

删除按软删做(status=2,第 06 章讲过的「不真删、标记删」),删一级评论时把整棵树一并标记——应用层级联,不用外键(理由见正文 3.5 节)。查询就是「根评论 + 回复列表」两层结构:列表接口给一级评论附前 3 条回复与回复总数,「查看更多」按 root_id 翻页。

// internal/comment/repository/comment.go 追加(同文件、同 package)
// Delete 软删除:只能删自己的评论;删一级评论时级联软删整棵树
func (r *CommentRepository) Delete(ctx context.Context, id, uid int64) error {
    return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        var c Comment
        // 只查"未删除"的评论:防止重复删除导致 reply_count 被重复扣减
        if err := tx.Where("id = ? AND user_id = ? AND status = 1", id, uid).First(&c).Error; err != nil {
            return fmt.Errorf("查评论: %w", ErrCommentNotFound)
        }
        if err := tx.Model(&Comment{}).Where("id = ?", c.ID).
            Update("status", 2).Error; err != nil { // 2=已删除
            return err
        }
        if c.ParentID > 0 {
            // 删的是回复:根评论的回复数 -1
            return tx.Model(&Comment{}).Where("id = ?", c.RootID).
                UpdateColumn("reply_count", gorm.Expr("reply_count - 1")).Error
        }
        // 删的是一级评论:名下所有回复一并软删(应用层级联,不用外键)
        return tx.Model(&Comment{}).Where("root_id = ? AND status = 1", c.ID).
            Update("status", 2).Error
    })
}

删一级评论的级联删除只有一行 WHERE root_id = ?——3.3 节埋的冗余字段红利再次兑现。

// internal/comment/repository/comment.go 追加
// FindByTarget 一级评论分页列表 + 每棵树最新 3 条回复(批量查,避免 N+1)
func (r *CommentRepository) FindByTarget(ctx context.Context, targetType int8, targetID int64, page, pageSize int) ([]Comment, map[int64][]Comment, error) {
    var roots []Comment
    err := r.db.WithContext(ctx).
        Where("target_type = ? AND target_id = ? AND parent_id = 0 AND status = 1", targetType, targetID).
        Order("id DESC").                              // 新评论在前
        Limit(pageSize).Offset((page - 1) * pageSize). // 本章按 page 分页;换 minID 游标见正文 3.6 节
        Find(&roots).Error
    if err != nil || len(roots) == 0 {
        return roots, nil, err
    }
    rootIDs := make([]int64, 0, len(roots))
    for _, rc := range roots {
        rootIDs = append(rootIDs, rc.ID)
    }
    // 窗口函数批量取每棵树最新 3 条——与正文 3.6 节 FindTopReplies 同一招
    var replies []Comment
    err = r.db.WithContext(ctx).Raw(`
        SELECT * FROM (
            SELECT *, ROW_NUMBER() OVER (PARTITION BY root_id ORDER BY id DESC) AS rn
            FROM comments
            WHERE root_id IN ? AND status = 1
        ) t WHERE t.rn <= 3
    `, rootIDs).Scan(&replies).Error
    if err != nil {
        return nil, nil, err
    }
    grouped := make(map[int64][]Comment, len(roots))
    for _, rc := range roots {
        grouped[rc.ID] = []Comment{} // 先占位:没有回复的评论序列化成 [] 而不是 null
    }
    for _, rep := range replies {
        grouped[rep.RootID] = append(grouped[rep.RootID], rep)
    }
    return roots, grouped, nil
}

// FindReplies "查看更多回复":某棵树下的全部回复,按 id 正序翻页
func (r *CommentRepository) FindReplies(ctx context.Context, rootID int64, page, pageSize int) ([]Comment, error) {
    var res []Comment
    return res, r.db.WithContext(ctx).
        Where("root_id = ? AND status = 1", rootID).
        Order("id ASC").
        Limit(pageSize).Offset((page - 1) * pageSize).
        Find(&res).Error
}

三个细节值得盯:parent_id = 0 过滤出一级评论(列表只展示楼,不混楼层);窗口函数方向是 id DESC(楼中楼摘要取最新 3 条,与正文 3.6 节一致;「查看更多」的楼中楼全文则按 id ASC 时间正序读,符合读楼习惯);grouped[rc.ID] = []Comment{} 占位让没回复的评论序列化成空数组而不是 null(前端少写一个判空)。

复习提示:⚠️ 新手必踩的坑:软删除后到处漏 status = 1。最典型的翻车现场:列表里评论「消失」了,点开楼中楼却把已删回复照样吐出来;或者删了回复、根评论的 reply_count 还是旧值——计数在 Delete 里减了,查询却没过滤。本步 Delete 与两个查询里的 status 条件是一套的,动手时别只抄一半。

验证:go build ./... 无输出即通过(查询效果放到第 4 步起服务后一起验)。

第 4 步:comment-service——HTTP 路由与启动入口

四个接口:POST /api/comments(发表)、DELETE /api/comments/:id(软删)、GET /api/comments(列表)、GET /api/comments/:id/replies(楼中楼)。先补配置:

# configs/config.yaml 追加(viper 统一读取;redis/etcd 段沿用第06/12章)
comment:
  http: 8200      # HTTP 端口(本章验证用)
  grpc: 9200      # gRPC 端口:服务间调用用,挂法见第12章第 5 步
  worker_id: 21   # 雪花节点号,每个服务错开(第06章的坑)
relation:
  http: 8300
  grpc: 9300
  worker_id: 31
mysql:
  comment_dsn: "root:root@tcp(localhost:3306)/comment_db?parseTime=true&loc=Local"
  relation_dsn: "root:root@tcp(localhost:3306)/relation_db?parseTime=true&loc=Local"

Handler 直连 Repository(聚焦树形本身;Service 层的参数校验、Kafka 通知、缓存清理与第 07 章互动服务同构,动手时按三分层补上):

// internal/comment/handler.go
package handler

import (
    "errors"
    "net/http"
    "strconv"

    "github.com/gin-gonic/gin"
    "github.com/yourname/content-community/internal/comment/repository"
)

type CommentHandler struct {
    repo *repository.CommentRepository
}

func NewCommentHandler(repo *repository.CommentRepository) *CommentHandler {
    return &CommentHandler{repo: repo}
}

func (h *CommentHandler) RegisterRoutes(server *gin.Engine) {
    g := server.Group("/api/comments")
    g.POST("", h.Create)             // 发表评论 / 回复
    g.DELETE("/:id", h.Delete)       // 删除(软删)
    g.GET("", h.List)                // 一级评论列表(附前 3 条回复)
    g.GET("/:id/replies", h.Replies) // 查看更多回复
}

// 统一响应格式(全局约定);relation 侧的 handler 包里放同样的两个函数
func ok(c *gin.Context, data any) {
    c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": data})
}

func fail(c *gin.Context, code int, msg string) {
    c.JSON(http.StatusOK, gin.H{"code": code, "msg": msg})
}

func (h *CommentHandler) Create(c *gin.Context) {
    var req struct {
        TargetType int8   `json:"target_type" binding:"required"` // 1=帖子
        TargetID   int64  `json:"target_id" binding:"required"`
        Content    string `json:"content" binding:"required"`
        ParentID   int64  `json:"parent_id"` // 可选:>0 表示这是回复
    }
    if err := c.ShouldBindJSON(&req); err != nil {
        fail(c, 400, "参数错误")
        return
    }
    uid := c.GetInt64("user_id") // JWT 中间件写入(第03章);本地调试可先临时写死
    id, err := h.repo.Create(c, &repository.Comment{
        TargetType: req.TargetType, TargetID: req.TargetID,
        UserID: uid, Content: req.Content, ParentID: req.ParentID,
    })
    if err != nil {
        if errors.Is(err, repository.ErrParentNotFound) {
            fail(c, 400, "父评论不存在或已删除")
            return
        }
        fail(c, 500, "系统错误") // 原始错误按全局约定 %w 包装后 zap 打 error,不外泄给前端
        return
    }
    ok(c, gin.H{"id": id})
}

List 和 Replies 两个 handler 是「取参 → 调 repo → ok()」的同样套路。List 里唯一值得贴的是「一级评论 + replies」的组装:

// internal/comment/handler.go 追加
func (h *CommentHandler) List(c *gin.Context) {
    targetType, _ := strconv.Atoi(c.DefaultQuery("target_type", "1"))
    targetID, _ := strconv.ParseInt(c.Query("target_id"), 10, 64)
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
    roots, replies, err := h.repo.FindByTarget(c, int8(targetType), targetID, page, pageSize)
    if err != nil {
        fail(c, 500, "系统错误")
        return
    }
    type commentVO struct { // 组装"一级评论 + 前 3 条回复"的返回结构
        repository.Comment
        Replies []repository.Comment `json:"replies"`
    }
    list := make([]commentVO, 0, len(roots))
    for _, rc := range roots {
        list = append(list, commentVO{Comment: rc, Replies: replies[rc.ID]})
    }
    ok(c, gin.H{"comments": list})
}

// Delete / Replies 两个 handler 是"取参 → 调 repo → ok()"的同款,不再重复贴

启动入口与第 12 章 cmd/user 完全同构,分两段看。第一段:配置、日志、依赖装配:

// cmd/comment/main.go —— 组装顺序与第12章 cmd/user 完全同构
package main

import (
    "fmt"
    "net/http"
    "os"
    "os/signal"
    "syscall"

    "github.com/bwmarrin/snowflake"
    "github.com/gin-gonic/gin"
    "github.com/spf13/viper"
    "go.uber.org/zap"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"

    "github.com/yourname/content-community/internal/comment/handler"
    "github.com/yourname/content-community/internal/comment/ioc"
    "github.com/yourname/content-community/internal/comment/repository"
    "github.com/yourname/content-community/pkg/registry"
)

func main() {
    // 1. 配置 + 日志(全局约定:viper 读 configs/,zap 结构化日志)
    viper.SetConfigFile("configs/config.yaml")
    if err := viper.ReadInConfig(); err != nil {
        panic(err)
    }
    logger, _ := zap.NewProduction()
    zap.ReplaceGlobals(logger)

    // 2. 依赖:comment_db 的 GORM 连接 + 雪花节点
    db, err := gorm.Open(mysql.Open(viper.GetString("mysql.comment_dsn")), &gorm.Config{})
    if err != nil {
        panic(err)
    }
    node, _ := snowflake.NewNode(viper.GetInt("comment.worker_id"))

第二段:HTTP 路由、ETCD 注册与优雅停机(这三步的「为什么」第 12 章都讲过,注释里标了出处):

// cmd/comment/main.go 续(同一个文件,接着上面的花括号)
    // 3. HTTP 路由(ioc.InitEtcdClient 与第12章 internal/user/ioc/etcd.go 一模一样,直接复制)
    server := gin.Default()
    handler.NewCommentHandler(repository.NewCommentRepository(db, node)).RegisterRoutes(server)

    // 4. 注册 ETCD:先起监听再注册(顺序的理由见第12章);gRPC 9200 挂法同第12章第 5 步
    port := viper.GetInt("comment.http")
    reg := registry.NewRegistry(ioc.InitEtcdClient(logger), logger)
    ip := registry.GetOutboundIP()
    if err := reg.Register(&registry.ServiceInstance{
        ServiceName: "comment-service",
        InstanceID:  fmt.Sprintf("comment-service-%s:%d", ip, port),
        Address:     ip, Port: port, Protocol: "http", Weight: 100,
    }); err != nil {
        panic(err) // fail-fast:注册不上就退出,别让客户端拿到死地址(第12章)
    }

    // 5. 优雅停机:先注销(客户端立刻感知,不等 TTL)再关 HTTP
    go func() {
        quit := make(chan os.Signal, 1)
        signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
        <-quit
        _ = reg.Deregister()
        _ = server.Shutdown(nil)
    }()
    if err := http.ListenAndServe(fmt.Sprintf(":%d", port), server); err != nil {
        panic(err)
    }
}

cmd/relation/main.go 同构:多一个 redis.NewClient 传给 cache、组装换成 relation 的 service / handler,其余照抄,不重复贴。

验证:

go run ./cmd/comment
# 另开终端。以下 curl 假设 JWT 中间件已按第03章解析出 uid;
# 本地联调最省事的做法:临时给中间件加一个 ?debug_uid=1 后门,验完删掉
curl -s -X POST localhost:8200/api/comments -H 'Content-Type: application/json' \
  -d '{"target_type":1,"target_id":42,"content":"写得好!"}'
# 预期输出:{"code":0,"msg":"success","data":{"id":736819...}}(雪花ID)
curl -s -X POST localhost:8200/api/comments -H 'Content-Type: application/json' \
  -d '{"target_type":1,"target_id":42,"content":"同感,尤其是树形那段","parent_id":736819...}'
# 预期输出:{"code":0,...,"data":{"id":736820...}}
curl -s "localhost:8200/api/comments?target_type=1&target_id=42&page=1&page_size=20"
# 预期输出:data.comments[0] 是一级评论,replies 数组里带着刚才那条回复,reply_count=1
docker exec content-etcd etcdctl get /services/comment-service/ --prefix
# 预期输出:本实例的注册 JSON(IP/端口换成你的);Ctrl+C 后再查,key 立刻消失

第 5 步:relation-service——模型与关注 toggle

关注接口做 toggle:已关注则取消、未关注则添加,且不能关注自己。核心是一条事务:关系表 upsert + 双方计数同增同减(正文 3.9 节方案一的落地);Redis 侧用 Hash 维护计数(方案二的思路,读多写多)。

先看模型:

// internal/relation/repository/model.go
package repository

import "time"

const (
    StatusActive   int8 = 1 // 已关注
    StatusCanceled int8 = 2 // 已取消
)

// Follow 关注关系表:约定 A 关注 B → follower=A、followee=B(与正文 3.8 节一致)
type Follow struct {
    ID         int64     `gorm:"primaryKey;autoIncrement:false"`
    FollowerID int64     // 关注者
    FolloweeID int64     // 被关注者
    Status     int8      // 1=正常 2=已取消
    CreatedAt  time.Time
    UpdatedAt  time.Time
}

func (Follow) TableName() string { return "follows" }

// UserStat 冗余计数表
type UserStat struct {
    UserID         int64 `gorm:"primaryKey"`
    FollowingCount int64 // 关注数
    FollowerCount  int64 // 粉丝数
    UpdatedAt      time.Time
}

func (UserStat) TableName() string { return "user_stats" }

Repository 的核心是 UpdateStatus——正文 3.9 节 Follow 函数里「步骤 3 占位」的那部分,在这里真正落地:

// internal/relation/repository/follow.go
package repository

import (
    "context"
    "fmt"
    "time"

    "github.com/bwmarrin/snowflake"
    "gorm.io/gorm"
    "gorm.io/gorm/clause"
)

type FollowRepository struct {
    db   *gorm.DB
    node *snowflake.Node
}

func NewFollowRepository(db *gorm.DB, node *snowflake.Node) *FollowRepository {
    return &FollowRepository{db: db, node: node}
}

// FindOne 查一对用户的关系记录(gorm.ErrRecordNotFound = 从未关注过)
func (r *FollowRepository) FindOne(ctx context.Context, follower, followee int64) (Follow, error) {
    var f Follow
    err := r.db.WithContext(ctx).
        Where("follower_id = ? AND followee_id = ?", follower, followee).First(&f).Error
    return f, err
}
// internal/relation/repository/follow.go 续
// UpdateStatus 写关系 + 双方计数,一个事务搞定(upsert 语义与第07章点赞同款)
func (r *FollowRepository) UpdateStatus(ctx context.Context, follower, followee int64, status int8) error {
    now := time.Now()
    return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        // 1) 关系表 upsert:唯一索引冲突时只改 status——天然幂等,并发重复点也不怕
        if err := tx.Clauses(clause.OnConflict{
            Columns:   []clause.Column{{Name: "follower_id"}, {Name: "followee_id"}},
            DoUpdates: clause.Assignments(map[string]any{"status": status, "updated_at": now}),
        }).Create(&Follow{
            ID: r.node.Generate().Int64(), FollowerID: follower, FolloweeID: followee,
            Status: status, CreatedAt: now, UpdatedAt: now,
        }).Error; err != nil {
            return err
        }
        // 2) 双方计数同增同减:关注 +1、取消 -1
        delta := int64(1)
        if status == StatusCanceled {
            delta = -1
        }
        if err := upsertStat(tx, follower, "following_count", delta, now); err != nil {
            return err
        }
        return upsertStat(tx, followee, "follower_count", delta, now)
    })
}

// upsertStat 计数行不存在则插入(首次关注),存在则增减。
// col 只会是上面写死的两个列名、不接收外部输入,无注入风险
func upsertStat(tx *gorm.DB, uid int64, col string, delta int64, now time.Time) error {
    initVal := delta
    if initVal < 0 {
        initVal = 0 // 取消时行必然已存在、走不到插入分支,这里只是保险
    }
    return tx.Exec(fmt.Sprintf(`
        INSERT INTO user_stats (user_id, %s, updated_at) VALUES (?, ?, ?)
        ON DUPLICATE KEY UPDATE %s = %s + ?, updated_at = VALUES(updated_at)
    `, col, col, col), uid, initVal, now, delta).Error
}

upsertStat 把 3.9 节方案一(计数表)落成了一条 SQL:INSERT ... ON DUPLICATE KEY UPDATE 让「首次关注插入新行」和「老用户改计数」合并成一次操作。注意它挂在 UpdateStatus 的同一个事务里——关系和计数要么一起变、要么都不变,这就是「关系表与计数表一致」的实现。

Service 层做 toggle 判断 + Redis 同步:

// internal/relation/service/follow.go
package service

import (
    "context"
    "errors"

    "gorm.io/gorm"
    "go.uber.org/zap"

    "github.com/yourname/content-community/internal/relation/cache"
    "github.com/yourname/content-community/internal/relation/repository"
)

var ErrFollowSelf = errors.New("不能关注自己")

type RelationService struct {
    repo   *repository.FollowRepository
    cache  *cache.FollowCache
    logger *zap.Logger
}

func NewRelationService(repo *repository.FollowRepository, c *cache.FollowCache,
    logger *zap.Logger) *RelationService {
    return &RelationService{repo: repo, cache: c, logger: logger}
}

// ToggleFollow 关注/取消关注二合一:已关注则取消,未关注则添加;返回最终状态
func (s *RelationService) ToggleFollow(ctx context.Context, follower, followee int64) (bool, error) {
    if follower == followee {
        return false, ErrFollowSelf
    }
    rel, err := s.repo.FindOne(ctx, follower, followee)
    if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
        return false, err
    }
    following := rel.Status == repository.StatusActive
    target := repository.StatusActive
    if following {
        target = repository.StatusCanceled
    }
    if err := s.repo.UpdateStatus(ctx, follower, followee, target); err != nil {
        return false, err
    }
    followedNow := target == repository.StatusActive
    // 3) Redis 同步:计数 Hash HINCRBY + 关注 Set SADD/SREM(Set 是第 7 步共同关注的原料)。
    // 缓存失败只打日志、不回滚 DB——计数漂移可由 3.10 节的"定时全量对表"纠偏
    if err := s.cache.SyncOnToggle(ctx, follower, followee, followedNow); err != nil {
        s.logger.Error("同步关注缓存失败", zap.Error(err))
    }
    // TODO:发 Kafka follow-events(通知被关注者 + Feed 收件箱预热,第19章接消费端),本章从简
    s.logger.Info("关注关系变更",
        zap.Int64("follower", follower), zap.Int64("followee", followee),
        zap.Bool("followed", followedNow))
    return followedNow, nil
}
复习提示:⚠️ 新手必踩的坑:toggle 的「读后写」竞态。两个请求几乎同时到达,都读到「未关注」、都走关注分支——关系表靠唯一索引 upsert 不会插出两行,但两边各 +1,计数多加了一次。低频场景影响不大;严格场景把「读状态」也挪进事务(或用 UPDATE ... WHERE status <> ? 的受影响行数决定加不减计数),让第二次 toggle 天然变成「已关注 → 取关」。这和第 15 章「先查状态再更新在并发下不可靠」是同一个道理。

验证:go build ./... 无输出即通过(cache 包第 7 步才创建,本步先把 SyncOnToggle 的调用方写好)。

第 6 步:relation-service——关注/粉丝列表与关系状态

三个查询接口:following / followers 都是 follows 表的分页查询,差别只在走哪个索引(一个走唯一索引最左前缀、一个走 idx_followee——3.8 节的两种索引各服务一个方向);status 返回「是否关注、是否被关注、是否互关」三元信息。列表里的昵称头像属于用户信息,由网关聚合 user-service 补齐(服务间调用走第 12~14 章的发现与治理),关系服务不掺和——每个服务只管自己的数据。

// internal/relation/repository/follow.go 追加(同文件、同 package)

// ListFollowing 关注列表:WHERE follower_id 命中唯一索引的最左前缀
func (r *FollowRepository) ListFollowing(ctx context.Context, uid int64, page, pageSize int) ([]Follow, error) {
    var res []Follow
    return res, r.db.WithContext(ctx).
        Where("follower_id = ? AND status = ?", uid, StatusActive).
        Order("id DESC"). // 雪花ID趋势递增,倒序=最近关注在前
        Limit(pageSize).Offset((page - 1) * pageSize).
        Find(&res).Error
}

// ListFollowers 粉丝列表:WHERE followee_id 走 idx_followee——
// 没有这个索引就是全表扫描,最左前缀的坑见正文 3.8 节
func (r *FollowRepository) ListFollowers(ctx context.Context, uid int64, page, pageSize int) ([]Follow, error) {
    var res []Follow
    return res, r.db.WithContext(ctx).
        Where("followee_id = ? AND status = ?", uid, StatusActive).
        Order("id DESC").
        Limit(pageSize).Offset((page - 1) * pageSize).
        Find(&res).Error
}
// internal/relation/repository/follow.go 追加

// Status 关系状态:我→TA 与 TA→我 各查一次存在性
func (r *FollowRepository) Status(ctx context.Context, uid, peer int64) (following, followed bool, err error) {
    exists := func(follower, followee int64) (bool, error) {
        var n int64
        e := r.db.WithContext(ctx).Model(&Follow{}).
            Where("follower_id = ? AND followee_id = ? AND status = ?",
                follower, followee, StatusActive).
            Count(&n).Error // 唯一索引保证最多一行,COUNT 足够
        return n > 0, e
    }
    if following, err = exists(uid, peer); err != nil {
        return
    }
    followed, err = exists(peer, uid)
    return
}

// ListFolloweeIDs 全量关注ID(共同关注回源 + Set 预热用,不分页)
func (r *FollowRepository) ListFolloweeIDs(ctx context.Context, uid int64) ([]int64, error) {
    var ids []int64
    err := r.db.WithContext(ctx).Model(&Follow{}).
        Where("follower_id = ? AND status = ?", uid, StatusActive).
        Order("id DESC").
        Limit(10000). // 上限保护:关注列表本身就该有产品上限(如微博 2000)
        Pluck("followee_id", &ids).Error
    return ids, err
}

Service 层是透传(保持 Handler → Service → Repository 分层完整):

// internal/relation/service/follow.go 追加:透传层
func (s *RelationService) ListFollowing(ctx context.Context, uid int64, page, pageSize int) ([]repository.Follow, error) {
    return s.repo.ListFollowing(ctx, uid, page, pageSize)
}

func (s *RelationService) ListFollowers(ctx context.Context, uid int64, page, pageSize int) ([]repository.Follow, error) {
    return s.repo.ListFollowers(ctx, uid, page, pageSize)
}

func (s *RelationService) Status(ctx context.Context, uid, peer int64) (following, followed bool, err error) {
    return s.repo.Status(ctx, uid, peer)
}

Handler 暴露五个路由(common 第 7 步实现):

// internal/relation/handler.go —— ok / fail 两个函数与 comment 侧一模一样,各服务包里放一份
package handler

import (
    "strconv"

    "github.com/gin-gonic/gin"
    "github.com/yourname/content-community/internal/relation/service"
)

type RelationHandler struct {
    svc *service.RelationService
}

func NewRelationHandler(svc *service.RelationService) *RelationHandler {
    return &RelationHandler{svc: svc}
}

func (h *RelationHandler) RegisterRoutes(server *gin.Engine) {
    g := server.Group("/api/relations")
    g.POST("/follow", h.Follow)      // 关注 / 取消关注(toggle)
    g.GET("/following", h.Following) // 关注列表
    g.GET("/followers", h.Followers) // 粉丝列表
    g.GET("/status", h.Status)       // 关系状态(是否关注 / 被关注 / 互关)
    g.GET("/common", h.Common)       // 共同关注(第 7 步实现)
}

func (h *RelationHandler) Follow(c *gin.Context) {
    var req struct {
        FolloweeID int64 `json:"followee_id" binding:"required"`
    }
    if err := c.ShouldBindJSON(&req); err != nil {
        fail(c, 400, "参数错误")
        return
    }
    followed, err := h.svc.ToggleFollow(c, c.GetInt64("user_id"), req.FolloweeID)
    if err != nil {
        fail(c, 500, "系统错误")
        return
    }
    ok(c, gin.H{"followed": followed})
}
// internal/relation/handler.go 追加
func (h *RelationHandler) Following(c *gin.Context) {
    uid, page, pageSize := parsePage(c)
    follows, err := h.svc.ListFollowing(c, uid, page, pageSize)
    if err != nil {
        fail(c, 500, "系统错误")
        return
    }
    ok(c, gin.H{"follows": follows})
}

func (h *RelationHandler) Followers(c *gin.Context) {
    uid, page, pageSize := parsePage(c)
    follows, err := h.svc.ListFollowers(c, uid, page, pageSize)
    if err != nil {
        fail(c, 500, "系统错误")
        return
    }
    ok(c, gin.H{"follows": follows})
}

func (h *RelationHandler) Status(c *gin.Context) {
    peerID, _ := strconv.ParseInt(c.Query("user_id"), 10, 64)
    following, followed, err := h.svc.Status(c, c.GetInt64("user_id"), peerID)
    if err != nil {
        fail(c, 500, "系统错误")
        return
    }
    ok(c, gin.H{"following": following, "followed": followed,
        "mutual": following && followed}) // 互关 = 我关注TA 且 TA关注我
}

// parsePage 列表接口三件套:user_id + page + page_size(默认 1 / 20)
func parsePage(c *gin.Context) (uid int64, page, pageSize int) {
    uid, _ = strconv.ParseInt(c.Query("user_id"), 10, 64)
    page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
    pageSize, _ = strconv.Atoi(c.DefaultQuery("page_size", "20"))
    return
}

验证:

go run ./cmd/relation
# uid 的取法同第 4 步(JWT 或调试后门)。先关注 100,再原样发一次验证 toggle:
curl -s -X POST localhost:8300/api/relations/follow -H 'Content-Type: application/json' \
  -d '{"followee_id":100}'
# 预期输出:{"code":0,"msg":"success","data":{"followed":true}};再发一次预期 followed:false
curl -s "localhost:8300/api/relations/status?user_id=100"
# 预期输出:..."data":{"following":true,"followed":false,"mutual":false}}

第 7 步:共同关注——Redis Set + SINTER

「我和 TA 的共同关注」有两条路:基础实现是查两边关注列表在内存求交集;优化路径是把关注列表同步进 Redis Set,用 SINTER 一条命令求交集。两条路都要写:SINTER 快但依赖预热,DB 交集慢但是兜底——缓存侧任何一步不可信都能退回数据库。

先补课两个 Redis 原语(第 06 章只用了 String / Hash,Set 是本章新用的):

补课胶囊 · Redis Set 与 SINTER 一句话:Set 是不重复的字符串集合,SINTER 一条命令求多个集合的交集。 类比:两张通讯录找共同好友——SINTER 就是「把两本通讯录摊开,重名的一挑」。 最小示例:SADD relation:follow_set:1 100 200(用户 1 关注了 100 和 200)、SADD relation:follow_set:2 100 200,然后 SINTER relation:follow_set:1 relation:follow_set:2 返回 100 200——两人的共同关注。 回到主线:只要在关注 / 取关时顺手维护 Set,共同关注就变成一条 O(N) 的 Redis 命令。

// internal/relation/cache/follow.go —— 计数 Hash + 关注 Set(共同关注的原料)
package cache

import (
    "context"
    "strconv"
    "time"

    "github.com/redis/go-redis/v9"
)

type FollowCache struct {
    client redis.Cmdable
}

func NewFollowCache(client redis.Cmdable) *FollowCache {
    return &FollowCache{client: client}
}

func setKey(uid int64) string { return "relation:follow_set:" + strconv.FormatInt(uid, 10) }

// SyncOnToggle toggle 后同步 Redis:计数 Hash HINCRBY(3.9 节的计数方案)
// + 关注 Set SADD/SREM。成对更新用 TxPipeline,理由见 3.9 节的对比表
func (c *FollowCache) SyncOnToggle(ctx context.Context, follower, followee int64, followed bool) error {
    delta := int64(1)
    if !followed {
        delta = -1
    }
    pipe := c.client.TxPipeline()
    pipe.HIncrBy(ctx, "relation:count:"+strconv.FormatInt(follower, 10), "following", delta)
    pipe.HIncrBy(ctx, "relation:count:"+strconv.FormatInt(followee, 10), "follower", delta)
    if followed {
        pipe.SAdd(ctx, setKey(follower), followee)
    } else {
        pipe.SRem(ctx, setKey(follower), followee)
    }
    _, err := pipe.Exec(ctx)
    return err
}
// internal/relation/cache/follow.go 续

// SInter 共同关注:两个 Set 都存在才敢信结果;否则第二个返回值 false,让 Service 回源 DB
func (c *FollowCache) SInter(ctx context.Context, uid, peer int64) ([]int64, bool, error) {
    n, err := c.client.Exists(ctx, setKey(uid), setKey(peer)).Uint64()
    if err != nil || n != 2 {
        return nil, false, err // 任一集合没预热过,SINTER 的空结果不可信
    }
    members, err := c.client.SInter(ctx, setKey(uid), setKey(peer)).Result()
    if err != nil {
        return nil, false, err
    }
    res := make([]int64, 0, len(members))
    for _, m := range members {
        id, _ := strconv.ParseInt(m, 10, 64)
        res = append(res, id)
    }
    return res, true, nil
}

// Warmup 回源后预热:全量关注列表灌回 Set,TTL 一小时(冷用户自然淘汰)
func (c *FollowCache) Warmup(ctx context.Context, uid int64, followeeIDs []int64) error {
    if len(followeeIDs) == 0 {
        return nil
    }
    members := make([]any, 0, len(followeeIDs))
    for _, id := range followeeIDs {
        members = append(members, id) // SAdd 是可变参数,切片要展开传
    }
    if err := c.client.SAdd(ctx, setKey(uid), members...).Err(); err != nil {
        return err
    }
    return c.client.Expire(ctx, setKey(uid), time.Hour).Err()
}

Service 侧把两条路径拼起来——SINTER 命中直接用,未预热回源求交集并回写:

// internal/relation/service/follow.go 追加:共同关注——SINTER 命中直接用,未预热回源求交集并回写
func (s *RelationService) CommonFollow(ctx context.Context, uid, peer int64) ([]int64, error) {
    ids, hit, err := s.cache.SInter(ctx, uid, peer)
    if err != nil {
        s.logger.Error("读共同关注缓存失败,降级 DB", zap.Error(err))
    }
    if hit {
        return ids, nil
    }
    // 回源:查两边全量关注列表,内存求交集(基础实现)
    mine, err := s.repo.ListFolloweeIDs(ctx, uid)
    if err != nil {
        return nil, err
    }
    theirs, err := s.repo.ListFolloweeIDs(ctx, peer)
    if err != nil {
        return nil, err
    }
    set := make(map[int64]struct{}, len(theirs))
    for _, id := range theirs {
        set[id] = struct{}{}
    }
    common := make([]int64, 0, len(mine))
    for _, id := range mine {
        if _, ok := set[id]; ok {
            common = append(common, id)
        }
    }
    // 回写预热两个 Set:下一次 SINTER 直接命中
    if err := s.cache.Warmup(ctx, uid, mine); err != nil {
        s.logger.Error("预热关注Set失败", zap.Error(err))
    }
    if err := s.cache.Warmup(ctx, peer, theirs); err != nil {
        s.logger.Error("预热关注Set失败", zap.Error(err))
    }
    return common, nil
}
// internal/relation/handler.go 追加
func (h *RelationHandler) Common(c *gin.Context) {
    peerID, _ := strconv.ParseInt(c.Query("user_id"), 10, 64)
    ids, err := h.svc.CommonFollow(c, c.GetInt64("user_id"), peerID)
    if err != nil {
        fail(c, 500, "系统错误")
        return
    }
    ok(c, gin.H{"common_follows": ids})
}
复习提示:⚠️ 新手必踩的坑:把 SINTER 的空结果当「没有共同关注」。Set 只在用户发生过关注 / 取关(或预热过)后才存在,没预热时 SINTER 同样返回空——两种语义完全不同。所以先 EXISTS 确认两个集合都在,才敢信交集;否则回源 DB 求交集,顺手回写预热。另注意:取关走 SREM 而不是删掉整个集合,否则又退回「没预热」状态。

验证:

# 让 1、2 两个用户都关注 100 和 200(uid 取法同第 4 步)
for uid in 1 2; do for peer in 100 200; do
  curl -s -X POST localhost:8300/api/relations/follow -H 'Content-Type: application/json' \
    -d "{\"followee_id\":$peer}" > /dev/null
done; done
curl -s "localhost:8300/api/relations/common?user_id=2"
# 预期输出:..."data":{"common_follows":[200,100]}}(按关注时间倒序)
redis-cli SINTER relation:follow_set:1 relation:follow_set:2
# 预期输出两行:200 / 100——接口与 Redis 侧一致,说明 SINTER 优化已生效

本章完成后的项目结构

content-community/
├── configs/
│   └── config.yaml            # 修改:追加 comment/relation 的端口、worker_id 与两个 DSN
├── scripts/
│   ├── comment.sql            # 新增:comment_db / comments 表
│   └── relation.sql           # 新增:relation_db / follows / user_stats 表
├── cmd/
│   ├── comment/main.go        # 新增:评论服务入口(HTTP 8200,注册 ETCD)
│   └── relation/main.go       # 新增:关系服务入口(HTTP 8300,注册 ETCD)
└── internal/
    ├── comment/
    │   ├── handler.go         # 新增:四个评论接口(含统一响应 ok/fail)
    │   ├── ioc/etcd.go        # 新增:复制第12章 user 侧同名文件改包名
    │   └── repository/
    │       ├── model.go       # 新增:Comment 模型
    │       └── comment.go     # 新增:发表/软删/树形查询(root_id 继承核心)
    └── relation/
        ├── handler.go         # 新增:follow/following/followers/status/common
        ├── ioc/etcd.go        # 新增:同上
        ├── cache/follow.go    # 新增:计数 Hash + 关注 Set(SINTER)
        ├── service/follow.go  # 新增:ToggleFollow / CommonFollow
        └── repository/
            ├── model.go       # 新增:Follow / UserStat 模型
            └── follow.go      # 新增:toggle 事务 + 列表 + 关系状态

验证清单

  • 两个库建好,comments / follows / user_stats 三张表索引齐全(SHOW CREATE TABLE 核对)
  • 「一级评论 → 回复 → 回复的回复」三层里,后两层的 root_id 都等于一级评论 id(SELECT id,parent_id,root_id FROM comments)
  • 列表接口每条一级评论带 3 条以内 replies 与 reply_count;删回复后 reply_count 减 1
  • 删一级评论后整棵树 status=2,列表与楼中楼都查不到
  • 关注自己返回「不能关注自己」;连点两次完成关注 → 取关,user_stats 与 Redis 计数同步加减
  • 两个用户共同关注的接口结果与 redis-cli SINTER 一致;首次查询走 DB 回源并完成预热
  • etcdctl get /services/comment-service/ --prefix 与 /services/relation-service/ 都能查到注册 JSON,Ctrl+C 后 key 立刻消失

五、工程实践要点

正文和「跟着写」覆盖了主线,这一节把生产环境里「面试官最爱追问、上线前必须想清楚」的点集中列出。

1. 表设计先行:复盘本章开头的两个事故

本章两个事故的解药都不在「代码」上,而在「表」上:

事故根源解药
递归查评论树、接口 2 秒只存 parent_id,查子树只能一层层递归root_id 冗余字段,WHERE root_id = ? 一条 SQL 捞整棵树
粉丝列表全表扫描、DB CPU 打满以为 (follower, followee) 联合索引双向生效最左前缀原则逼出 followee 单列索引

方法论就一句话:先想清楚查询模式(每个接口的 WHERE 长什么样、各跑多频繁),再反推表结构和索引——而不是先凭直觉建表,再让查询去迁就表。社区产品的核心竞争力在数据模型设计,指的就是这个工作顺序。

2. 冗余字段:拿空间换时间,但要付一致性代价

Comment 表里的 root_id、reply_count、like_count 全是冗余——理论上都能从其他行推导出来。冗余换来的红利贯穿了全章:整棵子树一条 SQL、列表接口不用 COUNT。代价是写入路径变复杂:新增回复要 reply_count + 1,删回复要 -1,漏掉任何一处,冗余值就开始「说谎」。

判断标准:读频率远大于写频率的字段才值得冗余。评论的 reply_count 一天被读几万次、被写几十次,值得;反过来一个只写不读的字段冗余它干什么。

3. 大厂为什么不用外键:一致性的责任挪到了应用层

3.5 节给过四条理由:性能开销(每次写入多一次引用检查)、分库分表后失效(级联删不了跨库的子评论)、死锁风险、阻碍数据迁移与修复。但要记住那个坑:不用外键 ≠ 不保证一致性——应用层手动级联必须包在事务里,否则删到一半进程崩了照样留孤儿。外键是数据库替你把关,应用层级联是你自己把关,两者必居其一,不能都没有。

顺手的红利:本课程的表结构用 root_id 之后,级联删除被压成一行 UPDATE comments SET status = 2 WHERE root_id = ?(软删,比物理 DELETE 更可回滚),比外键方案简洁得多。

4. 缓存不是无脑加:先算命中率这笔账

3.7 节的核心判断:同一份数据,在不同场景下命中率差异巨大。Feed 流每秒读上万个不同用户的关注列表——集合太大、重复访问太少,缓存不划算;用户翻自己的关注列表——数据不变、翻页来回读,缓存命中率高。冷热两极分化的评论数据只缓存热帖,长尾资源(三天前的第 50 页)压根不进缓存。

三层缓存各司其职,别记混了:

层解决什么更新方式
Kafka 通知预热新资源从 0 到 1(第一波流量前先灌缓存)发评论后发消息,消费者写缓存
秒级定时刷新从 1 到 1 持续保鲜(热帖前 N 页)定时任务覆盖写
本地缓存连 Redis 都不碰(扛极端热点)进程内 + 短过期

5. 接口分级是降级的前提

「一级评论列表」挂了用户等于看不到评论区,「楼中楼摘要」「加载更多」挂了只是少看几条。3.7 节的分级:核心接口全力保障(缓存 + 读写分组 + 限流),附属接口可以降级甚至直接关闭。分级这个动作要提前做——等数据库已经打满、人人自危的时候,你根本来不及判断「哪个接口可以先死」。

6. 计数的「增量更新 + 定时全量校准」

Redis INCRBY 维护计数快,但会漂移:Redis 重启丢数据、某次更新失败后补偿多加了一次,误差会越滚越大。工程惯例是混用两种更新:平时走增量(快),定时任务用 DB 的真实 COUNT 覆盖回写(准)——3.10 节思路④的 refreshFollowCount 就是给漂移的计数「对表」。所有用 INCRBY 维护的计数(点赞数、粉丝数、阅读量)都该配这么一道校准。

7. 共同关注:SINTER 的空结果有两个含义

第 7 步反复强调的坑:SINTER 返回空,可能是「真的没有共同关注」,也可能是「集合还没预热」——两种语义完全不同,所以必须先 EXISTS 确认两个集合都存在才敢信交集,否则回源 DB。缓存里「没有数据」永远是一笔糊涂账,这个判断模式(先确认缓存可信,再信缓存结果)适用于所有「集合类」缓存。

8. 平台化思维与存储可替换

两个「了解级」但面试能加分的点:其一,树形结构平台化——公司级规范强制所有树形表组合 TreeBase(带 PID/Path 字段),配通用 CRUD 和内存还原树的工具,评论、部门层级、商品分类不再各造一遍轮子;其二,存储可替换——Repository 接口隔离 MySQL 与 Tablestore,数据量真到十亿行、分库分表又撞上跨库聚合时,换个实现类就能切换,「不到逼不得已不要提早用」。


六、自测题与动手练习

自测题(先别翻正文,合上书能答出来才算懂;答案要点都藏在正文里):

  1. 树形结构四种存储方案各自记什么信息?两两对比优缺点,最后说清选型结论「邻接表 + root_id」的理由。
  2. root_id 的继承规则是什么?「回复的回复」的 root_id 是怎么确定的?为什么说这个字段把递归查询压成了一条 SQL?
  3. offset 分页在评论区具体怎么「翻车」?minID 游标分页为什么天然不怕并发写入?
  4. N+1 查询问题是什么?楼中楼「每条一级评论带最新 3 条回复」用哪条 SQL 一次搞定?窗口函数在里面干了什么?
  5. 最左前缀原则说的是什么?(follower, followee) 联合唯一索引为什么救不了「查我的粉丝列表」?
  6. 关注 toggle 的一个事务里依次做了几件事?Redis 侧 TxPipeline 发了哪几个命令?为什么要成对出现?
  7. 共同关注为什么必须先 EXISTS 再 SINTER?回源 DB 之后为什么还要回写预热?
  8. MongoDB 单文档操作为什么天然原子?多文档事务要满足哪两个条件(版本 + 部署形态)?

动手练习(建议真做一遍,做到才算过关):

  1. 楼中楼改造:把 FindTopReplies 从「最新 3 条」改成「点赞数最高的 3 条」。完成标准:SELECT * FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY root_id ORDER BY like_count DESC, id DESC) AS rn ...) t WHERE rn <= 3,造 5 条回复、其中 1 条点赞数最高,断言它出现在摘要里;说得出为什么 ORDER BY 里还要带 id DESC(点赞数相同时取更新的)。
  2. 计数对表任务:实现 refreshFollowCount 定时任务(每 10 秒跑一次),然后故意把 Redis 计数改坏——redis-cli HSET relation:count:1 following 99999,等任务跑完。完成标准:HGET 读回的值回到真实 COUNT;再压测 toggle 500 次,观察增量期间计数短暂偏离、校准后归位。
  3. 索引对比实验:先删掉 followee 单列索引(ALTER TABLE follows DROP INDEX idx_followee),EXPLAIN SELECT * FROM follows WHERE followee_id = 100 看一眼 type 列;再加回来重跑 EXPLAIN。完成标准:拿到「全表扫描(ALL / rows=全表行数)」和「索引查找(ref)」两份对比,能指出 possible_keys 和 key 两列的含义。
  4. (选做)共同关注的边界:删掉一方的关注 Set(redis-cli DEL relation:follow_set:1)后调 /api/relations/common。完成标准:接口仍返回正确交集(走了 DB 回源),且 Redis 里 Set 被重新预热;用自己的话解释「为什么 SINTER 此时不能直接信」。

七、模拟面试问答

以下 15 道题覆盖本章所有高频考点,按「基础 → 进阶 → 追问」三档排列。每题给出可以直接背的一句话结论、展开思路、面试官想听的关键词、新手最容易答错的地方。

面试官
【基础】 树形结构在关系型数据库里有哪几种存储方案?你们评论系统为什么选邻接表 + root_id?
候选人

一句话结论:四种方案——邻接表(记 parent_id)、路径枚举(记完整路径)、嵌套集(记左右区间)、闭包表(把所有祖先-后代对全记下来);选邻接表 + root_id 冗余,因为它写入最简单、配合 root_id 后查整棵子树也是一条 SQL。

展开:邻接表写入简单但查子树要递归;路径枚举查子树快(LIKE 前缀匹配)但节点移动要改所有后代路径;嵌套集查询极快但插入要重排一大片区间;闭包表什么查询都快但空间放大严重(每个节点存到所有祖先的记录)。B 站式评论只需要两级展示(一级评论 + 楼中楼),加一个 root_id 冗余字段后,WHERE root_id = ? 一条 SQL 捞整棵树——用最小的复杂度覆盖了核心查询。

面试官想听的关键词:邻接表、路径枚举、嵌套集、闭包表、冗余字段、两级展示。

常见失分点:只会背四个名字,说不出各自的致命短板(被追问「为什么不选闭包表」就卡壳);不知道 root_id 是邻接表的「补丁」而不是第五种方案。

面试官
【基础】 root_id 字段是干什么的?写入时怎么维护?
候选人

一句话结论:root_id 记录「这条评论属于哪棵树」——一级评论的 root_id 是自己,所有回复(不管多少层)都继承所在树根的 root_id;写入时一行继承规则:parent 是一级评论就继承 parent.id,否则继承 parent.root_id。

展开:只有 parent_id 的话,想取一条一级评论下的全部回复必须递归(查子节点的子节点的子节点);有了 root_id,WHERE root_id = ? 直接命中索引、一条 SQL 捞完整棵子树,楼中楼「加载更多」和级联软删(UPDATE ... WHERE root_id = ?)全靠它。代价是写入时多维护一个字段——这就是典型的「拿一点写复杂度换查询性能」的冗余设计。

面试官想听的关键词:冗余字段、继承规则、整棵子树一条 SQL、楼中楼、级联删除。

常见失分点:说不出继承规则(被问「回复的回复的 root_id 是谁」答错);只讲查询红利不讲写入维护成本,显得没权衡意识。

面试官
【基础】 最左前缀原则是什么?关注关系表为什么要建两个索引?
候选人

一句话结论:联合索引 (a, b) 只能按「从最左列开始连续的前缀」使用——WHERE a = ? 能走,WHERE b = ? 不能走;关注表的核心查询有两个方向(我关注了谁 follower = ?、谁关注了我 followee = ?),所以 (follower, followee) 唯一索引之外必须再建 followee 单列索引。

展开:联合索引像电话黄页按「姓氏、名字」排序——按姓氏查很快,只按名字查等于乱序。WHERE followee = ? 用不了 (follower, followee) 索引(最左列不匹配),没有第二个索引就是全表扫描,粉丝多的用户一打开粉丝页 DB CPU 直接打满(本章开头的事故二)。另外 (follower, followee) 唯一索引还兼职去重——重复关注撞唯一约束,是 Upsert 幂等的基础。

面试官想听的关键词:最左前缀、联合索引、查询方向、唯一索引兼职去重、全表扫描事故。

常见失分点:以为联合索引双向生效;只会背「最左前缀」四个字,被要求用黄页类比或画索引排序示意就讲不出。

面试官
【基础】 Redis 的 Pipeline、Transaction、TxPipeline 有什么区别?
候选人

一句话结论:Pipeline 只省网络往返(命令打包一次发出,不保证连续执行);MULTI/EXEC 事务保证命令连续执行中间不被插队(Go 里很少直接用,出错回滚语义很弱);TxPipeline 是「打包 + 连续执行」二合一,是 Go 客户端里最常用的选择。

展开:关注成功要同时改两个计数(我的 following +1、对方的 follower +1)再改 Set——三个命令必须成对执行。用普通命令发三次是三次网络往返且可能中途被打断;Pipeline 快但不保连续;TxPipeline 一次往返 + 连续执行,本章关注/取关的缓存同步就用它。要注意 TxPipeline 不是数据库事务——命令失败不回滚,所以它只适合「都成功最好、部分失败可容忍/可修复」的场景(计数漂移靠定时对表修复)。

面试官想听的关键词:省网络往返、连续执行、打包、不等于回滚、计数漂移 + 定时校准。

常见失分点:把 TxPipeline 当成「Redis 版数据库事务」宣称能回滚;说不清三个概念各自解决什么问题。

面试官
【进阶】 评论列表为什么不用 offset 分页?你的游标分页是怎么设计的?
候选人

一句话结论:评论区持续有人往顶部插新数据,offset(页码)分页在并发写入下会跳过或重复数据;改用 minID + limit 游标分页——「上一页最后一条的 ID」当书签,下一页只取 id < minID 的记录,天然免疫新数据插入。

展开:offset 翻车现场:查第 2 页(OFFSET 10)时别人发了 2 条新评论,原来的第 11、12 条被顶到 13、14 位,你取到的「1130」实际是「1332」——跳过 2 条;反过来有评论被删就会出现重复。游标分页只认 ID 不认位置:新评论 ID 更大,进不了 id < minID 的结果集,怎么刷都不串页。配合雪花 ID 趋势递增,ORDER BY id DESC 就是时间倒序,还省掉 ORDER BY ctime 的额外索引。局限也要说:游标分页做不了「跳转到第 N 页」,只能连续往后翻——评论场景恰好不需要跳页。

面试官想听的关键词:offset 重复/跳过、并发写入、书签/游标、minID + limit、雪花 ID 趋势递增、不支持跳页。

常见失分点:讲不清 offset 到底怎么翻车(背结论没过程);不知道游标分页换掉了「跳页」能力,被追问「用户想跳第 10 页怎么办」就懵。

面试官
【进阶】 「每条一级评论带最新 3 条回复」的楼中楼摘要怎么查?怎么避免 N+1?
候选人

一句话结论:窗口函数一条 SQL——ROW_NUMBER() OVER (PARTITION BY root_id ORDER BY id DESC) 给每棵树内的回复分别编号,外层 WHERE rn <= 3 取每棵树的最新 3 条;配合 WHERE root_id IN (?) 批量传入 20 个一级评论 ID,两趟查询完成,彻底没有 N+1。

展开:N+1 问题是循环里逐条查库——20 条一级评论逐条查回复就是 21 次数据库交互。批量查(IN)解决次数问题,但「每棵树只要 3 条」还要求分组限量,普通 GROUP BY 做不到「每组取前 N」,窗口函数正是干这个的:PARTITION BY 分组、组内 ORDER BY 排序、ROW_NUMBER 编号,类比运动会按班级报数后「每班前 3 号出列」。查回来在内存里按 RootID 分桶装成 map 返回。

面试官想听的关键词:N+1、批量 IN、窗口函数、PARTITION BY、每组取前 N、内存分桶。

常见失分点:不知道 GROUP BY 无法「每组取前 N」;答「循环里查 20 次」被追问性能就答「加缓存」——缓存治标不治本,先把查询次数降下来。

面试官
【进阶】 关注接口怎么保证「关系」和「计数」的一致性?
候选人

一句话结论:DB 侧一个事务里做三件事——关系表 Upsert(ON DUPLICATE KEY UPDATE 只改 status,天然幂等)、关注者 following_count 加减、被关注者 follower_count 加减;Redis 侧用 TxPipeline 把两个计数 Hash 和关注 Set 的更新打包成对执行,再配定时任务用 DB 真实值覆盖校准。

展开:Upsert 解决「连点两次关注按钮」的并发重复——唯一索引 (follower, followee) 冲突时只更新 status,不用先 SELECT 再判断。计数用 user_stats 表的 Upsert(存在则 count = count + delta)而不是每次 COUNT 全表——InnoDB 的 COUNT 是真的一行行数。DB 和 Redis 两个存储不共享事务,所以 Redis 侧失败只记日志不回滚 DB(关系是主数据、计数是缓存性质的衍生数据),靠「增量更新 + 定时全量校准」最终一致。

面试官想听的关键词:Upsert、唯一索引幂等、事务、双计数同增同减、TxPipeline、最终一致、定时校准。

常见失分点:把 Redis 计数和 DB 关系放进「同一个事务」来讲(跨存储没有分布式事务这么用的);忘了说 status 软删(重复关注是复活记录不是新插一行)。

面试官
【进阶】 哪些数据值得放缓存?评论的缓存是怎么设计的?
候选人

一句话结论:值不值得缓存看命中率——同一份数据短期内是否被重复访问;评论冷热两极分化,只缓存热门资源的前几页,且用三层缓存:Kafka 通知预热(0 到 1)+ 秒级定时刷新(1 到 1 保鲜)+ 本地缓存(极端热点连 Redis 都不碰)。

展开:反例先摆出来:Feed 流每次读上万个不同用户的关注列表,集合大、重复访问少,缓存命中率极低还挤占内存——不缓存;正例:用户翻自己的关注列表,数据稳定重复读,命中率高。评论数据两极分化,策略是只缓存热帖前 N 页:新热帖靠 Kafka 通知第一波流量前预热(从 0 到 1),在榜期间秒级定时刷新保持新鲜(从 1 到 1),个别极端热点再加进程内本地缓存。长尾资源(三天前的第 50 页)直接放行查库——反正没人来。配接口分级:一级评论列表是核心接口全力保,楼中楼摘要、加载更多可降级甚至关闭。

面试官想听的关键词:命中率、冷热分离、预热、定时刷新、本地缓存、长尾不缓存、接口分级。

常见失分点:答「都加缓存」不谈命中率分析;三层缓存只会报名字,说不出各自解决「从 0 到 1」还是「从 1 到 1」。

面试官
【追问】 共同关注怎么实现?线上为什么会查错?
候选人

一句话结论:双路径——优先 Redis SINTER 求两个关注 Set 的交集(快),但必须先 EXISTS 确认两个集合都被预热过,否则空结果不可信,回源 DB 查两边全量关注列表、内存求交集,并回写预热两个 Set。

展开:SINTER 的空结果有两个含义:「真没有共同关注」或「集合还没预热」——语义二义,所以 Exists 检查是正确性的前提,不是优化。回源后顺手 Warmup 回写(SADD 全量 + TTL 一小时,冷用户自然淘汰),下一次 SINTER 直接命中。坑还有两个:取关走 SREM 删成员而不是 DEL 整个集合(否则又退回未预热状态);Set 里的 ID 数量可能很大,超热的用户(关注几千人)交集运算在 Redis 单线程里执行太久,要考虑截断或异步化。

面试官想听的关键词:SINTER、EXISTS 前置判断、空结果二义、回源求交集、Warmup 回写、SREM 不 DEL。

常见失分点:直接 SINTER 不做 Exists 判断——被追问「集合不存在时返回什么」就暴露没踩过坑;不知道回写预热这步,方案退化成每次都打 DB。

面试官
【追问】 粉丝数这种 COUNT 很慢,你怎么优化?Redis 计数为什么会不准?
候选人

一句话结论:四种思路按常用度排——① Redis INCRBY 增量维护彻底不 COUNT;② 索引优化让 COUNT 走覆盖索引;③ 用 EXPLAIN 的 rows 估计值展示「约 1.2 万」;④ 异步定时 COUNT 后覆盖回写缓存;Redis 计数不准是因为 INCRBY 是增量累计,Redis 重启丢数据或某次补偿多加都会让误差滚雪球,所以必须配定时全量校准「对表」。

展开:InnoDB 的 COUNT 是真的一行行数(不像 MyISAM 把总数存在元信息里),千万行的表 COUNT 要几秒。方案①最快但有漂移风险;③的估计值误差 30%~50%,但展示场景够用;④是①的安全网——平时增量(快),定时用 DB 真实值覆盖(准),误差永远不超过一个校准周期。这题的隐藏考点是「知道每种方案的失效场景」,比背方案本身重要。

面试官想听的关键词:INCRBY 增量、覆盖索引、EXPLAIN 估计值、异步刷新对表、增量 + 全量校准混用。

常见失分点:只答「加缓存」一个字,被追问「缓存里的数怎么来的、错了怎么办」就断片;不知道 InnoDB COUNT 慢的原因。

面试官
【追问】 为什么大厂不用数据库外键?删评论的级联删除你怎么做的?
候选人

一句话结论:外键有性能开销(每次写入多一次引用检查)、分库分表后跨库失效、高并发下放大锁持有范围、阻碍数据迁移修复,所以一致性挪到应用层做——本课程用 root_id 把级联软删压成一条 UPDATE comments SET status = 2 WHERE root_id = ?,包在事务里。

展开:级联删除(删父节点连同所有子树)两种做法:应用层手动级联 vs 数据库外键 ON DELETE CASCADE。选应用层的核心理由是外键在分库分表后彻底失效——评论表按 biz_id 拆 16 个库,父子评论可能不在同一个库,外键管不到;而且级联范围不可控,误删一条一级评论可能瞬间锁住几百行。用软删(status=2)而不是物理 DELETE,是为了可审计可恢复——评论是 UGC 内容,平台侧要能配合监管回溯。

面试官想听的关键词:引用检查开销、跨库失效、锁范围、软删除、应用层事务保证一致性。

常见失分点:答「外键影响性能」就停,四条理由说不全;被追问「不用外键那一致性谁保证」答不上「应用层 + 事务」。

面试官
【追问】 关注和拉黑要放一张表吗?如果关注数据到十亿行了呢?
候选人

一句话结论:不放一张表——量级差两个数量级(关注人均上百、拉黑人均个位数),混在一起让拉黑查询背上十亿行包袱;数据量真到十亿行、MySQL 分库分表又撞上跨库聚合问题时,才考虑 Tablestore 这类海量结构化存储,且通过 Repository 接口预留切换、业务代码不动。

展开:拆表原则是「按量级和查询模式拆」——99.99% 的行 is_block 都是 0 的设计是灾难。十亿行的另一条路是分库分表:按 follower 分片,查关注列表没问题,但查粉丝列表时同一 followee 的粉丝散落在所有分片上,跨库聚合性能急剧恶化——这正是关系型数据在这类场景的边界。Tablestore 适合海量关系,但 API 难用且要小心 SQL 注入(不支持参数化查询),讲义原话「不到逼不得已,不要提早用」。正确的架构姿势:定义 FollowRepository 接口隔离实现,MySQL 跑不动了换实现类,业务代码零改动。

面试官想听的关键词:按量级拆表、分片键、跨库聚合、Tablestore/OTS、接口隔离、延迟决策。

常见失分点:上来就答「分库分表」却讲不清粉丝查询为什么恶化;不知道「用接口预留切换能力、不提早引入新存储」这个延迟决策思维。

面试官
【进阶】 评论列表的缓存为什么用 zset?楼中楼高频追加有个什么竞态、怎么解?
候选人

一句话结论:zset 天然支持按 score(热度 / 时间)排序 + 游标翻页,member 存评论 id、内容单独用 string 缓存;追加时"先判断 zset 存在再 ZADD"在判断与追加之间 key 可能恰好过期——这条追加就永远丢了,解法是用 EXPIRE 的返回值当存在性判定(返回 1 = 存在且顺手续期,返回 0 = 不存在走回源重建),把判定和续期合成一个原子动作。

展开:更复杂的序列用 Lua 把「判定 + 追加」包成一个脚本;EXPIRE 返回 0 的回源分支还要套 singleflight,否则竞态会升级成击穿。写路径完整链路:DB 更新后删缓存(并发更新会互相覆盖)→ binlog → MQ,分片 key 选评论区 id——同一评论区的变更落同一分区被顺序消费(07 章 3.7 局部顺序),zadd/zrem 按序执行,列表不会乱。

面试官想听的关键词:zset 排序、EXPIRE 返回值、原子存在性判定、删缓存不更新缓存、评论区 id 分区串行。

常见失分点:判断存在用 EXISTS(有竞态窗口);答不出分片 key 为什么选评论区 id 而不是评论 id。

面试官
【进阶】 敏感词过滤为什么选 AC 自动机?它和 Trie 是什么关系?
候选人

一句话结论:AC 自动机 = Trie + fail 指针——文本只扫一遍、复杂度 O(文本长度 + 命中数) 与词库大小无关;百万级词库、模式串长短不一、加载不频繁的场景下,效率、内存、更新成本三方面都合适。纯 Trie 每次失配要回退重来,AC 用 fail 指针把"失配跳哪"预建好了。

展开:fail 指针相当于 KMP 的 next 数组推广到多模式(单模式 → 多模式的进化)。词库启动时 BFS 建一次,之后只做热更新(双 buffer 原子切换防空窗漏审);Go 生态用 importcjj/sensitive 这类库,但面试更好使的是说清原理。前置还有一步黑白名单筛流量,AI 审核兜底后置——自动机只管中间最匹配的一层。

面试官想听的关键词:Trie、fail 指针、一遍扫描、O(n)、KMP 推广、双 buffer 热更新。

常见失分点:说"逐个 strings.Contains 也能做"却算不出复杂度账;把 AC 和 KMP 的关系(推广关系)说不清。

面试官
【追问】 组合敏感词和拼音变体怎么处理?
候选人

一句话结论:组合词——单词照常进 Trie 匹配,命中后按预先配置的映射归组(澳门/博彩/网站 → 组A),文本扫完后判断"组内全部命中"才算违规;拼音——文本和词库都用 go-pinyin 转拼音再过一遍自动机,且前置做全半角、大小写、插符(赌.博)的归一化清洗。

展开:这两个变体考的是"枚举 vs 规则"的思维——变体无穷,往词库里加整词永远加不完,要用匹配结果 + 归组规则表达"组合违规"这类逻辑。归一化漏做是最常见的漏判原因(测试用例里务必放几个变体)。

面试官想听的关键词:归组、全部命中才违规、go-pinyin、归一化、规则替代枚举。

常见失分点:以为词库里加个"澳门博彩网站"整词就完事(换个分隔符就绕过了);不知道拼音要文本和词库两侧同时转。


八、本章小结

复习提示:
  • 数据模型设计是社区产品的核心竞争力:先想查询模式,再反推表结构和索引——本章两个事故的解药都在表上
  • 树形四方案:邻接表 / 路径枚举 / 嵌套集 / 闭包表;选型结论 = 邻接表 + root_id 冗余,一条 SQL 捞整棵子树
  • root_id 继承规则:一级评论的 root_id 是自己;回复沿父评论继承——「回复的回复」和直接回复同属一棵树
  • minID 游标分页:offset 在并发写入下会重复/跳过;只认「上一页最后一条的 ID」怎么刷都不串页
  • 窗口函数楼中楼:ROW_NUMBER() OVER (PARTITION BY root_id ORDER BY id DESC) + rn <= 3,两趟查询无 N+1
  • 最左前缀原则:(follower, followee) 管不了 WHERE followee = ?——联合唯一索引 + followee 单列索引两个都要
  • 关注 toggle:一个事务 = 关系表 Upsert(唯一索引幂等)+ 双方计数同增同减;Redis 侧 TxPipeline 成对更新
  • 计数会漂移:INCRBY 是增量累计,必须配定时任务用 DB 真实 COUNT 覆盖回写「对表」
  • 缓存看命中率:Feed 流读万人关注列表不值得缓存、自己翻关注列表值得;热帖三层缓存(预热 → 定时刷新 → 本地缓存),长尾不缓存
  • 接口分级:一级评论核心全力保,楼中楼/加载更多可降级关闭——分级要提前做
  • 共同关注:SINTER + EXISTS 前置判断(空结果二义)+ DB 回源兜底 + Warmup 回写;取关走 SREM 不 DEL
  • 不用外键:性能、跨库失效、锁、运维四条理由;一致性由应用层事务接管,级联软删 WHERE root_id = ?
  • 面试亮点:数据模型选型的权衡叙述、offset 翻车现场复述、缓存命中率的算账思维

下一章(第 17 章)我们进入ElasticSearch 与搜索服务——内容社区的另一个硬需求:帖子、评论越来越多,用户怎么「找到」内容?LIKE '%关键词%' 为什么注定走不通(索引失效 + 无法按相关性排序)?倒排索引、分词、相关性打分——搜索引擎的核心积木会让你看到「数据库之外的另一个查询世界」。你会发现本章的武器在那里继续服役:缓存(热词搜索结果的防线)、Kafka(数据同步到 ES 的通道)、接口分级(搜索框的联想词挂了可以关,搜索主链路不能挂)。

复习提示:伏笔:本章楼中楼的「每条一级评论带最新 3 条回复」是按时间取的。如果产品要求「按点赞数取前 3 条」呢?——多维度排序、模糊匹配、全文检索,这些关系型数据库越来越吃力的活,正是第 17 章搜索引擎的主场。带着「SQL 在这里为什么开始吃力」的问题去读,你会对 ES 的每个设计有更深的体会。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!