一、功能设计
点赞与收藏的逻辑是一样的,这里就选取点赞功能来做开发。
按照本项目的设计,点赞业务涉两个个方面:
点赞的业务特性:频繁。用户一多,时时刻刻都在进行点赞,收藏等。如果采取传统的数据库模式,交互量是非常大的,很难抗住并发问题,所以采取 redis 的方式来做。
查询的数据交互,可以和 redis 直接来做,持久化的数据,通过数据库查询即可,采取定时任务 xxl-job 定期来刷数据,将数据同步到数据库。
记录的时候三个关键信息,点赞的人,被点赞的题目,点赞的状态。
最终的数据结构就是 hash,string,string 类型。
hash类型用于同步数据库:key:value([hashKey, hashVal]...)有一个总key,value分为一个个hashKey和hashVal,此处hashKey定义为subjectId:userId,hashVal为status点赞状态
第一个string类型存题目对应点赞数key=subjectId,value=count点赞数;第二个string类型存题目对应点赞人key=subjectId:userId,value="1"标记点赞(该string与上面hash类似,在判断当前用户是否点赞题目时处理方便)



数据库设计:

二、基本功能开发
2.1 新增/取消点赞
直接操作redis,存hash,存题目数量+-1,存题目和点赞人的关联
相关redisUtil:
public void putHash(String key, String hashKey, Object hashValue) {
redisTemplate.opsForHash().put(key, hashKey, hashValue);
}
public Integer getInt(String key) {
return (Integer) redisTemplate.opsForValue().get(key);
}
public void increment(String key, Integer count) {
redisTemplate.opsForValue().increment(key, count);
}
controller入口层
@PostMapping("/add")
public Result<Boolean> add(@RequestBody SubjectLikedDTO subjectLikedDto) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectLikedController.add.dto:{}", JSON.toJSONString(subjectLikedDto));
}
Preconditions.checkNotNull(subjectLikedDto.getSubjectId(), "题目id不能为空");
Preconditions.checkNotNull(subjectLikedDto.getStatus(), "点赞状态不能为空");
subjectLikedDto.setLikeUserId(LoginUtil.getLoginId());
Preconditions.checkNotNull(subjectLikedDto.getLikeUserId(), "点赞人不能为空");
SubjectLikedBO subjectLikedBO = SubjectLikedDTOConvert.INSTANCE.subjectLikedDtoToBo(subjectLikedDto);
subjectLikedDomainService.add(subjectLikedBO);
return Result.ok(true);
} catch (Exception e) {
log.info("SubjectLikedController.add.error:{}", e.getMessage(), e);
return Result.fail("题目点赞失败");
}
}
点赞状态枚举类
@Getter
public enum SubjectLikedStatusEnum {
LIKED(1, "点赞"),
UN_LIKED(0, "未点赞");
private int code;
private String desc;
SubjectLikedStatusEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
}
domain防腐层
private String buildSubjectLikedKey(String subjectId, String likeUserId) {
return subjectId + ":" + likeUserId;
}
@Resource
private RedisUtil redisUtil
/**
* 点赞hash的总key
*/
private static final String SUBJECT_LIKED_KEY = "subject.liked"
/**
* 题目点赞数key前缀
*/
private static final String SUBJECT_LIKED_COUNT_KEY = "subject.liked.count"
/**
* 题目点赞人key前置
*/
private static final String SUBJECT_LIKED_DETAIL_KEY = "subject.liked.detail"
/**
* 新增/取消点赞
* @return
*/
@Override
public void add(SubjectLikedBO subjectLikedBO) {
String likeUserId = subjectLikedBO.getLikeUserId()
Long subjectId = subjectLikedBO.getSubjectId()
Integer status = subjectLikedBO.getStatus()
String hashKey = buildSubjectLikedKey(subjectId.toString(), likeUserId)
redisUtil.putHash(SUBJECT_LIKED_KEY, hashKey, status)
String countKey = SUBJECT_LIKED_COUNT_KEY + "." + subjectId
String detailKey = SUBJECT_LIKED_DETAIL_KEY + "." + subjectId + "." + likeUserId
if(SubjectLikedStatusEnum.LIKED.getCode() == status) { //点赞状态
redisUtil.increment(countKey, 1)
redisUtil.set(detailKey, "1")
} else {
Integer count = redisUtil.getInt(countKey)
if(Objects.isNull(count) || count <= 0) { //当数量不存在或为0时直接结束
return
}
redisUtil.increment(countKey, -1)
redisUtil.del(detailKey)
}
}
2.2 题目详情增加点赞数据
此处涉及两个功能:查询当前题目被点赞的数量,查询当前题目被当前用户是否点过赞
直接与reids交换,查询key即可
subjectLiked的domain层实现以上两个功能:
@Override
public Boolean isLiked(String subjectId, String userId) {
String detailKey = SUBJECT_LIKED_DETAIL_KEY + "." + subjectId + "." + userId;
return redisUtil.exist(detailKey);
}
@Override
public Integer getLikedCount(String subjectId) {
String countKey = SUBJECT_LIKED_COUNT_KEY + "." + subjectId;
Integer count = redisUtil.getInt(countKey);
if(Objects.isNull(count) || count <= 0) {
count = 0;
}
return count;
}
在获取题目详情的返回值基础上添加题目点赞数和当前用户是否点赞属性,最后在domain层组装
在subjectInfoDTO和BO中添加private Boolean liked(是否被当前用户点赞); private Integer likedCount(题目点赞数量);
domain层组装:
@Override
public SubjectInfoBO querySubjectInfo(SubjectInfoBO subjectInfoBO) {
if(log.isInfoEnabled()) {
log.info("SubjectInfoDomainService.querySubjectInfo.subjectInfoBO:{}", JSON.toJSONString(subjectInfoBO))
}
//先查询题目主表数据
SubjectInfo subjectInfo = subjectInfoServices.queryById(subjectInfoBO.getId())
//工厂 + 策略 查询具体类型题目的数据
SubejctTypeHandler handler = subjectTypeHandlerFactory.getHandler(subjectInfo.getSubjectType())
SubjectOptionBO subjectOptionBO = handler.query(subjectInfoBO.getId())
//将主表数据info 和 具体题目数据(答案、选项信息) 一起转为 infoBo
SubjectInfoBO bo = SubjectInfoBOConvert.INSTANCE.subjectOptionBoAndInfoToBo(subjectInfo, subjectOptionBO)
//查询标签id->标签name
SubjectMapping subjectMapping = new SubjectMapping()
subjectMapping.setSubjectId(bo.getId())
subjectMapping.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode())
List<SubjectMapping> subjectMappingList = subjectMappingService.queryByLabelId(subjectMapping)
List<Long> labelIds = subjectMappingList.stream().map(SubjectMapping::getLabelId).collect(Collectors.toList())
List<SubjectLabel> subjectLabelList = subjectLabelService.queryByLabelIds(labelIds)
List<String> labelNames = subjectLabelList.stream().map(SubjectLabel::getLabelName).collect(Collectors.toList())
bo.setLabelName(labelNames)
//返回点赞数、是否点赞
Integer likedCount = subjectLikedDomainService.getLikedCount(bo.getId().toString())
Boolean liked = subjectLikedDomainService.isLiked(bo.getId().toString(), LoginUtil.getLoginId())
bo.setLikedCount(likedCount)
bo.setLiked(liked)
return bo
}
三、数据库同步reids点赞数据
通过xxl-job每隔一秒向数据库同步redis的hash点赞数据并删除hash类型,因为间隔一秒执行一次,所以当并发量大时会有细微的延迟。
3.1 xxl-job执行定时任务
@Component
@Log4j2
public class SyncLikedJob {
@Resource
private SubjectLikedDomainService subjectLikedDomainService;
@XxlJob("syncLikedJobHandler")
public void syncLikedJobHandler() throws Exception {
XxlJobHelper.log("syncLikedJobHandler.start");
try {
subjectLikedDomainService.syncLiked();
} catch (Exception e) {
XxlJobHelper.log("syncLikedJobHandler.error" + e.getMessage());
}
}
}
3.2 相关redisUtil
public Map<Object, Object> getHashAndDelete(String key) {
Map<Object, Object> map = new HashMap<>();
Cursor<Map.Entry<Object, Object>> scan = redisTemplate.opsForHash().scan(key, ScanOptions.NONE);
while (scan.hasNext()) {
Map.Entry<Object, Object> entry = scan.next();
map.put(entry.getKey(), entry.getValue());
redisTemplate.opsForHash().delete(key, entry.getKey());
}
return map;
}
3.3 domain层核心逻辑
@Override
public void syncLiked() {
Map<Object, Object> subjectLikedMap = redisUtil.getHashAndDelete(SUBJECT_LIKED_KEY)
if(log.isInfoEnabled()) {
log.info("syncLiked.subjectLikedMap:{}", JSON.toJSONString(subjectLikedMap))
}
if(subjectLikedMap.isEmpty()) {
return
}
//批量同步数据库
List<SubjectLiked> subjectLikedList = new ArrayList<>()
subjectLikedMap.forEach((key, val) -> {
SubjectLiked subjectLiked = new SubjectLiked()
String[] split = key.toString().split(":")
subjectLiked.setSubjectId(Long.valueOf(split[0]))
subjectLiked.setLikeUserId(split[1])
subjectLiked.setStatus(Integer.valueOf(val.toString()))
subjectLikedList.add(subjectLiked)
})
subjectLikedService.batchInsert(subjectLikedList)
}
3.4 infra原子性操作
<insert id="batchInsert">
INSERT INTO subject_liked (subject_id, like_user_id, status, created_by, created_time, update_by, update_time)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.subjectId}, #{item.likeUserId}, #{item.status}, #{item.createdBy}, #{item.createdTime}, #{item.updateBy}, #{item.updateTime})
</foreach>
</insert>
四、我的点赞
直接与数据库交换,分页查询即可。因为xxl-job每隔一秒同步一次数据,所以当并发量大时,会有微小延迟。
SubjectLikedDTO和BO都要继承PageInfo,并添加subjectName在页面显示
@PostMapping("/getSubjectLikedPage")
public Result<PageResult<SubjectLikedDTO>> getSubjectLikedPage(@RequestBody SubjectLikedDTO subjectLikedDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectLikedController.getSubjectLikedPage.dto:{}", JSON.toJSONString(subjectLikedDTO));
}
SubjectLikedBO subjectLikedBO = SubjectLikedDTOConvert.INSTANCE.subjectLikedDtoToBo(subjectLikedDTO);
PageResult<SubjectLikedBO> subjectLikedBOList = subjectLikedDomainService.getSubjectLikedPage(subjectLikedBO);
return Result.ok(subjectLikedBOList);
} catch (Exception e) {
log.info("SubjectLikedController.getSubjectLikedPage.error:{}", e.getMessage(), e);
return Result.fail("查询点赞记录失败");
}
}
@Override
public PageResult<SubjectLikedBO> getSubjectLikedPage(SubjectLikedBO subjectLikedBO) {
PageResult<SubjectLikedBO> pageResult = new PageResult<>()
pageResult.setPageNo(subjectLikedBO.getPageNo())
pageResult.setPageSize(subjectLikedBO.getPageSize())
int start = (subjectLikedBO.getPageNo() - 1) * subjectLikedBO.getPageSize()
SubjectLiked subjectLiked = SubjectLikedBOConvert.INSTANCE.subjectLikedBoToSubjectLiked(subjectLikedBO)
subjectLiked.setLikeUserId(LoginUtil.getLoginId())
int count = subjectLikedService.countByCondition(subjectLiked)
if(count == 0) {
return pageResult
}
List<SubjectLiked> subjectLikedList = subjectLikedService.queryPage(subjectLiked, start, subjectLikedBO.getPageSize())
List<SubjectLikedBO> subjectLikedBOList = SubjectLikedBOConvert.INSTANCE.subjectLikedsToBos(subjectLikedList)
subjectLikedBOList.forEach(info -> {
SubjectInfo subjectInfo = subjectInfoService.queryById(info.getSubjectId())
info.setSubjectName(subjectInfo.getSubjectName())
})
pageResult.setRecords(subjectLikedBOList)
pageResult.setTotal(count)
return pageResult
}
<select id="countByCondition" resultType="java.lang.Integer">
SELECT count(1) FROM subject_liked where like_user_id = #{likeUserId} and status = 1 and is_deleted = 0
</select>
<select id="queryPage" resultMap="SubjectLikedMap">
SELECT * FROM subject_liked
where status = 1 and is_deleted = 0
and like_user_id = #{subjectLiked.likeUserId}
limit #{start}, #{pageSize}
</select>
五、Rocketmq优化点赞业务
之前的业务中,通过redis的hash表来保存用户的点赞数据,并配合xxl-job来定时刷到数据库。这样太过依赖redis和xxl-job的可靠性,数据量大时可能会丢失数据,在此使用mq,每当用户点赞题目后,直接与mysql交互。
domain层修改,SubjectLikedMessage主要有subjectId,likedUserId,status
@Override
public void add(SubjectLikedBO subjectLikedBO) {
String likeUserId = subjectLikedBO.getLikeUserId()
Long subjectId = subjectLikedBO.getSubjectId()
Integer status = subjectLikedBO.getStatus()
// String hashKey = buildSubjectLikedKey(subjectId.toString(), likeUserId)
// redisUtil.putHash(SUBJECT_LIKED_KEY, hashKey, status)
//将每次的点赞消息发送到mq中直接与数据库交互,替换redis-hash表
SubjectLikedMessage subjectLikedMessage = new SubjectLikedMessage()
subjectLikedMessage.setSubjectId(subjectId)
subjectLikedMessage.setLikeUserId(likeUserId)
subjectLikedMessage.setStatus(status)
rocketMQTemplate.convertAndSend("subject-liked", JSON.toJSONString(subjectLikedMessage))
String countKey = SUBJECT_LIKED_COUNT_KEY + "." + subjectId
String detailKey = SUBJECT_LIKED_DETAIL_KEY + "." + subjectId + "." + likeUserId
if(SubjectLikedStatusEnum.LIKED.getCode() == status) { //点赞状态
redisUtil.increment(countKey, 1)
redisUtil.set(detailKey, "1")
} else {
Integer count = redisUtil.getInt(countKey)
if(Objects.isNull(count) || count <= 0) { //当数量不存在或为0时直接结束
return
}
redisUtil.increment(countKey, -1)
redisUtil.del(detailKey)
}
}
mq消费层
@Component
@RocketMQMessageListener(topic = "subject-liked", consumerGroup = "subject-group")
@Log4j2
public class SubjectLikedConsumer implements RocketMQListener<String> {
@Resource
private SubjectLikedDomainService subjectLikedDomainService;
@Override
public void onMessage(String message) {
log.info("SubjectLikedConsumer.onMessage.message:{}", message);
SubjectLikedBO subjectLikedBO = JSON.parseObject(message, SubjectLikedBO.class);
subjectLikedDomainService.syncLikedMsg(subjectLikedBO);
}
}
syncLikedMsg方法与数据库交互
@Override
public void syncLikedMsg(SubjectLikedBO subjectLikedBO) {
//同步到数据库
SubjectLiked subjectLiked = new SubjectLiked()
subjectLiked.setSubjectId(subjectLikedBO.getSubjectId())
subjectLiked.setLikeUserId(subjectLikedBO.getLikeUserId())
subjectLiked.setStatus(subjectLikedBO.getStatus())
subjectLiked.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode())
List<SubjectLiked> subjectLikedList = new LinkedList<>()
subjectLikedList.add(subjectLiked)
subjectLikedService.batchInsertOrUpdate(subjectLikedList)
}
六、点赞数据不更新BUG修复
在上述的操作中,用户每次点赞和取消点赞都会保存到数据库,导致同一个用户id,同一个题目id,在数据库中有点赞和未点赞两个状态,当用户查询我的点赞时,会从头到尾遍历数据库status为1的题目,当用户先点赞后取消点赞时,题目仍在我的点赞列表中。
通过为subjectId和likedUserId建立唯一索引来保证subject_id
和 like_user_id
的组合值必须是唯一的,不能有重复记录。
ALTER TABLE subject_liked ADD UNIQUE KEY unique_subject_like (subject_id, like_user_id);
向表中添加一个名为unique_subject_like
的唯一索引。
同时修改插入点赞数据的sql语句:
<insert id="batchInsertOrUpdate">
INSERT INTO subject_liked
(subject_id, like_user_id, status, created_by, created_time, update_by, update_time, is_deleted)
VALUES
<foreach collection="entities" item="item" separator=",">
(
</foreach>
ON DUPLICATE KEY UPDATE
status = VALUES(status),
created_by = VALUES(created_by),
created_time = VALUES(created_time),
update_by = VALUES(update_by),
update_time = VALUES(update_time),
is_deleted = VALUES(is_deleted)
</insert>
ON DUPLICATE KEY UPDATE
: 当插入的数据违反唯一键约束时,会触发此更新操作。VALUES()
函数用于获取插入语句中对应列的值,将这些值更新到已存在的记录中。
转自https://juejin.cn/post/7463393885961437218
该文章在 2025/4/19 8:49:36 编辑过