【解決方案】Java 網際網路專案中常見的 Redis 快取應用場景

CodeBlogMan發表於2024-09-23

目錄
  • 前言
  • 一、常見 key-value
  • 二、時效性強
  • 三、計數器相關
  • 四、高實時性
  • 五、排行榜系列
  • 六、文章小結

前言

在筆者 3 年的 Java 一線開發經歷中,尤其是一些移動端、使用者量大的網際網路專案,經常會使用到 Redis 作為快取中介軟體的基本工具來解決一些特定的問題。

下面是筆者總結梳理的一些常見的 Redis 快取應用場景,例如常見的 String 型別 Key-Value、對時效性要求高的場景、Hash 結構的場景以及對實時性要求高的場景等,基本涵蓋了 Redis 中所有的 5 種基本型別。

如果你也在專案中經常使用 Redis 來作為快取的中介軟體,那麼你一定不會對下面的內容感到陌生。如果你還是剛入行不久,暫時還沒接觸到 Redis 這樣的快取中介軟體,那麼也沒關係,本篇文章對你也會有一定的幫助。

關於快取的一些基本概念,大家可以看這裡再回顧一下:https://www.cnblogs.com/CodeBlogMan/p/18022719

一、常見 key-value

首先介紹的是專案開發中常見的一些String 型別的 key-value 結構場景,如:

  • 使用 jsonStr 結構儲存的使用者登入資訊,包括:手機號、token、唯一 uuid、暱稱等;
  • jsonStr 結構某個熱門商品的資訊,包括:商品名稱、商品唯一id、所屬商家、價格等;
  • String 型別的、帶過期時間的分散式鎖,包括:鎖的超時時間、隨機生成的 value、判斷加鎖成功、釋放鎖等。

下面用簡單的 demo 來演示一下如何獲取使用者登入資訊。

@RestController
@RequestMapping("/member")
public class MemberController {

    @Resource
    private MemberService memberService;

    /**
     * 透過 userUuid 獲取會員資訊
     * @param userUuid
     * @return 會員資訊
     */
    @GetMapping("/info")
    public Response<MemberVO> getMemberInfo(@RequestParam(value = "userUuid") String userUuid) {
        return ResponseBuilder.buildSuccess(this.memberService.info(userUuid));
    }

}
    @Resource
    private RedisTemplate<String, String> redisTemplate;    

    private final String MEMBER_INFO_USER_UUID_KEY = "initial.member.user.uuid.key";
    
    @Override
    public MemberVO info(String userUuid) {
        //先查快取
        String memberStr = redisTemplate.opsForValue().get(RedisKey.MEMBER_INFO_USER_UUID_KEY.concat(userUuid));
        if (StringUtils.isNotBlank(memberStr)){
            return JSON.parseObject(memberStr, MemberVO.class);
        }
        //快取沒有再查資料庫
        LambdaQueryWrapper<Member> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(Member::getMemberUuid, userUuid)
                .eq(Member::getEnableStatus, NumberUtils.INTEGER_ZERO)
                .eq(Member::getDataStatus, NumberUtils.INTEGER_ZERO);
        return this.getOne(wrapper).convertExt(MemberVO.class);
    }
/**
 * 僅部分核心屬性
 */
@EqualsAndHashCode(callSuper = true)
@Data
public class MemberVO extends BaseVO {
    /**
     * 使用者唯一uuid
     */
    private String memberUuid;
    /**
     * 登入 token
     */
    private String token;
    /**
     * 使用者暱稱
     */
    private String nickName;
    /**
     * 電話號碼
     */
    private String mobile;
    /**
     * 頭像地址
     */
    private String avatarImg;
    /**
     * 性別:1-male,2-female,3-unknown
     */
    private Integer gender;
}

二、時效性強

在開發的時候我們經常會碰到時效性強的一些場景,從業務上對過期時間的要求比較高,比如:

  • 如某個專案或者活動的預覽連結,設定該連結在 30 分鐘後失效,即過半小時後不允許再訪問該預覽連結;
  • 從 web 端跳轉到 app 客戶端訪問一些特定的內容,使用剪下板複製的分享口令開啟客戶端,設定在 60 分鐘後過期;
  • 使用者在客戶端或者小程式領取的優惠券,領取後放入“我的卡券”中,不同型別的卡券設定不同的過期時間,如某積分券在 30 天后過期等。

下面舉兩個例子,demo 雖然簡單但是可以執行,不是虛擬碼:

    /**
     * 活動預覽連結 30 分鐘後過期
     */
    @Test
    public void testPreviewLinkExpire(){
        String baseUrl = "http://localhost:8089/initial/ealbum/preview/detail";
        Long projectId = UUIDUtils.generateUUIDToLong();
        String projectUuid = UUIDUtils.generateUUID();
        //這裡是連結的簽名,如果簽名過期,那麼意味著整個連結過期
        String tempLinkSign = DigestUtils.md5Hex(projectUuid);
        String signKey = RedisKey.INITIAL_EALBUM_TEMP_LINK_SIGN_KEY.concat(".").concat(projectId.toString());
        stringRedisTemplate.opsForValue().set(signKey, tempLinkSign, Duration.ofMinutes(30));
        String sign = stringRedisTemplate.opsForValue().get(signKey);
        if (StringUtils.isNotBlank(sign)){
            //拼接臨時地址
            StringBuilder tempLinkUrl = new StringBuilder(baseUrl)
                    .append("?projectId=").append(projectId)
                    .append("&sign=").append(sign);
            log.info("列印看下預覽地址:{}", tempLinkUrl);
        }
        throw new BusinessException("生成預覽連結失敗!");
    }
    /**
     * 剪下板口令碼1小時過期
     */
    @Test
    public void testClipboardTextKey(){
        //這裡先隨機生產一個 uuid 作為例子
        String articleId = UUIDUtils.generateUUID();
        String redisKey = RedisKey.CLIP_BOARD_TEXT_KEY.concat(".").concat(articleId);
        String value = JSON.toJSONString(ClipboardVO.builder()
                .articleId(articleId)
                .articleTitle("測試標題")
                .copyright(NumberUtils.INTEGER_ZERO).build());
        //很簡單的一個結構,就是常見的 String 型別的 key-value,但是對時效性有要求,一個小時後直接失效
        stringRedisTemplate.opsForValue().set(redisKey, value, Duration.ofHours(1));
        //這裡去拿 value,判斷是否過期
        ClipboardVO vo = Optional.ofNullable(stringRedisTemplate.opsForValue().get(redisKey))
                .filter(StringUtils::isNotBlank)
                .map(val -> JSON.parseObject(val, ClipboardVO.class))
                .orElse(null);
        log.info("列印一下返回的vo:{}", vo);
    }

三、計數器相關

關於計數器也是 Redis 的一個常見應用場景了,比如以下幾點:

  • 點贊數/收藏數:文章的點贊數,文章的收藏數,可以同步到文章表作為一個屬性;

  • 文章評論數:採用 String 結構,redis-key 可以是文章 id 標識,redis-value 則是評論數,也可以同步到文章表作為一個屬性;

  • 未讀訊息數:採用 Hash 結構,常量 redis-key,使用者標識為 hash-key,數量為 hash-value,需要同步到通知表;

  • 加入購物車的商品數:採用 BoundHash 結構,redis-key 為使用者標識,hash-key 為商品 id 標識,hash-value 為數量,需要同步到購物車的表。

下面舉兩個例子吧,部分實體、DTO/VO 之類的沒有寫明,大家能意會就好,重要的是思路:

    /**
     * 使用者未讀通知數量計數
     */
    @Test 
    public void testNoticeCountNum() {
        //關於熱 Key 這裡場景有點不夠:因為通知數量只有啟動app和點選按鈕的時候才會呼叫,並不是那麼地頻繁,QPS 幾萬應該沒問題
        //但有大 key 的可能性,那麼:1、定期清理快取,配合資料庫解決;2、Hash 底層會壓縮資料;3、看佔用記憶體的大小或者元素的數量;4、資料分片
        NoticeAddDTO dto = NoticeAddDTO.builder()
                .targetUserUuid("123qwe456rty789uio")
                .superType(NumberUtils.INTEGER_TWO)
                .subType(5)
                .content("內容內容").build();
        //無論何種業務系統的何種型別訊息,先入資料庫
        Notification notification = this.notificationService.addNotice(dto);
        //1、按訊息的 tab 型別來做,可以為每一種型別都新建一個Redis-Key來單獨存,這樣是可行的,從某種程度上來說是拆 Key
        //2、思考了一下還是用 Hash 結構,用 List 或者 String 可以解決型別的問題,但是難以按照使用者來取值
        HashOperations<String, String, Integer> hashOperations = redisTemplate.opsForHash();
        //由於一個小時會清除全部快取,當前未讀數量需要查資料庫來確認
        this.notificationService.checkUnReadCount(notification);
        if (NumberUtils.INTEGER_ONE.equals(notification.getSuperType()) && NumberUtils.INTEGER_ONE.equals(notification.getSubType())) {
            //業務系統產生的訊息,未讀訊息數+1;
            Long increment = hashOperations.increment(RedisKey.INITIAL_NOTICE_COMMENT_NUM_PERFIX, dto.getTargetUserUuid(), 1);
            log.info("新增的評論tab訊息數量:{}", increment);
        }
        if (NumberUtils.INTEGER_TWO.equals(dto.getSuperType())) {
            Long increment = hashOperations.increment(RedisKey.INITIAL_NOTICE_NUM_PERFIX, dto.getTargetUserUuid(), 1);
            log.info("新增的通知tab訊息數量:{}", increment);
        }
        //業務系統撤回訊息,未讀數-1
        if (NumberUtils.INTEGER_TWO.equals(dto.getSuperType())) {
            //Redis裡不存在去做自減一,得到的就是-1;所以一定有值,不用判空只需判斷正負
            Long num = hashOperations.increment(RedisKey.INITIAL_NOTICE_NUM_PERFIX, dto.getTargetUserUuid(), -1);
            log.info("撤回後通知tab訊息數量:{}", num);
            int intNUm = num.intValue();
            //為了避免出現負數,要拿0作為界限來比
            int result = intNUm < NumberUtils.INTEGER_ZERO ? NumberUtils.INTEGER_ZERO : intNUm;
            hashOperations.put(RedisKey.INITIAL_NOTICE_NUM_PERFIX, dto.getTargetUserUuid(), result);
        }
    }
    /**
     * 使用者加入購物車的商品計數
     */
    @Resource
    private ShoppingCarService shoppingCarService;
    @Test
    public void testUserShoppingCarInfo(){
        //首選方案:boundHashOps() 在使用上的主要區別就是需要先繫結 Redis-Key,方便後續操作;而 opsForHash() 則是直接運算元據
        String userUuid = "1656698374114156635";
        String gooId = "3523465836543623675";
        Long shopId = 34776547437357643L;
        //1、首先是入參DTO的資訊應該至少包含哪些?
        ShoppingCarGoodInfoDTO dto = ShoppingCarGoodInfoDTO.builder()
                .userUuid(userUuid).goodId(gooId)
                    .goodName("商品名稱").goodDesc("618專屬活動商品")
                .price(BigDecimal.valueOf(98.99D))
                .shopId(shopId).shopName("xx品牌旗艦店")
                .quantities(2).manufactureTime(1720146162L).build();
        ShoppingCar shoppingCar = dto.convertExt(ShoppingCar.class);
        //2、先入資料庫
        this.shoppingCarService.save(shoppingCar);
        //3、再入 redis:將使用者 uuid 作為 redis-key,goodId 作為 hash-key,商品的具體資訊為 hash-value
        BoundHashOperations<String, String, String> operations = redisTemplate.boundHashOps(userUuid);
        //這樣就是有多少個使用者,就有多少個Redis-Key;雖然一般來說用不完,也不會造成大 Key 問題,但數量多了無疑是對資源的一種巨大消耗,要考慮成本
        String goodInfo = JSON.toJSONString(shoppingCar);
        operations.put(gooId, goodInfo);
        //入 redis 的時候直接設定7天過期時間,這樣可以定期刪除 Key 保證空間
        operations.expire(Duration.ofDays(7));
        //計數
        Long size = operations.size();
        log.info("列印看下數量:{}", size);
        //todo: 更新購物車時也是先入資料庫,再更新 Redis,並設定過期時間;查詢時先查 Redis,沒有再查資料庫,然後重新寫入資料庫,設定過期時間
    }

四、高實時性

實時性要求高的場景,一般指的是:使用者在使用某個功能時,服務能夠近乎實時地提供結果。且併發量高時,如果每次都去請求資料庫,那麼所花費的開銷對系統來說無疑是種挑戰和壓力。如果將資料儲存在 Redis 中進行取用,那麼其響應速度將會是極快的。

下面舉 2 個例子:

  • 使用者在 app 客戶端發表的評論,需要實時地展示在評論區,這個場景對效能有較高的要求,用 Redis 可以做到即進即出,而且方便入資料庫;
  • 文章系統在編寫文章時會選用一些媒體資源如圖片、影片等,那麼對於媒資系統而言,將這些資料立即同步到媒資系統就十分有必要了;
    /**
     * 效能要求高,評論即進即出,而且可以入庫
     */
    @Test
    public void testCommentList(){
        TestCommentAddDTO dto = TestCommentAddDTO.builder()
                .parentId(NumberUtils.INTEGER_ZERO)
                .articleType(NumberUtils.INTEGER_ONE)
                .articleTitle("新聞06").articleId(12)
                .content("評論一下看看").creatorName("使用者375368")
                .creatorUuid("abc123def789UUID").createTime(new Date())
                .build();
        //評論佇列先入 Redis 快取,從佇列左邊進入,值得注意的是,List 結構只是表示佇列的形式,具體的資料結構是 String 型別的
        Long num = stringRedisTemplate.opsForList().leftPush(RedisKey.COMMENT_IMPORT_LIST, JSON.toJSONString(dto));
        //這裡返回的數量是該佇列的大小
        log.info("評論佇列先入 Redis 快取,數量為:{}", num);
        //經驗證,這裡的方法是根據Redis-Key 彈出(即刪除)全部的 Value,並且設定超時時間為 5 秒;leftPull 配合 rightPop 就是先進先出
        String str = stringRedisTemplate.opsForList().rightPop(RedisKey.COMMENT_IMPORT_LIST,5, TimeUnit.SECONDS);
        log.info("從右邊彈出全部 Redis 佇列快取的內容,內容為:{}", str);
        //反序列化解析
        TestCommentAddDTO result = Optional.ofNullable(str)
                .filter(StringUtils::isNotBlank)
                .map(val -> JSON.parseObject(val, TestCommentAddDTO.class))
                .orElse(null);
        log.info("列印一下:{}", result);
        //todo:接下來可以入資料庫
    }
    /**
     * 效能要求高,立即同步文章選用的媒體資訊到媒資系統
     */
    @Test
    public void testMediaSet(){
        ArrayList<String> imageList = new ArrayList<>();
        imageList.add("20240702165612_image_xxx_filename_Media.png");
        imageList.add("20240702164556_image_xxx_filename_Media.png");
        ArrayList<String> videoList = new ArrayList<>();
        videoList.add("20240702152319_video_xxx_filename_Media.mp4");
        ArticleMediaDTO dto = ArticleMediaDTO.builder()
                .articleId(123L)
                .articleTitle("測文章")
                .articleType(NumberUtils.INTEGER_TWO)
                .imageMediaId(imageList)
                .videoMediaId(videoList).build();
        //這裡每次只新增一條,但是需要保證整個佇列沒有重複的元素,故選擇Set
        stringRedisTemplate.opsForSet().add(RedisKey.INITIAL_ARTICLE_MEDIA_KEY_SET, JSON.toJSONString(dto));
        //這裡pop()是隨機彈出一個元素,由於每次都是及時彈出的,所以佇列裡有的話只會有一個,否則為空
        String str = stringRedisTemplate.opsForSet().pop(RedisKey.INITIAL_ARTICLE_MEDIA_KEY_SET);
        ArticleMediaDTO result = Optional.ofNullable(str)
                .filter(StringUtils::isNotBlank)
                .map(val -> JSON.parseObject(val, ArticleMediaDTO.class))
                .orElse(null);
        log.info("列印一下彈出的內容:{}", result);
        //todo: 接下來還可以與 MQ 配合進行通知操作
    }

五、排行榜系列

顧名思義,排行的場景很好理解,無論是 web 網頁應用還是 app 客戶端,都有很多需要排行的場景,如:

  • 使用者參與活動的成績排名
  • 使用者參與某個抽獎遊戲的積分排名
  • 使用者在 app 內的活躍度排名

而 Redis 提供的 ZSet 集合資料型別結構能很好地實現各種複雜的排行榜需求,下面舉一個 demo 來簡單實現。

    /**
     * 計算使用者成績排名,ZSet 的 Score 需要根據一個權重來生成,最終 Redis 就會根據 Score 來排序
     */
    @Test 
    public void testUserScoreRanking(){
        //1、首先看分數(平均分、總分、最高分),總之按照配置會得到有一個分數;如果是整數那麼就直接看用時,如果是小數會取小數點後兩位
        //2、如果分數相同,那麼比誰的用時少(平均用時、總用時、最短用時),總之會得到一個用時,時間統一都精確到毫秒
        //3、如果分數和用時都完全一樣,那麼就看交卷時間,誰的的交卷時間早誰就排前面,這裡的時間也精確到毫秒
        ScoreData scoreData = ScoreData.builder()
                .finalScore(82.36D)
                .spendTime(368956L)
                .submitTime(1719915961902L).build();
        Long activityId = UUIDUtils.generateUUIDToLong();
        String userUuid = UUIDUtils.generateUUID();
        //注意:分數取大,用時取小,這樣的組合無法組成權重;如果將用時取反,即剩餘時間,那麼剩餘時間取大,兩者都成正比就能形成一個權重了
        //具體:1、分數右移兩位,組成權重的整數部分;
        BigDecimal scoreBigDecimal = BigDecimal.valueOf(scoreData.getFinalScore()).movePointRight(NumberUtils.INTEGER_TWO);
        //2、用一天的毫秒數 - 用時毫秒樹 = 剩餘時間,剩餘時間小數點左移8位,組成權重的小數部分;
        long time = Constants.MAX_DAY_TIME - scoreData.getSpendTime();
        BigDecimal spendTimeBig = BigDecimal.valueOf(time).movePointLeft(8);
        //3、整數部分+小數部分,即為 ZSet 的權重
        BigDecimal weight = scoreBigDecimal.add(spendTimeBig);
        //Java 的 BigDecimal 類提供了任意精度的計算,能完全滿足對當代數學算術運算結果的精度要求,廣泛運用於金融、科學計算等領域。
        String rankingKey = RedisKey.INITIAL_ACTIVITY_PLAYER_SCORE_RANKING_KET.concat(".").concat(activityId.toString());
        //最後到這裡就可以寫進 ZSet 了
        stringRedisTemplate.opsForZSet().add(rankingKey, userUuid, weight.doubleValue());
    }

六、文章小結

到這裡本篇文章就結束了,關於在實際 Java 網際網路專案開發中常見的 Redis 快取應用場景其實還有很多,以上只是我做的一些總結。

今天的分享就到這裡,如有不足和錯誤,還請大家指正。或者你有其它想說的,也歡迎大家在評論區交流!

相關文章