个人项目:社交支付项目(小老板)
作者:三哥,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、实现
现在根据上面的分析,可以得出点赞请求的简单数据流向如下图:
可以看到如果系统中的业务数据越来越多的话,点赞接口的访问量将会变得越来越大,从而变成一个高频访问接口(当然,点赞本身就是一个高频率动作)。
所以,面对一个高频接口,咱们的点赞记录肯定是不能直接入 MySQL 的,所以这里咱们的点赞数据第一步肯定是入 Redis。
一个是基于磁盘IO操作(MySQL),一个是基于内存操作(Redis)。
那,既然引入 Redis,肯定又会引申出一些其它问题,如:
- 使用什么数据类型存储点赞数据
- Redis 数据如何同步到 MySQL
- 查询用户点赞,业务数据的点赞量会变得复杂很多
针对这三个问题,也好解决,我把解决的大致思路花了张图,相信一图胜千言:
注: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)。
现在,我们来分析分析点赞数据同步到数据库的流程:
这里,可能2、3点大家有点不好理解,没关系,我先张流程图整体来熟悉这个流程,后再来分析你们疑惑的点:
在图中,我已经把能解释的问题都已经解释清楚了,下面就看代码实现吧!
因为点赞业务有很多,所以对应的定时任务肯定也是不同的,这里我以本项目的帖子为例,来实现帖子数据点赞数据的同步。
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 节的公共方法的实现,所以这一步将会变得简单些。
分析该逻辑之前,我们先来看看,页面要显示点赞的那些数据,页面如下:
- 当前登录人点赞状态
- 业务数据的点赞数量
ok,我们先来分析用户点赞状态的获取流程:
而点赞数量我就不分析业务流程了,因为 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 消息,告知用户,提示用户信息感知度
如果这些大家有好的建议,可以评论里聊聊。