评论与用户关系服务

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

@

学习目标

学完本章,你应该能够:

  1. 讲清评论系统的核心场景与架构难点,说出树形结构在数据库中的四种设计方案(邻接表 / 路径 / 闭包表 / 嵌套集)及选型理由。
  2. 实现评论的增删查接口,讲清级联删除、minID 游标分页、缓存与降级等工程实践。
  3. 设计用户关系(关注 / 粉丝 / 拉黑)的数据模型,讲清计数缓存、索引设计(最左前缀)的坑。
  4. 了解海量用户关系场景下 Tablestore 等可替换存储方案,理解平台化思维与"统一接入"思想。
  5. 把"异步写削峰、读写分组、缓存命中率分析"串成一套评论/关系高可用方案。

前置知识(如果下面任意一点生疏,先回看对应章):

  • 第02章 Gin + GORM:知道一个接口怎么写、事务怎么用、索引怎么建。
  • 第07章 Kafka:知道消息怎么异步写入、分区有序性。
  • 第14章 服务治理:知道降级、限流、读写分组的思路。

本章你会动手做的事

  • 写一段创建评论的代码,验证"回复某条评论时 RootID 如何沿父评论继承"。
  • minID + limit 改写一个原本用 offset 的分页,体会并发写入下不串数据。
  • 给"关注 B"写一条事务:同时更新关系表 + A 的 FollowCount + B 的 FanCount,并用 Redis Pipeline 保证原子。

一、评论系统

1.1 评论功能需求分析

评论系统是内容平台的核心模块,能极大提升用户粘性,并自身产出优质内容。以 B 站评论为例,可以从功能与查询两个维度拆解需求:

功能维度

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

查询维度(典型的高并发场景,因为打开任何资源都要加载评论)

  1. 用户打开资源时,加载该资源下的第一页评论
  2. 同步加载每条一级评论的前几条子回复(“楼中楼"摘要)
  3. 用户点击"加载更多"时,拉取某条评论下的更多回复
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

维护 nsleftnsright 两列,本质是深度优先遍历的顺序号。nsleft 小于所有子节点的 nsleftnsright 大于所有子节点的 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[整棵子树]
    end

1.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 层面提供 CreateCommentReplyComment 两个接口,调用方语义更清晰

异步写评论 —— 支撑高并发写流量的关键

微博 / 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. 外键约束会带来额外的性能开销(每次插入 / 删除都要检查)
  2. 级联删除在分库分表后无法跨库生效
  3. 高并发下死锁风险更大
  4. 数据迁移、数据修复时外键约束会成为阻碍 实践中通常在应用层维护数据一致性。

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 通知评论服务预热缓存
    • 评论服务启动秒级定时任务异步刷新缓存
    • 叠加本地缓存(如 freecachebigcache)减少 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 与"内存中还原树形"的工具方法。

这样后续业务方无需重复造轮子,且不易出错。“解决一类问题,而不是解决一个问题” —— 这是晋升的关键思维。


三、自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 树形评论的四种存储方案各有什么优缺点?为什么本课程选"邻接表 + root_id"?
  2. minID + limit 分页相比 offset 分页解决了什么问题?什么场景下必须用游标分页?
  3. 大厂为什么不推荐用外键做级联删除?应用层如何维护一致性?
  4. 用户关系表 (follower, followee) 唯一索引,为什么查"某人的粉丝列表"还要单独建索引?
  5. 关注/粉丝计数为什么要用 Redis Pipeline(事务)更新?缓存未命中时怎么兜底?

动手练习(建议真做一遍)

  1. RootID 继承:写一段创建评论代码,构造"一级评论 A → 回复 B → 回复 C"三层,验证 C 的 RootID 等于 A 的 ID,且 WHERE root_id = A 一次捞出 B、C。
  2. 改分页:把一个用 offset 的评论分页接口改成 minID 游标分页,模拟"翻页期间有新评论写入",确认不会重复/跳过。
  3. 计数原子更新:实现 IncrFollowAndFan,用 Redis TxPipeline 保证 A 关注 B 时 A 关注数、B 粉丝数同时 +1;并写缓存未命中时回源 COUNT 的逻辑。

四、本章小结

评论系统核心要点

  1. 树形存储选型:邻接表 + root_id 冗余字段,兼顾查询性能与维护成本
  2. 分页设计:使用 minID + limit 游标分页,规避并发写入导致的 offset 漂移
  3. 级联删除:手动级联或外键级联均可,大厂倾向应用层维护一致性,不依赖外键
  4. 高可用方案:读写分组部署、热门资源预加载缓存、降级时跳过子评论加载
  5. 高并发写:异步写 Kafka 削峰,正常直写、瓶颈时切换异步

用户关系系统核心要点

  1. 表结构:中间表表达多对多关系,唯一索引 (follower, followee) + 单独为 followee 建索引
  2. 计数方案:计数表 + 缓存(事务内更新) 或 Redis + Count 兜底,缓存更新必须用 Lua 脚本保证原子性
  3. 缓存命中:只缓存高频访问数据(大 V 关系、Feed 流关注列表),小透明数据不缓存
  4. 拆表原则:关注、拉黑、屏蔽数据量级差异巨大,必须拆表存储
  5. 海量存储:MySQL 分库分表难以同时优化两类查询,可考虑 Tablestore 等专用存储

面试要点速记

  • 树形表四种设计方法及优缺点(邻接表 / Path / Nested Set / Closure Table)
  • 外键的概念,大厂为何不推荐外键
  • 评论系统性能优化与可用性提升方案(缓存、降级、分组、异步)
  • COUNT 查询优化方案(Redis 维护结果、索引优化、Explain 估计、异步刷新)
  • Redis Pipeline 与 Transaction 的区别和适用场景

下一章(第17章起)我们会在前面的基础上继续扩展内容社区的其他核心服务,把"评论 / 关系 / Feed / 支付 / 治理"串成一套完整的后端体系。

About Me

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

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

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

目标

学AI,加油!加油!