day42 达人探店博客与点赞功能
一、达人探店功能概述
达人探店是黑马点评项目中的核心功能之一,用户可以发布探店笔记(博客),其他用户可以浏览、点赞、评论。本次主要实现以下内容:
- 探店笔记的查询(热门笔记分页、根据 ID 查询)
- 点赞功能(同一用户只能点赞一次,再次点击取消点赞)
- 点赞排行榜(展示最早点赞的 Top5 用户)
涉及的数据表:
tb_blog:探店笔记表,包含标题、内容、图片、点赞数、评论数等字段tb_user:用户表,用于关联笔记作者信息
二、查询笔记接口
2.1 Blog 实体类与非表字段
Blog 实体类对应数据库表 tb_blog,但其中有几个字段仅用于前端展示,数据库中并不存在,需要用 @TableField(exist = false) 标记。
1 |
|
@TableField(exist = false) 的作用:
- 这是 MyBatis-Plus 提供的注解,用于标记实体类中的字段在数据库表中不存在
- MyBatis-Plus 在生成 INSERT、UPDATE、SELECT 等 SQL 时会自动忽略该字段
- 适用于仅用于业务展示、需要在查询后手动填充的字段
2.2 根据 ID 查询笔记
查询单条笔记时,除了从数据库获取 blog 本身,还需要关联查询作者信息(昵称、头像),并判断当前用户是否点赞。
1 |
|
2.3 热门笔记分页查询
按点赞数倒序排列,分页返回热门笔记。每条笔记同样需要填充作者信息和点赞状态。
1 |
|
三、点赞功能
3.1 需求分析
- 同一个用户对同一篇笔记只能点赞一次,再次点击则取消点赞
- 前端需要知道当前用户是否已经点赞(点赞按钮高亮显示)
- 点赞数需要实时更新
3.2 基于 Redis Set 的实现(初始版本)
使用 Redis 的 Set 集合存储每篇笔记的点赞用户 ID,利用 Set 的元素唯一性保证一人一赞。
核心逻辑:
1 | 用户点击点赞 |
判断是否点赞:
查询笔记时,从 Redis Set 中判断当前用户是否存在,将结果写入 isLike 字段返回给前端。
1 | private void setIsLike(Blog blog) { |
Set 的缺点:元素无序,无法实现按点赞时间排序的排行榜功能。
3.3 基于 Redis ZSet 的实现(升级版)
为了实现点赞排行榜,将 Set 替换为 Sorted Set(ZSet),用时间戳作为 score,既保留了元素唯一性,又能按时间排序。
核心代码:
1 |
|
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 |
|
4.3 MySQL 的 FIELD 函数保证排序
WHERE id IN (5, 1, 3) 查询出来的结果不会按 IN 列表中的顺序返回,MySQL 会按主键或索引的顺序返回。如果需要保持指定顺序,可以使用 ORDER BY FIELD(id, 5, 1, 3)。
1 | SELECT * FROM tb_user |
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 |
在高并发下,两个线程可能同时判断为”未点赞”,然后都执行点赞操作,导致一人多赞。
解决方案:
- Redis 原子操作:将”判断 + 添加”用一条原子命令完成。对于 Set 可以用
SADD的返回值判断(返回 1 表示新增成功,返回 0 表示已存在);对于 ZSet 可以用ZADD的NX选项(仅在不存在时添加)。整个点赞逻辑可以用 Lua 脚本 保证原子性。 - 数据库唯一索引:给
(user_id, blog_id)建联合唯一索引,数据库层面兜底,重复插入直接报错。 - 分布式锁:以”用户 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 主存 + 定时同步;后者是点赞场景的主流方案 |