写在最前面:这一版和普通讲义有什么不同
这是《内容社区》课程第 16 章。从这一章起,全书进入第四阶段——高阶业务:前十五章解决的是「怎么把系统搭起来、拆开、治理好」,接下来要解决的是「内容社区最难做的几个业务」。本章啃两块硬骨头:评论和用户关系。
这两块业务的代码量其实不大,难点全在数据模型设计上:评论是一棵「回复的回复还有回复」的树,怎么塞进一张平面的数据库表?关注是「A 认识 B、B 也可能认识 A」的网状关系,一百万个用户能产生上亿条关注记录,索引建错一个方向,线上就是全表扫描。可以说:社区类产品的核心竞争力,就在数据模型设计。
上一章结尾埋的「提现」伏笔,本质是把支付链路反着再用一次(钱从平台流向创作者),积木你已经集齐了——等真实业务需要时自然能拼出来,本章先把「社区关系」这块骨架搭好。
这一版做了三件事:
- 每个概念先补课再使用。树形结构、邻接表、最左前缀、游标分页、窗口函数、Upsert……全部就地补「补课胶囊」,不让你去搜。
- 用类比先让你看见。家谱、通讯录、便利店补货、图书馆索引——数据模型的事生活里全有原型。
- 结尾有 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 的关注 / 粉丝列表缓存,「是否关注 / 共同关注」这两类判断则直接读这份缓存,不碰数据库。
二、本章知识点大纲
先把这张地图看一遍,知道这一章要往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 说出树形结构在关系型数据库里的四种设计方案(邻接表 / 路径枚举 / 闭包表 / 嵌套集),画出各自的表结构示意,并讲清选型结论「邻接表 +
root_id」的理由。 - 写出 Comment 表模型:
parent_id记父亲、root_id记根;实现创建评论时 RootID 的继承规则(回复沿父评论的 root_id 继承),并用一条WHERE root_id = ?捞出整棵子树。 - 写出
minID + limit游标分页查询,讲清它解决了offset分页在并发写入下的什么问题。 - 设计 Follow 关注关系表:
(follower, followee)唯一索引 +followee单独索引,并解释最左前缀原则为什么逼你建第二个索引。 - 实现关注 toggle 接口:一个事务里完成「关系表 Upsert + 双方计数增减」,Redis 侧用 TxPipeline 成对更新计数。
理解(能讲清原理和取舍,不要求背代码)
- 级联删除的两种做法(应用层手动级联 vs 数据库外键级联),以及大厂不用外键的四个理由。
- 评论缓存的三层设计:Kafka 通知预热(从 0 到 1)→ 秒级定时刷新(从 1 到 1 持续保鲜)→ 本地缓存(连 Redis 都不碰),以及长尾资源不缓存的判断。
- 缓存命中率分析:同一份数据在不同场景下命中率差异巨大(Feed 流读关注列表 vs 用户翻自己的关注列表),「值得缓存」的判断标准。
- 接口分级思想:核心接口(一级评论)全力保、附属接口(楼中楼、加载更多)可以降级甚至直接关闭。
- COUNT 查询慢的四种优化思路:Redis 维护结果、索引优化、EXPLAIN 估计值、异步刷新对表。
- Pipeline / Transaction / TxPipeline 三者的区别:省网络 vs 保连续执行,以及「INCRBY 会漂移、需要定时全量校准」的原因。
- 共同关注的双路径设计:Redis SINTER 求交集(快)+ DB 回源求交集(兜底),以及「空结果不可信必须先 EXISTS」的坑。
了解(知道有这回事,面试能说出名字和一句话定位)
- Tablestore(OTS):海量关系存储的可替换方案,「不到逼不得已不要提早用」,通过 Repository 接口预留切换能力。
- 平台化思维:统一树形结构方案(强制 TreeBase 组合)、「解决一类问题而不是一个问题」的晋升思维。
- 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 事务两道加餐,最后进第四节跟着写两个微服务,以第七节面试问答收尾。
三、正文
3.1 评论系统全景:打开一个 B 站视频时发生了什么
先补课:评论是一棵「树」
补课胶囊 · 树形结构(tree) 一句话:像家谱一样的数据组织方式——每个节点只有一个「父亲」,但可以有多个「孩子」。 类比:家谱。爷爷是一级评论(根节点),爸爸是回复爷爷的评论,你又是回复爸爸的评论——「回复的回复」一层层长下去,就是一棵树。 最小示例:一条视频下的评论区——「写得太好了」(一级评论)下面挂着「+1,第二节讲到我心里了」(回复),这条回复下面又挂着「同感!」(回复的回复)。 回到主线:评论天然是树形结构,难点是怎么把一棵树塞进关系型数据库那种「一行一行」的平面表里——这正是 3.2 节要回答的问题。
评论功能要满足什么需求
评论系统是内容平台的核心模块——它既能提升用户粘性,自己也能产出优质内容(神评论本身 就是被消费的内容)。以 B 站评论为例,从功能和查询两个维度拆解需求:
功能维度(用户能做什么):
- 直接针对某个资源(文章 / 视频)发表评论;
- 回复某条评论(评论的评论);
- 评论本身可被点赞。
查询维度(系统要扛住什么——这才是压力所在,因为打开任何资源都要加载评论):
- 用户打开资源时,加载该资源下的第一页一级评论;
- 同步加载每条一级评论的前几条子回复(就是 B 站的「楼中楼」摘要——每条一级评论下面默认露出两三条回复,想看全部再点开);
- 用户点击「加载更多」时,拉取某条评论下的全部回复。
flowchart TD
O[打开资源] --> L[加载第一页一级评论]
L --> T[每条一级评论加载 Top N 子回复 楼中楼]
T --> M[点击加载更多 拉取某条下全部回复]层级设计的第一道取舍
评论的嵌套有两种做法:
- 完整树形:允许任意深度嵌套,「回复的回复的回复」一直长下去;
- 两级结构:只有「资源 → 评论 → 子评论」三层,再深的回复都拍平挂在子评论层。
两级结构实现简单,但表达力有限(你没法精确表达「我回复的是楼里的哪一位」)。本课程采用完整树形结构——代价是存储和查询都要多动脑筋,而这些功夫正是本章的价值所在。
小收口:这一节明确了评论系统的三个查询场景(第一页 / 楼中楼 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 字段。
小收口:选型落定——用邻接表存父亲,用 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 = ? 一次就能拿到整棵子树,大幅减少数据库交互。
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),切换开关不改变业务语义。
小收口:写路径完成——同步写管简单场景,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"`
}
声明外键后,删除父节点时数据库自动删除所有子节点——看起来省心,但大厂基本不用。
为什么大厂不推荐使用外键?
- 性能开销:外键约束要求每次插入 / 删除都额外检查一次引用完整性;
- 分库分表后失效:级联删除无法跨库生效——评论表按 biz_id 拆到 16 个库后,父子评论可能不在同一个库,外键根本管不到;
- 死锁风险:高并发下外键检查会扩大锁的持有范围,死锁概率上升;
- 阻碍运维:数据迁移、数据修复时外键约束处处挡路。
实践中通常在应用层维护数据一致性(手动级联 + 事务包裹),把「删哪些行」的控制权握在自己手里。
小收口:删除的关键决策只有两个——是否级联(主流是删)、谁来级联(应用层,不用外键)。评论侧的增删都齐了,下一节是评论系统真正的性能主战场:查询。
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。
接口三:加载更多——拉取某条一级评论下的全部回复:
// 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 款饮料」摆在门口冰柜(缓存),顾客进门拿了就走;冷门商品还得去仓库翻(数据库)。畅销款占比越高(命中率越高),去仓库的次数越少。 回到主线:命中率取决于「同一份数据短期内是否被重复访问」——评论恰恰是两极分化最严重的场景。
评论缓存的设计比较微妙,因为数据冷热差异极大:
- 长尾资源(非热门文章 / 视频)很少被打开,缓存命中率低——为一个「一年被打开两次」的页面做缓存,写进去的第二天就过期了,纯属浪费;
- 热门资源被高频访问,值得重点保护,采用「预加载 + 定时刷新 + 本地缓存」的三层组合策略。
三层缓存链路:五个组件配合
把「预加载 + 定时刷新」这条链路拆开看,它需要多个组件配合才能跑起来:
- 热榜计算方(大数据平台或运营后台)算出「当前最热的 N 个资源」;
- 结果通过 Kafka 广播出去——热榜方不知道、也不需要知道评论服务有几个实例,两边完全解耦(第 07 章的解耦思想);
- 评论服务监听通知,把热榜资源的评论提前灌进 Redis——这一步叫预热 / 预加载,解决「从 0 到 1」的问题(等用户来访问时缓存已经就位,而不是第一个用户去撞数据库);
- 评论服务内部再跑一个秒级定时任务,异步刷新热榜资源的缓存——解决「从 1 到 1」的持续保鲜问题,避免缓存过期瞬间大量请求同时回源 DB(第 06 章讲过的缓存过期雪崩);
- 在 Redis 之前再叠一层本地缓存(进程内存,如
freecache、bigcache),热门资源的读请求连 Redis 都不用碰。
flowchart LR
H[热榜计算] -->|Kafka 通知| L[评论服务监听并预热]
T[秒级定时刷新任务] -->|持续保鲜| R[(Redis)]
L --> R
R --> C[本地缓存 freecache/bigcache]限流与降级:接口分级是前提
第 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。
| 对比项 | Pipeline | Transaction(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 章讲过的安全问题,在这里格外要命)。
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 里「三个字段的更新放进一个事务」,但一个字都不用写。
什么时候才需要多文档事务?
当一次业务操作必须同时修改多个文档或多个集合时,单文档原子性就帮不上忙了。经典例子是转账:扣 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 超时与集合须预先存在 |
$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 节点即命中
两个必考变体:
- 组合敏感词:
"澳门" + "博彩" + "网站"三个词单独出现都是正常内容,连起来才是。做法:把单个词都加入 Trie 正常匹配 → 命中后按预先配置的映射关系归组(澳门 → 组A,博彩 → 组A,网站 → 组A)→ 文本扫完后判断组A是否全部命中,全命中才算违规; - 拼音变体:
bo cai、bocai绕文字检测——用mozillazg/go-pinyin(Java 世界 pinyin4j 的对应物)把文本和词库都转拼音后再过一遍自动机。顺带把全半角、大小写、特殊字符插入(赌.博)先做归一化清洗,再进匹配。
常见坑:词库热更新时直接清空重建会有瞬时空窗(这条评论漏审),用双 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 章已练过)。
步骤总览:
- 建库建表:comment_db 的 comments 表、relation_db 的 follows / user_stats 表;
- comment-service:Comment 模型与发表评论——parent_id > 0 时继承 root_id;
- comment-service:软删除 + 树形查询(一级评论列表附前 3 条回复、回复列表);
- comment-service:HTTP 路由与启动入口(注册 ETCD,跑通「发表 → 回复 → 看树」);
- relation-service:Follow / UserStat 模型与关注 toggle——事务内改关系 + 双方计数;
- relation-service:关注 / 粉丝列表与关系状态接口;
- 共同关注:关注写入同步 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(®istry.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
}
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})
}
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,数据量真到十亿行、分库分表又撞上跨库聚合时,换个实现类就能切换,「不到逼不得已不要提早用」。
六、自测题与动手练习
自测题(先别翻正文,合上书能答出来才算懂;答案要点都藏在正文里):
- 树形结构四种存储方案各自记什么信息?两两对比优缺点,最后说清选型结论「邻接表 + root_id」的理由。
- root_id 的继承规则是什么?「回复的回复」的 root_id 是怎么确定的?为什么说这个字段把递归查询压成了一条 SQL?
- offset 分页在评论区具体怎么「翻车」?minID 游标分页为什么天然不怕并发写入?
- N+1 查询问题是什么?楼中楼「每条一级评论带最新 3 条回复」用哪条 SQL 一次搞定?窗口函数在里面干了什么?
- 最左前缀原则说的是什么?
(follower, followee)联合唯一索引为什么救不了「查我的粉丝列表」? - 关注 toggle 的一个事务里依次做了几件事?Redis 侧 TxPipeline 发了哪几个命令?为什么要成对出现?
- 共同关注为什么必须先 EXISTS 再 SINTER?回源 DB 之后为什么还要回写预热?
- MongoDB 单文档操作为什么天然原子?多文档事务要满足哪两个条件(版本 + 部署形态)?
动手练习(建议真做一遍,做到才算过关):
- 楼中楼改造:把
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(点赞数相同时取更新的)。 - 计数对表任务:实现
refreshFollowCount定时任务(每 10 秒跑一次),然后故意把 Redis 计数改坏——redis-cli HSET relation:count:1 following 99999,等任务跑完。完成标准:HGET读回的值回到真实 COUNT;再压测 toggle 500 次,观察增量期间计数短暂偏离、校准后归位。 - 索引对比实验:先删掉
followee单列索引(ALTER TABLE follows DROP INDEX idx_followee),EXPLAIN SELECT * FROM follows WHERE followee_id = 100看一眼 type 列;再加回来重跑 EXPLAIN。完成标准:拿到「全表扫描(ALL / rows=全表行数)」和「索引查找(ref)」两份对比,能指出possible_keys和key两列的含义。 - (选做)共同关注的边界:删掉一方的关注 Set(
redis-cli DEL relation:follow_set:1)后调/api/relations/common。完成标准:接口仍返回正确交集(走了 DB 回源),且 Redis 里 Set 被重新预热;用自己的话解释「为什么 SINTER 此时不能直接信」。
七、模拟面试问答
以下 15 道题覆盖本章所有高频考点,按「基础 → 进阶 → 追问」三档排列。每题给出可以直接背的一句话结论、展开思路、面试官想听的关键词、新手最容易答错的地方。
一句话结论:四种方案——邻接表(记 parent_id)、路径枚举(记完整路径)、嵌套集(记左右区间)、闭包表(把所有祖先-后代对全记下来);选邻接表 + root_id 冗余,因为它写入最简单、配合 root_id 后查整棵子树也是一条 SQL。
展开:邻接表写入简单但查子树要递归;路径枚举查子树快(LIKE 前缀匹配)但节点移动要改所有后代路径;嵌套集查询极快但插入要重排一大片区间;闭包表什么查询都快但空间放大严重(每个节点存到所有祖先的记录)。B 站式评论只需要两级展示(一级评论 + 楼中楼),加一个 root_id 冗余字段后,WHERE root_id = ? 一条 SQL 捞整棵树——用最小的复杂度覆盖了核心查询。
面试官想听的关键词:邻接表、路径枚举、嵌套集、闭包表、冗余字段、两级展示。
常见失分点:只会背四个名字,说不出各自的致命短板(被追问「为什么不选闭包表」就卡壳);不知道 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 幂等的基础。
面试官想听的关键词:最左前缀、联合索引、查询方向、唯一索引兼职去重、全表扫描事故。
常见失分点:以为联合索引双向生效;只会背「最左前缀」四个字,被要求用黄页类比或画索引排序示意就讲不出。
一句话结论:Pipeline 只省网络往返(命令打包一次发出,不保证连续执行);MULTI/EXEC 事务保证命令连续执行中间不被插队(Go 里很少直接用,出错回滚语义很弱);TxPipeline 是「打包 + 连续执行」二合一,是 Go 客户端里最常用的选择。
展开:关注成功要同时改两个计数(我的 following +1、对方的 follower +1)再改 Set——三个命令必须成对执行。用普通命令发三次是三次网络往返且可能中途被打断;Pipeline 快但不保连续;TxPipeline 一次往返 + 连续执行,本章关注/取关的缓存同步就用它。要注意 TxPipeline 不是数据库事务——命令失败不回滚,所以它只适合「都成功最好、部分失败可容忍/可修复」的场景(计数漂移靠定时对表修复)。
面试官想听的关键词:省网络往返、连续执行、打包、不等于回滚、计数漂移 + 定时校准。
常见失分点:把 TxPipeline 当成「Redis 版数据库事务」宣称能回滚;说不清三个概念各自解决什么问题。
一句话结论:评论区持续有人往顶部插新数据,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 页怎么办」就懵。
一句话结论:窗口函数一条 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。
一句话结论:四种思路按常用度排——① 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 天然支持按 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 + 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 的通道)、接口分级(搜索框的联想词挂了可以关,搜索主链路不能挂)。