我所理解的Redis系列·第a篇·基於 Redis 優化訂單統計介面的解決方案
「這是我參與2022首次更文挑戰的第4天,活動詳情檢視:2022首次更文挑戰」
1. 場景描述
在客戶效能壓測現場支援時,遇到一個問題:使用者訂單狀態統計介面響應時間(RT, Response Time)較長,成為專案整體效能的瓶頸,亟需優化。
使用者訂單狀態統計介面是標品提供的能力,由於其業務複雜性及資料儲存結構的特殊性(具體內容下文描述),進行了多次 ES 的查詢,導致 RT 較長,影響整體效能。
1.1 業務邏輯描述
標品原有的儲存及查詢邏輯是這樣:
- 使用者下單,RDS 進行持久化
- DTS 監聽 bin log 變動,經由 Kafka 傳送訂單變更訊息
- 交易中心監聽並消費 Kafka 訊息,將訂單寫入 ES
- 當觸點端進行查詢時,交易中心直接查詢 ES 的訂單資料返回
整體鏈路時序圖如下:
```mermaid sequenceDiagram autonumber Touch Side ->> Trade Center: 變更訂單狀態 Trade Center ->> RDS: 持久化訂單資訊 RDS ->> Trade Center: 持久化完成 Trade Center ->> Touch Side: 訂單狀態變更完成
Note right of RDS: 訂單非同步寫入 ES 邏輯
RDS ->> DTS: 訂單變動資訊
DTS ->> Kafka: 投遞訂單變動訊息
Kafka ->> Trade Center: 訂閱訂單變動訊息
Trade Center ->> ES: 同步訂單資訊
Note right of Touch Side: 觸點端查詢訂單統計資訊邏輯
Touch Side ->> Trade Center: 查詢訂單統計資訊
Trade Center ->> ES: 查詢 ES
ES ->> Trade Center: 返回 ES 訂單資訊
Trade Center ->> Touch Side: 返回訂單統計資訊
```
1.2 核心問題
在標品原有的業務邏輯中,有這樣兩個問題是比較麻煩的:
1.2.1 訂單狀態對映關係
第一個是訂單狀態對映關係的複雜性,觸點端需要展示的訂單狀態有:待支付、待發貨、待收貨、已完成。這些觸點端訂單狀態是由多個持久化欄位的狀態共同決定的,其對映關係如下表:
| 觸點端訂單狀態 | 持久化欄位需滿足的狀態 | | | | -------------- | ---------------------- | -------- | -------- | | | 支付狀態 | 發貨狀態 | 收貨狀態 | | 待支付 | 待支付 | | | | 待發貨 | 已支付 | 待發貨 | | | 待收貨 | 已支付 | 已發貨 | 未收貨 | | 已完成 | | | 已收貨 |
Ps:私以為已完成狀態的判斷條件有待商榷,比如已完成,只要收貨狀態是已收貨就可以嗎?嚴格來說,已完成的狀態應該同時滿足:支付狀態為已支付,發貨狀態為已發貨,收貨狀態為已收貨。不應該有“收貨狀態為已收貨時必定意味著已支付、已發貨”這樣的約定,當約定太多時,可能會發生很多意料之外的情況。
1.2.2 ES 資料儲存結構
交易中心在向 ES 寫入資料時,其結構與 RDS 持久化的資料結構是一致的,也就是說,查詢 ES 無法直接獲得觸點端需要的訂單狀態,這也是在查詢訂單統計結果需要多次查詢 ES 的原因。
由於 ES 中儲存的資料與 RDS 持久化的資料結構,我們可以把 ES 看成是一個更快的 MySQL,當我們查詢使用者 id=1 的待收貨訂單數量時,SQL 語句如下:
sql
select count(*) from order where user_id = 1 and `支付狀態`='已支付' and `發貨狀態`='已發貨' and `收貨狀態`='未收貨';
而如果我們需要同時查詢到四種訂單狀態的數量,那麼只能通過四次查詢獲得:
``sql
-- 待支付
select count(*) from order where user_id = 1 and
支付狀態`='待支付';
-- 待發貨
select count(*) from order where user_id = 1 and 支付狀態
='已支付' and 發貨狀態
='待發貨';
-- 待收貨
select count(*) from order where user_id = 1 and 支付狀態
='已支付' and 發貨狀態
='已發貨' and 收貨狀態
='未收貨';
-- 已完成
select count(*) from order where user_id = 1 and 支付狀態
='已支付' and 發貨狀態
='已發貨' and 收貨狀態
='已收貨';
```
這就是整個使用者訂單統計結果介面 RT 較長的原因。
2. 解決方案
基於上述的業務及核心問題分析,我們提出了以下幾種解法:
2.1 修改 ES 儲存資料結構
每條資料新增一個訂單狀態欄位,然後再寫入 ES。
這樣就將原本要N次的查詢減少到了1次,並且僅需要將訂單狀態作為查詢條件即可。但是,基於種種無法描述的原因,這個方案被放棄了(主要的原因還是儘量不對標品做大改動)。
2.2 新增快取機制
在摒棄了第一種方案之後,我們又提出第二種解決方案:快取,對使用者訂單統計結果做快取。
這樣就不需要每次都對 ES 進行N次查詢然後再組裝結果集進行返回了。但是,快取的準確性和健壯性是一個需要考慮的問題:
- 如何在非同步場景下保證資料的準確性,不被例如重複消費的場景影響快取資料?
- 如何讓快取資料具備健壯性?即假設快取有誤,如何在沒有後臺干預的情況下讓快取資料能夠自我修正?
- 如何在特定場景下使得快取能夠持續命中(如效能壓測)?
- ……
這些都是在設計快取機制時需要考慮的問題。
3. 詳細設計
3.1 快取流程設計
在確定了基於快取對本場景進行優化的方向之後,首先需要確定的是優化後整體的業務流程,前面幾個步驟不變,還是:
- 使用者下單,RDS 進行持久化
- DTS 監聽 bin log 變動,經由 Kafka 傳送訂單變更訊息
- 交易中心監聽並消費 Kafka 訊息,將訂單寫入 ES
新增了同步快取的步驟:
- 交易中心將訂單資訊同步至快取
當觸點端進行訂單統計查詢時,首先查詢快取,需要分情況討論:
- 第一次查詢(快取未命中)
- 交易中心直接查詢 ES 的訂單資料
- 將查詢結果快取,快取分兩類
- 使用者訂單統計結果快取:快取了各狀態訂單數量
- 使用者訂單ID快取:快取了各狀態訂單ID值
- 返回查詢結果至觸點端
- 第N次查詢(快取命中),這裡也需要分情況討論
- 兩類快取一致
- 返回查詢結果至觸點端
- 兩類快取不一致
- 交易中心直接查詢 ES 的訂單資料
- 修正快取,將查詢結果更新至快取
- 返回查詢結果至觸點端
除此之外,我們還在使用者查詢指定狀態所有訂單時,對快取進行準確性校驗及補償:
- 觸點端查詢指定狀態所有訂單
- 交易中心查詢 ES 中指定狀態所有訂單
- 判斷與快取中資料是否一致
- 若不一致,對快取中的資料進行補償更新
- 返回指定狀態所有訂單至觸點端
```mermaid
sequenceDiagram autonumber Touch Side ->> Trade Center: 變更訂單狀態 Trade Center ->> RDS: 持久化訂單資訊 RDS ->> DTS: 訂單變動資訊 DTS ->> Kafka: 投遞訂單變動訊息 Kafka ->> Trade Center: 訂閱訂單變動訊息 Trade Center ->> ES: 同步訂單資訊 Trade Center ->> Redis: 同步使用者訂單資訊快取
Note right of Touch Side: 觸點端發起訂單統計查詢
Touch Side ->> Trade Center: 發起訂單統計查詢請求
Trade Center ->> Redis: 查詢使用者訂單統計資訊快取及使用者訂單ID快取
Redis ->> Trade Center: 快取命中
Trade Center ->> Trade Center: 快取資料一致性校驗
Trade Center ->> Touch Side: 校驗通過,返回訂單統計結果
Note right of Trade Center: 快取未命中或快取資料不一致的場景
Trade Center -->> ES: 查詢準確的訂單資訊
Trade Center -->> Redis: 資料補償及修正
Trade Center -->> Touch Side: 返回訂單統計結果
Note right of Touch Side: 觸點端發起查詢指定狀態所有訂單請求
Touch Side ->> Trade Center: 發起查詢指定狀態所有訂單請求
Trade Center ->> ES: 查詢指定狀態所有訂單
Trade Center ->> Redis: 查詢快取資訊
Trade Center ->> Trade Center: 快取資料準確性校驗
Trade Center ->> Touch Side: 快取準確性校驗通過,返回指定狀態所有訂單
Note right of Trade Center: 快取準確性校驗未通過的場景
Trade Center -->> Redis: 快取資料補償修正
Trade Center -->> Touch Side: 返回指定狀態所有訂單
```
3.2 Redis 資料結構設計
在客戶的專案中使用的快取中介軟體是 Redis。
在快取設計中提到,我們會將快取分為兩類,其含義及資料結構如下:
- 使用者訂單統計結果快取:快取了各狀態訂單數量
- Redis 資料結構(Hash)
- 鍵:UserOrderCountCache:userId
- 值:(訂單狀態:統計數量結果)
- 使用者訂單ID快取:快取了各狀態訂單ID值
- Redis 資料結構(Zset)
- 鍵:UserOrderDetailCache:userId:訂單狀態
- 值:(訂單ID)
3.3 補償機制設計
基於對快取的不信任,我們設計了兩種補償機制。
3.3.1 主動補償
- 查詢訂單統計資訊(Redis 快取內部修正)
```mermaid sequenceDiagram autonumber Touch Side ->> Trade Center: 傳送訂單統計查詢請求 Trade Center ->> ES: 第1次查詢 ES ->> Trade Center: 返回訂單統計查詢結果 Trade Center ->> Redis: 第N次查詢 Redis ->> Trade Center: 訂單ID快取與訂單統計結果快取校驗 Trade Center ->> Touch Side: 兩類快取一致,返回查詢結果 Note right of Trade Center: 兩類快取不一致的操作 Trade Center -->> ES: 查詢使用者所有訂單 Trade Center -->> Redis: 資料補償,更新快取 Note right of Redis: 更新訂單ID快取及訂單統計結果快取
Trade Center -->> Touch Side: 補償更新快取結束,返回訂單統計查詢結果
```
- 查詢指定狀態所有訂單(懶載入主動補償修正)
```mermaid sequenceDiagram autonumber Touch Side ->> Trade Center: 傳送查詢指定狀態所有訂單請求 Trade Center ->> ES: 查詢指定訂單狀態的訂單資訊 Trade Center ->> Redis: 查詢使用者訂單ID快取與統計結果快取 Trade Center ->> Trade Center: 快取準確性校驗 Trade Center ->> Touch Side: 快取準確,返回查詢結果 Note right of Trade Center: 快取不準確的操作 Trade Center -->> Redis: 資料補償,更新快取 Note right of Redis: 更新訂單ID快取及訂單統計結果快取
Trade Center -->> Touch Side: 補償更新快取結束,返回查詢指定狀態所有訂單結果
```
3.3.2 定時補償
基於定時任務,進行兩類快取的一致性判斷,若不一致查詢 ES 進行快取補償更新。
```mermaid sequenceDiagram autonumber Schedule ->> Trade Center: 定時任務啟動 Trade Center ->> Redis: 查詢快取 Redis ->> Trade Center: 使用者訂單ID快取與統計結果快取校驗 Trade Center ->> Schedule: 兩類快取一致,定時任務結束 Note right of Trade Center: 兩類快取不一致的操作 Trade Center -->> ES: 查詢使用者所有訂單 Trade Center -->> Redis: 資料補償,更新快取 Note right of Redis: 更新訂單ID快取及訂單統計結果快取
Trade Center -->> Schedule: 補償更新快取結束,定時任務結束
```
3.4 論證及說明
3.4.1 兩類快取是否過度設計
設計兩類快取的目的是為了防止重複消費。
假設有一條使用者下單的訊息被重複消費了,如果只設計了一種快取(訂單統計結果),那麼在消費 Kafka 訊息更新快取資料時,訂單統計結果將會+2,導致快取資料異常。
而如果按照現在的設計有兩種快取時,就算使用者訂單統計結果快取中資料異常,但是由於使用者訂單ID快取儲存的是訂單 ID,所以不會產生重複資料,能夠再使用者進行下一次查詢時識別到本次命中的快取資料是錯誤的,從而走 ES 的查詢。
3.4.2 對快取進行計算是否合理
可能有人會說,對快取結果進行計算是不安全的,假設不是重複消費而是在消費訊息時在某種情況下導致 ES 中同步了訂單資訊,但快取中資料丟失的情況怎麼辦?此時兩類快取的資料是一致的,並不會走 ES 查詢,這就導致了向觸點端返回了錯誤的資料。
確實,是存在這種可能的,所以當觸點端查詢指定狀態所有訂單時(如下圖的查詢所有代付款訂單),我們追加了一種主動補償機制。
這種主動補償機制是基於查詢指定狀態所有訂單的結果一定是準確的,其工作原理是在得到查詢結果後並不直接返回,而是對快取資料進行一次查詢並校驗準確性,如查詢結果與快取不一致,說明快取資料是錯誤的,需要對快取進行補償修正。
4. 主要場景時序圖
本部分時序圖主要是為了枚舉出所有業務場景,將交易中心基於各種場景需要對快取進行的操作一一列舉。
4.1 使用者下單(建立訂單)
省略 DTS/Kafka 等非核心節點
```mermaid sequenceDiagram autonumber Touch Side ->> Trade Center: 建立訂單 Trade Center ->> RDS: 持久化訂單資訊 Trade Center ->> ES: 同步訂單資訊 Trade Center ->> Redis: 更新快取資訊 Note right of Redis: 統計結果快取(待支付)+1 Note right of Redis: 訂單明細快取(待支付)增加OrderId
Trade Center ->> Touch Side: 返回下單結果
```
4.2 使用者支付(訂單狀態變更)
省略 DTS/Kafka 等非核心節點
```mermaid sequenceDiagram autonumber Touch Side ->> Trade Center: 使用者支付 Trade Center ->> RDS: 更新訂單狀態 Trade Center ->> ES: 同步訂單資訊 Trade Center ->> Redis: 更新快取資訊 Note right of Redis: 統計結果快取(待支付)-1,(待發貨)+1 Note right of Redis: 訂單明細快取(待支付)刪除OrderId,(待發貨)增加OrderId
Trade Center ->> Touch Side: 返回支付結果
```
4.3 商家發貨(訂單狀態變更)
省略 DTS/Kafka 等非核心節點
```mermaid sequenceDiagram autonumber Management Platform ->> Trade Center: 商家發貨 Trade Center ->> RDS: 更新訂單狀態 Trade Center ->> ES: 同步訂單資訊 Trade Center ->> Redis: 更新快取資訊 Note right of Redis: 統計結果快取(待發貨)-1,(待收貨)+1 Note right of Redis: 訂單明細快取(待發貨)刪除OrderId,(待收貨)增加OrderId
Trade Center ->> Management Platform: 返回發貨結果
```
4.4 使用者確認收貨(訂單狀態變更)
省略 DTS/Kafka 等非核心節點
```mermaid sequenceDiagram autonumber Touch Side ->> Trade Center: 使用者確認收貨 Trade Center ->> RDS: 更新訂單狀態 Trade Center ->> ES: 同步訂單資訊 Trade Center ->> Redis: 更新快取資訊 Note right of Redis: 統計結果快取(待收貨)-1,(已完成)+1 Note right of Redis: 訂單明細快取(待收貨)刪除OrderId,(已完成)增加OrderId
Trade Center ->> Touch Side: 返回確認收貨結果
```
4.5 使用者退貨(取消訂單)
省略 DTS/Kafka 等非核心節點
```mermaid sequenceDiagram autonumber Touch Side ->> Trade Center: 使用者取消訂單 Trade Center ->> RDS: 更新訂單狀態 Trade Center ->> ES: 同步訂單資訊 Trade Center ->> Redis: 更新快取資訊 Note right of Redis: 統計結果快取(原訂單狀態)-1 Note right of Redis: 訂單明細快取(原訂單狀態)刪除OrderId
Trade Center ->> Touch Side: 返回取消訂單結果
```
5. 兜底方案
- 定時任務:定時補償修正所有快取
- 開放補償介面:主動呼叫補償介面修正(指定/所有)快取
- 以批任務的形式防止出現慢 SQL 打爆資料庫導致服務不可用
至此,本次分享到此結束,感謝。最後,本文收錄於個人語雀知識庫: 我所理解的後端技術,歡迎來訪。
- 我所理解的Redis系列·第a篇·基於 Redis 優化訂單統計介面的解決方案
- 我所理解的Redis系列·第1篇·快取一致性問題的前世今生
- 我所理解的ThreadLocal
- 寫了近10W字關於MySQL的文章後,我整理了一份MySQL進階面試題
- 我所理解的MySQL(六)分庫分表與主從同步
- 我所理解的MySQL(五)鎖及加鎖規則
- 我所理解的MySQL(四)事務、隔離級別及MVCC
- 我所理解的MySQL(三)執行計劃
- 面試官:你真的理解String嗎
- RocketMQ.2-NameServer是如何啟動的
- RocketMQ.1-快速入門
- 記一次使用執行緒池出現的問題(執行緒池異常)
- MyBatis 查詢結果與 MySQL 執行結果不一致?
- JVM 異常表及 try-catch-finally 位元組碼分析
- MySQL 中 update 語句踩坑日記
- 關於 JVM 中原始型別 boolean 的討論
- JVM 類載入機制及初始化時機分析
- JVM 雙親委派模型及 SPI 實現分析