在分散式並發系統中,資料庫與快取資料一致性是一項富有挑戰性的技術困難。假設有完善的工業級分散式事務解決方案,那麼資料庫與快取資料一致性便迎刃而解,實際上,目前分散式交易尚未成熟。
在資料庫與快取資料一致解決方式中,有各種聲音。
先操作資料庫後快取還是先快取後資料庫
#快取是更新還是刪除
在並發系統中,資料庫與快取雙寫場景下,為了追求更大的並發量,操作資料庫與快取顯而易見不會同步進行。前者操作成功後者以非同步的方式進行。
關係型資料庫作為成熟的工業級資料儲存方案,有完善的事務處理機制,資料一旦落盤,不考慮硬體故障,可以負責任的說資料不會遺失。
所謂緩存,無非是儲存在記憶體中的數據,服務一旦重啟,快取資料全部遺失。既然稱之為緩存,那麼時刻做好了快取資料遺失的準備。儘管Redis有持久化機制,是否能確保百分之百持久化? Redis將資料非同步持久化到磁碟有不可,快取是緩存,資料庫是資料庫,兩個不同的東西。把緩存當資料庫使用是一件極度危險的事。
從資料安全的角度來講,先操作資料庫,然後以非同步的方式操作緩存,回應使用者請求。
快取是更新還是刪除,對應懶漢式和飽漢式,從處理執行緒安全實踐來講,刪除快取操作相對難度較低。如果在刪除快取的前提下滿足了查詢效能,那麼優先選擇刪除快取。
更新快取儘管能夠提高查詢效率,然後帶來的線程並發髒資料處理起來較麻煩,序言引入MQ等其它訊息中間件,因此非必要不推薦。
理解執行緒並發所帶來問題的關鍵是先理解系統中斷,作業系統在任務調度時,中斷隨時都在發生,這是執行緒資料不一致產生的根源。以4和8線程CPU為例,同一時刻最多處理8個線程,然而操作系統管理的線程遠遠超過8個,因此線程們以一種看似並行的方式進行。
在非並發環境中,使用以下方式查詢資料並無不妥:先查詢緩存,如果快取資料不存在,查詢資料庫,更新緩存,返回結果。
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錯誤,重則資料庫壓力過大服務宕機。
因此在並發環境中,需要對上述程式碼進行修改,使用分散式鎖定。當大量請求湧入時,獲得鎖的線程有機會存取資料庫查詢數據,其餘線程阻塞。當查詢完資料並更新緩存,然後釋放鎖定。等待的線程重新檢查緩存,發現能夠獲取到數據,直接將緩存數據響應。
這裡提到分散式鎖定,那麼使用表鎖還是行鎖呢?使用分散式行鎖提高並發量;使用二次檢查機制,確保等待取得鎖定的執行緒能夠快速返回結果
@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); }
非並發環境中,如下程式碼儘管可能會產生資料不一致問題(資料被覆蓋)。儘管使用資料庫層面樂觀鎖能夠解決資料被覆蓋問題,然而無效更新流量依舊會流向資料庫。
public Boolean editOrder(BuOrder order) { /* 更新数据库 */ updateById(order); /* 删除缓存 */ RedisUtils.deleteObject(OrderServiceImpl.ORDER_KEY_PREFIX + order.getOrderId()); return true; }
上面分析中使用資料庫樂觀鎖定能夠解決並發更新中資料被覆蓋的問題,然而當同一行記錄被修改後,版本號發生改變,後續並發流向資料庫的請求為無效流量。減小資料庫壓力的首要策略是將無效流量攔截在資料庫之前。
使用分散式鎖能夠保證並發流量有序存取資料庫,考慮到資料庫層級已經使用了樂觀鎖,第二個及以後獲得鎖的執行緒操作資料庫為無效流量。
執行緒在取得鎖定時採用逾時退出的策略,等待取得鎖定的執行緒逾時快速退出,快速回應使用者請求,重試更新資料操作。
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)快取剛好失效
(2)請求A查詢資料庫,得一個舊值
(3)請求B將新值寫入資料庫
(4)請求B刪除快取
(5)請求A將查到的舊值寫入快取
上述並發問題出現的關鍵是第5步比第3、4步後發生,由作業系統中斷不確定因素可知,此種情況卻有發生的可能。
從實際情況來看,將資料寫入Redis遠比將資料寫入資料庫耗時短,儘管發生的機率較低,但仍會發生。
(1)增加快取過期時間
#增加快取過期時間允許一定時間範圍內臟資料存在,直到下次並發更新出現,可能會出現髒數據。髒數據會週期性存在。
(2)更新與查詢共用一把行鎖
#更新與查詢共用一把行分散式鎖,上述問題不復存在。當讀取請求取得到鎖定時,寫入請求處於阻塞狀態(逾時會快速失敗返回),能夠保證步驟5在步驟3之前進行。
(3)延遲刪除緩存
使用RabbitMQ延遲刪除緩存,並移除步驟5的影響。使用非同步的方式進行,幾乎不影響效能。
資料庫有事務機制保證操作成功與否;Redis單一指令具有原子性,然後組合起來卻不具備原子特徵,具體來說是資料庫操作成功,然後應用異常掛掉,導致Redis快取未刪除。 Redis服務網路連線逾時出現此問題。
如果設定有快取過期時間,那麼在快取尚未過期前,髒資料一直存在。如果未設定過期時間,那麼直到下一次修改資料前,髒資料一直存在。 (資料庫資料已改變,快取尚未更新)
在操作資料庫前,向RabbitMQ寫入延遲刪除快取的訊息,然後執行資料庫操作,執行快取刪除操作。不管程式碼層面快取是否刪除成功,MQ刪除快取作為保底操作。
以上是Java並發程式設計的資料庫與快取資料一致性方案是什麼?的詳細內容。更多資訊請關注PHP中文網其他相關文章!