Redis从入门到入土 --- 黑马点评点赞功能实现详解

黑马点评达人探店板块 ------ 点赞功能完整实现详解

本文基于黑马点评项目,完整梳理达人探店模块的点赞功能实现思路,涵盖:一人一赞、点赞状态判断、Redis 数据结构选型、点赞排行榜设计等核心知识点,适合正在学习该项目或想深入理解 Redis 实战应用的同学。


达人探店板块的点赞功能,初看不复杂,但拆开来以后,会发现里面包含了不少高频业务场景中的典型设计思路。

具体来说,它需要解决以下几个核心问题:

  • 如何实现一人一赞
  • 如何判断当前用户是否已点过赞?
  • 如何维护笔记的点赞总数?
  • 如何实现点赞排行榜(最早点赞的前 5 位用户)?
  • 为什么要同时使用 MySQL + Redis,各自负责什么?

本文就结合代码,把这一整套逻辑完整梳理一遍。


一、点赞功能的需求拆解

在达人探店模块里,每一篇探店笔记对应一个 Blog 实体。

点赞功能至少要满足以下几点:

  1. 同一个用户对同一篇笔记只能点一次赞
  2. 用户再次点击,应当取消点赞
  3. 查询笔记详情或列表时,需要告知前端:当前用户是否已点赞(用于高亮点赞按钮)
  4. 需要展示这篇笔记最早点赞的前 5 个用户
  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()(点赞时间戳)

这样就同时具备了两个能力:

  1. 判断用户是否点赞 :通过 ZSCORE key userId 判断,返回 null 则未点赞
  2. 按时间排序查询 :通过 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 组合的分工思路,在实际业务开发中非常常见。

相关推荐
唯一世1 小时前
Open Feign最佳实践
java·spring cloud
小江的记录本1 小时前
【MacOS】MacBook Pro 键盘全解析 + macOS 快捷键大全
java·经验分享·学习·macos·计算机外设·键盘·敏捷开发
Java编程爱好者1 小时前
Java8 HashMap高低位拆分扩容,核心逻辑一次性说清
后端
淘源码d2 小时前
基于Spring Boot + Vue的诊所管理系统(源码)全栈开发指南
java·vue.js·spring boot·后端·源码·门诊系统·诊所系统
李少兄2 小时前
IntelliJ IDEA 中撤销 Commit
java·elasticsearch·intellij-idea
iPadiPhone2 小时前
Java 反射机制底层原理、面试陷阱与实战指南
java·开发语言·后端·面试
iPadiPhone2 小时前
Java SPI 机制全链路深度解析与面试通关指南
java·后端·面试
神奇小汤圆2 小时前
Spring Boot中获取真实客户端IP的终极方案,99%的人都没做对!
后端
问道飞鱼2 小时前
【大模型学习】LangChain 入门指南:基本概念、核心功能与简单示例
java·学习·langchain