資料庫與快取資料一致性解決方案

Java知識圖譜發表於2022-03-15

一、序言

在分散式併發系統中,資料庫與快取資料一致性是一項富有挑戰性的技術難點。本文將討論資料庫與快取資料一致性問題,並提供通用的解決方案。

假設有完善的工業級分散式事務解決方案,那麼資料庫與快取資料一致性便迎刃而解,實際上,目前分散式事務不成熟。

二、不同的聲音

在資料庫與快取資料一致解決方式中,有各種聲音。

  • 先運算元據庫後快取還是先快取後資料庫
  • 快取是更新還是刪除
1、操作的先後順序

在併發系統中,資料庫與快取雙寫場景下,為了追求更大的併發量,運算元據庫與快取顯而易見不會同步進行。前者操作成功後者以非同步的方式進行。

關係型資料庫作為成熟的工業級資料儲存方案,有完善的事務處理機制,資料一旦落盤,不考慮硬體故障,可以負責任的說資料不會丟失。

所謂快取,無非是儲存在記憶體中的資料,服務一旦重啟,快取資料全部丟失。既然稱之為快取,那麼時刻做好了快取資料丟失的準備。儘管Redis有持久化機制,是否能夠保證百分之百持久化?Redis將資料非同步持久化到磁碟有不可,快取是快取,資料庫是資料庫,兩個不同的東西。把快取當資料庫使用是一件極其危險的事情。

從資料安全的角度來講,先運算元據庫,然後以非同步的方式操作快取,響應使用者請求。

2、處理快取的態度

快取是更新還是刪除,對應懶漢式飽漢式,從處理執行緒安全實踐來講,刪除快取操作相對難度低一些。如果在刪除快取的前提下滿足了查詢效能,那麼優先選擇刪除快取。

更新快取儘管能夠提高查詢效率,然後帶來的執行緒併發髒資料處理起來較麻煩,序言引入MQ等其它訊息中介軟體,因此非必要不推薦。

三、執行緒併發分析

理解執行緒併發所帶來問題的關鍵是先理解系統中斷,作業系統在任務排程時,中斷隨時都在發生,這是執行緒資料不一致產生的根源。以4和8執行緒CPU為例,同一時刻最多處理8個執行緒,然而作業系統管理的執行緒遠遠超過8個,因此執行緒們以一種看似並行的方式進行。

(一)查詢資料

1、非併發環境

在非併發環境中,使用如下方式查詢資料並無不妥:先查詢快取,如果快取資料不存在,查詢資料庫,更新快取,返回結果。

public BuOrder getOrder(Long orderId) {
    String key = ORDER_KEY_PREFIX + orderId;
    BuOrder buOrder = RedisUtils.getObject(key, BuOrder.class);
    if (buOrder != null) {
        return buOrder;
    }
    BuOrder order = getById(orderId);
    RedisUtils.setObject(key, order, 5, TimeUnit.MINUTES);
    return order;
}

如果在高併發環境中有一個嚴重缺陷:當快取失效時,大量查詢請求湧入,瞬間全部打到DB上,輕則資料庫連線資源耗盡,使用者端響應500錯誤,重則資料庫壓力過大服務當機。

2、併發環境

因此在併發環境中,需要對上述程式碼進行修改,使用分散式鎖。大量請求湧入時,獲得鎖的執行緒有機會訪問資料庫查詢資料,其餘執行緒阻塞。當查詢完資料並更新快取,然後釋放鎖。等待的執行緒重新檢查快取,發現能夠獲取到資料,直接將快取資料響應。

這裡提到分散式鎖,那麼使用表鎖還是行鎖呢?使用分散式行鎖提高併發量;使用二次檢查機制,確保等待獲得鎖的執行緒能夠快速返回結果

@Override
public BuOrder getOrder(Long orderId) {
    /* 如果快取不存在,則新增分散式鎖更新快取 */
    String key = ORDER_KEY_PREFIX + orderId;
    BuOrder order = RedisUtils.getObject(key, BuOrder.class);
    if (order != null) {
        return order;
    }
    String orderLock = ORDER_LOCK + orderId;
    RLock lock = redissonClient.getLock(orderLock);
    if (lock.tryLock()) {
        order = RedisUtils.getObject(key, BuOrder.class);
        if (order != null) {
            LockOptional.ofNullable(lock).ifLocked(RLock::unlock);
            return order;
        }
        BuOrder buOrder = getById(orderId);
        RedisUtils.setObject(key, buOrder, 5, TimeUnit.MINUTES);
        LockOptional.ofNullable(lock).ifLocked(RLock::unlock);
    }
    return RedisUtils.getObject(key, BuOrder.class);
}

(二)更新資料

1、非併發環境

非併發環境中,如下程式碼儘管可能會產生資料不一致問題(資料被覆蓋)。儘管使用資料庫層面樂觀鎖能夠解決資料被覆蓋問題,然而無效更新流量依舊會流向資料庫。

public Boolean editOrder(BuOrder order) {
    /* 更新資料庫 */
    updateById(order);
    /* 刪除快取 */
    RedisUtils.deleteObject(OrderServiceImpl.ORDER_KEY_PREFIX + order.getOrderId());
    return true;
}
2、併發環境

上面分析中使用資料庫樂觀鎖能夠解決併發更新中資料被覆蓋的問題,然而當同一行記錄被修改後,版本號發生改變,後續併發流向資料庫的請求為無效流量。減小資料庫壓力的首要策略是將無效流量攔截在資料庫之前。

使用分散式鎖能夠保證併發流量有序訪問資料庫,考慮到資料庫層面已經使用了樂觀鎖,第二個及以後獲得鎖的執行緒運算元據庫為無效流量。

執行緒在獲得鎖時採用超時退出的策略,等待獲得鎖的執行緒超時快速退出,快速響應使用者請求,重試更新資料操作。

public Boolean editOrder(BuOrder order) {
    String orderLock = ORDER_LOCK + order.getOrderId();
    RLock lock = redissonClient.getLock(orderLock);
    try {
        /* 超時未獲取到鎖,快速失敗,使用者端重試 */
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
            /* 更新資料庫 */
            updateById(order);
            /* 刪除快取 */
            RedisUtils.deleteObject(OrderServiceImpl.ORDER_KEY_PREFIX + order.getOrderId());
            /* 釋放鎖 */
            LockOptional.ofNullable(lock).ifLocked(RLock::unlock);
            return true;
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return false;
}

(三)依賴環境

上述程式碼使用了封裝鎖的工具類。

<dependency>
  <groupId>xin.altitude.cms</groupId>
  <artifactId>ucode-cms-common</artifactId>
  <version>1.4.3.2</version>
</dependency>

LockOptional根據鎖的狀態執行後續操作。

四、先資料庫後快取

(一)資料一致性

1、問題描述

接下來討論先更新資料庫,後刪除快取是否存在併發問題。

(1)快取剛好失效
(2)請求A查詢資料庫,得一箇舊值
(3)請求B將新值寫入資料庫
(4)請求B刪除快取
(5)請求A將查到的舊值寫入快取

上述併發問題出現的關鍵是第5步比第3、4步後發生,由作業系統中斷不確定因素可知,此種情況卻有發生的可能。

2、解決方式

從實際情況來看,將資料寫入Redis遠比將資料寫入資料庫耗時要短,儘管發生的概率較低,但仍會發生。

(1)增加快取過期時間

增加快取過期時間允許一定時間範圍內臟資料存在,直到下一次併發更新出現,可能會出現髒資料。髒資料會週期性存在。

(2)更新和查詢共用一把行鎖

更新和查詢共用一把行分散式鎖,上述問題不復存在。當讀請求獲取到鎖時,寫請求處於阻塞狀態(超時會快速失敗返回),能夠保證步驟5在步驟3之前進行。

(3)延遲刪除快取

使用RabbitMQ延遲刪除快取,去除步驟5的影響。使用非同步的方式進行,幾乎不影響效能。

(二)特殊情況

資料庫有事務機制保證操作成功與否;Redis單條指令具有原子性,然後組合起來卻不具備原子特徵,具體來說是資料庫操作成功,然後應用異常掛掉,導致Redis快取未刪除。Redis服務網路連線超時出現此問題。

如果設定有快取過期時間,那麼在快取尚未過期前,髒資料一直存在。如果未設定過期時間,那麼直到下一次修改資料前,髒資料一直存在。(資料庫資料已經發生改變,快取尚未更新)

解決方式

在運算元據庫前,向RabbitMQ寫入一條延遲刪除快取的訊息,然後執行資料庫操作,執行快取刪除操作。不管程式碼層面快取是否刪除成功,MQ刪除快取作為保底操作。

五、小結

上述方式提供的資料庫與快取資料一致性解決方式,屬於耦合版,當然還有訂閱binlog日誌的解耦版。解耦版由於增加了訂閱binlog元件,對系統穩定性提出更高的要求。

資料庫與快取一致性問題看似是解決資料問題,實質上解決併發問題:在儘可能保證更多併發量的前提下,在保證資料庫安全的前提下,保證資料庫與快取資料一致。

相關文章