【解決方案】Java 網際網路專案中訊息通知系統的設計與實現(下)

CodeBlogMan發表於2024-08-05

目錄
  • 前言
  • 四、技術選型
  • 五、後端介面設計
    • 5.1業務系統介面
    • 5.2App 端介面
  • 六、關鍵邏輯實現
    • 6.1Redis儲存結構
    • 6.2已讀訊息處理
    • 6.3快取定時清除
  • 本篇小結

前言

書接上回,訊息通知系統(notification-system)作為一個獨立的微服務,完整地負責了 App 端內所有訊息通知相關的後端功能實現。該系統既需要與文章系統、訂單系統、會員系統等相關聯,也需要和其它業務系統相關聯,是一個偏底層的通用服務系統。

App 端內的訊息通知型別常見有這幾項:評論通知、點贊通知、收藏通知、訂單通知、活動通知、個人中心相關通知等。該系統在可擴充性、高效能、較高可用性、資料一致性等方面有較高要求,最終目的是提升使用者粘性、加強 App 與使用者的互動、支撐核心業務的發展。

文章的(上)篇將從需求分析、資料模型設計、關鍵流程設計這 3 部分來說明,(下)篇將從技術選型、後端介面設計、關鍵邏輯實現這 3 部分來進行說明。


四、技術選型

我將該系統需要使用到的關鍵技術選型做成表格,方便梳理:

【解決方案】Java 網際網路專案中訊息通知系統的設計與實現(下)
說明:
  • 可以用 Spirng Cloud 或者 Spirng Cloud Alibaba,哪個習慣用哪個,只要是能打包成一個可執行的微服務即可;
  • 也可以用非關係型資料庫如 MongoDB 來代替 MySQL,表與表之間的關係不密切的前提下,效能會更高;
  • Redis 拿來做快取中介軟體去儲存非結構化的一些資料是非常合適的,很多場景下,突出的效能和便捷的 API 是它的優勢;
  • MQ 其實是選用的,適合較為複雜的專案拿來非同步/解耦,既可以 kafka 也可以 RabbitMQ,RocketMQ 是阿里親生的,控制檯用起來也方便;
  • 其它開源依賴最好使用 apache 的頂級專案或者 Spring 官方的,像 hutool 這種第三方的包其實不太推薦,安全風險可能會比較高。

五、後端介面設計

作為一個偏底層的公共服務,基本上都會先由上游的業務系統進行呼叫,再服務於使用者(即 App 端)。下面設計兩個 Controller 分別針對業務端和 App 端,大家可以先參考一下介面規範,也寫了總體的思路註釋,關鍵邏輯會在下一節再展開講。

5.1業務系統介面

暴露給業務系統的有 3 個介面:

  1. 獲取通知配置
  2. 傳送通知
  3. 撤回通知
@RestController
@RequestMapping("notice/api")
public class NoticeApiController {

    @Resource
    private NotificationService notificationService;

    /**
     * 新增通知,業務系統用
     * @param dto
     * @return 訊息系統唯一 id
     */
    @PostMapping("/add")
    public Response<Long> addNotice(@Valid @RequestBody AddNoticeDTO dto){
        //業務方呼叫該介面前需要先根據 sourceId 確認來源,實現就是先入資料庫,再入 Redis
        return ResponseBuilder.buildSuccess(this.notificationService.addNotice(dto));
    }

    /**
     * 撤回通知(同批次撤回),業務系統用
     * @param idList,需要撤回的訊息主鍵 id 集合
     * @return 是否成功:true-成功,false-失敗
     */
    @PostMapping("/recall")
    public Response<Boolean> recallNotice(@RequestBody List<Long> idList){
        //撤回只需要考慮先更新資料庫,後更新 Redis
        return ResponseBuilder.buildSuccess(this.notificationService.recallNotice(idList));
    }

    /**
     * 獲取通知配置
     * @param sourceId 業務系統標識
     * @return 配置詳情資訊
     */
    @GetMapping("/getNoticeConfig")
    public Response<NotificationConfig> getNoticeConfig(@RequestParam(value = "noticeId") String sourceId){
        //每個業務系統呼叫前需要校驗通知配置,以防非法呼叫
        return ResponseBuilder.buildSuccess(this.notificationService.getNoticeConfig(sourceId));
    }
    
}

5.2App 端介面

開放給 App 端使用的有 2 個介面:

  1. 獲取使用者未讀訊息總數
  2. 獲取使用者訊息列表
@RestController
@RequestMapping("notice/app")
public class NoticeAppController {

    @Resource
    private NotificationService notificationService;

    /**
     * 獲取使用者未讀訊息總數
     */
    @Auth
    @GetMapping("/num")
    public Response<NoticeNumVO> getMsgNum() {
        //App 端的使用者唯一 uuid
        String userUuid = "";
        return ResponseBuilder.buildSuccess(this.notificationService.getMsgNum(userUuid));
    }

    /**
     * 獲取使用者訊息列表
     *
     * @param queryDate:查詢時間 queryDate
     * @param pageIndex:頁碼,1開始
     * @param pageSize:每頁大小
     * @param superType:訊息父型別,1-評論、點贊、系統訊息,2-通知,3-私信,4-客服訊息
     */
    @Auth
    @GetMapping("/list/{queryDate}/{pageIndex}/{pageSize}/{superType}")
    public Response<List<Notification>> getNoticeList(@PathVariable String queryDate, @PathVariable Integer pageIndex,
                                                      @PathVariable Integer pageSize, @PathVariable Integer superType) throws ParseException {
        //App 端的使用者唯一 uuid
        String userUuid = "";
        Date dateStr = DateUtils.parseDate(queryDate, new String[]{"yyyyMMddHHmmss"});
        return ResponseBuilder.buildSuccess(this.notificationService.getNoticeList(userUuid, dateStr, pageIndex, pageSize, superType));
    }

}

六、關鍵邏輯實現

本小節會針對 APP 端的兩個介面進行詳細講解,未讀訊息數和訊息列表的實現需要 Redis + MySQL 的緊密配合。

6.1Redis儲存結構

下面先著重介紹一下本系統的 Redis 快取結構設計,全域性只使用 Hash 結構,新增訊息時+1,撤回訊息時-1,已讀訊息時做算術更新:

【解決方案】Java 網際網路專案中訊息通知系統的設計與實現(下)
Redis-Hash 結構

說明:

  • Redis-key 是固定 String 常量 "sysName.notice.num.key";

  • Hash-key 為 App 端使用者唯一的 userUuid;

  • Hash-value 為該使用者接收的訊息總數,新增 +1,撤回 -1。

如果大家對於 Redis 的基本結構還不太瞭解,參考下我的這篇部落格:https://www.cnblogs.com/CodeBlogMan/p/17816699.html

下面是關鍵實現步驟的程式碼示例:

  1. 新增訊息

        //先入 MySQL
        Notification notification = this.insertNotice(dto);
        //再入 Redis
        redisTemplate.opsForHash().increment(RedisKey, dto.getTargetUserUuid(), 1);
    
  2. 撤回訊息

        //先更新 MySQL
        this.updateById(notification);
        //再更新 Redis
        redisTemplate.opsForHash().increment(RedisKey, userUuid, -1);
    

注意:

寫操作和更新操作都是先運算元據庫,然後再同步入 Redis。原因:資料庫裡的資料是源頭,且存的是結構化的永續性資料;Redis 只是作為快取,發揮 Redis 讀取速度快的優點,儲存的是一些 size 不大的熱點資料。

6.2已讀訊息處理

已讀和未讀其實就是兩種狀態,Redis 裡一開始儲存的都是未讀數,當使用者點選檢視列表時,前端會呼叫後端的訊息列表介面,訊息列表直接查資料庫(記錄了已讀和未讀狀態),此時同步更新 Redis 裡的未讀訊息數,那麼此時:未讀訊息數 = Redis總數 - MySQL已讀訊息數。

下面的程式碼說得比較清楚了:

  1. 查詢未讀訊息數

        Integer num;
        //先讀 redis,沒有再讀資料庫,最後再把資料庫讀出的放回 redis
        num = (Integer) redisTemplate.opsForHash().get(RedisKey, userUuid);
        //防止一開始新增通知的時候沒放進 redis 裡,null 表示什麼都沒有,而不是 0
        if (Objects.nonNull(num)) {
            msgNumVO.setMsgNum(num);
        }else {
            num = this.getNoticeNum(userUuid, queryDate);
            log.info("快取中沒有未讀訊息總數,查資料庫:{}", num);
            msgNumVO.setMsgNum(num);
            //放入快取,取出什麼放什麼
            redisTemplate.opsForHash().put(RedisKey, userUuid, num);
        }
    return num;
    
  2. 查詢訊息列表

     wrapper.eq(Notification::getTargetUserUuid, userUuid)
            .eq(Notification::getSuperType, superType)
            .eq(Notification::getMsgStatus, StatusEnum.TRUE.getType())
            .le(Notification::getCreateTime, dateTime)
            .orderByDesc(Notification::getCreateTime);
        List<Notification> queryList = pageInfo.getResult();
        //查詢後即要同步去更新資料庫中該型別下的訊息為已讀
        this.updateListBySuperType(wrapper);
        long isReadNum;
        isReadNum = queryList.stream().filter(val -> NumberUtils.INTEGER_ZERO.equals(val.getIsRead())).count();
        //關鍵的一步,同步更新 redis 裡的未讀訊息數
        Integer redisNum = (Integer) redisTemplate.opsForHash().get(RedisKey.INITIAL_NOTICE_NUM_PERFIX, userUuid);
        //要先判斷 redis 裡是否為 null,和 0 不一樣
        int hv = Objects.isNull(redisNum) ? 0 : (int) (redisNum - isReadNum);
        redisTemplate.opsForHash().put(RedisKey, userUuid, Math.max(hv, 0));
    return queryList;
    

6.3快取定時清除

由於在上述的 redis-hash 結構中並沒有加入 expire 過期時間,那麼顯而易見的是這個結構隨著時間增加會越來越大,最終導致形成一個大key,給 redis 的讀/寫效能帶來影響。
所以這裡需要給出一個方案來解決這個問題,我的核心思路是:

  • 每當寫redis計數的時候同時用另一個 key 記操作時間,每10分鐘執行一次定時任務;
  • 逐一將時間 key 的 value (即操作時間)根據 uuid 拿出來,如果當前系統時間 - 該uuid的操作時間>3600ms(即一個小時)那麼就將該 uuid 的資料刪除;
  • 下次調介面先讀資料庫,再寫進 redis 裡面,具體看程式碼。
@Component
@Slf4j
public class HandleNoticeCache {
    private static final Long FLAG_TIME = 3600L;
    @Resource
    private RedisTemplate redisTemplate;
    @Scheduled(cron = " * 0/10 * * * ? ")
    public void deleteNoticeCache(){
        HashOperations<String, String, Integer> hashOperations = redisTemplate.opsForHash();
        //通知操作的全部 uuid,資料量一大可能導致 OOM
        Set<String> uuidList = hashOperations.keys(RedisKey.NOTICE_NUM_TIME);
        if (CollectionUtils.isNotEmpty(uuidList)){
            uuidList.forEach(val -> {
                Integer operateTime = hashOperations.get(RedisKey.NOTICE_NUM_TIME, val);
                if (Objects.nonNull(operateTime)){
                    //當前系統時間-操作的記錄時間
                   long resultTime =  System.currentTimeMillis() - operateTime;
                   if (resultTime > FLAG_TIME){
                       hashOperations.delete(RedisKey.NOTICE_NUM_PERFIX, val);
                       log.info("刪除通知的 uuid 為:{}", val);
                       hashOperations.delete(RedisKey.COMMENT_NUM_PERFIX, val);
                       log.info("刪除評論通知的 uuid 為:{}", val);
               }
                }
            });
        }
    }

}

本篇小結

到這裡關於網際網路訊息通知系統的設計與實現就分享完了,至於原始碼我看在週末或者假期有沒有時間發出來,之後自己的個人 git 開源倉庫應該已經建設好了。

文章如有錯誤和不足,還望指正,同時也歡迎大家在評論區說出自己的想法!

相關文章