点赞系统问题

介绍一下这个项目

这是一个基于Spring Boot 3和Java 21构建的高并发点赞系统,专门设计用于点赞场景。该系统支持高并发、高可用和可观测性,采用了多级缓存和异步处理等架构设计,能够在大规模流量下保持稳定运行。

技术选型:

  • 后端框架:Spring Boot 3 + Java 21虚拟线程
  • 数据库:MySQL + MyBatis-Plus 和 TiDB分布式数据库
  • 缓存系统:Redis多级缓存 + 本地热点缓存(HeavyKeeper算法)
  • 消息队列:Apache Pulsar
  • 监控和可观测性:Prometheus + Grafana
  • 容器化部署:Docker
  • 网关负载均衡:Nginx

核心功能:

  1. 点赞/取消点赞

通过ThumbController接收操作请求

ThumbService处理业务逻辑:

• 校验用户/文章有效性

• 更新thumb表记录

• 同步更新blog表的thumbCount计数器

  1. 状态查询

采用组合查询方式:

• 检查thumb表是否存在对应记录

• 返回布尔型状态标识

  1. 统计功能

基于blog表的thumbCount字段

支持实时查询文章点赞总数

基础功能怎么实现的?

1. 登录

在后端,登录请求由 UserController 处理:

java 复制代码
@RestController  
@RequestMapping("user")  
public class UserController {  
    @Resource  
    private UserService userService;  
  
    @GetMapping("/login")  
    public BaseResponse<User> login(long userId, HttpServletRequest request) {  
        User user = userService.getById(userId);  
        request.getSession().setAttribute(UserConstant.LOGIN_USER, user);  
        return ResultUtils.success(user);  
    }  
}

身份验证流程

  1. 用户在登录表单中输入 ID(无密码)
  2. 表单提交调用 handleLogin(),进而调用 userStore.login()
  3. store使用 userApi.login() 向 /api/user/login?userId={userId} 发出 GET 请求
  4. 后端 UserController.login() 方法:
  5. 使用 userService.getById() 从数据库获取用户
  6. 使用 request.getSession().setAttribute() 将用户存储在会话中
  7. 返回用户数据

响应成功后,前端:

  1. 更新 Pinia store状态(currentUser 和 isLoggedIn)
  2. 将 userId 保存到 localStorage 进行持久化
  3. 重定向到主页

2. 获取当前登录用户

在UserController下新建接口:

java 复制代码
@GetMapping("/get/login")  
public BaseResponse<User> getLoginUser(HttpServletRequest request) {  
    User loginUser = (User) request.getSession().getAttribute(UserConstant.LOGIN_USER);  
    return ResultUtils.success(loginUser);  
}

3. 获取博客(循环依赖)

在 model.vo 下新建 BlogVO 类,这是一个视图包装类,可以额外关联上传图片的点赞信息、用户信息

等。除此之外,还可以编写 Blog 实体类和该VO 类的转换方法,便于后续快速传值,不过本项目中没有必要。

在BlogService添加根据Blog id获取对应blog的方法:

java 复制代码
BlogVO getBlogVOById(long blogId, HttpServletRequest request);

实现:

java 复制代码
@Resource  
private UserService userService;  
  
@Resource  
@Lazy  
private ThumbService thumbService;  
  
@Override  
public BlogVO getBlogVOById(long blogId, HttpServletRequest request) {  
    Blog blog = this.getById(blogId);  
    User loginUser = userService.getLoginUser(request);  
    return this.getBlogVO(blog, loginUser);  
}  
  
private BlogVO getBlogVO(Blog blog, User loginUser) {  
    BlogVO blogVO = new BlogVO();  
    BeanUtil.copyProperties(blog, blogVO);  
  
    if (loginUser == null) {  
        return blogVO;  
    }  
      
    Thumb thumb = thumbService.lambdaQuery()  
            .eq(Thumb::getUserId, loginUser.getId())  
            .eq(Thumb::getBlogId, blog.getId())  
            .one();  
    blogVO.setHasThumb(thumb != null);  
  
    return blogVO;  
}

getBlogVO会根据用户是否已登录(loginUser不为空),获取当前登录用户是否已经点赞该博客并设置到blogVO中。

注意:因为后续我们会在 thumbService 中引入 blogService,所以这里在引入 thumbService 时标注了 @lazy 注解,用来解决循环引用问题。当两个 Bean 相互依赖时,@Lazy 会让其中一个 Bean 的初始化推迟到第一次使用时,从而打破初始化阶段的死循环。

执行流程变化​​:

  1. 创建 Chicken 时,发现需要 Egg 但被标记为 @Lazy
  2. Spring 会先创建一个 Egg​代理对象​(不是真实对象)
  3. 完成 Chicken 的初始化
  4. Chicken 第一次调用 egg 的方法时:
    • 触发真实 Egg 的创建
    • 此时 Chicken 已经存在 → 可以正常注入到 Egg

换到这里,当我获取一篇博客时,我需要知道当前用户对该博客是否已经点赞;当我要看点赞数量或状态时,我必须知道我要看的是哪篇博客的点赞信息!!!这就是循环依赖。

BlogController添加接口:

java 复制代码
@RestController  
@RequestMapping("blog")  
public class BlogController {  
    @Resource  
    private BlogService blogService;  
  
    @GetMapping("/get")  
    public BaseResponse<BlogVO> get(long blogId, HttpServletRequest request) {  
        BlogVO blogVO = blogService.getBlogVOById(blogId, request);  
        return ResultUtils.success(blogVO);  
    }  
}

4. 获取博客列表

java 复制代码
@Override  
public List<BlogVO> getBlogVOList(List<Blog> blogList, HttpServletRequest request) {  
    User loginUser = userService.getLoginUser(request);  
    Map<Long, Boolean> blogIdHasThumbMap = new HashMap<>();  
    if (ObjUtil.isNotEmpty(loginUser)) {  
        Set<Long> blogIdSet = blogList.stream().map(Blog::getId).collect(Collectors.toSet());  
        // 获取点赞  
        List<Thumb> thumbList = thumbService.lambdaQuery()  
                .eq(Thumb::getUserId, loginUser.getId())  
                .in(Thumb::getBlogId, blogIdSet)  
                .list();  
  
        thumbList.forEach(blogThumb -> blogIdHasThumbMap.put(blogThumb.getBlogId(), true));  
    }  
  
    return blogList.stream()  
            .map(blog -> {  
                BlogVO blogVO = BeanUtil.copyProperties(blog, BlogVO.class);  
                blogVO.setHasThumb(blogIdHasThumbMap.get(blog.getId()));  
                return blogVO;  
            })  
            .toList();  
}

先批量查出当前登录用户对这批博客的点赞记录,然后在内存中进行判断处理,避免循环查库

5. 点赞

java 复制代码
@Service  
@Slf4j  
@RequiredArgsConstructor  
public class ThumbServiceImpl extends ServiceImpl<ThumbMapper, Thumb> implements ThumbService {  
  
    private final UserService userService;  
  
    private final BlogService blogService;  
  
    private final TransactionTemplate transactionTemplate;  
      
    @Override  
    public Boolean doThumb(DoThumbRequest doThumbRequest, HttpServletRequest request) {  
        if (doThumbRequest == null || doThumbRequest.getBlogId() == null) {  
            throw new RuntimeException("参数错误");  
        }  
        User loginUser = userService.getLoginUser(request);  
        // 加锁  
        synchronized (loginUser.getId().toString().intern()) {  
      
            // 编程式事务  
            return transactionTemplate.execute(status -> {  
                Long blogId = doThumbRequest.getBlogId();  
                boolean exists = this.lambdaQuery()  
                        .eq(Thumb::getUserId, loginUser.getId())  
                        .eq(Thumb::getBlogId, blogId)  
                        .exists();  
                if (exists) {  
                    throw new RuntimeException("用户已点赞");  
                }  
      
                boolean update = blogService.lambdaUpdate()  
                        .eq(Blog::getId, blogId)  
                        .setSql("thumbCount = thumbCount + 1")  
                        .update();  
      
                Thumb thumb = new Thumb();  
                thumb.setUserId(loginUser.getId());  
                thumb.setBlogId(blogId);  
                // 更新成功才执行  
                return update && this.save(thumb);  
            });  
        }  
    }  
}
  1. 继承MyBatis-Plus的ServiceImpl,获得基础CRUD能力

  2. doThumbRequest:封装了blogId等请求参数;request:用于获取当前用户信息

  3. 使用MyBatis-Plus的Lambda查询

通过Redis的hMGet命令获取用户点赞状态优化(避免多次单条查询)

通过Redis的hMGet命令 实现批量获取用户点赞状态(批量是指当前登录用户对一批博客的点赞状态)

基于用户角度,选择hash结构存储(在redis中,哈希类型是指Redis键值对中的值本身又是一个键值对结构)。key 为用户 id,field为博客 id,value为 点赞记录 id。这样在批量查询时,能够通过 hMGet 命令获取用户的点赞数据,新点赞时用 HSet 命令添加。

是否已点赞接口:

java 复制代码
private final RedisTemplate<String, Object> redisTemplate;  
  
@Override  
public Boolean hasThumb(Long blogId, Long userId) {  
    return redisTemplate.opsForHash().hasKey(ThumbConstant.USER_THUMB_KEY_PREFIX + userId, blogId.toString());  
}

原点赞服务中判断是否已点赞的逻辑修改:

java 复制代码
Boolean exists = this.hasThumb(blogId, loginUser.getId());

点赞成功后,还要将点赞记录存在redis中:

java 复制代码
boolean success = update && this.save(thumb);  
  
// 点赞记录存入 Redis  
if (success) {  
    redisTemplate.opsForHash().put(ThumbConstant.USER_THUMB_KEY_PREFIX + loginUser.getId().toString(), blogId.toString(), thumb.getId());  
}  
// 更新成功才执行  
return success;

原批量获取博客的方法修改:

java 复制代码
...  
if (ObjUtil.isNotEmpty(loginUser)) {  
    List<Object> blogIdList = blogList.stream().map(blog -> blog.getId().toString()).collect(Collectors.toList());  
    // 获取点赞  
    List<Object> thumbList = redisTemplate.opsForHash().multiGet(ThumbConstant.USER_THUMB_KEY_PREFIX + loginUser.getId(), blogIdList);  
    for (int i = 0; i < thumbList.size(); i++) {  
        if (thumbList.get(i) == null) {  
            continue;  
        }  
        blogIdHasThumbMap.put(Long.valueOf(blogIdList.get(i).toString()), true);  
    }  
}  
...

multiGet即是对hMGet命令的封装

注意:我们这里并没有设置过期时间,因为如果有过期时间,那就需要考虑缓存中不存在数据的情况,可能是因为过期、也可能是因为本来就没点赞,就必须要去数据库中查询。但是在正常的业务场景中,绝大部分的博客、内容应该都是未被用户点赞的,那就意味着这些数据都需要通过 Redis查一次,再去MySQL 查一次,结果还是未点赞。不仅没有降低MySQL的读压力,反而多请求了一次Redis,这就与我们引入缓存的初衷相悖了。
即使这样,在生产环境中保存大量不会过期且持续增加的数据还是不可取的,那我们该怎么办呢?

可以采用 冷热分离 的策略。比如我们认为最近一个月新发的内容是热数据,那么可以让 Redis 中点赞记录的存在时间是帖子的发布时间+1个月如果点赞时该博客的发布时间不超过一个月,则查 Redis 校验是否已点赞;如果发布时间超过了一个月,则通过 MySQL 校验是否已点赞。

还可以引入布隆过滤器,布隆过滤器中存在再进行后续步骤,否则直接返回未点赞

但是还有一个问题,Redis 中是不支持针对 Hash 结构中的具体某个属性设置过期时间的!我们可以调整value 的数据结构,比如调整为:

bash 复制代码
{
    "thumbId":xxx,
    "expireTime":xxx
}

点赞实现1: 使用 Hash 结构存储用户点赞关系(读压力)

在Redis中,我们使用Hash结构存储用户点赞记录/状态,用于追踪用户点赞过的博客,键为用户ID,字段为博客ID,值为布尔值表示是否点赞。对于用户 123,键为 thumb:123,其中博客 ID 作为字段,值 1 表示对该博客点赞。

  • Key:thumb:{userId}
  • Field: {blogId}
  • Value: 1: 点赞

同时,我们设计了临时点赞记录,按时间戳分片存储待同步的操作。这种时间分片策略有利于并行处理和问题追踪。

  • Key:thumb:temp:{timeSlice}
  • Field:{userId}:{blogId}
  • Value:操作类型(1:点赞,-1:取消点赞,0:无变化)

简言之:每次点赞后会生成两种点赞记录,用户点赞记录是为了快速获取当前登录用户是否点赞,而临时点赞记录是为了将本次点赞同步到数据库中。

两种点赞记录的应用如下图所示:

编写 Lua 脚本保证多条 Redis 命令的原子性(点赞中的读写一致)

在上述点赞操作中,我们需要同时完成"记录点赞状态"和"写入临时记录"两个操作。如果分开执行这两个 Redis 操作,步骤 2执行结束 记录点赞状态之后系统宕机了,这里就会数据不一致。

Lua 脚本通过原子操作的特性,保证点赞状态和临时记录要么同时存在,要么同时不存在,不会因为系统宕机而导致数据不一致。

这是一个重要的优化点,因为在原来的实现中,我们需要使用synchronized 锁来保证同一用户的点赞操作串行执行,并且需要使用事务来保证数据库操作的一致性。而使用Lua 脚本后,锁和事务都可以去掉,提升系统性能 ,因为:

  1. Lua 脚本在 Redis 中是原子执行的,不需要额外加锁

  2. 点赞操作被转移到 Redis 中,不需要事务,定时任务会将数据批量同步到数据库,避免了事务的开销。

java 复制代码
@Service("thumbService")  
@Slf4j  
@RequiredArgsConstructor  
public class ThumbServiceRedisImpl extends ServiceImpl<ThumbMapper, Thumb> implements ThumbService {  
  
    private final UserService userService;  
  
    private final RedisTemplate<String, Object> redisTemplate;  
  
    @Override  
    public Boolean doThumb(DoThumbRequest doThumbRequest, HttpServletRequest request) {  
        if (doThumbRequest == null || doThumbRequest.getBlogId() == null) {  
            throw new RuntimeException("参数错误");  
        }  
        User loginUser = userService.getLoginUser(request);  
        Long blogId = doThumbRequest.getBlogId();  
  
        String timeSlice = getTimeSlice();  
        // Redis Key  
        String tempThumbKey = RedisKeyUtil.getTempThumbKey(timeSlice);  
        String userThumbKey = RedisKeyUtil.getUserThumbKey(loginUser.getId());  
  
        // 执行 Lua 脚本  
        long result = redisTemplate.execute(  
                RedisLuaScriptConstant.THUMB_SCRIPT,  
                Arrays.asList(tempThumbKey, userThumbKey),  
                loginUser.getId(),  
                blogId  
        );  
  
        if (LuaStatusEnum.FAIL.getValue() == result) {  
            throw new RuntimeException("用户已点赞");  
        }  
  
        // 更新成功才执行  
        return LuaStatusEnum.SUCCESS.getValue() == result;  
    }  
  
    @Override  
    public Boolean undoThumb(DoThumbRequest doThumbRequest, HttpServletRequest request) {  
        if (doThumbRequest == null || doThumbRequest.getBlogId() == null) {  
            throw new RuntimeException("参数错误");  
        }  
        User loginUser = userService.getLoginUser(request);  
  
        Long blogId = doThumbRequest.getBlogId();  
        // 计算时间片  
        String timeSlice = getTimeSlice();  
        // Redis Key  
        String tempThumbKey = RedisKeyUtil.getTempThumbKey(timeSlice);  
        String userThumbKey = RedisKeyUtil.getUserThumbKey(loginUser.getId());  
  
        // 执行 Lua 脚本  
        long result = redisTemplate.execute(  
                RedisLuaScriptConstant.UNTHUMB_SCRIPT,  
                Arrays.asList(tempThumbKey, userThumbKey),  
                loginUser.getId(),  
                blogId  
        );  
        // 根据返回值处理结果  
        if (result == LuaStatusEnum.FAIL.getValue()) {  
            throw new RuntimeException("用户未点赞");  
        }  
        return LuaStatusEnum.SUCCESS.getValue() == result;  
    }  
  
    private String getTimeSlice() {  
        DateTime nowDate = DateUtil.date();  
        // 获取到当前时间前最近的整数秒,比如当前 11:20:23 ,获取到 11:20:20  
        return DateUtil.format(nowDate, "HH:mm:") + (DateUtil.second(nowDate) / 10) * 10;  
    }  
  
    @Override  
    public Boolean hasThumb(Long blogId, Long userId) {  
        return redisTemplate.opsForHash().hasKey(RedisKeyUtil.getUserThumbKey(userId), blogId.toString());  
    }  
}

lua脚本:

Lua 复制代码
public class RedisLuaScriptConstant {  
  
    /**  
     * 点赞 Lua 脚本  
     * KEYS[1]       -- 临时计数键  
     * KEYS[2]       -- 用户点赞状态键  
     * ARGV[1]       -- 用户 ID  
     * ARGV[2]       -- 博客 ID  
     * 返回:  
     * -1: 已点赞  
     * 1: 操作成功  
     */  
    public static final RedisScript<Long> THUMB_SCRIPT = new DefaultRedisScript<>("""  
            local tempThumbKey = KEYS[1]       -- 临时计数键(如 thumb:temp:{timeSlice})  
            local userThumbKey = KEYS[2]       -- 用户点赞状态键(如 thumb:{userId})  
            local userId = ARGV[1]             -- 用户 ID  
            local blogId = ARGV[2]             -- 博客 ID  
              
            -- 1. 检查是否已点赞(避免重复操作)  
            if redis.call('HEXISTS', userThumbKey, blogId) == 1 then  
                return -1  -- 已点赞,返回 -1 表示失败  
            end  
              
            -- 2. 获取旧值(不存在则默认为 0)  
            local hashKey = userId .. ':' .. blogId  
            local oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0)  
              
            -- 3. 计算新值  
            local newNumber = oldNumber + 1  
              
            -- 4. 原子性更新:写入临时计数 + 标记用户已点赞  
            redis.call('HSET', tempThumbKey, hashKey, newNumber)  
            redis.call('HSET', userThumbKey, blogId, 1)  
              
            return 1  -- 返回 1 表示成功  
            """, Long.class);  
  
    /**  
     * 取消点赞 Lua 脚本  
     * 参数同上  
     * 返回:  
     * -1: 未点赞  
     * 1: 操作成功  
     */  
    public static final RedisScript<Long> UNTHUMB_SCRIPT = new DefaultRedisScript<>("""  
            local tempThumbKey = KEYS[1]      -- 临时计数键(如 thumb:temp:{timeSlice})  
            local userThumbKey = KEYS[2]      -- 用户点赞状态键(如 thumb:{userId})  
            local userId = ARGV[1]            -- 用户 ID  
            local blogId = ARGV[2]            -- 博客 ID  
              
            -- 1. 检查用户是否已点赞(若未点赞,直接返回失败)  
            if redis.call('HEXISTS', userThumbKey, blogId) ~= 1 then  
                return -1  -- 未点赞,返回 -1 表示失败  
            end  
              
            -- 2. 获取当前临时计数(若不存在则默认为 0)  
            local hashKey = userId .. ':' .. blogId  
            local oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0)  
              
            -- 3. 计算新值并更新  
            local newNumber = oldNumber - 1  
              
            -- 4. 原子性操作:更新临时计数 + 删除用户点赞标记  
            redis.call('HSET', tempThumbKey, hashKey, newNumber)  
            redis.call('HDEL', userThumbKey, blogId)  
              
            return 1  -- 返回 1 表示成功  
            """, Long.class);  
}

点赞后,记录点赞状态,同时定时任务将临时点赞记录同步到数据库中:

java 复制代码
/**  
 * 定时将 Redis 中的临时点赞数据同步到数据库  
 *  
 */  
@Component  
@Slf4j  
public class SyncThumb2DBJob {  
  
    @Resource  
    private ThumbService thumbService;  
  
    @Resource  
    private BlogMapper blogMapper;  
  
    @Resource  
    private RedisTemplate<String, Object> redisTemplate;  
  
    @Scheduled(fixedRate = 10000)
    @Transactional(rollbackFor = Exception.class)
    public void run() {  
        log.info("开始执行");  
        DateTime nowDate = DateUtil.date();
        // 如果秒数为0~9 则回到上一分钟的50秒
        int second = (DateUtil.second(nowDate) / 10 - 1) * 10;
        if (second == -10) {
            second = 50;
            // 回到上一分钟
            nowDate = DateUtil.offsetMinute(nowDate, -1);
        }
        String date = DateUtil.format(nowDate, "HH:mm:") + second;
        syncThumb2DBByDate(date);
        log.info("临时数据同步完成");
    }  
  
    public void syncThumb2DBByDate(String date) {  
        // 获取到临时点赞和取消点赞数据  
        String tempThumbKey = RedisKeyUtil.getTempThumbKey(date);  
        Map<Object, Object> allTempThumbMap = redisTemplate.opsForHash().entries(tempThumbKey);  
        boolean thumbMapEmpty = CollUtil.isEmpty(allTempThumbMap);  
  
        // 同步 点赞 到数据库  
        // 构建插入列表并收集blogId  
        Map<Long, Long> blogThumbCountMap = new HashMap<>();  
        if (thumbMapEmpty) {  
            return;  
        }  
        ArrayList<Thumb> thumbList = new ArrayList<>();  
        LambdaQueryWrapper<Thumb> wrapper = new LambdaQueryWrapper<>();  
        boolean needRemove = false;  
        for (Object userIdBlogIdObj : allTempThumbMap.keySet()) {  
            String userIdBlogId = (String) userIdBlogIdObj;  
            String[] userIdAndBlogId = userIdBlogId.split(StrPool.COLON);  
            Long userId = Long.valueOf(userIdAndBlogId[0]);  
            Long blogId = Long.valueOf(userIdAndBlogId[1]);  
            // -1 取消点赞,1 点赞  
            Integer thumbType = Integer.valueOf(allTempThumbMap.get(userIdBlogId).toString());  
            if (thumbType == ThumbTypeEnum.INCR.getValue()) {  
                Thumb thumb = new Thumb();  
                thumb.setUserId(userId);  
                thumb.setBlogId(blogId);  
                thumbList.add(thumb);  
            } else if (thumbType == ThumbTypeEnum.DECR.getValue()) {  
                // 拼接查询条件,批量删除  
                needRemove = true;  
                wrapper.or().eq(Thumb::getUserId, userId).eq(Thumb::getBlogId, blogId);  
            } else {  
                if (thumbType != ThumbTypeEnum.NON.getValue()) {  
                    log.warn("数据异常:{}", userId + "," + blogId + "," + thumbType);  
                }  
                continue;  
            }  
            // 计算点赞增量  
            blogThumbCountMap.put(blogId, blogThumbCountMap.getOrDefault(blogId, 0L) + thumbType);  
        }  
        // 批量插入  
        thumbService.saveBatch(thumbList);  
        // 批量删除  
        if (needRemove) {  
            thumbService.remove(wrapper);  
        }  
        // 批量更新博客点赞量  
        if (!blogThumbCountMap.isEmpty()) {  
            blogMapper.batchUpdateThumbCount(blogThumbCountMap);  
        }  
        // 异步删除  
        Thread.startVirtualThread(() -> {  
            redisTemplate.delete(tempThumbKey);  
        });  
    }  
}

在这个定时任务中,我们按照时间戳从Redis中获取临时点赞数据,分别处理点赞和取消点赞操作后再将数据批量入库,最后使用虚拟线程异步删除已处理的临时数据。
不过由于把临时点赞数据存入Redis 的时候,我们没有记录用户的操作时间,所以将写入数据库的时间作为点赞记录的创建时间,跟用户实际点赞时间就会有一点误差(我们的定时任务10s执行一次,所以误差一般会在10s内)
。因为在本项目里其他地方没有用到这个数据,而且误差不大,所以一般是可以容忍的。不过大家在做的时候也可以考虑优化下这里,很简单,将点赞时间也存到临时记录里,在批量将数据写入数据库的时候使用实际点赞时间即可。

还有一点需要注意,在数据量较大、定时任务执行时间超过10s 时,这个任务就会影响后续的定时任务

执行,可能导致部分临时点赞记录没有及时被处理,需要等到补偿任务执行数据才恢复一致。

补偿任务:

java 复制代码
/**  
 * 定时将 Redis 中的临时点赞数据同步到数据库的补偿措施  
 *  
 */  
@Component  
@Slf4j  
public class SyncThumb2DBCompensatoryJob {  
  
    @Resource  
    private RedisTemplate<String, Object> redisTemplate;  
  
    @Resource  
    private SyncThumb2DBJob syncThumb2DBJob;  
  
    @Scheduled(cron = "0 0 2 * * *")  
    public void run() {
        log.info("开始补偿数据");
        Set<String> thumbKeys = redisTemplate.keys(RedisKeyUtil.getTempThumbKey("") + "*");
        Set<String> needHandleDataSet = new HashSet<>();
        thumbKeys.stream().filter(ObjUtil::isNotNull).forEach(thumbKey -> needHandleDataSet.add(thumbKey.replace(ThumbConstant.TEMP_THUMB_KEY_PREFIX.formatted(""), "")));

        if (CollUtil.isEmpty(needHandleDataSet)) {
            log.info("没有需要补偿的临时数据");
            return;
        }
        // 补偿数据
        for (String date : needHandleDataSet) {
            syncThumb2DBJob.syncThumb2DBByDate(date);
        }
        log.info("临时数据补偿完成");
    }
}

基于 Redis Hash 结构和 Caffeine 构建两级缓存(HotKey)

为了解决超级热点问题,我们可以采用多级缓存策略引入本地缓存,不过考虑到本地缓存的成本问题,肯定不能将所有的数据存起来,所以还需要结合热点检测机制

本地缓存的框架我们选择 caffeine,它比较适合数据量有限的小型数据集以及高频、低延迟的短期热点数据。

Caffeine 是一款基于 Java 的高性能本地缓存库,由 Google Guava 缓存改进而来,并被 Spring Framework 5+ 选为默认本地缓存实现:

1. 高性能与线程安全​

  • 底层采用 ConcurrentHashMap 实现,支持并发读写和 O(1) 时间复杂度操作
  • 通过减少锁竞争和优化内存分配,实现高吞吐量(单节点 QPS 可达 10 万+)
  1. 智能淘汰策略​
  • ​W-TinyLFU​:结合 LRU(最近最少使用)和 LFU(最不经常使用)算法,通过频率统计实现接近理论最优的缓存命中率
  • ​容量控制​ :支持基于条目数(maximumSize)或权重(maximumWeight)的淘汰机制
  • ​时间策略​ :提供写入后过期(expireAfterWrite)、访问后过期(expireAfterAccess)和定时刷新(refreshAfterWrite

引入 HeavyKeeper 算法识别热点内容(HotKey)

HeavyKeeper是一种用于​​高流速数据流中快速识别Top-K高频元素​ ​的算法,由B站技术团队优化并应用。其核心是通过​​哈希指纹+概率衰减​​的设计,在有限内存中高效筛选出高频元素,尤其适合互联网场景下符合"二八定律"的数据分布。

算法所需的数据结构:

  1. 一个二维数组,它有d行w列(通过d个哈希函数映射到每行的某个位置,类似于布隆过滤器)
python 复制代码
# 示例结构(d=3行,w=5列)
[
  [ {指纹:123, 计数:5}, {指纹:456, 计数:3}, ... ],  # 第1行
  [ {指纹:789, 计数:7}, {指纹:123, 计数:2}, ... ],  # 第2行
  [ {指纹:456, 计数:9}, {指纹:000, 计数:0}, ... ]   # 第3行
]

2. 计数衰减机制,当发生哈希冲突时,不是简单覆盖,而是通过概率衰减原有计数

  1. 一个大小为k的最小堆,用于记录当前观测到的topK项

算法过程:

  1. 当一个key到达时,对这个key应用d个哈希函数,映射到d个数组中的桶

  2. 对每个映射到的桶:

  • 如果桶为空 或 已存储的哈希指纹与当前的哈希指纹相同,增加计数
  • 如果发生冲突,以概率P(decay) = 1 / (b^C)衰减原有计数,生成一个随机数与decay概率相比较,若比P(decay)小则衰减。若归零则替换为新元素
  1. 最小堆维护:
  • 最小堆辅助​:实时维护当前Top-K元素及最小阈值
  • ​淘汰机制​:新元素计数需超过堆最小值才能入选

有了Caffeine + redis + mysql的多级缓存架构和HotKey检测后,缓存的访问流程为:

  1. 先查询本地缓存,命中后直接返回

  2. 本地缓存未命中,查询redis

  3. 每次访问都记录key的访问频率

  4. 对于访问频率高的key,将数据缓存到本地

点赞实现2: 集成 Pulsar 消息队列,将点赞操作异步化处理(写压力)

Apache Pulsar 是一款云原生的分布式消息流平台,由 Yahoo 开发并于 2016 年开源,现为 Apache 顶级项目。它集消息队列、流处理和持久化存储于一体,专为高吞吐、低延迟的大规模实时场景设计。以下是其核心特性及技术实现的深度解析:

Pulsar 采用 ​​分层架构​​,将计算层(Broker)与存储层(BookKeeper)解耦

  • ​Broker​:无状态节点,负责消息路由、负载均衡和协议处理。通过 ZooKeeper 协调元数据,支持动态扩缩容
  • ​BookKeeper​:分布式预写日志(WAL)系统,由多个 Bookie 节点组成,每个分片(Ledger)以追加写入方式存储,确保数据强一致性和高吞吐(每秒百万级消息)
  • ​ZooKeeper​:管理集群元数据,未来计划逐步减少依赖

灵活的订阅模式​

  • ​独占(Exclusive)​:单消费者独占 Topic,一个 Subscription 只能与一个 Consumer 关联,只有这个 Consumer 可以接收到 Topic 的全部消息,如果该 Consumer 出现故障了就会停止消费。适合严格有序场景。
  • ​灾备(Failover)​:主备消费者自动切换,保障高可用。当存在多个 consumer 时,将会按字典顺序排序,第一个 consumer 被初始化为唯一接受消息的消费者。当第一个 consumer 断开时,所有的消息(未被确认和后续进入的)将会被分发给队列中的下一个 consumer。
  • ​共享(Shared)​:多消费者并行消费,提升吞吐(如电商秒杀场景)。消息通过 round robin 轮询机制(也可以自定义)分发给不同的消费者,并且每个消息仅会被分发给一个消费者。当消费者断开连接,所有被发送给他,但没有被确认的消息将被重新安排,分发给其它存活的消费者。

​流处理一体化​

  • ​Pulsar Functions​:轻量级无服务器计算框架,支持在 Broker 端直接处理消息(如过滤、聚合)
  • ​Pulsar IO​:内置 Connector 生态,无缝对接 MySQL、Elasticsearch 等数据源

分层存储与数据生命周期​

  • ​热存储​:消息默认缓存在 Broker 内存,加速消费。
  • ​冷存储​:老化数据自动迁移至 S3/GCS,降低存储成本

引入pulsar后,点赞流程改为:

这里与第一种方法的主要区别是:不再使用定时任务持久化数据库,而是构造点赞事件发送给消息队列,消息队列异步处理队列中的点赞事件并将数据持久化到数据库。

点赞消息包括:

  1. 当用户发出点赞请求时,服务端首先在redis验证该用户是否已点赞。如果用户未点赞,立即更新redis中的点赞状态,然后构造点赞事件给消息队列。

  2. 消费者异步处理队列中的点赞事件,将数据持久化到数据库。

  3. 消费者批量处理模式,配置为每批次处理1000条消息或等待10s

  4. 消息消费失败后,进行消息重试,重试多次仍失败则进入死信队列

从单机MySQL开移到TiDB分布式教据库

TiDB是PingCAP公司开发的开源分布式关系型数据库,

​TiDB核心组件:

1)TiDB Server: SQL 层,对外暴露MySQL 协议的连接 接口,负责接受客户端的连接,执行 SQL解析和优化,最终生成分布式执行计划 。TiDB 层本身是无状态的,实践中可以启动多个 TiDB 实例,通过负载均衡组件(如 TiProxy、LVS、HAProxy、ProxySQL 或 F5)对外提供统一的接入地址,客户端的连接可以均匀地分摊在多个 TiDB 实例上以达到负载均衡的效果。TiDB Server 本身并不存储数据,只是解析 SQL,将实际的数据读取请求转发给底层的存储节点 TiKV(或 TiFlash)

2) PD (Placement Driver) Server:整个 TiDB集群的元信息管理模块,负责存储每个 TiKV 节点实时的数据分布情况和集群的整体拓扑结构,提供 TiDB Dashboard 管控界面,并为分布式事务分配事务ID。 PD 不仅存储元信息,同时还会根据 TiKV 节点实时上报的数据分布状态,下发数据调度命令给具体的TiKV 节点 ,可以说是整个集群的"大脑"。此外,PD 本身也是由至少3个节点构成,拥有高可用的能力 。建议部署奇数个 PD 节点。

3)存储节点

  • TiKV Server:负责存储数据 ,从外部看 TiKV 是一个分布式的提供事务的 Key-Value 存储引擎。存储数据的基本单位是 Region,每个 Region 负责存储一个 Key Range(从 StartKey 到 EndKey 的左闭右开区间)的数据,每个 TiKV 节点会负责多个 Region。TiKV 的 API 在KV 键值对层面提供对分布式事务的原生支持,默认提供了 SI (Snapshot Isolation)的隔离级别,这也是 TiDB在SQL层面支持分布式事务的核心。TiDB 的 SQL 层做完 SQL 解析后,会将 SQL的执行计划转换为对 TiKVAPI 的实际调用。所以,数据都存储在 TiKV中。另外,TiKV 中的数据都会自动维护多副本(默认为三副本),天然支持高可用和自动故障转移。
  • TiFlash:TiFlash 是一类特殊的存储节点。和普通 TiKV 节点不一样的是,在 TiFlash 内部,数据是以列式的形式进行存储,主要的功能是为分析型的场景加速。

五大核心特性:

  • 一键水平扩缩容:得益于 TiDB 存储计算分离的架构的设计,可按需对计算、存储分别进行在线扩容或者缩容,扩容或者缩容过程中对应用运维人员透明。
  • 金融级高可用:数据采用多副本存储,数据副本通过 Multi-Raft 协议同步事务日志,多数派写入成功事务才能提交,确保数据强一致性且少数副本发生故障时不影响数据的可用性。可按需配置副本地理位置、副本数量等策略,满足不同容灾级别的要求。
  • 实时 HTAP:提供行存储引擎 TiKV、列存储引擎 TiFlash 两款存储引擎,TiFlash 通过Multi-Raft Learner 协议实时从 TiKV 复制数据,确保行存储引擎 TiKV 和列存储引擎 TiFlash 之间的数据强一致。TiKV、TiFlash 可按需部署在不同的机器,解决 HTAP 资源隔离的问题。
  • 云原生的分布式数据库:专为云而设计的分布式数据库,通过 TiDB Operator 可在公有云、私有云、混合云中实现部署工具化、自动化。
  • 兼容 MySQL 协议和 MySQL生态:兼容MySQL 协议、MySQL常用的功能、MySQL 生态,应用无需或者修改少量代码即可从MySQL 迁移到 TiDB。提供丰富的数据迁移工具帮助应用便捷完成数据迁移。

本文档总体介绍可用于 TiDB 的数据迁移方案。数据迁移方案如下:

    1. 全量数据迁移。
    • 数据导入:使用 TiDB Lightning 将 Aurora Snapshot,CSV 文件或 SQL dump 文件的数据全量导入到 TiDB 集群。
    • 数据导出:使用 Dumpling 将 TiDB 集群的数据全量导出为 CSV 文件或 SQL dump 文件,从而更好地配合从 MySQL 数据库或 MariaDB 数据库进行数据迁移。
    • TiDB DM (Data migration) 也提供了适合小规模数据量数据库(例如小于 1 TiB)的全量数据迁移功能
    1. 快速初始化 TiDB 集群:TiDB Lightning 提供的快速导入功能可以实现快速初始化 TiDB 集群的指定表的效果。请注意,使用快速初始化 TiDB 集群的功能对 TiDB 集群的影响极大,在进行初始化的过程中,TiDB 集群不支持对外访问。
    1. 增量数据迁移:使用 TiDB DM 从 MySQL,MariaDB 或 Aurora 同步 Binlog 到 TiDB,该功能可以极大降低业务迁移过程中停机窗口时间。
    1. TiDB 集群复制:TiDB 支持备份恢复功能,该功能可以实现将 TiDB 的某个快照初始化到另一个全新的 TiDB 集群。
    1. TiDB 集群增量数据同步:TiCDC 支持同构数据库之间的灾备场景,能够在灾难发生时保证主备集群数据的最终一致性。目前该场景仅支持 TiDB 作为主备集群。

本项目目前的数据量远达不到TB级别,同时只需要一次全量同步即可。因此直接用SQL文件的方式迁移数据。

场景:在保证线上服务平稳运行的前提下,如何迁移数据?

在实际生产中,官方建议TiDB DM + TiDB Lightning,过程如下:

  1. 全量数据同步:使用mydumper导出mysql中的数据,通过TiDB Lightning并行导入,禁止用TiKV写入保护

  2. 增量数据同步:配置 DM-worker 监听 MySQL Binlog ,通过 ​​Sharding DDL 协调器​​ 合并分库分表 DDL(如拆分的用户表合并)

  3. 双写验证阶段:在应用层同时写入 MySQL 和 TiDB,通过 ​​分布式事务​​ 保证双写一致性

  4. 切换读流量:将读请求切换到TiDB

  5. 切换写流量:确认无误后,将写请求切换到TiDB

  6. 下线旧MySQL:完成迁移后,逐步下线MySQL实例

基于 Prometheus + Grafana 构建全方位监控体系

Prometheus

Prometheus 是一款开源的云原生系统监控和告警工具包,由 SoundCloud 工程师于 2012 年开发,2016 年成为 CNCF 第二个毕业项目。

Prometheus 收集并将其指标存储为 时间序列 数据,即指标信息与记录时的时间戳一起存储,还可以带有称为标签的可选键值对。

Prometheus 的主要特性包括:

• 具有多维数据模型的时间序列数据 ,通过指标名称和键值对来识别,指标由 <metric_name>{label1="value1",...} 唯一标识,例如 http_requests_total{method="POST", path="/api"}

• 使用灵活的查询语言 PromQL

• 不依赖分布式存储;单个服务器节点是自主的

• 通过 HTTP的拉取模型进行时间序列数据收集

• 通过中间网关支持推送时间序列数据

• 目标通过服务发现或静态配置来发现

• 支持多种图形和仪表盘显示模式

简单来说,指标是数值测量。时间序列这个术语指的是随时间变化的记录。用户想要测量的内容因应用程序而异。对于 web 服务器,可能是请求时间;对于数据库,可能是活动连接数或活动查询数等。

指标类型​

  • ​Counter(计数器)​ :单调递增计数器(如请求总数),适合速率计算 rate(http_requests_total[5m])。一种累积型指标,代表单调递增的计数值。应用场景为记录服务请求数量、完成的任务数或者发生的错误数等,不适合用于可能会减少的值,比如当前运行的进程线程数量。
  • ​Gauge​(仪表):瞬时值测量(如内存使用量),支持直接加减操作。一个可以任意上下波动的单一数值,适合用于测量类数据,如温度、当前内存使用量、并发请求的数量。
  • **​Histogram(直方图):**用于测量数值分布情况的指标类型,适用于测量请求持续时间、响应大小等。工作原理是把观测值放进预先定义的多个区间(桶)中计数,同时计算所有观测值的总和与计数。当用户请求这些数据时,Prometheus可以使用histogram_quantile()函数计算出分位数,比如找出95%的请求响应时间在什么范围内。Histogram 的特点是在服务器端计算分位数,支持跨多个实例的聚合计算,但精确度相对较低。
  • **Summary(摘要):**与Histogram类似,都可以用于观察数值的分布情况,但是原理不同,Summary会直接在客户端计算并存储分位数。Summary不支持跨多个实例的分位数聚合,但是提供的计算结果更精确。

在实际应用场景中,Histogram多用于服务监控场景,Summary适用于需要精确分位数的场景。

架构

Prometheus 主服务器 ,用于抓取和存储时间序列数据。 通过 PromQL 定义告警阈值规则(如 100 - (avg(node_cpu_seconds_total{mode="idle"}) * 100) > 80),触发后推送至 Alertmanage

• 用于检测应用程序代码的客户端库

支持短期作业的推送网关(Pushgateway) ,为短期作业(如批处理脚本)提供 Push 通道,数据暂存后由 Prometheus Server 拉取

• 用于 HAProxy、StatsD、Graphite 等服务的特殊用途导出器

处理告警的告警管理器(Alertmanager) ,支持分组、去重、静默和路由策略,集成邮件、Slack、Webhook 等通知渠道。动态抑制​​:例如当集群级故障触发时,自动屏蔽相关实例的重复告警

· Exporters​指标转换器​​:将第三方系统数据转为 Prometheus 格式,覆盖硬件(Node Exporter)、数据库(MySQL Exporter)、中间件(Nginx Exporter)等 200+ 生态

• 各种支持工具

为了易于构建和部署为静态二进制文件,大多数 Prometheus 组件都是用Go语言编写的。

Prometheus 从已检测的jobs 中抓取指标,可以直接抓取,也可以通过中间推送网关为短期jobs 抓

取。它将所有抓取的样本本地存储,并在这些数据上执行一些运算,这样就能从现有数据中聚合并记录新的时间序列数据或生成告警,之后可以使用Grafana 或其他 API 消费者可视化收集的数据。

适用场景

Prometheus 适用于记录任何纯数值时间序列,比如服务器中心的监控、高度动态的面向服务的架构、
微服务的监控
。Prometheus 注重可靠性,在系统发生故障时,能够快速判断出问题。每个Prometheus 服务器都是独立的,不依赖网络存储或其他远程服务,所以即使底层设施的其他部分出现问题,Prometheus 仍然可用。

不适用场景

Prometheus 重视可靠性,即使在故障条件下,也始终可以查看系统的可用统计信息。如果需要100%的准确性,例如按请求计费,Prometheus就不太合适,因为它收集的数据可能不够详细和完整。在这种情况下,最好使用其他系统来收集和分析计费数据,将 Prometheus 用于其余的监控

PromQL 基础语法

PromQL(Prometheus Query Language)是 Prometheus 提供的强大查询语言,用于查询和分析存储在 Prometheus 时序数据库中的监控数据。支持灵活的查询和计算,能够提取、过滤、聚合和转换时间序列数据,经常结合 Grafana 进行可视化展示、告警设置和数据分析。为了方便后续使用,我们需要先对 PromQL 有一些基础的了解。

特点

1. 支持多维数据查询:

• 通过 标签(Label)进行筛选,如{job="node",instance="localhost:9100"}。

2. 支持多种数据类型:

• 瞬时向量 (Instant Vector):某一时间点的多个时间序列数据(如 cpu_usage{instance="

localhost:9100"})。

。区间向量(Range Vector):一段时间内的数据集合(如 cpu_usage[5m])。

。标量(Scalar):单个浮点数值(如 5*60)。

• 字符串(String):很少使用,目前主要用于 labe1_replace()等函数的匹配操作。

  1. 丰富的运算支持:

◎ 算术运算(+-*/%^)

◎ 比较运算(==!=><>=<=)

• 逻辑运算(and or unless)

。 聚合操作(如 sum(),avg(),max(),min(),count(),rate()等)

  1. 内置监控指标查询:

。例如 up 指标可用于检查 Prometheus 目标 (target)的运行状态:uptjob="node"}

。 rate()函数用于计算指标的变化速率,如计算过去 5分钟内 HTTP 请求的速率:rate(httP_requests_total[5m])

  1. 支持高级函数:

。时间序列处理(rate(),irate(),increase())

。 标签操作(label_replace(),label_join())

。 直方图计算(histogram_quantile()

基本的查询指标:

python 复制代码
metric_name{label1="value1", label2="value2"}

其中:

• metric_name 是指标名称

• 花括号内是标签过滤条件,支持多种匹配操作符:

• =:精确匹配

• !=:不等于

• =~:正则表达式匹配

• !~:正则表达式不匹配

python 复制代码
1. 查询当前CPU使用率
cpu_usage{instance="localhost:9100"}

2. 计算HTTP请求速率(过去五分钟)
rate(http_requests_total[5m])

3. 计算所有实例CPU使用率的平均数
avg(rate(cpu_usage[5m]))

4. 统计所有目标的存活情况
count(up == 1)

5. 计算95%线(P95)请求延迟
histogram_quantile(0.95, rate(http_requests_duration_seconds_bucket[5m]))
histogram_quantile(0.95, ...)基于直方图数据计算​​95%分位数​​,即找出一个时间值,使得95%的请求持续时间≤该值,5%的请求超过该值
rate(http_request_duration_seconds_bucket[5m])计算每个桶在过去5分钟内的​​每秒增长率​​,由于直方图的桶是累积计数器(Counter),rate()会将原始计数转换为每秒增量,反映时间窗口内各桶的实际增长速率

Grafana

Grafana 是一款开源的跨平台数据可视化和监控工具,支持通过实时、交互式仪表板监控和分析各类数据,能够对存储在任何位置的指标进行查询、可视化和告警。

​数据可视化​

  • ​多源支持​:支持 Prometheus、InfluxDB、Elasticsearch、MySQL 等 50+ 数据源,通过统一界面展示异构数据。
  • ​图表类型​:提供折线图、热力图、仪表盘等多种可视化形式,支持自定义样式和动态交互。
  • ​动态仪表盘​:用户可通过拖放组件(如 Panel、Row)灵活构建仪表盘,实时展示多维度指标。

监控告警​

  • ​阈值告警​ :基于 PromQL 或 SQL 定义告警规则,触发后通过邮件、Slack、Webhook 等渠道通知。
  • ​智能分组​:Alertmanager 支持告警去重和路由策略,避免信息过载。

本项目使用

整体架构包括四个关键部分:

• 应用侧的指标埋点与暴露

• Prometheus 的指标采集与存储

• Grafana 的指标可视化

• Alertmanager 的告警管理

在应用层,利用 Spring Boot Actuator 和 Micrometer 框架进行指标埋点,过"/actuator/prometheu

s"端点(默认端点)暴露监控数据。Prometheus 服务器定期从各个应用实例(后端服务、Redis、TiDB)抓取指标数据并进行存储。Grafana 连接 Prometheus 数据源,提供丰富的可视化面板。当监控指标触发预设的告警规则时,Alertmanager 将通过邮件、钉钉或企业微信等渠道通知到我们。

Prometheus 会通过 HTTP 协议定期抓取应用暴露的指标。配置 Prometheus 以适当的频率(比如15

秒)从各个服务实例拉取数据,同时设置合理的数据保留周期。

可视化设计:用 Grafana 构建多层次的监控面板,覆盖从业务到基础设施的各个方面:

1.业务概览面板:展示点赞系统的核心业务指标,如点赞成功率等

  1. 服务性能面板:监控接口响应时间分布等

  2. 存储监控面板:监控 TiDB、Redis 等存储系统的性能指标

  3. 消息队列面板:展示 Pulsar 的消息处理状况、积压情况等

面板需要注重信息的 层次感和可读性,避免过多冗余信息干扰判断。对于核心指标,可以添加同环比分析功能,快速识别异常变化。

告警策略:一个合格的可观测性系统必须要有相应的告警措施,围绕"及时、准确、有效"的原则设计的告警策略。

基于 AlertManager,可以实现多渠道、分级的告警机制:

  1. 告警分级:按照 P0(严重)、P1(重要)、P2(一般)三级划分告警优先级

  2. 告警渠道:工作群(钉钉、企微)、邮件

  3. 告警抑制:避免告警风暴,合理设置告警间隔和聚合规则

关键监控指标的告警阈值可以根据历史数据和业务重要性设定,例如:

• PO级告警:服务接口成功率低于 99.5%、核心接口 P99 延迟超过 1S

• P1 级告警:服务接口成功率低于 99.9%、核心接口 P95 延迟超过 500ms

• P2级告警:缓存命中率低于 80%、消息积压量持续增长等

在本项目中,我们会重点监控以下关键指标:

1 业务指标:
• QPS->反映系统负载情况
• 点赞成功率->直接关系用户体验

2) 性能指标:
• P95/P99 响应时间->反映系统性能稳定性
• Redis 缓存命中率->影响系统整体性能

• Pulsar 消息处理延迟->反映异步处理效率

3)资源指标:

• JVM 内存使用率

• 垃圾回收频率和持续时间->影响服务稳定性

• 虚拟线程

4)依赖服务指标:

• Redis 连接数和请求延迟

• TiDB 查询延迟和错误率

• Pulsar 消息积压量

在ThumbController中集成计数器指标:

java 复制代码
private final Counter successCounter;  
private final Counter failureCounter;  
  
public ThumbController(MeterRegistry registry) {  
    this.successCounter = Counter.builder("thumb.success.count")  
            .description("Total successful thumb")  
            .register(registry);  
    this.failureCounter = Counter.builder("thumb.failure.count")  
            .description("Total failed thumb")  
            .register(registry);  
}

统计成功/失败数,用于计算点赞成功率:

java 复制代码
@PostMapping("/do")  
public BaseResponse<Boolean> doThumb(@RequestBody ThumbAddRequest thumbAddRequest,  
                                      HttpServletRequest request) {  
    try {  
        boolean result = thumbService.doThumb(thumbAddRequest, request);  
        if (result) {  
            // 记录成功计数  
            successCounter.increment();  
            return ResultUtils.success(true);  
        } else {  
            // 记录失败计数  
            failureCounter.increment();  
            return ResultUtils.error(ErrorCode.SYSTEM_ERROR);  
        }  
    } catch (Exception e) {  
        // 记录失败计数  
        failureCounter.increment();  
        return ResultUtils.error(e.getMessage());  
    }  
}

为了监控 Redis 的性能和缓存命中率,我们需要添加 Redis Exporter。使用 Docker运行 Redis Exporter:

bash 复制代码
docker run --name redis-exporter \
  -p 9121:9121 \
  oliver006/redis_exporter \
  --redis.addr=redis://host.docker.internal:6379 \
  --redis.password=xxx

Grafana配置各项指标面板

  1. QPS:输入查询语句 sum(rate(http_server_requests_seconds_count[1m]))

http_server_requests_seconds_count 是 Spring Boot Actuator Micrometer 生成的 HTTP 请求总数相关的指标,经常用于分析请求频率。如果我们在 Prometheus 的控制台(localhost:9090)查询这个指标,会得到类似数据:

• exception="None":是否有异常

• outcome="SUCCESS":请求结果

· method="GET":表示 HTTP 方法

• status="200":HTTP 状态码

• uri="/actuator/prometheus":请求路径

• 299:表示该接口累计被请求299次

  1. 分位数
  1. 点赞成功率
  1. 缓存命中率
  1. 请求失败数

配置Alertmanager

使用Apache JMeter压力测试

压测配置

1)创建线程组

线程组(Thread Group)​​:定义虚拟用户数(并发量)、Ramp-Up 时间(加压速率)和循环次数,模拟真实用户行为。

先创建一个线程组,主要是填写线程数、启动时间、循环次数3个值。

  • 线程数 * 循环次数 = 要测试的请求总数
  • 启动时间的作用是控制线程的启动速率,从而控制请求速率。例如,10秒启动100个线程,那么每秒启动10个线程,相当于最开始每秒10个请求。

注意,每秒启动的线程数要大于接口的QPS(每秒请求数),才能测试到极限,不能因为请求速度不够影响测试结果。

这里统一性能测试标准:

  • 线程数:5010个 / 组
  • 启动时间(Ramp-Up):5秒
  • 循环次数:10组

也就是说,我们模拟了5010个用户,每个用户在5s内请求10次, 相当于每秒5000 * (10 / 5) = 1w个请求,一共5w个请求。

2)创建HTTP信息头管理

可以自主添加请求头,比如设置请求头Content-Type为application/json,和我们要测试的接口保持一致。

还可以添加Cookie,存储登录态:

3)新建HTTP请求

填写要测试的接口路径、请求类型、请求参数等。

请求参数和前端进入主页时发送的请求一致

4)配置压测结果展示

添加查看结果树、聚合报告:

获取多用户的登录态

为了测试更真实的场景,要模拟多用户并发请求点赞接口, 那请求头中的Cookie就不能写死,而是要通过某种方式动态读取,这里我们采用读取CSV文件的形式来实现。

先通过脚本模拟登录,然后记录相应头中的Session到CSV文件中,最后使用JMeter读取CSV文件中的Session。

1)通过脚本获取Session

java 复制代码
// Spring Boot测试注解(加载完整上下文)
@SpringBootTest
// 自动配置MockMvc(模拟HTTP请求)
@AutoConfigureMockMvc
class ThumbBackendApplicationTests {

    @Resource
    private ThumbService thumbService;

    @Resource
    private UserService userService;

    @Resource
    private BlogService blogService;

    @Test
    void contextLoads() {
        // 打印各服务的数据列表(用于验证服务是否正常注入)
        System.out.println(thumbService.list());
        System.out.println(userService.list());
        System.out.println(blogService.list());
    }
    
     // 批量创建用户测试
    @Test
    void addUser() {
        for (int i = 0; i < 50000; i++) { // 创建5万个测试用户
            User user = new User();
            // 生成6位随机用户名(Hutool工具)
            user.setUsername(RandomUtil.randomString(6));
            userService.save(user);
        }
    }

    // 注入MockMvc用于模拟HTTP请求
    @Resource
    private MockMvc mockMvc;

    // 登录测试并导出Session到CSV
    @Test
    void testLoginAndExportSessionToCsv() throws Exception {
        List<User> list = userService.list();

        // 使用try-with-resources自动关闭文件流
        try (PrintWriter writer = new PrintWriter(new FileWriter("session_output.csv", true))) {
            // 写入CSV表头(仅在文件首次创建时写入)
            writer.println("userId,sessionId,timestamp");

            for (User user : list) {
                long testUserId = user.getId();

                // 模拟发送GET登录请求(实际项目建议用POST)
                MvcResult result = mockMvc.perform(get("/user/login")
                                // 添加请求参数
                                .param("userId", String.valueOf(testUserId))
                                .contentType(MediaType.APPLICATION_JSON))
                        .andReturn(); // 获取完整响应

                // 验证响应头包含Set-Cookie
                List<String> setCookieHeaders = result.getResponse().getHeaders("Set-Cookie");
                assertThat(setCookieHeaders).isNotEmpty();

                // 提取Spring Session ID(格式示例:SESSION=abc123; Path=/; ...)
                String sessionId = setCookieHeaders.stream()
                        .filter(cookie -> cookie.startsWith("SESSION")) // 过滤目标Cookie
                        .map(cookie -> cookie.split(";")[0]) // 取第一个分段(SESSION=xxx)
                        .findFirst()
                        .orElseThrow(() -> new RuntimeException("No SESSION found in response"));

                // 解析Session值(SESSION=后的部分)
                String sessionValue = sessionId.split("=")[1];

                // 写入CSV文件(格式:用户ID,Session值,当前时间)
                writer.printf("%d,%s,%s%n", testUserId, sessionValue, LocalDateTime.now());

                System.out.println("✅ 写入 CSV:" + testUserId + " -> " + sessionValue);
            }
        }
    }
}

执行成功后,在项目根目录会生成一个CSV文件:

2)将CSV文件导入到 JMeter中

注意:

  1. 变量名称中,第二个变量名称为sessionId对应的是Session,与我们的CSV文件对应
  2. "遇到文件结束符再次循环"一定为True,"遇到文件结束符停止线程"一定为False, 否则会影响模拟请求的次数。

3)修改HTTP信息头管理器,动态从CSV文件中读取

使用${}方式读取变量,注意要与上一步中定义的变量名称一致。

测试基础实现

这种情况下,接口响应的平均值为12.6s左右,最慢的是在26.1s左右才会返回响应,吞吐量TPS(每秒事务处理的数量,一个事务表示客户端向服务器发送请求,然后响应)为358.8/s,最重要的是出现了异常!

测试 引入Redis校验是否已点赞 的实现

发现性能几乎没变化,依然有异常,响应时间也依然较长。因为这里主要针对的是对读操作的优化,而目前的性能瓶颈不是在读上,而是两次数据库的写操作,特别是博客点赞量的更新还有热点行问题。

测试 使用Redis + 定时任务更新数据库 的实现

测试使用Pulsar消息队列

后续优化

使用空接口测试最大性能

java 复制代码
@RestController
@RequestMapping("index")
public class IndexController {

    @GetMapping
    public String index() {
        return "hello world";
    }

}

场景与面试问题

两级缓存之间的"一致性"如何保证?

Caffeine 缓存中存储的数据

Caffeine 缓存作为本地内存缓存,用于存储热键数据,其特点如下:

存储容量:最多 1000 条记录,有效期 5 分钟 CacheManager.java:49-52

数据格式:采用"hashKey:key"格式的复合键,用于存储点赞关系数据 CacheManager.java:56-58

内容:被标识为热键的频繁访问点赞记录(用户与博客点赞关系)

Redis 缓存中存储的数据

Redis 作为分布式缓存层,主要包含两种数据类型:

用户点赞键:格式为"thumb:{userId}",用于存储每个用户点赞过的博客 ThumbConstant.java:11

临时点赞键:格式为"thumb:temp:{timeSlice}",用于存储用于批处理的临时点赞操作 ThumbConstant.java:18

临时键使用向下取整到最接近的 10 秒间隔计算的时间片

  1. 多级缓存策略

系统实现了分层缓存查找:首先检查本地 Caffeine 缓存,如果本地缓存未命中,则查找 Redis CacheManager.java:64-77

  1. 热键检测和选择性缓存

使用 HeavyKeeper 算法检测热键,并仅将频繁访问的数据缓存在本地 CacheManager.java:32-44 。仅当数据被识别为热键时,才会将其提升到本地缓存 CacheManager.java:82-85

java 复制代码
// 4. 如果是热 Key 且不在本地缓存,则缓存数据
if (addResult.isHotKey()) {
    localCache.put(compositeKey, redisValue);
}
  1. 条件缓存更新

系统使用 putIfPresent 方法,仅在键已存在时才更新本地缓存,从而防止缓存污染,同时保持热键的一致性 CacheManager.java:90-97

  1. 更新的直写模式

当 Thumb 操作发生时,系统会同时更新 Redis 数据,并使用直写模式有条件地更新本地缓存 ThumbServiceImpl.java:68-74

  1. 定期缓存清理

系统会运行计划清理任务以保持缓存的新鲜度,包括每 20 秒淡化一次热键检测器数据 CacheManager.java:100-103

  1. 数据库同步

Redis 临时数据会通过计划作业定期同步到数据库,确保缓存和持久存储之间的最终一致性 SyncThumb2DBJob.java:40-48

Redis缓存与数据库之间的数据一致性问题如何解决?

最终一致性。

  1. 基于时间片的批量同步(单redis无消息队列)

本项目采用时间片方法,将 Redis 操作批量处理并定期同步到数据库。系统使用基于时间的键将临时的点赞数据存储在 Redis 中,并进行批量处理(ThumbServiceRedisImpl.java:45-48)。

每 10 秒运行一次定时作业,将这些临时数据从 Redis 同步到数据库(SyncThumb2DBJob.java:40-48)。同步过程同时处理"点赞"和"取消点赞"操作,在更新博客点赞计数的同时执行批量插入和删除(SyncThumb2DBJob.java:94-103)

  1. 基于消息队列的异步更新

该项目还提供了一种使用 Apache Pulsar 消息队列进行异步处理的替代实现。在这种方法中,Redis 会立即更新,而数据库更新则通过消息事件 ThumbServiceMQImpl.java:48-68 异步处理。

Pulsar 消费者会批量处理这些事件,并通过为每个用户-博客对保留最新事件 ThumbConsumer.java:76-91 来处理重复和冲突的操作。这确保了高效的批处理,同时保持了数据完整性。

原子操作和数据完整性

系统使用 Lua 脚本确保 Redis 操作的原子性,防止并发 like/unlike 操作期间出现竞争条件 (RedisLuaScriptConstant.java:21-44)。这些脚本会以原子方式检查现有状态、更新计数器并维护用户 like 状态 (RedisLuaScriptConstant.java:39-41)。

  1. 协调与恢复机制

为了确保即使在故障情况下也能保持最终一致性,该项目实施了多种恢复机制:

每日协调:每日运行计划作业,比较 Redis 和数据库数据,识别不一致之处并生成补偿事件(pulsar版)

补偿同步:通过处理 Redis 中剩余的临时点赞数据来处理丢失的同步(单redis版)

一致性模型是最终一致性,而非强一致性。这种设计选择适用于点赞系统,因为:

性能优先:Redis 作为读取操作的直接数据源,提供快速响应时间

可接受的不一致窗口:对于点赞等社交功能,数据库更新的暂时延迟是可以接受的

高可用性:即使数据库同步暂时失败,系统也能继续运行

可扩展性:异步处理使系统能够处理高吞吐量场景

项目中的消息队列Pulsar

消息不丢失

  1. 当消息在最大重试次数后仍失败时,它们将被发送到死信队列以防止消息丢失。DLQ 有一个专用的消费者,用于记录失败的消息并通知管理员
  2. 生产者发送消息后,需等待 Broker 的写入确认(ACK)。若未收到确认,生产者自动重试
    1. 消息确认(ACK)​ :消费者成功处理消息后需显式发送 ACK,Broker 收到后才会标记消息可清理。未确认的消息会被保留并重投。
    2. ​否定应答(NACK)​ :消费者处理失败时可发送 NACK,触发消息立即重投。
    3. ​ACK 超时机制​:若消费者未在指定时间(如 30 秒)内确认,消息自动重新加入投递队列
  3. 生产者发送消息时,Broker 将消息写入 BookKeeper 集群,每条消息需持久化到多个 Bookie 节点(默认 2 副本)。仅当多数副本(Quorum)成功写入磁盘后,Broker 才向生产者返回确认。例如配置 ackQuorum=2 时,需至少 2 个 Bookie 确认写入成功。即使节点故障也能通过剩余副本恢复数据。

处理重复消息

ThumbConsumer.processBatch 中,消费者会根据 (userId, blogId) 对 对所有传入事件进行分组,并且只处理每个唯一对 中的最新事件。这确保即使同一用户-博客组合收到多个"喜欢/不喜欢"事件,也只处理最新的事件。

  • 每个生产者(Producer)为消息分配严格递增的 sequenceId。Broker 为每个 Producer 维护一个哈希表,记录当前接收到的最大 sequenceId;若新消息的 sequenceId ≤ Broker 记录的 sequenceId,视为重复消息,​直接丢弃​ 并返回确认。若 sequenceId 更大,则持久化消息并更新哈希表。
  • 当消息需写入多个分区(如分库分表场景),基础去重无法保证原子性。
  • ​实现​
    • ​事务协调器(TC)​:由 Broker 担任,管理全局事务状态。
    • ​事务生产者​
      生产者开启事务后,发送到不同分区的消息​先预提交到事务缓冲区​,待全局事务提交时才真正可见。
    • ​原子提交​
      若事务回滚,所有预提交消息标记为 Aborted,消费者不可见;提交后消息才持久化并释放

消息有序性

  • 生产者发送消息时指定业务 Key(如订单 ID),Pulsar 根据 Key 的哈希值将消息​固定路由到同一分区​ (Partition)。同一分区内消息严格按追加顺序存储,天然保证​分区内有序​
  • 每个分区对应 BookKeeper 中的一个 ​Ledger(分段日志)​ ,消息以追加写入(Append-Only)方式存储,​物理存储顺序即消息顺序​

该项目并不依赖 Pulsar 内置的 thumb 事件消息排序保证。相反,它实现了应用程序级排序逻辑来处理潜在的乱序消息传递。

消息生产者配置

Thumb 事件无需任何排序键配置即可发送到 Pulsar。 生产者使用简单的异步发送,无需指定分区键或排序键。

消息消费者配置

消费者配置为共享订阅类型,允许多个消费者同时处理消息,但不保证消息的排序。

消息排序的关键在于消费者的批处理逻辑:

  1. 消息分组:消息按 (userId, blogId) 对分组,以确保对同一用户-博客组合的操作一起处理。
  2. 基于时间的排序:在每个组中,事件按事件时间排序,以建立正确的时间顺序。
  3. 重复数据删除逻辑:系统仅处理包含奇数个事件的组,从而有效地消除点赞/取消点赞的操作对。
  4. 最新事件处理:只有每个组中的最新事件决定最终操作(增加或减少)。

消息堆积

该项目实现了批处理,以高效处理消息累积。消费者配置为单批最多处理 1000 条消息,超时时间为 10 秒(ThumbConsumerConfig.java:23-30)。这种方法有助于更高效地处理累积消息,而无需逐条处理。

消费者使用共享订阅类型,允许多个消费者实例分担工作负载,有助于在水平扩展时更快地处理积压消息

前端

本项目借助cursor使用ai来帮助我们编写前端代码。

cursor的几种交互模式:

  1. ask:
  • 这是一个"只读"模式,主要用于提问和探索代码库,AI被动响应问题。
  • 理解当前文件或选中代码,不会对代码进行修改。
  • 适合询问特定代码段的问题、获取复杂函数的解释、查找代码模式和示例等。
  1. agent:
  • AI主动协助,全程参与项目开发。
  • 理解整体项目结构和依赖关系,生成项目骨架。
  • 适合从零搭建新功能模块、重构代码库。
  1. manual:
  • 用户完全控制代码编写,AI仅作为参考。
  • 不主动理解上下文。
  • 适合熟悉的技术栈、简单任务、代码审查等。

后端所有代码实现

com.blue.thumb

.common(项目的公共模块,用于存放通用组件)

BaseResponse

通用返回类:

1. Lombok 注解​ ​:@Data 自动生成 getter/setterequalshashCodetoString 方法,减少样板代码。

  1. 字段
  • 状态码​code 是 HTTP 状态码的扩展,通常与业务错误码结合使用(参考 ErrorCode 参数构造器)。
  • ​数据主体​data 通过泛型 T 动态承载响应内容,例如 API 返回的 JSON 数据体。
  • ​消息​message 用于补充说明操作结果(如错误详情),增强接口可读性。
  1. 初始化所有字段,适合需要完整信息的场景(如自定义业务状态码)

  2. 成功响应无需额外消息时使用(如 code=200data 有效)

  3. ​错误 :通过 ErrorCode 枚举(enum)统一管理错误码和消息,提升代码可维护性;data=null 表示操作失败时无有效数据返回

java 复制代码
    public BaseResponse(int code, T data) {
        this(code, data, "");
    }

    public BaseResponse(ErrorCode errorCode) {
        this(errorCode.getCode(), null, errorCode.getMessage());
    }
ErrorCode

错误码:

  • 分段结构​ :采用5位数字编码(如 40100),通常前3位关联 HTTP 状态码类别,后2位表示具体错误类型
  • ​语义关联​401xx 表示认证/授权问题,404xx 表示资源问题,500xx 表示服务端错误
java 复制代码
    SUCCESS(0, "ok"),
    PARAMS_ERROR(40000, "请求参数错误"),
    NOT_LOGIN_ERROR(40100, "未登录"),
    NO_AUTH_ERROR(40101, "无权限"),
    NOT_FOUND_ERROR(40400, "请求数据不存在"),
    FORBIDDEN_ERROR(40300, "禁止访问"),
    OPERATION_ERROR(50001, "操作失败"),

private final int code; // 错误码(不可变) private final String message; // 错误描述(不可变)

  • final 修饰确保枚举实例的不可变性,线程安全
  • code 用于程序逻辑判断,message 用于前端展示或日志记录
ResultUtils

作为全局响应构建工具,集中管理成功/失败响应的创建逻辑,避免代码重复。

成功:

java 复制代码
public static <T> BaseResponse<T> success(T data) {
    return new BaseResponse<>(0, data, "ok");
}

User user = userService.getById(1);
return ResultUtils.success(user);
  • 泛型设计​<T> 支持动态返回数据类型(如 StringList<User>)。
  • ​参数解析​
    • code=0:与 ErrorCode.SUCCESS 的状态码一致,表示操作成功。
    • data:业务数据主体(如查询结果)。
    • message="ok":固定成功提示,与 ErrorCode.SUCCESS 的默认消息匹配。

失败:

java 复制代码
public static BaseResponse<?> error(ErrorCode errorCode) {
    return new BaseResponse<>(errorCode);
}
  • errorCode:预定义的错误码枚举(如 ErrorCode.PARAMS_ERROR)。
  • 底层调用 BaseResponseErrorCode 构造函数,自动填充 code 和默认 message

.config(存放 Spring Boot 的配置类)

CorsConfig(全局跨域配置)
  • @Configuration:标记为配置类,Spring 容器启动时自动加载。
  • WebMvcConfigurer :通过实现此接口,重写 addCorsMappings 方法实现全局 CORS 配置
  • registry.
    • addMapping("/**") :匹配所有接口路径(如 /api/**),实现全局跨域控制
    • allowCredentials(true):允许跨域请求携带认证信息(如 Cookie)
    • allowedOriginPatterns("*") :使用通配符允许所有域名(*
    • allowedMethods:包含 GET、POST等 方法以支持预检请求(Preflight)
    • allowedHeaders("*") :允许所有自定义请求头(如 Authorization
    • exposedHeaders("*") :允许客户端读取所有响应头(如 Set-Cookie),但应仅暴露必要头
JsonConfig(Spring MVC Json 配置)
  • @JsonComponent :Spring Boot 专用注解,标记该类为 Jackson 的扩展组件,自动注册到 Spring 容器中,无需手动配置;作用等价于 @Configuration + @Bean,但更专注于 Jackson 的定制化。
  • Jackson2ObjectMapperBuilder :Spring Boot 提供的构建器,用于简化 ObjectMapper 的配置,支持 Fluent API 链式调用
  • ObjectMapper 初始化​ :ObjectMapper是Jackson 的核心序列化工具,负责 JSON 与 Java 对象的转换。
    • createXmlMapper(false):禁用 XML 序列化功能,仅支持 JSON
    • build() :基于构建器的默认配置(如日期格式、空值处理)创建 ObjectMapper 实例
  • 自定义序列化模块:
    • SimpleModule:Jackson 提供的模块化扩展机制,用于添加自定义序列化/反序列化器
    • ToStringSerializer :将 Longlong 类型序列化为字符串,避免 JavaScript 因 Number 类型精度限制(2^53-1)导致的数据丢失;示例:Java 中的 1234567890123456789L 序列化为 "1234567890123456789" 而非 1234567890123456800
RedisConfig(Spring Data Redis 的序列化机制和最佳实践)
  1. 初始化模版与连接工厂
  • RedisConnectionFactory :由 Spring Boot 自动注入,管理 Redis 连接池。
  • ​模板初始化​ :创建 RedisTemplate 实例并绑定连接工厂。
  1. 配置Jackson序列化器,使用 Jackson2JsonRedisSerializer 序列化值
  • ​类型安全处理​
    • activateDefaultTyping:启用默认类型信息嵌入,支持反序列化时识别多态类型。
    • LaissezFaireSubTypeValidator:允许所有子类反序列化,需注意潜在的安全风险。
  • ​序列化器定义​ :使用 Jackson2JsonRedisSerializer 处理值对象,支持复杂类型序列化JSON。
  1. 序列化策略配置,Key使用String序列化
  • ​键序列化​
    • StringRedisSerializer:将键序列化为 UTF-8 字符串,避免二进制乱码。
  • ​值序列化​
    • Jackson2JsonRedisSerializer:将对象序列化为 JSON,保留类型信息(如 @class 字段)。
  • ​Hash 结构​ :对 Hash 的键值采用相同策略,保持一致性。
  • ​属性生效​afterPropertiesSet() 确保配置参数正确初始化。
  1. Spring Session序列化器配置
  • 覆盖 Spring Session 默认的 JDK 序列化,使用 JSON 存储会话数据。
  • GenericJackson2JsonRedisSerializer :相比 Jackson2JsonRedisSerializer,无需指定具体类型,自动处理泛型
ThumbConsumerConfig(集成Pulsar的自定义消费者配置代码)
  • PulsarListenerConsumerBuilderCustomizer :Spring Pulsar 提供的接口,允许通过 customize 方法深度定制消费者行为。
  • 批量接收策略配置。BatchReceivePolicy:控制批量消息接收策略
    • maxNumMessages:单次批量拉取多少条信息
    • timeout:超时后触发批量处理。
  • NACK重试策略
    • 当消费者调用 negativeAcknowledge() 时触发重试。
    • RedeliveryBackoff:消息重投递的退避策略接口。
    • .minDelayMs(1000) // 初始延迟 1 秒 .maxDelayMs(60000) // 最大延迟 60 秒 .multiplier(2) // 指数退避倍数。首次失败后 1 秒重试,后续每次延迟时间翻倍(1s → 2s → 4s...),上限 60 秒
  • ACK超时重试策略
    • 消费者未在指定时间内确认消息(默认 ACK 超时 30 秒)触发。
    • .minDelayMs(5000) // 初始延迟 5 秒 .maxDelayMs(300_000) // 最大延迟 300 秒(5分钟) .multiplier(3) // 指数退避倍数。首次超时后 5 秒重试,后续延迟按 3 倍递增(5s → 15s → 45s...),上限 5 分钟
  • 死信队列策略
    • DeadLetterPolicy:定义消息失败后的死信队列处理规则
    • .maxRedeliverCount(3) // 最大重试 3 次 .deadLetterTopic("thumb-dlq-topic") // 死信主题名称
    • 消息重试 3 次失败后,自动路由到 thumb-dlq-topic,避免无限重试导致资源浪费

.constant(常量类)

RedisLuaScriptConstant(点赞Lua脚本)

定义了四个Lua脚本:THUMB_SCRIPT、UNTHUMB_SCRIPT、THUMB_SCRIPT_MQ和UNTHUMB_SCRIPT_MQ。这些脚本处理点赞和取消点赞的逻辑,涉及原子操作和防止重复操作。通过 RedisScript 接口封装脚本,支持 Spring Data Redis 的 execute 方法调用

java 复制代码
public class RedisLuaScriptConstant {
    public static final RedisScript<Long> THUMB_SCRIPT = new DefaultRedisScript<>("""
        // Lua脚本内容
        """, Long.class);
}

THUMB_SCRIPT

  • tempThumbKey:临时哈希表,按时间分片(thumb:temp:202305),用于批量更新数据库。
  • userThumbKey:永久哈希表(thumb:userId),记录用户对哪些博客点过赞。
Lua 复制代码
    local tempThumbKey = KEYS[1]       -- 临时计数键(如 thumb:temp:{timeSlice})
            local userThumbKey = KEYS[2]       -- 用户点赞状态键(如 thumb:{userId})
            local userId = ARGV[1]             -- 用户 ID
            local blogId = ARGV[2]             -- 博客 ID
            
            -- 1. 检查是否已点赞(避免重复操作)
            if redis.call('HEXISTS', userThumbKey, blogId) == 1 then
                return -1  -- 已点赞,返回 -1 表示失败
            end
            
            -- 2. 获取旧值(不存在则默认为 0)
            local hashKey = userId .. ':' .. blogId
            local oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0)
            
            -- 3. 计算新值
            local newNumber = oldNumber + 1
            
            -- 4. 原子性更新:写入临时计数 + 标记用户已点赞
            redis.call('HSET', tempThumbKey, hashKey, newNumber)
            redis.call('HSET', userThumbKey, blogId, 1)
            
            return 1  -- 返回 1 表示成功

UNTHUMB_SCRIPT

  • 检查已点赞改为检查是否未点赞,如果未点赞则返回失败(~= 1)
  • 计算新值改为-1

轻量级消息队列版本(THUMB_SCRIPT_MQ/UNTHUMB_SCRIPT_MQ)​

Lua 复制代码
-- 仅操作用户状态键,计数交由消息队列异步处理
if redis.call("HEXISTS", userThumbKey, blogId) == 1 then
    return -1
end
redis.call("HSET", userThumbKey, blogId, 1)
return 1

在这种实现中,lua脚本不处理点赞计数,由pulsar消息队列批量处理

ThumbConstant(点赞记录中的模板常量)

String USER_THUMB_KEY_PREFIX = "thumb:"; // 用户点赞状态键前缀(如:thumb:user:123)

Long UN_THUMB_CONSTANT = 0L; // 取消点赞的操作标识(如:0表示取消)

String TEMP_THUMB_KEY_PREFIX = "thumb:temp:%s"; // 临时计数键模板(如:thumb:temp:202305)

UserConstant

用于会话管理,如在 Redis 中存储登录用户信息(键如 login_user:123)或在 HTTP 请求属性中标识当前用户

java 复制代码
package com.yuyuan.thumb.constant;

public interface UserConstant {
    public static final String LOGIN_USER = "login_user";
}

.controller(Spring MVC控制器类)

BlogController
  • BlogBlogVO:实体类与视图对象,实现数据层与展示层解耦。
  • @Resource:Jakarta EE 的依赖注入注解,按名称自动装配 Bean
  • HttpServletRequest:用于获取请求信息(如 Session、IP 等)
  • @RestController :标识为 RESTful 控制器,自动将返回值序列化为 JSON。
  • @RequestMapping("blog") :定义基础路径为 /blog,所有方法 URL 以该路径为前缀
  • @Resource :注入 BlogService 实现类,优先按名称匹配 Bean(等效于 @Autowired + @Qualifier

获取单个博客

java 复制代码
@GetMapping("/get")
public BaseResponse<BlogVO> get(long blogId, HttpServletRequest request) {
    BlogVO blogVO = blogService.getBlogVOById(blogId, request);
    return ResultUtils.success(blogVO);
}

获取博客列表

java 复制代码
@GetMapping("/list")
public BaseResponse<List<BlogVO>> list(HttpServletRequest request) {
    List<Blog> blogList = blogService.list();
    List<BlogVO> blogVOList = blogService.getBlogVOList(blogList, request);
    return ResultUtils.success(blogVOList);
}

​业务逻辑​​:

  1. 调用 blogService.list() 获取原始数据列表。
  2. 转换为视图对象 BlogVO 列表,可能包含权限过滤或数据脱敏
ThumbController
  • @RestController:声明为 RESTful 控制器,自动将返回值序列化为 JSON
  • @RequestMapping("thumb") :基础路径映射为 /thumb,所有方法 URL 以该路径为前缀。
java 复制代码
private final Counter successCounter;
private final Counter failureCounter;

public ThumbController(MeterRegistry registry) {
    this.successCounter = Counter.builder("thumb.success.count")
            .description("Total successful thumb").register(registry);
    this.failureCounter = Counter.builder("thumb.failure.count")
            .description("Total failed thumb").register(registry);
}
  • ​Micrometer 计数器​ :通过构造函数初始化两个计数器,用于统计成功和失败次数。
    • thumb.success.count:成功点赞的累计次数。
    • thumb.failure.count:失败操作的累计次数(包括异常和业务逻辑失败)。
  • ​设计意图​:提供监控指标,便于集成 Prometheus + Grafana 监控系统。
java 复制代码
@PostMapping("/do")
public BaseResponse<Boolean> doThumb(@RequestBody DoThumbRequest request, HttpServletRequest httpRequest) {
    Boolean success;
    try {
        success = thumbService.doThumb(request, httpRequest);
        if (success) {
            successCounter.increment(); // 成功计数
        } else {
            failureCounter.increment(); // 业务逻辑失败计数
        }
    } catch (Exception e) {
        failureCounter.increment(); // 异常失败计数
        throw e; // 抛出异常由全局异常处理器处理[6,7,8](@ref)
    }
    return ResultUtils.success(success);
}
  • @PostMapping("/do") :处理 POST 请求 /thumb/do
  • ​参数解析​
    • @RequestBody DoThumbRequest:接收 JSON 格式的请求体,自动反序列化为 DTO 对象。
    • HttpServletRequest:获取请求上下文(如用户 Session、IP 等)。
  • 业务逻辑​
    • 调用 thumbService.doThumb 执行点赞操作。
    • 根据结果更新监控计数器。
    • 异常捕获后统一计数,并通过 throw e 触发全局异常处理
UserController
  • @RestController :声明为 RESTful 控制器,自动将返回值序列化为 JSON。
  • @RequestMapping("user") :定义基础路径为 /user,所有方法 URL 前缀为此路径
java 复制代码
@GetMapping("/login")
public BaseResponse<User> login(long userId, HttpServletRequest request) {
    User user = userService.getById(userId);
    request.getSession().setAttribute(UserConstant.LOGIN_USER, user);
    return ResultUtils.success(user);
}
  • @GetMapping("/login") :映射 GET 请求到 /user/login,接收 userId 参数。
  • ​参数解析​
    • long userId:通过 URL 查询参数获取(如 /user/login?userId=1)。
    • HttpServletRequest:获取会话对象,用于存储登录态。
  • ​业务逻辑​
    • 调用 userService.getById 查询用户信息。
    • 将用户对象存入会话,键名为常量 LOGIN_USER
    • 返回用户信息,响应体格式为 BaseResponse<User>

.exception(全局异常处理)

GlobalExceptionHandler
java 复制代码
@RestControllerAdvice
@Slf4j
// 在接口文档中隐藏
@Hidden
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    public BaseResponse<?> runtimeExceptionHandler(RuntimeException e) {
        log.error(e.getMessage(), e);
        return ResultUtils.error(ErrorCode.OPERATION_ERROR, e.getMessage());
    }
}
  • @RestControllerAdvice :组合了 @ControllerAdvice@ResponseBody,表示这是一个全局异常处理器,所有异常响应自动转换为 JSON 格式。
  • @Slf4j :自动生成日志对象,用于记录异常信息。
  • @Hidden:在 Swagger/OpenAPI 文档中隐藏此控制器,避免暴露异常处理端点
  • @ExceptionHandler(RuntimeException.class) :捕获所有 RuntimeException 及其子类异常(如 NullPointerExceptionIllegalArgumentException
  • 参数​RuntimeException e 接收抛出的异常对象。
  • log.error(e.getMessage(), e) 记录错误消息和完整堆栈跟踪,便于排查问题
  • 调用 ResultUtils.error() 构建标准化错误响应,包含预定义的错误码 ErrorCode.OPERATION_ERROR 和异常消息
java 复制代码
{
  "code": 50002,
  "message": "操作失败",
  "data": null
}

.job(定时、持久化、补偿任务)

SyncThumb2DBCompensatoryJob

定时将 Redis 中的临时点赞数据同步到数据库的补偿措施。当数据在 Redis 中,由于不可控因素停机导致没有成功同步到数据库时,通过该任务补偿。

  • RedisTemplate​ :用于操作 Redis 键值,支持模糊查询 (keys 方法) 和数据结构管理。
  • ​SyncThumb2DBJob​:封装具体的数据同步逻辑,实现业务解耦
java 复制代码
@Scheduled(cron = "0 0 2 * * *")
    public void run() {
        log.info("开始补偿数据");
        Set<String> thumbKeys = redisTemplate.keys(RedisKeyUtil.getTempThumbKey("") + "*");
        Set<String> needHandleDataSet = new HashSet<>();
        thumbKeys.stream().filter(ObjUtil::isNotNull).forEach(thumbKey -> needHandleDataSet.add(thumbKey.replace(ThumbConstant.TEMP_THUMB_KEY_PREFIX.formatted(""), "")));

        if (CollUtil.isEmpty(needHandleDataSet)) {
            log.info("没有需要补偿的临时数据");
            return;
        }
        // 补偿数据
        for (String date : needHandleDataSet) {
            syncThumb2DBJob.syncThumb2DBByDate(date);
        }
        log.info("临时数据补偿完成");
    }

​Cron 表达式​ ​:0 0 2 * * * 表示每天凌晨2点执行,用于低峰期处理补偿任务

  • 模糊查询​redisTemplate.keys 通过通配符 * 匹配所有临时点赞数据键,例如 temp:thumb:20240520
  • ​数据存储​ :使用 HashSet 存储待处理日期集合,避免重复
  • Hutool 判空​ObjUtil.isNotNull 过滤无效键值,防止 NPE。
  • ​键名处理​ :通过字符串替换提取日期部分(如 20240520),剥离前缀 temp:thumb:
  • Hutool 集合工具​CollUtil.isEmpty 替代原生 Collection.isEmpty,增强可读性
  • 职责分离​ :调用 syncThumb2DBJob 实现具体同步逻辑,符合单一职责原则。
  • ​补偿机制​:针对 Redis 中残留的临时数据(可能因服务宕机未同步),确保数据最终一致性
SyncThumb2DBJob

定时将 Redis 中的临时点赞数据同步到数据库

java 复制代码
@Resource
private ThumbService thumbService;      // 点赞业务服务
@Resource
private BlogMapper blogMapper;         // 博客数据库操作
@Resource
private RedisTemplate<String, Object> redisTemplate; // Redis 操作模板

定时任务配置

java 复制代码
@Scheduled(fixedRate = 10000)
@Transactional(rollbackFor = Exception.class)
public void run() {
    DateTime nowDate = DateUtil.date();
    String date = DateUtil.format(nowDate, "HH:mm:") + (DateUtil.second(nowDate) / 10 - 1) * 10;
    syncThumb2DBByDate(date);
}
  • @Scheduled :每 10 秒执行一次同步任务,fixedRate 表示从任务开始时间计算间隔。
  • ​时间窗口计算​ :按 10 秒时间窗口生成 Redis 键名(如 12:34:20 对应 12:34:10),实现数据分片存储。
  • @Transactional:启用事务管理,确保数据库操作的原子性

数据同步核心逻辑

java 复制代码
public void syncThumb2DBByDate(String date) {
    // 使用 temp:thumb:{date} 作为键,字段为 userID:blogID,值为点赞类型(+1/-1)
    String tempThumbKey = RedisKeyUtil.getTempThumbKey(date);
    // entries() 获取指定键的所有字段和值,数据格式为 用户ID:博客ID -> 操作类型(1/-1)
    Map<Object, Object> allTempThumbMap = redisTemplate.opsForHash().entries(tempThumbKey);
    
    // Hutool工具类数据判空
    if (CollUtil.isEmpty(allTempThumbMap)) return;
    
    // 初始化数据结构
    ArrayList<Thumb> thumbList = new ArrayList<>();
    // 动态构建删除条件(userId 和 blogId 组合),支持批量删除取消的点赞记录
    LambdaQueryWrapper<Thumb> wrapper = new LambdaQueryWrapper<>();
    // 键是博客ID(blogId),值是该博客的点赞增量(累计的 thumbType 值)
    Map<Long, Long> blogThumbCountMap = new HashMap<>();
    boolean needRemove = false;
    
    // 遍历 Redis 数据
    for (Object userIdBlogIdObj : allTempThumbMap.keySet()) {
        String userIdBlogId = (String) userIdBlogIdObj;
        // 根据:拆分userID与blogID
        String[] userIdAndBlogId = userIdBlogId.split(StrPool.COLON);
        Long userId = Long.parseLong(userIdAndBlogId[0]);
        Long blogId = Long.parseLong(userIdAndBlogId[1]);
        Integer thumbType = Integer.valueOf(allTempThumbMap.get(userIdBlogId).toString());
        
        // 处理点赞(1)和取消点赞(-1)
        if (thumbType == ThumbTypeEnum.INCR.getValue()) {
            Thumb thumb = new Thumb().setUserId(userId).setBlogId(blogId);
            thumbList.add(thumb);
        } else if (thumbType == ThumbTypeEnum.DECR.getValue()) {
            // or()声明后续条件与前一个条件通过 ​​OR 逻辑连接
            // .eq()添加等值条件,相当于SQL中的=
            // .eq(Thumb::getUserId, userId):匹配 user_id 字段等于参数 userId
            // WHERE (user_id = ? OR blog_id = ?)
            wrapper.or().eq(Thumb::getUserId, userId).eq(Thumb::getBlogId, blogId);
            needRemove = true;
        }
        
        // 统计博客点赞增量
        // ​​getOrDefault(blogId, 0L)​​:若 blogId 已存在,则返回其当前值;若不存在,则返回默认值 0L(避免 NullPointerException)
        blogThumbCountMap.put(blogId, blogThumbCountMap.getOrDefault(blogId, 0L) + thumbType);
    }
    
    // 批量插入新点赞
    thumbService.saveBatch(thumbList);
    
    // 批量删除取消点赞
    if (needRemove) thumbService.remove(wrapper);
    
    // 批量更新博客点赞量
    if (!blogThumbCountMap.isEmpty()) {
        blogMapper.batchUpdateThumbCount(blogThumbCountMap);
    }
    
    // 使用虚拟线程异步删除 Redis 数据
    Thread.startVirtualThread(() -> redisTemplate.delete(tempThumbKey));
}
ThumbReconcileJob (集成pulsar)
一个定时任务,用于点赞数据的对账,检查Redis和MySQL之间的数据一致性,并发送补偿事件。
  • ​RedisTemplate​:用于操作 Redis 键值,支持 SCAN 分页查询
  • ​ThumbService​:MyBatis Plus 的 Service 层,处理数据库操作。
  • ​PulsarTemplate​:Apache Pulsar 的消息发送模板,实现异步事件补偿
  • @Scheduled :Cron 表达式触发定时任务;​低峰期执行​:选择凌晨2点避免业务高峰期影响

获取该分片下的所有用户ID

java 复制代码
// 存储从 Redis 中提取的用户 ID
Set<Long> userIds = new HashSet<>();
// * 是通配符,匹配所有以该前缀开头的键
String pattern = ThumbConstant.USER_THUMB_KEY_PREFIX + "*";
// 执行 SCAN 命令分页遍历键​​
try (Cursor<String> cursor = redisTemplate.scan(
    // ScanOptions​​配置扫描参数,包括匹配模式和分页数量。
    // .match(pattern):设置键的匹配模式
    // .count(1000):提示 Redis 单次返回约 1000 个键
    ScanOptions.scanOptions().match(pattern).count(1000).build())) {
    // 遍历游标获取键
    while (cursor.hasNext()) {
        String key = cursor.next();
        // key.replace()​​:去除键名前缀
        Long userId = Long.valueOf(key.replace(ThumbConstant.USER_THUMB_KEY_PREFIX, ""));
        userIds.add(userId);
    }
}

从 Redis 获取用户点赞的博客 ID​​、从 MySQL 获取持久化的博客 ID​​、计算数据差异​​(Redis中有但是MySQL中没有的数据)

java 复制代码
// 遍历用户 ID 集合 userIds,逐个处理每个用户的点赞数据对账
// ​​Lambda 表达式​​:Java 8 的 forEach 语法,替代传统循环
userIds.forEach(userId -> {
    // 从 Redis 获取该用户userId点赞的博客 blogID
    Set<Long> redisBlogIds = redisTemplate.opsForHash()
        // ​​opsForHash().keys()​​:获取指定 Redis 键的所有字段(即博客 ID),返回 Set<Object>
        .keys(ThumbConstant.USER_THUMB_KEY_PREFIX + userId)
        // ​​stream().map()​​:将字段值(Object 类型)转换为 Long 类型
        .stream().map(obj -> Long.valueOf(obj.toString()))
        // ​​Collectors.toSet()​​:收集为 Set<Long> 集合,自动去重
        .collect(Collectors.toSet());

    // 从 MySQL 获取已持久化的博客 ID
    // 通过 MyBatis Plus 的 Lambda 表达式构建查询条件,筛选 Thumb 表中 userId 字段等于参数 userId 的所有记录
    // 将查询结果包装为 Optional 对象,允许接受 null 值
    Set<Long> mysqlBlogIds = Optional.ofNullable(thumbService.lambdaQuery()
            // 添加 WHERE user_id = userId 条件
            .eq(Thumb::getUserId, userId).list())
        // 若查询结果为 null,返回空列表作为默认值
        .orElse(new ArrayList<>())
        // 将 List<Thumb> 转换为 Stream<Thumb>,并通过 map 操作提取每个 Thumb 对象的 blogId 字段
        .stream().map(Thumb::getBlogId)
        // 将 Stream<Long> 收集为 Set<Long>,自动去重 blogId
        .collect(Collectors.toSet());
        // 最终 mysqlBlogIds 是用户 userId 在 MySQL 中所有点赞的博客 ID 集合。

    // 计算差异(Redis 有但 MySQL 无)
    // 包含所有在集合 ​​redis​ 中存在但集合 ​​mysql 中​​不存在​​的元素
    Set<Long> diffBlogIds = Sets.difference(redisBlogIds, mysqlBlogIds);

    // 4. 发送补偿事件
    sendCompensationEvents(userId, diffBlogIds);
});

发送补偿事件到Pulsar

  • ​作用​:定义私有方法,用于发送点赞补偿事件。
  • ​参数​
    • userId:触发补偿操作的用户 ID。
    • blogIds:需补偿的博客 ID 集合(Redis 中存在但 MySQL 缺失的数据)。
java 复制代码
private void sendCompensationEvents(Long userId, Set<Long> blogIds) {
    blogIds.forEach(blogId -> {
        ThumbEvent thumbEvent = new ThumbEvent(userId, blogId, ThumbEvent.EventType.INCR, LocalDateTime.now());
        pulsarTemplate.sendAsync("thumb-topic", thumbEvent)
            .exceptionally(ex -> {
                log.error("补偿事件发送失败: userId={}, blogId={}", userId, blogId, ex);
                return null;
            });
    });
}
  • ​逻辑​ :使用 forEach 遍历 blogIds 集合,逐个处理需要补偿的博客 ID。
  • 参数解析​
    • userIdblogId:标识具体用户和博客的补偿操作。
    • EventType.INCR:表示点赞增量事件(对应 Redis 的点赞计数恢复)。
    • LocalDateTime.now():记录事件触发时间,用于后续审计或延迟处理
  • Pulsar 异步发送​
    • sendAsync() :非阻塞发送消息,提升吞吐量。
    • ​Topic 名称​thumb-topic 是预定义的 Pulsar 主题,需确保与消费者订阅匹配
    • ​异常捕获​exceptionally() 捕获异步发送中的异常(如网络故障、Topic 不存在),避免因单条消息失败导致整个补偿流程中断。
    • ​日志记录​ :记录详细的错误信息(包括 userIdblogId),便于后续人工干预或自动化重试

.listener.thumb(点赞事件监听)

.msg ThumbEvent(定义点赞事件)

@Data功能​​:组合注解,自动生成以下方法:

  • ​Getter/Setter​:所有字段的访问器和修改器。
  • ​toString()​:返回包含所有字段的字符串表示。
  • ​equals()/hashCode()​:基于所有非静态字段生成对象相等性判断和哈希值。

@Builder功能​​:生成建造者模式代码,支持链式调用:

  • 创建 ThumbEventBuilder 内部类,提供字段的链式设置方法(如 .userId(1L).blogId(2L))。
  • 通过 ThumbEvent.builder().build() 构造对象。

@NoArgsConstructor​ ​:生成无参构造函数(public ThumbEvent() {})。

​@AllArgsConstructor​ ​:生成包含所有字段的全参构造函数(public ThumbEvent(Long userId, Long blogId, ...)

​字段类型​​:

  • Long:用户和博客的唯一标识符,支持高并发场景的 ID 生成。
  • EventType:自定义枚举,表示操作类型(如点赞 INCR 或取消点赞 DECR)。
  • LocalDateTime:记录事件发生时间,支持精确到纳秒的时间处理。
java 复制代码
private Long userId;
    private Long blogId;
    // INCR/DECR
    private EventType type;
    private LocalDateTime eventTime;

public enum EventType {
    INCR,
    DECR
}
ThumbConsumer(处理点赞事件的消费者)
  • @Service:声明为 Spring 服务组件,由容器管理生命周期。
  • @RequiredArgsConstructor :Lombok 生成基于 final 字段的构造函数,自动注入 BlogMapperThumbService
  • @Slf4j :自动生成日志对象 log,用于记录操作日志。

死信队列监听器

java 复制代码
@PulsarListener(topics = "thumb-dlq-topic")
public void consumerDlq(Message<ThumbEvent> message) {
    MessageId messageId = message.getMessageId();
    log.info("dlq message = {}", messageId);
    log.info("消息 {} 已入库", messageId);
    log.info("已通知相关人员 {} 处理消息 {}", "坤哥", messageId);
}

死信队列主题​ ​:当消息在常规队列中达到最大重试次数(通过 deadLetterPolicy 配置,本项目为3次)后,会被自动路由到此主题

Message<ThumbEvent> ​:封装了消息内容(ThumbEvent 对象)和元数据(如消息 ID、生产者信息、投递时间戳等)

功能解析​​:

  1. ​记录死信消息​:将消息 ID 写入日志,便于后续审计。
  2. ​数据持久化​:将消息内容存储至数据库(如 MySQL 或 Elasticsearch),防止数据丢失。
  3. ​人工干预通知​:通过日志触发告警(如邮件、短信)通知运维人员(如"坤哥")介入处理
java 复制代码
@PulsarListener(
    subscriptionName = "thumb-subscription", // 订阅名称,用于标识消费者组
    topics = "thumb-topic", // 监听的 Pulsar 主题
    schemaType = SchemaType.JSON, // 消息序列化方式(JSON 格式)
    batch = true, // 启用批量消费模式
    subscriptionType = SubscriptionType.Shared, // 共享订阅模式(允许多消费者并行)
    negativeAckRedeliveryBackoff = "negativeAckRedeliveryBackoff", // NACK 重试策略
    ackTimeoutRedeliveryBackoff = "ackTimeoutRedeliveryBackoff", // ACK 超时重试策略
    deadLetterPolicy = "deadLetterPolicy" // 死信队列策略
)
// 将消息处理与数据库操作绑定到同一事务,任一环节异常触发整体回滚
// 若消息处理或数据库更新失败,Pulsar 消息不会被确认(ACK),触发重试机制
@Transactional(rollbackFor = Exception.class) 
public void processBatch(List<Message<ThumbEvent>> messages) {
    // 记录当前批次处理的消息数量
    log.info("ThumbConsumer processBatch: {}", messages.size());
    // 存储每个博客(blogId)的点赞数变化量
    // ​​键(Key)​​:Long 类型,表示博客 ID;
    // 值(Value)​​:Long 类型,表示该博客的点赞数累计变化量。
    Map<Long, Long> countMap = new ConcurrentHashMap<>();
    // 收集需要批量插入数据库的新增点赞记录(Thumb 实体对象)
    List<Thumb> thumbs = new ArrayList<>();

    // 并行处理消息
    // 动态构建删除条件,用于批量删除取消点赞(DECR 事件)对应的记录。
    LambdaQueryWrapper<Thumb> wrapper = new LambdaQueryWrapper<>();
    // 标记是否需要执行删除操作。当至少存在一个 DECR 事件时,设置为 true
    AtomicReference<Boolean> needRemove = new AtomicReference<>(false);
    
    // 提取有效事件​​
    List<ThumbEvent> events = messages.stream()
        .map(Message::getValue) // 从消息中提取事件对象
        .filter(Objects::nonNull) // 过滤掉 value 为 null 的无效消息(如反序列化失败的消息)
        .toList(); // 转换为不可变列表
    
    // 按(userId, blogId)分组,并获取每个分组的最新事件
    // 使用 Pair<Long, Long> 组合用户 ID 和博客 ID
    Map<Pair<Long, Long>, ThumbEvent> latestEvents = events.stream()
        .collect(Collectors.groupingBy( // groupingBy 根据键将事件分组到不同列表中
            e -> Pair.of(e.getUserId(), e.getBlogId()), // 分组键:用户ID + 博客ID组合
            Collectors.collectingAndThen( // 分组后的聚合处理
                Collectors.toList(), // 先收集为列表
                list -> {
                    // 按时间升序排序
                    list.sort(Comparator.comparing(ThumbEvent::getEventTime));
                    // 若分组事件数量为偶数,返回 null 丢弃该分组(可能用于过滤重复操作,如点赞后取消)
                    if (list.size() % 2 == 0) return null;
                    // 取最新事件(排序后最后一个
                    return list.get(list.size() - 1);
                }
            )
        ));

    latestEvents.forEach((userBlogPair, event) -> { // 遍历按(userId,blogId)分组后的最新事件集合
        if (event == null) return; // 过滤空事件(如偶数次操作被业务规则排除)
        ThumbEvent.EventType finalAction = event.getType(); // 获取事件类型(INCR/DECR)


        if (finalAction == ThumbEvent.EventType.INCR) { // 处理点赞逻辑
            countMap.merge(event.getBlogId(), 1L, Long::sum); // 原子性更新博客点赞计数
            Thumb thumb = new Thumb(); // 创建点赞记录实体
            thumb.setBlogId(event.getBlogId());
            thumb.setUserId(event.getUserId());
            thumbs.add(thumb); // 收集待插入的点赞记录
        } else { // 处理取消点赞逻辑
            needRemove.set(true); // 标记为 需要删除
            wrapper.or().eq(Thumb::getUserId, event.getUserId())
               .eq(Thumb::getBlogId, event.getBlogId());
            // Key:博客ID(blogId)Value:增量(+1或-1)函数:Long::sum 实现累加/累减
            countMap.merge(event.getBlogId(), -1L, Long::sum); // 原子性减少博客点赞计数
        }
    });

    // 批量更新数据库
    if (needRemove.get()) {
        thumbService.remove(wrapper);
    }
    batchUpdateBlogs(countMap);
    batchInsertThumbs(thumbs);
}

批量更新博客的点赞计数

java 复制代码
public void batchUpdateBlogs(Map<Long, Long> countMap) {
    if (!countMap.isEmpty()) {
        blogMapper.batchUpdateThumbCount(countMap);
    }
}

批量向数据库插入点赞记录

java 复制代码
public void batchInsertThumbs(List<Thumb> thumbs) {
    if (!thumbs.isEmpty()) {
        // 分批次插入
        thumbService.saveBatch(thumbs, 500);
    }
}

.manager.cache

CacheManager

多级缓存架构 + 初始化HeavyKeeper

java 复制代码
@Component
@Slf4j
public class CacheManager {
    private TopK hotKeyDetector;  // 热点Key检测器(基于HeavyKeeper算法)
    private Cache<String, Object> localCache;  // Caffeine本地缓存实例
    @Resource
    private RedisTemplate<String, Object> redisTemplate;  // Redis操作模板

    @Bean
    public TopK getHotKeyDetector() {
        hotKeyDetector = new HeavyKeeper(
            100,        // 监控Top 100 Key
            100000,     // 哈希表宽度(降低哈希冲突)
            5,          // 哈希表深度(桶的层级)
            0.92,       // 衰减系数(定期减少历史计数,防止旧数据堆积)
            10          // 最小出现次数(阈值,超过才视为热点)
        );
        return hotKeyDetector;
    }
    
    @Bean
    public Cache<String, Object> localCache() {
        return localCache = Caffeine.newBuilder()
            .maximumSize(1000)               // 最大缓存条目数
            .expireAfterWrite(5, TimeUnit.MINUTES)  // 写入5分钟后过期
            .build();
    }
    
    // 缓存键构造方法​​
    private String buildCacheKey(String hashKey, String key) {
        return hashKey + ":" + key;  // 构造复合键(如"user:123")
    }

检索数据与预热

java 复制代码
public Object get(String hashKey, String key) {
    String compositeKey = buildCacheKey(hashKey, key);
    
    // 1. 先查本地缓存
    // 查询Caffeine本地缓存,命中则直接返回
    Object value = localCache.getIfPresent(compositeKey);
    if (value != null) {
        log.info("本地缓存命中: {} = {}", compositeKey, value);
        hotKeyDetector.add(key, 1);  // 记录访问频率,更新热点检测器中的访问计数
        return value;
    }

    // 2. 查询Redis
    // 通过Spring Data Redis访问Redis哈希结构数据
    Object redisValue = redisTemplate.opsForHash().get(hashKey, key);
    if (redisValue == null) return null;

    // 累计访问次数并判断是否达到阈值
    // 3. 检测是否为热点Key
    AddResult addResult = hotKeyDetector.add(key, 1);
    
    // 4. 热点Key则写入本地缓存
    if (addResult.isHotKey()) {
        localCache.put(compositeKey, redisValue);
    }
    
    return redisValue;
}

​条件性更新本地缓存​​,仅当缓存中已存在指定键时更新其值。

参数​​:

  • hashKey:thumb : useID。
  • key:blogID。
  • compositeKey:thumb : userID : blogID
  • value:点赞 / 取消点赞
java 复制代码
public void putIfPresent(String hashKey, String key, Object value) {
    String compositeKey = buildCacheKey(hashKey, key);
    // 查询 Caffeine 本地缓存,若键存在则返回旧值,否则返回 null
    Object object = localCache.getIfPresent(compositeKey);
    // 若缓存未命中(object == null),直接终止方法,​​避免插入新键值对
    if (object == null) {
        return;
    }
    localCache.put(compositeKey, value);
}

定时任务:清理过期的热Key检测数据

java 复制代码
// 定时清理过期的热 Key 检测数据
// 表示每​​20​s​执行一次方法
@Scheduled(fixedRate = 20, timeUnit = TimeUnit.SECONDS)
public void cleanHotKeys() {
    hotKeyDetector.fading(); // 对统计的Key访问计数进行​​指数衰减​​(乘以0.92系数)
}

固定速率模式​​(fixedRate特性):

  • 每20秒触发一次,​不考虑前次任务执行时长​
  • 若任务执行时间超过20秒,会立即启动新线程执行(需配置线程池)
HeavyKeeper
java 复制代码
public class HeavyKeeper implements TopK {
    private static final int LOOKUP_TABLE_SIZE = 256;
    private final int k;          // 维护的 Top-K 数量
    private final int width;      // 哈希表每行的桶数量
    private final int depth;      // 哈希表的行数(哈希函数数量)
    private final double[] lookupTable; // 预计算的衰减概率表
    private final Bucket[][] buckets;    // 二维哈希桶数组
    private final PriorityQueue<Node> minHeap; // 最小堆维护 Top-K
    private final BlockingQueue<Item> expelledQueue; // 被移出 Top-K 的队列
    private final Random random;  // 用于概率衰减
    private long total;           // 总访问次数
    private final int minCount;   // 进入 Top-K 的最小阈值
    
    // 构造函数
    public HeavyKeeper(int k, int width, int depth, double decay, int minCount) {
        // 初始化参数
        this.lookupTable = new double[LOOKUP_TABLE_SIZE];
        for (int i = 0; i < LOOKUP_TABLE_SIZE; i++) {
            lookupTable[i] = Math.pow(decay, i); // 预计算衰减概率,生成衰减概率表
        }
        // 初始化二维哈希桶
        this.buckets = new Bucket[depth][width];
        for (int i = 0; i < depth; i++) {
            for (int j = 0; j < width; j++) {
                buckets[i][j] = new Bucket(); // 每个桶存储指纹和计数
            }
        }
        // 初始化最小堆和队列
        this.minHeap = new PriorityQueue<>(Comparator.comparingInt(n -> n.count));
        this.expelledQueue = new LinkedBlockingQueue<>();
        this.random = new Random();
        this.total = 0;
    }
}

lookupTable 是一个​​预计算的概率衰减表​

​存储内容​ ​:每个元素对应 decay^i,即 i 次方的衰减概率值。例如:

  • lookupTable[0] = 1decay^0
  • lookupTable[1] = decaydecay^1
  • lookupTable[2] = decay^2,依此类推

在 ​​HeavyKeeper​ ​ 处理哈希冲突时,lookupTable 用于​​动态调整低频键的计数​​:

  1. ​冲突处理​ :当两个不同键哈希到同一桶(Bucket)时,根据当前桶的计数值 count,从 lookupTable 中获取对应的衰减概率 decay^count
  2. ​概率衰减​ :通过 random.nextDouble() < decay 判断是否减少该桶的计数值。高频键因计数高(decay^count 值小),衰减概率低;低频键则更可能被衰减淘汰。
  3. ​性能优化​ :预计算替代实时计算 Math.pow(decay, count),减少 CPU 开销(类似网页2提到的查表加速原理)

add方法处理数据流中的元素并更新TopK

java 复制代码
@Override
public AddResult add(String key, int increment) {
    byte[] keyBytes = key.getBytes(); // 将键转换为字节数组 keyBytes(用于哈希计算)
    long itemFingerprint = hash(keyBytes); // 计算键的指纹
    int maxCount = 0; //maxCount 用于记录该键在所有哈希层中的最大计数值(最终用于判断是否进入 TopK)

    // 遍历所有哈希层(depth 行),通过多个哈希函数(h1, h2, ..., hd)映射到不同行的桶
    for (int i = 0; i < depth; i++) {
        // bucketNumber 通过哈希计算确定当前行的桶位置,% width 保证不越界
        int bucketNumber = Math.abs(hash(keyBytes)) % width; // 哈希到当前行的桶
        Bucket bucket = buckets[i][bucketNumber];
        
        synchronized (bucket) { // 线程安全
            if (bucket.count == 0) { // Case 1: 桶为空
                // 直接占用空桶,记录指纹和初始计数值。
                bucket.fingerprint = itemFingerprint;
                bucket.count = increment;
            } else if (bucket.fingerprint == itemFingerprint) { // Case 2: 指纹匹配
                // 指纹一致时直接增加计数
                bucket.count += increment;
            } else { // Case 3: 冲突,概率衰减
                for (int j = 0; j < increment; j++) {
                    // 通过预计算的 lookupTable(存储 decay^count 概率)决定是否衰减冲突桶的计数
                    double decay = (bucket.count < LOOKUP_TABLE_SIZE) 
                        ? lookupTable[bucket.count] 
                        : lookupTable[LOOKUP_TABLE_SIZE - 1];
                    if (random.nextDouble() < decay) { // 按概率衰减
                        bucket.count--;
                        if (bucket.count == 0) { // 桶清空后重新占用
                            bucket.fingerprint = itemFingerprint;
                            bucket.count = increment - j;
                            maxCount = Math.max(maxCount, bucket.count); // 取各层的最大计数
                            break;
                        }
                    }
                }
            }
        }
    }

    // total 记录所有键的总访问量(用于监控)
    total += increment;
    // minCount 是进入 TopK 的最低频率阈值,未达标直接返回。
    if (maxCount < minCount) {
        return new AddResult(null, false, null);
    }

    // 更新最小堆
    // 以 minHeap 对象为锁,所有对 minHeap 的操作(增、删、查)在并发场景下互斥执行,避免数据不一致。
    synchronized (minHeap) { 
        // 标记当前Key是否进入Top-K
        boolean isHot = false;
        // 记录被挤出Top-K的Key
        String expelled = null;
        // 遍历最小堆,检查当前Key是否已存在于堆中
        Optional<Node> existing = minHeap.stream().filter(n -> n.key.equals(key)).findFirst();
        
        if (existing.isPresent()) { // 键已存在堆中
            // 删除堆中已有的相同Key节点(旧计数可能已过时)
            minHeap.remove(existing.get());
            // 用当前最新的 maxCount 创建新节点加入堆
            minHeap.add(new Node(key, maxCount)); // 更新计数
            // 更新操作后Key仍留在Top-K中,标记为热点
            isHot = true;
        } else { // 新键尝试加入堆
            // 堆未满 || 堆已满但新计数 >= 堆顶
            if (minHeap.size() < k || maxCount >= minHeap.peek().count) {
                if (minHeap.size() >= k) { // 堆满时挤出堆顶
                    // poll() 移除堆顶元素(最小计数项)
                    expelled = minHeap.poll().key;
                    // 将被淘汰的Key加入 expelledQueue,供外部监控(
                    expelledQueue.offer(new Item(expelled, maxCount));
                }
                minHeap.add(new Node(key, maxCount));
                isHot = true;
            }
        }
        return new AddResult(expelled, isHot, key);
    }
}

获取当前Top-K列表:最小堆内部按计数升序排列,但对外提供降序列表,便于用户直接获取热点排名。

java 复制代码
@Override
public List<Item> list() {
    synchronized (minHeap) { // 线程安全:锁定最小堆对象
        List<Item> result = new ArrayList<>(minHeap.size());
        for (Node node : minHeap) { // 遍历最小堆中的节点
            result.add(new Item(node.key, node.count)); // 将节点转换为 Item 对象
        }
        result.sort((a, b) -> Integer.compare(b.count(), a.count())); // 按计数降序排序
        return result;
    }
}

expelled() 方法:获取被移出 Top-K 的队列​​

java 复制代码
@Override
public BlockingQueue<Item> expelled() {
    return expelledQueue;              // 直接返回被淘汰项的阻塞队列
}

fading()方法:调用来定期清理历史热点数据

java 复制代码
@Override
public void fading() {
    for (Bucket[] row : buckets) {
        for (Bucket bucket : row) {
            synchronized (bucket) { // 锁定单个桶对象
                bucket.count = bucket.count >> 1; // 计数值右移一位(等价于除以2)
            }
        }
    }
        
    synchronized (minHeap) {
        PriorityQueue<Node> newHeap = new PriorityQueue<>(Comparator.comparingInt(n -> n.count));
        for (Node node : minHeap) {
            newHeap.add(new Node(node.key, node.count >> 1)); // 重建新堆并衰减计数
        }
        minHeap.clear();
        minHeap.addAll(newHeap);
    }
        
    total = total >> 1; // 全局总访问量右移一位(等价于总数减半)
}

total()方法:获取系统中所有Key的历史访问总数

java 复制代码
@Override
public long total() {
    return total;    // 返回全局总访问量(如所有键的累计计数)
}

Bucket类:每个 Bucket 是二维哈希表中的一个桶,存储:

  1. fingerprint:通过 hash(byte[] data) 计算的64位哈希值,用于区分相同位置的不同键。
  2. count:记录该键在当前桶的累计访问次数。
java 复制代码
private static class Bucket {
    long fingerprint; // 哈希指纹,用于唯一标识一个键
    int count;        // 当前键在此桶中的计数值
}

Node类:作为最小堆(PriorityQueue)中的元素,用于维护 ​​Top-K 高频键

java 复制代码
private static class Node {
    final String key;   // 键名
    final int count;    // 当前键的计数值(在堆中的排序依据)

    Node(String key, int count) {
        this.key = key;     // 不允许键名修改(防御性编程)
        this.count = count; // 不可变计数,保障一致性
    }
}

hash函数:为输入数据生成快速且低冲突的哈希值

  • 选择MurmurHash的原因​
    • ​高效性​:优化后的位运算,性能优于 MD5、SHA 等加密算法。
    • ​扩散性​:良好的雪崩效应,相似输入生成差异巨大的哈希值。
    • ​实现简单​:无需外部依赖,适用于内存受限场景。
  • ​应用场景​ :在 HeavyKeeper 的每一层哈希表(depth 层)中生成不同的桶索引(int bucketNumber = ...
java 复制代码
private static int hash(byte[] data) {
    return HashUtil.murmur32(data); // 使用MurmurHash算法生成32位哈希值
}

新增返回结果类AddResult

java 复制代码
// 新增返回结果类
@Data
class AddResult {
    // 被挤出Top-K的 key
    private final String expelledKey;
    // 当前 key 是否进入/保留 TopK
    private final boolean isHotKey;
    // 当前操作的 key
    private final String currentKey;

    public AddResult(String expelledKey, boolean isHotKey, String currentKey) {
        this.expelledKey = expelledKey;
        this.isHotKey = isHotKey;
        this.currentKey = currentKey;
    }

}

@Data 注解​​:Lombok 注解,自动生成以下方法:

  • getter 方法:用于访问所有字段。
  • equals()hashCode():基于所有字段的相等性判断。
  • toString():生成包含所有字段的字符串表示。
  • ​注意​ :由于字段为 final,Lombok 不会生成 setter 方法,确保对象​不可变性​(线程安全)

参数说明​​:

  • expelledKey:由 HeavyKeeper.add() 方法在淘汰旧键时传递(例如 minHeap.poll().key)。
  • isHotKey:根据新键是否成功加入 Top-K 确定(例如堆未满或计数超过堆顶)。
  • currentKey:直接传递调用 add() 方法时传入的键名。

​不可变设计​​:构造函数一次性初始化所有字段,后续无法修改,符合函数式编程理念。

Item
java 复制代码
package com.yuyuan.thumb.manager.cache;

// Item.java
public record Item(String key, int count) {}

记录类(Record)的基本特性

  • ​不可变性​Item类是一个记录类(Java 14+特性),其所有字段(keycount)默认是final的,即不可变。一旦对象被创建,无法修改其状态。
  • ​自动生成方法​ :编译器会自动生成以下内容:
    • 全参构造函数:public Item(String key, int count)
    • 字段访问方法:key()count()(没有get前缀)
    • equals()hashCode():基于所有字段的相等性和哈希值
    • toString():返回类似Item[key=..., count=...]的字符串
  • 数据载体​Item类用于表示一个简单的数据对象,包含两个属性:
    • key:标识某个缓存项(Key ID)。
    • count:与key关联的数值(访问次数)。

HeavyKeeper等算法中,用Item存储热点键及其计数,用于排序和淘汰逻辑。

TopK
java 复制代码
package com.yuyuan.thumb.manager.cache;// TopK.java
import java.util.List;
import java.util.concurrent.BlockingQueue;

public interface TopK {
    AddResult add(String key, int increment); // 向 TopK 结构中添加一个键及其增量计数,返回操作结果(是否成为热点、被挤出的键等)。
    List<Item> list(); // 返回当前 TopK 列表(按计数降序排列)
    BlockingQueue<Item> expelled(); // 返回被移出 TopK 的队
    void fading(); // 对所有计数进行衰减
    long total(); // 返回全局总访问量
}

.mapper(Spring 数据访问层)

BlogMapper
java 复制代码
public interface BlogMapper extends BaseMapper<Blog> {
    // 批量更新博客点赞数,接收 Map<Long, Long> 参数,键为博客 ID (blogId),值为点赞增量(正负均可)
    void batchUpdateThumbCount(@Param("countMap") Map<Long, Long> countMap);
}

继承 BaseMapper<Blog>​:

  • ​功能扩展​ :自动获得 17 种基础 CRUD 方法(如 selectListupdateById),无需手动实现。
  • ​泛型参数 Blog:指定实体类型,MyBatis-Plus 自动关联实体与数据库表(默认按驼峰转下划线规则映射表名)

注解 @Param("countMap")​:

  • ​参数绑定​ :将 Java 参数 countMap 映射到 XML SQL 中的同名变量,避免动态 SQL 解析错误。
  • ​XML 引用​ :在 XML 映射文件中通过 #{countMap.key}${countMap.value} 访问参数
ThumbMappper
java 复制代码
public interface ThumbMapper extends BaseMapper<Thumb> {

}

继承 BaseMapper<Thumb>​:

  • ​泛型参数​ :指定实体类型为 Thumb,MyBatis-Plus 自动关联该实体与数据库表(默认按驼峰转下划线规则映射表名,如 Thumbthumb 表)。

  • ​功能扩展​ :继承后自动获得以下核心方法(部分示例):

    java 复制代码
    int insert(Thumb entity);          // 插入一条记录
    int deleteById(Serializable id);   // 按主键删除
    Thumb selectById(Serializable id); // 按主键查询
    List<Thumb> selectList(Wrapper<Thumb> queryWrapper); // 条件查询列表

    动态代理机制​ ​:

    MyBatis-Plus 通过 ​​JDK 动态代理​ ​ 生成 ThumbMapper 的代理类,拦截接口方法调用并关联到预定义的 CRUD 操作。

  • ​示例​ :调用 thumbMapper.selectById(1L) 会触发代理逻辑,生成 SELECT * FROM thumb WHERE id = 1并执行。

UserMapper
java 复制代码
package com.yuyuan.thumb.mapper;

import com.yuyuan.thumb.model.entity.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

/**
 * @author pine
 */
public interface UserMapper extends BaseMapper<User> {

}

.model

.dto.thumb.DoThumbRequest

执行点赞操作的请求对象。

model.dto.thumb 路径表明这是一个 ​​数据传输对象 (DTO)​ ​,专门用于点赞业务模块(thumb)的请求参数封装。

java 复制代码
package com.yuyuan.thumb.model.dto.thumb;

import lombok.Data;

@Data
public class DoThumbRequest {
    // 标识被点赞的博客 ID,用于服务端处理点赞逻辑
    private Long blogId;
}
  • 引入 Lombok 的 @Data 注解。
  • ​Lombok 功能​
    • 自动生成 gettersettertoString()equals()hashCode() 方法,避免手动编写样板代码。
    • 编译时通过操作抽象语法树(AST)修改字节码,保持源码简洁
.entity.Blog
java 复制代码
/**
 * 
 * @TableName blog
 */
@TableName(value ="blog")
@Data
public class Blog {
    /**
     * 
     */
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;

    private Long userId;

    /**
     * 标题
     */
    private String title;

    /**
     * 封面
     */
    private String coverImg;

    /**
     * 内容
     */
    private String content;

    /**
     * 点赞数
     */
    private Integer thumbCount;

    /**
     * 创建时间
     */
    private Date createTime;

    /**
     * 更新时间
     */
    private Date updateTime;
}

@TableName("blog")​:

  • ​作用​ :显式声明实体类对应的数据库表名为 blog(默认类名小写为 blog,但显式声明更安全)。
  • ​适用场景​ :当表名与类名不一致时(如 Blog 类对应 tb_blog 表),需通过 value 属性指定

@Data​:

  • ​功能​ :Lombok 自动生成 getThumbCount()setCreateTime() 等方法,并覆盖 toString()equals()

@TableId​:

  • ​作用​ :标识主键字段,并指定主键生成策略。
  • type = IdType.ASSIGN_ID
    • ​策略说明​ :使用分布式 ID 生成算法(如雪花算法)自动生成主键值。
    • ​适用场景​ :适用于分布式系统,避免主键冲突(相比自增主键 AUTO 更灵活)。
    • 在分布式系统中,传统自增主键(如 MySQL 的 AUTO_INCREMENT)会导致分库分表时主键冲突,而 UUID 虽然唯一但无序且存储效率低
    • 时间戳 + 机器 ID + 序列号
  • ​默认映射​ :若数据库主键列名为 id,则无需通过 value 属性指定字段名
  • ​隐式映射规则​
    • MyBatis-Plus 默认将驼峰命名的字段(如 coverImg)映射为下划线格式的列名(如 cover_img)。
    • 若字段名与列名一致(如 title),无需使用 @TableField 注解
.entity.Thumb
java 复制代码
/**
 * 
 * @TableName thumb
 */
@TableName(value ="thumb")
@Data
public class Thumb {
    /**
     * 
     */
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;

    /**
     * 
     */
    private Long userId;

    /**
     * 
     */
    private Long blogId;

    /**
     * 创建时间
     */
    private Date createTime;
}

.entity.User

java 复制代码
/**
 * 
 * @TableName user
 */
@TableName(value ="user")
@Data
public class User implements Serializable {
    /**
     * 
     */
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;

    /**
     * 
     */
    private String username;
}

implements Serializable ​:

使 User 对象支持序列化,确保其可在网络传输、缓存(如 Redis)或持久化存储时保持状态一致性

.enums.LuaStatusEnum
java 复制代码
/**
 * lua 脚本执行结果类型
 *
 */
@Getter
public enum LuaStatusEnum {
    // 成功
    SUCCESS(1L),
    // 失败
    FAIL(-1L),
    ;

    private final long value;

    LuaStatusEnum(long value) {
        this.value = value;
    }

}

此枚举用于表示 Lua 脚本的执行结果类型。

@Getter 自动生成所有字段的 getter 方法

.enums.ThumbTypeEnum
java 复制代码
package com.yuyuan.thumb.model.enums;

import lombok.Getter;

/**
 * 点赞类型
 *
 */
@Getter
public enum ThumbTypeEnum {
    // 点赞
    INCR(1),
    // 取消点赞
    DECR(-1),
    // 不发生改变
    NON(0),
    ;

    private final int value;

    ThumbTypeEnum(int value) {
        this.value = value;
    }

}
.vo.BlogVO
java 复制代码
package com.yuyuan.thumb.model.vo;

import lombok.Data;

import java.util.Date;

/**
 * 
 * @TableName blog
 */
@Data
public class BlogVO {
    /**
     * 
     */
    private Long id;

    /**
     * 标题
     */
    private String title;

    /**
     * 封面
     */
    private String coverImg;

    /**
     * 内容
     */
    private String content;

    /**
     * 点赞数
     */
    private Integer thumbCount;

    /**
     * 创建时间
     */
    private Date createTime;

    /**
     * 是否已点赞
     */
    private Boolean hasThumb;

}

model.vo 表示这是一个 ​​视图对象(View Object)​ ​,用于封装前端展示数据,与 model.entity.Blog(数据库实体类)分层隔离。

相比 Blog.java增加了 hasThumb 字段表示"是否已点赞" ,这是视图层特有的状态信息,无需持久化到数据库。hasThumb 是典型 ​​业务逻辑字段​​,通过后端计算当前用户是否点赞,动态注入 VO 中。

  • Blog.java​ :数据库交互的实体,关注 ​数据持久化​ (如 ORM 注解、全字段映射)。
  • ​BlogVO​ :前后端交互的载体,关注 ​业务展示​(如状态字段、数据脱敏)

.service

BlogService
java 复制代码
package com.yuyuan.thumb.service;

import com.yuyuan.thumb.model.entity.Blog;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yuyuan.thumb.model.entity.User;
import com.yuyuan.thumb.model.vo.BlogVO;
import jakarta.servlet.http.HttpServletRequest;

import java.util.List;

public interface BlogService extends IService<Blog> {

    // 根据 blogId 查询博客详情,并转换为包含业务状态的 BlogVO 对象
    // request:用于获取当前用户信息(如通过 request.getSession() 获取登录用户 ID)
    BlogVO getBlogVOById(long blogId, HttpServletRequest request);
    
    // 根据 loginUser 的 ID,查询该用户是否对当前博客点赞、收藏等。
    BlogVO getBlogVO(Blog blog, User loginUser);

    // 批量将 Blog 实体列表转换为 BlogVO 列表
    // 批量查询所有博客的点赞状态
    List<BlogVO> getBlogVOList(List<Blog> blogList, HttpServletRequest request);
}

​继承 IService<Blog> ​:

继承 MyBatis-Plus 的通用服务接口,自动获得 ​​20+ 个 CRUD 方法​ ​(MyBatis-Plus 提供的通用 CRUD 接口,如 save(), update() , getById(), list() 等),无需手动实现基础操作

Impl:

java 复制代码
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog>
        implements BlogService {

    @Resource
    private UserService userService;

    @Resource
    @Lazy // 延迟加载避免循环依赖
    private ThumbService thumbService;

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    // 通过 getById() 查询数据库中的 Blog 实体
    @Override
    public BlogVO getBlogVOById(long blogId, HttpServletRequest request) {
        Blog blog = this.getById(blogId); // 调用 MyBatis-Plus 的 getById 方法
        User loginUser = userService.getLoginUser(request); // 获取当前登录用户
        return this.getBlogVO(blog, loginUser); // 转换实体为 VO
    }

    // 实体转视图对象​​
    @Override
    public BlogVO getBlogVO(Blog blog, User loginUser) {
        BlogVO blogVO = new BlogVO();
        BeanUtil.copyProperties(blog, blogVO); // 将 Blog 实体属性复制到 BlogVO,要求字段名一致

        if (loginUser != null) {
            // 查询用户是否点赞该博客
            Boolean exist = thumbService.hasThumb(blog.getId(), loginUser.getId());
            // 通过 ThumbService 查询用户点赞记录,设置 hasThumb 字段
            blogVO.setHasThumb(exist);
        }

        return blogVO;
    }

    @Override
    public List<BlogVO> getBlogVOList(List<Blog> blogList, HttpServletRequest request) {
        User loginUser = userService.getLoginUser(request);
        // 存储博客 ID 与用户是否点赞的映射关系(Key: 博客 ID,Value: 是否点赞)
        Map<Long, Boolean> blogIdHasThumbMap = new HashMap<>();

        if (ObjUtil.isNotEmpty(loginUser)) {
            // 使用 stream().map() 将 Blog 列表转换为博客 ID 字符串列表,便于 Redis 查询
            List<Object> blogIdList = blogList.stream().map(blog -> blog.getId().toString()).collect(Collectors.toList());
            // 通过 RedisKeyUtil 生成用户点赞记录的 Hash 键
            // ​​批量查询 Redis​​:通过 multiGet 一次性获取所有博客的点赞状态,减少网络开销(对比循环单次查询)。
            List<Object> thumbList = redisTemplate.opsForHash().multiGet(RedisKeyUtil.getUserThumbKey(loginUser.getId()), blogIdList);
            for (int i = 0; i < thumbList.size(); i++) {
                if (thumbList.get(i) == null) {
                    continue;
                }
                blogIdHasThumbMap.put(Long.valueOf(blogIdList.get(i).toString()), true);
            }
        }

        return blogList.stream()
                .map(blog -> {
                    // 使用 BeanUtil.copyProperties 复制 Blog 属性到 BlogVO(需字段名一致)
                    BlogVO blogVO = BeanUtil.copyProperties(blog, BlogVO.class);
                    // 从 blogIdHasThumbMap 获取当前博客的点赞状态
                    blogVO.setHasThumb(blogIdHasThumbMap.get(blog.getId()));
                    return blogVO;
                })
                .toList(); //.toList() 代替 .collect(Collectors.toList()),返回不可变列表(Java 16+ 支持)
    }
}

@Lazy:解决 ThumbServiceBlogService 的循环依赖问题。在我们的代码中,ThumbService中需要注入BlogService,反之BlogService中也要注入ThumbService。

三级缓存仅适用于​​字段/Setter 注入​ ​,对构造器注入无效。此时需借助 @Lazy 生成代理对象,避免直接触发初始化

  • @Lazy 修饰一个 Bean 的依赖时,Spring 不会在启动时立即初始化该 Bean,而是创建一个​代理对象​ 占位。
  • Spring 先初始化 BlogService,注入 ThumbService 的代理对象。
  • ThumbService 需要注入 BlogService 时,由于 BlogService 已存在(尽管可能未完全初始化),依赖关系得以满足
  • ThumbService 完成初始化后,代理对象会在实际调用时委托给真实实例
ThumbService
java 复制代码
public interface ThumbService extends IService<Thumb> {

    // 处理用户点赞请求
    Boolean doThumb(DoThumbRequest doThumbRequest, HttpServletRequest request);

    // 处理用户取消点赞请求,逻辑与 doThumb 对称
    Boolean undoThumb(DoThumbRequest doThumbRequest, HttpServletRequest request);

    // 查询指定用户(一般是目前的已登录用户)是否对某篇博客点赞
    Boolean hasThumb(Long blogId, Long userId);
}
  1. Impl(普通实现)
java 复制代码
@Service("thumbServiceLocalCache")  // 自定义Bean名称,支持多实现类按名称注入
@Slf4j  // Lombok日志注解
@RequiredArgsConstructor  // 自动生成final字段的构造函数
public class ThumbServiceImpl extends ServiceImpl<ThumbMapper, Thumb>
        implements ThumbService {

    private final UserService userService;  // 用户服务
    private final BlogService blogService;  // 博客服务
    private final TransactionTemplate transactionTemplate;  // 编程式事务模板
    private final RedisTemplate<String, Object> redisTemplate;  // Redis操作模板
    private final CacheManager cacheManager;  // 自定义缓存管理器(封装多级缓存)
}

@Service("thumbServiceLocalCache"):指定 Bean 名称,便于其他组件按名称注入(如 A/B 测试不同实现)

点赞逻辑

java 复制代码
@Override
public Boolean doThumb(DoThumbRequest request, HttpServletRequest httpRequest) {
    // 参数校验
    if (request == null || request.getBlogId() == null) {
        throw new RuntimeException("参数错误");
    }
    User loginUser = userService.getLoginUser(httpRequest);  // 获取当前用户
    
    // 用户级锁(防止同一用户并发重复点赞)
    synchronized (loginUser.getId().toString().intern()) {  
        // 编程式事务模板执行
        return transactionTemplate.execute(status -> {
            Long blogId = request.getBlogId();
            
            // 检查是否已点赞
            Boolean exists = this.hasThumb(blogId, loginUser.getId());
            if (exists) {
                throw new RuntimeException("用户已点赞");
            }
            
            // 更新博客点赞数(MyBatis-Plus的Lambda更新)
            boolean updateBlog = blogService.lambdaUpdate()
                    .eq(Blog::getId, blogId)
                    .setSql("thumbCount = thumbCount + 1")
                    .update();
            
            // 插入点赞记录
            Thumb thumb = new Thumb();
            thumb.setUserId(loginUser.getId());
            thumb.setBlogId(blogId);
            boolean saveThumb = this.save(thumb);
            
            // 事务性操作:数据库更新与缓存更新
            if (updateBlog && saveThumb) {
                String hashKey = ThumbConstant.USER_THUMB_KEY_PREFIX + loginUser.getId();
                String fieldKey = blogId.toString();
                Long thumbId = thumb.getId();
                
                // hashkey:fieldKey -> thumbId
                // 更新Redis缓存
                redisTemplate.opsForHash().put(hashKey, fieldKey, thumbId);
                // 更新本地缓存(Caffeine)
                cacheManager.putIfPresent(hashKey, fieldKey, thumbId);
            }
            return updateBlog && saveThumb;  // 事务提交
        });
    }
}

取消点赞

java 复制代码
@Override
public Boolean undoThumb(DoThumbRequest request, HttpServletRequest httpRequest) {
    // 参数校验(同上)
    User loginUser = userService.getLoginUser(httpRequest);
    
    synchronized (loginUser.getId().toString().intern()) {
        return transactionTemplate.execute(status -> {
            Long blogId = request.getBlogId();
            
            // 从缓存(caffeine + redis)获取点赞记录ID
            Object thumbIdObj = cacheManager.get(
                ThumbConstant.USER_THUMB_KEY_PREFIX + loginUser.getId(), 
                blogId.toString()
            );
            if (thumbIdObj == null || thumbIdObj.equals(ThumbConstant.UN_THUMB_CONSTANT)) {
                throw new RuntimeException("用户未点赞");
            }
            
            // 更新博客点赞数
            boolean updateBlog = blogService.lambdaUpdate()
                    .eq(Blog::getId, blogId)
                    .setSql("thumbCount = thumbCount - 1")
                    .update();
            // 删除点赞记录
            boolean deleteThumb = this.removeById((Long) thumbIdObj);
            
            if (updateBlog && deleteThumb) {
                String hashKey = ThumbConstant.USER_THUMB_KEY_PREFIX + loginUser.getId();
                String fieldKey = blogId.toString();
                
                // 删除Redis记录
                redisTemplate.opsForHash().delete(hashKey, fieldKey);
                // 标记本地缓存为未点赞状态(避免缓存穿透)
                cacheManager.putIfPresent(hashKey, fieldKey, ThumbConstant.UN_THUMB_CONSTANT);
            }
            return updateBlog && deleteThumb;
        });
    }
}

检查点赞状态

java 复制代码
@Override
public Boolean hasThumb(Long blogId, Long userId) {
    // 先查询本地缓存(Caffeine),未命中则查 Redis
    Object thumbIdObj = cacheManager.get(
        ThumbConstant.USER_THUMB_KEY_PREFIX + userId, 
        blogId.toString()
    );
    // 缓存未命中或标记为未点赞时返回false
    return thumbIdObj != null && 
           !thumbIdObj.equals(ThumbConstant.UN_THUMB_CONSTANT);
}
  1. Redis临时点赞记录 + 定时任务持久化实现
java 复制代码
@Service("thumbServiceRedis")  // 通过Bean名称区分不同实现(与本地缓存版本区分)
@Slf4j
@RequiredArgsConstructor // Lombok自动生成构造器,注入final修饰的依赖
public class ThumbServiceRedisImpl extends ServiceImpl<ThumbMapper, Thumb>
        implements ThumbService {

    private final UserService userService;  // 用户服务(用于获取登录用户)
    private final RedisTemplate<String, Object> redisTemplate;  // Redis操作模板
}

点赞逻辑

java 复制代码
@Override
public Boolean doThumb(DoThumbRequest request, HttpServletRequest httpRequest) {
    // 参数校验
    if (request == null || request.getBlogId() == null) {
        throw new RuntimeException("参数错误");
    }
    User loginUser = userService.getLoginUser(httpRequest);  // 获取当前用户
    Long blogId = request.getBlogId();
    
    // 生成时间片(如"11:20:20")
    String timeSlice = getTimeSlice();  
    // 构造Redis键:临时点赞记录 & 用户点赞记录
    String tempThumbKey = RedisKeyUtil.getTempThumbKey(timeSlice);  
    String userThumbKey = RedisKeyUtil.getUserThumbKey(loginUser.getId());
    
    // 执行Lua脚本(原子操作)
    long result = redisTemplate.execute(
        RedisLuaScriptConstant.THUMB_SCRIPT,  // 预加载的Lua脚本
        Arrays.asList(tempThumbKey, userThumbKey),  // KEYS参数
        loginUser.getId(), blogId  // ARGV参数
    );
    
    // 处理结果
    if (result == LuaStatusEnum.FAIL.getValue()) {
        throw new RuntimeException("用户已点赞");
    }
    return result == LuaStatusEnum.SUCCESS.getValue();
}

取消点赞

java 复制代码
@Override
public Boolean undoThumb(DoThumbRequest request, HttpServletRequest httpRequest) {
    // 参数校验(同上)
    User loginUser = userService.getLoginUser(httpRequest);
    Long blogId = request.getBlogId();
    
    // 构造Redis键(同上)
    String timeSlice = getTimeSlice();
    String tempThumbKey = RedisKeyUtil.getTempThumbKey(timeSlice);
    String userThumbKey = RedisKeyUtil.getUserThumbKey(loginUser.getId());
    
    // 执行取消点赞的Lua脚本
    long result = redisTemplate.execute(
        RedisLuaScriptConstant.UNTHUMB_SCRIPT,  // 反向操作脚本
        Arrays.asList(tempThumbKey, userThumbKey), // KEYS参数
        loginUser.getId(), blogId // ARGV参数
    );
    
    // 处理结果
    if (result == LuaStatusEnum.FAIL.getValue()) {
        throw new RuntimeException("用户未点赞");
    }
    return result == LuaStatusEnum.SUCCESS.getValue();
}

时间片生成

java 复制代码
private String getTimeSlice() {
    DateTime nowDate = DateUtil.date();
    // 计算当前时间前最近的10秒整数(如23秒→20秒)
    return DateUtil.format(nowDate, "HH:mm:") + (DateUtil.second(nowDate) / 10) * 10;
}

检测点赞状态

java 复制代码
@Override
public Boolean hasThumb(Long blogId, Long userId) {
    // 检查指定 Redis Hash 的键(Key)下是否存在某个字段(Field)
    // 直接查询用户点赞记录(Redis Hash)的thumb:userId(Key)中是否存在该博客ID(field)
    return redisTemplate.opsForHash().hasKey(
        RedisKeyUtil.getUserThumbKey(userId), 
        blogId.toString()
    );
}
  1. Pulsar消息队列异步处理点赞事件
java 复制代码
@Service("thumbService") // 声明为Spring服务层组件,bean名称为thumbService
@Slf4j // 启用Lombok日志功能,自动生成Logger实例
@RequiredArgsConstructor // Lombok生成包含final字段的构造方法
public class ThumbServiceMQImpl extends ServiceImpl<ThumbMapper, Thumb>
        implements ThumbService { // 遵循服务层实现规范
    // 通过构造器注入依赖
    private final UserService userService;
    private final RedisTemplate<String, Object> redisTemplate;
    private final PulsarTemplate<ThumbEvent> pulsarTemplate;

点赞逻辑

java 复制代码
/**
     * 执行点赞操作
     * @param doThumbRequest 点赞请求DTO
     * @param request HTTP请求对象
     * @return 操作结果
     */
    @Override
    public Boolean doThumb(DoThumbRequest doThumbRequest, HttpServletRequest request) {
        // 参数校验(第34-36行)
        if (doThumbRequest == null || doThumbRequest.getBlogId() == null) {
            throw new RuntimeException("参数错误");  // 抛出运行时异常
        }

        // 获取登录用户(第38行)
        User loginUser = userService.getLoginUser(request);  // 调用用户服务获取当前用户
        Long loginUserId = loginUser.getId();               // 提取用户ID
        Long blogId = doThumbRequest.getBlogId();           // 提取博客ID

        // 生成Redis键(第41行)
        String userThumbKey = RedisKeyUtil.getUserThumbKey(loginUserId);  // 格式如:thumb:123

        // 执行Lua脚本(第43-47行)
        long result = redisTemplate.execute(
                RedisLuaScriptConstant.THUMB_SCRIPT_MQ,  // 预定义的Lua脚本
                List.of(userThumbKey),                   // 键列表(这里只有一个键)
                blogId                                   // 参数:博客ID
        );

        // 处理Lua脚本返回结果(第49-51行)
        if (LuaStatusEnum.FAIL.getValue() == result) {   // 判断是否操作失败
            throw new RuntimeException("用户已点赞");      // 已点赞则抛出异常
        }

        // 构建点赞事件(第53-57行)
        ThumbEvent thumbEvent = ThumbEvent.builder()
                .blogId(blogId)                          // 设置博客ID
                .userId(loginUserId)                     // 设置用户ID
                .type(ThumbEvent.EventType.INCR)         // 事件类型为增加
                .eventTime(LocalDateTime.now())          // 记录事件时间
                .build();

        // 异步发送消息(第59-64行)
        pulsarTemplate.sendAsync("thumb-topic", thumbEvent)  // 发送到指定主题
                .exceptionally(ex -> {                       // 异常回调处理
                    // 补偿操作:删除Redis中的点赞记录
                    redisTemplate.opsForHash().delete(userThumbKey, blogId.toString(), true);
                    log.error("点赞事件发送失败: userId={}, blogId={}", loginUserId, blogId, ex);
                    return null;
                });

        return true;  // 返回操作成功
    }

取消点赞

java 复制代码
/**
     * 取消点赞操作(结构与点赞方法对称)
     */
    @Override
    public Boolean undoThumb(DoThumbRequest doThumbRequest, HttpServletRequest request) {
        // ...(参数校验与点赞方法相同)...

        // 执行取消点赞的Lua脚本
        long result = redisTemplate.execute(
                RedisLuaScriptConstant.UNTHUMB_SCRIPT_MQ,  // 不同的Lua脚本
                List.of(userThumbKey),
                blogId
        );

        // ...(结果处理与点赞方法类似)...

        // 构建取消点赞事件
        ThumbEvent thumbEvent = ThumbEvent.builder()
                .type(ThumbEvent.EventType.DECR)  // 事件类型为减少
                // ...其他字段相同...
                .build();

        // 异步发送消息的补偿逻辑不同
        pulsarTemplate.sendAsync(...).exceptionally(ex -> {
            redisTemplate.opsForHash().put(...);  // 补偿操作:恢复Redis记录
            // ...日志记录...
        });

        return true;
    }

检查登录用户是否已对该博客点赞

java 复制代码
/**
     * 检查是否已点赞
     * @param blogId 博客ID
     * @param userId 用户ID
     * @return 是否已点赞
     */
    @Override
    public Boolean hasThumb(Long blogId, Long userId) {
        // 直接查询Redis Hash结构
        return redisTemplate.opsForHash()
                .hasKey(RedisKeyUtil.getUserThumbKey(userId), blogId.toString());
    }

.util

RedisKeyUtil
java 复制代码
package com.yuyuan.thumb.util;

import com.yuyuan.thumb.constant.ThumbConstant;

public class RedisKeyUtil {

    public static String getUserThumbKey(Long userId) {
        // 生成用户点赞记录的Redis key
        return ThumbConstant.USER_THUMB_KEY_PREFIX + userId;
    }

    /**
     * 获取 临时点赞记录 key
     */
    public static String getTempThumbKey(String time) {
        // TEMP_THUMB_KEY_PREFIX定义为"temp:thumb:%s"的格式字符串,动态插入时间值
        return ThumbConstant.TEMP_THUMB_KEY_PREFIX.formatted(time);
    }

}
相关推荐
ss2732 小时前
ThreadPoolExecutor:自定义线程池参数
java·开发语言
鸽鸽程序猿2 小时前
【Redis】事务
数据库·redis·缓存
invicinble2 小时前
关于fastjson的具体使用案例
java
墨着染霜华3 小时前
Spring Boot整合Kaptcha生成图片验证码:新手避坑指南+实战优化
java·spring boot·后端
码界奇点3 小时前
Java外功核心7深入源码拆解Spring Bean作用域生命周期与自动装配
java·开发语言·spring·dba·源代码管理
czlczl200209253 小时前
Spring Security @PreAuthorize 与自定义 @ss.hasPermission 权限控制
java·后端·spring
我爱学习好爱好爱3 小时前
Prometheus监控栈 监控java程序springboot
java·spring boot·prometheus
东东的脑洞3 小时前
【面试突击】Redis 主从复制核心面试知识点
redis·面试·职场和发展
老华带你飞3 小时前
考试管理系统|基于java+ vue考试管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端