Springboot仿抖音app开发之消息业务模块后端复盘及相关业务知识总结
Springboot仿抖音app开发之粉丝业务模块后端复盘及相关业务知识总结
Springboot仿抖音app开发之用短视频务模块后端复盘及相关业务知识总结
Springboot仿抖音app开发之用户业务模块后端复盘及相关业务知识总结
用户发表评论并显示评论总数
1. 控制层 (CommentController)
@PostMapping("create")
public GraceJSONResult create(@RequestBody @Valid CommentBO commentBO)throws Exception {CommentVO commentVO = commentService.createComment(commentBO);return GraceJSONResult.ok(commentVO);
}
控制层的主要职责:
- 提供REST API接口
/comment/create
(POST请求) - 接收前端传来的JSON数据并绑定到CommentBO对象
- 使用
@Valid
注解进行参数校验 - 调用服务层方法处理业务逻辑
- 将处理结果包装成统一响应格式返回
2. 服务层 (CommentServiceImpl)
@Override
public CommentVO createComment(CommentBO commentBO) {String commentId = sid.nextShort();Comment comment = new Comment();comment.setId(commentId);comment.setVlogId(commentBO.getVlogId());comment.setVlogerId(commentBO.getVlogerId());comment.setCommentUserId(commentBO.getCommentUserId());comment.setFatherCommentId(commentBO.getFatherCommentId());comment.setContent(commentBO.getContent());comment.setLikeCounts(0);comment.setCreateTime(new Date());commentMapper.insert(comment);// redis操作,评论总数的累加redis.increment(REDIS_VLOG_COMMENT_COUNTS + ":" + commentBO.getVlogId(), 1);// 留言后的最新评论需要返回给前端进行展示CommentVO commentVO = new CommentVO();BeanUtils.copyProperties(comment, commentVO);// 系统消息相关代码被注释掉了return commentVO;
}
服务层的主要职责:
- 生成唯一评论ID (
sid.nextShort()
) - 将前端数据(CommentBO)转换为数据库实体(Comment)
- 设置额外信息(点赞数初始化为0、创建时间等)
- 调用Mapper层将评论保存到数据库
- 在Redis中增加视频的评论计数
- 将数据库实体(Comment)转换为视图对象(CommentVO)返回给前端
- (注释掉的部分)创建系统消息通知视频作者有新评论
3. 数据访问层 (CommentMapper)
- 使用
commentMapper.insert(comment)
将评论数据插入到数据库
4. 数据模型
业务对象 (CommentBO):
- 包含用户提交的评论信息:vlogerId, fatherCommentId, vlogId, commentUserId, content
- 使用注解实现参数验证,如@NotBlank, @Length等
数据库实体 (Comment):
- 映射到数据库表的实体类
- 包含完整的评论信息:id, vlogerId, fatherCommentId, vlogId, commentUserId, content, likeCounts, createTime
视图对象 (CommentVO):
- 返回给前端的数据对象
- 包含额外信息:commentUserNickname, commentUserFace, replyedUserNickname, isLike等
评论总数显示
非常容易理解吧 从redis中获取之前存储的评论总数
@GetMapping("counts")public GraceJSONResult counts(@RequestParam String vlogId) {String countsStr = redis.get(REDIS_VLOG_COMMENT_COUNTS + ":" + vlogId);if (StringUtils.isBlank(countsStr)) {countsStr = "0";}return GraceJSONResult.ok(Integer.valueOf(countsStr));}
为什么要Integer.valueOf(countsStr)
Integer.valueOf(countsStr)
的作用是将字符串类型的数字转换为 Integer 对象。这在这段代码中非常重要,原因如下:
-
数据类型一致性:
- Redis 中存储的评论数是字符串类型(即使存储的是数字,从 Redis 获取时也是字符串)
- 前端或调用方期望接收的是数值类型(Integer),而不是字符串
-
统一响应格式:
GraceJSONResult.ok()
方法接收各种数据类型,并将其包装成统一的响应格式- 返回 Integer 类型更符合 API 的语义,因为评论数就是一个整数
-
避免前端处理:
- 如果直接返回字符串 "0",前端在进行数值运算时可能需要额外的转换
- 返回 Integer 类型让前端可以直接使用这个数值,无需自行转换
-
确保类型安全:
- Java 是强类型语言,通过明确的类型转换可以避免隐式转换带来的潜在问题
Integer.valueOf()
比Integer.parseInt()
更优,因为它可以重用常用整数的缓存
这段代码的完整逻辑是:
- 从 Redis 获取指定视频的评论数(字符串格式)
- 如果 Redis 中没有该数据,默认为 "0"
- 将字符串转换为 Integer 对象
- 将转换后的 Integer 对象包装在统一响应格式中返回
这种处理方式体现了 RESTful API 设计的最佳实践,确保返回的数据类型与其语义一致,并减轻了客户端的处理负担。
实现查询评论列表与联调
1. 控制层实现 (CommentController)
@GetMapping("list")
public GraceJSONResult list(@RequestParam String vlogId,@RequestParam(defaultValue = "") String userId,@RequestParam Integer page,@RequestParam Integer pageSize) {return GraceJSONResult.ok(commentService.queryVlogComments(vlogId,userId,page,pageSize));
}
控制层职责:
- 提供REST API接口
/comment/list
(GET请求) - 接收前端传来的参数:视频ID、用户ID、页码、每页数量
- 为userId设置默认值为空字符串,处理可选参数
- 调用服务层方法查询评论列表
- 将查询结果包装成统一响应格式返回
2. 服务层实现 (CommentServiceImpl)
@Override
public PagedGridResult queryVlogComments(String vlogId,String userId,Integer page,Integer pageSize) {Map<String, Object> map = new HashMap<>();map.put("vlogId", vlogId);PageHelper.startPage(page, pageSize);List<CommentVO> list = commentMapperCustom.getCommentList(map);return setterPagedGrid(list, page);
}
服务层职责:
- 创建参数Map用于传递查询条件
- 设置vlogId作为查询条件
- 使用PageHelper进行分页设置
- 调用Mapper查询评论列表
- 将结果封装为分页对象返回
3. 数据访问层实现 (CommentMapperCustom)
接口定义:
public List<CommentVO> getCommentList(@Param("paramMap") Map<String, Object> map);
SQL查询实现:
<select id="getCommentList" parameterType="map" resultType="com.imooc.vo.CommentVO">SELECTc.id as commentId,c.vlog_id as vlogId,u.id as vlogerId,u.nickname as commentUserNickname,u.face as commentUserFace,c.father_comment_id as fatherCommentId,c.comment_user_id as commentUserId,c.content as content,c.like_counts as likeCounts,fu.nickname as replyedUserNickname,c.create_time as createTime
FROM`comment` as c
LEFT JOINusers as u
ONc.comment_user_id = u.id
LEFT JOIN`comment` as fc
ONc.father_comment_id = fc.id
LEFT JOINusers as fu
ONfc.comment_user_id = fu.id
WHEREc.vlog_id = #{paramMap.vlogId}
ORDER BYc.like_counts DESC,c.create_time DESC</select>
评论列表查询SQL的设计详解
SQL整体设计思路
这段SQL是为了实现短视频平台的评论列表功能,需要展示评论内容及相关的用户信息,并支持评论回复的层级关系。整体设计采用了多表关联查询的方式,将评论数据与用户数据整合在一起。
表结构及关系设计
该查询涉及到两个主要数据表:
- comment表:存储评论的基本信息
- users表:存储用户的基本信息
在SQL中,这些表以不同的别名出现,形成了四个逻辑实体:
c
:主评论表,包含当前查询的评论数据u
:评论作者表,包含评论作者的信息fc
:父评论表,用于处理回复关系fu
:父评论作者表,包含被回复者的信息
关联关系详解
1. 评论与评论作者关联
LEFT JOIN users as u ON c.comment_user_id = u.id
这个关联将评论与发表评论的用户连接起来:
- 关联条件:评论表的
comment_user_id
与用户表的id
- 使用LEFT JOIN:确保即使用户信息缺失,评论数据也能返回
- 查询目的:获取评论者的昵称和头像
2. 评论与父评论关联
LEFT JOIN `comment` as fc ON c.father_comment_id = fc.id
这个关联实现了评论的层级结构:
- 关联条件:当前评论的
father_comment_id
与父评论的id
- 表的自关联:
comment
表自己与自己关联,通过不同别名区分 - 设计意图:支持评论回复功能,建立评论之间的父子关系
- 使用LEFT JOIN:允许一级评论存在(father_comment_id可能为null或"0")
3. 父评论与父评论作者关联
LEFT JOIN users as fu ON fc.comment_user_id = fu.id
这个关联获取被回复者的信息:
- 关联条件:父评论的
comment_user_id
与用户表的id
- 设计目的:获取被回复者的昵称,以便显示"回复@某人"的效果
- 使用LEFT JOIN:即使父评论作者信息缺失,也不影响结果返回
删除评论功能的实现分析
1. 控制层实现 (CommentController)
@DeleteMapping("delete")
public GraceJSONResult delete(@RequestParam String commentUserId,@RequestParam String commentId,@RequestParam String vlogId) {commentService.deleteComment(commentUserId,commentId,vlogId);return GraceJSONResult.ok();
}
控制层职责:
- 提供REST API接口
/comment/delete
(DELETE请求) - 接收三个关键参数:评论者ID、评论ID和视频ID
- 调用服务层方法执行删除逻辑
- 返回统一的成功响应格式
设计特点:
- 使用HTTP DELETE方法符合RESTful设计规范
- 参数通过@RequestParam接收,表明这些是必填项
- 返回标准化的响应格式
2. 服务层实现 (CommentServiceImpl)
@Override
public void deleteComment(String commentUserId,String commentId,String vlogId) {Comment pendingDelete = new Comment();pendingDelete.setId(commentId);pendingDelete.setCommentUserId(commentUserId);commentMapper.delete(pendingDelete);// 评论总数的累减redis.decrement(REDIS_VLOG_COMMENT_COUNTS + ":" + vlogId, 1);
}
服务层职责:
- 构建待删除的评论对象
- 设置评论ID和评论者ID作为删除条件
- 调用Mapper执行数据库删除操作
- 在Redis中减少视频的评论计数
设计特点:
- 同时验证评论ID和评论者ID,确保用户只能删除自己的评论
- 数据库操作与缓存操作结合,保持数据一致性
- 使用Redis计数器维护评论数,避免频繁查询数据库
3. 数据访问层实现
虽然代码片段中没有显示commentMapper.delete()
的具体实现,但根据通用Mapper的使用方式,这是一个条件删除操作:
// MyBatis通用Mapper的典型实现
public interface CommentMapper extends Mapper<Comment> {// delete方法会根据非空字段作为条件// 等价于 DELETE FROM comment WHERE id = #{id} AND comment_user_id = #{commentUserId}
}
数据访问层职责:
- 执行SQL删除操作
- 根据提供的条件(评论ID和评论者ID)定位要删除的记录
评论点赞与取消点赞功能分析
1. 点赞功能实现
@PostMapping("like")
public GraceJSONResult like(@RequestParam String commentId,@RequestParam String userId) {// 故意犯错,bigkeyredis.incrementHash(REDIS_VLOG_COMMENT_LIKED_COUNTS, commentId, 1);// // 改为每个评论单独一个 Key// String key = REDIS_VLOG_COMMENT_LIKED_COUNTS + ":" + commentId;// redis.increment(key, 1);redis.setHashValue(REDIS_USER_LIKE_COMMENT, userId + ":" + commentId, "1");// // 改为每个用户+评论组合一个 Key// String key = REDIS_USER_LIKE_COMMENT + ":" + userId + ":" + commentId;// redis.set(key, "1");// redis.hset(REDIS_USER_LIKE_COMMENT, userId, "1");return GraceJSONResult.ok();
}
功能逻辑:
- 接收评论ID和用户ID作为参数
- 在Redis中增加评论的点赞数
- 记录用户对该评论的点赞状态
- 返回成功响应
2. 取消点赞功能实现
@PostMapping("unlike")
public GraceJSONResult unlike(@RequestParam String commentId,@RequestParam String userId) {redis.decrementHash(REDIS_VLOG_COMMENT_LIKED_COUNTS, commentId, 1);redis.hdel(REDIS_USER_LIKE_COMMENT, userId + ":" + commentId);return GraceJSONResult.ok();
}
功能逻辑:
- 接收评论ID和用户ID作为参数
- 在Redis中减少评论的点赞数
- 删除用户对该评论的点赞状态记录
- 返回成功响应
3. 设计问题分析
3.1 Redis Bigkey问题
代码中注释部分明确指出"故意犯错,bigkey",这表明当前实现存在Redis性能问题:
// 故意犯错,bigkey
redis.incrementHash(REDIS_VLOG_COMMENT_LIKED_COUNTS, commentId, 1);
问题描述:
- 使用单个hash结构(
REDIS_VLOG_COMMENT_LIKED_COUNTS
)存储所有评论的点赞数 - 随着评论数增长,这个hash会变得非常大,成为"bigkey"
- 大型hash会导致Redis性能下降,甚至可能导致服务不稳定
更好的方案(代码中已注释):
String key = REDIS_VLOG_COMMENT_LIKED_COUNTS + ":" + commentId;
redis.increment(key, 1);
这种方案为每个评论创建独立的key,避免了单个hash过大的问题。
3.2 用户点赞记录存储问题
同样的bigkey问题也存在于用户点赞记录的存储中:
redis.setHashValue(REDIS_USER_LIKE_COMMENT, userId + ":" + commentId, "1");
问题描述:
- 使用单个hash(
REDIS_USER_LIKE_COMMENT
)存储所有用户的所有点赞记录 - 该hash将随用户数和评论数的增长而迅速膨胀
- 对大型hash的操作会变得越来越慢
更好的方案(代码中已注释):
String key = REDIS_USER_LIKE_COMMENT + ":" + userId + ":" + commentId;
redis.set(key, "1");
这种方案为每个用户对每个评论的点赞创建独立的key,避免了hash结构过大。
Redis BigKey 问题解析
什么是 BigKey 问题
BigKey 指的是 Redis 中存储的某个 Key 对应的 Value 过大,通常表现为:
- 一个 String 类型的 Value 过大(超过 10KB)
- 一个 Hash/List/Set/ZSet 类型的元素过多(比如 Hash 有数千个字段)
当前代码中的 BigKey 问题
1. REDIS_VLOG_COMMENT_LIKED_COUNTS
Hash 结构问题
redis.incrementHash(REDIS_VLOG_COMMENT_LIKED_COUNTS, commentId, 1);
这种方式将所有视频评论的点赞数都存储在一个 Hash 中:
- 随着评论数量增加,这个 Hash 会变得非常庞大
- 每次操作都需要加载整个 Hash 到内存
- 可能导致内存占用过高、操作延迟增加
2. REDIS_USER_LIKE_COMMENT
Hash 结构问题
redis.setHashValue(REDIS_USER_LIKE_COMMENT, userId + ":" + commentId, "1");
这种方式将所有用户对评论的点赞关系存储在一个 Hash 中:
- 用户和评论数量增加时,这个 Hash 会变得极其庞大
- 每个用户点赞一个评论就增加一个字段
- 可能导致内存爆炸性增长
改进方案(注释中的正确做法)
1. 为每个评论单独创建 Key
String key = REDIS_VLOG_COMMENT_LIKED_COUNTS + ":" + commentId;
redis.increment(key, 1);
优点:
- 每个评论的点赞数独立存储
- 避免单个 Key 过大
- 操作更高效
2. 为每个用户+评论组合创建 Key
String key = REDIS_USER_LIKE_COMMENT + ":" + userId + ":" + commentId;
redis.set(key, "1");
优点:
- 每个点赞关系独立存储
- 避免 Hash 结构无限增长
- 更容易管理和清理
BigKey 的危害
- 内存不均:导致 Redis 内存使用不平衡,可能引发内存不足
- 阻塞风险:操作大 Key 可能导致 Redis 阻塞
- 网络拥塞:传输大 Key 消耗更多带宽
- 持久化问题:AOF 重写和 RDB 保存时效率降低
- 迁移困难:集群环境下数据迁移更困难
最佳实践建议
- 避免使用单个 Key 存储大量数据
- 合理拆分数据结构,如示例中的改进方案
- 对于计数器类需求,考虑使用多个 Key 而不是单个 Hash
- 定期监控 Redis 的 Key 大小分布
- 对大 Key 进行拆分或使用其他存储方案
Redis Hash 数据结构详解
Redis Hash 的三层结构
Redis 的 Hash 数据结构实际上是三层结构:
- 外层 Key - Hash 结构的名称(如
REDIS_VLOG_COMMENT_LIKED_COUNTS
) - 内层 Field - Hash 内部的字段名(如
commentId
) - Value - 字段对应的值(点赞数)
所以完整结构是:Key → (Field → Value)
对比普通 Key-Value
结构类型 | 示例 | 说明 |
---|---|---|
普通 Key-Value | SET comment:1001:likes 5 | 直接键值对 |
Hash 结构 | HSET comment_likes comment:1001 5 | 两层映射关系 |
您的代码具体解析
redis.incrementHash(REDIS_VLOG_COMMENT_LIKED_COUNTS, commentId, 1);
对应关系:
- 外层 Key:
REDIS_VLOG_COMMENT_LIKED_COUNTS
(所有评论点赞数的容器) - Field:
commentId
(具体是哪个评论) - Value:点赞数(通过参数1实现原子递增)
为什么这样设计?
-
组织相关数据:可以把相关联的多个字段放在一个Hash中
- 例如用户信息:
user:1001
→ {name:"张三", age:25, email:"xx@xx.com"}
- 例如用户信息:
-
减少Key数量:避免为每个小数据都创建独立Key
-
原子操作:可以单独操作Hash中的某个字段
用户是否点赞评论与评论总数展示
主要是在查询接口中添加业务逻辑
@Overridepublic PagedGridResult queryVlogComments(String vlogId,String userId,Integer page,Integer pageSize) {Map<String, Object> map = new HashMap<>();map.put("vlogId", vlogId);PageHelper.startPage(page, pageSize);List<CommentVO> list = commentMapperCustom.getCommentList(map);for (CommentVO cv:list) {String commentId = cv.getCommentId();// 当前短视频的某个评论的点赞总数String countsStr = redis.getHashValue(REDIS_VLOG_COMMENT_LIKED_COUNTS, commentId);Integer counts = 0;if (StringUtils.isNotBlank(countsStr)) {counts = Integer.valueOf(countsStr);}cv.setLikeCounts(counts);// 判断当前用户是否点赞过该评论String doILike = redis.hget(REDIS_USER_LIKE_COMMENT, userId + ":" + commentId);if (StringUtils.isNotBlank(doILike) && doILike.equalsIgnoreCase("1")) {cv.setIsLike(YesOrNo.YES.type);}}return setterPagedGrid(list, page);}
1. 基础评论数据查询
Map<String, Object> map = new HashMap<>();
map.put("vlogId", vlogId);
PageHelper.startPage(page, pageSize);
List<CommentVO> list = commentMapperCustom.getCommentList(map);
- 创建参数Map,设置视频ID作为查询条件
- 使用PageHelper设置分页参数
- 调用自定义Mapper查询评论列表
- 得到包含基本评论信息的VO对象列表
2. 为每条评论添加点赞数
for (CommentVO cv:list) {String commentId = cv.getCommentId();// 当前短视频的某个评论的点赞总数String countsStr = redis.getHashValue(REDIS_VLOG_COMMENT_LIKED_COUNTS, commentId);Integer counts = 0;if (StringUtils.isNotBlank(countsStr)) {counts = Integer.valueOf(countsStr);}cv.setLikeCounts(counts);// ...
}
- 遍历评论列表
- 从Redis的hash结构中获取每条评论的点赞数
- 若点赞数不存在,则默认为0
- 将点赞数设置到评论VO对象中
3. 判断当前用户是否点赞过评论
// 判断当前用户是否点赞过该评论
String doILike = redis.hget(REDIS_USER_LIKE_COMMENT, userId + ":" + commentId);
if (StringUtils.isNotBlank(doILike) && doILike.equalsIgnoreCase("1")) {cv.setIsLike(YesOrNo.YES.type);
}
- 构造用户点赞记录的key格式为
userId:commentId
- 从Redis的hash结构中查询该记录是否存在
- 如果记录存在且值为"1",则设置评论VO对象的isLike属性为YES
4. 封装分页结果返回
return setterPagedGrid(list, page);
- 将处理后的评论列表封装成统一的分页响应对象返回