目录
- ⭐点赞模块
-
- 1、Redis相关(特点、数据结构、配置等)
- 2、修改其他模块(核心补充点)
- [3、点赞接口设计(REST 风格)](#3、点赞接口设计(REST 风格))
- 4、Service层:点赞/取消赞逻辑
- 5、Mapper
- [6、Controller 层](#6、Controller 层)
- 7.测试
- ⭐可扩展的点
- [⭐面试模拟10个问题ψ(.. )>](#⭐面试模拟10个问题ψ(.. )>)
🔍本篇博客基于前几篇,主要内容:
前端发点赞请求 → 进入后端 Controller → Service 校验并切 Redis 点赞状态 → 写回数据库 → 返回最新点赞数给前端。
⭐点赞模块
1、Redis相关(特点、数据结构、配置等)
1. 为什么点赞要用 Redis?
点赞行为的特点:
- 高频操作(文章越火,写操作越多)
- 每次点赞量一般很小(+1/-1)
- 用户点赞/取消是瞬间行为
- 不需要实时写入数据库
如果直接用 MySQL:
- 每次点击都 update SQL → 热点文章可能被打爆
- 并发下还会出现写锁竞争
- 查询"是否已点赞"也需要查表 → 慢
Redis 就非常适合:
- 1ms 内完成读写
- 基于 Set 结构可以天然防重复点赞
- 对热点数据友好
- 可以定时同步回 MySQL(不需要实时)
2. 数据结构设计
我的方案是使用 Redis 的 Set:
Key: post:like:{postId}
Value: Set<userId>
含义:这篇文章被哪些用户点赞了
这样可以:
- 防止重复点赞 同一个 userId 重复加入 Set,没有副作用。
- O(1) 查询是否点赞
SISMEMBER post:like:1 1001- O(1) 点赞 / 取消
SADD/SREM操作都非常快。- 点赞数量就是 Set 的大小
SCARD post:like:1
3. Redis 配置
下载:https://github.com/tporadowski/redis/releases
下载 Redis-x64-5.0.14.1.msi(最稳定)
安装完后,会自动作为 Windows 服务启动(安装时勾选添加环境变量)。
启动Redis服务:进入Redis安装目录,管理员方式打开cmd,运行:
redis-server.exe redis.windows.conf

新建类进行配置:
java
//config/RedisConfig
package com.example.blog.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// key = String
template.setKeySerializer(new StringRedisSerializer());
// value = String(你的 userId 都会用 toString)
template.setValueSerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
return template;
}
}
application.yml里的Spring下配置redis
java
Spring:
redis:
host: localhost
port: 6379
password:
database: 0
2、修改其他模块(核心补充点)
①我们不用创建点赞记录表,只在 post 表中增加一个字段:
sql
ALTER TABLE post ADD COLUMN like_count INT DEFAULT 0;
MySQL 只负责:
1.保持最终一致的点赞数
2.用于页面初始化展示
其余全部 Redis 负责。
② 由于在"取消点赞"功能中我们需要userId,所以我们专门写一个UserContext 来存放当前登录用户的信息。
UserContext
UserContext 是你自己创建的 ThreadLocal,用来存当前登录用户的 userId,让后续所有逻辑都能随时拿到它。
java
package com.example.blog.utils;
public class UserContext {
private static final ThreadLocal<Long> USER_ID_HOLDER = new ThreadLocal<>();
// 保存用户ID
public static void setUserId(Long userId) {
USER_ID_HOLDER.set(userId);
}
// 获取用户ID
public static Long getUserId() {
return USER_ID_HOLDER.get();
}
// 请求结束后必须清除
public static void clear() {
USER_ID_HOLDER.remove();
}
}
③修改JwtFilter,把userId放进去
JWT 必须携带 userId 的原因:
因为点赞模块需要用户 ID 作为 Redis 的操作主体,如果 JWT 中不带 ID:
后端无法知道谁点赞
Controller 不应该让前端传 userId,那样不安全
ThreadLocal(UserContext)也取不到数据
所以 登录模块必须改 → token 内存 userId。
过滤器要在 ThreadLocal 写入 userId:
Http 请求是多线程的
Spring 线程池会复用线程
ThreadLocal 必须 set() & clear()
满足"请求内共享用户状态"的需求
安全,不依赖前端传 userId
之前的 JwtFilter 只解析了 username,没有解析 userId。
点赞模块是需要 userId 的,所以我们要改成解析 userId。
(以下复制粘贴即可。)
java
package com.example.blog.filter;
import com.example.blog.utils.UserContext;
import com.example.blog.utils.JwtUtil;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.io.IOException;
import java.util.List;
@Component
public class JwtFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
try {
// 放行 OPTIONS
if ("OPTIONS".equalsIgnoreCase(req.getMethod())) {
chain.doFilter(request, response);
return;
}
String path = req.getRequestURI();
if (path.contains("/login") || path.contains("/register")) {
chain.doFilter(request, response);
return;
}
String authHeader = req.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
res.getWriter().write("Missing or invalid Authorization header");
return;
}
String token = authHeader.substring(7);
// 解析 username 和 userId
String username = JwtUtil.parseUsername(token);
Long userId = JwtUtil.parseUserId(token); // ⭐ 关键行:解析 userId
// 存入 ThreadLocal(让后续系统随时获取)
UserContext.setUserId(userId);
// 设置 Spring Security 身份
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
username, null,
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
} finally {
// 必须清理!!!!
UserContext.clear();
}
}
}
④ 修改JwtUtil
java
package com.example.blog.utils;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.util.Date;
public class JwtUtil {
private static final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
private static final long EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 7; // 1周
// ----------- 创建 token:多存一个 userId -----------
public static String generateToken(Long userId, String username) {
return Jwts.builder()
.setSubject(username) // 也保持 subject = username
.claim("userId", userId)
.claim("username", username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(key)
.compact();
}
// ----------- 解析 token:内部通用方法 -----------
private static Claims getClaims(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
} catch (JwtException e) {
return null; // token 无效或过期
}
}
// 原本的 parseToken ------ 留着兼容旧逻辑
public static String parseToken(String token) {
// System.out.println("token: "+token);
Claims claims = getClaims(token);
return claims == null ? null : claims.getSubject();
}
// ----------- 新增:解析 username -----------
public static String parseUsername(String token) {
Claims claims = getClaims(token);
// System.out.println("token2: "+token);
if (claims == null) return null;
return claims.get("username", String.class);
}
// ----------- 新增:解析 userId -----------
public static Long parseUserId(String token) {
// System.out.println("token3: "+token);
Claims claims = getClaims(token);
if (claims == null) return null;
// System.out.println("claims:"+claims);
return claims.get("userId", Long.class);
}
}
⑤ 注意:原来我们没把userId 放进 token,取出来需要先在登录的时候放进去,所以要修改登录部分。
只需要在UserController的登录逻辑里 修改这一行(加了user.getId)
java
String token = JwtUtil.generateToken(user.getId(), user.getUsername());
3、点赞接口设计(REST 风格)
统一一个接口:
POST /posts/{postId}/like
请求体无需数据,因为用户信息从 JWT 中读取。
返回统一格式:
json
{
"code": 200,
"msg": "success",
"data": true // true=点赞,false=取消
}
4、Service层:点赞/取消赞逻辑
- 我把逻辑拆成两个步骤:
Step 1:在 Redis 中标记点赞/取消
Step 2:更新数据库中的计数
下面是完整实现。
java
//postLikeService
@Service
public class PostLikeService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private PostLikeMapper postLikeMapper;
private String getKey(Long postId) {
return "post:like:" + postId;
}
public boolean toggleLike(Long postId) {
Long userId = UserContext.getUserId();
// System.out.println("userId:"+userId);
String key = getKey(postId);
Boolean isLiked = redisTemplate.opsForSet().isMember(key, userId.toString());
if (Boolean.TRUE.equals(isLiked)) {
// 取消点赞
redisTemplate.opsForSet().remove(key, userId.toString());
postLikeMapper.decreaseLike(postId);
return false;
} else {
// 点赞
redisTemplate.opsForSet().add(key, userId.toString());
postLikeMapper.increaseLike(postId);
return true;
}
}
public int getLikeCount(Long postId) {
String key = getKey(postId);
Long count = redisTemplate.opsForSet().size(key);
return count == null ? 0 : count.intValue();
}
public boolean isLiked(Long postId) {
Long userId = UserContext.getUserId();
return Boolean.TRUE.equals(
redisTemplate.opsForSet().isMember(getKey(postId), userId.toString())
);
}
}
注意:
1.Redis 与数据库更新不一定在同一事务,可考虑"定时同步"(点赞数不要求强一致)
2.Redis 使用字符串存储,不支持直接存 Java Long。所以存userId时要使用 Set 的string
5、Mapper
java
package com.example.blog.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface PostLikeMapper {
@Update("UPDATE post SET like_count = like_count + 1 WHERE id = #{postId}")
void increaseLike(Long postId);
@Update("UPDATE post SET like_count = like_count - 1 WHERE id = #{postId} AND like_count > 0")
void decreaseLike(Long postId);
}
扩展点:
- 为什么点赞数量要落库?
排序时无需每次从 Redis SCARD
DB 中有最终值可用于统计
Redis 是缓存,可能清空,所以 DB 必须保存
- 为什么不保存 userId -> posts 的反向结构?
- 因为你的需求只需要判断是否点赞 & 点赞数。
除非你要:查询某个用户点赞了哪些文章,才需要反向结构。
6、Controller 层
注意:
Controller 不参与逻辑,只做转发
不能让前端传 userId
userId 必须从 ThreadLocal 获取
Controller 返回统一 Result 结构,增强规范性
java
//PostLikeController
package com.example.blog.controller;
import com.example.blog.dto.Result;
import com.example.blog.service.PostLikeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/likes")
public class PostLikeController {
@Autowired
private PostLikeService postLikeService;
// 点赞 / 取消点赞(二合一)
@PostMapping("/{postId}")
public Result<Boolean> toggleLike(@PathVariable Long postId) {
boolean liked = postLikeService.toggleLike(postId);
return Result.success(liked);
}
// 查询点赞数量
@GetMapping("/{postId}/count")
public Result<Integer> getLikeCount(@PathVariable Long postId) {
return Result.success(postLikeService.getLikeCount(postId));
}
// 查询当前用户是否点赞
@GetMapping("/{postId}/isLiked")
public Result<Boolean> isLiked(@PathVariable Long postId) {
return Result.success(postLikeService.isLiked(postId));
}
}
7.测试
在apifox上面创建3个接口进行测试,均没问题。
(注意:我写完所有代码写的博客,其实中间报了很多错误,有一定概率没有记录完整,若实践过程中出现意外,要自行进行解决!)

⭐可扩展的点
- 全文搜索:支持关键词搜索或接入 ES 做更精准检索。
- 点赞优化:用 Redis 做防重复、统计和热门文章排名。
- 阅读量统计:Redis 记录 PV/UV,并定时同步数据库。
- 操作日志:记录用户登录、发文、点赞等行为,便于审计。
- 权限管理:区分普通用户与管理员,实现基础 RBAC。
- 草稿箱:文章分草稿/已发布两种状态。
- 定时任务:定时同步缓存、清理垃圾数据、生成榜单。
- 异步任务:点赞、记录阅读量等非核心操作异步执行。
- 文件上传:支持本地图片上传并生成可访问 URL。
- 评论通知:评论后写通知或发邮件提醒。
- 历史版本:文章更新时记录历史版本便于回滚。
- 接口限流:防止刷接口,用 Redis 做简单限流。
- 系统监控:接入 Actuator 监控服务健康状态
⭐面试模拟10个问题ψ(._. )>
1. 为什么点赞模块用 Redis 而不是直接写 MySQL?
因为点赞是高频 IO,直接写 MySQL 会导致:
热点文章被疯狂点击 → 单行更新锁竞争严重
like/unlike 高频切换导致 写放大
判断"是否点赞"用 SELECT 会成为瓶颈
分布式场景需要缓存去做降压
Redis 的 Set 操作是 O(1),天然支持去重、判断是否存在,非常适合点赞业务。
此外,点赞本质是 读多写多的小数据结构操作,完全契合 Redis 的定位。
2. Redis 用 Set 结构做点赞,有哪些优点?缺点是什么?
优点:
天然去重(一个 userId 不可能被加入两次)
判断是否点赞很快(SISMEMBER O(1))
统计点赞数很快(SCARD O(1))
缺点:
占用内存比 bitmap/HyperLogLog 更高
如果点赞用户非常多(百万级),Set 结构膨胀明显
key 维度过多可能需要 TTL 策略
数据最终一致性依赖 MySQL 回写(可能出现延迟)
面试加分点(可补充):
"我们的文章点赞规模不大,所以 Redis Set 在可控范围内,性能好、开发简单,是最合适的选择。"
3. 你如何保证 Redis 与 MySQL 点赞数一致?
点赞模块一般采用:Redis 负责实时数据,MySQL 负责持久存储。
常用方案:
✔ 同步写(你现在的写法)
- Redis + MySQL 同时更新 → 一致性较强,但写压力会放大
✔ 异步批量回写 (更面试加分)
Redis 做缓存层
后台定时任务(如每 5 分钟)把 Redis 里的数据 flush 到 MySQL。
优点:减轻 MySQL 压力 缺点:有短暂不一致性
✔ 双删策略
- 写 MySQL → 删除 Redis → 延迟双删
你可以直接说:
"本项目点赞量不大,我采用的是同步更新 MySQL 的方式保证数据一致性;如果未来扩容,我会改为异步批量回写。"
这句话直接是面试金句。
4. 并发点赞时如何保持原子性?会不会出现数据错乱?
面试官真正考察的是你对 Redis 的理解。
Redis 单线程执行每条命令,所以:
SADD
SREM
SISMEMBER
SCARD
这些操作都是 天然原子 的,不需要加分布式锁。
加分回答:
"唯一需要注意的是 Redis 和 MySQL 同步写时可能出现并发问题,因此我把对数据库的更新放在同一个业务流程里面,并且后续会考虑将
MySQL 更新改为异步单线程执行,避免并发写数据库冲突。"
5. 如果 Redis 崩溃,点赞数据会丢吗?如何避免?
你可以给面试官两种策略:
✔ 开启 AOF 持久化(推荐)
- Redis 重启后可恢复绝大部分数据。
✔ Redis + MySQL 双写(你现在做的)
- 即便 Redis 崩溃,MySQL 依然是最完整的数据源,下次启动可以从 MySQL 恢复。
一句话总结:
"我做的是 Redis + MySQL 双写,所以 Redis 数据丢失不会影响最终结果;并且配合 AOF 保证崩溃恢复。"
6. 你项目登录态如何传递到点赞模块?为什么要用 ThreadLocal?
流程:
用户登录获得 JWT
请求带 Authorization: Bearer xxxx
JwtFilter 解析 token → 拿到 userId
存入 UserContext.setUserId()(ThreadLocal)
Service 层随时调用 UserContext.getUserId()
为什么不用请求参数传 userId?
太危险(容易伪造)
污染接口
Service 层会变得很丑
ThreadLocal 能做到:
与当前线程绑定
不需要重复传参
使用完自动清理(filter finally 里)
一句话总结:
"ThreadLocal 帮我把 userId 与当前线程绑定,让 Service 层保持纯净,不泄露安全信息。"
7. 点赞接口如何防止频繁点击造成负载?
你可以说你预留了扩展点:
方法 1:基于 Redis 的限流(强烈推荐)
- 例:每个用户对同一篇文章的点赞操作 1 秒内最多 1 次 用 SETEX user:like:uid:pid 1 判断。
方法 2:使用前端节流(但你现在没有前端)
一句话:
"后端我可以加 Redis 限流 key,保证用户短时间内只能操作一次,避免滥用。
"
8. 点赞模块如果要扩展为排行榜功能怎么做?
这是加分题,你会回答,面试官直接觉得你水平提升了一档。
直接说:
"我会将点赞数同步到 Redis SortedSet,例如 article:rank,用点赞数做 score,就能按 score
排序实现排行榜。"
9. 如何处理 Redis key 过多的问题?
如果文章量巨大,post:like:{id} 会变多。
一般策略:
热文章常驻 Redis
冷文章设置 TTL,如 7 天不访问自动过期
定期批量删除冷 key
使用 BigKey 监控防止过大 set
一句话:
"我会对冷 key 设置 TTL,并定期清理长时间无人访问的文章点赞 Set。"
10. 你觉得你实现的点赞模块有哪些可以优化的?(非常加分)
你可以按你的项目情况说:
Redis + MySQL 同步写有写放大 → 改为异步回写
可加入 Redis 限流
可加入文章热度排行
可以引入 Redisson 缓存框架简化操作
分布式部署时需要传递 ThreadLocal(解决方案:使用 SpringMVC 拦截器 + filter)
这类回答最能体现你"自己设计系统"的能力。