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) 表示临时展示字段

iconnameisLike 不属于 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/临时字段:组装前端需要的展示数据
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐