天天看點

點贊功能,分析與實作,實用!

作者:IT知識分享官

1、分析

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

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

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

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

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

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

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

数据ID点赞状态

为啥没有数据类型?

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

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

用户ID

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

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、实现

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

點贊功能,分析與實作,實用!

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

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

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

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

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

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

點贊功能,分析與實作,實用!

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

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

2.1 点赞接口实现

1)controller

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

java复制代码@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

java复制代码@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

java复制代码@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

java复制代码public interface LikeService extends IService<Like> {
    void like(LikeRequest request);
}
@Slf4j
@AllArgsConstructor
@Service
public class LikeServiceImpl extends ServiceImpl<LikeMapper, Like>
    implements LikeService {

    private final RedisTemplate<String, Object> 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)。

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

  1. 获取对应数据类型的所有点赞 key
  2. 根据获取到的 key,获取所有的点赞记录,数据格式为 Map<业务数据ID,Map<用户ID,点赞状态>>
  3. 结合 Redis 点赞记录 + MySQL 持久化记录,计算出同一条数据的最终点赞状态和数据的点赞数量
  4. 移除 redis 中,已经同步的数据
  5. 更新业务数据点赞数量
  6. 插入用户点赞记录数据

这里,可能2、3点大家有点不好理解,没关系,我先张流程图整体来熟悉这个流程,后再来分析你们疑惑的点:

點贊功能,分析與實作,實用!

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

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

1)schedule

位置:cn.j3code.community.schedule

java复制代码@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

java复制代码public interface PostService extends IService<Post> {
    void syncPostLike();
}
@Slf4j
@AllArgsConstructor
@Service
public class PostServiceImpl extends ServiceImpl<PostMapper, Post>
    implements PostService {
    
    private final LikeServiceImpl likeService;
    private final RedisTemplate<String, Object> redisTemplate;
    private final TransactionTemplate transactionTemplate;

    @Override
    public void syncPostLike() {
        // 获取帖子点赞 key
        List<String> keys = new ArrayList<>(redisTemplate.keys(SbUtil.getItemLikeKey(CommentTypeEnum.POST.getValue() + ":*")));
        if (CollectionUtils.isEmpty(keys)) {
            return;
        }

        Set<Long> 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<Long, Integer> itemLikeCount = itemLikeBO.getItemLikeCount();

        /**
         * 帖子id 对应,用户id 和 点赞状态 的 map
         */
        Map<Long, Map<Long, Boolean>> postToUserLikeMap = itemLikeBO.getItemIdToUserLikeMap();

        // 待修改的评论的点赞数量集合
        List<Post> updatePostList = new ArrayList<>();
        // 待插入的点赞集合
        List<Like> 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<Long, Post> 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

java复制代码public interface LikeService extends IService<Like> {
    /**
     *
     * @param itemIdList 条目id集合
     * @param type 条目的数据类型
     * @param redisDataRemove 统计完后,是否移除redis中的数据
     * @return
     */
    ItemLikeBO getItemLikeCount(List<Long> itemIdList, CommentTypeEnum type, Boolean redisDataRemove);
}
@Slf4j
@AllArgsConstructor
@Service
public class LikeServiceImpl extends ServiceImpl<LikeMapper, Like>
    implements LikeService {

    private final RedisTemplate<String, Object> redisTemplate;

    @Override
    public ItemLikeBO getItemLikeCount(List<Long> itemIdList, CommentTypeEnum type, Boolean redisDataRemove) {
        ItemLikeBO itemLikeBO = new ItemLikeBO();
        Map<Long, Integer> result = new HashMap<>();
        itemIdList.forEach(itemId -> result.put(itemId, 0));

        // 批量查看 redis 评论id,的点赞数据
        List<Object> executePipelined = redisTemplate.executePipelined(new SessionCallback<>() {
            @Override
            public <K, V> Object execute(RedisOperations<K, V> redisOperations) throws DataAccessException {
                for (Long itemId : itemIdList) {
                    redisOperations.opsForHash().entries((K) SbUtil.getItemLikeKey(type.getValue() + ":" + itemId));
                }
                return null;
            }
        });

        /**
         * 评论id 对应,用户id 和 点赞状态 的 map
         */
        Map<Long, Map<Long, Boolean>> commentToUserLikeMap = new HashMap<>();
        for (int i = 0; i < itemIdList.size(); i++) {
            if (Objects.isNull(executePipelined.get(i))) {
                continue;
            }
            Long commentId = itemIdList.get(i);
            Map<String, Boolean> likeMap = (Map<String, Boolean>) executePipelined.get(i);

            Map<Long, Boolean> lb = new HashMap<>();
            likeMap.forEach((k, v) -> 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 < itemIdList.size(); i++) {
            Map<Long, Boolean> redisUserLikeMap = commentToUserLikeMap.get(itemIdList.get(i));
            if (Objects.nonNull(redisUserLikeMap) && CollectionUtils.isNotEmpty(redisUserLikeMap.keySet())) {
                // 查询数据库中,该评论的点赞记录
                Map<Long, Boolean> dbUserLikeMap = lambdaQuery()
                    .eq(Like::getItemId, itemIdList.get(i))
                    .eq(Like::getLike, Boolean.TRUE)
                    .in(Like::getUserId, redisUserLikeMap.keySet())
                    .list().stream().collect(Collectors.toMap(Like::getUserId, Like::getLike));
                // redis 与 数据库点赞记录 结合
                AtomicInteger redisLikeCount = new AtomicInteger(0);
                for (Map.Entry<Long, Boolean> entry : redisUserLikeMap.entrySet()) {
                    // 数据库存在点赞,redis 取消点赞,那点赞数量减一
                    if (dbUserLikeMap.containsKey(entry.getKey()) && Boolean.FALSE.equals(entry.getValue())) {
                        redisLikeCount.set(redisLikeCount.get() - 1);
                    }
                    // 数据库存在点赞,redis 存在点赞,那 Redis 点赞状态变为 false,不记录总点赞数量中
                    if (dbUserLikeMap.containsKey(entry.getKey()) && Boolean.TRUE.equals(entry.getValue())) {
                        entry.setValue(Boolean.FALSE);
                    }
                }

                redisLikeCount.set(redisLikeCount.get() +
                                   Integer.parseInt(redisUserLikeMap.values().stream().filter(like -> like).count() + "")
                                  );
                result.put(itemIdList.get(i), redisLikeCount.get());
            }
        }

        itemLikeBO.setItemLikeCount(result);
        itemLikeBO.setItemIdToUserLikeMap(commentToUserLikeMap);
        return itemLikeBO;
    }
}
           

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

2.3 数据查询回填点赞记录

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

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

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

點贊功能,分析與實作,實用!
  • 当前登录人点赞状态
  • 业务数据的点赞数量

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

  1. 将查询到的业务数据集合转为 Map<业务ID,用户点赞状态>,初始情况下,状态都为 false
  2. 根据业务ID、用户ID、点赞状态(true),查询MySQL的点赞记录,将对应的数据回填到 第一步的 Map 中
  3. 再通过管道操作,批量访问 Redis,找到 hash key为业务id,属性 key 为 用户id 的点赞记录,将找到的数据直接覆盖到 Map 中
  4. 最终,Map 中的数据,就是用户是否点赞业务的标识了。

而点赞数量我就不分析业务流程了,因为 2.2 节已经分析过了就是 getItemLikeCount 方法的实现,只不过这里的 redisDataRemove 参数为 false ,不需要移除 redis 的数据,因为这是一个查询,而不是同步。

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

用伪代码实现是因为查询的流程基本就是先查业务数据,然后回填点赞数据,而前一步就没必要和大家说了,我用伪代码体现回填点赞的数据即可,相信大家都懂。
java复制代码public IPage<PostVO> page(PostPageRequest request) {

    // 先查询 MySQL 的业务数据

    // 用户评论点赞状态
    Map<Long, Boolean> itemIdToLikeMap = likeService.getItemLikeState(voiPage.getRecords().stream().map(PostVO::getId).collect(Collectors.toList()), CommentTypeEnum.POST);
    // redis 中评论点赞数量
    Map<Long, Integer> 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 方法实现:

java复制代码public Map<Long, Boolean> getItemLikeState(List<Long> itemIdList, CommentTypeEnum type) {
    Map<Long, Boolean> 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<Object> executePipelined = redisTemplate.executePipelined(new SessionCallback<>() {
        @Override
        public <K, V> Object execute(RedisOperations<K, V> 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 消息,告知用户,提示用户信息感知度

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