☰
【黑马点评 | 第十篇】点赞功能与点赞排行榜的实现
2026/9/30 7:19:50 网站建设 项目流程

这次实现的是探店笔记的点赞功能:同一用户点击一次点赞,再次点击取消;打开笔记列表或详情时,点赞按钮要显示当前用户的状态;详情页还要展示最早点赞的 5 位用户。前两个需求只需要判断用户是否在集合中,排行榜则要求记录点赞的先后顺序,因此实现会从 Redis Set 过渡到 ZSet。

一、先完成点赞与取消点赞

1.1 点赞数和点赞状态分别保存什么

tb_blog 表中的 liked 字段记录一篇笔记的点赞总数。仅凭这个数字,无法判断某个用户有没有点过赞。因此还需要为每篇笔记保存点赞用户集合:key 使用 blog:liked: 加笔记 ID,集合成员使用用户 ID。

例如,blog:liked:42 对应 42 号笔记。用户 5 的 ID 在集合中,表示他已经点赞;不在集合中,表示可以点赞。Set 的成员不重复,适合做第一版的点赞状态判断。

Blog 中的 isLike 字段只用于接口响应,让前端决定点赞按钮是否点亮,不对应 tb_blog 的数据库列:

/** * 是否点赞过了 */@TableField(exist=false)privateBooleanisLike;

1.2 点击按钮时切换点赞状态

点赞接口是 PUT /blog/like/{id},路径中的 id 是笔记 ID。Controller 把请求交给点赞业务方法:

@PutMapping("/like/{id}")publicResultlikeBlog(@PathVariable("id")Longid){// 修改点赞数量returnblogService.likeBlog(id);}

使用 Set 时,业务方法先判断当前用户是不是集合成员。未点赞就增加数据库计数并加入集合;已经点赞就减少计数并移出集合:

@OverridepublicResultlikeBlog(Longid){// 1.获取登录用户LonguserId=UserHolder.getUser().getId();// 2.判断当前登录用户是否已经点赞Stringkey=RedisConstants.BLOG_LIKED_KEY+id;BooleanisMember=stringRedisTemplate.opsForSet().isMember(key,userId.toString());if(BooleanUtil.isFalse(isMember)){//3.如果未点赞,可以点赞//3.1 数据库点赞数+1booleanisSuccess=update().setSql("liked = liked + 1").eq("id",id).update();//3.2 保存用户到Redis的set集合if(isSuccess){stringRedisTemplate.opsForSet().add(key,userId.toString());}}else{//4.如果已点赞,取消点赞//4.1 数据库点赞数-1booleanisSuccess=update().setSql("liked = liked - 1").eq("id",id).update();//4.2 把用户从Redis的set集合移除if(isSuccess){stringRedisTemplate.opsForSet().remove(key,userId.toString());}}returnResult.ok();}

这里的 userId 作为成员保存,key 中的 id 则区分不同笔记。数据库更新成功后才修改 Redis 集合,避免数据库没有更新成功时仍改变点赞状态。第一次点击使 liked 加 1,第二次点击使 liked 减 1,同时移除用户 ID。

接口返回成功并不等于按钮会自动点亮。查询笔记时还要根据当前登录用户补出 isLike:用户 ID 在集合中时为 true,否则为 false。热门笔记列表查询会逐篇补充作者信息和点赞状态;笔记详情查询也需要执行相同的状态判断。这样用户刷新页面后,按钮仍能显示正确状态。

二、为什么排行榜要换成 ZSet

详情页要显示“最早点赞的 5 位用户”。Set 只能回答“用户是否点赞”,不能按点赞时间取出成员。Redis ZSet 在成员之外增加了 score,集合会按照 score 排序,正好可以保存点赞先后顺序。

对一篇笔记,可以把 ZSet 的三个部分理解为:

部分存放的内容作用
keyblog:liked: 加笔记 ID区分每篇笔记的点赞集合
member用户 ID判断用户是否点赞,并保证同一用户在集合中只有一个成员
score点赞时的毫秒时间戳决定用户在排行榜中的位置

假设用户 5 比用户 9 先点赞,那么用户 5 的时间戳更小。按 score 从小到大读取 ZSet,得到的就是先点赞的用户。取消点赞时移除这个 member;取消后再点赞,会以新的时间戳重新加入,顺序也会随之改变。

与这条业务链路对应的 Redis 命令有四个。ZSCORE 根据用户 ID 取 score,返回空值表示用户不在集合中;ZADD 写入用户 ID 和点赞时间;ZREM 在取消点赞时删除用户;ZRANGE 按 score 从小到大读取指定范围。例如 ZRANGE blog:liked:42 0 4 中的 0 和 4 是从零开始的下标,返回最早点赞的前 5 位,而不是“点赞数最高的 5 位”。

ZSet 的 member 仍然唯一,但 ZADD 对已有 member 会更新它的 score。因此业务代码先用 score 判断是否已经点赞,只在未点赞时执行新增。同一毫秒内若有多人点赞,score 可能相同,Redis 会再按 member 的字典序决定顺序;毫秒时间戳不能保证并列时的真实先后顺序。

三、用 ZSet 改造点赞状态

Set 改为 ZSet 后,点赞入口和数据库计数更新方式不变,改变的是“是否点赞”的判断,以及 Redis 中保存用户的方式:

@OverridepublicResultlikeBlog(Longid){// 1.获取登录用户LonguserId=UserHolder.getUser().getId();// 2.判断当前登录用户是否已经点赞Stringkey=RedisConstants.BLOG_LIKED_KEY+id;Doublescore=stringRedisTemplate.opsForZSet().score(key,userId.toString());if(score==null){//3.如果未点赞,可以点赞//3.1 数据库点赞数+1booleanisSuccess=update().setSql("liked = liked + 1").eq("id",id).update();//3.2 保存用户到Redis的zset集合if(isSuccess){stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());}}else{//4.如果已点赞,取消点赞//4.1 数据库点赞数-1booleanisSuccess=update().setSql("liked = liked - 1").eq("id",id).update();//4.2 把用户从Redis的zset集合移除if(isSuccess){stringRedisTemplate.opsForZSet().remove(key,userId.toString());}}returnResult.ok();}

score() 返回 null,说明该用户没有点赞记录;返回时间戳,说明他已经点赞。新增时必须把 System.currentTimeMillis() 作为 score,排行榜才能按点赞时间排序。若误把笔记 ID 作为 score,同一篇笔记里的所有点赞用户分数都会相同,ZRANGE 就无法得到时间顺序。

读取笔记时,isLike 也必须从 ZSet 查询。如果写入已经改成 ZSet,读取仍调用 Set 的 isMember,Redis 会因 key 的数据类型不匹配而报错:

privatevoidisBlogLiked(Blogblog){//1.获取登录用户UserDTOuser=UserHolder.getUser();if(user==null){// 用户未登录,无需查询是否点赞return;}LonguserId=user.getId();//2.判断当前登录用户是否已经点赞Stringkey=RedisConstants.BLOG_LIKED_KEY+blog.getId();Doublescore=stringRedisTemplate.opsForZSet().score(key,userId.toString());blog.setIsLike(score!=null);}

未登录时直接返回,避免从空的用户信息中取 ID。对已登录用户,只关心 score 是否存在,不需要把时间戳返回给前端。

热门笔记接口 GET /blog/hot 会先按 tb_blog.liked 从高到低分页查询,再为每篇笔记补充作者和当前用户的 isLike:

@OverridepublicResultqueryHotBlog(Integercurrent){// 根据用户查询Page<Blog>page=query().orderByDesc("liked").page(newPage<>(current,SystemConstants.MAX_PAGE_SIZE));// 获取当前页数据List<Blog>records=page.getRecords();// 查询用户records.forEach(blog->{queryBlogbyuser(blog);isBlogLiked(blog);});returnResult.ok(records);}

这里按 liked 排序的是笔记列表,依据是每篇笔记的点赞总数。ZSet 按时间排序的是某篇笔记的点赞用户。两种排序服务于不同页面需求。

四、查询最早点赞的 5 位用户

4.1 从 ZSet 取出用户 ID

点赞用户接口是 GET /blog/likes/{id}。id 仍是笔记 ID,Controller 将它传给业务方法:

@GetMapping("/likes/{id}")publicResultqueryBlogLikes(@PathVariable("id")Longid){returnblogService.queryBlogLikes(id);}

先根据 blog:liked: 加笔记 ID 找到 ZSet,再取下标 0 到 4 的成员。如果集合为空,直接返回空列表;有数据时,得到的是按点赞时间升序排列的用户 ID。

4.2 查询用户资料后保持排行榜顺序

Redis 中只保存用户 ID,展示头像和昵称还需要查用户表。数据库的 in 查询并不保证结果顺序等于传入 ID 的顺序,因此查询后要显式按照 Redis 返回的 ID 序列排序:

@OverridepublicResultqueryBlogLikes(Longid){Stringkey=RedisConstants.BLOG_LIKED_KEY+id;// 1.查询top5的点赞用户 zrange key 0 4Set<String>top5=stringRedisTemplate.opsForZSet().range(key,0,4);if(top5==null||top5.isEmpty()){returnResult.ok(Collections.emptyList());}// 2.解析出其中的用户idList<Long>ids=top5.stream().map(Long::valueOf).collect(Collectors.toList());StringidStr=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.返回returnResult.ok(userDTOS);}

range(key, 0, 4) 最多取出 5 个 member;这里的 Set 接口承载的是有序的查询结果,转成 ids 时需要保留其迭代顺序。MySQL 的 FIELD(id, …) 按 ids 的先后排列查询结果,避免用户资料查出来后打乱排行榜。最后转换为 UserDTO,只向页面返回展示所需的用户信息。

五、验证时看清两条排序规则

将 score 改为点赞时间后,可以让两个账号依次给同一篇笔记点赞:tb_blog.liked 应增加 2,该笔记对应的 ZSet 应有两个用户 ID,较早点赞的用户排在前面。再次点击其中一个账号的点赞按钮后,liked 应减 1,该用户也应从 ZSet 和前五列表中消失。刷新热门列表和详情页,还应分别检查 isLike 是否与当前登录账号的状态一致。

排行榜顺序取决于 score。当前项目的点赞方法把笔记 ID 传给 ZSet 的 add 方法作为 score;同一篇笔记的用户因此得到相同分数,无法按点赞时间排名。改为 System.currentTimeMillis() 后,新点赞才会得到正确时间分数;已有错误 score 不会自动变成历史点赞时间。如果没有保存历史点赞时间,就无法准确恢复这些用户原来的先后顺序。

若 Redis 中还保留第一版的 Set key,直接对同名 key 执行 ZSet 命令会出现数据类型错误。切换时需要处理旧点赞数据;旧 Set 没有记录点赞时间,无法仅靠它还原历史先后顺序。

这套实现通过“先查 Redis 状态、再更新数据库、最后写 Redis”处理普通点击流程。多个请求同时给同一用户、同一笔记点赞时,可能同时读到未点赞状态;数据库计数与 Redis 更新也不是一个原子事务。因此它适合说明点赞与时间排行榜的基本实现,严格的一人一次和跨存储一致性还需要额外的并发控制。

总结

点赞按钮需要“用户是否在集合中”,最早点赞的 5 位用户还需要“何时加入集合”。Set 能完成前一项;ZSet 用用户 ID 做 member、点赞时间做 score,同时支持状态判断和时间排序。取到前五个用户 ID 后,还要让数据库查询结果保持 Redis 的顺序,排行榜才能正确显示。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询