点赞系统
开发流程
分析需求
点赞需要满足:
-
通用:点赞业务在设计的时候不要与业务系统耦合,必须同时支持不同业务的点赞功能
-
独立:点赞功能是独立系统,并且不依赖其它服务。这样才具备可迁移性。
-
并发:一些热点业务点赞会很多,所以点赞功能必须支持高并发
-
安全:要做好并发安全控制,避免重复点赞
因此必须抽取为一个独立服务。多个其它微服务业务的点赞数据都有点赞系统来维护。
点赞系统可以在点赞数变更时,通过MQ通知业务方,这样业务方就可以更新自己的点赞数量了。并且还避免了点赞系统与业务方的耦合。
实现思路
接口统计和分析;
数据库设计:分析字段,创建表格;
利用MP生成代码;
由于这个点赞微服务是一个独立的模块,他的开发流程是新建模块-依赖-配置环境-启动类-开发业务代码
实现点赞功能
点赞或取消点赞
根据需求和接口分析,得到四要素,然后写功能实现代码
代码思路
需要注意的是,由于每次点赞的业务类型不同,所以没有必要通知到所有业务方,而是仅仅通知与当前点赞业务关联的业务方即可。
在RabbitMQ中,利用TOPIC类型的交换机,结合不同的RoutingKey,可以实现通知对象的变化。我们需要让不同的业务方监听不同的RoutingKey,然后发送通知时根据点赞类型不同,发送不同RoutingKey
private final RabbitMqHelper mqHelper;
@Override
public void addLikeRecord(LikeRecordFormDTO recordDTO) {
// 1.基于前端的参数,判断是执行点赞还是取消点赞
boolean success = recordDTO.getLiked() ? like(recordDTO) : unlike(recordDTO);
// 2.判断是否执行成功,如果失败,则直接结束
if (!success) {
return;
}
// 3.如果执行成功,统计点赞总数
Integer likedTimes = lambdaQuery()
.eq(LikedRecord::getBizId, recordDTO.getBizId())
.count();
// 4.发送MQ通知
mqHelper.send(
LIKE_RECORD_EXCHANGE,
StringUtils.format(LIKED_TIMES_KEY_TEMPLATE, recordDTO.getBizType()),
LikedTimesDTO.of(recordDTO.getBizId(), likedTimes));
}
private boolean unlike(LikeRecordFormDTO recordDTO) {
return remove(new QueryWrapper<LikedRecord>().lambda()
.eq(LikedRecord::getUserId, UserContext.getUser())
.eq(LikedRecord::getBizId, recordDTO.getBizId()));
}
private boolean like(LikeRecordFormDTO recordDTO) {
Long userId = UserContext.getUser();
// 1.查询点赞记录
Integer count = lambdaQuery()
.eq(LikedRecord::getUserId, userId)
.eq(LikedRecord::getBizId, recordDTO.getBizId())
.count();
// 2.判断是否存在,如果已经存在,直接结束
if (count > 0) {
return false;
}
// 3.如果不存在,直接新增
LikedRecord r = new LikedRecord();
r.setUserId(userId);
r.setBizId(recordDTO.getBizId());
r.setBizType(recordDTO.getBizType());
save(r);
return true;
}
批量查询点赞状态
根据需求和接口分析,得到四要素,然后写功能实现代码
@Override
public Set<Long> isBizLiked(List<Long> bizIds) {
// 1.获取登录用户id
Long userId = UserContext.getUser();
// 2.查询点赞状态
List<LikedRecord> list = lambdaQuery()
.in(LikedRecord::getBizId, bizIds)
.eq(LikedRecord::getUserId, userId)
.list();
// 3.返回结果
return list.stream().map(LikedRecord::getBizId).collect(Collectors.toSet());
}
由于该接口是给其它微服务调用的,所以必须暴露出Feign客户端,并且定义好fallback降级处理
暴露出Feign客户端
@FeignClient(value = "remark-service", fallbackFactory = RemarkClientFallback.class)
public interface RemarkClient {
@GetMapping("/likes/list")
Set<Long> isBizLiked(@RequestParam("bizIds") Iterable<Long> bizIds);
}
定义好fallback降级处理
@Slf4j
public class RemarkClientFallback implements FallbackFactory<RemarkClient> {
@Override
public RemarkClient create(Throwable cause) {
log.error("查询remark-service服务异常", cause);
return new RemarkClient() {
@Override
public Set<Long> isBizLiked(Iterable<Long> bizIds) {
return CollUtils.emptySet();
}
};
}
}
由于RemarkClientFallback
是定义在tj-api
的com.tianji.api
包,由于每个微服务扫描包不一致。因此其它引用tj-api
的微服务是无法通过扫描包加载到这个类的。
我们需要通过SpringBoot的自动加载机制来加载这些fallback类
由于SpringBoot会在启动时读取/META-INF/spring.factories
文件,我们只需要在该文件中指定了要加载
监听点赞变更的消息
@Slf4j
@Component
@RequiredArgsConstructor
public class LikeTimesChangeListener {
private final IInteractionReplyService replyService;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "qa.liked.times.queue", durable = "true"),
exchange = @Exchange(name = LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),
key = QA_LIKED_TIMES_KEY
))
public void listenReplyLikedTimesChange(LikedTimesDTO dto){
log.debug("监听到回答或评论{}的点赞数变更:{}", dto.getBizId(), dto.getLikedTimes());
InteractionReply r = new InteractionReply();
r.setId(dto.getBizId());
r.setLikedTimes(dto.getLikedTimes());
replyService.updateById(r);
}
}
点赞功能改进
改进思路
我们应该像之前播放记录业务一样,采用合并写请求的方案。当然,现在的异步处理也保留,这样就兼顾了异步写、合并写的优势。
需要注意的是,合并写是有使用场景的,必须是对中间的N次写操作不敏感的情况下。点赞业务是否符合这一需求呢?
无论用户中间执行点赞、取消、再点赞、再取消多少次,点赞次数发生了多少次变化,业务方只关注最终的点赞结果即可:
-
用户是否点赞了
-
业务的总点赞次数
因此,点赞功能可以使用合并写方案。最终我们的点赞业务流程变成这样:
我们应该像之前播放记录业务一样,采用合并写请求的方案。当然,现在的异步处理也保留,这样就兼顾了异步写、合并写的优势。
需要注意的是,合并写是有使用场景的,必须是对中间的N次写操作不敏感的情况下。点赞业务是否符合这一需求呢?
无论用户中间执行点赞、取消、再点赞、再取消多少次,点赞次数发生了多少次变化,业务方只关注最终的点赞结果即可:
-
用户是否点赞了
-
业务的总点赞次数
因此,点赞功能可以使用合并写方案。最终我们的点赞业务流程变成这样:
我们应该像之前播放记录业务一样,采用合并写请求的方案。当然,现在的异步处理也保留,这样就兼顾了异步写、合并写的优势。
需要注意的是,合并写是有使用场景的,必须是对中间的N次写操作不敏感的情况下。点赞业务是否符合这一需求呢?
无论用户中间执行点赞、取消、再点赞、再取消多少次,点赞次数发生了多少次变化,业务方只关注最终的点赞结果即可:
-
用户是否点赞了
-
业务的总点赞次数
因此,点赞功能可以使用合并写方案。最终我们的点赞业务流程变成这样:
合并写请求有两个关键点要考虑:
-
数据如何缓存
-
缓存何时写入数据库
数据如何缓存
设计数据结构
Redis中的集合类型包含四种:
-
List
-
Set
-
SortedSet
-
Hash
而要判断用户是否点赞,就是判断存在且唯一。显然,Set集合是最合适的。我们可以用业务id为Key,创建Set集合,将点赞的所有用户保存其中,格式如下:
KEY(bizId) | VALUE(userId) |
---|---|
bizId:1 | userId:1 |
userId:2 | |
userId:3 |
由于点赞次数需要在业务方持久化存储到数据库,因此Redis只起到缓存作用即可。
由于需要记录业务id、业务类型、点赞数三个信息
我们可以把每一个业务类型作为一组,使用Redis的一个key,然后业务id作为键,点赞数作为值。这样的键值对集合,有两种结构都可以满足:
-
Hash:传统键值对集合,无序
-
SortedSet:基于Hash结构,并且增加了跳表。因此可排序,但更占用内存
如果是从节省内存角度来考虑,Hash结构无疑是最佳的选择;但是考虑到将来我们要从Redis读取点赞数,然后移除(避免重复处理)。为了保证线程安全,查询、移除操作必须具备原子性。
而SortedSet则提供了几个移除并获取的功能,天生具备原子性。
并且我们每隔一段时间就会将数据从Redis移除,并不会占用太多内存。因此,这里我们计划使用SortedSet结构。
格式如下:
KEY(bizType) | Member(bizId) | Score(likedTimes) |
---|---|---|
likes:qa | bizId:1001 | 10 |
bizId:1002 | 5 | |
likes:note | bizId:2001 | 9 |
bizId:2002 | 21 |
缓存何时写入数据库
而多数情况下,我们只能通过定时任务,定期将缓存的数据持久化到数据库中。
实现思路
业务代码修改
点赞
@Override
public void addLikeRecord(LikeRecordFormDTO recordDTO) {
// 1.基于前端的参数,判断是执行点赞还是取消点赞
boolean success = recordDTO.getLiked() ? like(recordDTO) : unlike(recordDTO);
// 2.判断是否执行成功,如果失败,则直接结束
if (!success) {
return;
}
// 3.如果执行成功,统计点赞总数
Long likedTimes = redisTemplate.opsForSet()
.size(RedisConstants.LIKES_BIZ_KEY_PREFIX + recordDTO.getBizId());
if (likedTimes == null) {
return;
}
// 4.缓存点总数到Redis
redisTemplate.opsForZSet().add(
RedisConstants.LIKES_TIMES_KEY_PREFIX + recordDTO.getBizType(),
recordDTO.getBizId().toString(),
likedTimes
);
}
private boolean unlike(LikeRecordFormDTO recordDTO) {
// 1.获取用户id
Long userId = UserContext.getUser();
// 2.获取Key
String key = RedisConstants.LIKES_BIZ_KEY_PREFIX + recordDTO.getBizId();
// 3.执行SREM命令
Long result = redisTemplate.opsForSet().remove(key, userId.toString());
return result != null && result > 0;
}
private boolean like(LikeRecordFormDTO recordDTO) {
// 1.获取用户id
Long userId = UserContext.getUser();
// 2.获取Key
String key = RedisConstants.LIKES_BIZ_KEY_PREFIX + recordDTO.getBizId();
// 3.执行SADD命令
Long result = redisTemplate.opsForSet().add(key, userId.toString());
return result != null && result > 0;
}
批量查询点赞状态统计
当我们判断某用户是否点赞时,需要使用下面命令:
# 判断用户是否点赞 SISMEMBER bizId userId
需要注意的是,这个命令只能判断一个用户对某一个业务的点赞状态。而我们的接口是要查询当前用户对多个业务的点赞状态。
因此,我们就需要多次调用SISMEMBER
命令,也就需要向Redis多次发起网络请求,给网络带宽带来非常大的压力,影响业务性能。
那么,有没有办法能够一个命令完成多个业务点赞状态判断呢?
非常遗憾,答案是没有!只能多次执行SISMEMBER
命令来判断。
不过,Redis中提供了一个功能,可以在一次请求中执行多个命令,实现批处理效果。这个功能就是Pipeline
@Override
public Set<Long> isBizLiked(List<Long> bizIds) {
// 1.获取登录用户id
Long userId = UserContext.getUser();
// 2.查询点赞状态
List<Object> objects = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
StringRedisConnection src = (StringRedisConnection) connection;
for (Long bizId : bizIds) {
String key = RedisConstants.LIKES_BIZ_KEY_PREFIX + bizId;
src.sIsMember(key, userId.toString());
}
return null;
});
// 3.返回结果
return IntStream.range(0, objects.size()) // 创建从0到集合size的流
.filter(i -> (boolean) objects.get(i)) // 遍历每个元素,保留结果为true的角标i
.mapToObj(bizIds::get)// 用角标i取bizIds中的对应数据,就是点赞过的id
.collect(Collectors.toSet());// 收集
}
定时任务
首先,在tj-remark
模块的RemarkApplication
启动类上添加注解,其作用就是启用Spring的定时任务功能。
然后,定义一个定时任务处理器类
@Component
@RequiredArgsConstructor
public class LikedTimesCheckTask {
private static final List<String> BIZ_TYPES = List.of("QA", "NOTE");
private static final int MAX_BIZ_SIZE = 30;
private final ILikedRecordService recordService;
@Scheduled(fixedDelay = 20000)
public void checkLikedTimes(){
for (String bizType : BIZ_TYPES) {
recordService.readLikedTimesAndSendMessage(bizType, MAX_BIZ_SIZE);
}
}
@Override
public void readLikedTimesAndSendMessage(String bizType, int maxBizSize) {
// 1.读取并移除Redis中缓存的点赞总数
String key = RedisConstants.LIKES_TIMES_KEY_PREFIX + bizType;
Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet().popMin(key, maxBizSize);
if (CollUtils.isEmpty(tuples)) {
return;
}
// 2.数据转换
List<LikedTimesDTO> list = new ArrayList<>(tuples.size());
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
String bizId = tuple.getValue();
Double likedTimes = tuple.getScore();
if (bizId == null || likedTimes == null) {
continue;
}
list.add(LikedTimesDTO.of(Long.valueOf(bizId), likedTimes.intValue()));
}
// 3.发送MQ消息
mqHelper.send(
LIKE_RECORD_EXCHANGE,
StringUtils.format(LIKED_TIMES_KEY_TEMPLATE, bizType),
list);
}
监听点赞数变更
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "qa.liked.times.queue", durable = "true"),
exchange = @Exchange(name = LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),
key = QA_LIKED_TIMES_KEY
))
public void listenReplyLikedTimesChange(List<LikedTimesDTO> likedTimesDTOs){
log.debug("监听到回答或评论的点赞数变更");
List<InteractionReply> list = new ArrayList<>(likedTimesDTOs.size());
for (LikedTimesDTO dto : likedTimesDTOs) {
InteractionReply r = new InteractionReply();
r.setId(dto.getBizId());
r.setLikedTimes(dto.getLikedTimes());
list.add(r);
}
replyService.updateBatchById(list);
}
面试
面试官:看你项目中介绍,你负责点赞功能的设计和开发,那你能不能讲讲你们的点赞系统是如何设计的?
答:首先在设计之初我们分析了一下点赞业务可能需要的一些要求。
例如,在我们项目中需要用到点赞的业务不止一个,因此点赞系统必须具备通用性,独立性,不能跟具体业务耦合。
再比如,点赞业务可能会有较高的并发,我们要考虑到高并发写库的压力问题。
所以呢,我们在设计的时候,就将点赞功能抽离出来作为独立服务。当然这个服务中除了点赞功能以外,还有与之关联的评价功能,不过这部分我就没有参与了。在数据层面也会用业务类型对不同点赞数据做隔离。
从具体实现上来说,为了减少数据库压力,我们会利用Redis来保存点赞记录、点赞数量信息。然后利用定时任务定期的将点赞数量同步给业务方,持久化到数据库中。
注意事项:回答时要先说自己的思考过程,再说具体设计,彰显你的逻辑清晰。设计的时候先不说细节,只说大概,停顿一下,吸引面试官去追问细节。如果面试官不追问,停顿一下后,自己接着说下面的
面试官追问:那你们Redis中具体使用了哪种数据结构呢?
答:我们使用了两种数据结构,set和zset
首先保存点赞记录,使用了set结构,key是业务类型+业务id,值是点赞过的用户id。当用户点赞时就SADD
用户id进去,当用户取消点赞时就SREM
删除用户id。当判断是否点赞时使用SISMEMBER
即可。当要统计点赞数量时,只需要SCARD
就行,而Redis的SET结构会在头信息中保存元素数量,因此SCARD直接读取该值,时间复杂度为O(1),性能非常好。
为什么不用用户id为key,业务id为值呢?如果用户量很大,可能出现BigKey?
您说的这个方案也是可以的,不过呢,考虑到我们的项目数据量并不会很大,我们不会有大V,因此点赞数量通常不会超过1000,因此不会出现BigKey。并且,由于我们采用了业务id为KEY,当我们要统计点赞数量时,可以直接使用SCARD来获取元素数量,无需额外保存,这是一个很大的优势。但如果是考虑到有大V的场景,有两种选择,一种还是应该选择您说的这种方案,另一种则是对用户id做hash分片,将大V的key拆分到多个KEY中,结构为 [bizType:bizId:userId高8位]
不过这里存在一个问题,就是页面需要判断当前用户有没有对某些业务点赞。这个时候会传来多个业务id的集合,而SISMEMBER只能一次判断一个业务的点赞状态,要判断多个业务的点赞状态,就必须多次调用SISMEMBER命令,与Redis多次交互,这显然是不合适的。(此处略停顿,等待面试官追问,面试官可能会问“那你们怎么解决的”。如果没追问,自己接着说),所以呢我们就采用了Pipeline管道方式,这样就可以一次请求实现多个业务点赞状态的判断了。
面试官追问(可能会):那你ZSET干什么用的?
答:严格来说ZSET并不是用来实现点赞业务的,因为点赞只靠SET就能实现了。但是这里有一个问题,我们要定期将业务方的点赞总数通过MQ同步给业务方,并持久化到数据库。但是如果只有SET,我没办法知道哪些业务的点赞数发生了变化,需要同步到业务方。
因此,我们又添加了一个ZSET结构,用来记录点赞数变化的业务及对应的点赞总数。可以理解为一个待持久化的点赞任务队列。
每当业务被点赞,除了要缓存点赞记录,还要把业务id及点赞总数写入ZSET。这样定时任务开启时,只需要从ZSET中获取并移除数据,然后发送MQ给业务方,并持久化到数据库即可。
面试官追问(可能会,没追问就自己说):那为什么一定要用ZSET结构,把更新过的业务扔到一个List中不行吗?
答:扔到List结构中虽然也能实现,但是存在一些问题:
首先,假设定时任务每隔2分钟执行一次,一个业务如果在2分钟内多次被点赞,那就会多次向List中添加同一个业务及对应的点赞总数,数据库也要持久化多次。这显然是多余的,因为只有最后一次才是有效的。而使用ZSET则因为member的唯一性,多次添加会覆盖旧的点赞数量,最终也只会持久化一次。
(面试官可能说:“那就改为SET结构,SET中只放业务id,业务方收到MQ通知后再次查询不就行了。”如果没问就自己往下说)
当然要解决这个问题,也可以用SET结构代替List,然后当业务被点赞时,只存业务id到SET并通知业务方。业务方接收到MQ通知后,根据id再次查询点赞总数从而避免多次更新的问题。但是这种做法会导致多次网络通信,增加系统网络负担。而ZSET则可以同时保存业务id及最新点赞数量,避免多次网络查询。
不过,并不是说ZSET方案就是完全没问题的,毕竟ZSET底层是哈希结构+跳表,对内存会有额外的占用。但是考虑到我们的定时任务每次会查询并删除ZSET数据,ZSET中的数据量始终会维持在一个较低级别,内存占用也是可以接受的。
注意:加黑的地方一定要说,彰显你对Redis底层数据结构和算法有深入了解。