学习目标
学完本章,你应该能够:
- 讲清评论系统的核心场景与架构难点,说出树形结构在数据库中的四种设计方案(邻接表 / 路径 / 闭包表 / 嵌套集)及选型理由。
- 实现评论的增删查接口,讲清级联删除、minID 游标分页、缓存与降级等工程实践。
- 设计用户关系(关注 / 粉丝 / 拉黑)的数据模型,讲清计数缓存、索引设计(最左前缀)的坑。
- 了解海量用户关系场景下 Tablestore 等可替换存储方案,理解平台化思维与"统一接入"思想。
- 把"异步写削峰、读写分组、缓存命中率分析"串成一套评论/关系高可用方案。
前置知识(如果下面任意一点生疏,先回看对应章):
- 第02章 Gin + GORM:知道一个接口怎么写、事务怎么用、索引怎么建。
- 第07章 Kafka:知道消息怎么异步写入、分区有序性。
- 第14章 服务治理:知道降级、限流、读写分组的思路。
本章你会动手做的事:
- 写一段创建评论的代码,验证"回复某条评论时 RootID 如何沿父评论继承"。
- 用
minID + limit改写一个原本用offset的分页,体会并发写入下不串数据。 - 给"关注 B"写一条事务:同时更新关系表 + A 的 FollowCount + B 的 FanCount,并用 Redis Pipeline 保证原子。
一、评论系统
1.1 评论功能需求分析
评论系统是内容平台的核心模块,能极大提升用户粘性,并自身产出优质内容。以 B 站评论为例,可以从功能与查询两个维度拆解需求:
功能维度
- 直接针对某个资源(文章 / 视频)发表评论
- 回复某条评论(评论的评论)
- 评论本身可被点赞
查询维度(典型的高并发场景,因为打开任何资源都要加载评论)
- 用户打开资源时,加载该资源下的第一页评论
- 同步加载每条一级评论的前几条子回复(“楼中楼"摘要)
- 用户点击"加载更多"时,拉取某条评论下的更多回复
flowchart TD
O[打开资源] --> L[加载第一页一级评论]
L --> T[每条一级评论加载 Top N 子回复 楼中楼]
T --> M[点击加载更多 拉取某条下全部回复]层级设计取舍:评论与回复的嵌套可以是无限层级的完整树形结构,也可以简化为"资源 - 评论 - 子评论"的两级结构。两级结构实现简单,但表达力有限;本次课程采用完整树形结构,允许任意深度嵌套。
工程提示:层级越深,查询与维护成本越高。B 站、微博等大多采用"两级显示 + 通过 root_id 关联"的折中方案,本质上是邻接表 + root_id 字段的组合。
1.2 树形结构的四种数据库设计
把一棵树存进关系型数据库,是评论系统的核心难题。下面四种方案各有利弊,理解清楚才能在不同场景下做出合理选型。
flowchart TD
Q[树存关系库] --> A[邻接表 parent_id]
Q --> B[分段 Path]
Q --> C[Nested Set]
Q --> D[Closure Table](1)邻接表(Adjacency List)
最常见的设计。表中增加一个 parent_id 列,指向父节点的主键;根节点的 parent_id 设为 NULL 或 -1。
id | content | parent_id
----|---------------|----------
1 | 一级评论A | NULL
2 | 一级评论B | NULL
3 | 回复A的评论 | 1
4 | 回复3的评论 | 3
- 优点:结构简单,插入方便,查询直接子节点用
WHERE parent_id = ?即可命中索引 - 缺点:查询整棵子树需要递归或多次查询
(2)分段式 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 高版本配合索引下推可缓解)
(3)Nested Set
维护 nsleft 和 nsright 两列,本质是深度优先遍历的顺序号。nsleft 小于所有子节点的 nsleft,nsright 大于所有子节点的 nsright。
- 优点:查询子树通过范围比较,一次查询即可
- 缺点:插入 / 删除时要更新大量节点的左右值,维护代价极高,不推荐使用
(4)Closure Table(闭包表)
额外建一张关系表,记录每个节点与其所有祖先 / 后继的两两关系。
TreePaths
ancestor | descendant
---------|-----------
1 | 1
1 | 3
1 | 4
3 | 3
3 | 4
4 | 4
- 优点:查询任意层级关系都很快
- 缺点:数据膨胀严重,N 个节点可能产生 N² 级别的记录
选型结论
评论系统的主要查询场景是"查某个节点的直接子节点”,邻接表用等值查询性能最好,因此本课程采用邻接表 + 额外的 root_id 字段。
1.3 评论表结构设计
// 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:更新时间"`
}
// 索引设计要点:
// 1. (biz, biz_id) 联合索引 —— 加载资源的评论列表
// 2. pid 单列索引 —— 加载某条评论的子评论
// 3. root_id 单列索引 —— 加载某条评论下的全部回复(楼中楼展开)
// 4. uid 单列索引(可选)—— 用户查询自己发表过的评论
// 5. ctime 索引 —— 按时间排序
为什么需要 RootID? 仅靠
pid想加载某条一级评论下的所有回复,需要递归查询。引入root_id后,WHERE root_id = ?一次就能拿到整棵子树,大幅减少数据库交互。这是典型的"用空间换时间"的冗余字段设计。
flowchart LR
subgraph 只用pid
A1[一级评论] --> B1[回复]
B1 --> C1[回复的回复]
Q1[查全部回复] -->|需递归| A1
end
subgraph pid+root_id
R[RootID 冗余] -->|一次 WHERE root_id=?| ALL[整棵子树]
end1.4 评论的增删接口
创建评论
// 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)
}
接口设计上有两种思路:
- 统一接口:一个
CreateComment同时处理一级评论和回复,靠PID区分(本课程采用) - 拆分接口:在 gRPC 层面提供
CreateComment和ReplyComment两个接口,调用方语义更清晰
异步写评论 —— 支撑高并发写流量的关键
微博 / B 站这种体量,写评论通常不直接落库,而是先写 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", string(c.BizID), 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
}
flowchart LR
U[用户发评论] --> K[Kafka comment_write]
K -->|同一 biz_id 同分区| C[消费者落库]
C --> DB[(评论表)]删除评论与级联删除
删除评论的关键决策:是否需要同时删除其所有子评论? 主流方案是会一并删除子评论。
手动级联删除(邻接表方案):
删除节点 4 的步骤:
1. DELETE FROM comments WHERE id = 4;
2. 查找 parent_id = 4 的节点(5、6),删除;
3. 查找 parent_id IN (5,6) 的节点(7),删除;
4. 递归直到没有子节点。
层级越深,查找 + 删除次数越多。
借助 GORM 外键级联删除:
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.5 查询接口设计
根据资源查询直接评论(分页)
// 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 而不是 offset?
offset分页在并发写入场景下有问题:当你查第 2 页(offset=10)时,期间又产生了新评论,原本第 11 条变成了第 12 条,导致数据"跳过"或"重复"。minID是基于 last seen ID 的游标分页,新评论不影响后续查询,适合频繁追加的列表场景。
查询一级评论的前三条子评论
B 站评论设计的核心效果:每条一级评论下默认展示最新 3 条回复。
// 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
}
容错设计:加载子评论是"可选"步骤,如果触发了降级,直接跳过这一步,只返回一级评论。这属于"核心功能保住、附属功能可丢"的典型降级思路。
加载更多评论
用户点击"加载更多"时,根据 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
}
1.6 评论系统的缓存与高可用
缓存策略
评论缓存的设计比较微妙:
- 长尾资源(非热门文章 / 视频)很少被打开,缓存命中率低,可以不缓存
- 热门资源需要重点保护,可采用"预加载 + 定时刷新 + 本地缓存"的组合策略
- 计算热榜后通过 Kafka 通知评论服务预热缓存
- 评论服务启动秒级定时任务异步刷新缓存
- 叠加本地缓存(如
freecache、bigcache)减少 Redis 访问
限流与降级
- 热门资源限流阈值可以设高(如 400 QPS),非热门设低(如 100 QPS)
- 触发降级时,只查询热门资源的评论,非热门直接返回空或错误
读写分组部署
将查询接口(读组)与增删接口(写组)分离部署:
- 读组 100 个实例,写组 20 个实例
- 客户端通过
read_weight/write_weight做流量调度 - 读组故障时,可将部分读流量导入写组,保证核心读可用
这是"分组 + 动态流量调度"思想的具体应用。
flowchart LR
C[客户端] -->|read_weight| R[读组 100实例]
C -->|write_weight| W[写组 20实例]
R -->|故障导流| W二、用户关系系统
2.1 需求分析
关注、粉丝、拉黑、屏蔽,统称为用户关系系统。从系统设计角度,这些功能没有本质区别,都是"用户 A 与用户 B 之间建立 / 解除某种关系"。
关注类型细分
部分平台区分"普通关注"和"特殊关注"(如 B 站的"特别关注"),可以抽象为 关注类型 或 关注优先级 字段,影响 Feed 流的优先级排序。
核心使用场景
- 关注 / 取消关注某个人
- 打开文章 / 视频时,判断是否已关注创作者
- 查看自己的关注列表
- 辅助功能:给被关注者打标签、分组(本质都是 CRUD)
2.2 设计难点
小型应用中,用户关系就是普通 CRUD。但用户量级上来后,会面临:
- 高并发:每次打开文章都要判定是否关注创作者;Feed 流生成也依赖用户关系
- 大数据量:用户关系数据量是用户数量的数量级放大。假设每用户平均关注 100 人,关注表行数 = 用户数 × 100
2.3 数据库表结构设计
多对多关系通常通过中间表实现:
// 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;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取消"
}索引顺序的坑:唯一索引
(follower, followee)在WHERE follower = ?时走索引;但WHERE followee = ?不走索引(最左前缀原则)。所以查询粉丝列表时,必须额外为followee创建索引。
⚠️ 新手必踩的坑:以为一个联合唯一索引能同时服务两个方向查询。很多同学建了
(follower, followee)就以为"查粉丝列表"也走索引,结果线上WHERE followee = ?全表扫描。记住最左前缀——查粉丝必须单独给followee建索引。
2.4 关注 / 取消关注接口实现
借鉴点赞接口的"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:同步更新计数表(详见 2.5),保证关系表与计数表一致
return nil
})
}
关注 / 取消关注都是高并发场景,可引入 Kafka 异步处理,与评论写入同理。
2.5 关注 / 粉丝数量查询
方案一:维护计数表 + 缓存(类似点赞计数)
type FollowStatics struct {
UID int64 `gorm:"primaryKey"`
FollowCount int64 `gorm:"comment:关注了多少人"`
FanCount int64 `gorm:"comment:有多少粉丝"`
}
关键点:A 关注 B 时,事务内同时增加 A 的 FollowCount 和 B 的 FanCount。
缓存更新必须借助 Lua 脚本,确保"判定缓存是否存在 + 更新"的原子性,否则会出现缓存未命中却更新了缓存的问题(详见点赞章节)。
方案二: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,因为唯一索引已去重WHERE follower = ?命中索引;WHERE followee = ?需额外建索引- 大公司较少用此方案,因为 Count 不命中缓存时性能差,可作为降级 / 限流时跳过的查询路径
2.6 缓存命中率分析
什么数据值得缓存?
| 场景 | 命中率 | 是否缓存 |
|---|---|---|
| 用户查看自己的关注列表 | 低(少有人频繁查看) | 视情况而定 |
| Feed 流生成时读取关注列表 | 高 | 强烈推荐 |
| 判断"A 是否关注 B"(A、B 都是小透明) | 低 | 不缓存 |
| 判断"用户是否关注大 V" | 高 | 推荐缓存 |
核心原则:同一个资源短期内会被重复访问才值得缓存;缓存过期时间应该根据用户使用习惯确定。
2.7 是否用一张表统一记录关注 / 拉黑 / 屏蔽?
答案是 不要。
反例(不推荐):
| follower | followee | is_follow | is_block | is_mute |
原因:
1. 关注的量级远大于拉黑、屏蔽(关注:100/人,拉黑:可能 0-5/人)
2. 拆开后单表数据量小,扫描快,索引维护成本低
3. 不同业务的查询模式不同,拆表后可以独立优化
2.8 海量用户关系存储:Tablestore
分库分表方案存在的问题:以 follower 作为分片键,查询某人关注列表没问题;但查询某人粉丝数量时,会触发跨库聚合。
阿里推出的 Tablestore(OTS)是一种海量结构化数据存储,适合用户关系这类大数据量场景:
- 插入数据:使用
Update而非Insert,达成 Upsert 语义 - 更新数据:使用结构化更新语句(非 SQL UPDATE)
- 查询数据:SQL 形态最简单,但 Tablestore 不支持参数化查询,只能拼接 SQL,需小心 SQL 注入
实践建议(来自讲义原话):“不到逼不得已,不要提早用 Tablestore,付钱 + API 贼难用”。可以通过定义接口(如
FollowRepository),在 MySQL 与 Tablestore 之间自由切换,与 Article 章节切换 MongoDB / MySQL 的思路一致。
2.9 平台化思维:统一树形结构方案
公司内部可推广一套规范:所有树形结构表必须包含 PID(或 Path)字段,强制业务方组合 TreeBase 结构体;同时提供树形结构通用 CRUD 与"内存中还原树形"的工具方法。
这样后续业务方无需重复造轮子,且不易出错。“解决一类问题,而不是解决一个问题” —— 这是晋升的关键思维。
三、自测题与动手练习
自测题(合上书能答出来,才算懂):
- 树形评论的四种存储方案各有什么优缺点?为什么本课程选"邻接表 + root_id"?
minID + limit分页相比offset分页解决了什么问题?什么场景下必须用游标分页?- 大厂为什么不推荐用外键做级联删除?应用层如何维护一致性?
- 用户关系表
(follower, followee)唯一索引,为什么查"某人的粉丝列表"还要单独建索引? - 关注/粉丝计数为什么要用 Redis Pipeline(事务)更新?缓存未命中时怎么兜底?
动手练习(建议真做一遍):
- RootID 继承:写一段创建评论代码,构造"一级评论 A → 回复 B → 回复 C"三层,验证 C 的 RootID 等于 A 的 ID,且
WHERE root_id = A一次捞出 B、C。 - 改分页:把一个用
offset的评论分页接口改成minID游标分页,模拟"翻页期间有新评论写入",确认不会重复/跳过。 - 计数原子更新:实现
IncrFollowAndFan,用 RedisTxPipeline保证 A 关注 B 时 A 关注数、B 粉丝数同时 +1;并写缓存未命中时回源COUNT的逻辑。
四、本章小结
评论系统核心要点
- 树形存储选型:邻接表 +
root_id冗余字段,兼顾查询性能与维护成本 - 分页设计:使用
minID + limit游标分页,规避并发写入导致的 offset 漂移 - 级联删除:手动级联或外键级联均可,大厂倾向应用层维护一致性,不依赖外键
- 高可用方案:读写分组部署、热门资源预加载缓存、降级时跳过子评论加载
- 高并发写:异步写 Kafka 削峰,正常直写、瓶颈时切换异步
用户关系系统核心要点
- 表结构:中间表表达多对多关系,唯一索引
(follower, followee)+ 单独为followee建索引 - 计数方案:计数表 + 缓存(事务内更新) 或 Redis + Count 兜底,缓存更新必须用 Lua 脚本保证原子性
- 缓存命中:只缓存高频访问数据(大 V 关系、Feed 流关注列表),小透明数据不缓存
- 拆表原则:关注、拉黑、屏蔽数据量级差异巨大,必须拆表存储
- 海量存储:MySQL 分库分表难以同时优化两类查询,可考虑 Tablestore 等专用存储
面试要点速记
- 树形表四种设计方法及优缺点(邻接表 / Path / Nested Set / Closure Table)
- 外键的概念,大厂为何不推荐外键
- 评论系统性能优化与可用性提升方案(缓存、降级、分组、异步)
- COUNT 查询优化方案(Redis 维护结果、索引优化、Explain 估计、异步刷新)
- Redis Pipeline 与 Transaction 的区别和适用场景
下一章(第17章起)我们会在前面的基础上继续扩展内容社区的其他核心服务,把"评论 / 关系 / Feed / 支付 / 治理"串成一套完整的后端体系。