点赞功能,分析与实现,实用!

2023年 8月 22日 48.3k 0

个人项目:社交支付项目(小老板)

作者:三哥,j3code.cn

项目文档:www.yuque.com/g/j3code/dv…

预览地址(未开发完):admire.j3code.cn/small-boss

  • 内网穿透部署,第一次访问比较慢

1、分析

点赞功能我相信大家都不陌生,打开朋友圈、B 站、抖音、小红书等关于社交相关的功能基本都有点赞这一功能,所以本篇,咱们就来分析一下点赞功能如何来实现。

首先点赞肯定是对某一类数据进行点赞,如:B 站就有视频点赞、评论点赞、动态点赞等。所以,按照我们常规的套路,是不是就应该有视频点赞接口,评论点赞接口,动态点赞接口等,但事实果真如此吗?

你看我这样设计,是否可行:

  • 数据id(视频、评论等id)
  • 数据类型(视频、评论等)
  • 点赞状态(true,false)

这样通过不同的参数,将多个不同数据的点赞接口归到一个接口当中实现,这样是不是减少了不必要的冗余代码。但随之而来的问题也是显而易见的,以前点赞功能的流量被分散到三个或多个接口当中,而现在都归到一个接口来实现,那所有的流量就都会打到一个点赞接口当中,这对接口性能就要求很高了。

现在知道接口怎么设计了,那再来看看点赞表如何设计?

根据接口,我们可以确定这几个字段

  • 数据ID
  • 点赞状态

为啥没有数据类型?

我们回想一下,接口接入数据类类型是为了适配更多的不同数据的点赞业务。而点赞的记录表有一个点赞的数据ID,就可以锁定那条数据被点赞了,所以无需数据类型ID。

当然为了确保知道用户点赞了那条数据,所以还需要如下字段:

  • 用户ID

所以最后,我们的点在表 SQL 就是下面这样:

CREATE TABLE `sb_like` (
  `id` bigint(20) NOT NULL,
  `item_id` bigint(20) NOT NULL COMMENT '点赞条目id',
  `user_id` bigint(20) NOT NULL COMMENT '用户id',
  `like` tinyint(1) NOT NULL COMMENT '是否点赞,true点赞,false未点赞',
  `create_time` datetime DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `un` (`item_id`,`user_id`),
  KEY `k` (`item_id`,`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

说明:

  • item_id:必须是全局唯一ID(例如:雪花ID),也即不能是自增,因为这张点赞表是所有业务数据的点赞记录(视频、评论、动态等)。
  • id:字段最好也是全局唯一ID,因为后期可能需要进行分表,毕竟点赞记录是一个增长肯快的数据量,如果用自增ID,后期很难迁移。

2、实现

现在根据上面的分析,可以得出点赞请求的简单数据流向如下图:

Snipaste_2023-08-21_14-32-58.png

可以看到如果系统中的业务数据越来越多的话,点赞接口的访问量将会变得越来越大,从而变成一个高频访问接口(当然,点赞本身就是一个高频率动作)。

所以,面对一个高频接口,咱们的点赞记录肯定是不能直接入 MySQL 的,所以这里咱们的点赞数据第一步肯定是入 Redis。

一个是基于磁盘IO操作(MySQL),一个是基于内存操作(Redis)。

那,既然引入 Redis,肯定又会引申出一些其它问题,如:

  • 使用什么数据类型存储点赞数据
  • Redis 数据如何同步到 MySQL
  • 查询用户点赞,业务数据的点赞量会变得复杂很多

针对这三个问题,也好解决,我把解决的大致思路花了张图,相信一图胜千言:

Snipaste_2023-08-21_15-11-34.png

注:Redis 的存储数据类型为 hash

ok,下面就是我们的编码实现环节了,先来写点赞接口,这个非常好实现。

2.1 点赞接口实现

1)controller

位置:cn.j3code.community.api.v1.controller

@Slf4j
@ResponseResult
@AllArgsConstructor
@RestController
@RequestMapping(UrlPrefixConstants.WEB_V1 + "/like")
public class LikeController {

    private final LikeService likeService;

    /**
     * 点赞
     * @param request
     */
    @PostMapping("/")
    public void like(@Validated @RequestBody LikeRequest request) {
        likeService.like(request);
    }
}

LikeRequest 对象

位置:cn.j3code.community.api.v1.request

@Data
public class LikeRequest {
    @NotNull(message = "条目id不为空")
    private Long itemId;
    @NotNull(message = "点赞不为空")
    private Boolean like;
    @NotNull(message = "类型不为空")
    private CommentTypeEnum type;
}

CommentTypeEnum 枚举

位置:cn.j3code.config.enums

@Getter
public enum CommentTypeEnum {
    COMMODITY(1, "商品评论"),

    POST_COMMENT(2, "帖子评论"),

    POST(3, "帖子"),
    ;

    @EnumValue
    private Integer value;

    private String description;

    CommentTypeEnum(Integer value, String description) {
        this.value = value;
        this.description = description;
    }
}

2)service

位置:cn.j3code.community.service

public interface LikeService extends IService {
    void like(LikeRequest request);
}
@Slf4j
@AllArgsConstructor
@Service
public class LikeServiceImpl extends ServiceImpl
    implements LikeService {

    private final RedisTemplate redisTemplate;

    @Override
    public void like(LikeRequest request) {
        redisTemplate.opsForHash().put(
            SbUtil.getItemLikeKey(request.getType().getValue() + ":" + request.getItemId()),
            Objects.requireNonNull(SecurityUtil.getUserId(), "获取登录人信息出错!").toString(),
            request.getLike());
    }
}

是不是非常简单,只需要组装好 hash 结构的数据,访问一下 Redis 即可。

2.2 点赞数据同步数据库实现

前面我们提到过,持久化是通过定时任务进行的,所以这里有一点点问题就是如果在定时任务还未执行的时候,Redis 挂了,那这段时间的点赞记录将会丢失。上线的时候,Redis 的持久化功能要记得配好(AOF/RDB)。

现在,我们来分析分析点赞数据同步到数据库的流程:

  • 获取对应数据类型的所有点赞 key
  • 根据获取到的 key,获取所有的点赞记录,数据格式为 Map
  • 结合 Redis 点赞记录 + MySQL 持久化记录,计算出同一条数据的最终点赞状态和数据的点赞数量
  • 移除 redis 中,已经同步的数据
  • 更新业务数据点赞数量
  • 插入用户点赞记录数据
  • 这里,可能2、3点大家有点不好理解,没关系,我先张流程图整体来熟悉这个流程,后再来分析你们疑惑的点:

    Snipaste_2023-08-21_16-42-36.png

    在图中,我已经把能解释的问题都已经解释清楚了,下面就看代码实现吧!

    因为点赞业务有很多,所以对应的定时任务肯定也是不同的,这里我以本项目的帖子为例,来实现帖子数据点赞数据的同步。

    1)schedule

    位置:cn.j3code.community.schedule

    @Slf4j
    @Component
    @AllArgsConstructor
    public class PostLikeSchedule {
    
        private final PostService postService;
    
        /**
         * 同步帖子点赞
         */
        @DistributedLock
        @Scheduled(cron = "11 0/9 * * * ?")
        public void syncPostLike(){
            postService.syncPostLike();
        }
    }
    

    2)service

    位置:cn.j3code.community.service

    public interface PostService extends IService {
        void syncPostLike();
    }
    @Slf4j
    @AllArgsConstructor
    @Service
    public class PostServiceImpl extends ServiceImpl
        implements PostService {
        
        private final LikeServiceImpl likeService;
        private final RedisTemplate redisTemplate;
        private final TransactionTemplate transactionTemplate;
    
        @Override
        public void syncPostLike() {
            // 获取帖子点赞 key
            List keys = new ArrayList(redisTemplate.keys(SbUtil.getItemLikeKey(CommentTypeEnum.POST.getValue() + ":*")));
            if (CollectionUtils.isEmpty(keys)) {
                return;
            }
    
            Set commentIdList = keys.stream().map(key -> Long.valueOf(key.substring(key.lastIndexOf(":") + 1)))
                .collect(Collectors.toSet());
    
            // 批量查看 redis 评论id,的点赞数据
            ItemLikeBO itemLikeBO = likeService.getItemLikeCount(new ArrayList(commentIdList), CommentTypeEnum.POST, Boolean.TRUE);
            Map itemLikeCount = itemLikeBO.getItemLikeCount();
    
            /**
             * 帖子id 对应,用户id 和 点赞状态 的 map
             */
            Map postToUserLikeMap = itemLikeBO.getItemIdToUserLikeMap();
    
            // 待修改的评论的点赞数量集合
            List updatePostList = new ArrayList();
            // 待插入的点赞集合
            List saveLikeList = new ArrayList();
    
            postToUserLikeMap.forEach((postId, likeMap) -> {
                Post post = new Post();
                post.setId(postId);
                post.setLikeCount(itemLikeCount.get(postId));
                updatePostList.add(post);
    
                likeMap.forEach((key, value) -> {
                    Like like = new Like();
                    like.setId(SnowFlakeUtil.getId());
                    like.setItemId(postId);
                    like.setUserId(key);
                    like.setLike(value);
                    like.setCreateTime(LocalDateTime.now());
                    like.setUpdateTime(LocalDateTime.now());
                    saveLikeList.add(like);
                });
            });
    
            Map postMap = lambdaQuery()
                .in(Post::getId, postToUserLikeMap.keySet()).list()
                .stream().collect(Collectors.toMap(Post::getId, item -> item));
            // 点赞数量等于 数据库 + redis
            updatePostList.forEach(item ->
                                   item.setLikeCount(item.getLikeCount() + postMap.get(item.getId()).getLikeCount()));
    
            String format = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
            log.info(format + "-同步点赞数据:updatePostList={},saveLikeList={}",
                     JSON.toJSONString(updatePostList),
                     JSON.toJSONString(saveLikeList));
    
            // 修改数据库
            MyTransactionTemplate.execute(transactionTemplate, accept -> {
                CollUtil.split(updatePostList, 100).forEach(this::updateBatchById);
                CollUtil.split(saveLikeList, 100).forEach(likeService::saveOrUpdateByDuplicate);
            }, format + "-同步帖子点赞逻辑出错!");
        }
    }
    

    其中:likeService.getItemLikeCount 的代码就是上图中的第3、4、5的实现,它是点赞逻辑的公用方法,后期批量点赞同步、其它类型同步对应的3、4、5逻辑都是调用该方法实现的。代码如下:

    3)getItemLikeCount 方法实现

    位置:cn.j3code.community.service

    public interface LikeService extends IService {
        /**
         *
         * @param itemIdList 条目id集合
         * @param type 条目的数据类型
         * @param redisDataRemove 统计完后,是否移除redis中的数据
         * @return
         */
        ItemLikeBO getItemLikeCount(List itemIdList, CommentTypeEnum type, Boolean redisDataRemove);
    }
    @Slf4j
    @AllArgsConstructor
    @Service
    public class LikeServiceImpl extends ServiceImpl
        implements LikeService {
    
        private final RedisTemplate redisTemplate;
    
        @Override
        public ItemLikeBO getItemLikeCount(List itemIdList, CommentTypeEnum type, Boolean redisDataRemove) {
            ItemLikeBO itemLikeBO = new ItemLikeBO();
            Map result = new HashMap();
            itemIdList.forEach(itemId -> result.put(itemId, 0));
    
            // 批量查看 redis 评论id,的点赞数据
            List executePipelined = redisTemplate.executePipelined(new SessionCallback() {
                @Override
                public  Object execute(RedisOperations redisOperations) throws DataAccessException {
                    for (Long itemId : itemIdList) {
                        redisOperations.opsForHash().entries((K) SbUtil.getItemLikeKey(type.getValue() + ":" + itemId));
                    }
                    return null;
                }
            });
    
            /**
             * 评论id 对应,用户id 和 点赞状态 的 map
             */
            Map commentToUserLikeMap = new HashMap();
            for (int i = 0; i  lb.put(Long.valueOf(k), v));
    
                commentToUserLikeMap.put(commentId, lb);
    
    
                if (redisDataRemove) {
                    /**
                     * 这里会有一点点问题,就是如果在获取点赞 与 删除点赞 的时间空隙之间,同一个用户又操作了同一个评论的点赞
                     * 那这将会导致数据丢失
                     * 解决方法:
                     * 1、加锁
                     * 2、改用 MQ 方式
                     */
                    // 删除一下 redis 中的 点赞 记录
                    redisTemplate.opsForHash().delete(
                        SbUtil.getItemLikeKey(type.getValue() + ":" + commentId),
                        likeMap.keySet().stream().map(Object::toString).toArray(Object[]::new));
                }
            }
    
            for (int i = 0; i  like).count() + "")
                                      );
                    result.put(itemIdList.get(i), redisLikeCount.get());
                }
            }
    
            itemLikeBO.setItemLikeCount(result);
            itemLikeBO.setItemIdToUserLikeMap(commentToUserLikeMap);
            return itemLikeBO;
        }
    }
    

    代码实现的逻辑基本和我上图中画的流程一致,而且我代码注释也写得很清楚,相信你们应该能看懂。

    2.3 数据查询回填点赞记录

    那最后一个功能就是查询业务数据的时候我们不仅要吧MySQL中的点赞数据查出来,还要把 Redis 中的数据一同查出来进行组装,最后才把数据回显到页面。

    不过,这个逻辑不是很难,因为有了 2.2 节的公共方法的实现,所以这一步将会变得简单些。

    分析该逻辑之前,我们先来看看,页面要显示点赞的那些数据,页面如下:

    Snipaste_2023-08-21_17-09-37.png

    • 当前登录人点赞状态
    • 业务数据的点赞数量

    ok,我们先来分析用户点赞状态的获取流程:

  • 将查询到的业务数据集合转为 Map,初始情况下,状态都为 false
  • 根据业务ID、用户ID、点赞状态(true),查询MySQL的点赞记录,将对应的数据回填到 第一步的 Map 中
  • 再通过管道操作,批量访问 Redis,找到 hash key为业务id,属性 key 为 用户id 的点赞记录,将找到的数据直接覆盖到 Map 中
  • 最终,Map 中的数据,就是用户是否点赞业务的标识了。
  • 而点赞数量我就不分析业务流程了,因为 2.2 节已经分析过了就是 getItemLikeCount 方法的实现,只不过这里的 redisDataRemove 参数为 false ,不需要移除 redis 的数据,因为这是一个查询,而不是同步。

    下面,来看看我的查询业务数据列表伪代码实现:

    用伪代码实现是因为查询的流程基本就是先查业务数据,然后回填点赞数据,而前一步就没必要和大家说了,我用伪代码体现回填点赞的数据即可,相信大家都懂。

    public IPage page(PostPageRequest request) {
    
        // 先查询 MySQL 的业务数据
    
        // 用户评论点赞状态
        Map itemIdToLikeMap = likeService.getItemLikeState(voiPage.getRecords().stream().map(PostVO::getId).collect(Collectors.toList()), CommentTypeEnum.POST);
        // redis 中评论点赞数量
        Map itemIdToLikeCountMap = likeService.getItemLikeCount(voiPage.getRecords().stream().map(PostVO::getId).collect(Collectors.toList()), CommentTypeEnum.POST, Boolean.FALSE)
            .getItemLikeCount();
    
        // 填充评论点赞数量及当前用户点赞状态,用户信息
        业务数据列表.forEach(postVO -> {
            设置业务数据点赞数据(业务MySQL点赞数量 + itemIdToLikeCountMap.get(postVO.getId()));
            设置业务数据用户点赞状态(itemIdToLikeMap.get(postVO.getId()));
        });
    
    }
    

    getItemLikeState 方法实现:

    public Map getItemLikeState(List itemIdList, CommentTypeEnum type) {
        Map result = new HashMap();
        itemIdList.forEach(itemId -> result.put(itemId, Boolean.FALSE));
    
        if (Objects.isNull(SecurityUtil.getUserId())) {
            // 未登录
            return result;
        }
    
        if (CollectionUtils.isEmpty(itemIdList)) {
            return result;
        }
    
        // 查看数据库中是否有用户点赞记录
        lambdaQuery()
            .eq(Like::getUserId, SecurityUtil.getUserId())
            .eq(Like::getLike, Boolean.TRUE)
            .in(Like::getItemId, itemIdList)
            .list().forEach(likeObj -> {
            if (likeObj.getLike()) {
                result.put(likeObj.getItemId(), Boolean.TRUE);
            }
        });
    
        // 查看 redis 用户点赞记录
        List executePipelined = redisTemplate.executePipelined(new SessionCallback() {
            @Override
            public  Object execute(RedisOperations redisOperations) throws DataAccessException {
                for (Long itemId : itemIdList) {
                    redisOperations.opsForHash().get(
                        (K) SbUtil.getItemLikeKey(type.getValue() + ":" + itemId),
                        SecurityUtil.getUserId().toString());
                }
                return null;
            }
        });
    
        for (int i = 0; i < itemIdList.size(); i++) {
            if (Objects.nonNull(executePipelined.get(i))) {
                result.put(itemIdList.get(i), (Boolean) executePipelined.get(i));
            }
        }
    
        return result;
    }
    

    这个方法,就是我上面分析的代码实现了。

    到此,我们整个的一个业务点赞功能的实现就算完成了,当然其中肯定是有写不好的点和需要扩充的点:

    • 不好的点:如何解决查询 Redis 数据与移除 Redis 数据之间存在的数据差,或者说减小时间间隔
    • 不好的点:如何解决同步点赞数据到MySQL出错,而导致点赞记录丢失问题(目前:日志恢复)
    • 扩充的点:用户点赞之后,可以发送相关的 MQ 消息,告知用户,提示用户信息感知度

    如果这些大家有好的建议,可以评论里聊聊。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论