day42 达人探店博客与点赞功能

day42 达人探店博客与点赞功能

一、达人探店功能概述

达人探店是黑马点评项目中的核心功能之一,用户可以发布探店笔记(博客),其他用户可以浏览、点赞、评论。本次主要实现以下内容:

  • 探店笔记的查询(热门笔记分页、根据 ID 查询)
  • 点赞功能(同一用户只能点赞一次,再次点击取消点赞)
  • 点赞排行榜(展示最早点赞的 Top5 用户)

涉及的数据表:

  • tb_blog:探店笔记表,包含标题、内容、图片、点赞数、评论数等字段
  • tb_user:用户表,用于关联笔记作者信息

二、查询笔记接口

2.1 Blog 实体类与非表字段

Blog 实体类对应数据库表 tb_blog,但其中有几个字段仅用于前端展示,数据库中并不存在,需要用 @TableField(exist = false) 标记。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@TableName("tb_blog")
public class Blog implements Serializable {

@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long shopId; // 商户id
private Long userId; // 用户id

@TableField(exist = false)
private String icon; // 用户头像(非数据库字段)

@TableField(exist = false)
private String name; // 用户姓名(非数据库字段)

@TableField(exist = false)
private Boolean isLike; // 当前用户是否点赞(非数据库字段)

private String title; // 标题
private String images; // 探店照片,多张以","隔开
private String content; // 文字描述
private Integer liked; // 点赞数量
private Integer comments; // 评论数量
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

@TableField(exist = false) 的作用:

  • 这是 MyBatis-Plus 提供的注解,用于标记实体类中的字段在数据库表中不存在
  • MyBatis-Plus 在生成 INSERT、UPDATE、SELECT 等 SQL 时会自动忽略该字段
  • 适用于仅用于业务展示、需要在查询后手动填充的字段

2.2 根据 ID 查询笔记

查询单条笔记时,除了从数据库获取 blog 本身,还需要关联查询作者信息(昵称、头像),并判断当前用户是否点赞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public Result quereBlogById(Long id) {
Blog blog = getById(id);
if (blog == null) {
return Result.fail("博客不存在");
}
// 查询作者信息,填充 name 和 icon
queueBlogUser(blog);
// 判断当前用户是否点赞,填充 isLike
setIsLike(blog);
return Result.ok(blog);
}

private void queueBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}

2.3 热门笔记分页查询

按点赞数倒序排列,分页返回热门笔记。每条笔记同样需要填充作者信息和点赞状态。

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public Result queryHotBlog(Integer current) {
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
List<Blog> records = page.getRecords();
records.forEach(blog -> {
queueBlogUser(blog);
setIsLike(blog);
});
return Result.ok(records);
}

三、点赞功能

3.1 需求分析

  • 同一个用户对同一篇笔记只能点赞一次,再次点击则取消点赞
  • 前端需要知道当前用户是否已经点赞(点赞按钮高亮显示)
  • 点赞数需要实时更新

3.2 基于 Redis Set 的实现(初始版本)

使用 Redis 的 Set 集合存储每篇笔记的点赞用户 ID,利用 Set 的元素唯一性保证一人一赞。

核心逻辑:

1
2
3
4
5
用户点击点赞
↓
查 Redis Set 中是否存在该用户 ID
├─ 不存在 → 数据库 liked + 1 → 将用户加入 Redis Set
└─ 已存在 → 数据库 liked - 1 → 从 Redis Set 中移除用户

判断是否点赞:

查询笔记时,从 Redis Set 中判断当前用户是否存在,将结果写入 isLike 字段返回给前端。

1
2
3
4
5
6
7
8
9
10
11
private void setIsLike(Blog blog) {
UserDTO user = UserHolder.getUser();
if (user == null) {
return;
}
Long userId = user.getId();
String key = "blog:like:" + blog.getId();
// Set 方式:判断是否为成员
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
blog.setIsLike(BooleanUtil.isTrue(isMember));
}

Set 的缺点:元素无序,无法实现按点赞时间排序的排行榜功能。

3.3 基于 Redis ZSet 的实现(升级版)

为了实现点赞排行榜,将 Set 替换为 Sorted Set(ZSet),用时间戳作为 score,既保留了元素唯一性,又能按时间排序。

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public Result likeBlog(Long id) {
Long userId = UserHolder.getUser().getId();
String key = "blog:like:" + id;

// 判断是否已点赞:通过 score 是否为 null 判断
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());

if (score == null) {
// 未点赞 → 点赞
update().setSql("liked = liked + 1").eq("id", id).update();
stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
} else {
// 已点赞 → 取消点赞
update().setSql("liked = liked - 1").eq("id", id).update();
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
return Result.ok();
}

ZSet 常用操作:

操作 命令 / 方法 说明
添加 ZADD key score member / opsForZSet().add() 添加元素,score 为排序依据
删除 ZREM key member / opsForZSet().remove() 移除指定元素
判断存在 ZSCORE key member / opsForZSet().score() 返回分数,null 表示不存在
按排名范围查 ZRANGE key start stop / opsForZSet().range() 按 score 从小到大返回

四、点赞排行榜

4.1 需求与实现思路

在笔记详情页展示最早点赞的 Top5 用户,形成点赞排行榜。

由于 ZSet 天然支持按 score 排序,使用 ZRANGE key 0 4 即可取出前 5 名(按时间戳从小到大,即最早点赞的 5 人)。

4.2 关键代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public Result quereBlogLikes(Long id) {
String key = "blog:like:" + id;
// 取 ZSet 中前 5 名(score 最小的 5 个,即最早点赞)
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (top5 == null || top5.isEmpty()) {
return Result.ok(Collections.emptyList());
}
// 转为 Long 类型的用户 ID 列表
List<Long> list = top5.stream()
.map(Long::valueOf)
.collect(Collectors.toList());
// 拼接成逗号分隔字符串,用于 ORDER BY FIELD
String listStr = StrUtil.join(",", list);
// 根据用户 ID 列表查询,并保持 ZSet 中的顺序
List<UserDTO> userDTOS = userService
.query().in("id", list)
.last("order by FIELD(id, " + listStr + ")")
.list()
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(userDTOS);
}

4.3 MySQL 的 FIELD 函数保证排序

WHERE id IN (5, 1, 3) 查询出来的结果不会按 IN 列表中的顺序返回,MySQL 会按主键或索引的顺序返回。如果需要保持指定顺序,可以使用 ORDER BY FIELD(id, 5, 1, 3)。

1
2
3
SELECT * FROM tb_user
WHERE id IN (5, 1, 3)
ORDER BY FIELD(id, 5, 1, 3);

FIELD(id, 5, 1, 3) 的含义是:按照 id 在列表中的位置排序,id=5 排第 1,id=1 排第 2,id=3 排第 3。

注意:MyBatis-Plus 的 listByIds() 方法内部也是用 IN 查询,不保证结果顺序与入参一致,需要手动用 FIELD 排序。


五、点赞功能的深度思考

5.1 线程安全问题

当前点赞逻辑存在经典的 check-then-act 竞态条件:

1
判断是否点赞(查 Redis) → 操作数据库 → 更新 Redis

在高并发下,两个线程可能同时判断为”未点赞”,然后都执行点赞操作,导致一人多赞。

解决方案:

  1. Redis 原子操作:将”判断 + 添加”用一条原子命令完成。对于 Set 可以用 SADD 的返回值判断(返回 1 表示新增成功,返回 0 表示已存在);对于 ZSet 可以用 ZADD 的 NX 选项(仅在不存在时添加)。整个点赞逻辑可以用 Lua 脚本 保证原子性。
  2. 数据库唯一索引:给 (user_id, blog_id) 建联合唯一索引,数据库层面兜底,重复插入直接报错。
  3. 分布式锁:以”用户 ID + 笔记 ID”为锁粒度,让同一用户对同一笔记的请求串行执行。但对于点赞这种高频操作,锁的开销较大,优先考虑 Redis 原子操作。

5.2 双向存储问题

当前只存储了”某篇笔记被哪些用户赞过”(blog:like:{blogId} → 用户集合),方向是 blog → users。

如果业务需要展示用户点赞过哪些笔记(用户的点赞历史),遍历所有笔记不现实,必须增加反向存储。

建议双向存储:

方向 Redis 结构 Key Value / Score 用途
笔记 → 用户 ZSet blog:like:{blogId} userId / 点赞时间戳 判断是否点赞、点赞排行榜
用户 → 笔记 ZSet user:like:{userId} blogId / 点赞时间戳 用户点赞历史、按时间分页

点赞和取消点赞时,两边要同时更新,建议用 Lua 脚本保证原子性,避免只更了一边导致数据不一致。

5.3 高并发下的数据库压力

当前每次点赞都会同步修改数据库(liked + 1 或 liked - 1),并发很高时数据库压力会很大。有两种优化思路:

方案一:消息队列异步写数据库

  • 将”更新数据库”的操作放入消息队列,后台线程异步消费执行
  • 优点:削峰填谷,数据库压力可控
  • 注意事项:
    • 同一用户对同一笔记的消息需要按顺序消费(可用笔记 ID 做分区路由)
    • 消费端需要保证幂等性,防止重复消费
    • 数据存在短暂不一致(最终一致)

方案二:Redis 维护点赞数 + 定时/定量同步

  • Redis 中维护点赞计数(如用 Hash 或 String),点赞操作全部在 Redis 完成
  • 定时(如每 5 分钟)或定量(如每 100 次)将 Redis 中的数据同步到数据库
  • 优点:性能最好,读写都走 Redis
  • 注意事项:
    • 判断点赞、更新计数、修改集合需要用 Lua 脚本保证原子性
    • Redis 需要开启 AOF 持久化,降低宕机数据丢失风险
    • 数据库和 Redis 之间存在延迟,属于最终一致

对于点赞、计数这类读多写多、允许短暂不一致的场景,方案二是行业内的主流做法。


今日总结

模块 核心要点
博客查询 @TableField(exist = false) 标记非数据库字段,查询后手动填充作者信息
点赞功能 Redis Set/ZSet 存储点赞用户,判断存在性实现一人一赞,数据库维护点赞数
点赞排行榜 ZSet 存用户 ID + 时间戳,ZRANGE 取 TopN,ORDER BY FIELD() 保证 SQL 查询顺序
FIELD 函数 MySQL FIELD() 可让结果按指定顺序排列,解决 IN 查询顺序不一致问题
线程安全 check-then-act 有竞态条件;可用 Lua 脚本原子操作、唯一索引、分布式锁解决
双向存储 只存 blog→users 不够;需同时存 user→blogs,支持用户点赞历史查询
高并发优化 消息队列异步写库,或 Redis 主存 + 定时同步;后者是点赞场景的主流方案