黑马点评达人探店板块 ------ 点赞功能完整实现详解
本文基于黑马点评项目,完整梳理达人探店模块的点赞功能实现思路,涵盖:一人一赞、点赞状态判断、Redis 数据结构选型、点赞排行榜设计等核心知识点,适合正在学习该项目或想深入理解 Redis 实战应用的同学。
达人探店板块的点赞功能,初看不复杂,但拆开来以后,会发现里面包含了不少高频业务场景中的典型设计思路。
具体来说,它需要解决以下几个核心问题:
- 如何实现一人一赞?
- 如何判断当前用户是否已点过赞?
- 如何维护笔记的点赞总数?
- 如何实现点赞排行榜(最早点赞的前 5 位用户)?
- 为什么要同时使用 MySQL + Redis,各自负责什么?
本文就结合代码,把这一整套逻辑完整梳理一遍。
一、点赞功能的需求拆解
在达人探店模块里,每一篇探店笔记对应一个 Blog 实体。
点赞功能至少要满足以下几点:
- 同一个用户对同一篇笔记只能点一次赞
- 用户再次点击,应当取消点赞
- 查询笔记详情或列表时,需要告知前端:当前用户是否已点赞(用于高亮点赞按钮)
- 需要展示这篇笔记最早点赞的前 5 个用户
- 需要支持按点赞数量排序,查询热门笔记
看到这里就能意识到:点赞不只是给一个字段 liked + 1 这么简单,还需要维护用户与笔记之间的点赞关系。
二、为什么不能只用数据库实现
一开始很容易写出这样的代码:
less
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
blogService.update()
.setSql("liked = liked + 1")
.eq("id", id)
.update();
return Result.ok();
}
这种写法问题很明显:
| 问题 | 原因 |
|---|---|
| 用户可以无限重复点赞 | 没有记录「谁点的赞」 |
| 无法判断用户是否已点赞 | 只存了总数,无法查关系 |
| 无法取消点赞 | 同上 |
| 无法获取点赞用户列表 | 同上 |
根本原因在于:数据库里的 liked 字段只能表示总点赞数 ,但无法表示是谁点的赞。
所以,点赞功能必须额外维护一份用户点赞关系数据。
三、核心设计:MySQL + Redis 分工协作
黑马点评的设计思路是让 MySQL 和 Redis 各司其职。
MySQL 负责什么?
tb_blog 表中的 liked 字段负责保存笔记的总点赞数,主要承担两个职责:
- 持久化存储点赞总数,数据不会因 Redis 重启丢失
- 支持热门排序 ,例如:
ORDER BY liked DESC
Redis 负责什么?
Redis 用来保存某用户是否给某篇笔记点过赞这层关系,优势在于:
- 判断是否点赞的速度极快(O(log n))
- 点赞 / 取消点赞操作性能高
- 可扩展出按时间排序的点赞排行榜
整体架构示意

四、Blog 实体为什么需要 isLike 字段
前端展示笔记时,需要根据当前登录用户是否点赞来高亮点赞按钮。
因此后端返回的数据里,除了笔记本身的信息,还要附带一个字段告诉前端:当前用户有没有点过赞。
由于这个字段不来自数据库,需要用 @TableField(exist = false) 标注:
kotlin
@Data
@TableName("tb_blog")
public class Blog {
private Long id;
private Long userId;
private Integer liked; // 点赞总数(来自数据库)
@TableField(exist = false)
private Boolean isLike; // 当前用户是否点赞(后端实时计算)
}
isLike的值在查询笔记列表或详情时,通过查询 Redis 动态填充,不持久化到数据库。
五、Redis 数据结构选型:Set → ZSet
阶段一:用 Set 实现「一人一赞」
如果只需要判断用户是否点赞,用 Redis 的 Set 完全够用:
| 操作 | Redis 命令 |
|---|---|
| 点赞 | SADD key userId |
| 取消点赞 | SREM key userId |
| 判断是否点赞 | SISMEMBER key userId |
例如,key 为 blog:liked:12,value 为 {1, 5, 8, 10},表示 id 为 1、5、8、10 的用户给 id 为 12 的 Blog 点过赞。
优点 :实现简单,天然去重,满足一人一赞的核心需求。
阶段二:升级为 ZSet,支持排行榜
Set 有一个致命缺陷:无序 ,无法记录谁先点赞、谁后点赞。
但点赞排行榜通常需要展示最早点赞的前 5 个用户 ,所以需要升级为 ZSet(有序集合) 。
ZSet 的设计方案如下:
makefile
key: blog:liked:{blogId}
member: userId(点赞用户 ID)
score: System.currentTimeMillis()(点赞时间戳)
这样就同时具备了两个能力:
- 判断用户是否点赞 :通过
ZSCORE key userId判断,返回 null 则未点赞 - 按时间排序查询 :通过
ZRANGE key 0 4取出 score 最小(最早点赞)的前 5 个用户
六、点赞 / 取消点赞核心代码解析
scss
public Result likeBlog(Long id) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
if (user == null) {
// 用户未登录
return Result.fail("未登录...");
}
// 2.判断当前登录用户是否已经点赞
String key = BLOG_LIKED_KEY + id;
Double score = stringRedisTemplate.opsForZSet().score(key, user.getId().toString());
// 3.如果未点赞,可以点赞
if (score == null) {
// 3.1.数据库点赞数 + 1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 3.2.保存用户数据到Redis的set集合 ZADD key value score
if (isSuccess) {
stringRedisTemplate.opsForZSet()
.add(key, user.getId().toString(),
System.currentTimeMillis());
}
// 4.如果已点赞,取消点赞
} else {
// 4.1.数据库点赞数 - 1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
// 4.2.把用户从Redis的set集合移除
if (isSuccess) {
stringRedisTemplate.opsForZSet().remove(key, user.getId().toString());
}
}
return Result.ok();
}
逐步拆解:
1. 获取当前登录用户
UserDTO user = UserHolder.getUser();
点赞是登录用户行为,必须先确认当前用户身份。
2. 用 score() 判断是否点过赞
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
score != null:用户存在于 ZSet 中,说明已点赞score == null:用户不在 ZSet 中,说明未点赞
3. 先操作 MySQL,成功后再更新 Redis
注意代码中 if (isSuccess) 的判断:只有数据库操作成功,才会去更新 Redis,避免数据不一致。
七、点赞排行榜实现代码解析
scss
public Result queryBlogLikes(Long id) {
String key = BLOG_LIKED_KEY + id;
// 1.查询top5的点赞用户 zrange key 0 4
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (top5 == null || top5.isEmpty()) {
return Result.ok(Collections.emptyList());
}
// 2.解析出其中的用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
String idStr = StrUtil.join(",", ids);
// 3.根据用户id查询用户 WHERE id IN ( 5 , 1 ) ORDER BY FIELD(id, 5, 1)
List<UserDTO> userDTOS = userService.query()
.in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list()
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
// 4.返回
return Result.ok(userDTOS);
}
为什么必须用 ORDER BY FIELD?
这是一个很容易被忽略的细节。
Redis 中 ZRANGE 返回的结果是有序的 ,例如:[5, 2, 9, 1, 7](按点赞时间从早到晚排列)。
但如果直接用 listByIds(ids) 查 MySQL不能保证顺序
MySQL 返回的结果顺序由其内部执行计划决定 ,通常会变为 [1, 2, 5, 7, 9](按主键升序)。
这样一来,前端展示的「最早点赞用户」顺序就会出错。
正确做法是使用 MySQL 的 FIELD() 函数,强制按照指定 ID 顺序返回结果:
vbnet
SELECT * FROM tb_user WHERE id IN (5, 2, 9, 1, 7)
ORDER BY FIELD(id, 5, 2, 9, 1, 7);
这样,数据库的返回顺序就与 Redis 的查询顺序完全一致了。
八、整体流程总结


总结
| 知识点 | 要点 |
|---|---|
| 为什么不能只用 MySQL | liked 字段只存总数,无法记录点赞关系 |
| MySQL 的职责 | 持久化总点赞数,支持热门排序 |
| Redis 的职责 | 维护用户点赞关系,支持快速判断与排行榜 |
| 为什么从 Set 升级到 ZSet | Set 无序,无法支持按时间排序的点赞排行榜 |
| ZSet 的 score 存什么 | 点赞时间戳,用于排序 |
| 判断是否点赞的方法 | ZSCORE 返回 null 即未点赞 |
| ORDER BY FIELD 的作用 | 保证 MySQL 返回顺序与 Redis 查询顺序一致 |
这套设计的核心思想是:让 Redis 承担高频的关系查询与写入,MySQL 只负责数据的持久化。这种 MySQL + Redis 组合的分工思路,在实际业务开发中非常常见。