多用户跨学科交流系统(5):点赞功能的后端完整处理链路

目录

🔍本篇博客基于前几篇,主要内容:

前端发点赞请求 → 进入后端 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);
}

扩展点:

  1. 为什么点赞数量要落库?
  • 排序时无需每次从 Redis SCARD

  • DB 中有最终值可用于统计

  • Redis 是缓存,可能清空,所以 DB 必须保存

  1. 为什么不保存 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?

流程:

  1. 用户登录获得 JWT

  2. 请求带 Authorization: Bearer xxxx

  3. JwtFilter 解析 token → 拿到 userId

  4. 存入 UserContext.setUserId()(ThreadLocal)

  5. 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)

这类回答最能体现你"自己设计系统"的能力。

相关推荐
q***13341 小时前
SpringMVC新版本踩坑[已解决]
java
q***d1731 小时前
Rust并发模型
开发语言·后端·rust
j***12151 小时前
springboot整合libreoffice(两种方式,使用本地和远程的libreoffice);docker中同时部署应用和libreoffice
spring boot·后端·docker
Charles_go1 小时前
C#中级48、Debug版本和Release版本有什么区别
java·linux·c#
资深web全栈开发2 小时前
Golang Cobra 教程:构建强大的CLI应用
开发语言·后端·golang
u***27612 小时前
Spring Boot 条件注解:@ConditionalOnProperty 完全解析
java·spring boot·后端
c***72742 小时前
SpringBoot集成Flink-CDC,实现对数据库数据的监听
数据库·spring boot·flink
222you2 小时前
MyBatis-Plus当中BaseMapper接口的增删查改操作
java·开发语言·mybatis