💬 设计一个评论系统:盖楼的艺术!

40 阅读9分钟

📖 开场:论坛盖楼

想象你在论坛发帖 📝:

1楼(楼主)

今天天气真好!☀️

2楼(回复楼主)

是啊,适合出去玩

3楼(回复2楼)

└─ 回复@2楼:去哪里玩?
      └─ 回复@3楼:去爬山
            └─ 回复@2楼:带我一个!

这就是评论系统:社交的基石!


🤔 核心挑战

挑战1:多级评论(盖楼)🏢

评论层级:
1楼 → 楼主评论
  └─ 2楼 → 回复1楼
       └─ 3楼 → 回复2楼
            └─ 4楼 → 回复3楼
                 └─ 5楼 → 回复4楼

问题:
- 如何存储树形结构?
- 如何展示?
- 如何分页?

挑战2:高性能查询 🚀

热门视频:
- 评论数:10万条
- 用户查看:需要秒级响应

问题:
- 如何快速查询?
- 如何排序(按时间/热度)?
- 如何分页加载?

挑战3:敏感词审核 🔍

用户评论:
"这个垃圾产品,fuck!"
    ↓
敏感词检测
    ↓
"这个**产品,***!"
    ↓
或直接拦截 ❌

必须:
- 实时过滤 ✅
- 毫秒级响应 ✅

🎯 核心设计

设计1:数据库设计 💾

方案1:邻接表(简单,查询慢)

-- ⭐ 评论表
CREATE TABLE t_comment (
    id BIGINT PRIMARY KEY COMMENT '评论ID',
    parent_id BIGINT COMMENT '父评论ID(NULL表示一级评论)',
    root_id BIGINT COMMENT '根评论ID(一级评论ID)',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    content TEXT NOT NULL COMMENT '评论内容',
    like_count INT NOT NULL DEFAULT 0 COMMENT '点赞数',
    reply_count INT NOT NULL DEFAULT 0 COMMENT '回复数',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-正常 2-删除 3-审核中',
    create_time DATETIME NOT NULL,
    
    INDEX idx_parent (parent_id, create_time),
    INDEX idx_root (root_id, create_time),
    INDEX idx_user (user_id, create_time)
) COMMENT '评论表';

优点

  • 简单 ✅
  • 易于理解 ✅

缺点

  • 查询多级评论需要多次查询 ❌
  • 性能差 ❌

方案2:路径枚举(推荐)⭐⭐⭐

-- ⭐ 评论表(路径枚举)
CREATE TABLE t_comment (
    id BIGINT PRIMARY KEY COMMENT '评论ID',
    parent_id BIGINT COMMENT '父评论ID',
    root_id BIGINT COMMENT '根评论ID',
    path VARCHAR(500) COMMENT '路径(如:/1/5/10)',
    level INT NOT NULL DEFAULT 1 COMMENT '层级',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    reply_to_user_id BIGINT COMMENT '回复的用户ID',
    content TEXT NOT NULL COMMENT '评论内容',
    like_count INT NOT NULL DEFAULT 0 COMMENT '点赞数',
    reply_count INT NOT NULL DEFAULT 0 COMMENT '回复数',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态',
    create_time DATETIME NOT NULL,
    
    INDEX idx_root_time (root_id, create_time),
    INDEX idx_path (path),
    INDEX idx_user (user_id, create_time)
) COMMENT '评论表';

示例数据

评论1(一级评论):
id=1, parent_id=NULL, root_id=1, path='/1/', level=1

评论2(回复评论1):
id=2, parent_id=1, root_id=1, path='/1/2/', level=2

评论3(回复评论2):
id=3, parent_id=2, root_id=1, path='/1/2/3/', level=3

查询评论1的所有子评论:
SELECT * FROM t_comment 
WHERE path LIKE '/1/%' 
ORDER BY create_time

优点

  • 一次查询获取所有子评论 ✅
  • 性能好 ✅

设计2:评论查询 🔍

查询策略

查询策略:
1. 查询一级评论(parent_id IS NULL2. 每个一级评论查询前3条二级评论
3. 点击"查看更多回复"加载剩余二级评论

优点:
- 减少查询数据量 ✅
- 用户体验好 ✅

代码实现

@Service
public class CommentService {
    
    @Autowired
    private CommentMapper commentMapper;
    
    @Autowired
    private UserService userService;
    
    /**
     * ⭐ 查询评论列表(分页)
     */
    public CommentPageResult getComments(Long bizId, String bizType, int page, int pageSize) {
        // 1. 查询一级评论(分页)
        List<Comment> rootComments = commentMapper.selectRootComments(
            bizId, bizType, page, pageSize);
        
        if (rootComments.isEmpty()) {
            return new CommentPageResult();
        }
        
        // 2. 查询每个一级评论的前3条二级评论
        List<CommentVO> commentVOList = new ArrayList<>();
        
        for (Comment rootComment : rootComments) {
            CommentVO commentVO = buildCommentVO(rootComment);
            
            // 查询二级评论(前3条)
            List<Comment> childComments = commentMapper.selectChildComments(
                rootComment.getId(), 1, 3);
            
            List<CommentVO> childVOList = childComments.stream()
                .map(this::buildCommentVO)
                .collect(Collectors.toList());
            
            commentVO.setChildComments(childVOList);
            commentVO.setHasMore(rootComment.getReplyCount() > 3);
            
            commentVOList.add(commentVO);
        }
        
        // 3. 统计总数
        long total = commentMapper.countRootComments(bizId, bizType);
        
        CommentPageResult result = new CommentPageResult();
        result.setComments(commentVOList);
        result.setTotal(total);
        result.setPage(page);
        result.setPageSize(pageSize);
        
        return result;
    }
    
    /**
     * ⭐ 查询更多回复(二级评论)
     */
    public List<CommentVO> getMoreReplies(Long rootCommentId, int page, int pageSize) {
        List<Comment> comments = commentMapper.selectChildComments(
            rootCommentId, page, pageSize);
        
        return comments.stream()
            .map(this::buildCommentVO)
            .collect(Collectors.toList());
    }
    
    /**
     * 构建CommentVO
     */
    private CommentVO buildCommentVO(Comment comment) {
        CommentVO vo = new CommentVO();
        vo.setId(comment.getId());
        vo.setContent(comment.getContent());
        vo.setLikeCount(comment.getLikeCount());
        vo.setReplyCount(comment.getReplyCount());
        vo.setCreateTime(comment.getCreateTime());
        
        // 用户信息
        User user = userService.getById(comment.getUserId());
        vo.setUserName(user.getNickname());
        vo.setUserAvatar(user.getAvatar());
        
        // 回复的用户信息
        if (comment.getReplyToUserId() != null) {
            User replyToUser = userService.getById(comment.getReplyToUserId());
            vo.setReplyToUserName(replyToUser.getNickname());
        }
        
        return vo;
    }
}

Mapper实现

@Mapper
public interface CommentMapper {
    
    /**
     * ⭐ 查询一级评论(分页)
     */
    @Select("SELECT * FROM t_comment " +
            "WHERE biz_id = #{bizId} " +
            "  AND biz_type = #{bizType} " +
            "  AND parent_id IS NULL " +
            "  AND status = 1 " +
            "ORDER BY create_time DESC " +
            "LIMIT #{offset}, #{pageSize}")
    List<Comment> selectRootComments(@Param("bizId") Long bizId, 
                                     @Param("bizType") String bizType,
                                     @Param("offset") int offset, 
                                     @Param("pageSize") int pageSize);
    
    /**
     * ⭐ 查询子评论(分页)
     */
    @Select("SELECT * FROM t_comment " +
            "WHERE parent_id = #{parentId} " +
            "  AND status = 1 " +
            "ORDER BY create_time ASC " +
            "LIMIT #{offset}, #{pageSize}")
    List<Comment> selectChildComments(@Param("parentId") Long parentId,
                                      @Param("offset") int offset, 
                                      @Param("pageSize") int pageSize);
    
    /**
     * 统计一级评论数
     */
    @Select("SELECT COUNT(*) FROM t_comment " +
            "WHERE biz_id = #{bizId} " +
            "  AND biz_type = #{bizType} " +
            "  AND parent_id IS NULL " +
            "  AND status = 1")
    long countRootComments(@Param("bizId") Long bizId, 
                          @Param("bizType") String bizType);
}

设计3:发布评论 ✍️

@Service
public class CommentPublishService {
    
    @Autowired
    private CommentMapper commentMapper;
    
    @Autowired
    private SensitiveWordFilter sensitiveWordFilter;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * ⭐ 发布评论
     */
    @Transactional(rollbackFor = Exception.class)
    public Comment publishComment(Long userId, CommentRequest request) {
        // 1. 限流(每人每分钟最多10条评论)
        checkRateLimit(userId);
        
        // 2. 敏感词过滤
        String content = request.getContent();
        String filteredContent = sensitiveWordFilter.filter(content);
        
        // 3. 创建评论
        Comment comment = new Comment();
        comment.setId(idGenerator.nextId());
        comment.setUserId(userId);
        comment.setBizId(request.getBizId());
        comment.setBizType(request.getBizType());
        comment.setContent(filteredContent);
        comment.setStatus(CommentStatus.NORMAL);
        comment.setCreateTime(new Date());
        
        // ⭐ 4. 设置父评论和层级
        if (request.getParentId() != null) {
            Comment parentComment = commentMapper.selectById(request.getParentId());
            
            if (parentComment == null) {
                throw new CommentNotFoundException("父评论不存在");
            }
            
            comment.setParentId(request.getParentId());
            comment.setRootId(parentComment.getRootId());
            comment.setPath(parentComment.getPath() + comment.getId() + "/");
            comment.setLevel(parentComment.getLevel() + 1);
            comment.setReplyToUserId(parentComment.getUserId());
            
            // 更新父评论的回复数
            commentMapper.incrementReplyCount(request.getParentId());
            
        } else {
            // 一级评论
            comment.setRootId(comment.getId());
            comment.setPath("/" + comment.getId() + "/");
            comment.setLevel(1);
        }
        
        // 5. 保存评论
        commentMapper.insert(comment);
        
        // ⭐ 6. 删除缓存
        deleteCacheComment(request.getBizId(), request.getBizType());
        
        return comment;
    }
    
    /**
     * 限流检查
     */
    private void checkRateLimit(Long userId) {
        String key = "comment:rate:" + userId;
        
        Long count = redisTemplate.opsForValue().increment(key, 1);
        if (count == 1) {
            redisTemplate.expire(key, 1, TimeUnit.MINUTES);
        }
        
        if (count > 10) {
            throw new RateLimitException("评论太频繁,请稍后再试");
        }
    }
    
    /**
     * 删除评论缓存
     */
    private void deleteCacheComment(Long bizId, String bizType) {
        String key = "comment:" + bizType + ":" + bizId + ":*";
        Set<String> keys = redisTemplate.keys(key);
        if (keys != null && !keys.isEmpty()) {
            redisTemplate.delete(keys);
        }
    }
}

设计4:评论点赞 👍

@Service
public class CommentLikeService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private CommentMapper commentMapper;
    
    private static final String LIKE_KEY_PREFIX = "comment:like:";
    
    /**
     * ⭐ 点赞/取消点赞
     */
    public void like(Long userId, Long commentId) {
        String key = LIKE_KEY_PREFIX + commentId;
        String member = String.valueOf(userId);
        
        // ⭐ 检查是否已点赞
        Boolean isMember = redisTemplate.opsForSet().isMember(key, member);
        
        if (Boolean.TRUE.equals(isMember)) {
            // 已点赞,取消点赞
            redisTemplate.opsForSet().remove(key, member);
            commentMapper.decrementLikeCount(commentId);
        } else {
            // 未点赞,点赞
            redisTemplate.opsForSet().add(key, member);
            commentMapper.incrementLikeCount(commentId);
        }
    }
    
    /**
     * ⭐ 检查用户是否点赞
     */
    public boolean isLiked(Long userId, Long commentId) {
        String key = LIKE_KEY_PREFIX + commentId;
        String member = String.valueOf(userId);
        
        return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(key, member));
    }
    
    /**
     * ⭐ 批量检查用户是否点赞
     */
    public Map<Long, Boolean> batchIsLiked(Long userId, List<Long> commentIds) {
        Map<Long, Boolean> result = new HashMap<>();
        
        for (Long commentId : commentIds) {
            result.put(commentId, isLiked(userId, commentId));
        }
        
        return result;
    }
}

Mapper实现

@Mapper
public interface CommentMapper {
    
    /**
     * ⭐ 点赞数+1
     */
    @Update("UPDATE t_comment " +
            "SET like_count = like_count + 1 " +
            "WHERE id = #{commentId}")
    int incrementLikeCount(@Param("commentId") Long commentId);
    
    /**
     * ⭐ 点赞数-1
     */
    @Update("UPDATE t_comment " +
            "SET like_count = like_count - 1 " +
            "WHERE id = #{commentId} " +
            "  AND like_count > 0")
    int decrementLikeCount(@Param("commentId") Long commentId);
    
    /**
     * 回复数+1
     */
    @Update("UPDATE t_comment " +
            "SET reply_count = reply_count + 1 " +
            "WHERE id = #{commentId}")
    int incrementReplyCount(@Param("commentId") Long commentId);
}

设计5:评论审核 🔍

@Service
public class CommentAuditService {
    
    @Autowired
    private CommentMapper commentMapper;
    
    @Autowired
    private SensitiveWordFilter sensitiveWordFilter;
    
    /**
     * ⭐ 自动审核(敏感词检测)
     */
    public CommentStatus autoAudit(String content) {
        // 检查敏感词
        List<String> sensitiveWords = sensitiveWordFilter.findSensitiveWords(content);
        
        if (!sensitiveWords.isEmpty()) {
            // 包含敏感词,需要人工审核
            return CommentStatus.AUDITING;
        }
        
        return CommentStatus.NORMAL;
    }
    
    /**
     * ⭐ 人工审核
     */
    @Transactional(rollbackFor = Exception.class)
    public void manualAudit(Long commentId, CommentStatus status, String reason) {
        Comment comment = commentMapper.selectById(commentId);
        
        if (comment == null) {
            throw new CommentNotFoundException("评论不存在");
        }
        
        // 更新审核状态
        comment.setStatus(status);
        commentMapper.updateById(comment);
        
        // 审核不通过,通知用户
        if (status == CommentStatus.DELETED) {
            notifyUser(comment.getUserId(), "您的评论因" + reason + "被删除");
        }
    }
    
    /**
     * 通知用户
     */
    private void notifyUser(Long userId, String message) {
        // 发送站内信或推送通知
        // ...
    }
}

🎓 面试题速答

Q1: 评论如何存储多级结构?

A: **路径枚举(推荐)**⭐:

-- 评论表
CREATE TABLE t_comment (
    id BIGINT PRIMARY KEY,
    parent_id BIGINT,
    root_id BIGINT,
    path VARCHAR(500),  -- 路径:/1/2/3/
    level INT,
    ...
);

-- 查询评论1的所有子评论
SELECT * FROM t_comment 
WHERE path LIKE '/1/%' 
ORDER BY create_time

优点:一次查询获取所有子评论


Q2: 如何查询评论列表?

A: 分层查询

// 1. 查询一级评论(分页)
List<Comment> rootComments = selectRootComments(page, pageSize);

// 2. 每个一级评论查询前3条二级评论
for (Comment root : rootComments) {
    List<Comment> children = selectChildComments(root.getId(), 1, 3);
    root.setChildren(children);
}

优点

  • 减少数据量
  • 用户体验好

Q3: 评论点赞如何实现?

A: Redis Set

// 点赞
String key = "comment:like:" + commentId;
redisTemplate.opsForSet().add(key, userId);

// 检查是否点赞
boolean isLiked = redisTemplate.opsForSet().isMember(key, userId);

// 取消点赞
redisTemplate.opsForSet().remove(key, userId);

优点

  • 高性能(O(1))
  • 支持批量查询

Q4: 如何防止刷评论?

A: 限流 + 审核

// 限流(每人每分钟最多10条)
String key = "comment:rate:" + userId;
Long count = redisTemplate.increment(key);
if (count > 10) {
    throw new RateLimitException("评论太频繁");
}

// 敏感词审核
String filtered = sensitiveWordFilter.filter(content);

Q5: 如何优化评论查询性能?

A: 三层优化

  1. 索引优化
INDEX idx_root_time (root_id, create_time)
  1. 分页查询
// 只查询前20条一级评论
// 每条一级评论只查询前3条二级评论
  1. Redis缓存
// 缓存热门评论
String key = "comment:hot:" + bizId;
redisTemplate.opsForValue().set(key, comments, 5, TimeUnit.MINUTES);

Q6: 删除评论如何处理?

A: 软删除 + 级联更新

@Transactional
public void deleteComment(Long commentId) {
    // 1. 软删除评论
    commentMapper.updateStatus(commentId, CommentStatus.DELETED);
    
    // 2. 更新父评论的回复数-1
    Comment comment = commentMapper.selectById(commentId);
    if (comment.getParentId() != null) {
        commentMapper.decrementReplyCount(comment.getParentId());
    }
    
    // 3. 删除子评论(可选)
    commentMapper.deleteChildComments(commentId);
}

🎬 总结

       评论系统核心设计

┌────────────────────────────────────┐
│ 1. 路径枚举存储 ⭐                  │
│    - path字段:/1/2/3/             │
│    - 一次查询获取子评论            │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 2. 分层查询                        │
│    - 一级评论分页                  │
│    - 每条一级评论前3条二级评论     │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 3. 点赞(Redis Set)               │
│    - O(1)查询                      │
│    - 支持批量检查                  │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 4. 限流 + 审核                      │
│    - 每分钟最多10条                │
│    - 敏感词过滤                    │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 5. 性能优化                        │
│    - 索引优化                      │
│    - Redis缓存                     │
│    - 分页查询                      │
└────────────────────────────────────┘

🎉 恭喜你!

你已经完全掌握了评论系统的设计!🎊

核心要点

  1. 路径枚举存储:path字段存储路径,一次查询所有子评论
  2. 分层查询:一级评论分页,每条前3条二级评论
  3. 点赞:Redis Set存储,O(1)查询
  4. 限流:每分钟最多10条评论
  5. 审核:敏感词过滤,自动+人工审核

下次面试,这样回答

"评论系统采用路径枚举方式存储多级评论。在评论表中增加path字段,存储从根评论到当前评论的完整路径,如'/1/2/3/'。查询某条评论的所有子评论时,使用WHERE path LIKE '/1/%'一次性查出,避免递归查询。同时增加level字段记录层级,限制最多3级评论防止无限嵌套。

查询评论列表采用分层策略。首先分页查询一级评论(parent_id IS NULL),每页20条。然后对每条一级评论,查询前3条二级评论。用户点击'查看更多回复'时,再分页加载该一级评论的剩余二级评论。这样既减少了数据传输量,又保证了用户体验。

点赞功能使用Redis Set实现。key为'comment:like:评论ID',member为用户ID。点赞时add操作,取消点赞时remove操作,检查是否点赞用isMember操作,时间复杂度都是O(1)。同时支持批量检查,一次性查询多条评论的点赞状态。点赞数同步更新到MySQL的like_count字段。

防刷评论采用限流和审核。使用Redis计数器限流,key为'comment:rate:用户ID',1分钟过期,超过10次拒绝评论。发布评论时进行敏感词过滤,使用DFA算法检测,毫秒级响应。包含敏感词的评论进入审核状态,需人工审核后才能展示。

性能优化方面,root_id和create_time建立联合索引,加速一级评论查询。热门评论使用Redis缓存5分钟。大V用户的评论单独缓存,避免频繁查询数据库。"

面试官:👍 "很好!你对评论系统的设计理解很深刻!"


本文完 🎬

上一篇: 214-设计一个优惠券系统.md
下一篇: 216-设计一个API网关.md

作者注:写完这篇,我都想去盖楼了!💬
如果这篇文章对你有帮助,请给我一个Star⭐!