關於點贊業務對MySQL和Redis和MongoDB的思考

Rookie_Leixin發表於2023-11-25

點贊

​ 在我個人理解中,點贊業務比較頻繁,很多人業務可能都會有這個,比如:部落格,影片,文章,動態,評論等,但是不應該是核心業務,不應該大量地請求MySQL資料庫,給資料庫造成大量的資源消耗,MySQL的資料庫是非常寶貴的.

以某音為例,當我去搜尋的時候,全抖音比較高的點贊數目應該是在1200w - 2000w,我們自己的業務肯定答不到這麼高的,但是假設有10個100w的點讚的部落格,user_id為使用者ID,publication_id為部落格的id

  1. 第一種方式是直接運算元據庫.每次有點贊或者取消點贊操作時,就更新MySQL資料庫的點贊數.同時,插入或者更新一個user_id和publication_id的資料行,如果點贊操作非常頻繁,會對資料庫產生很大的壓力.如果有大量的點贊記錄,會對資料庫產生很大的資料量,一篇文章,100w+的點讚的記錄,對於MySQL來說,是非常恐怖的.

  2. 第二種方式是透過MySQL + Redis的Set來實現,具體程式碼如下,以下的程式碼為B站Redis黑馬點評專案:

    @Override
    public Result likeBlog(Long id){
        // 1. 獲取登入使用者
        Long userId = UserHolder.getUser().getId();
    
        // 2. 判斷當前登入使用者是否已經點贊
        String key = BLOG_LIKED_KEY + id;
        Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
    
        if(BooleanUtil.isFalse(isMember)){
            // 3. 如果未點贊,可以點贊
            // 3.1 資料庫點贊數+1
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
    
            // 3.2 儲存使用者到Redis的set集合
            if(isSuccess){
                stringRedisTemplate.opsForSet().add(key, userId.toString());
            }
        } else {
            // 4. 如果已點贊,取消點贊
            // 4.1 資料庫點贊數-1
            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
    
            // 4.2 把使用者從Redis的set集合移除
            if(isSuccess){
                stringRedisTemplate.opsForSet().remove(key, userId.toString());
            }
        }
    }
    
    

    這樣造成的問題是,Redis是記憶體資料庫,點贊資訊儲存在記憶體中。當點贊數量非常大時,會佔用大量記憶體。

    下面測試一下,一個key為"userId:114514:publication_id:738836",value為100000-1100000的資料

    • 資料量

       scard userId:114514:publication_id:738836
      

      image-20231105180611714

    • 判斷一個value是否存在這個set中-----(對應的業務為"判斷當前登入使用者是否已經點贊")

          @Test
          public void selectBigKey() {
              String key = "userId:114514:publication_id:738836";
              String value1 = "100000";
              String value2 = "5000000";
              // 記錄開始時間
              long startTime = System.nanoTime();
      
              boolean cacheSet1 = RedisUtils.containsInCacheSet(key, value1);
              if (cacheSet1) {
                  System.out.println("程式碼2:" + "存在這個value");
              } else {
                  System.out.println("程式碼2:" + "不存在這個value");
              }
              // 記錄結束時間
              long endTime = System.nanoTime();
      
              // 計算執行時間(以毫秒為單位)
              long executionTime = (endTime - startTime) / 1_000_000; // 將納秒轉換為毫秒
      
              System.out.println("程式碼執行時間1: " + executionTime + " 毫秒");
      
              // 記錄開始時間
              long startTime2 = System.nanoTime();
      
              boolean cacheSet2 = RedisUtils.containsInCacheSet(key, value2);
              if (cacheSet2) {
                  System.out.println("程式碼2:" + "存在這個value");
              } else {
                  System.out.println("程式碼2:" + "不存在這個value");
              }
      
              // 記錄結束時間
              long endTime2 = System.nanoTime();
      
              // 計算執行時間(以毫秒為單位)
              long executionTime2 = (endTime2 - startTime2) / 1_000_000; // 將納秒轉換為毫秒
      
              System.out.println("程式碼執行時間2: " + executionTime2 + " 毫秒");
      
          }
      

      image-20231105180819204

      可以看到,其實對於時間來說,61毫秒和66毫秒可以說時間非常短了,不愧是redis,即使資料量很大,但是查詢一個value是否在比較大的set的效率是非常短的.

    • 往一個key中新增一個value,或者刪除一個value--->(對應一個點贊,和取消點贊)

          @Test
          public void addAndRemoveElementFromBigKey() {
              String key = "userId:114514:publication_id:738836";
              String value1 = "10000000";
              String value2 = "200000";
      
              // 記錄開始時間
              long startTime = System.nanoTime();
      
              boolean cacheSet1 = RedisUtils.addToCacheSet(key, value1);
      
              // 記錄結束時間
              long endTime = System.nanoTime();
      
              // 計算執行時間(以毫秒為單位)
              long executionTime = (endTime - startTime) / 1_000_000; // 將納秒轉換為毫秒
      
              System.out.println("新增一個元素的執行時間: " + executionTime + " 毫秒");
      
              // 記錄開始時間
              long startTime2 = System.nanoTime();
      
              boolean cacheSet2 = RedisUtils.removeFromCacheSet(key, value2);
      
              // 記錄結束時間
              long endTime2 = System.nanoTime();
      
              // 計算執行時間(以毫秒為單位)
              long executionTime2 = (endTime2 - startTime2) / 1_000_000; // 將納秒轉換為毫秒
      
              System.out.println("刪除一個元素的程式碼執行時間: " + executionTime2 + " 毫秒");
      
          }
      

      image-20231105182159692

      但從時間來講,只有一個字:快

    • 查詢佔用的記憶體的空間

      MEMORY USAGE  userId:114514:publication_id:738836
      

      image-20231105182354488

​ 其實可以看到,大概是佔用66mb,如果使用者的id為雪花演算法的id,那可能佔用的記憶體100mb

以上來說,主要還是一個bigkey的問題,如果點讚的數量過大,佔用的記憶體過大,寶貴的記憶體不應該給這種業務.

  1. 自然而然,我們想到用非關係型資料庫,但是不要是基於記憶體的,我想到的是用MongoDB的方案

    我們可以往MongoDB中插入一條這樣的資料:

    db.collectionName.insertOne({
      "id": "yourIdValue",
      "userId": yourUserIdValue,
      "type": yourTypeValue,
      "likedItemId": yourLikedItemIdValue,
      "createTime": new Date("yourCreateTimeValue")
    });
    
    

    id 主鍵id,userId為使用者的ID,type為文章或者動態或者其他的型別,likedItemId為文章或者動態或者其他的型別的主鍵ID,createTime為點贊時間

    在MongoDB中,可以使用createIndex方法來建立唯一索引。為userId,typelikedItemId欄位建立一個唯一索引。

    db.collectionName.createIndex(
      { "userId": 1, "type": 1, "likedItemId": 1 },
      { unique: true, name: "unique_index_name" }
    );
    

    詳細解釋:

    • collectionName:集合名稱。
    • unique_index_name:你想要給索引起的名字,可以根據你的需求替換為其他名稱。

    這個命令將在collectionName集合上建立一個名為unique_index_name的唯一索引,涵蓋了userIdtypelikedItemId欄位。 1表示升序,如果需要降序索引,可以使用-1unique: true選項確保索引是唯一的。

    執行這個命令後,如果有重複的組合出現在這三個欄位上,MongoDB將會阻止插入並丟擲錯誤。

    即如果裡面有記錄為已經點過贊,點贊就是往裡面加記錄,取消點贊就是刪除記錄

    詳細程式碼如下:

    @Service
    public class LikeServiceImpl implements LikeService {
        @Autowired
        private MongoTemplate mongoTemplate;
    
        @Autowired
        private PublicationService publicationService;
    
        /**
         * 為動態或者文章點贊
         *
         * @param publicationId 動態或者文章的ID
         * @param userId        使用者的ID
         * @param type          型別,區分是文章還是動態
         * @return 點贊總數
         */
        @Override
        public Integer likePublication(Long publicationId, Long userId, Integer type) {
            // 構建查詢條件
            Criteria criteria = Criteria.where("userId").is(userId)
                    .and("type").is(type)
                    .and("likedItemId").is(publicationId);
            // 建立查詢物件並應用查詢條件
            Query query = new Query(criteria);
            boolean isExists = mongoTemplate.exists(query, PublicationLike.class);
    
            if (isExists) {
                Asserts.fail("重複點贊");
            }
            //將點贊記錄儲存到mongodb
            PublicationLike publicationLike = new PublicationLike();
            publicationLike.setType(type);
            publicationLike.setCreateTime(DateUtil.date());
            publicationLike.setLikedItemId(publicationId);
            publicationLike.setUserId(userId);
            PublicationLike savedLike = mongoTemplate.save(publicationLike);
            //點贊數統計
    
            String redisLikeCountKey = String.format(RedisConstant.PUBLICATION_LIKE_COUNT, publicationId, userId, type);
            Long likeCount = RedisUtils.getAtomicValueWithDefault(redisLikeCountKey);
            //如果沒有快取過點贊數,則查詢資料庫
            if (likeCount.equals(-1L)) {
                Publication publication = publicationService.getById(publicationId);
                RedisUtils.setAtomicValue(redisLikeCountKey, publication.getLikeCount());
                return publication.getLikeCount();
            } else {
                //返回點贊數+1
                return Math.toIntExact(RedisUtils.incrAtomicValue(redisLikeCountKey));
            }
    
    
        }
    
        @Override
        public Integer unlikePublication(Long publicationId, Long userId, Integer type) {
            // 構建查詢條件
            Criteria criteria = Criteria.where("userId").is(userId)
                    .and("type").is(type)
                    .and("likedItemId").is(publicationId);
            // 建立查詢物件並應用查詢條件
            Query query = new Query(criteria);
            boolean isExists = mongoTemplate.exists(query, PublicationLike.class);
    
            if (!isExists) {
                Asserts.fail("未點贊過該內容,無法取消點贊");
            }
    
            // 從MongoDB中刪除點贊記錄
            mongoTemplate.remove(query, PublicationLike.class);
    
            // 更新點贊數統計
            String redisLikeCountKey = String.format(RedisConstant.PUBLICATION_LIKE_COUNT, publicationId, userId, type);
            Long likeCount = RedisUtils.getAtomicValueWithDefault(redisLikeCountKey);
    
            // 如果點贊數存在於快取中,減少點贊數並返回
            if (!likeCount.equals(-1L)) {
                long newLikeCount = RedisUtils.decrAtomicValue(redisLikeCountKey);
                return Math.toIntExact(newLikeCount);
            } else {
                // 如果點贊數沒有快取,查詢資料庫並更新快取
                Publication publication = publicationService.getById(publicationId);
                if (publication != null) {
                    RedisUtils.setAtomicValue(redisLikeCountKey, publication.getLikeCount());
                    return publication.getLikeCount();
                } else {
                    Asserts.fail("無法獲取點贊數");
                    return 0;
                }
            }
        }
    
    }