Skip to content

如何设计评论系统

一、问题描述

1.1 业务背景

评论系统是内容平台最重要的互动功能之一,广泛应用于:

  • 社交平台:微博、抖音、小红书、朋友圈
  • 内容平台:知乎、掘金、CSDN、B站
  • 电商平台:淘宝、京东、拼多多的商品评价
  • 新闻资讯:今日头条、网易新闻的评论区

1.2 核心功能

基础功能

  1. 发表评论:用户可以对内容发表评论
  2. 回复评论:支持楼中楼(二级评论)
  3. 评论点赞:可以点赞评论
  4. 评论展示:按时间/热度排序
  5. 删除评论:用户可删除自己的评论

进阶功能

  1. @提醒:评论中@某人
  2. 表情包:支持图片、表情
  3. 折叠评论:折叠长评论或低质评论
  4. 举报审核:举报不良评论
  5. 敏感词过滤:自动过滤敏感内容

1.3 技术挑战

树形结构

  • 一级评论、二级评论(楼中楼)
  • 如何高效存储和查询树形数据
  • 如何支持无限层级(知乎)

高并发读写

  • 热门内容可能有百万条评论
  • 读QPS >> 写QPS(100:1)
  • 需要分页加载和懒加载

排序策略

  • 按时间排序(最新)
  • 按热度排序(点赞数、回复数)
  • 如何平衡时间和热度

性能优化

  • 评论数统计如何优化
  • 如何避免深度查询
  • 如何缓存评论数据

1.4 面试考察点

  • 数据库设计:如何设计表结构支持树形数据
  • 查询优化:如何高效查询评论树
  • 排序算法:如何设计合理的排序策略
  • 性能优化:如何优化高并发场景
  • 扩展性:如何支持更复杂的评论功能

二、需求分析

2.1 功能性需求

需求描述优先级
FR1发表一级评论P0
FR2回复评论(二级评论)P0
FR3查看评论列表(分页)P0
FR4评论点赞P0
FR5删除评论P0
FR6按时间/热度排序P1
FR7@提醒功能P1
FR8敏感词过滤P1
FR9表情包支持P2
FR10评论举报P2

2.2 非功能性需求

性能需求

  • 发表评论响应时间:<100ms
  • 查询评论列表:<200ms(含100条评论)
  • 支持QPS:读10万+,写1万+
  • 评论数统计:<50ms

一致性需求

  • 评论发表后立即可见
  • 评论数统计最终一致(允许延迟)
  • 删除评论不影响子评论(软删除)

可用性需求

  • 系统可用性:99.9%
  • 支持降级(禁用评论功能)

扩展性需求

  • 支持多种内容类型(文章、视频、商品)
  • 支持无限层级评论(可配置)
  • 支持多种排序方式

2.3 数据规模

假设

  • 日活用户:1000万
  • 每用户每天评论:2次
  • 日评论量:2000万
  • 平均回复率:30%(二级评论)

计算

写QPS = 2000万 / 86400 ≈ 231 QPS(峰值700+)
读QPS = 写QPS × 100 ≈ 23,100 QPS
存储 = 2000万条/天 × 365天 × 500字节 ≈ 3.65TB/年

三、技术选型

3.1 存储方案对比

方案优点缺点适用场景
邻接表简单、易理解查询性能差小规模评论
路径枚举查询快插入慢、维护难读多写少
闭包表查询灵活存储大、维护复杂复杂查询
嵌套集查询快插入更新慢静态树
两表分离平衡性能需要两次查询推荐

推荐方案:两表分离

  • 一级评论和二级评论分表存储
  • 一级评论支持排序、分页
  • 二级评论按评论ID聚合

3.2 缓存策略

数据类型缓存方式TTL说明
评论列表Redis List5分钟热门内容
评论数Redis String永久实时更新
热门评论Redis ZSet10分钟按点赞排序
用户评论Redis ZSet1小时个人中心

3.3 技术栈

组件技术选型作用
数据库MySQL持久化存储
缓存Redis热数据缓存
搜索Elasticsearch评论搜索
消息队列Kafka异步处理
审核敏感词库 + AI内容过滤

四、架构设计

4.1 系统架构图

mermaid
graph TB
    subgraph 客户端
        A[移动端/Web]
    end
    
    subgraph 接入层
        B[评论API]
        B --> C[评论服务]
        B --> D[统计服务]
        B --> E[审核服务]
    end
    
    subgraph 缓存层
        F[Redis集群]
        F --> F1[评论列表]
        F --> F2[评论数]
        F --> F3[热门评论]
    end
    
    subgraph 消息队列
        G[Kafka]
        C --> G
        G --> H1[统计消费者]
        G --> H2[通知消费者]
        G --> H3[审核消费者]
    end
    
    subgraph 存储层
        I[MySQL主库]
        J[MySQL从库]
        H1 --> I
        I --> J
        D --> J
    end
    
    subgraph 搜索层
        K[Elasticsearch]
        H3 --> K
    end
    
    A --> B
    C --> F
    D --> F

4.2 数据库设计

一级评论表

sql
CREATE TABLE comment (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    obj_type TINYINT NOT NULL COMMENT '对象类型 1文章 2视频 3商品',
    obj_id BIGINT NOT NULL COMMENT '对象ID',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    content TEXT NOT NULL COMMENT '评论内容',
    like_count INT DEFAULT 0 COMMENT '点赞数',
    reply_count INT DEFAULT 0 COMMENT '回复数',
    status TINYINT DEFAULT 1 COMMENT '状态 1正常 2删除 3审核中',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    KEY idx_obj (obj_type, obj_id, create_time),
    KEY idx_user (user_id, create_time),
    KEY idx_hot (obj_type, obj_id, like_count, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 
COMMENT='一级评论表';

-- 分表:按obj_id取模分16张表

二级评论表(回复)

sql
CREATE TABLE comment_reply (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    comment_id BIGINT NOT NULL COMMENT '一级评论ID',
    user_id BIGINT NOT NULL COMMENT '评论人ID',
    reply_user_id BIGINT COMMENT '回复对象ID',
    content TEXT NOT NULL COMMENT '回复内容',
    like_count INT DEFAULT 0 COMMENT '点赞数',
    status TINYINT DEFAULT 1 COMMENT '状态',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    KEY idx_comment (comment_id, create_time),
    KEY idx_user (user_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='二级评论表';

-- 分表:按comment_id取模分16张表

评论统计表

sql
CREATE TABLE comment_count (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    obj_type TINYINT NOT NULL,
    obj_id BIGINT NOT NULL,
    comment_count INT DEFAULT 0 COMMENT '评论总数',
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    UNIQUE KEY uk_obj (obj_type, obj_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='评论统计表';

4.3 核心流程

发表评论流程

mermaid
sequenceDiagram
    participant U as 用户
    participant API as 评论API
    participant Audit as 审核服务
    participant DB as MySQL
    participant Redis as Redis
    participant MQ as Kafka
    
    U->>API: 发表评论
    API->>Audit: 敏感词过滤
    
    alt 包含敏感词
        Audit-->>API: 过滤失败
        API-->>U: 提示包含敏感词
    else 通过审核
        Audit-->>API: 审核通过
        API->>DB: 插入评论
        DB-->>API: 返回评论ID
        
        par 并行处理
            API->>Redis: 删除评论列表缓存
            API->>Redis: 评论数+1
        and
            API->>MQ: 发送评论消息
        end
        
        API-->>U: 评论成功
        
        MQ->>统计服务: 更新统计
        MQ->>通知服务: 发送通知
    end

五、核心实现

5.1 评论服务(Go实现)

点击查看完整实现
go
package comment

import (
    "context"
    "fmt"
    "time"
    
    "github.com/go-redis/redis/v8"
    "gorm.io/gorm"
)

// CommentService 评论服务
type CommentService struct {
    db    *gorm.DB
    redis *redis.Client
    mq    MessageQueue
}

// Comment 一级评论
type Comment struct {
    ID         int64     `gorm:"primarykey"`
    ObjType    int8      `gorm:"index:idx_obj,priority:1"`
    ObjID      int64     `gorm:"index:idx_obj,priority:2"`
    UserID     int64     `gorm:"index:idx_user"`
    Content    string    `gorm:"type:text"`
    LikeCount  int       `gorm:"default:0"`
    ReplyCount int       `gorm:"default:0"`
    Status     int8      `gorm:"default:1"`
    CreateTime time.Time `gorm:"autoCreateTime;index:idx_obj,priority:3"`
    UpdateTime time.Time `gorm:"autoUpdateTime"`
}

// CommentReply 二级评论
type CommentReply struct {
    ID          int64     `gorm:"primarykey"`
    CommentID   int64     `gorm:"index:idx_comment"`
    UserID      int64     `gorm:"index:idx_user"`
    ReplyUserID int64     // 回复对象
    Content     string    `gorm:"type:text"`
    LikeCount   int       `gorm:"default:0"`
    Status      int8      `gorm:"default:1"`
    CreateTime  time.Time `gorm:"autoCreateTime;index:idx_comment,priority:2"`
    UpdateTime  time.Time `gorm:"autoUpdateTime"`
}

// CreateComment 发表一级评论
func (s *CommentService) CreateComment(ctx context.Context, req *CreateCommentRequest) (*Comment, error) {
    // 1. 敏感词过滤
    if containsSensitiveWords(req.Content) {
        return nil, fmt.Errorf("content contains sensitive words")
    }
    
    // 2. 创建评论
    comment := &Comment{
        ObjType:    req.ObjType,
        ObjID:      req.ObjID,
        UserID:     req.UserID,
        Content:    req.Content,
        Status:     1,
        CreateTime: time.Now(),
    }
    
    err := s.db.Create(comment).Error
    if err != nil {
        return nil, err
    }
    
    // 3. 异步处理
    go func() {
        // 删除缓存
        s.deleteCommentCache(req.ObjType, req.ObjID)
        
        // 评论数+1
        s.incrCommentCount(req.ObjType, req.ObjID)
        
        // 发送消息
        s.mq.Send("comment_topic", CommentMessage{
            Action:    "create",
            CommentID: comment.ID,
            ObjType:   req.ObjType,
            ObjID:     req.ObjID,
            UserID:    req.UserID,
        })
    }()
    
    return comment, nil
}

// CreateReply 回复评论
func (s *CommentService) CreateReply(ctx context.Context, req *CreateReplyRequest) (*CommentReply, error) {
    // 1. 检查一级评论是否存在
    var comment Comment
    err := s.db.Where("id = ? AND status = 1", req.CommentID).First(&comment).Error
    if err != nil {
        return nil, fmt.Errorf("comment not found")
    }
    
    // 2. 敏感词过滤
    if containsSensitiveWords(req.Content) {
        return nil, fmt.Errorf("content contains sensitive words")
    }
    
    // 3. 创建回复
    reply := &CommentReply{
        CommentID:   req.CommentID,
        UserID:      req.UserID,
        ReplyUserID: req.ReplyUserID,
        Content:     req.Content,
        Status:      1,
        CreateTime:  time.Now(),
    }
    
    err = s.db.Create(reply).Error
    if err != nil {
        return nil, err
    }
    
    // 4. 更新一级评论的回复数
    s.db.Model(&Comment{}).
        Where("id = ?", req.CommentID).
        UpdateColumn("reply_count", gorm.Expr("reply_count + 1"))
    
    // 5. 异步处理
    go func() {
        // 删除回复缓存
        s.deleteReplyCache(req.CommentID)
        
        // 发送通知
        s.mq.Send("comment_topic", ReplyMessage{
            Action:      "reply",
            ReplyID:     reply.ID,
            CommentID:   req.CommentID,
            UserID:      req.UserID,
            ReplyUserID: req.ReplyUserID,
        })
    }()
    
    return reply, nil
}

// GetCommentList 获取评论列表
func (s *CommentService) GetCommentList(ctx context.Context, objType int8, objID int64, 
    orderBy string, page, pageSize int) ([]*CommentWithReplies, error) {
    
    // 1. 尝试从缓存获取
    cacheKey := s.getCommentListKey(objType, objID, orderBy, page)
    cached, err := s.redis.Get(ctx, cacheKey).Result()
    if err == nil {
        // 缓存命中
        var comments []*CommentWithReplies
        json.Unmarshal([]byte(cached), &comments)
        return comments, nil
    }
    
    // 2. 从数据库查询一级评论
    var comments []Comment
    query := s.db.Where("obj_type = ? AND obj_id = ? AND status = 1", objType, objID)
    
    // 排序
    switch orderBy {
    case "hot":
        query = query.Order("like_count DESC, create_time DESC")
    default: // time
        query = query.Order("create_time DESC")
    }
    
    offset := (page - 1) * pageSize
    err = query.Offset(offset).Limit(pageSize).Find(&comments).Error
    if err != nil {
        return nil, err
    }
    
    // 3. 批量查询二级评论(每个一级评论查最新3条)
    commentIDs := make([]int64, len(comments))
    for i, comment := range comments {
        commentIDs[i] = comment.ID
    }
    
    var replies []CommentReply
    err = s.db.Where("comment_id IN ? AND status = 1", commentIDs).
        Order("create_time DESC").
        Find(&replies).Error
    
    // 4. 组装数据
    result := make([]*CommentWithReplies, len(comments))
    replyMap := make(map[int64][]CommentReply)
    
    for _, reply := range replies {
        replyMap[reply.CommentID] = append(replyMap[reply.CommentID], reply)
    }
    
    for i, comment := range comments {
        commentReplies := replyMap[comment.ID]
        // 只取最新3条
        if len(commentReplies) > 3 {
            commentReplies = commentReplies[:3]
        }
        
        result[i] = &CommentWithReplies{
            Comment: comment,
            Replies: commentReplies,
        }
    }
    
    // 5. 写入缓存
    data, _ := json.Marshal(result)
    s.redis.Set(ctx, cacheKey, data, 5*time.Minute)
    
    return result, nil
}

// GetReplyList 获取某条评论的所有回复
func (s *CommentService) GetReplyList(ctx context.Context, commentID int64, page, pageSize int) ([]CommentReply, error) {
    // 1. 尝试从缓存获取
    cacheKey := s.getReplyListKey(commentID, page)
    cached, err := s.redis.Get(ctx, cacheKey).Result()
    if err == nil {
        var replies []CommentReply
        json.Unmarshal([]byte(cached), &replies)
        return replies, nil
    }
    
    // 2. 从数据库查询
    var replies []CommentReply
    offset := (page - 1) * pageSize
    err = s.db.Where("comment_id = ? AND status = 1", commentID).
        Order("create_time ASC"). // 回复按时间正序
        Offset(offset).
        Limit(pageSize).
        Find(&replies).Error
    
    if err != nil {
        return nil, err
    }
    
    // 3. 写入缓存
    data, _ := json.Marshal(replies)
    s.redis.Set(ctx, cacheKey, data, 5*time.Minute)
    
    return replies, nil
}

// DeleteComment 删除评论(软删除)
func (s *CommentService) DeleteComment(ctx context.Context, commentID, userID int64) error {
    // 1. 检查权限
    var comment Comment
    err := s.db.Where("id = ? AND user_id = ?", commentID, userID).First(&comment).Error
    if err != nil {
        return fmt.Errorf("comment not found or no permission")
    }
    
    // 2. 软删除
    err = s.db.Model(&Comment{}).
        Where("id = ?", commentID).
        Update("status", 2).Error
    
    if err != nil {
        return err
    }
    
    // 3. 异步处理
    go func() {
        // 删除缓存
        s.deleteCommentCache(comment.ObjType, comment.ObjID)
        
        // 评论数-1
        s.decrCommentCount(comment.ObjType, comment.ObjID)
    }()
    
    return nil
}

// LikeComment 点赞评论
func (s *CommentService) LikeComment(ctx context.Context, commentID, userID int64) error {
    // 1. 检查是否已点赞(复用点赞系统)
    liked, err := s.isCommentLiked(commentID, userID)
    if err != nil {
        return err
    }
    if liked {
        return fmt.Errorf("already liked")
    }
    
    // 2. 点赞
    err = s.db.Model(&Comment{}).
        Where("id = ?", commentID).
        UpdateColumn("like_count", gorm.Expr("like_count + 1")).Error
    
    if err != nil {
        return err
    }
    
    // 3. 记录点赞状态
    s.setCommentLiked(commentID, userID)
    
    return nil
}

// GetCommentCount 获取评论数
func (s *CommentService) GetCommentCount(ctx context.Context, objType int8, objID int64) (int, error) {
    countKey := s.getCommentCountKey(objType, objID)
    
    // 从Redis获取
    count, err := s.redis.Get(ctx, countKey).Int()
    if err == nil {
        return count, nil
    }
    
    // Redis miss,从数据库统计
    var total int64
    err = s.db.Model(&Comment{}).
        Where("obj_type = ? AND obj_id = ? AND status = 1", objType, objID).
        Count(&total).Error
    
    if err != nil {
        return 0, err
    }
    
    // 写回Redis
    s.redis.Set(ctx, countKey, total, 0)
    
    return int(total), nil
}

// 辅助方法
func (s *CommentService) getCommentListKey(objType int8, objID int64, orderBy string, page int) string {
    return fmt.Sprintf("comment:list:%d:%d:%s:%d", objType, objID, orderBy, page)
}

func (s *CommentService) getReplyListKey(commentID int64, page int) string {
    return fmt.Sprintf("comment:reply:%d:%d", commentID, page)
}

func (s *CommentService) getCommentCountKey(objType int8, objID int64) string {
    return fmt.Sprintf("comment:count:%d:%d", objType, objID)
}

func (s *CommentService) deleteCommentCache(objType int8, objID int64) {
    // 删除所有页的缓存
    pattern := fmt.Sprintf("comment:list:%d:%d:*", objType, objID)
    keys, _ := s.redis.Keys(context.Background(), pattern).Result()
    if len(keys) > 0 {
        s.redis.Del(context.Background(), keys...)
    }
}

func (s *CommentService) deleteReplyCache(commentID int64) {
    pattern := fmt.Sprintf("comment:reply:%d:*", commentID)
    keys, _ := s.redis.Keys(context.Background(), pattern).Result()
    if len(keys) > 0 {
        s.redis.Del(context.Background(), keys...)
    }
}

func (s *CommentService) incrCommentCount(objType int8, objID int64) {
    key := s.getCommentCountKey(objType, objID)
    s.redis.Incr(context.Background(), key)
}

func (s *CommentService) decrCommentCount(objType int8, objID int64) {
    key := s.getCommentCountKey(objType, objID)
    s.redis.Decr(context.Background(), key)
}

// CommentWithReplies 带回复的评论
type CommentWithReplies struct {
    Comment Comment
    Replies []CommentReply
}

// Request结构
type CreateCommentRequest struct {
    ObjType int8
    ObjID   int64
    UserID  int64
    Content string
}

type CreateReplyRequest struct {
    CommentID   int64
    UserID      int64
    ReplyUserID int64
    Content     string
}

5.2 热门评论算法

go
// 热度计算:威尔逊得分
func calculateWilsonScore(likes, total int) float64 {
    if total == 0 {
        return 0
    }
    
    p := float64(likes) / float64(total)
    z := 1.96 // 95%置信度
    n := float64(total)
    
    score := (p + z*z/(2*n) - z*math.Sqrt((p*(1-p)+z*z/(4*n))/n)) / (1 + z*z/n)
    return score
}

// 时间衰减
func calculateTimeDecay(createTime time.Time) float64 {
    hours := time.Since(createTime).Hours()
    return math.Pow(hours+2, -1.5)
}

// 最终得分 = 威尔逊得分 × 时间衰减
func calculateHotScore(comment *Comment) float64 {
    wilson := calculateWilsonScore(comment.LikeCount, comment.LikeCount+comment.ReplyCount)
    decay := calculateTimeDecay(comment.CreateTime)
    return wilson * decay * 1000
}

六、性能优化

6.1 查询优化

1. 避免N+1查询

go
// 批量查询回复
func (s *CommentService) batchGetReplies(commentIDs []int64) map[int64][]CommentReply {
    var replies []CommentReply
    s.db.Where("comment_id IN ?", commentIDs).Find(&replies)
    
    result := make(map[int64][]CommentReply)
    for _, reply := range replies {
        result[reply.CommentID] = append(result[reply.CommentID], reply)
    }
    return result
}

2. 分页优化(延迟关联)

sql
-- 传统分页(性能差)
SELECT * FROM comment 
WHERE obj_id = 123 
ORDER BY create_time DESC 
LIMIT 1000, 20;

-- 延迟关联(性能好)
SELECT c.* FROM comment c
INNER JOIN (
    SELECT id FROM comment
    WHERE obj_id = 123
    ORDER BY create_time DESC
    LIMIT 1000, 20
) tmp ON c.id = tmp.id;

6.2 缓存优化

缓存预热

go
// 预加载热门内容的评论
func (s *CommentService) preloadHotComments(objIDs []int64) {
    for _, objID := range objIDs {
        comments, _ := s.GetCommentList(context.Background(), 1, objID, "time", 1, 20)
        // 写入缓存
        // ...
    }
}

6.3 性能数据

指标优化前优化后提升
评论列表查询500ms50ms10x
回复列表查询300ms30ms10x
发表评论200ms80ms2.5x
缓存命中率60%95%-

七、面试要点

7.1 常见追问

Q1: 如何设计评论的树形结构?

A: 推荐两表分离方案

  • 一级评论表:存储顶级评论,支持排序、分页
  • 二级评论表:存储回复,按comment_id聚合
  • 优点:查询性能好,易于扩展
  • 缺点:不支持无限层级(可通过path字段扩展)

Q2: 评论的排序策略如何设计?

A:

  1. 按时间:最新评论优先,适合动态场景
  2. 按热度:点赞数 + 回复数,适合内容沉淀
  3. 混合排序:威尔逊得分 × 时间衰减,平衡新旧
  4. 个性化:结合用户兴趣,推荐相关评论

Q3: 如何处理热门评论的性能问题?

A:

  1. 缓存:Redis缓存评论列表
  2. 异步:统计数据异步更新
  3. 降级:限制显示数量,超过折叠
  4. CDN:静态化评论数据

Q4: 如何防止恶意评论?

A:

  1. 敏感词过滤:DFA算法实时过滤
  2. 限流:用户维度、IP维度限流
  3. 风控:异常行为检测
  4. 人工审核:AI初审 + 人工复审

7.2 扩展知识点

八、总结

评论系统设计要点:

  1. 数据模型:两表分离,支持一二级评论
  2. 排序策略:时间、热度、混合排序
  3. 性能优化:Redis缓存 + 批量查询
  4. 内容审核:敏感词过滤 + 人工审核

面试中要能说清楚树形结构设计、排序算法、缓存策略、性能优化等关键点。


相关场景题

正在精进