当前位置: 首页 > news >正文

点赞系统实现

点赞功能是社交、电商等几乎所有的互联网项目中都广泛使用。虽然看起来简单,不过蕴含的技术方案和手段还是比较多的。

下面将分享之前做的判题OJ系统的点赞系统的思路。

1.需求分析

点赞功能与其它功能不同,没有复杂的原型和需求,仅仅是一个点赞、取消点赞的操作。所以,今天我们就不需要从原型图来分析,而是仅仅从这个功能的实现方案来思考。

1.1.业务需求

首先我们来分析整理一下点赞业务的需求,一个通用点赞系统需要满足下列特性:

  • 通用:点赞业务在设计的时候不要与业务系统耦合,必须同时支持不同业务的点赞功能
  • 独立:点赞功能是独立系统,并且不依赖其它服务。这样才具备可迁移性。
  • 并发:一些热点业务点赞会很多,所以点赞功能必须支持高并发
  • 安全:要做好并发安全控制,避免重复点赞

1.2.实现思路

要保证安全,避免重复点赞,我们就必须保存每一次点赞记录。只有这样在下次用户点赞时我们才能查询数据,判断是否是重复点赞。同时,因为业务方经常需要根据点赞数量排序,因此每个业务的点赞数量也需要记录下来。

综上,点赞的基本思路如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

但问题来了,我们说过点赞服务必须独立,因此必须抽取为一个独立服务。多个其它微服务业务的点赞数据都有点赞系统来维护。但是问题来了:

如果业务方需要根据点赞数排序,就必须在数据库中维护点赞数字段。但是点赞系统无法修改其它业务服务的数据库,否则就出现了业务耦合。该怎么办呢?

点赞系统可以在点赞数变更时,通过MQ通知业务方,这样业务方就可以更新自己的点赞数量了。并且还避免了点赞系统与业务方的耦合。

于是,实现思路变成了这样:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

2.数据结构

点赞的数据结构分两部分,一是点赞记录,二是与业务关联的点赞数

点赞数是跟具体业务表关联在一起,比如题目讨论区的点赞,自然是在题目表中记录点赞数。问答区的话自然在问答表中记录点赞数。为什么呢?我们不可能将点赞数表放在点赞系统的数据库表里,因为查询题目列表不得每次用Feign调用每一条题目数据的点赞数,这样是不可行的。

因此,本节我们只需要实现点赞记录的表结构设计即可。

2.1.ER图

点赞记录本质就是记录谁给什么内容点了赞,所以核心属性包括:

  • 点赞目标id
  • 点赞人id

不过点赞的内容多种多样,为了加以区分,我们还需要把点赞内的类型记录下来:

  • 点赞对象类型(为了通用性)

当然还有点赞时间,综上对应的数据库ER图如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

2.2.表结构

由于点赞系统是独立于其它业务的,这里我们需要创建一个新的数据库hjx_remark

CREATE DATABASE hjx_remark CHARACTER SET 'utf8mb4';

然后在ER图基础上,加上一些通用属性,点赞记录表结构如下:

CREATE TABLE IF NOT EXISTS `liked_record` (`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',`user_id` bigint NOT NULL COMMENT '用户id',`biz_id` bigint NOT NULL COMMENT '点赞的业务id',`biz_type` VARCHAR(16) NOT NULL COMMENT '点赞的业务类型',`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (`id`),UNIQUE KEY `idx_biz_user` (`biz_id`,`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='点赞记录表';

点赞统计表:

CREATE TABLE IF NOT EXISTS `liked_stat` (`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',`liked_times` int NOT NULL COMMENT '点赞数量',`biz_id` bigint NOT NULL COMMENT '点赞的业务id',`biz_type` VARCHAR(16) NOT NULL COMMENT '点赞的业务类型',`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (`id`),UNIQUE KEY `idx_biz_user` (`biz_id`,`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='点赞统计表';

2.3.代码生成

3.实现点赞功能

从表面来看,点赞功能要实现的接口就是一个点赞接口。不过仔细观察所有的点赞页面,你会发现点赞按钮有灰色和点亮两种状态。

也就是说我们还需要实现查询用户点赞状态的接口,这样前端才能根据点赞状态渲染不同效果。因此我们要实现的接口包括:

  • 点赞/取消点赞
  • 根据多个业务id批量查询用户是否点赞多个业务(比如查询多个题目id对应的用户点赞情况)

3.1.点赞或取消点赞

3.1.1.接口信息

当用户点击点赞按钮的时候,第一次点击是点赞,按钮会高亮;第二次点击是取消,点赞按钮变灰:

从后台实现来看,点赞就是新增一条点赞记录取消就是删除这条记录。为了方便前端交互,这两个合并为一个接口即可。

因此,请求参数首先要包含点赞有关的数据,并且要标记是点赞还是取消:

  • 点赞的目标业务id:bizId
  • 谁在点赞(就是登陆用户,可以不用提交)
  • 点赞还是取消

除此以外,我们之前说过,在问答题目讨论等功能中都会出现点赞功能,所以点赞必须具备通用性。因此还需要在提交一个参数标记点赞的类型:

  • 点赞目标的类型

返回值有两种设计:

  • 方案一:无返回值,200就是成功,页面直接把点赞数+1展示给用户即可(弱一致性,但为了性能)
  • 方案二:返回点赞数量,页面渲染

这里推荐使用方案一,因为每次统计点赞数量也有很大的性能消耗。

综上,按照Restful风格设计,接口信息如下:

接口说明用户可以给自己喜欢的内容点赞,也可以取消点赞
请求方式POST
请求路径/likes
请求参数格式{ "bizId": "1578558664933920770", // 点赞业务id(如题目id) "bizType": 1, // 点赞业务类型,1:问答区;2:题目讨论;.. "liked": true, // 是否点赞,true:点赞,false:取消 }
返回值格式

3.1.2.实体代码实现

@Data
@ApiModel(description = "点赞记录表单实体")
public class LikeRecordFormDTO {@ApiModelProperty("点赞业务id")@NotNull(message = "业务id不能为空")private Long bizId;@ApiModelProperty("点赞业务类型")@NotNull(message = "业务类型不能为空")private String bizType;@ApiModelProperty("是否点赞,true:点赞;false:取消点赞")@NotNull(message = "是否点赞不能为空")private Boolean liked;
}
/*** <p>* 点赞记录表 控制器* </p>*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/likes")
@Api(tags = "点赞业务相关接口")
public class LikedRecordController {private final ILikedRecordService likedRecordService;@PostMapping@ApiOperation("点赞或取消点赞")public void addLikeRecord(@Valid @RequestBody LikeRecordFormDTO recordDTO) {likedRecordService.addLikeRecord(recordDTO);}
}

3.1.4.业务流程

梳理一下点赞业务的几点需求:

  • 点赞就新增一条点赞记录,取消点赞就删除记录
  • 用户不能重复点赞
  • 点赞数由具体的业务方保存,需要通知业务方更新点赞数

由于业务方的类型很多,比如互动问答、题目问答等。所以通知方式必须是低耦合的,这里建议使用MQ来实现。

当点赞或取消点赞后,点赞数发生变化,我们就发送MQ通知。整体业务流程如图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

需要注意的是,由于每次点赞的业务类型不同,所以没有必要通知到所有业务方,而是仅仅通知与当前点赞业务关联的业务方即可

在RabbitMQ中,利用TOPIC类型的交换机,结合不同的RoutingKey,可以实现通知对象的变化。我们需要让不同的业务方监听不同的RoutingKey,然后发送通知时根据点赞类型不同,发送不同RoutingKey:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

当然,真实的RoutingKey不一定如图中所示,这里只是做一个示意。

3.1.5.实现完整业务

首先我们需要定义一个MQ通知的消息体,由于这个消息体会在各个相关微服务中使用,需要定义到公用的模块中:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LikedTimesDTO {/*** 点赞的业务id*/private Long bizId;/*** 总的点赞次数*/private Integer likedTimes;
}

然后是com.tianji.remark.service.impl.LikedRecordServiceImpl完整的业务逻辑:

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import java.util.List;
import java.util.stream.Collectors;/*** <p>* 点赞记录表 服务实现类* </p>*/
@Service
@RequiredArgsConstructor
public class LikedRecordServiceImpl extends ServiceImpl<LikedRecordMapper, LikedRecord> implements ILikedRecordService {private final RabbitMqHelper mqHelper;@Overridepublic void addLikeRecord(LikeRecordFormDTO recordDTO) {// 1.基于前端的参数,判断是执行点赞还是取消点赞boolean success = recordDTO.getLiked() ? like(recordDTO) : unlike(recordDTO);// 2.判断是否执行成功,如果失败,则直接结束if (!success) {return;}// 3.如果执行成功,根据业务id统计点赞总数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;}
}

3.2.批量查询点赞状态

由于这个接口是供其它微服务调用,实现完成接口后,还需要定义对应的FeignClient

3.2.1.接口信息

这里是查询多个业务的点赞状态,因此请求参数自然是业务id的集合。由于是查询当前用户的点赞状态,因此无需传递用户信息。

经过筛选判断后,我们把点赞过的业务id集合返回即可。

综上,按照Restful来设计该接口,接口信息如下:

接口说明查询当前用户是否点赞了指定的业务
请求方式GET
请求路径/likes/list
请求参数格式请求数据类型:application/x-www-form-urlencoded例如:bizIds=1,2,3 代表业务id集合
返回值格式[ "业务id1", "业务id2", "业务id3", "业务id4" ]

3.3.2.代码

首先是tj-remarkcom.hjx.remark.controller.LikedRecordController

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;import javax.validation.Valid;
import java.util.List;
import java.util.Set;/*** <p>* 点赞记录表 控制器* </p>*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/likes")
@Api(tags = "点赞业务相关接口")
public class LikedRecordController {private final ILikedRecordService likedRecordService;@PostMapping@ApiOperation("点赞或取消点赞")public void addLikeRecord(@Valid @RequestBody LikeRecordFormDTO recordDTO) {likedRecordService.addLikeRecord(recordDTO);}@GetMapping("list")@ApiOperation("查询指定业务id的点赞状态")public Set<Long> isBizLiked(@RequestParam("bizIds") List<Long> bizIds){return likedRecordService.isBizLiked(bizIds);}
}

对应实现类

@Override
public Set<Long> isBizLiked(List<Long> bizIds) {// 1.获取登录用户idLong 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());
}

3.3.3.暴露Feign接口

由于该接口是给其它微服务调用的,所以必须暴露出Feign客户端,并且定义好fallback降级处理:

我们在api模块中定义一个客户端:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

其中RemarkClient如下:

import com.tianji.api.client.remark.fallback.RemarkClientFallback;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;import java.util.Set;@FeignClient(value = "remark-service", fallbackFactory = RemarkClientFallback.class)
public interface RemarkClient {@GetMapping("/likes/list")Set<Long> isBizLiked(@RequestParam("bizIds") Iterable<Long> bizIds);
}

对应的fallback逻辑:

import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;import java.util.Set;@Slf4j
public class RemarkClientFallback implements FallbackFactory<RemarkClient> {@Overridepublic RemarkClient create(Throwable cause) {log.error("查询remark-service服务异常", cause);return new RemarkClient() {@Overridepublic Set<Long> isBizLiked(Iterable<Long> bizIds) {return CollUtils.emptySet();}};}
}

如果Feign定义在api包下,由于每个微服务扫描包不一致。因此其它引用api的微服务是无法通过扫描包加载到这个类的。

我们需要通过SpringBoot的自动加载机制来加载这些fallback类:

由于SpringBoot会在启动时读取/META-INF/spring.factories文件,我们只需要在该文件中指定了要加载

FallbackConig类:

@Configuration
public class FallbackConfig {@Beanpublic LearningClientFallback learningClientFallback(){return new LearningClientFallback();}@Beanpublic TradeClientFallback tradeClientFallback(){return new TradeClientFallback();}@Beanpublic RemarkClientFallback remarkClientFallback(){return new RemarkClientFallback();}
}

这样所有在其中定义的fallback类都会被加载了。

3.3.3.改造查询回复接口

开发查询点赞状态接口的目的,是为了在查询用户回答和评论时,能看到当前用户是否点赞了。所以我们需要改造之前实现的分页查询回答或评论的接口。

注入评价服务的Feign客户端:

3.4.监听点赞变更的消息

既然点赞后会发送MQ消息通知业务服务,那么每一个有关的业务服务都应该监听点赞数变更的消息,更新本地的点赞数量。

例如题目讨论区:我们需要再题目服务中定义MQ监视器:

比如本地回答区服务,执行更新自己的点赞数对应字段。

@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);}
}

4.点赞功能改进

虽然我们初步实现了点赞功能,不过有一个非常严重的问题,点赞业务包含多次数据库读写操作:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

更重要的是,点赞操作波动较大,有可能会在短时间内访问量激增。例如有人非常频繁的点赞、取消点赞。这样就会给数据库带来非常大的压力。

怎么办呢?

4.1.改进思路分析

高并发写操作常见的优化手段有:

  • 优化SQL和代码
  • 变同步写为异步写
  • 合并写请求

有人可能会说,我们更新业务方点赞数量的时候,不就是利用MQ异步写来实现的吗?

没错,确实如此,虽然异步写减少了业务执行时间,降低了数据库写频率。不过此处更重要的是利用MQ来解耦。而且数据库的写次数没有减少,压力依然很大。(总的读写并没有改变)

这里我们采用合并写的优化方式

需要注意的是,合并写是有使用场景的,必须是对中间的N次写操作不敏感的情况下。点赞业务是否符合这一需求呢?

无论用户中间执行点赞、取消、再点赞、再取消多少次,点赞次数发生了多少次变化,业务方只关注最终的点赞结果即可:(点赞这个东西不要求实时性很强)

  • 用户是否点赞了
  • 业务的总点赞次数

因此,点赞功能可以使用合并写方案。最终我们的点赞业务流程变成这样:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

合并写请求有两个关键点要考虑:

  • 数据如何缓存
  • 缓存何时写入数据库

4.1.1.点赞数据缓存

点赞记录中最两个关键信息:

  • 用户是否点赞
  • 某业务的点赞总次数

这两个信息需要分别记录,也就是说我们需要在Redis中设计两种数据结构分别存储。

4.1.1.1.用户是否点赞

要知道某个用户是否点赞某个业务,就必须记录业务id以及给业务点赞的所有用户id . 由于一个业务可以被很多用户点赞,显然是需要一个集合来记录。而Redis中的集合类型包含四种:

  • List
  • Set
  • SortedSet
  • Hash

而要**判断用户是否点赞,就是判断存在且唯一。显然,Set集合是最合适的。**我们可以用业务id为Key,创建Set集合,将点赞的所有用户保存其中,格式如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

可以使用Set集合的下列命令完成点赞功能:

# 判断用户是否点赞 Redis Sismember 命令判断成员元素是否是集合的成员。
SISMEMBER bizId userId
# 点赞,如果返回1则代表点赞成功,返回0则代表点赞失败    	SADD key member1 [member2]向集合添加一个或多个成员
SADD bizId userId   
# 取消点赞,就是删除一个元素  
SREM bizId userId
# 统计点赞总数  获取集合的成员数
SCARD bizId

由于本身具备持久化机制AOF提供的数据可靠性已经能够满足点赞业务的安全需求,因此我们完全可以用Redis存储来代替数据库的点赞记录。

也就是说,用户的一切点赞行为,以及将来查询点赞状态我们可以都走Redis,不再使用数据库查询。

如果点赞数据非常庞大,达到数百亿,那么该怎办呢?

大多数企业根本达不到这样的规模,如果真的达到也没有关系。这个时候我们可以将Redis与数据库结合。

  • 先利用Redis来记录点赞状态
  • 并且定期的将Redis中的点赞状态持久化到数据库
  • 对于历史点赞记录,比如删除的题目、或者超过2年以上的访问量较低的数据都可以从redis移除,只保留在数据库中
  • 当某个记录点赞时,优先去Redis查询并判断,如果Redis中不存在,再去查询数据库数据并缓存到Redis
4.1.1.2.点赞次数

由于点赞次数需要在业务方持久化存储到数据库,因此Redis只起到缓存作用即可。

由于需要记录业务id、业务类型、点赞数三个信息:

  • 一个业务类型下包含多个业务id
  • 每个业务id对应一个点赞数。

因此,我们可以把每一个业务类型作为一组,使用Redis的一个key,然后业务id作为键,点赞数作为值。这样的键值对集合,有两种结构都可以满足:

  • Hash:传统键值对集合,无序
  • SortedSet:基于Hash结构,并且增加了跳表。因此可排序,但更占用内存

如果是从节省内存角度来考虑,Hash结构无疑是最佳的选择;**但是考虑到将来我们要从Redis读取点赞数,然后移除(避免重复处理)。为了保证线程安全,查询、移除操作必须具备原子性。SortedSet则提供了几个移除并获取的功能,天生具备原子性。**并且我们每隔一段时间就会将数据从Redis移除,并不会占用太多内存。因此,这里我们计划使用SortedSet结构。

格式如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

当用户对某个业务点赞时,我们统计点赞总数,并将其缓存在Redis中。这样一来在一段时间内,不管有多少用户对该业务点赞(热点业务数据,比如某个微博大V),都只在Redis中修改点赞总数,无需修改数据库。

4.1.2.点赞数据入库

点赞数据写入缓存了,但是这里有一个新的问题:

何时把缓存的点赞数,通过MQ通知到业务方,持久化到业务方的数据库呢?

用户何时点赞、点赞频率如何完全不确定。因此无法采用延迟检测这样的手段。怎么办?

事实上这也是大多数合并写请求业务面临的问题,而多数情况下,我们只能通过定时任务,定期将缓存的数据持久化到数据库中。

4.1.3.流程图

综上所述,基于Redis做写缓存后,点赞流程如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

由于需要访问Redis,我们提前定义一个常量类,把Redis相关的Key定义为常量:

public interface RedisConstants {/*给业务点赞的用户集合的KEY前缀,后缀是业务id*/String LIKE_BIZ_KEY_PREFIX = "likes:set:biz:";/*业务点赞数统计的KEY前缀,后缀是业务类型*/String LIKES_TIMES_KEY_PREFIX = "likes:times:type:";
}

4.2.1.点赞接口

接下来,我们定义一个新的点赞业务实现类:

/*** <p>* 点赞记录表 服务实现类* </p>*/
@Service
@RequiredArgsConstructor
public class LikedRecordServiceRedisImpl extends ServiceImpl<LikedRecordMapper, LikedRecord> implements ILikedRecordService {private final RabbitMqHelper mqHelper;private final StringRedisTemplate redisTemplate;@Overridepublic 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.缓存点总数到RedisredisTemplate.opsForZSet().add(RedisConstants.LIKES_TIMES_KEY_PREFIX + recordDTO.getBizType(),recordDTO.getBizId().toString(),likedTimes);}private boolean unlike(LikeRecordFormDTO recordDTO) {// 1.获取用户idLong userId = UserContext.getUser();// 2.获取KeyString 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.获取用户idLong userId = UserContext.getUser();// 2.获取KeyString key = RedisConstants.LIKES_BIZ_KEY_PREFIX + recordDTO.getBizId();// 3.执行SADD命令Long result = redisTemplate.opsForSet().add(key, userId.toString());return result != null && result > 0;}
}

4.2.2.批量查询点赞状态统计

目前我们的Redis点赞记录数据结构如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

当我们判断某用户是否点赞时,需要使用下面命令:

# 判断用户是否点赞
SISMEMBER bizId userId

需要注意的是,这个命令只能判断一个用户对某一个业务的点赞状态。而我们的接口是要查询当前用户对多个业务的点赞状态。

因此,我们就需要多次调用SISMEMBER命令,也就需要向Redis多次发起网络请求,给网络带宽带来非常大的压力,影响业务性能。

那么,有没有办法能够一个命令完成多个业务点赞状态判断呢?

非常遗憾,答案是没有!只能多次执行SISMEMBER命令来判断。

不过,Redis中提供了一个功能,可以在一次请求中执行多个命令,实现批处理效果。这个功能就是Pipeline

不要在一次批处理中传输太多命令,否则单次命令占用带宽过多,会导致网络阻塞

Spring提供的RedisTemplate也具备pipeline功能,最终批量查询点赞状态功能实现如下:

@Override
public Set<Long> isBizLiked(List<Long> bizIds) {// 1.获取登录用户idLong 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());// 收集
}

4.2.3.定时任务

点赞成功后,会更新点赞总数并写入Redis中。而我们需要定时读取这些点赞总数的变更数据,通过MQ发送给业务方。这就需要定时任务来实现了。

定时任务的实现方案有很多,简单的例如:

  • SpringTask
  • Quartz

还有一些依赖第三方服务的分布式任务框架:

  • Elastic-Job
  • XXL-Job

此处先使用简单的SpringTask来实现并测试效果。

首先,在tj-remark模块的RemarkApplication启动类上添加注解:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

其作用就是启用Spring的定时任务功能。

然后,定义一个定时任务处理器类:


import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;import java.util.List;@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);}}
}

由于可能存在多个业务类型,不能厚此薄彼只处理部分业务。所以我们会遍历多种业务类型,分别处理。同时为了避免一次处理的业务过多,这里设定了每次处理的业务数量为30,当然这些都是可以调整的。

真正处理业务的逻辑封装到了ILikedRecordService中:

@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);
}

4.2.4.监听点赞数变更

需要注意的是,由于在定时任务中一次最多处理20条数据,这些数据就需要通过MQ一次发送到业务方,也就是说MQ的消息体变成了一个集合:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

因此,作为业务方,在监听MQ消息的时候也必须接收集合格式。

我们修改类com.hjx.learning.mq.LikeTimesChangeListener


import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;import java.util.ArrayList;
import java.util.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);}
}

至此完成了整个流程

相关文章:

点赞系统实现

点赞功能是社交、电商等几乎所有的互联网项目中都广泛使用。虽然看起来简单&#xff0c;不过蕴含的技术方案和手段还是比较多的。 下面将分享之前做的判题OJ系统的点赞系统的思路。 1.需求分析 点赞功能与其它功能不同&#xff0c;没有复杂的原型和需求&#xff0c;仅仅是一…...

c++进阶学习-----继承

1.继承的概念及定义 1.1继承的概念 继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段&#xff0c;它允许程序员在保持原有类特性的基础上进行扩展&#xff0c;增加功能&#xff0c;这样产生新的类&#xff0c;称派生类。 继承呈现了面向对象 程序设计的…...

C++学习笔记(37)

302、makefile 在实际开发中&#xff0c;项目的源代码文件比较多&#xff0c;按类型、功能、模块分别存放在不同的目录和文件中&#xff0c;哪 些文件需要先编译&#xff0c;那些文件后编译&#xff0c;那些文件需要重新编译&#xff0c;还有更多更复杂的操作。 make 是一个强大…...

Redis发布和订阅

Redis 发布订阅 (pub/sub) 是一种消息通信模式&#xff1a;发送者 (pub) 发送消息&#xff0c;订阅者(sub) 接收消息 可以实现进程间的消息传递。这种模式非常适用于实时消息传递、事件通知和消息分发等场景 Redis可以实现消息中间件MQ的功能&#xff0c;通过发布订阅实现消息…...

计算机毕设设计推荐-基于python+Djanog大数据的电影数据可视化分析

精彩专栏推荐订阅&#xff1a;在下方主页&#x1f447;&#x1f3fb;&#x1f447;&#x1f3fb;&#x1f447;&#x1f3fb;&#x1f447;&#x1f3fb; &#x1f496;&#x1f525;作者主页&#xff1a;计算机毕设木哥&#x1f525; &#x1f496; 文章目录 一、电影数据可视…...

dhtmlxGantt 甘特图 一行展示多条任务类型

效果如图: 后台拿到数据 处理之后如图: 含义: 如上图所示, 如果一行需要展示多个 需要给父数据的那条添加render:split属性, 子数据的parent为父数据的Id即可 切记 父数据的id 别为0 为0 时 会出现错乱 因为有些小伙伴提出分段展示的数据结构还是有点问题,下面展示一个完整…...

COLORmap

在这段MATLAB代码中&#xff0c;surf(peaks)、map的定义以及colormap(map)的调用共同完成了以下任务&#xff1a; 1. **绘制曲面图**&#xff1a; - surf(peaks)&#xff1a;这个函数调用了MATLAB内置的peaks函数来生成数据&#xff0c;并使用surf函数将这些数据绘制成一个…...

手机在网状态查询接口如何用Java进行调用?

一、什么是手机在网状态查询接口&#xff1f; 手机在网状态查询接口&#xff0c;又叫运营商在网状态查询&#xff0c;手机号在网状态查询&#xff0c;传入手机号码&#xff0c;查询该手机号的在网状态&#xff0c;返回内容有正常使用、停机、在网但不可用、不在网&#xff08;…...

mysql性能优化- 数据库配置优化

MySQL 性能优化 - 数据库配置优化 MySQL 是一个广泛使用的关系型数据库管理系统&#xff0c;但随着数据量的增长和访问频率的提高&#xff0c;其性能可能会成为瓶颈。为了保持高效的性能&#xff0c;除了应用层的查询优化和索引优化之外&#xff0c;数据库配置优化 也是非常重…...

(算法)大数的进制转换

题目描述 将一个长度最多为30位数字的十进制非负整数转换为二进制数输出输入描述: 多组数据&#xff0c;每行为一个长度不超过30位的十进制非负整数。 &#xff08;注意是10进制数字的个数可能有30个&#xff0c;而非30bits的整数&#xff09;解析 例子 &#xff1a;123&…...

演示jvm锁存在的问题

文章目录 1、AlbumInfoApiController --》testLock()2、redis添加键值对3、AlbumInfoServiceImpl --》testLock() 没有加锁4、使用ab工具测试4.1、安装 ab 工具4.2、查看 redis 中的值 5、添加本地锁 synchronized6、集群情况下问题演示 jvm锁&#xff1a;synchronized lock 只…...

Android SharedPreference详解

Android SharedPreference详解 SharedPreferences作为一种数据持久化的方式&#xff0c;是处理简单的key-value类型数据时的首选。 一般用法: //demo是该sharedpreference对应文件名,对应的是一个xml文件,里面存放key-value格式的数据. SharedPreferences sharedPreferences…...

论文阅读 | 可证安全隐写(网络空间安全科学学报 2023)

可证安全隐写&#xff1a;理论、应用与展望 一、什么是可证安全隐写&#xff1f; 对于经验安全的隐写算法&#xff0c;即使其算法设计得相当周密&#xff0c;隐写分析者&#xff08;攻击者&#xff09;在观察了足够数量的载密&#xff08;含有隐写信息的数据&#xff09;和载体…...

Arthas jvm(查看当前JVM的信息)

文章目录 二、命令列表2.1 jvm相关命令2.1.3 jvm&#xff08;查看当前JVM的信息&#xff09; 二、命令列表 2.1 jvm相关命令 2.1.3 jvm&#xff08;查看当前JVM的信息&#xff09; 基础语法&#xff1a; jvm [arthas18139]$ jvmRUNTIME …...

【c++】介绍

C是一种强大而灵活的编程语言&#xff0c;广泛用于开发各种应用程序和系统软件。它结合了C语言的高效性和面向对象编程的特性&#xff0c;为程序员提供了丰富的工具和功能&#xff0c;以满足各种编程需求。 C的历史可以追溯到上世纪80年代&#xff0c;最初由丹尼斯里奇和贝尔实…...

JavaScript typeof与instanceof的区别

typeof 和 instanceof 都是 JavaScript 中的运算符&#xff0c;用于检查数据类型或对象的类型。它们有不同的用途和适用场景&#xff1a; 1. typeof 作用&#xff1a;返回变量的数据类型&#xff0c;适用于原始数据类型&#xff08;如 number、string、boolean 等&#xff09…...

C++11 可变的模板参数

前言 本期我们接着继续介绍C11的新特性&#xff0c;本期我们介绍的这个新特性是很多人都感觉抽象的语法&#xff01;它就是可变的模板参数&#xff01; 目录 前言 一、可变的模板参数 1.1可变的参数列表 1.2可变的参数包 1.3可变参数包的解析 • 递归展开解析 • 逗号…...

手机在网状态查询接口如何用PHP进行调用?

一、什么是手机在网状态查询接口&#xff1f; 手机在网状态查询接口&#xff0c;即输入手机号码查询手机号在网状态&#xff0c;返回有正常使用、停机、在网但不可用、不在网&#xff08;销号/未启用/异常&#xff09;、预销户等多种状态。 二、手机在网状态查询适用哪些场景…...

MATLAB中多张fig图合并为一个图

将下列两个图和为一个图 打开查看-----绘图浏览器 点击第一幅图中曲线右键复制&#xff0c;到第二幅图中粘贴即可完成...

Java启动Tomcat: Can‘t load IA 32-bit .dll on a AMD 64-bit platform报错问题解决

&#x1f3ac; 鸽芷咕&#xff1a;个人主页 &#x1f525; 个人专栏: 《C干货基地》《粉丝福利》 ⛺️生活的理想&#xff0c;就是为了理想的生活! 专栏介绍 在软件开发和日常使用中&#xff0c;BUG是不可避免的。本专栏致力于为广大开发者和技术爱好者提供一个关于BUG解决的经…...

深入剖析AI大模型:大模型时代的 Prompt 工程全解析

今天聊的内容&#xff0c;我认为是AI开发里面非常重要的内容。它在AI开发里无处不在&#xff0c;当你对 AI 助手说 "用李白的风格写一首关于人工智能的诗"&#xff0c;或者让翻译模型 "将这段合同翻译成商务日语" 时&#xff0c;输入的这句话就是 Prompt。…...

蓝桥杯 2024 15届国赛 A组 儿童节快乐

P10576 [蓝桥杯 2024 国 A] 儿童节快乐 题目描述 五彩斑斓的气球在蓝天下悠然飘荡&#xff0c;轻快的音乐在耳边持续回荡&#xff0c;小朋友们手牵着手一同畅快欢笑。在这样一片安乐祥和的氛围下&#xff0c;六一来了。 今天是六一儿童节&#xff0c;小蓝老师为了让大家在节…...

C++中string流知识详解和示例

一、概览与类体系 C 提供三种基于内存字符串的流&#xff0c;定义在 <sstream> 中&#xff1a; std::istringstream&#xff1a;输入流&#xff0c;从已有字符串中读取并解析。std::ostringstream&#xff1a;输出流&#xff0c;向内部缓冲区写入内容&#xff0c;最终取…...

2023赣州旅游投资集团

单选题 1.“不登高山&#xff0c;不知天之高也&#xff1b;不临深溪&#xff0c;不知地之厚也。”这句话说明_____。 A、人的意识具有创造性 B、人的认识是独立于实践之外的 C、实践在认识过程中具有决定作用 D、人的一切知识都是从直接经验中获得的 参考答案: C 本题解…...

Reasoning over Uncertain Text by Generative Large Language Models

https://ojs.aaai.org/index.php/AAAI/article/view/34674/36829https://ojs.aaai.org/index.php/AAAI/article/view/34674/36829 1. 概述 文本中的不确定性在许多语境中传达,从日常对话到特定领域的文档(例如医学文档)(Heritage 2013;Landmark、Gulbrandsen 和 Svenevei…...

安宝特案例丨Vuzix AR智能眼镜集成专业软件,助力卢森堡医院药房转型,赢得辉瑞创新奖

在Vuzix M400 AR智能眼镜的助力下&#xff0c;卢森堡罗伯特舒曼医院&#xff08;the Robert Schuman Hospitals, HRS&#xff09;凭借在无菌制剂生产流程中引入增强现实技术&#xff08;AR&#xff09;创新项目&#xff0c;荣获了2024年6月7日由卢森堡医院药剂师协会&#xff0…...

【Android】Android 开发 ADB 常用指令

查看当前连接的设备 adb devices 连接设备 adb connect 设备IP 断开已连接的设备 adb disconnect 设备IP 安装应用 adb install 安装包的路径 卸载应用 adb uninstall 应用包名 查看已安装的应用包名 adb shell pm list packages 查看已安装的第三方应用包名 adb shell pm list…...

作为测试我们应该关注redis哪些方面

1、功能测试 数据结构操作&#xff1a;验证字符串、列表、哈希、集合和有序的基本操作是否正确 持久化&#xff1a;测试aof和aof持久化机制&#xff0c;确保数据在开启后正确恢复。 事务&#xff1a;检查事务的原子性和回滚机制。 发布订阅&#xff1a;确保消息正确传递。 2、性…...

WPF八大法则:告别模态窗口卡顿

⚙️ 核心问题&#xff1a;阻塞式模态窗口的缺陷 原始代码中ShowDialog()会阻塞UI线程&#xff0c;导致后续逻辑无法执行&#xff1a; var result modalWindow.ShowDialog(); // 线程阻塞 ProcessResult(result); // 必须等待窗口关闭根本问题&#xff1a…...

华为OD最新机试真题-数组组成的最小数字-OD统一考试(B卷)

题目描述 给定一个整型数组,请从该数组中选择3个元素 组成最小数字并输出 (如果数组长度小于3,则选择数组中所有元素来组成最小数字)。 输入描述 行用半角逗号分割的字符串记录的整型数组,0<数组长度<= 100,0<整数的取值范围<= 10000。 输出描述 由3个元素组成…...