📖 开场:论坛盖楼
想象你在论坛发帖 📝:
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 NULL)
2. 每个一级评论查询前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: 三层优化:
- 索引优化:
INDEX idx_root_time (root_id, create_time)
- 分页查询:
// 只查询前20条一级评论
// 每条一级评论只查询前3条二级评论
- 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缓存 │
│ - 分页查询 │
└────────────────────────────────────┘
🎉 恭喜你!
你已经完全掌握了评论系统的设计!🎊
核心要点:
- 路径枚举存储:path字段存储路径,一次查询所有子评论
- 分层查询:一级评论分页,每条前3条二级评论
- 点赞:Redis Set存储,O(1)查询
- 限流:每分钟最多10条评论
- 审核:敏感词过滤,自动+人工审核
下次面试,这样回答:
"评论系统采用路径枚举方式存储多级评论。在评论表中增加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⭐!