仿小紅書交流社區(微服務架構)

在這里插入圖片描述

文章目錄

  • framework - 平臺基礎設施
    • starter - jackson
    • common
      • exception
      • response
      • util
    • starter - content 全局上下文
    • distributed - id - generate - 分布式 Id
      • Snowflake - 基于雪花算法生成 Id
      • Segment - 基于分段式生成 Id
  • OSS - 對象存儲
  • KV - 短文本存儲
    • 筆記
    • 評論
  • user - 用戶服務
  • user-relation 用戶關系服務
  • note - 筆記服務
  • count - 計數服務
    • user - relation
  • search - 搜索服務
  • comment - 評論服務
    • 發布評論
      • publishComment: 生成id,包裝 MQ 消息
      • Comment2DBConsumer:評論落庫,發送計數 MQ
      • OneLevelCommentFirstReplyCommentIdUpdateConsumer:更新一級評論的最早的二級評論
      • CountNoteCommentConsumer:更新筆記的總評論數
      • CountNoteChildCommentConsumer: 更新一級評論的子評論數
      • CommentHeatUpdateConsumer:計算一級評論的熱度
    • 評論分頁查詢
    • 刪除評論
      • deleteComment: 刪除評論
      • DeleteCommentConsumer: 刪除相關聯評論

framework - 平臺基礎設施

starter - jackson

為了支持 Java 8 中新的日期 API ,我們需要自定義 Jackson 配置

@AutoConfiguration
public class JacksonAutoConfiguration {@Beanpublic ObjectMapper objectMapper() {// 初始化一個 ObjectMapper 對象,用于自定義 Jackson 的行為ObjectMapper objectMapper = new ObjectMapper();// 忽略未知屬性objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);// 設置凡是為 null 的字段,返參中均不返回,請根據項目組約定是否開啟// objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);// 設置時區objectMapper.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));// JavaTimeModule 用于指定序列化和反序列化規則JavaTimeModule javaTimeModule = new JavaTimeModule();// 支持 LocalDateTime、LocalDate、LocalTimejavaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateConstants.DATE_FORMAT_Y_M_D_H_M_S));javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateConstants.DATE_FORMAT_Y_M_D_H_M_S));javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateConstants.DATE_FORMAT_Y_M_D));javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateConstants.DATE_FORMAT_Y_M_D));javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateConstants.DATE_FORMAT_H_M_S));javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateConstants.DATE_FORMAT_H_M_S));// 支持 YearMonthjavaTimeModule.addSerializer(YearMonth.class, new YearMonthSerializer(DateConstants.DATE_FORMAT_Y_M));javaTimeModule.addDeserializer(YearMonth.class, new YearMonthDeserializer(DateConstants.DATE_FORMAT_Y_M));objectMapper.registerModule(javaTimeModule);// 初始化 JsonUtils 中的 ObjectMapperJsonUtils.init(objectMapper);return objectMapper;}}

starter 自動配置,重中之重 ,當引入這個組件時,這個類會自動運行,并注冊 Bean
在這里插入圖片描述
需要嚴格按照此格式書寫

common

exception

public interface BaseExceptionInterface {String getErrorCode();String getErrorMessage();
}

這個接口用來規范錯誤信息的格式,可以使用枚舉類來繼承它
eq:

@AllArgsConstructor
@Getter
public enum ResponseCodeEnum implements BaseExceptionInterface {// ----------- 通用異常狀態碼 -----------SYSTEM_ERROR("COMMENT-10000", "出錯啦,后臺小哥正在努力修復中..."),PARAM_NOT_VALID("COMMENT-10001", "參數錯誤"),COMMENT_NOT_FOUND("COMMENT-20001", "此評論不存在"),// ----------- 業務異常狀態碼 -----------;// 異常碼private final String errorCode;// 錯誤信息private final String errorMessage;
}
@Getter
@Setter
public class BizException extends RuntimeException {// 異常碼private String errorCode;// 錯誤信息private String errorMessage;public BizException(BaseExceptionInterface baseExceptionInterface) {this.errorCode = baseExceptionInterface.getErrorCode();this.errorMessage = baseExceptionInterface.getErrorMessage();}
}

BizException 繼承 RuntimeException ,意味著當系統內部出現錯誤或者用戶操作不規范,系統會捕獲該異常,并將其返回給前端
eq:
當我們主動拋出異常

throw new BizException(ResponseCodeEnum.USER_NOT_FOUND);
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {/*** 捕獲自定義業務異常* @return*/@ExceptionHandler({ BizException.class })@ResponseBodypublic Response<Object> handleBizException(HttpServletRequest request, BizException e) {log.warn("{} request fail, errorCode: {}, errorMessage: {}", request.getRequestURI(), e.getErrorCode(), e.getErrorMessage());return Response.fail(e);}/*** 捕獲參數校驗異常* @return*/@ExceptionHandler({ MethodArgumentNotValidException.class })@ResponseBodypublic Response<Object> handleMethodArgumentNotValidException(HttpServletRequest request, MethodArgumentNotValidException e) {// 參數錯誤異常碼String errorCode = ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode();// 獲取 BindingResultBindingResult bindingResult = e.getBindingResult();StringBuilder sb = new StringBuilder();// 獲取校驗不通過的字段,并組合錯誤信息,格式為: email 郵箱格式不正確, 當前值: '123124qq.com';Optional.ofNullable(bindingResult.getFieldErrors()).ifPresent(errors -> {errors.forEach(error ->sb.append(error.getField()).append(" ").append(error.getDefaultMessage()).append(", 當前值: '").append(error.getRejectedValue()).append("'; "));});// 錯誤信息String errorMessage = sb.toString();log.warn("{} request error, errorCode: {}, errorMessage: {}", request.getRequestURI(), errorCode, errorMessage);return Response.fail(errorCode, errorMessage);}/*** 捕獲 guava 參數校驗異常* @return*/@ExceptionHandler({ IllegalArgumentException.class })@ResponseBodypublic Response<Object> handleIllegalArgumentException(HttpServletRequest request, IllegalArgumentException e) {// 參數錯誤異常碼String errorCode = ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode();// 錯誤信息String errorMessage = e.getMessage();log.warn("{} request error, errorCode: {}, errorMessage: {}", request.getRequestURI(), errorCode, errorMessage);return Response.fail(errorCode, errorMessage);}/*** 其他類型異常* @param request* @param e* @return*/@ExceptionHandler({ Exception.class })@ResponseBodypublic Response<Object> handleOtherException(HttpServletRequest request, Exception e) {log.error("{} request error, ", request.getRequestURI(), e);return Response.fail(ResponseCodeEnum.SYSTEM_ERROR);}
}

這個類的第一個方法就會觸發,捕獲 BizException 異常

response

@Data
public class Response<T> implements Serializable {// 是否成功,默認為 trueprivate boolean success = true;// 響應消息private String message;// 異常碼private String errorCode;// 響應數據private T data;// =================================== 成功響應 ===================================public static <T> Response<T> success() {Response<T> response = new Response<>();return response;}public static <T> Response<T> success(T data) {Response<T> response = new Response<>();response.setData(data);return response;}// =================================== 失敗響應 ===================================public static <T> Response<T> fail() {Response<T> response = new Response<>();response.setSuccess(false);return response;}public static <T> Response<T> fail(String errorMessage) {Response<T> response = new Response<>();response.setSuccess(false);response.setMessage(errorMessage);return response;}public static <T> Response<T> fail(String errorCode, String errorMessage) {Response<T> response = new Response<>();response.setSuccess(false);response.setErrorCode(errorCode);response.setMessage(errorMessage);return response;}public static <T> Response<T> fail(BizException bizException) {Response<T> response = new Response<>();response.setSuccess(false);response.setErrorCode(bizException.getErrorCode());response.setMessage(bizException.getErrorMessage());return response;}public static <T> Response<T> fail(BaseExceptionInterface baseExceptionInterface) {Response<T> response = new Response<>();response.setSuccess(false);response.setErrorCode(baseExceptionInterface.getErrorCode());response.setMessage(baseExceptionInterface.getErrorMessage());return response;}}

這個類用來規范數據返回前端的格式

@Data
public class PageResponse<T> extends Response<List<T>> {// 當前頁碼private long pageNo;// 總數據量private long totalCount;// 每頁展示的數據量private long pageSize;// 總頁數private long totalPage;public static <T> PageResponse<T> success(List<T> data, long pageNo, long totalCount) {PageResponse<T> pageResponse = new PageResponse<>();pageResponse.setSuccess(true);pageResponse.setData(data);pageResponse.setPageNo(pageNo);pageResponse.setTotalCount(totalCount);// 每頁展示的數據量long pageSize = 10L;pageResponse.setPageSize(pageSize);// 計算總頁數long totalPage = (totalCount + pageSize - 1) / pageSize;pageResponse.setTotalPage(totalPage);return pageResponse;}public static <T> PageResponse<T> success(List<T> data, long pageNo, long totalCount, long pageSize) {PageResponse<T> pageResponse = new PageResponse<>();pageResponse.setSuccess(true);pageResponse.setData(data);pageResponse.setPageNo(pageNo);pageResponse.setTotalCount(totalCount);pageResponse.setPageSize(pageSize);// 計算總頁數long totalPage = pageSize == 0 ? 0 : (totalCount + pageSize - 1) / pageSize;pageResponse.setTotalPage(totalPage);return pageResponse;}/*** 獲取總頁數*/public static long getTotalPage(long totalCount, long pageSize) {return pageSize == 0 ? 0 : (totalCount + pageSize - 1) / pageSize;}/*** 計算分頁查詢的 offset*/public static long getOffset(long pageNo, long pageSize) {// 如果頁碼小于 1,默認返回第一頁的 offsetif (pageNo < 1) {pageNo = 1;}return (pageNo - 1) * pageSize;}
}

這個類是用來規范數組類型的數據的返回格式

util

public class JsonUtils {private static ObjectMapper OBJECT_MAPPER = new ObjectMapper();static {OBJECT_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);OBJECT_MAPPER.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);OBJECT_MAPPER.registerModules(new JavaTimeModule()); // 解決 LocalDateTime 的序列化問題}/*** 初始化:統一使用 Spring Boot 個性化配置的 ObjectMapper*/public static void init(ObjectMapper objectMapper) {OBJECT_MAPPER = objectMapper;}/***  將對象轉換為 JSON 字符串*/@SneakyThrowspublic static String toJsonString(Object obj) {return OBJECT_MAPPER.writeValueAsString(obj);}/*** 將 JSON 字符串轉換為對象*/@SneakyThrowspublic static <T> T parseObject(String jsonStr, Class<T> clazz) {if (StringUtils.isBlank(jsonStr)) {return null;}return OBJECT_MAPPER.readValue(jsonStr, clazz);}/*** 將 JSON 字符串轉換為 Map*/public static <K, V> Map<K, V> parseMap(String jsonStr, Class<K> keyClass, Class<V> valueClass) throws Exception {// 創建 TypeReference,指定泛型類型TypeReference<Map<K, V>> typeRef = new TypeReference<Map<K, V>>() {};// 將 JSON 字符串轉換為 Mapreturn OBJECT_MAPPER.readValue(jsonStr, OBJECT_MAPPER.getTypeFactory().constructMapType(Map.class, keyClass, valueClass));}/*** 將 JSON 字符串解析為指定類型的 List 對象*/public static <T> List<T> parseList(String jsonStr, Class<T> clazz) throws Exception {// 使用 TypeReference 指定 List<T> 的泛型類型return OBJECT_MAPPER.readValue(jsonStr, new TypeReference<>() {@Overridepublic CollectionType getType() {return OBJECT_MAPPER.getTypeFactory().constructCollectionType(List.class, clazz);}});}/*** 將 JSON 字符串解析為指定類型的 Set 對象*/public static <T> Set<T> parseSet(String jsonStr, Class<T> clazz) throws Exception {return OBJECT_MAPPER.readValue(jsonStr, new TypeReference<>() {@Overridepublic Type getType() {return OBJECT_MAPPER.getTypeFactory().constructCollectionType(Set.class, clazz);}});}
}

序列化與反序列化使用的是 Jackson

starter - content 全局上下文

在單體項目中,使用 ThreadLocal 就可以滿足要求,但在微服務項目,存在微服務間調用的問題,這時 ThreadLocal 就不能保證全局上下文,需要放置攔截器,將信息放在請求頭中,保證透傳
當一個微服務調用另一個微服務時,會觸發這個類,將 USER_ID 設置到請求頭中

@Slf4j
public class FeignRequestInterceptor implements RequestInterceptor {@Overridepublic void apply(RequestTemplate requestTemplate) {Long userId = LoginUserContextHolder.getUserId();if (Objects.nonNull(userId)) {requestTemplate.header(GlobalConstants.USER_ID, String.valueOf(userId));log.info("########## feign 請求設置請求頭 userId: {}", userId);}}
}

過濾器,設置上下文,請求返回時刪除上下文

@Slf4j
public class HeaderUserId2ContextFilter extends OncePerRequestFilter {@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,FilterChain chain) throws ServletException, IOException {// 從請求頭中獲取用戶 IDString userId = request.getHeader(GlobalConstants.USER_ID);// 判斷請求頭中是否存在用戶 IDif (StringUtils.isBlank(userId)) {// 若為空,則直接放行chain.doFilter(request, response);return;}log.info("===== 設置 userId 到 ThreadLocal 中, 用戶 ID: {}", userId);LoginUserContextHolder.setUserId(userId);try {chain.doFilter(request, response);} finally {// 一定要刪除 ThreadLocal ,防止內存泄露LoginUserContextHolder.remove();log.info("===== 刪除 ThreadLocal, userId: {}", userId);}}
}

上下文

public class LoginUserContextHolder {// 初始化一個 ThreadLocal 變量private static final ThreadLocal<Map<String, Object>> LOGIN_USER_CONTEXT_THREAD_LOCAL= TransmittableThreadLocal.withInitial(HashMap::new);......
}

在這里插入圖片描述
引入這個組件時,確保這個組件的 Bean 全部注冊成功

distributed - id - generate - 分布式 Id

使用的是美團的 Leaf

Snowflake - 基于雪花算法生成 Id

核心方法

    public synchronized Result get(String key) {long timestamp = timeGen();if (timestamp < lastTimestamp) {long offset = lastTimestamp - timestamp;if (offset <= 5) {try {wait(offset << 1);timestamp = timeGen();if (timestamp < lastTimestamp) {return new Result(-1, Status.EXCEPTION);}} catch (InterruptedException e) {LOGGER.error("wait interrupted");return new Result(-2, Status.EXCEPTION);}} else {return new Result(-3, Status.EXCEPTION);}}if (lastTimestamp == timestamp) {sequence = (sequence + 1) & sequenceMask;if (sequence == 0) {//seq 為0的時候表示是下一毫秒時間開始對seq做隨機sequence = RANDOM.nextInt(100);timestamp = tilNextMillis(lastTimestamp);}} else {//如果是新的ms開始sequence = RANDOM.nextInt(100);}lastTimestamp = timestamp;long id = ((timestamp - twepoch) << timestampLeftShift) | (workerId << workerIdShift) | sequence;return new Result(id, Status.SUCCESS);}
  1. 獲取當前時間戳:
  • 若時間回退(當前時間戳小于上次生成 ID 的時間戳):
    • 小范圍回退(≤5ms):等待雙倍時間后重試。
    • 大范圍回退:直接返回異常。
  • 若時間正常:
    • 同一毫秒內:序列號遞增(通過 sequenceMask 取模)。
    • 若序列號溢出(變為 0):等待下一毫秒,并生成隨機序列號(0-99)。
      不同毫秒:重置序列號為隨機數(0-99)。
  1. 組合生成 ID:
ID = (時間戳差值 << 22) | (工作機器 ID << 12) | 序列號
  • 時間戳差值:當前時間減去基準時間。
  • 工作機器 ID:通過 ZooKeeper 生成唯一標識生成節點。
  • 序列號:同一毫秒內的唯一計數。

Segment - 基于分段式生成 Id

核心方法

    public Result get(final String key) {if (!initOK) {return new Result(EXCEPTION_ID_IDCACHE_INIT_FALSE, Status.EXCEPTION);}if (cache.containsKey(key)) {SegmentBuffer buffer = cache.get(key);if (!buffer.isInitOk()) {// 重入鎖synchronized (buffer) {if (!buffer.isInitOk()) {try {updateSegmentFromDb(key, buffer.getCurrent());logger.info("Init buffer. Update leafkey {} {} from db", key, buffer.getCurrent());buffer.setInitOk(true);} catch (Exception e) {logger.warn("Init buffer {} exception", buffer.getCurrent(), e);}}}}// 獲取 idreturn getIdFromSegmentBuffer(cache.get(key));}return new Result(EXCEPTION_ID_KEY_NOT_EXISTS, Status.EXCEPTION);}

ID 生成過程采用 “雙緩沖區 + 預加載” 模式,核心步驟如下:

  1. 檢查緩存初始化狀態,未初始化則返回異常
  2. 根據業務 key 獲取對應的 SegmentBuffer
  3. 從 SegmentBuffer 中獲取可用 ID 段,采用讀寫鎖保證線程安全
  4. 當當前 Segment 即將耗盡時,異步預加載下一個 Segment
    public Result getIdFromSegmentBuffer(final SegmentBuffer buffer) {while (true) {buffer.rLock().lock();try {final Segment segment = buffer.getCurrent();if (!buffer.isNextReady() && (segment.getIdle() < 0.9 * segment.getStep()) && buffer.getThreadRunning().compareAndSet(false, true)) {service.execute(new Runnable() {@Overridepublic void run() {Segment next = buffer.getSegments()[buffer.nextPos()];boolean updateOk = false;try {updateSegmentFromDb(buffer.getKey(), next);updateOk = true;logger.info("update segment {} from db {}", buffer.getKey(), next);} catch (Exception e) {logger.warn(buffer.getKey() + " updateSegmentFromDb exception", e);} finally {if (updateOk) {buffer.wLock().lock();buffer.setNextReady(true);buffer.getThreadRunning().set(false);buffer.wLock().unlock();} else {buffer.getThreadRunning().set(false);}}}});}long value = segment.getValue().getAndIncrement();if (value < segment.getMax()) {return new Result(value, Status.SUCCESS);}} finally {buffer.rLock().unlock();}waitAndSleep(buffer);buffer.wLock().lock();try {final Segment segment = buffer.getCurrent();long value = segment.getValue().getAndIncrement();if (value < segment.getMax()) {return new Result(value, Status.SUCCESS);}if (buffer.isNextReady()) {buffer.switchPos();buffer.setNextReady(false);} else {logger.error("Both two segments in {} are not ready!", buffer);return new Result(EXCEPTION_ID_TWO_SEGMENTS_ARE_NULL, Status.EXCEPTION);}} finally {buffer.wLock().unlock();}}}

采用雙緩沖區設計:

  1. 兩個 Segment 交替使用:currentSegment 和 nextSegment
  2. 預加載機制:當 currentSegment 使用量超過 90% 時,異步加載 nextSegment
  3. 讀寫鎖分離:使用 ReentrantReadWriteLock 實現讀寫并發

OSS - 對象存儲

策略模式 + 工廠模式管理 Minio 和 AliyunOSS 實現實現文件處理可擴展
當文件上傳成功后,會返回一個 URL,訪問這個 URL 就可以獲取這個文件
在這里插入圖片描述

KV - 短文本存儲

這個項目涉及短文本存儲的地方主要有兩個:筆記、評論

采用 Cassandra 存儲短文本

筆記

筆記的表

CREATE TABLE note_content (id UUID PRIMARY KEY,content TEXT
);

在這里插入圖片描述

@Table("note_content")
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class NoteContentDO {@PrimaryKey("id")private UUID id;private String content;
}public interface NoteContentRepository extends CassandraRepository<NoteContentDO, UUID> {
}
  • CassandraRepository: 這是 Spring Data Cassandra 提供的一個泛型接口,它為 Cassandra 數據庫提供了 CRUD(創建、讀取、更新、刪除)和其他一些基本的操作方法。
  • <NoteContentDO, UUID>: 這里有兩個類型參數:
    • NoteContentDO: 表示與 Cassandra 數據庫交互時使用的數據對象類型。通常情況下,這是一個 Java 類,它映射到數據庫中的表。
    • UUID: 表示 NoteContentDO 對象的主鍵類型。根據表的實際情況來定義,這里使用 UUID 作為主鍵類型。

插入筆記:

    @Resourceprivate NoteContentRepository noteContentRepository;@Overridepublic Response<?> addNoteContent(AddNoteContentReqDTO addNoteContentReqDTO) {String content = addNoteContentReqDTO.getContent();NoteContentDO noteContentDO = new NoteContentDO(UUID.fromString(addNoteContentReqDTO.getUuid()), content);noteContentRepository.save(noteContentDO);return Response.success(noteContentDO);}

評論

評論的表

CREATE TABLE comment_content (note_id BIGINT, -- 筆記 ID,分區鍵year_month TEXT, -- 發布年月content_id UUID, -- 評論內容 IDcontent TEXT,PRIMARY KEY ((note_id, year_month), content_id)
);
  1. 復合分區鍵 (note_id, year_month):
  • 分區鍵的作用:在 Cassandra中,分區鍵決定了數據存儲的位置,即哪個節點將保存該數據。通過使用復合分區鍵,可以更靈活地控制數據分布。
  • 為什么選擇這兩個字段:
    • note_id:筆記 ID, 作為筆記的唯一標識符,讓它成為分區鍵的一部分,因為評論查詢,是基于某篇筆記進行。
    • year_month:加入發布年月是為了避免單個分區變得過大。如果僅以 note_id 作為分區鍵,那么所有該筆記的評論都將存儲在一個分區里,可能導致熱點問題(hotspotting),即某些分區的數據量遠大于其他分區,影響性能。通過引入時間維度,可以將數據分散到多個分區中,有助于提高讀寫性能和負載均衡。
  1. 聚簇列 (content_id):
  • 在 Cassandra 中,主鍵由分區鍵和聚簇列組成。這里 content_id 作為聚簇列,確保了在同一 note_id 和 year_month 下,并且每個評論內容具有唯一性。UUID 類型非常適合用作這樣的唯一標識符,因為它能夠保證全局唯一性,即使是在分布式環境中。

在這里插入圖片描述

user - 用戶服務

主要的接口:

@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {@Resourceprivate UserService userService;/*** 用戶信息修改*/@PostMapping(value = "/update", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)public Response<?> updateUserInfo(@Validated UpdateUserInfoReqVO updateUserInfoReqVO) {userService.updateUserInfo(updateUserInfoReqVO);return Response.success();}// ===================================== 對其他服務提供的接口 =====================================@PostMapping("/register")public Response<?> register(@RequestBody RegisterUserReqDTO registerUserReqDTO) {return userService.register(registerUserReqDTO);}@PostMapping("/findByPhone")@ApiOperationLog(description = "手機號查詢用戶信息")public Response<FindUserByPhoneRspDTO> findByPhone(@Validated @RequestBody FindUserByPhoneReqDTO findUserByPhoneReqDTO) {return userService.findByPhone(findUserByPhoneReqDTO);}@PostMapping("/password/update")@ApiOperationLog(description = "更新密碼")public Response<?> updatePassword(@Validated @RequestBody UpdateUserPasswordReqDTO updateUserPasswordReqDTO) {return userService.updatePassword(updateUserPasswordReqDTO);}@PostMapping("/findById")@ApiOperationLog(description = "根據用戶 id 查詢用戶信息")public Response<FindUserByIdRspDTO> findById(@Validated @RequestBody FindUserByIdReqDTO findUserByIdReqDTO) {return userService.findById(findUserByIdReqDTO);}@PostMapping("/findByIds")@ApiOperationLog(description = "批量查詢用戶信息")public Response<List<FindUserByIdRspDTO>> findByIds(@Validated @RequestBody FindUsersByIdsReqDTO findUsersByIdsReqDTO) {return userService.findByIds(findUsersByIdsReqDTO);}
}

這個項目提供的注冊方式手機驗證碼,當嘗試通過手機號注冊時,會先檢查數據庫,如果存儲則返回原有賬戶,如果不存在則新加一個賬戶插入到數據庫中,用戶的 Id,是由上文提到的分布式 id 服務生成的

這個項目使用 RBAC 0 來進行鑒權設計
在這里插入圖片描述
用戶與角色之間,角色與權限之前是多對多的關系

在這里插入圖片描述

CREATE TABLE `t_permission` (`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵ID',`parent_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '父ID',`name` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '權限名稱',`type` tinyint unsigned NOT NULL COMMENT '類型(1:目錄 2:菜單 3:按鈕)',`menu_url` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '菜單路由',`menu_icon` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '菜單圖標',`sort` int unsigned NOT NULL DEFAULT 0 COMMENT '管理系統中的顯示順序',`permission_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '權限標識',`status` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '狀態(0:啟用;1:禁用)',`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新時間',`is_deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '邏輯刪除(0:未刪除 1:已刪除)',PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='權限表';

其中的權限表示設計有點類似與目錄的結構

用戶在注冊成功后,默認設置為普通用戶,并將用戶的角色緩存在 redis 中,供后續網關鑒權使用

在通過用戶 id 查找用戶時,設置了二級緩存,當本地緩存找不到時,會從 redis 找,如果 redis 找不到,會查詢數據庫,并將結果緩存到 redis 中

用戶服務還有一個職責是在 用戶服務項目啟動時角色 - 權限集合數據 同步到 redis 中,為了確保多個用戶服務不會重復同步將角色 - 權限集合數據,可以使用 redis 分布式鎖
分布式鎖是確保在分布式系統中多個節點能夠協調一致地訪問共享資源的一種機制。Redis 分布式鎖通過 Redis 的原子操作,確保在高并發情況下,對共享資源的訪問是互斥的。
實現思路:

  • 可以使用 Redis 的 SETNX 命令來實現。如果鍵不存在,則設置鍵值并返回 1(表示加鎖成功);如果鍵已存在,則返回 0(表示加鎖失敗)。
  • 多個子服務同時操作 Redis , 第一個加鎖成功,則可以同步權限數據;后續的子服務都會加鎖失敗,若加鎖失敗,則不同步權限數據;
  • 另外,結合 EXPIRE 命令為鎖設置一個過期時間,比如 1 天,防止死鎖。則在 1 天內,無論啟動多少次認證服務,均只會同步一次數據。

在這里插入圖片描述

user-relation 用戶關系服務

@RestController
@RequestMapping("/relation")
@Slf4j
public class RelationController {@Resourceprivate RelationService relationService;@PostMapping("/follow")@ApiOperationLog(description = "關注用戶")public Response<?> follow(@Validated @RequestBody FollowUserReqVO followUserReqVO) {return relationService.follow(followUserReqVO);}@PostMapping("/unfollow")@ApiOperationLog(description = "取關用戶")public Response<?> unfollow(@Validated @RequestBody UnfollowUserReqVO unfollowUserReqVO) {return relationService.unfollow(unfollowUserReqVO);}@PostMapping("/follow/list")@ApiOperationLog(description = "查詢用戶關注列表")public PageResponse<FindFollowingUserRspVO> findFollowingLst(@Validated @RequestBody FindFollowingListReqVO findFollowingListReqVO) {return relationService.findFollowingList(findFollowingListReqVO);}@PostMapping("/fans/list")@ApiOperationLog(description = "查詢用戶粉絲列表")public PageResponse<FindFansUserRspVO> findFansList(@Validated @RequestBody FindFansListReqVO findFansListReqVO) {return relationService.findFansList(findFansListReqVO);}
}

用戶關系服務主要有 4 個功能:關注、取關、查看用戶關注列表、查看用戶粉絲列表

有兩張表:一張關注表、一張粉絲表

關注方法:

開始關注操作
參數校驗與用戶存在性檢查
執行 Lua 腳本校驗關注狀態
Lua結果是否為ZSET_NOT_EXIST?
查詢數據庫關注記錄
記錄是否為空?
單條添加關注到 Redis
批量同步關注數據到 Redis
是否已關注或達到上限?
拋出對應異常
發送關注 MQ 消息
返回操作成功
  1. 基礎參數校驗
  • 獲取用戶 ID:從上下文中獲取當前登錄用戶 ID(userId)和被關注用戶 ID(followUserId)。
  • 自我關注校驗:若兩者相等,拋出CANT_FOLLOW_YOUR_SELF異常,禁止用戶關注自己。
  • 用戶存在性校驗:通過userRpcService遠程調用檢查被關注用戶是否存在,不存在則拋出FOLLOW_USER_NOT_EXISTED異常。
  1. Redis 緩存操作(核心邏輯)
  • 構建 Redis Key:使用RedisKeyConstants.buildUserFollowingKey(userId)生成用戶關注列表的 Redis 鍵。
  • 執行 Lua 腳本校驗關注狀態:
    • 加載follow_check_and_add.lua腳本,傳入 Redis 鍵、被關注用戶 ID 和當前時間戳。
    • 腳本返回結果通過LuaResultEnum枚舉解析,可能的狀態包括:
      • FOLLOW_LIMIT:關注數達到上限,拋出FOLLOWING_COUNT_LIMIT異常。
      • ALREADY_FOLLOWED:已關注該用戶,拋出對應異常。
      • ZSET_NOT_EXIST:關注列表不存在,進入數據同步邏輯。
  • 處理 ZSET 不存在的情況:
  • 從數據庫查詢用戶當前關注記錄(followingDOMapper.selectByUserId(userId))。
  • 若記錄為空:直接執行follow_add_and_expire.lua腳本,添加新關注關系并設置過期時間(保底 1 天 + 隨機秒數)。
  • 若記錄不為空:
    • 構建 Lua 參數(包含所有關注記錄的時間戳和用戶 ID)。
    • 執行follow_batch_add_and_expire.lua腳本,批量同步關注數據到 Redis。
    • 再次調用follow_check_and_add.lua腳本,確保新關注關系被正確添加。
  1. 發送 RocketMQ 消息
    • 構建消息體:創建FollowUserMqDTO對象,包含用戶 ID、被關注用戶 ID 和操作時間。
    • 設置消息屬性:
      • 主題和標簽:使用 MQConstants.TOPIC_FOLLOW_OR_UNFOLLOW:TAG_FOLLOW。
      • 有序性:通過用戶 ID 作為hashKey保證同一用戶的消息有序處理。
      • 異步發送消息:使用 rocketMQTemplate.asyncSendOrderly 發送消息,并通過回調函數記錄發送結果(成功 / 異常)。

在 consumer 接收到 RocketMQ 消息時,會將此關系分到寫入到 關注表粉絲表 ,并且如果關注的人的粉絲表在 redis 沒有過期的話,會將此關系添加到此粉絲表中

note - 筆記服務

@RestController
@RequestMapping("/note")
@Slf4j
public class NoteController {@Resourceprivate NoteService noteService;@PostMapping(value = "/publish")@ApiOperationLog(description = "筆記發布")public Response<?> publish(@Valid @RequestBody PublishNoteReqVO publishNoteReqVO){return noteService.publishNote(publishNoteReqVO);}@PostMapping(value = "/detail")@ApiOperationLog(description = "筆記詳情")public Response<FindNoteDetailRspVO> findNoteDetail(@Valid @RequestBody FindNoteDetailReqVO findNoteDetailReqVO){return noteService.findNoteDetail(findNoteDetailReqVO);}@PostMapping(value = "/update")@ApiOperationLog(description = "筆記修改")public Response<?> updateNote(@Validated @RequestBody UpdateNoteReqVO updateNoteReqVO) {return noteService.updateNote(updateNoteReqVO);}@PostMapping(value = "/delete")@ApiOperationLog(description = "刪除筆記(邏輯刪除)")public Response<?> deleteNote(@Valid @RequestBody DeleteNoteReqVO deleteNoteReqVO) {return noteService.deleteNote(deleteNoteReqVO);}@PostMapping(value = "/top")@ApiOperationLog(description = "/置頂/取消置頂筆記")public Response<?> topNote(@Validated @RequestBody TopNoteReqVO topNoteReqVO) {return noteService.topNote(topNoteReqVO);}@PostMapping(value = "/like")@ApiOperationLog(description = "點贊筆記")public Response<?> likeNote(@Validated @RequestBody LikeNoteReqVO likeNoteReqVO) {return noteService.likeNote(likeNoteReqVO);}@PostMapping(value = "/unlike")@ApiOperationLog(description = "取消點贊筆記")public Response<?> unlikeNote(@Validated @RequestBody UnlikeNoteReqVO unlikeNoteReqVO) {return noteService.unlikeNote(unlikeNoteReqVO);}@PostMapping(value = "/collect")@ApiOperationLog(description = "收藏筆記")public Response<?> collectNote(@Validated @RequestBody CollectNoteReqVO collectNoteReqVO) {return noteService.collectNote(collectNoteReqVO);}@PostMapping(value = "/uncollect")@ApiOperationLog(description = "取消收藏筆記")public Response<?> unCollectNote(@Validated @RequestBody UnCollectNoteReqVO unCollectNoteReqVO) {return noteService.unCollectNote(unCollectNoteReqVO);}
}
  • 發布筆記:

    • 先檢驗筆記的類型、然后判斷筆記的圖片、視頻鏈接是否為空
    • 再檢驗筆記內容是否為空,如果不為空生成 uuid 然后調用 kv 服務
    • 將筆記插入到數據庫中
    • 發送 MQ 消息,進行異步計數
  • 查詢筆記詳情:

    • 先從本地緩存查詢,如果存在且筆記可見,則直接返回
    • 查詢 redis 緩存,如果存在且筆記可見,將筆記詳情緩存在本地再返回
    • 查詢數據庫獲取筆記,再通過 uuid 獲取筆記內容,異步將筆記詳情緩存在 redis 中,再返回
  • 修改筆記::

    • 查看筆記的類型,是否需要修改筆記的視頻、圖片連接
    • 查看筆記的創建者 id 和當前用戶的 id,檢測是否有權限修改
    • 刪除 redis 緩存
    • 更新在數據庫中筆記的元數據
    • 發送 MQ 消息,進行延遲刪除 redis 緩存
    • 發送 MQ 消息,刪除筆記的本地緩存
    • 最后嘗試調用 kv 服務根據 uuid 更新筆記內容

延時消息 + 延遲雙刪策略:保證 Redis 緩存一致性
在這里插入圖片描述

  • 點贊筆記:

    • 先檢測目標筆記是否存在
    • 查詢添加 布隆過濾器,并返回結果
      • 如果添加成功則說明此前沒有點贊過,直接進行下一步
      • 如果布隆過濾器不存在,查詢數據庫,并將結果緩存在 redis,如果已點贊則拋出異常
      • 如此布隆過濾器存在,也要進一步通過 zset 查詢,如果 zset 存在點贊,則拋出異常
    • 更新用戶的點贊 zset
    • 發送 MQ 消息,異步將點贊落庫,提升接口響應速度
  • 取消點贊:

    • 查詢布隆過濾器
      • 如果不存在,異步更新布隆過濾器,查詢數據庫是否點贊,如果沒點贊直接拋出異常
      • 如果布隆過濾器中不存在點贊,拋出異常
    • 布隆過濾器存在點贊,刪除 zset 緩存,發送 MQ 消息,異步將消息落庫,提升接口響應速度

count - 計數服務

user - relation

在成功將關系落庫后,會向 CountFollowingConsumer 和 CountFansConsumer 分別發送 MQ 消息,分別進行關注數和粉絲數計數

search - 搜索服務

這個服務使用 RestHighLevelClient 與 es 進行交互

RestHighLevelClient 是 Elasticsearch Java 客戶端 提供的一種高級客戶端,用于與 Elasticsearch 集群進行交互。它封裝了低級客戶端(RestClient),提供了對 Elasticsearch API 的高級操作方法,支持更直觀的請求和響應處理,適合大多數開發場景。

其特性如下:

  • 基于 REST 協議:它替代了早期的 Transport Client,通過 HTTP/RESTful API 與 Elasticsearch 交互。
  • 高級 API 支持:提供與 Elasticsearch 各種功能相關的 API,比如索引文檔、搜索、聚合、更新、刪除等。同時支持同步和異步操作。
  • 類型安全:返回的結果可以直接解析為 Java 對象,便于使用。
  • 線程安全:多線程環境下可以安全地使用單個客戶端實例。
  • 自定義擴展:通過組合低級客戶端(RestClient),可以執行自定義的 REST 請求。

有 MQ 通知和 Canal 這兩種兩種方案進行增量同步,這里選擇 Canal

MQ 通知:
在這里插入圖片描述
當筆記發布/修改,用戶注冊/修改時,由對應服務發送 MQ,搜索服務的消費者監聽 MQ 消息,來更新 Elasticsearch 的索引。

優點

  • 高效解耦:
    • 業務系統和搜索服務之間通過 MQ 解耦,便于維護。
    • 如果搜索服務故障,MQ 消息可以暫時緩存,避免數據丟失。
  • 靈活性高:
    • MQ 消息可以同時被多個消費者處理(如同步到其他下游系統)。
    • 消息內容可以包含精確的變更字段,減少不必要的數據傳輸。
  • 性能控制:
    • 可以通過限流、批量消費等手段控制索引更新速率,避免對 Elasticsearch 造成壓力。
  • 容災能力:
    • MQ 具備消息持久化功能,防止因服務異常丟失數據。
      缺點
  • 開發成本:
    • 需要在業務服務中額外實現 MQ 消息的生產邏輯。
    • 搜索服務需要編寫消費者邏輯,解析消息并處理異常。
  • 一致性處理復雜:
    • 如果數據庫更新成功,但發送 MQ 消息失敗,可能導致數據不一致。
    • 通常需要配合事務消息或其他補償機制解決一致性問題。
  • 消息丟失風險:
    • 如果 MQ 配置不當(如不開啟持久化),可能丟失消息。

Canal:
在這里插入圖片描述
實現思路

  • Canal 通過解析數據庫的 binlog 日志 捕獲數據變更。
  • 將變更數據實時同步到下游服務,如更新 Elasticsearch 索引。
  • 優點
    • 零侵入:
      • 不需要修改業務服務邏輯,無需在代碼中額外發送消息。
      • 直接從 binlog 獲取數據變更,減少對業務代碼的侵入性。
    • 一致性強:
      • Canal 直接從數據庫日志解析數據變更,與數據庫主數據完全一致。
    • 低耦合:
      • 不依賴業務服務的實現,與數據庫交互即可實現同步。
    • 實時性高:
      • 通過解析 binlog,變更數據可以實時同步到搜索服務。
  • 缺點
    • 運維成本高:
      • Canal 需要獨立部署,并且對高并發的 binlog 解析有較高的硬件要求。
      • Canal 本身也需要高可用方案(如集群模式)。
    • 功能有限:
      • Canal 只能捕獲數據庫變更(新增、修改、刪除),難以處理復雜的業務邏輯(如某些需要額外字段加工的消息)。
      • 如果業務中對數據的更新不是直接寫入數據庫,而是通過緩存(如 Redis),Canal 無法捕獲。
    • 數據處理復雜性:
      • Canal 只能獲取到原始數據變更,需要額外開發邏輯將 binlog 數據轉換為 Elasticsearch 所需的格式。
      • 多表關聯、字段映射等邏輯可能增加實現復雜性。
    • 數據庫依賴:
      • Canal 強依賴數據庫的 binlog 格式(如 MySQL Binlog),對某些數據庫(如 NoSQL 或非 MySQL 系統)支持有限。

主要分為 用戶筆記 搜索

用戶:

{"mapping": {"properties": {"avatar": {"type": "keyword"},"fans_total": {"type": "integer"},"id": {"type": "long"},"nickname": {"type": "text","analyzer": "ik_max_word","search_analyzer": "ik_smart"},"note_total": {"type": "integer"},"xiaohashu_id": {"type": "keyword"}}}
}

用戶搜索:

  • 參數提取:從請求對象中獲取搜索關鍵詞和當前頁碼。
  • 構建搜索請求:創建針對 UserIndex 索引的搜索請求。
  • 構建查詢條件:使用 multi_match 查詢同時在用戶昵稱和用戶 ID 字段中搜索關鍵詞。
  • 設置排序規則:按粉絲總數降序排列搜索結果。
  • 處理分頁:計算并設置查詢的起始位置和每頁顯示數量。
  • 配置高亮顯示:設置昵稱字段的高亮顯示標簽。
  • 執行搜索請求:發送請求到 Elasticsearch 并獲取響應。
  • 處理搜索結果:解析響應,提取所需字段,處理高亮內容,并構建返回結果對象。
  • 異常處理:捕獲并記錄可能出現的異常,保證系統穩定性。

筆記:

{"mapping": {"properties": {"avatar": {"type": "text","fields": {"keyword": {"type": "keyword","ignore_above": 256}}},"collect_total": {"type": "integer"},"comment_total": {"type": "integer"},"cover": {"type": "keyword"},"create_time": {"type": "date","format": "yyyy-MM-dd HH:mm:ss"},"creator_avatar": {"type": "keyword"},"creator_nickname": {"type": "keyword"},"id": {"type": "long"},"img_uris": {"type": "text","fields": {"keyword": {"type": "keyword","ignore_above": 256}}},"like_total": {"type": "integer"},"nickname": {"type": "text","fields": {"keyword": {"type": "keyword","ignore_above": 256}}},"title": {"type": "text","analyzer": "ik_max_word","search_analyzer": "ik_smart"},"topic": {"type": "text","analyzer": "ik_max_word","search_analyzer": "ik_smart"},"type": {"type": "integer"},"update_time": {"type": "date","format": "yyyy-MM-dd HH:mm:ss"}}}
}

筆記搜索:

  • 參數提取:從請求對象中獲取關鍵詞、頁碼、筆記類型、排序方式和發布時間范圍等參數。
  • 構建基礎查詢:使用 boolQueryBuilder 創建布爾查詢,其中 must 子句包含多字段匹配查詢(標題和話題),并為標題設置更高權重(2.0)。
  • 添加篩選條件:
    • 根據筆記類型添加 term 過濾條件
    • 根據發布時間范圍添加 range 過濾條件
  • 處理排序邏輯:
    • 根據用戶選擇的排序類型(最新、最多點贊、最多評論、最多收藏)設置對應字段的排序
    • 綜合排序模式下,使用 function_score 查詢自定義評分,結合點贊、收藏、評論數量計算最終得分
  • 分頁和高亮設置:設置每頁 10 條數據的分頁參數,并配置標題字段的高亮顯示。
  • 執行搜索并處理結果:執行查詢請求,解析響應數據,提取所需字段,處理高亮內容,構建返回對象。

comment - 評論服務

@RestController
@RequestMapping("/comment")
@Slf4j
public class CommentController {@Resourceprivate CommentService commentService;@PostMapping("/publish")@ApiOperationLog(description = "發布評論")public Response<?> publishComment(@RequestBody @Validated PublishCommentReqVO publishCommentReqVO){return  commentService.publishComment(publishCommentReqVO);}@PostMapping("/list")@ApiOperationLog(description = "評論分頁查詢")public PageResponse<FindCommentItemRspVO> findCommentPageList(@Validated @RequestBody FindCommentPageListReqVO findCommentPageListReqVO) {return commentService.findCommentPageList(findCommentPageListReqVO);}@PostMapping("/child/list")@ApiOperationLog(description = "二級評論分頁查詢")public PageResponse<FindChildCommentItemRspVO> findChildCommentPageList(@Validated @RequestBody FindChildCommentPageListReqVO findChildCommentPageListReqVO) {return commentService.findChildCommentPageList(findChildCommentPageListReqVO);}@PostMapping("/like")@ApiOperationLog(description = "評論點贊")public Response<?> likeComment(@Validated @RequestBody LikeCommentReqVO likeCommentReqVO) {return commentService.likeComment(likeCommentReqVO);}@PostMapping("/unlike")@ApiOperationLog(description = "評論取消點贊")public Response<?> unlikeComment(@Validated @RequestBody UnLikeCommentReqVO unLikeCommentReqVO) {return commentService.unlikeComment(unLikeCommentReqVO);}@PostMapping("/delete")@ApiOperationLog(description = "刪除評論")public Response<?> deleteComment(@Validated @RequestBody DeleteCommentReqVO deleteCommentReqVO) {return commentService.deleteComment(deleteCommentReqVO);}
}

發布評論

publishComment
Comment2DBConsumer
OneLevelCommentFirstReplyCommentIdUpdateConsumer
CountNoteCommentConsumer
CountNoteChildCommentConsumer
CommentHeatUpdateConsumer

publishComment: 生成id,包裝 MQ 消息

  • 從上下文查詢發布者 id,并調用分布式 id 服務,生成評論 id
  • 將評論包裝成 MQ 消息進行異步落庫

Comment2DBConsumer:評論落庫,發送計數 MQ

使用 DefaultMQPushConsumer 提高 MQ 消息吞吐量

  • 提取所有不為空的回復評論 id,并根據 id 查詢評論,并根據 id 進行分組
                // 提取所有不為空的回復評論 IDList<Long> replyCommentIds = publishCommentMqDTOS.stream().map(PublishCommentMqDTO::getReplyCommentId).filter(Objects::nonNull).toList();// 批量查詢相關回復評論記錄List<CommentDO> replyCommentDOS = null;if (CollUtil.isNotEmpty(replyCommentIds)) {replyCommentDOS = commentDOMapper.selectByCommentIds(replyCommentIds);}// DO 集合轉 <評論 ID - 評論 DO> 字典, 以方便后續查找Map<Long, CommentDO> commentIdAndCommentDOMap = Maps.newHashMap();if (CollUtil.isNotEmpty(replyCommentDOS)) {commentIdAndCommentDOMap =  replyCommentDOS.stream().collect(Collectors.toMap(CommentDO::getId, commentDO -> commentDO));}
  • 遍歷每個 MQ 消息,將其轉換為 BO 對象,判斷評論內容是否為空,設置評論級別、回復用戶 ID (reply_user_id)、父評論 ID (parent_id)
  • 批量插入評論元數據和評論內容
  • 將評論消息聚合成一個鏈表并組裝成一個 MQ 消息發送出去,進行 統計計數更新一級評論的第一條子評論統計二級評論

OneLevelCommentFirstReplyCommentIdUpdateConsumer:更新一級評論的最早的二級評論

在這里插入圖片描述

  • 根據二級評論過濾出其一級評論
  • 由于過濾出的是批量的一級評論 ID, 為了防止頻繁查詢 Redis, 帶來性能損耗,需要進行批量查詢 Redis;
  • 提取出 Redis 中不存在的一級評論 ID (若存在,說明該一級評論的 first_reply_comment_id 字段不為 0,無需更新,直接跳過);
  • 拿著這些不存在的一級評論 ID, 批量查詢數據庫;
    • 對于查詢結果,過濾出 first_reply_comment_id不為 0 的一級評論 ID, 隨機過期時間,異步同步到 Redis 中;
    • 對于查詢結果,過濾出 first_reply_comment_id 為 0 的一級評論 ID,循環查詢數據庫,拿到一級評論最早回復的那條評論;
      • 更新該一級評論的 first_reply_comment_id , 然后異步同步到 Redis;

CountNoteCommentConsumer:更新筆記的總評論數

通過筆記 id 將評論分組

        Map<Long, List<CountPublishCommentMqDTO>> groupMap = countPublishCommentMqDTOList.stream().collect(Collectors.groupingBy(CountPublishCommentMqDTO::getNoteId));

遍歷這個 map 更新每個筆記的總評論數

CountNoteChildCommentConsumer: 更新一級評論的子評論數

  • 過濾二級評并根據一級評論分組
        Map<Long, List<CountPublishCommentMqDTO>> groupMap = countPublishCommentMqDTOS.stream().filter(countPublishCommentMqDTO -> Objects.equals(countPublishCommentMqDTO.getLevel(), CommentLevelEnum.TWO.getCode())).collect(Collectors.groupingBy(CountPublishCommentMqDTO::getParentId));
  • 遍歷這個 map,更新對應一級評論的子評論數 redis 緩存(如果存在)和數據庫
  • 發送 MQ 消息,計算一級評論的熱度

CommentHeatUpdateConsumer:計算一級評論的熱度

熱度計算公式

    public static BigDecimal calculateHeat(long likeCount, long replyCount) {// 點贊數權重 70%,被回復數權重 30%BigDecimal likeWeight = new BigDecimal(LIKE_WEIGHT);BigDecimal replyWeight = new BigDecimal(REPLY_WEIGHT);// 轉換點贊數和回復數為 BigDecimalBigDecimal likeCountBD = new BigDecimal(likeCount);BigDecimal replyCountBD = new BigDecimal(replyCount);// 計算熱度 (點贊數*點贊權重 + 回復數*回復權重)BigDecimal heat = likeCountBD.multiply(likeWeight).add(replyCountBD.multiply(replyWeight));// 四舍五入保留兩位小數return heat.setScale(2, RoundingMode.HALF_UP);}
  • 根據 id 批量查詢評論
  • 遍歷評論計算熱度
  • 將評論熱度批量插入數據庫中

評論分頁查詢

  • 先根據 notrId 從 redis 查詢筆記的評論總數 count
  • 如果 count 不為 0
    • 從 redis 獲取筆記的評論 id,如果不存在則進行異步緩存
    • 如果存在,先根據評論 id 從本地緩存獲取數據,并記錄已經過期的評論 id,用于后續從 redis 或者數據庫中獲取
    • 因為評論的點贊數和子評論數變化快(一級評論),需要額外進行同步
  • 如果 count 為 0
    • 直接從數據庫中獲取,并緩存到 redis 和本地緩存中

刪除評論

CommentServiceImpl.deleteComment
DeleteCommentConsumer

deleteComment: 刪除評論

  • 查詢筆記是否存在
  • 判斷是否有權限進行刪除
  • 根據評論 id 和評論內容的 uuid 進行物理刪除
  • 刪除 redis 緩存,發送 MQ 消息,進行刪除本地緩存
  • 發送 MQ, 異步去更新計數、刪除關聯評論、熱度值等

DeleteCommentConsumer: 刪除相關聯評論

  • 如果是一級評論,則一級評論的子評論都要刪除
  • 如果是二級評論
    • 先將此評論作為 replyCommentId 通過遞歸查詢相關聯評論,批量進行刪除
    • 更新此二級評論的一級評論的 first_reply_comment_id
    • 發送 MQ 重新計算一級評論的熱度

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/pingmian/87294.shtml
繁體地址,請注明出處:http://hk.pswp.cn/pingmian/87294.shtml
英文地址,請注明出處:http://en.pswp.cn/pingmian/87294.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

大模型開源技術解析 4.5 的系列開源技術解析:從模型矩陣到產業賦能的全棧突破

提示&#xff1a;本篇文章 1300 字&#xff0c;閱讀時間&#xff1a;5分鐘。 前言 6 月 30 日&#xff0c;百度正式開源文心大模型 4.5 系列&#xff0c;這一動作不僅兌現了 2 月發布會上的技術承諾&#xff0c;更以 10 款全維度模型矩陣刷新了國內開源模型的技術邊界。從學術…

[6-02-01].第05節:配置文件 - YAML配置文件語法

SpringBoot學習大綱 一、YAML語法 1.1.概述&#xff1a; 1.YAML是一種數據序列化格式&#xff1b;2.它是以數據為中心3.容易閱讀&#xff0c;容易與腳本語言交互,如下圖所示&#xff1a; 1.2.基本語法 1.key: value&#xff1a;kv之間有空格2.使用縮進表示層級關系3.縮進時…

FPGA學習

一、module : 定義&#xff1a; 是構建數字系統的基本單元&#xff0c;用于封裝電路的結構和行為。它可以表示從簡單的邏輯門到復雜的處理器等任何硬件組件。 1. module 的基本定義 module 模塊名 (端口列表);// 端口聲明input [位寬] 輸入端口1;output [位寬] 輸出端口1;ino…

26-計組-存儲器與Cache機制

一、存儲器與局部性原理 1. 局部性原理 基礎概念&#xff1a; 時間局部性&#xff1a;一個存儲單元被訪問后&#xff0c;短時間內可能再次被訪問&#xff08;例如循環變量&#xff09;。空間局部性&#xff1a;一個存儲單元被訪問后&#xff0c;其附近單元可能在短時間內被訪…

I/O 線程 7.3

前言 以下&#xff1a; 概述 1.基礎 2.代碼演示 3.練習 4.分析題 1.基礎 一、線程基礎概念 并發執行原理 通過時間片輪轉實現多任務"并行"效果 實際為CPU快速切換執行不同線程 線程 vs 進程 線程共享進程地址空間&#xff0c;切換開銷更小 進程擁有獨立資源&am…

MySQL JSON數據類型完全指南:從版本演進到企業實踐的深度對話

&#x1f4ca; MySQL JSON數據類型完全指南&#xff1a;從版本演進到企業實踐的深度對話 在當今數據驅動的時代&#xff0c;MySQL作為最受歡迎的關系型數據庫之一&#xff0c;不斷演進以滿足現代應用的需求。JSON數據類型的引入&#xff0c;讓MySQL在保持關系型數據庫優勢的同時…

BI × 餐飲行業 | 以數據應用重塑全鏈路業務增長路徑

在競爭激烈的餐飲行業中&#xff0c;數據已成為企業保持競爭力的關鍵資產。通過深入分析顧客數據&#xff0c;餐飲企業能夠洞察消費者的需求和偏好&#xff0c;從而提供更加精準和個性化的服務。此外&#xff0c;利用數據優化業務管理&#xff0c;降低成本&#xff0c;并提高運…

【學習線路】機器學習線路概述與內容關鍵點說明

文章目錄 零、機器學習的企業價值一、基礎概念1. 機器學習定義2. 學習類型3. 學習范式 二、核心算法與技術1. 監督學習2. 無監督學習3. 模型評估與優化 三、深度學習與神經網絡1. 神經網絡基礎2. 深度學習框架3. 應用場景 四、工具與實踐1. 數據處理2. 模型部署3. 機器學習的生…

Linux 命令:cp

Linux cp 命令詳細教程 cp 是 Linux 系統中最常用的命令之一&#xff0c;用于復制文件或目錄。它可以將源文件/目錄復制到指定的目標位置&#xff0c;支持批量復制、強制覆蓋、保留文件屬性等功能。下面詳細介紹其用法。資料已經分類整理好&#xff1a;https://pan.quark.cn/s…

java分頁插件| MyBatis-Plus分頁 vs PageHelper分頁:全面對比與最佳實踐

MyBatis-Plus分頁 vs PageHelper分頁&#xff1a;全面對比與最佳實踐 一、分頁技術概述 在Java持久層框架中&#xff0c;分頁是高頻使用的功能。主流方案有&#xff1a; MyBatis-Plus分頁&#xff1a;MyBatis增強工具的內置分頁方案PageHelper分頁&#xff1a;獨立的MyBatis…

PROFINET轉MODBUS TCP網關在機械臂通信操作中的應用研究

在特定的汽車零部件生產工廠焊接生產線上&#xff0c;機械臂被應用于焊接作業&#xff0c;其控制體系基于Profinet協議。同時&#xff0c;工廠的自動化控制體系以西門子S7-1200PLC為核心&#xff0c;通過ModbusTCP協議實現數據交換。為實現焊接過程的自動化控制以及生產數據的實…

Mac中如何Chrome禁用更新[update chflags macos]

寫在前面 在 macOS 系統中&#xff0c;系統更新提示的小紅點常常讓人不勝其擾。 尤其是當你希望保持現有系統的穩定性&#xff0c;或因兼容性問題暫不想升級時&#xff0c;這個小紅點就像一個頑固的提醒。 - windowsMac版直接刪除更新程序, 有效 cd ~/Library/Google/Googl…

LoRA使用-多個LoRA

LoRA的風格分類 不用去記它有什么很特別的風格&#xff0c;簡單來說基礎模型就像一個全能畫手&#xff0c;什么都能畫&#xff0c;而LoRA是在某個風格中經過特訓的它的一個分身。使得它更精通該風格。 關于LoR風格分類&#xff1a;提示詞撰寫公式 Checkpoint&LoRA對比 訓…

牛客刷題 — 【排序】[NOIP2012] 國王的游戲(高精度結構體排序)

1.題面&#xff1a;傳送門 2. 思路&#xff1a; 相鄰的兩個大臣的先后順序只會互相影響&#xff0c;并不會影響其他人的金幣數。 假設前 i-1 個人左手上的數乘積為 s 。 ① 若 A 大臣排在B 大臣的前面&#xff0c;則&#xff1a; s 此時的金幣數最大值為 。 ② 若B大臣排…

grpc 和限流Sentinel

基于gRPC的微服務通信模塊技術方案書 1. 總體架構設計 #mermaid-svg-TiN9cudEfW5mCWHm {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-TiN9cudEfW5mCWHm .error-icon{fill:#552222;}#mermaid-svg-TiN9cudEfW5mCWHm…

經典灰狼算法+編碼器+雙向長短期記憶神經網絡,GWO-Transformer-BiLSTM多變量回歸預測,作者:機器學習之心!

經典灰狼算法編碼器雙向長短期記憶神經網絡&#xff0c;GWO-Transformer-BiLSTM多變量回歸預測&#xff0c;作者&#xff1a;機器學習之心&#xff01; 目錄 經典灰狼算法編碼器雙向長短期記憶神經網絡&#xff0c;GWO-Transformer-BiLSTM多變量回歸預測&#xff0c;作者&#…

VGG Image Annotator (VIA):一款免費的數據標注軟件介紹與使用

VGG Image Annotator (VIA)&#xff1a;一款免費的數據標注軟件介紹與使用 在計算機視覺領域&#xff0c;數據標注是訓練機器學習模型的基礎步驟之一&#xff0c;而標注工具的選擇直接影響標注的效率和準確性。眾多標注工具中&#xff0c;VGG Image Annotator (VIA) 是一個開源…

CSS實現百分比水柱圖

背景 在echarts沒發現有可以直接使用的展示百分比的柱形圖,只好自己封裝一個組件使用 實現思路 一、圖形拆解 要實現的組件是一個 可配置的圓柱形液柱圖組件&#xff0c;常用于展示比例進度&#xff0c;比如任務完成度、指標達成率等。把圖拆成最小單元然后拼接起來&#x…

詳解 rzsz 工具:Windows 與 Linux 文件傳輸

&#xff08;Linux之軟件包管理器&#xff08;CentOS系統&#xff09; —— yum-CSDN博客&#xff09;rzsz工具之前我在這篇文章中介紹過&#xff0c;現在重新詳細介紹一下該工具。rzsz 是一個用于在 Windows 和 Linux 系統之間傳輸文件的工具集&#xff0c;通常通過終端模擬器…

網絡編程1(UDP)

網絡編程套接字&#xff08;socket api&#xff09; 了解了網絡的一些概念&#xff0c;接下來就要進行網絡中的跨主機通信&#xff0c;了解網絡中的一些API&#xff0c;這里談到的API都是針對傳輸層進行的&#xff0c;這是因為我們編寫的代碼是在應用層&#xff0c;而傳輸層就…