前言
通用点赞系统需要满足下列特性:
- 通用:点赞业务在设计的时候不要与业务系统耦合,必须同时支持不同业务的点赞功能
- 独立:点赞功能是独立系统,并且不依赖其它服务。这样才具备可迁移性。
- 并发:一些热点业务点赞会很多,所以点赞功能必须支持高并发
- 安全:要做好并发安全控制,避免重复点赞
实现思路
要保证安全,避免重复点赞,我们就必须 保存每一次点赞记录 。只有这样在下次用户点赞时我们 才能查询数据,判断是否是重复点赞 。同时,因为业务方经常需要根据点赞数量 排序 ,因此每个业务的 点赞数量也需要记录下来 。 综上,点赞的基本思路如下:
问题
点赞服务必须独立,因此必须 抽取为一个独立服务
如果业务方需要根据点赞数排序,就必须在数据库中维护点赞数字段。但是点赞系统无法修改其它业务服务的数据库,否则就出现了 业务耦合 。该怎么办呢?
解决:点赞数变更时,通过 MQ通知业务方,避免了点赞系统与业务方的耦合
数据结构设计
一是 点赞记录 ,二是与 业务关联的点赞数
点赞数是与具体业务表关联在一起记录 例如回答或者评论:
其他亦是如此
点赞记录本质就是记录谁给什么内容点了赞,所以核心属性包括:
- 点赞目标id
- 点赞人id 不过点赞的内容多种多样,为了加以区分,我们还需要把点赞内的类型记录下来:
- 点赞对象类型(为了通用性) 当然还有点赞时间,综上对应的数据库ER图如下:
设计出表结构如下
优化前的方案实现
- 点赞就 新增 一条点赞记录,取消点赞就 删除 记录
- 用户不能重复点赞
- 点赞数由具体的业务方保存,需要 通知业务方更新点赞数
由于业务方的类型很多,比如互动问答、笔记、课程等。所以通知方式必须是低耦合的,这里建议使用MQ来实现。 当点赞或取消点赞后,点赞数发生变化,我们就发送MQ通知。整体业务流程如图:
每次点赞的业务类型不同,所以没有必要通知到所有业务方,而是仅仅通知与当前点赞业务关联的业务方即可。
利用TOPIC
类型的交换机,结合不同的RoutingKey
,不同的业务方监听不同的RoutingKey,互不干扰。
我在MqConstants定义了如下的用于点赞的常量
/*点赞的RoutingKey*/
String LIKED_TIMES_KEY_TEMPLATE = "{}.times.changed";
/*问答*/
String QA_LIKED_TIMES_KEY = "QA.times.changed";
/*笔记*/
String NOTE_LIKED_TIMES_KEY = "NOTE.times.changed";
/*点赞记录有关的交换机*/
String LIKE_RECORD_EXCHANGE = "like.record.topic";
优化前的代码实现
/**
* <p>
* 点赞记录表 服务实现类
* </p>
*/
@Service
@RequiredArgsConstructor
public class LikedRecordServiceImpl extends ServiceImpl<LikedRecordMapper, LikedRecord> implements ILikedRecordService {
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;
}
}
- 点赞数变更的消息监听器
@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);
}
}
老方案存在的问题
- 点赞业务包含 多次数据库读写操作
- 点赞操作波动较大,有可能会在短时间内访问量激增。例如 有人非常频繁的点赞、取消点赞。这样就会给数据库带来非常大的压力。
改进思路
点赞业务与提交播放记录类似,都是高并发写操作。
参考我们之前视频续播的功能实现:续播功能之合并写请求提高并发量 有如下三个手段
- 优化SQL和代码
- 变同步写为异步写
- 合并写请求
虽然利用MQ异步写来实现点赞数的变更(仅仅是解耦),但是数据库的写次数没有减少,压力依然很大;我们可以借鉴之前视频续播的合并写吗?
合并写是有使用场景的,必须是对中间的N次写操作不敏感的情况下。点赞业务是否符合这一需求呢?
无论用户中间执行点赞、取消、再点赞、再取消多少次,业务方只关注最终的点赞次数结果,所以是符合的!
改善后如下:
合并写请求有两个关键点要考虑:
- 数据如何缓存
- 缓存何时写入数据库
分析
点赞数据缓存 赞记录中最两个关键信息:
- 用户是否点赞
- 某业务的点赞总次数
用户是否点赞
判断用户是否点赞,就是判断存在且唯一。显然,Set
集合 是最合适的
业务id为Key set
集合保存点赞过的用户ID
redis也提供了丰富的指令
# 判断用户是否点赞
SISMEMBER bizId userId
# 点赞,如果返回1则代表点赞成功,返回0则代表点赞失败
SADD bizId userId
# 取消点赞,就是删除一个元素
SREM bizId userId
# 统计点赞总数
SCARD bizId
- 由于Redis本身具备持久化机制,
AOF
提供的数据可靠性已经能够满足点赞业务的安全需求,因此我们完全可以用Redis存储来代替数据库的点赞记录。 - 也就是说,用户的一切点赞行为,以及将来查询点赞状态我们可以都走Redis,不再使用数据库查询。
如果点赞数据非常庞大,达到数百亿,那么该怎办呢?
大多数企业根本达不到这样的规模,如果真的达到也没有关系。这个时候我们可以将Redis与数据库结合。
- 先利用Redis来记录点赞状态
- 并且 定期的将Redis中的点赞状态持久化到数据库
- 对于历史点赞记录,比如下架的课程、或者超过2年以上的访问量较低的数据都可以从redis移除,只保留在数据库中
- 当某个记录点赞时,优先去Redis查询并判断,如果Redis中不存在,再去查询数据库数据并缓存到Redis
某业务的点赞总次数
由于点赞次数需要在业务方持久化存储到数据库,因此Redis只起到缓存作用即可。
由于需要记录业务id
、业务类型
、点赞数
三个信息:
- 一个业务类型下包含多个业务id
- 每个业务id对应一个点赞数。
因此,我们可以把每一个业务类型
作为一组,使用Redis的一个key
,然后业务id作为键
,点赞数作为值。这样的键值对集合,有两种结构都可以满足:
- Hash:传统键值对集合,无序
- SortedSet:基于Hash结构,并且增加了跳表。因此可排序,但更占用内存
从节省 内存角度 来考虑,Hash结构 无疑是最佳的选择
考虑到将来我们要从 Redis读取点赞数,然后移除(避免重复处理)
为了保证线程安全, 查询、移除操作必须具备原子性
。而SortedSet则提供了几个移除并获取的功能,天生具备原子性
并且我们 每隔一段时间就会将数据从Redis移除 ,并不会占用太多内存。因此,这里我们计划使用 SortedSet
结构。
优化后的代码实现
Redis的常量定义
public interface RedisConstants {
String LIKES_BIZ_KEY_PREFIX = "likes
biz:";
String LIKES_TIMES_KEY_PREFIX = "likes
type:";
}
点赞取消点赞的实现
@Service
@RequiredArgsConstructor
public class LikedRecordServiceRedisImpl extends ServiceImpl<LikedRecordMapper, LikedRecord> implements ILikedRecordService {
private final RabbitMqHelper mqHelper;
private final StringRedisTemplate redisTemplate;
@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;
}
}
批量判断用户对某一业务的点赞情况,可以看我之前发过的另外一篇文章: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());// 收集
}
入库问题
何时把缓存的点赞数,通过MQ通知到业务方,持久化到业务方的数据库呢?
点赞频率如何完全不确定。因此无法采用延迟检测这样的手段。怎么办?
解决方案:
- 通过定时任务,定期将缓存的数据持久化到数据库中。
我们这里先使用SpringTask来进行点赞次数的持久化,稍后我们使用xxl-job来实现
启动类加上注解@EnableScheduling
定时任务
@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);
}
监听点赞次数的变更并且入库
@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(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中具体使用了哪种数据结构呢?
那你ZSET干什么用的?
那为什么一定要用ZSET结构,把更新过的业务扔到一个List中不行吗?
评论( 0 )