多用户跨学科交流系统(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)

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

相关推荐
小安同学iter18 小时前
天机学堂-排行榜功能-day08(六)
java·redis·微服务·zset·排行榜·unlink·天机学堂
hgz071018 小时前
Spring Boot Starter机制
java·spring boot·后端
daxiang1209220518 小时前
Spring boot服务启动报错 java.lang.StackOverflowError 原因分析
java·spring boot·后端
我家领养了个白胖胖18 小时前
极简集成大模型!Spring AI Alibaba ChatClient 快速上手指南
java·后端·ai编程
jiayong2318 小时前
Markdown编辑完全指南
java·编辑器
他是龙55118 小时前
第40天:JavaEE安全开发SpringBoot JWT身份鉴权与打包部署(JAR&WAR)
spring boot·安全·java-ee
heartbeat..18 小时前
深入理解 Redisson:分布式锁原理、特性与生产级应用(Java 版)
java·分布式·线程·redisson·
一代明君Kevin学长18 小时前
快速自定义一个带进度监控的文件资源类
java·前端·后端·python·文件上传·文件服务·文件流
aiopencode18 小时前
上架 iOS 应用到底在做什么?从准备工作到上架的流程
后端
未来之窗软件服务18 小时前
幽冥大陆(四十九)PHP打造Java的Jar实践——东方仙盟筑基期
java·php·jar·仙盟创梦ide·东方仙盟·东方仙盟sdk·东方仙盟一体化