文章目錄
- 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);}
- 獲取當前時間戳:
- 若時間回退(當前時間戳小于上次生成 ID 的時間戳):
- 小范圍回退(≤5ms):等待雙倍時間后重試。
- 大范圍回退:直接返回異常。
- 若時間正常:
- 同一毫秒內:序列號遞增(通過 sequenceMask 取模)。
- 若序列號溢出(變為 0):等待下一毫秒,并生成隨機序列號(0-99)。
不同毫秒:重置序列號為隨機數(0-99)。
- 組合生成 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 生成過程采用 “雙緩沖區 + 預加載” 模式,核心步驟如下:
- 檢查緩存初始化狀態,未初始化則返回異常
- 根據業務 key 獲取對應的 SegmentBuffer
- 從 SegmentBuffer 中獲取可用 ID 段,采用讀寫鎖保證線程安全
- 當當前 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();}}}
采用雙緩沖區設計:
- 兩個 Segment 交替使用:currentSegment 和 nextSegment
- 預加載機制:當 currentSegment 使用量超過 90% 時,異步加載 nextSegment
- 讀寫鎖分離:使用 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)
);
- 復合分區鍵 (note_id, year_month):
- 分區鍵的作用:在 Cassandra中,分區鍵決定了數據存儲的位置,即哪個節點將保存該數據。通過使用復合分區鍵,可以更靈活地控制數據分布。
- 為什么選擇這兩個字段:
- note_id:筆記 ID, 作為筆記的唯一標識符,讓它成為分區鍵的一部分,因為評論查詢,是基于某篇筆記進行。
- year_month:加入發布年月是為了避免單個分區變得過大。如果僅以 note_id 作為分區鍵,那么所有該筆記的評論都將存儲在一個分區里,可能導致熱點問題(hotspotting),即某些分區的數據量遠大于其他分區,影響性能。通過引入時間維度,可以將數據分散到多個分區中,有助于提高讀寫性能和負載均衡。
- 聚簇列 (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 個功能:關注、取關、查看用戶關注列表、查看用戶粉絲列表
有兩張表:一張關注表、一張粉絲表
關注方法:
- 基礎參數校驗
- 獲取用戶 ID:從上下文中獲取當前登錄用戶 ID(userId)和被關注用戶 ID(followUserId)。
- 自我關注校驗:若兩者相等,拋出CANT_FOLLOW_YOUR_SELF異常,禁止用戶關注自己。
- 用戶存在性校驗:通過userRpcService遠程調用檢查被關注用戶是否存在,不存在則拋出FOLLOW_USER_NOT_EXISTED異常。
- 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腳本,確保新關注關系被正確添加。
- 發送 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 消息失敗,可能導致數據不一致。
- 通常需要配合事務消息或其他補償機制解決一致性問題。
- 消息丟失風險:
- 如果 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: 生成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 和本地緩存中
刪除評論
deleteComment: 刪除評論
- 查詢筆記是否存在
- 判斷是否有權限進行刪除
- 根據評論 id 和評論內容的 uuid 進行物理刪除
- 刪除 redis 緩存,發送 MQ 消息,進行刪除本地緩存
- 發送 MQ, 異步去更新計數、刪除關聯評論、熱度值等
DeleteCommentConsumer: 刪除相關聯評論
- 如果是一級評論,則一級評論的子評論都要刪除
- 如果是二級評論
- 先將此評論作為 replyCommentId 通過遞歸查詢相關聯評論,批量進行刪除
- 更新此二級評論的一級評論的 first_reply_comment_id
- 發送 MQ 重新計算一級評論的熱度