设计一个评论系统:楼中楼、盖楼、热评排序的数据模型设计
设计一个评论系统:楼中楼、盖楼、热评排序的数据模型设计
适读人群:Java中高级工程师、内容平台技术人员 | 阅读时长:约18分钟 | 难度:★★★☆☆
开篇故事
我做过一个内容社区,评论功能需求方说"就是微博那种楼中楼"。我以为简单,结果第一版上线后翻车了:评论嵌套层级不限,有个用户发现了这个漏洞,故意回复自己创建了400层嵌套评论,前端页面直接卡死。后端数据库查这条评论也超时了,因为我用了递归查询关联评论,400层递归在MySQL里是灾难。
这件事让我意识到,评论系统看似简单,但数据模型设计非常关键。楼中楼到底存几层?热评怎么排序?评论数怎么计数?删除时子评论怎么处理?每个决策背后都有取舍。这篇文章我把评论系统从数据模型到缓存架构全链路说清楚。
一、需求分析与规模估算
功能需求
- 根评论(一楼): 针对内容(文章/视频/商品)的直接评论
- 子评论(楼中楼): 针对评论的回复,支持 @提及
- 层级限制: 只展示两层(根评论 + 一层子评论),子评论的回复仍属于同一个根评论
- 热评排序: 综合点赞数、回复数、时间的热度算法排序
- 最新排序: 按时间倒序
- 评论删除: 软删除,"已删除的评论"占位展示,子评论继续可见
- 评论计数: 内容的总评论数(实时准确)
- 举报屏蔽: 被举报多次的评论自动隐藏,等待审核
规模估算
以一个中型内容平台为例:
内容与评论规模:
- 每天新增内容:10万篇
- 每天新增评论:500万条
- 评论总量(累计3年):约55亿条 → 需要分库分表
QPS估算:
- 读(查评论列表):每天5亿次 → 平均5787 QPS,峰值约3万QPS
- 写(发评论):每天500万次 → 平均58 QPS,峰值约600 QPS
读写比约 100:1,典型的读多写少场景,缓存是核心优化手段。
存储估算:
- 每条评论约500字节(评论文本平均100字,加上各种元数据字段)
- 55亿 × 500字节 = 2.75TB → 需要分库分表
热评缓存估算:
- 每篇内容的热评列表(Top20)缓存
- 活跃内容约100万篇,每个缓存项约5KB(20条评论)
- Redis内存:100万 × 5KB = 5GB(可接受)
二、系统架构设计
三、核心数据模型设计
3.1 评论表结构(关键决策)
这里最重要的设计决策是:楼中楼使用"有限层级"模型,而不是无限递归模型。
CREATE TABLE comment_0 ( -- 按content_id分64张表
id BIGINT PRIMARY KEY,
content_id BIGINT NOT NULL, -- 所属内容ID
root_id BIGINT DEFAULT 0, -- 根评论ID(=0表示自己是根评论)
parent_id BIGINT DEFAULT 0, -- 直接父评论ID
reply_user_id BIGINT DEFAULT 0, -- @的用户ID
user_id BIGINT NOT NULL, -- 评论者ID
content VARCHAR(2000) NOT NULL,
like_count INT DEFAULT 0,
reply_count INT DEFAULT 0,
status TINYINT DEFAULT 1, -- 1正常 0已删除 2审核中
hot_score DECIMAL(10,4) DEFAULT 0, -- 热度分
create_time DATETIME NOT NULL,
INDEX idx_content_root (content_id, root_id, create_time),
INDEX idx_content_hot (content_id, root_id, hot_score DESC)
) ENGINE=InnoDB;设计要点:
root_id=0表示根评论,root_id>0表示子评论,且root_id就是所属的根评论ID- 这样"查某条根评论的所有子评论"只需要
WHERE root_id = ?,一次查询,不用递归 parent_id用于展示"回复了谁",只需要显示,不用于查询- 层级只有两层:根评论 + 子评论,子评论的子评论也归属于同一个根评论
举例:
根评论A(id=100, root_id=0)
├── 回复A(id=101, root_id=100, parent_id=100)
├── 回复B(id=102, root_id=100, parent_id=100)
└── 回复C(id=103, root_id=100, parent_id=101)← 回复了A的回复,但root_id仍是1003.2 计数表
CREATE TABLE comment_count (
content_id BIGINT PRIMARY KEY,
comment_count INT DEFAULT 0, -- 总评论数(根评论数)
update_time DATETIME NOT NULL
);根评论数单独存,不包含子评论(业务上通常展示的是"X条评论"指根评论数)。
3.3 热度分算法
热度分综合了点赞数、回复数和时间衰减:
hot_score = (like_count × 2 + reply_count × 1) / (age_hours + 2)^1.5其中 age_hours 是评论发布距今的小时数,(age_hours+2)^1.5 是时间衰减因子,确保新评论也有机会冒泡。
四、关键代码实现
4.1 评论写入服务
@Service
@Slf4j
public class CommentWriteService {
@Autowired
private CommentMapper commentMapper;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private KafkaTemplate<String, CommentEvent> kafkaTemplate;
@Autowired
private SnowflakeIdGenerator idGenerator;
@Autowired
private ContentAuditService auditService;
/**
* 发布评论
*/
@Transactional
public CommentVO postComment(PostCommentRequest request) {
String content = request.getContent();
// 1. 基础校验
if (content == null || content.trim().length() < 1) {
throw new BusinessException("评论内容不能为空");
}
if (content.length() > 2000) {
throw new BusinessException("评论超过最大长度限制");
}
// 2. 构建评论实体
long commentId = idGenerator.nextId();
Comment comment = new Comment();
comment.setId(commentId);
comment.setContentId(request.getContentId());
comment.setUserId(request.getUserId());
comment.setContent(content.trim());
comment.setCreateTime(LocalDateTime.now());
comment.setStatus(CommentStatus.NORMAL);
// 3. 处理根评论 vs 子评论
if (request.getParentId() == null || request.getParentId() == 0) {
// 根评论
comment.setRootId(0L);
comment.setParentId(0L);
comment.setReplyUserId(0L);
} else {
// 子评论:确定root_id
Comment parent = commentMapper.findById(request.getParentId(),
request.getContentId());
if (parent == null || parent.getStatus() != CommentStatus.NORMAL) {
throw new BusinessException("回复的评论不存在");
}
// 不管回复的是根评论还是子评论,root_id都指向根评论
long rootId = parent.getRootId() == 0
? parent.getId() // parent是根评论
: parent.getRootId(); // parent是子评论
comment.setRootId(rootId);
comment.setParentId(request.getParentId());
comment.setReplyUserId(parent.getUserId());
// 更新父评论(根评论)的reply_count
commentMapper.incrementReplyCount(rootId, request.getContentId());
// 清除该根评论子评论列表的缓存
redisTemplate.delete("comment:children:" + rootId);
}
// 4. 写入数据库
commentMapper.insert(comment);
// 5. 更新内容的评论计数(根评论才计数)
if (comment.getRootId() == 0) {
commentMapper.incrementCommentCount(request.getContentId());
// 清除该内容的热评缓存
redisTemplate.delete("comment:hot:" + request.getContentId());
redisTemplate.delete("comment:new:" + request.getContentId());
}
// 6. 发送评论事件(异步审核、通知、热度计算)
CommentEvent event = CommentEvent.builder()
.type(CommentEventType.POST)
.comment(comment)
.build();
kafkaTemplate.send("comment-events", String.valueOf(request.getContentId()), event);
return buildVO(comment);
}
/**
* 删除评论(软删除)
*/
@Transactional
public void deleteComment(Long commentId, Long userId, Long contentId) {
Comment comment = commentMapper.findById(commentId, contentId);
if (comment == null) {
throw new BusinessException("评论不存在");
}
if (!comment.getUserId().equals(userId)) {
throw new BusinessException("无权删除此评论");
}
// 软删除:status置为0,内容替换为"已删除的评论"
commentMapper.softDelete(commentId, contentId);
// 如果是根评论被删除,评论计数-1
if (comment.getRootId() == 0) {
commentMapper.decrementCommentCount(contentId);
redisTemplate.delete("comment:hot:" + contentId);
redisTemplate.delete("comment:new:" + contentId);
}
}
}4.2 评论读取服务(带缓存)
@Service
@Slf4j
public class CommentReadService {
@Autowired
private CommentMapper commentMapper;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private UserService userService;
private static final int HOT_COMMENT_CACHE_SIZE = 20; // 缓存前20条热评
private static final long CACHE_TTL_SECONDS = 300;
/**
* 获取根评论列表
* 支持两种排序:热评(hot)和最新(new)
*/
public PageResult<CommentVO> listRootComments(
Long contentId, String sortBy, int page, int size) {
// 第一页热评:走缓存
if ("hot".equals(sortBy) && page == 0) {
return getCachedHotComments(contentId, size);
}
List<Comment> comments;
long total;
if ("hot".equals(sortBy)) {
comments = commentMapper.listByHotScore(contentId, page, size);
} else {
comments = commentMapper.listByTime(contentId, page, size);
}
total = commentMapper.countRootComments(contentId);
List<CommentVO> vos = enrichWithUserInfo(comments);
return new PageResult<>(total, vos);
}
/**
* 获取某条根评论的子评论列表
*/
public PageResult<CommentVO> listChildComments(
Long rootCommentId, Long contentId, int page, int size) {
// 第一页子评论:走缓存
if (page == 0) {
String cacheKey = "comment:children:" + rootCommentId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
List<CommentVO> cachedList = JsonUtils.fromJson(
cached, new TypeReference<List<CommentVO>>(){});
long total = commentMapper.countChildComments(rootCommentId, contentId);
return new PageResult<>(total,
cachedList.subList(0, Math.min(cachedList.size(), size)));
}
}
List<Comment> children = commentMapper.listChildren(
rootCommentId, contentId, page, size);
long total = commentMapper.countChildComments(rootCommentId, contentId);
List<CommentVO> vos = enrichWithUserInfo(children);
// 第一页写缓存
if (page == 0 && !vos.isEmpty()) {
redisTemplate.opsForValue().set(
"comment:children:" + rootCommentId,
JsonUtils.toJson(vos),
CACHE_TTL_SECONDS, TimeUnit.SECONDS
);
}
return new PageResult<>(total, vos);
}
private PageResult<CommentVO> getCachedHotComments(Long contentId, int size) {
String cacheKey = "comment:hot:" + contentId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
List<CommentVO> cachedList = JsonUtils.fromJson(
cached, new TypeReference<List<CommentVO>>(){});
return new PageResult<>((long) cachedList.size(),
cachedList.subList(0, Math.min(cachedList.size(), size)));
}
// 缓存未命中:从DB查
List<Comment> hotComments = commentMapper.listByHotScore(
contentId, 0, HOT_COMMENT_CACHE_SIZE);
long total = commentMapper.countRootComments(contentId);
List<CommentVO> vos = enrichWithUserInfo(hotComments);
// 写缓存
if (!vos.isEmpty()) {
redisTemplate.opsForValue().set(
cacheKey, JsonUtils.toJson(vos),
CACHE_TTL_SECONDS, TimeUnit.SECONDS
);
}
return new PageResult<>(total,
vos.subList(0, Math.min(vos.size(), size)));
}
/**
* 批量填充用户信息(头像、昵称)
*/
private List<CommentVO> enrichWithUserInfo(List<Comment> comments) {
if (comments.isEmpty()) return Collections.emptyList();
// 收集所有需要查询的用户ID
Set<Long> userIds = comments.stream()
.map(Comment::getUserId)
.collect(Collectors.toSet());
comments.stream()
.filter(c -> c.getReplyUserId() != null && c.getReplyUserId() > 0)
.map(Comment::getReplyUserId)
.forEach(userIds::add);
// 批量查用户信息(走用户服务的缓存)
Map<Long, UserInfo> userMap = userService.batchGetUserInfo(
new ArrayList<>(userIds));
return comments.stream()
.map(c -> buildVO(c, userMap))
.collect(Collectors.toList());
}
private CommentVO buildVO(Comment comment, Map<Long, UserInfo> userMap) {
CommentVO vo = new CommentVO();
vo.setId(comment.getId());
vo.setContent(comment.getStatus() == CommentStatus.DELETED
? "该评论已删除" : comment.getContent());
vo.setLikeCount(comment.getLikeCount());
vo.setReplyCount(comment.getReplyCount());
vo.setCreateTime(comment.getCreateTime());
vo.setDeleted(comment.getStatus() == CommentStatus.DELETED);
UserInfo user = userMap.get(comment.getUserId());
if (user != null) {
vo.setUserName(user.getNickname());
vo.setUserAvatar(user.getAvatar());
}
return vo;
}
}4.3 热度分异步计算
@Component
@Slf4j
public class HotScoreWorker {
@Autowired
private CommentMapper commentMapper;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 消费评论事件,异步更新热度分
* 触发条件:新评论发布、被点赞、被回复
*/
@KafkaListener(topics = "comment-events", groupId = "hot-score-group")
public void onCommentEvent(CommentEvent event) {
Comment comment = event.getComment();
// 找到需要更新热度分的根评论
Long rootCommentId = comment.getRootId() == 0
? comment.getId()
: comment.getRootId();
// 从DB获取最新的评论数据
Comment rootComment = commentMapper.findById(
rootCommentId, comment.getContentId());
if (rootComment == null) return;
// 计算新的热度分
double hotScore = calculateHotScore(rootComment);
// 更新到数据库
commentMapper.updateHotScore(rootCommentId, comment.getContentId(), hotScore);
// 清除热评缓存(下次查询时会重新计算)
redisTemplate.delete("comment:hot:" + comment.getContentId());
}
/**
* Wilson score lower bound算法(更公平的热评排序)
* 考虑了置信区间,防止点赞数少但全是正的评论排在前面
*/
private double calculateHotScore(Comment comment) {
long likeCount = comment.getLikeCount();
long replyCount = comment.getReplyCount();
long ageMinutes = ChronoUnit.MINUTES.between(
comment.getCreateTime(), LocalDateTime.now());
// 简化版热度公式
double interactionScore = likeCount * 2.0 + replyCount * 1.0;
double timeDecay = Math.pow(ageMinutes / 60.0 + 2, 1.5);
return interactionScore / timeDecay;
}
}五、扩展性设计
分库分表策略
按 content_id % 64 分64张表(comment_0 到 comment_63)。
选择按 content_id 分表而不是按 user_id 分表,原因是:绝大多数查询都是"查某个内容的评论",按 content_id 分表能保证同一内容的评论在同一张表里,不需要跨表查询。
public String getTableSuffix(Long contentId) {
return String.valueOf(contentId % 64);
}评论计数的高并发处理
热门内容可能每秒有几百条新评论,每次都更新计数表会成为热点。
解决方案:Redis计数 + 异步持久化。
// 写评论时:Redis incr
redisTemplate.opsForValue().increment("comment:count:" + contentId);
// 查询时:优先读Redis
String count = redisTemplate.opsForValue().get("comment:count:" + contentId);
if (count != null) return Long.parseLong(count);
// Redis没有则读DB
return commentMapper.countRootComments(contentId);
// 定时任务:每分钟将Redis中的计数同步到MySQL
@Scheduled(fixedDelay = 60000)
public void syncCountToDb() {
// 扫描变更的contentId,批量更新MySQL
}六、踩坑实录
坑1:评论删除后子评论也消失了
第一版删除根评论时,我写的是物理删除,子评论也一并级联删除了。上线后用户反馈:"我被回复的评论可以看,但点进去之后看不到上下文了"。
解决方案:改为软删除,根评论 status=0,内容替换为"该评论已删除",子评论保留不变。
坑2:高并发下评论计数不准
并发量大时,多个请求同时 UPDATE comment_count SET count=count+1,行锁争用严重,QPS高时延迟从毫秒级飙升到秒级。
解决方案:Redis原子计数 + 异步刷库,不再直接写MySQL计数字段。
坑3:热评排序被刷赞攻击
某次发现有人用脚本疯狂给自己的评论点赞,把广告评论刷到了热评第一位。
解决方案:点赞去重(同一用户对同一评论只能点赞一次,Redis Set记录),同时对异常点赞(1分钟内超过10次点赞行为)做限流和封号处理。
坑4:分页游标失效,翻页时出现重复数据
用时间戳做分页游标,但同一秒内可能有多条评论,WHERE create_time < ? 会把同一秒内的评论截断,导致翻页时漏掉部分评论或出现重复。
解决方案:用评论ID做游标(雪花ID天然有序),WHERE id < lastId ORDER BY id DESC,ID唯一,不会出现重复或遗漏。
七、总结
评论系统的核心设计决策:
| 决策 | 方案 | 原因 |
|---|---|---|
| 层级结构 | root_id标记法,只支持两层 | 避免递归查询,防止无限嵌套 |
| 软删除 | status字段 + 内容替换 | 保留子评论上下文 |
| 热度排序 | 异步计算,缓存结果 | 不阻塞写入,读取高效 |
| 评论计数 | Redis原子计数 + 异步持久化 | 高并发下性能优先 |
| 分页游标 | ID游标而非时间游标 | 唯一性保证,防止漏数据 |
评论系统看似简单,但每个细节都关乎用户体验和系统稳定性。数据模型是根基,设计好了后面的优化才有意义。
