一、功能設計
點贊與收藏的邏輯是一樣的,這里就選取點贊功能來做開發。
按照本項目的設計,點贊業務涉兩個個方面:
點贊的業務特性:頻繁。用戶一多,時時刻刻都在進行點贊,收藏等。如果采取傳統的數據庫模式,交互量是非常大的,很難抗住并發問題,所以采取 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 編輯過