Redis 缓存 + MySql 持久化 实现点赞服务

发布于:2025-04-11 ⋅ 阅读:(37) ⋅ 点赞:(0)

前言

为什么所用 redis 作为缓存来实现点赞服务, 而不是直接就使用 mysql 来完成?

  1. 使用 Redis 的集合数据结构来存储点赞用户的 ID,方便快速判断用户是否已点赞;

  2. 当用户频繁的点赞和取消点赞时, 无需操作数据库, 减轻服务器压力

  3. Redis 可以承受高并发的读写操作。当大量用户同时点赞时,Redis 可以先将这些点赞请求缓存起来,然后由后台线程逐步将数据持久化到 MySQL

实现

查询流程

客户端
API Gateway
点赞服务
Redis缓存
MySQL持久化

数据存储格式

redis

# 存储用户点赞状态(SET)
SADD picture_likes:{picture_id} {user_id}  # 用户点赞图片
SREM picture_likes:{picture_id} {user_id}  # 取消点赞

# 存储点赞计数器(String)
INCR picture_like_count:{picture_id}       # 点赞数+1
DECR picture_like_count:{picture_id}       # 点赞数-1

# 存储用户点赞历史(ZSET)用于做增量同步
ZADD user_likes:{picture_id} {timestamp} {user_id}  # 按时间排序
ZREM user_likes:{picture_id} {user_id}  # 删除点赞关系时删除点赞历史

ADD picture_likes:last_sync_timestamp {timestamp} # 用做增量同步的时间参考

mysql

下面是用户点赞关系表, 至于 likesCount 则作为字段添加进 picture 表

create table user_likes
(
    id           bigint auto_increment
        primary key,
    user_id      bigint                              not null,
    picture_id   bigint                              not null,
    created_time timestamp default CURRENT_TIMESTAMP not null,
    constraint uk_user_picture
        unique (user_id, picture_id)
);

核心代码实现

点赞或取消点赞

相关参数构建

根据自己实际情况来

Long userId = userService.getLoginUser().getId();
String key = PICTURE_LIKE_PREFIX + request.getPictureId();
String userIdStr = userId.toString();
String likeCountKey = PICTURE_LIKE_COUNT_PREFIX + request.getPictureId();
String likeTimeKey = PICTURE_LIKE_TIME_PREFIX + request.getPictureId();

// 查询用户是否已经点赞
boolean isLiked = Boolean.TRUE.equals(
        stringRedisTemplate.opsForSet().isMember(key, userId.toString())
);
操作 redis

为了防止指令穿插导致不一致性, 需要保证操作的原子性, 这里使用 管道 + MULTI 和 EXEC 命令来保证原子性和隔离性(也可以使用 lua 脚本来完成)

stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
	connection.multi(); // 开启事务
    if (!isLiked) {
        // 记录用户点赞关系
        connection.sAdd(key.getBytes(), userIdStr.getBytes());
        // 记录用户点赞时间
        connection.zAdd(likeTimeKey.getBytes(), System.currentTimeMillis() / 1000.0, userIdStr.getBytes());
        // 增加点赞计数
        connection.incr(likeCountKey.getBytes());
    } else {
        // 移除用户点赞关系
        connection.sRem(key.getBytes(), userIdStr.getBytes());
        // 用处用户赞时间
        connection.zRem(likeTimeKey.getBytes(), userIdStr.getBytes());
        // 减少点赞计数
        connection.decr(likeCountKey.getBytes());
    }
    return connection.exec();
});

补充: redis 的事务机制

当使用事务来执行多个指令时,通过 MULTI 命令开启事务,将多个指令放入一个事务中,然后使用 EXEC 命令来原子性地执行这些指令。在 EXEC 执行期间,Redis 会按照顺序依次执行事务中的指令,不会被其他客户端的命令打断,从而保证了事务内指令执行的原子性和顺序性,也就确保了两条指令执行的间隔内没有其他指令被执行。

查询点赞数据

这里需要查询出用户是否给图片点赞, 以及图片对应的点赞总数

public List<LikeInfoVO> batchCheckLikeStatus(Long userId, List<Long> pictureIds) {
    // 1. 使用Pipeline同时查询两种数据
    List<Object> results = stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
        // 查询用户点赞状态
        for (Long picId : pictureIds) {
            connection.sIsMember(
                    (PICTURE_LIKE_PREFIX + picId).getBytes(),
                    userId.toString().getBytes()
            );
        }

        // 查询点赞总数
        // 未匹配的项会返回空
        for (Long picId : pictureIds) {
            connection.get(
                    (PICTURE_LIKE_COUNT_PREFIX + picId).getBytes()
            );
        }
        return null;
    });

    // 2. 处理混合结果
    List<LikeInfoVO> resultMap = new ArrayList<>();
    int halfSize = results.size() / 2;

    for (int i = 0; i < halfSize; i++) {
        Boolean isLiked = (Boolean) results.get(i);
        Integer likeCount = results.get(i + halfSize) != null ?
                Integer.parseInt((String) results.get(i + halfSize)) : 0;

        resultMap.add(new LikeInfoVO(isLiked, likeCount));
    }

    return resultMap;
}

将点赞数据封装进响应结果

List<LikeInfoVO> likeStatus = batchCheckLikeStatus(userId, pictureIds);

List<PictureVO> vos = IntStream.range(0, records.size()).mapToObj(i -> {
    PictureVO pictureVO = new PictureVO();
    BeanUtils.copyProperties(records.get(i), pictureVO);
    pictureVO.setLike(likeStatus.get(i).getIsLike());
    pictureVO.setLikesCount(likeStatus.get(i).getLikesCount());
    return pictureVO;
}).collect(Collectors.toList());

数据同步

增量同步 or 全量同步

使用定时任务来完成将 redis 中数据同步到 mysql, 这里就不使用全量同步了, 一方面全量同步性能底, 再未处理 redis 删除的点赞关系

代码

@Scheduled(fixedRate = 300000)
public void syncIncrementally() {
    log.info("开始: 增量同步图片用户点赞关系到数据库, 并处理Redis已删除的点赞关系");
    // 1. 获取Redis最后同步时间戳
    long lastSyncTime = getLastSyncTimestamp();

    // 2. 同步新增点赞
    syncNewLikes(lastSyncTime);

    // 3. 同步取消点赞
    syncUnlikes();

    // 5. 同步图片点赞数量
    syncPicLikesCount();

    // 4. 更新同步时间
    updateSyncTimestamp();
    log.info("结束: 增量同步图片用户点赞关系到数据库, 并处理Redis已删除的点赞关系");
}
设置同步时间和获取最后同步时间
// 获取最后同步时间(默认返回24小时前的时间戳)
private long getLastSyncTimestamp() {
    String timestampStr = stringRedisTemplate.opsForValue().get(LAST_SYNC_KEY);
    return timestampStr != null ?
            Long.parseLong(timestampStr) :
            System.currentTimeMillis() - 86400_000; // 默认24小时前
}

// 更新同步时间戳为当前时间
private void updateSyncTimestamp() {
    stringRedisTemplate.opsForValue().set(
            LAST_SYNC_KEY,
            String.valueOf(System.currentTimeMillis())
    );
}
同步新增点赞
private void syncNewLikes(long sinceTime) {
    // 使用ZSET记录点赞时间戳
    Set<String> newLikeKeys = stringRedisTemplate.keys(PICTURE_LIKE_PREFIX + "*");
    Objects.requireNonNull(newLikeKeys).forEach(key -> {
        long pictureId = Long.parseLong(StrUtil.removePrefix(key, PICTURE_LIKE_PREFIX));

        // 获取新增点赞用户(ZRANGEBYSCORE)
        Set<String> newUserIds = stringRedisTemplate.opsForZSet()
                .rangeByScore(PICTURE_LIKE_TIME_PREFIX + pictureId, sinceTime, Double.MAX_VALUE);

        List<UserLikes> entityList = Objects.requireNonNull(newUserIds).stream().map(userId -> {
            UserLikes userLikes = new UserLikes();
            userLikes.setPictureId(pictureId);
            userLikes.setUserId(Long.parseLong(userId));
            return userLikes;
        }).collect(Collectors.toList());

        // 批量插入并忽略重复的元素
        if (CollUtil.isNotEmpty(entityList)) {
            userLikesMapper.insertIgnoreBatch(entityList);
        }

    });
}
同步取消点赞
private void syncUnlikes() {
    Set<String> likeKeys = stringRedisTemplate.keys(PICTURE_LIKE_PREFIX + "*");
    Objects.requireNonNull(likeKeys).forEach(key -> {
        Long pictureId = Long.parseLong(key.substring(PICTURE_LIKE_PREFIX.length()));
        Set<String> members = stringRedisTemplate.opsForSet().members(key);

        Set<Long> redisUserIds = Objects.requireNonNull(members).stream()
                .map(Long::parseLong).collect(Collectors.toSet());


        QueryWrapper<UserLikes> wrapper = new QueryWrapper<>();
        wrapper.select("user_id")
                .eq("picture_id", pictureId);

		// 从Redis Set与MySQL比对找出取消的点赞
        Set<Long> mysqlUserIds = userLikesMapper.selectList(wrapper).stream()
                .map(UserLikes::getUserId).collect(Collectors.toSet());


        // 找出MySQL有但Redis没有的记录
        mysqlUserIds.removeAll(redisUserIds);

		// 删除取消的记录
        if (CollUtil.isNotEmpty(mysqlUserIds)) {
            UpdateWrapper<UserLikes> userLikesUpdateWrapper = new UpdateWrapper<>();
            userLikesUpdateWrapper.eq("picture_id", pictureId)
                    .in("user_id", mysqlUserIds);

            userLikesMapper.delete(userLikesUpdateWrapper);
        }

    });
}

好的!本次分享到这就结束了
如果对铁汁你有帮助的话,记得点赞👍+收藏⭐️+关注➕
我在这先行拜谢了:)