技術派中的緩存一致性解決方案

大家好,我是樓仔呀。

之前寫過一篇《高頻面試:如何保障 MySQL 和 Redis 的數據一致性?》,閱讀量直奔 7K,但是裏面只有理論,沒有實戰,今天就結合技術派項目,告訴大家如何去實現 MySQL 和 Redis 的一致性。

在講解實戰部分之前,我們還是先回顧一下理論知識,根據網上的衆多解決方案,我們總結出 6 種:

你可以先想想,技術派會採用哪種方案呢?

理論知識

溫馨提示:如果你對理論知識已經非常清楚,可以直接跳到文章的實戰部分。

不好的方案

1. 先寫 MySQL,再寫 Redis

圖解說明:

  • 這是一副時序圖,描述請求的先後調用順序;

  • 橘黃色的線是請求 A,黑色的線是請求 B;

  • 橘黃色的文字,是 MySQL 和 Redis 最終不一致的數據;

  • 數據是從 10 更新爲 11;

  • 後面所有的圖,都是這個含義,不再贅述。

請求 A、B 都是先寫 MySQL,然後再寫 Redis,在高併發情況下,如果請求 A 在寫 Redis 時卡了一會,請求 B 已經依次完成數據的更新,就會出現圖中的問題。

這個圖已經畫的很清晰了,我就不用再去囉嗦了吧,不過這裏有個前提,就是對於讀請求,先去讀 Redis,如果沒有,再去讀 DB,但是讀請求不會再回寫 Redis。 大白話說一下,就是讀請求不會更新 Redis。

2. 先寫 Redis,再寫 MySQL

同 “先寫 MySQL,再寫 Redis”,看圖可秒懂。

3. 先刪除 Redis,再寫 MySQL

這幅圖和上面有些不一樣,前面的請求 A 和 B 都是更新請求,這裏的請求 A 是更新請求,但是請求 B 是讀請求,且請求 B 的讀請求會回寫 Redis。

請求 A 先刪除緩存,可能因爲卡頓,數據一直沒有更新到 MySQL,導致兩者數據不一致。

這種情況出現的概率比較大,因爲請求 A 更新 MySQL 可能耗時會比較長,而請求 B 的前兩步都是查詢,會非常快。

好的方案

4. 先刪除 Redis,再寫 MySQL,再刪除 Redis

對於 “先刪除 Redis,再寫 MySQL”,如果要解決最後的不一致問題,其實再對 Redis 重新刪除即可,這個也是大家常說的 “緩存雙刪”。

爲了便於大家看圖,對於藍色的文字,“刪除緩存 10”必須在 “回寫緩存 10” 後面,那如何才能保證一定是在後面呢?網上給出的第一個方案是,讓請求 A 的最後一次刪除,等待 500ms。

對於這種方案,看看就行,反正我是不會用,太 Low 了,風險也不可控。

那有沒有更好的方案呢,我建議異步串行化刪除,即刪除請求入隊列

異步刪除對線上業務無影響,串行化處理保障併發情況下正確刪除。

如果雙刪失敗怎麼辦,網上有給 Redis 加一個緩存過期時間的方案,這個不敢苟同。個人建議整個重試機制,可以藉助消息隊列的重試機制,也可以自己整個表,記錄重試次數,方法很多。

簡單小結一下:

  • “緩存雙刪” 不要用無腦的 sleep 500 ms;

  • 通過消息隊列的異步 & 串行,實現最後一次緩存刪除;

  • 緩存刪除失敗,增加重試機制。

5. 先寫 MySQL,再刪除 Redis

對於上面這種情況,對於第一次查詢,請求 B 查詢的數據是 10,但是 MySQL 的數據是 11,只存在這一次不一致的情況,對於不是強一致性要求的業務,可以容忍。(那什麼情況下不能容忍呢,比如秒殺業務、庫存服務等。)

當請求 B 進行第二次查詢時,因爲沒有命中 Redis,會重新查一次 DB,然後再回寫到 Reids。

這裏需要滿足 2 個條件:

對於第二個條件,我們都知道更新 DB 肯定比查詢耗時要長,所以出現這個情況的概率很小,同時滿足上述條件的情況更小。

6. 先寫 MySQL,通過 Binlog,異步更新 Redis

這種方案,主要是監聽 MySQL 的 Binlog,然後通過異步的方式,將數據更新到 Redis,這種方案有個前提,查詢的請求,不會回寫 Redis。

這個方案,會保證 MySQL 和 Redis 的最終一致性,但是如果中途請求 B 需要查詢數據,如果緩存無數據,就直接查 DB;如果緩存有數據,查詢的數據也會存在不一致的情況。

所以這個方案,是實現最終一致性的終極解決方案,但是不能保證實時性。

幾種方案比較

我們對比上面討論的 6 種方案:

  1. 先寫 Redis,再寫 MySQL
  1. 先寫 MySQL,再寫 Redis
  1. 先刪除 Redis,再寫 MySQL
  1. 先刪除 Redis,再寫 MySQL,再刪除 Redis
  1. 先寫 MySQL,再刪除 Redis
  1. 先寫 MySQL,通過 Binlog,異步更新 Redis

個人結論:

項目實戰

數據更新

因爲項目對實時性要求高,所以採用方案 5,先寫 MySQL,再刪除 Redis 的方式。

下面只是一個示例,我們將文章的標籤放入 MySQL 之後,再刪除 Redis,所有涉及到 DB 更新的操作都需要按照這種方式處理。

這裏加了一個事務,如果 Redis 刪除失敗,MySQL 的更新操作也需要回滾,避免查詢時讀取到髒數據。

@Override
@Transactional(rollbackFor = Exception.class)
public void saveTag(TagReq tagReq) {
    TagDO tagDO = ArticleConverter.toDO(tagReq);

    // 先寫 MySQL
    if (NumUtil.nullOrZero(tagReq.getTagId())) {
        tagDao.save(tagDO);
    } else {
        tagDO.setId(tagReq.getTagId());
        tagDao.updateById(tagDO);
    }

    // 再刪除 Redis
    String redisKey = CACHE_TAG_PRE + tagDO.getId();
    RedisClient.del(redisKey);
}

@Override
@Transactional(rollbackFor = Exception.class)
public void deleteTag(Integer tagId) {
    TagDO tagDO = tagDao.getById(tagId);
    if (tagDO != null){
        // 先寫 MySQL
        tagDao.removeById(tagId);

        // 再刪除 Redis
        String redisKey = CACHE_TAG_PRE + tagDO.getId();
        RedisClient.del(redisKey);
    }
}

@Override
public void operateTag(Integer tagId, Integer pushStatus) {
    TagDO tagDO = tagDao.getById(tagId);
    if (tagDO != null){

        // 先寫 MySQL
        tagDO.setStatus(pushStatus);
        tagDao.updateById(tagDO);

        // 再刪除 Redis
        String redisKey = CACHE_TAG_PRE + tagDO.getId();
        RedisClient.del(redisKey);
    }
}

數據獲取

這個也很簡單,先查詢緩存,如果有就直接返回;如果未查詢到,需要先查詢 DB ,再寫入緩存。

我們放入緩存時,加了一個過期時間,用於兜底,萬一兩者不一致,緩存過期後,數據會重新更新到緩存。

@Override
public TagDTO getTagById(Long tagId) {

    String redisKey = CACHE_TAG_PRE + tagId;

    // 先查詢緩存,如果有就直接返回
    String tagInfoStr = RedisClient.getStr(redisKey);
    if (tagInfoStr != null && !tagInfoStr.isEmpty()) {
        return JsonUtil.toObj(tagInfoStr, TagDTO.class);
    }

    // 如果未查詢到,需要先查詢 DB ,再寫入緩存
    TagDTO tagDTO = tagDao.selectById(tagId);
    tagInfoStr = JsonUtil.toStr(tagDTO);
    RedisClient.setStrWithExpire(redisKey, tagInfoStr, CACHE_TAG_EXPRIE_TIME);

    return tagDTO;
}

測試用例

/**
 * @author Louzai
 * @date 2023/5/5
 */
@Slf4j
public class MysqlRedisService extends BasicTest {

    @Autowired
    private TagSettingService tagSettingService;

    @Test
    public void save() {
        TagReq tagReq = new TagReq();
        tagReq.setTag("Java");
        tagReq.setTagId(1L);
        tagSettingService.saveTag(tagReq);
        log.info("save success:{}", tagReq);
    }

    @Test
    public void query() {
        TagDTO tagDTO = tagSettingService.getTagById(1L);
        log.info("query tagInfo:{}", tagDTO);
    }
}

我們看一下 Redis:

127.0.0.1:6379> get pai_cache_tag_pre_1
"{\"tagId\":1,\"tag\":\"Java\",\"status\":1,\"selected\":null}"

以及結果輸出:

後記

這篇文章很基礎,也非常適用,大家可以直接下載技術派項目,裏面都有代碼和測試用例,代碼倉庫詳見:https://github.com/itwanger/paicoding

後面我會把 RabbitMQ、ES、Nacos、MongoDB 和 prometheus 都集成到技術派項目,不爲其它的,存粹爲了自娛自樂。

先預告一下,技術派項目的消息通知,目前是通過 Spring 中 ApplicationEvent 完成,下一篇文章將會改造成 RabbitMQ 的方式,敬請期待!


最後,把樓仔的座右銘送給你:我從清晨走過,也擁抱夜晚的星辰,人生沒有捷徑,你我皆平凡,你好,陌生人,一起共勉。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UHANeIKe7YADZzuBKcqdgA