黑马点评-Redis ZSet-实现探店点赞排行榜
Redis ZSet 实现探店点赞排行榜:从图片上传到点赞 Top5,一篇讲透
本文整理自黑马点评 Redis 实战篇第 8 章「达人探店」。这一章表面上是在做探店笔记,实际包含了几个很常见的业务能力:图片上传、发布内容、查看详情、点赞/取消点赞、点赞状态回显、点赞排行榜。重点不是把接口写出来,而是理解:文件本身存在哪里,数据库存什么,Redis 又负责解决什么问题。
1. 这一章解决什么问题
黑马点评里的「达人探店」类似小红书、大众点评里的笔记功能。
用户可以:
上传探店图片
发布探店笔记
查看热门笔记
查看笔记详情
给笔记点赞或取消点赞
查看最早点赞的前 5 个用户
从后端角度看,这一章主要解决四类问题:
1. 图片文件怎么上传和访问?
2. 探店笔记怎么保存和查询?
3. 如何避免同一个用户重复点赞?
4. 如何按点赞时间做 Top5 点赞排行榜?
对应到项目代码,核心文件是:
UploadController.java 图片上传和删除
BlogController.java 探店笔记相关接口入口
BlogServiceImpl.java 发布、查看、点赞、点赞排行榜业务逻辑
Blog.java 探店笔记实体
RedisConstants.java Redis key 前缀定义
SystemConstants.java 图片上传目录配置
2. 先分清:文件本身和图片路径不是一回事
达人探店发布前,用户通常会先选择图片。这里最容易混的点是:
上传阶段传的是图片文件本身。
保存笔记时数据库存的是图片路径。
展示阶段浏览器根据图片路径再请求图片文件本身。
也就是说,数据库不会直接保存 jpg/png 的二进制内容。
项目中 tb_blog.images 存的是这样的字符串:
/imgs/blogs/7/14/abc.jpg,/imgs/blogs/4/10/def.jpg
这些是图片访问路径,不是图片文件本身。
真正的图片文件会保存在 nginx 静态资源目录里,比如:
F:\hmdp\nginx-1.18.0\html\hmdp\imgs\blogs\7\14\abc.jpg
浏览器页面里写的是:
<img src="/imgs/blogs/7/14/abc.jpg">
浏览器看到 src 后,会再次向 nginx 发请求:
GET /imgs/blogs/7/14/abc.jpg
nginx 根据配置中的静态资源根目录,把这个 URL 路径映射到磁盘文件,然后把图片文件本身返回给浏览器。
所以一句话概括:
链接只是地址,文件本身在磁盘/nginx/OSS 这类文件存储系统里。
3. 图片上传接口:UploadController
图片上传入口:
@PostMapping("/blog")
public Result uploadImage(@RequestParam("file") MultipartFile image) {
try {
String originalFilename = image.getOriginalFilename();
String fileName = createNewFileName(originalFilename);
image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
log.debug("文件上传成功,{}", fileName);
return Result.ok(fileName);
} catch (IOException e) {
throw new RuntimeException("文件上传失败", e);
}
}
这个接口对应:
POST /upload/blog
前端上传时使用 multipart/form-data,字段名叫 file。
前端大致逻辑是:
const formData = new FormData()
formData.append("file", imageFile)
axios.post("/upload/blog", formData)
后端这句:
@RequestParam("file") MultipartFile image
对应的就是前端 formData.append("file", imageFile) 里的 file。
MultipartFile 可以理解成 Spring 对上传文件的封装。它里面包含:
原始文件名
文件大小
文件类型
文件二进制内容
输入流
真正保存文件的是这句:
image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
含义是:把前端传来的图片文件本身,写入服务器磁盘。
4. 上传目录和 nginx 静态访问关系
项目中的上传根目录:
public static final String IMAGE_UPLOAD_DIR =
"F:\\hmdp\\nginx-1.18.0\\html\\hmdp\\imgs\\";
nginx 配置里一般有这样的静态资源规则:
location / {
root html/hmdp;
index index.html index.htm;
}
这里没有单独配置 location /imgs 也没关系,因为 /imgs/... 会被 location / 接住。
请求:
/imgs/blogs/7/14/abc.jpg
会被映射为:
F:\hmdp\nginx-1.18.0\html\hmdp\imgs\blogs\7\14\abc.jpg
也就是说,Java 保存文件的目录必须和 nginx 对外暴露的静态目录对得上。
如果以后换成阿里云 OSS,本质也一样:
本地 nginx 方案:文件本身存 nginx 静态目录,数据库存 /imgs/xxx.jpg
OSS 方案:文件本身存 OSS bucket,数据库存 https://xxx.oss-cn.../xxx.jpg
变的是存储位置,不变的是:
数据库存路径,不直接存图片文件本身。
5. 为什么要生成新文件名
生成文件名的方法:
private String createNewFileName(String originalFilename) {
String suffix = StrUtil.subAfter(originalFilename, ".", true);
String name = UUID.randomUUID().toString();
int hash = name.hashCode();
int d1 = hash & 0xF;
int d2 = (hash >> 4) & 0xF;
File dir = new File(SystemConstants.IMAGE_UPLOAD_DIR,
StrUtil.format("/blogs/{}/{}", d1, d2));
if (!dir.exists()) {
dir.mkdirs();
}
return StrUtil.format("/blogs/{}/{}/{}.{}", d1, d2, name, suffix);
}
这段代码做了三件事。
第一,保留文件后缀:
String suffix = StrUtil.subAfter(originalFilename, ".", true);
如果原文件叫:
food.jpg
那么后缀是:
jpg
第二,使用 UUID 生成新文件名:
String name = UUID.randomUUID().toString();
这样可以避免不同用户上传同名图片时互相覆盖。
第三,根据 hash 值分目录:
int d1 = hash & 0xF;
int d2 = (hash >> 4) & 0xF;
最终路径可能是:
/blogs/7/14/abc.jpg
/blogs/3/9/def.png
为什么要分目录?
因为如果所有图片都放在一个目录里,文件数量多了之后,目录会很臃肿。分成两级目录可以让文件更分散,管理和访问更稳定。
6. 发布探店笔记:POST /blog
图片上传完成后,前端会拿到图片路径,然后把标题、正文、店铺 id、图片路径一起提交给后端。
Controller 入口:
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
return blogService.saveBlog(blog);
}
对应请求:
POST /blog
请求体大概是:
{
"shopId": 1,
"title": "这家店很好吃",
"images": "/imgs/blogs/7/14/abc.jpg,/imgs/blogs/3/9/def.jpg",
"content": "环境不错,菜也很好吃"
}
注意这里没有让前端决定 userId。
真正保存时,后端会从登录上下文中获取当前用户:
@Override
public Result saveBlog(Blog blog) {
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
boolean isSuccess = save(blog);
if(!isSuccess){
return Result.fail("新增笔记失败!");
}
return Result.ok(blog.getId());
}
为什么 userId 要后端设置?
因为发布者身份不能相信前端传值。前端如果能传 userId,用户就可以伪造成别人发布笔记。后端从 UserHolder 里取当前登录用户,更符合安全边界。
所以发布笔记的本质是:
前端传笔记内容和图片路径
后端补充当前登录用户 id
MyBatis-Plus save(blog) 插入 tb_blog
返回新生成的 blogId
7. 查看热门探店笔记
热门笔记接口:
@GetMapping("/hot")
public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
Page<Blog> page = blogService.query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
List<Blog> records = page.getRecords();
records.forEach(blog ->{
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
blogService.isBlogLiked(blog);
});
return Result.ok(records);
}
这个接口做了两件事。
第一,按点赞数量倒序查 tb_blog:
.orderByDesc("liked")
第二,给每篇 Blog 补充额外展示字段:
name 作者昵称
icon 作者头像
isLike 当前登录用户是否点过赞
这三个字段都不是 tb_blog 表里的字段,而是返回给前端展示用的临时字段。
8. Blog 实体中的临时字段
Blog 实体中有这些字段:
@TableField(exist = false)
private String icon;
@TableField(exist = false)
private String name;
@TableField(exist = false)
private Boolean isLike;
@TableField(exist = false) 的意思是:
这个字段不属于数据库表字段,MyBatis-Plus 做 insert/update/select 映射时不要把它当作表字段。
为什么需要这些字段?
因为前端展示一篇笔记时,不只需要 tb_blog 本身的信息,还需要作者头像、作者昵称,以及当前用户是否点赞。
但是这些信息不适合直接冗余到 tb_blog 表中:
作者昵称、头像来自 tb_user
是否点赞来自 Redis
所以后端查询时临时组装,返回给前端即可。
9. 查看笔记详情
详情接口:
@GetMapping("/{id}")
public Result queryBlogById(@PathVariable("id") Long id){
return blogService.queryBlogById(id);
}
Service 实现:
@Override
public Result queryBlogById(Long id) {
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在!");
}
queryBlogUser(blog);
isBlogLiked(blog);
return Result.ok(blog);
}
这个流程可以拆成三步:
1. getById(id) 查询 tb_blog,拿到笔记主体
2. queryBlogUser(blog) 根据 blog.userId 查询作者昵称和头像
3. isBlogLiked(blog) 查询当前登录用户是否点赞
补充作者信息:
public void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
判断点赞状态:
public void isBlogLiked(Blog blog) {
UserDTO user = UserHolder.getUser();
if (user == null) {
return;
}
Long userId = UserHolder.getUser().getId();
String key = BLOG_LIKED_KEY + blog.getId();
Double score = stringRedisTemplate.opsForZSet()
.score(key, userId.toString());
blog.setIsLike(score != null);
}
这里的核心是:
详情页不是简单查一张 tb_blog,而是把 Blog 主体、作者信息、点赞状态组装成前端需要的数据。
10. 点赞功能为什么不能只让 liked + 1
最朴素的点赞代码可能是:
update().setSql("liked = liked + 1").eq("id", id).update();
这样确实能让点赞数量加一,但有一个严重问题:
后端不知道当前用户之前有没有点过赞。
如果只更新 liked,同一个用户可以一直点、一直加,点赞数会失真。
所以点赞功能至少要解决两个问题:
1. 这篇笔记一共有多少赞?
2. 当前用户是否已经点过赞?
项目的设计是:
tb_blog.liked 保存点赞总数
Redis blog:liked:{blogId} 保存给这篇笔记点过赞的用户
也就是:
MySQL 负责数量
Redis 负责关系
11. 点赞/取消点赞核心代码
Controller:
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
return blogService.likeBlog(id);
}
Service:
@Override
public Result likeBlog(Long id) {
Long userId = UserHolder.getUser().getId();
String key = BLOG_LIKED_KEY + id;
Double score = stringRedisTemplate.opsForZSet()
.score(key, userId.toString());
if (score == null) {
boolean isSuccess = update()
.setSql("liked = liked + 1")
.eq("id", id)
.update();
if (isSuccess) {
stringRedisTemplate.opsForZSet()
.add(key, userId.toString(), System.currentTimeMillis());
}
} else {
boolean isSuccess = update()
.setSql("liked = liked - 1")
.eq("id", id)
.update();
if (isSuccess) {
stringRedisTemplate.opsForZSet()
.remove(key, userId.toString());
}
}
return Result.ok();
}
点赞状态机:
用户点击点赞按钮
-> 查询 Redis:blog:liked:{blogId} 中有没有当前 userId
-> 没有:说明没点过赞
-> MySQL liked + 1
-> Redis ZADD 记录 userId 和点赞时间
-> 有:说明已经点过赞
-> MySQL liked - 1
-> Redis ZREM 删除 userId
12. 为什么用 SortedSet,而不是 Set
如果只是判断一个用户有没有点过赞,用普通 Set 就够了:
blog:liked:23 = {5, 8, 2}
它可以回答:
用户 5 有没有点赞?有。
用户 9 有没有点赞?没有。
但普通 Set 没有顺序,不能回答:
谁最早点赞?
谁最近点赞?
最早点赞的前 5 个人是谁?
第 8.4 要做点赞排行榜,所以这里用了 SortedSet,也就是 ZSet。
ZSet 的结构是:
key = blog:liked:{blogId}
member = userId
score = 点赞时间戳
例如:
blog:liked:23
member=5, score=1710000000000
member=8, score=1710000003000
member=2, score=1710000009000
它同时满足两个条件:
1. member 唯一,可以防止重复点赞
2. score 可排序,可以按点赞时间取 Top5
所以本章用 SortedSet,不是因为判断点赞必须用它,而是因为下一步的点赞排行榜需要排序能力。
13. score、add、remove 分别是什么意思
判断是否点赞:
Double score = stringRedisTemplate.opsForZSet()
.score(key, userId.toString());
对应 Redis:
ZSCORE blog:liked:23 5
意思是:查询用户 5 在 blog:liked:23 这个 ZSet 中的分数。
返回结果:
score != null:用户 5 点过赞
score == null:用户 5 没点过赞
点赞时添加:
stringRedisTemplate.opsForZSet()
.add(key, userId.toString(), System.currentTimeMillis());
对应 Redis:
ZADD blog:liked:23 1710000000000 5
含义是:用户 5 给博客 23 点赞,点赞时间是 1710000000000。
取消点赞时删除:
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
对应 Redis:
ZREM blog:liked:23 5
含义是:从博客 23 的点赞集合中移除用户 5。
14. 点赞排行榜:查询最早点赞的 Top5
排行榜接口:
@GetMapping("/likes/{id}")
public Result queryBlogLikes(@PathVariable("id") Long id) {
return blogService.queryBlogLikes(id);
}
Service:
@Override
public Result queryBlogLikes(Long id) {
String key = BLOG_LIKED_KEY + id;
Set<String> top5 = stringRedisTemplate.opsForZSet()
.range(key, 0, 4);
if (top5 == null || top5.isEmpty()) {
return Result.ok(Collections.emptyList());
}
List<Long> ids = top5.stream()
.map(Long::valueOf)
.collect(Collectors.toList());
String idStr = StrUtil.join(",", ids);
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());
return Result.ok(userDTOS);
}
这里最核心的是:
range(key, 0, 4)
它对应 Redis:
ZRANGE blog:liked:23 0 4
含义是:
按照 score 从小到大排序,取下标 0 到 4 的元素。
因为 score 是点赞时间戳,时间戳越小,说明点赞越早。
所以:
range(key, 0, 4)
取得的是:
最早点赞的前 5 个用户 id
如果要取最近点赞的前 5 个,可以使用:
reverseRange(key, 0, 4)
15. 为什么需要 ORDER BY FIELD
Redis 返回的 top5 是有顺序的。
例如:
[5, 8, 2, 9, 6]
这个顺序表示点赞先后顺序。
但是根据这些 id 去 MySQL 查询用户时:
.in("id", ids)
大致生成:
SELECT * FROM tb_user WHERE id IN (5,8,2,9,6);
问题是:MySQL 的 IN 查询不保证返回顺序。
也就是说,你传进去是:
5, 8, 2, 9, 6
数据库可能返回:
2, 5, 6, 8, 9
这样排行榜顺序就乱了。
所以代码加了:
.last("ORDER BY FIELD(id," + idStr + ")")
生成:
ORDER BY FIELD(id, 5,8,2,9,6)
含义是:按照 id 在给定列表中的位置排序。
也就是:
id=5 排第 1
id=8 排第 2
id=2 排第 3
id=9 排第 4
id=6 排第 5
所以本节有一个很重要的细节:
Redis 负责算出排行榜顺序,MySQL 回表查询用户详情时必须保持这个顺序。
16. String 转 Long 再拼 String 有意义吗
代码里有:
List<Long> ids = top5.stream()
.map(Long::valueOf)
.collect(Collectors.toList());
String idStr = StrUtil.join(",", ids);
看起来像是:
String -> Long -> String
确实有来回转换,但两个变量服务的对象不同。
ids 用于 MyBatis-Plus 查询条件:
.in("id", ids)
数据库里的 id 是数字类型,Java 实体里通常是 Long,所以用 List<Long> 更合适。
idStr 用于拼 SQL 排序片段:
ORDER BY FIELD(id, 5,8,2,9,6)
所以:
ids -> 服务 WHERE id IN (...)
idStr -> 服务 ORDER BY FIELD(id, ...)
这不是为了转换而转换,而是因为查询条件和排序片段需要的数据形态不同。
17. 为什么返回 UserDTO,而不是 User
排行榜最终返回:
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
也就是把 User 转成 UserDTO。
原因是:
User 是数据库实体,字段可能比较多。
UserDTO 是接口返回对象,只暴露前端需要的信息。
比如前端展示点赞 Top5,只需要:
用户 id
昵称
头像
不应该把手机号、密码、创建时间等内部字段都返回给前端。
这就是 DTO 的意义:
Entity 面向数据库表
DTO 面向接口传输
18. 完整流程图
下面这张图把第 8 章拆成 5 条业务链路:图片上传、发布笔记、查看详情、点赞/取消点赞、点赞排行榜。

读这张图时可以抓住三点:
1. 箭头表示数据流向:请求从前端进入后端,再分别访问 nginx、MySQL 或 Redis。
2. 菱形表示分支判断:点赞时先判断 Redis ZSet 中是否已经存在当前 userId。
3. 颜色表示职责分工:nginx/OSS 保存图片文件本身,MySQL 保存笔记主体和 liked 总数,Redis ZSet 保存点赞用户关系和点赞时间顺序。
19. 易错点总结
1. 数据库不存图片文件本身
tb_blog.images 存的是图片路径。真正的图片文件在 nginx 静态目录或 OSS 中。
2. nginx 不是猜路径,而是靠 root/alias 映射
请求 /imgs/... 时,nginx 会根据配置的静态资源根目录拼出磁盘路径。
3. 发布笔记时 userId 必须由后端设置
不能相信前端传来的用户 id,否则可能伪造身份发布笔记。
4. @TableField(exist = false) 表示临时展示字段
icon、name、isLike 不属于 tb_blog 表,只是返回给前端展示。
5. 点赞不能只改 liked 数量
还必须记录谁点过赞,否则无法防止重复点赞。
6. SortedSet 不是为了判断点赞才必须用
普通 Set 也能判断是否点赞;这里用 SortedSet 是为了按点赞时间做排行榜。
7. MySQL 的 IN 查询不保证顺序
Redis 返回的 Top5 顺序要用 ORDER BY FIELD 保住。
20. 面试怎么说
如果面试官问:探店笔记点赞功能怎么实现?
可以回答:
我们用 MySQL 的
tb_blog.liked保存点赞总数,用 Redis 的 ZSet 保存某篇笔记被哪些用户点过赞。key 设计为blog:liked:{blogId},member 是用户 id,score 是点赞时间戳。用户点赞时先用ZSCORE判断当前用户是否已经在 ZSet 中,如果不存在,就把数据库 liked 加一,并ZADD当前用户 id;如果存在,就说明已经点过赞,再次点击就是取消点赞,数据库 liked 减一,并ZREM当前用户 id。
如果问:为什么用 ZSet 不用 Set?
可以回答:
如果只是判断是否点赞,Set 就够了。但业务还要展示最早点赞的 Top5 用户,所以需要按照点赞时间排序。ZSet 同时具备 member 唯一和 score 排序能力,因此用用户 id 做 member,用点赞时间戳做 score,就能通过
ZRANGE key 0 4查询最早点赞的前 5 个用户。
如果问:为什么查用户后还要 ORDER BY FIELD?
可以回答:
Redis ZSet 返回的 userId 是按点赞时间排好序的,但 MySQL 的
WHERE id IN (...)不保证返回顺序。为了让数据库返回顺序和 Redis 排名顺序一致,需要使用ORDER BY FIELD(id, ...)强制按照 Redis 返回的 id 顺序排序。
21. 总结
第 8 章的主线可以压缩成一句话:
达人探店模块中,图片文件本身存 nginx/OSS,数据库保存笔记内容和图片路径;点赞总数存在 MySQL,点赞用户关系和点赞时间存在 Redis ZSet,最终用 ZSet 的排序能力实现点赞 Top5 排行榜。
这一章真正要掌握的不是某个 API,而是不同存储各自负责什么:
nginx/OSS:保存图片文件本身
MySQL:保存笔记主体和点赞总数
Redis ZSet:保存点赞用户关系和点赞时间顺序
DTO/临时字段:组装前端需要的展示数据
D` 保住。
20. 面试怎么说
如果面试官问:探店笔记点赞功能怎么实现?
可以回答:
我们用 MySQL 的
tb_blog.liked保存点赞总数,用 Redis 的 ZSet 保存某篇笔记被哪些用户点过赞。key 设计为blog:liked:{blogId},member 是用户 id,score 是点赞时间戳。用户点赞时先用ZSCORE判断当前用户是否已经在 ZSet 中,如果不存在,就把数据库 liked 加一,并ZADD当前用户 id;如果存在,就说明已经点过赞,再次点击就是取消点赞,数据库 liked 减一,并ZREM当前用户 id。
如果问:为什么用 ZSet 不用 Set?
可以回答:
如果只是判断是否点赞,Set 就够了。但业务还要展示最早点赞的 Top5 用户,所以需要按照点赞时间排序。ZSet 同时具备 member 唯一和 score 排序能力,因此用用户 id 做 member,用点赞时间戳做 score,就能通过
ZRANGE key 0 4查询最早点赞的前 5 个用户。
如果问:为什么查用户后还要 ORDER BY FIELD?
可以回答:
Redis ZSet 返回的 userId 是按点赞时间排好序的,但 MySQL 的
WHERE id IN (...)不保证返回顺序。为了让数据库返回顺序和 Redis 排名顺序一致,需要使用ORDER BY FIELD(id, ...)强制按照 Redis 返回的 id 顺序排序。
21. 总结
第 8 章的主线可以压缩成一句话:
达人探店模块中,图片文件本身存 nginx/OSS,数据库保存笔记内容和图片路径;点赞总数存在 MySQL,点赞用户关系和点赞时间存在 Redis ZSet,最终用 ZSet 的排序能力实现点赞 Top5 排行榜。
这一章真正要掌握的不是某个 API,而是不同存储各自负责什么:
nginx/OSS:保存图片文件本身
MySQL:保存笔记主体和点赞总数
Redis ZSet:保存点赞用户关系和点赞时间顺序
DTO/临时字段:组装前端需要的展示数据
更多推荐

所有评论(0)