技術派中的快取一致性解決方案
來源:樓仔
大家好,我是樓仔呀。
之前寫過一篇《高頻面試:如何保障 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 個條件:
快取剛好自動失效; 請求 B 從資料庫查出 10,回寫快取的耗時,比請求 A 寫資料庫,並且刪除快取的還長。
對於第二個條件,我們都知道更新 DB 肯定比查詢耗時要長,所以出現這個情況的機率很小,同時滿足上述條件的情況更小。
6. 先寫 MySQL,透過 Binlog,非同步更新 Redis
這種方案,主要是監聽 MySQL 的 Binlog,然後透過非同步的方式,將資料更新到 Redis,這種方案有個前提,查詢的請求,不會回寫 Redis。
這個方案,會保證 MySQL 和 Redis 的最終一致性,但是如果中途請求 B 需要查詢資料,如果快取無資料,就直接查 DB;如果快取有資料,查詢的資料也會存在不一致的情況。
所以這個方案,是實現最終一致性的終極解決方案,但是不能保證實時性。
幾種方案比較
我們對比上面討論的 6 種方案:
先寫 Redis,再寫 MySQL
這種方案,我肯定不會用,萬一 DB 掛了,你把資料寫到快取,DB 無資料,這個是災難性的; 我之前也見同學這麼用過,如果寫 DB 失敗,對 Redis 進行逆操作,那如果逆操作失敗呢,是不是還要搞個重試?
先寫 MySQL,再寫 Redis
對於併發量、一致性要求不高的專案,很多就是這麼用的,我之前也經常這麼搞,但是不建議這麼做; 當 Redis 瞬間不可用的情況,需要報警出來,然後線下處理。
先刪除 Redis,再寫 MySQL
這種方式,我還真沒用過,直接忽略吧。
先刪除 Redis,再寫 MySQL,再刪除 Redis
這種方式雖然可行,但是感覺好複雜,還要搞個訊息佇列去非同步刪除 Redis。
先寫 MySQL,再刪除 Redis
比較推薦這種方式,刪除 Redis 如果失敗,可以再多重試幾次,否則報警出來; 這個方案,是實時性中最好的方案,在一些高併發場景中,推薦這種。
先寫 MySQL,透過 Binlog,非同步更新 Redis
對於異地容災、資料彙總等,建議會用這種方式,比如 binlog + kafka,資料的一致性也可以達到秒級; 純粹的高併發場景,不建議用這種方案,比如搶購、秒殺等。
個人結論:
實時一致性方案:採用“先寫 MySQL,再刪除 Redis”的策略,這種情況雖然也會存在兩者不一致,但是需要滿足的條件有點苛刻,所以是滿足實時性條件下,能儘量滿足一致性的最優解。 最終一致性方案:採用“先寫 MySQL,透過 Binlog,非同步更新 Redis”,可以透過 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}"
以及結果輸出:
後記
這篇文章很基礎,也非常適用,大家可以直接下載技術派專案,裡面都有程式碼和測試用例,程式碼倉庫詳見:
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70027824/viewspace-2950847/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 快取高一致性:Meta的快取失效解決方案快取
- 快取技術方案改造思考快取
- 資料庫與快取資料一致性解決方案資料庫快取
- 快取的三大方案以及解決方案快取
- Redis 快取雪崩,快取擊穿和快取穿透技術方案總結Redis快取穿透
- 快取穿透、快取擊穿、快取雪崩概念及解決方案快取穿透
- REDIS快取穿透,快取擊穿,快取雪崩原因+解決方案Redis快取穿透
- 【Redis】快取穿透,快取擊穿,快取雪崩及解決方案Redis快取穿透
- 快取世界中的三大問題及解決方案快取
- Redis 快取穿透、快取雪崩原理及解決方案Redis快取穿透
- 快取穿透詳解及解決方案快取穿透
- HTML5中快取技術HTML快取
- 快取技術快取
- Redis快取的主要異常及解決方案Redis快取
- 分散式快取--快取與資料庫一致性方案分散式快取資料庫
- Redis快取穿透/快取雪崩/快取擊穿(案例:產生的原因 解決方案利/弊)Redis快取穿透
- 快取熱點,快取穿透,終極解決方案看過來快取穿透
- 一文讀懂快取穿透、快取擊穿、快取雪崩及其解決方案快取穿透
- 天空分割技術解決方案
- 微信支付技術解決方案
- 前端常用的快取技術前端快取
- 快取常見問題及解決方案快取
- 快取三大問題及解決方案快取
- 快取穿透,快取擊穿,快取雪崩解決方案分析快取穿透
- jquery的get()函式快取問題解決方案jQuery函式快取
- PHP 中 9 大快取技術總結PHP快取
- 快取一致性和跨伺服器查詢的資料異構解決方案canal快取伺服器
- 快取穿透、快取擊穿、快取雪崩區別和解決方案快取穿透
- Python快取技術Python快取
- 位元組快取技術快取
- 快取技術淺談快取
- ASP快取技術 (轉)快取
- 有贊透明多級快取解決方案(TMC)快取
- redis快取相關問題及解決方案Redis快取
- Redis 快取擊穿、穿透、雪崩的原因以及解決方案Redis快取穿透
- PC端直播工具技術解決方案
- Redis系列 - 快取雪崩、擊穿、穿透及解決方案Redis快取穿透
- Redis快取穿透解決方案--布隆過濾器Redis快取穿透過濾器