点赞功能的简易实现

4,787 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情

整体思路

  1. 使用Redis存储某一篇文章的点赞数和该篇文章已经点赞的用户ID,某一篇文章的点赞数暂时永久性存储在Redis中,并以定时任务持久化到数据库中;
  2. 点赞的用户ID有时间限制地存储到redis中(暂时定为1个星期),通过使用定时任务,会定时清除Redis中点赞的用户,个人认为:点赞是一种热度的表现,热度一旦过去,就没有什么用户会注意了,如果热度过长,可以将时间设置长一些即可;不过存储一张点赞记录表进行兜底

Redis实现

数据结构设计

使用hash数据结构来存储某一篇文章的点赞数🗝️

key 的形式:like_nums_hash_key

field字段的形式:like:nums:资源类型:资源id

使用set数据结构来存储某一篇文章的已经点赞的用户ID🗝️

key 的形式:like:user:资源类型:id

value 的值就是用户的ID


亮点:

  1. 点赞数量可以通过定时任务永久性将Redis中的点赞数量保存MySQL中,保持点赞数量的准确性
  2. 短时间内可以判断用户是否已经点赞(使用set集合)

存在问题:

  1. 用户对谋篇文章的点赞只能保持1周(过期时间为一周,避免Redis中的存储量过多), 1周过后用户可以重复点赞,消除了原来的点赞记录(因为判断是否点赞是在Redis中进行的),但是对于流量较少的网站是可以接收的,不过后端还存在着一张点赞记录表进行兜底;
  2. hash存储存在大key问题(用户量多的话),可以使用一些散列函数进行解决;
  3. MySQL与缓存一致性的问题, 主页面中缓存到的文章的点赞数量可能会和数据库中文章的点赞数量不一致 ,可以将主页面的缓存停留的时间设置的短一点

MySQL实现

表设计

create table `like`
(
    id          bigint auto_increment comment '点赞主键'
        primary key,
    type        bigint                              not null comment '点赞资源类型 1-文章 2-评论',
    user_id     bigint                              not null comment '用户id',
    owner_id    bigint                              not null comment '被点赞的资源 id',
    create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间'
)
    comment '点赞表';
  1. 通过type的类型,可以标识不同类型的点赞类型,如:文章,评论,视频等;
  2. 通过own_Id字段可以标识被点赞资源的ID;
  3. 通过1、2 的标识可以足够用一张表来标识所有点赞的需求;

相关代码

  1. 点赞逻辑代码
@Override
    public BaseResponse likeArticle(LikeRequest likeRequest) {
        /**
         * 存在线程安全问题!要实现互斥效果!要加锁!
         */
        Boolean isLiked = likeRequest.getIsLiked();
        Long userId = UserHolder.getId();

        // 资源类型
        Long type = likeRequest.getType();

        // 资源对应的id
        Long ownerId = likeRequest.getOwnerId();
        String userKey = LIKE_USER_KEY + type + ":" + ownerId;  // 存储在set中
        String numsKey = LIKE_NUMS_KEY + type + ":" + ownerId;  // 存储在hash中

        try {
            // 线程尝试获取锁
            boolean tryLock = reentrantLock.tryLock(100, TimeUnit.MILLISECONDS);
            if (tryLock) {
                if (isLiked) {
                    // 移除用户
                    Long remove = redisTemplate.opsForSet().remove(userKey, userId);
                    // 移除成功,并减少点赞的数量
                    if (remove != null && remove > 0) {
                        Integer likeNums = (Integer) redisTemplate.opsForHash().get(LIKE_ARTICLE_NUMS_HASH_KEY, numsKey);
                        if (likeNums == null) {
                            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "减少点赞数量错误");
                        }
                        if (likeNums > 0) {
                            likeNums--;
                            redisTemplate.opsForHash().put(LIKE_ARTICLE_NUMS_HASH_KEY, numsKey, likeNums);
                        }
                    }
                    return ResultUtils.success(SuccessCode.LIKE_CANCEL);
                }

                // 点赞 设置点赞用户
                Long add = redisTemplate.opsForSet().add(userKey, userId);
                if (add != null && add > 0) {
                    // 点赞数量 自增1
                    Integer likeNums = (Integer) redisTemplate.opsForHash().get(LIKE_ARTICLE_NUMS_HASH_KEY, numsKey);
                    if (likeNums == null || likeNums == 0) {
                        redisTemplate.opsForHash().put(LIKE_ARTICLE_NUMS_HASH_KEY, numsKey, 1);
                        // 设置过期时间为1分钟
                        redisTemplate.expire(LIKE_ARTICLE_NUMS_HASH_KEY, 1, TimeUnit.MINUTES);
                    } else {
                        likeNums++;
                        redisTemplate.opsForHash().put(LIKE_ARTICLE_NUMS_HASH_KEY, numsKey, likeNums);
                    }
                }
                return ResultUtils.success(SuccessCode.LIKE_SUCCESS);
            }

        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            if (reentrantLock.isHeldByCurrentThread()) {  // 解锁
                reentrantLock.unlock();
            }
        }
        // 无论如何都要返回成功
        return ResultUtils.success(SuccessCode.LIKE_DELAY);
    }
  1. 文章点赞数量定时存储到数据库中
/**
     * 将文章点赞的数量存储到 redis中
     */
    @Scheduled(cron = "0/30 * * * * ?")   
    public void storeLikeNum2DB() {
        // 存储点赞次数
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(LIKE_ARTICLE_NUMS_HASH_KEY);
        // 获取所有键值
        Set<Object> keySet = entries.keySet();

        // 遍历
        keySet.forEach((hashKey) -> {
            String key = (String) hashKey;
            // 将键值分开
            String[] split = key.split(":");

            // 如果是文章类型
            if (split[2].equals(LIKE_ARTICLE_TYPE)) {
                // 获取点赞的数量 更新文章点赞的数量
                Integer likeNums = (Integer) entries.get(key);
                Integer articleId = Integer.parseInt(split[3]);
                // 更新文章的点赞数量
                articleMapper.updateLikesByArticleId(likeNums.longValue(), articleId.longValue());
            }
        });
    }
  1. 文章点赞用户的记录缓存数据库中,并移除Redis中的点赞用户
@Scheduled(cron = "0 16 11 * * ?")
    public void storeLikeUserInfo2DB() {
        // 将点赞的用户信息存储到redis中
        Set<String> keys = redisTemplate.keys(LIKE_USER_KEY + "*");
        if (keys != null && keys.size() != 0) {
            // 遍历每一个相似的key
            for (String key : keys) {
                String[] split = key.split(":");
                // 获取键上面的信息
                int type = Integer.parseInt(split[2]);
                int ownerId = Integer.parseInt(split[3]);
                // 用户信息的值
                Set<Object> members = redisTemplate.opsForSet().members(key);
                if (members != null) {
                    for (Object value : members) {
                        Integer userId = (Integer) value;
                        // 将数据插入点赞表
                        likeMapper.insertTypeAndUserIdAndOwnerId(type, userId, ownerId);
                    }
                    // 用定时任务来删除对应的键即可
                    redisTemplate.delete(key);
                }
            }
        }
    }