應用效能設計的聖盃:讀寫擴散的概念與實踐

陶然陶然發表於2023-11-08

應用效能設計的聖盃:讀寫擴散的概念與實踐

來源:阿里雲開發者

阿里妹導讀

本文結合這三年作者在釘釘見到的應用架構,以及一些業界的實踐分享,整理出一篇關於應用讀寫擴散設計的維基。
應用程式設計師常常自嘲 "CRUD Boy"。也確實, 在應用開發的過程中,主要是在和各式各樣的儲存打交道,比如 MySQL, Tair, Odps 或者搜尋引擎等等,應用主要時間都是在執行各種各樣的 "SQL", 而不是在執行業務邏輯。
2020年剛來阿里(釘釘)時,第一次聽到同事們用 "讀擴散" 或者 "寫擴散" 來描述自己的架構方案, 瞬間眼前一亮, 覺得這兩個詞非常簡潔地概括了業務應用效能設計的實質。
所有的業務應用效能最佳化,無非就是根據業務場景,透過讀寫擴散,將資料額外寫擴散到一份效能更好的儲存,或者讀擴散減少資料冗餘。比如我們 Tair 快取,本質上就是將資料庫的資料額外寫擴散一份到 Tair,這樣後續讀取效能更好。
本文結合這三年我在釘釘見到的應用架構,以及一些業界的實踐分享,希望能整理出一篇關於應用讀寫擴散設計的維基。

概念的三層理解


基本理解: 經典讀寫擴散案例

讀擴散,指犧牲了讀的效能,去提升寫的效能。
而寫擴散,一般指犧牲了寫的效能,去提升讀的效能。
比如群發訊息的場景,就有兩種方案:

  • 方案一:往每個群員的收件箱中發一個訊息,這樣雖然要寫很多冗餘的資料,但是當每個群員閱讀訊息時,效能會好很多(因為只需要查詢自己的收件箱)。所謂 "寫擴散";
  • 方案二: 只往群聊中寫一條資料,每個群員都去群聊中拉取最新訊息,此時沒有資料冗餘,寫效能非常好,但是讀卻很容易產生熱點. 所謂 "讀擴散";

應用效能設計的聖盃:讀寫擴散的概念與實踐


資料庫的規範化和反規範化: 日常開發中的讀寫擴散

上文中使用者訊息訂閱場景是讀寫擴散的經典案例,比如 IM 或者 微博/Twitter 的 feeds 流訂閱。
但是大多數業務應用場景都跟訊息訂閱不太一樣,從資料庫的規範化和反規範化角度來看,能幫我們進一步理解日常業務開發中的讀寫擴散。
試想一個電商購物車系統的設計,根據購物車中商品資訊的儲存方式不同,我們又能得出兩個方案。
第一種方案, 在購物車表中只儲存一個商品 ID,每次使用者讀取時, 用購物車表去 join 商品表 (或者等價的,在應用中透過 rpc 去呼叫商品系統查詢),獲得商品名稱,價格等額外的商品資訊。
這種方案非常符合我們上學時學習的所謂資料庫 “三大正規化”,也稱為資料庫的 “規範化”。“三大正規化” 主張每張表都是原子的,不能含有不屬於該維度的冗餘資料,讀取時透過外來鍵 join 來獲取其他維度資料。所以“三大正規化”對於寫效能是非常友好的,但是讀取卻需要查詢大量其他維度的資料,非常符合上面對於 “讀擴散” 的定義。
第二種方案, 在購物車表中冗餘需要的商品資訊,比如商品名稱,商品價格等等,這樣在讀取時只需要讀取這一張表即可。但是對於寫入卻是災難,商品更新時,除了要更新商品表外,還要去更新冗餘了商品資訊的購物車表。
這種方案和“三大正規化” 理念相反, 所以被稱為 “反規範化”,它透過寫冗餘資料,提升了讀效能,非常符合上面對於 “寫擴散” 的定義。
"反規範化" 在網際網路中越來越受到歡迎, 主要有兩點原因:
第一點是很多場景讀多寫少,比如商品資訊可能每年才會更新一次,但是購物車卻每時每刻都在讀取,所以著力最佳化讀取效能,犧牲一點寫入也未嘗不可。
第二點是,很多新興的 no sql 資料庫為了提升水平擴充套件能力,都不再支援 join。它們主張一種叫做查詢驅動(query-driven)的庫表設計,即針對每一種查詢檢視,設計一張單獨的表。比如針對購物車檢視,我在 MongoDB 中單獨新建一個集合,集合中的每個文件就是完整的購物車記錄:

在 MongoDB 中, "集合" 相當於一張 "表", "文件" 相當於表中的 "一行"
{    // 購物車記錄主鍵    "id": "123456",    // 購物車中的商品冗餘資訊    "product": {        "name": "康師傅泡麵",        // 商品圖片        "pic": "商品價格        "price": 2    },    // 商品數量    "num": 10}
查詢驅動(query-driven)這個詞我最早是在 no sql 資料庫 Cassandra 的 官方文件 [參考1] 中讀到的, 這個詞簡潔地闡釋了所有 no sql 資料庫建模的本質。

應用效能設計的聖盃:讀寫擴散的概念與實踐

"規範化" 和 "反規範化" 也不是一個一刀切的概念, 隨著冗餘的資料越來越多 (比如我還額外冗餘了使用者的頭像,這樣就不用在購物車頁面額外查詢使用者系統了),讀效能會越來越好,寫效能會越來越差。另外,對於糟糕的軟體設計,讀寫效能會同時很差。

應用效能設計的聖盃:讀寫擴散的概念與實踐

上圖參考自 《HBase實戰》的第四章


應用的讀寫路徑: 不存在絕對的讀寫擴散

"讀寫擴散" 這個詞其實有一定誤導性,讓人覺得設計一個架構,肯定不是 "寫擴散",就是 "讀擴散"。
下文中我們發現不是這樣,不存在絕對的 "讀擴散" 和 "寫擴散",任何架構設計都是 "讀寫路徑" 的結合。
我們先來試想一下 "絕對的寫擴散" 是像什麼樣子。在 "絕對的寫擴散" 中,讀效能已經好了極致,而最好的讀效能就是 "不讀", 躺平等待推送:

應用效能設計的聖盃:讀寫擴散的概念與實踐

但是現實中沒有人會這麼做,首先讀者客戶端不一定總是線上給你寫,所以開發者總是會找個地方先暫存下寫入的內容,等讀者上線後,再去讀取。另外這麼無節制地寫入, 也容易超過讀者自身處理能力上限,把讀者寫掛。所以現實架構中總是需要讀者稍微做點 "努力" 去讀取內容的,典型的就是訊息佇列的架構:

應用效能設計的聖盃:讀寫擴散的概念與實踐

所以不管是有意還是無意,應用最後總是被設計成了讀路徑與寫路徑的結合,它們一般在某個持久化儲存中交匯(比如資料庫, 訊息佇列等等)。
所謂 "寫擴散",其實就是把寫路徑延長一些,讀路徑縮短一些。
而 "讀擴散" 相反,把讀路徑延長一點,寫路徑縮短一些。
以釘釘考勤的統計頁為例,使用者可以在此檢視不同週期內公司的打卡簽到情況:

應用效能設計的聖盃:讀寫擴散的概念與實踐

最初的方案是,每次使用者打卡只儲存一條打卡記錄,然後在老闆檢視統計頁時實時計算週期內的統計資料。這種方案雖然簡單,但是每到月初業務高峰期,就產生大量的慢 SQL,有些企業打卡記錄眾多導致 Full GC。這個方案本質上就是一個 "讀擴散",寫路徑很短,只需要寫入一條打卡記錄,代價就是讀路徑很長,需要做各種複雜的聚合統計。
既然 "讀路徑" 太長,我們就可以透過延長 "寫路徑",來縮短 "讀路徑"。比如在每次使用者打卡後,非同步更新當前週期的統計資料,或者定期地離線計算不同週期的資料,這樣就能大大降低讀取的壓力。

讀寫路徑的觀點參考自 《資料密集型應用系統設計》
釘釘考勤的案例參考自 @樂徐 的文件

寫擴散的使用場景

從上面考勤例子可以看出,一般專案初期會先使用相對簡單的 "讀擴散" 方案,後期隨著專案規模變大,遇到問題,才會採用適當的 "寫擴散" 去解決問題。
本節列舉了幾個我在業界和釘釘看到架構最佳化案例,希望在遇到類似場景時,能夠快速有個參考。

Twitter: 透過寫擴散分散讀熱點

該案例參考自 《HBase實戰》

Twitter 有一個正在關注列表的功能, 能夠按時間顯示使用者關注的人所發表的內容:

應用效能設計的聖盃:讀寫擴散的概念與實踐

最初 Twitter 採用的也是簡單的讀擴散的方案,每個作者只會將推文寫到自己的發表記錄中,粉絲實時去發表記錄表中讀取所有關注者的推文,並且按時間排序。
一開始這個方案不會有太大問題,但是後來 A 平臺出現了很多大 V,大 V 的粉絲量非常多,而 Twitter 底層的分散式儲存是按照使用者 ID 來分割槽,這就造成大 V 所在分割槽的讀熱點。

應用效能設計的聖盃:讀寫擴散的概念與實踐

後來 Twitter 還是改造成了寫擴散的方案,每個粉絲都有自己的收件箱,大 V 發表推文後,除了寫到自己的發表記錄中,還會非同步地寫入所有粉絲的收件箱。Twitter 的場景非常適合寫擴散,原因在於:

  • Twitter 在很長的時間裡嚴格限制了推文的長度, 最多隻允許 140 個字元,所以每條推文都不大;

  • Twitter 的使用者對延遲不太敏感,系統可以根據自己當前的水位,逐步將推文推送下去;

應用效能設計的聖盃:讀寫擴散的概念與實踐

釘釘審批首頁: 查詢維度過於複雜, 分頁不好設計

應用效能設計的聖盃:讀寫擴散的概念與實踐

釘釘審批首頁的 "全部審批單" 部分,會展示使用者在該企業所有可見的審批單。然而審批單可見性取決的因素非常多:

  • 員工本人是否有可見許可權;

  • 員工所處的部門是否可見;

  • 員工在企業中的角色是否可見;

  • 審批單是否是特殊的 "業務審批單",一些特殊的 "業務審批單" 在首頁對所有員工不可見,只有在特定釘釘應用才會展示;

這就導致審批首頁完全沒法做分頁查詢,只能一次性把所有審批單查詢出來,逐個判斷使用者是否可見,最後只把使用者可見的審批單返回給使用者,虛擬碼如下:

List result = new ArrayList();List 所有審批單 = 查詢企業所有審批單();for (審批單 : 所有審批單) {    if (判斷審批單對使用者是否可見(使用者ID, 審批單)) {        result.add(審批單);    }}return result;

可以看出讀路徑非常複雜,需要逐個審批單按照各種維度進行過濾,是典型的 "讀擴散" 方案。當企業模板數量非常多時,首頁的載入時間就會特別長,所以我們限制了企業的審批單模板數量不能超過 1000 個,對大企業非常不友好。

所以我們就計劃採用寫擴散進行最佳化,給每個使用者推送自己的可見模板列表,在模板可見性變更時,再修改推送的內容。這樣當使用者讀取時,分頁查詢就非常好做了。
不過代價就是會造成大量的資料冗餘,增加成本,一般方案都不會像上文寫的那樣簡單直接,還會使用多種手段最佳化寫擴散帶來的資料冗餘。後文中會再詳細闡述,針對寫擴散的缺陷,常見的最佳化思路。

審批首頁的案例參考自 @發靨 的文件

釘釘工作臺: 透過推送削峰填谷,降低伺服器壓力

應用效能設計的聖盃:讀寫擴散的概念與實踐

工作臺是釘釘的一個一級入口,因為釘釘的業務特點,有明顯的流量毛刺,業務高峰期一瞬間的請求量能是平時的 3-4 倍,影響系統的穩定性。
我們可以透過進一步縮短讀路徑來最佳化該場景。比如利用釘釘客戶端的能力,快取使用者上次開啟的工作臺介面,等到使用者再次開啟,直接顯示快取的頁面。然後請求服務端更新工作臺,這個請求立即返回。如果服務端發現該使用者的工作臺需要更新,再根據自己的當前負載,以穩定的速率將更新內容推送給客戶端,實現削峰填谷的效果。

應用效能設計的聖盃:讀寫擴散的概念與實踐

這個方案的缺陷就是,只適合那些能夠支援服務端推送的客戶端,比如釘釘。對於需要在瀏覽器開啟的網頁端應用來說,是無法實現的。

工作臺的案例參考自 @北集 的文件 

審批單搜尋: 業務功能需要特殊的儲存引擎實現

應用效能設計的聖盃:讀寫擴散的概念與實踐

審批在提供基本的功能之外,為了方便使用者使用,還支援按照關鍵字檢索審批單。MySQL 中的資料結構無法滿足靈活搜尋的需求,所以業務上會冗餘一份資料到專門的搜尋引擎,搜尋引擎採用搜尋友好的倒排索引來儲存資料,可以實現靈活搜尋的需求。
這裡也體現了前文所述的查詢驅動(query-driven)的設計原則,即每一份資料冗餘儲存,其實都是對應的一種資料查詢檢視。案例中就是專門冗餘了一份資料到搜尋引擎,來應對關鍵詞檢索的查詢檢視。
不過這也給我們帶來了多資料來源一致性的問題,下文中會再討論。

寫擴散的缺陷治理

上文為了方便理解,將寫擴散的方案都描述得很簡單,似乎就是多寫幾份資料,或者給每個使用者推送一份資料。雖然大體思路就是這樣,但是現實中的方案都要複雜很多,這些複雜性都是為了治理寫擴散帶來的資料不一致,延遲高,資料冗餘等缺陷。

實時性差

寫擴散雖然對於讀取更為有利,但是寫的效能也不能太差,所以冗餘資料的寫入常常是非同步的。這就導致寫者寫完後,讀者無法立馬讀到。這在分散式系統中叫做 讀自己寫(read-own-write) 問題。
不過這對於大多數應用來說,這都不是什麼問題,可以透過下面手段解決:

  • 首先使用者寫的資料大多不是給自己看,比如推文是給粉絲看的,使用者很難發覺其中的延遲;

  • 其次,即使寫入的資料是給自己看的,也可以在使用者提交完資料後,給使用者一個完成頁面,一定要使用者手動點選退出,才能看到自己寫入的資料。比如每次在淘寶提交訂單後,都會彈出一個 "訂單已完成" 頁面,要先點選退出,才能看到訂單列表;

資料一致性

基本上所有大規模應用都會碰到的問題,但是大家又不得不都做一遍這些老生常談的保障:

  • 資料對賬

  • 定期全量重新整理,糾正增量鏈路中可能存在的無法核對的錯誤

  • 冗餘資料無法寫入時(比如資料來源故障),記錄錯誤日誌,並實時同步到 odps,等到資料來源恢復後根據日誌再重新同步。

無效資料過多

上面的案例中,寫擴散總是從使用者維度切入,給每個使用者儲存一份資料。不禁讓人困惑,這資料量會不會太大了?而且大多數使用者根本就不會進入這個頁面,所以寫入的大多數資料都是無效的。
最佳化這兩個問題的方法常被稱做 "讀寫結合",本質就是在部分場景採用讀擴散減少資料冗餘,舉幾個例子:

  • 上文中的釘釘審批首頁案例,我們可以做好使用者分層。對於審批單模板數量較小的企業,還是採用讀擴散,只有在審批單資料達到一定規模後,才觸發寫擴散的方案。"寫擴散" 就變成了一個針對大客戶的 "高階服務方案",甚至可以引導使用者付費購買超額的模板數量。

  • 釘釘影片號中的大 V 擁有幾十萬的粉絲,大 V 一旦發一條影片,系統就需要在所有粉絲收件箱中推送這條記錄,造成了巨大的延遲和系統壓力。一個最佳化方案是,只給高活使用者收件箱進行推送(寫擴散),普通使用者等在下一次訪問時,才即時構建收件箱(讀擴散),這樣就能大大減少寫入的無效資料。
釘釘影片號的案例參考自 @定理 的文件

從讀寫擴散看業務發展的三階段

從上面的案例進一步總結,可以發現,我們甚至可以透過應用的讀寫擴散設計,看出業務當前的發展階段:

  • 第一階段:業務剛剛啟動,還處於探索與試錯期。應用傾向於使用讀擴散方案快速迭代試錯。

  • 第二階段:業務已經確定可行,很快就進入了快速規模增長期。之前快速迭代留下的坑,都隨著規模增長一一暴露。之前讀擴散的方案已經很難滿足業務要求,架構治理迫在眉睫,開發者們就會使用各種寫擴散的技術最佳化效能,以支援快速增長的規模。

  • 第三階段:業務的規模已經達到天花板,進入 業務瓶頸期。之前因為規模和營收還在快速增長,所以寫擴散帶來的儲存成本,看起來沒什麼。但是到了業務瓶頸期,寫擴散帶來的成本,已經沒法帶來相應的規模增長了。此時很多開發者們的 OKR 都會變成 “降成本”, 讀擴散的方案因為儲存成本低,在很多場景又會被重新提出,最終變成 “讀寫混合” 的方案。

應用效能設計的聖盃:讀寫擴散的概念與實踐

應用效能設計的聖盃:讀寫擴散的概念與實踐

上圖只是對於一個成功業務的一般情況,現實情況會更加複雜,可能有很多業務在探索期就死了,或者有的業務瓶頸不高,即使在瓶頸期,也可以透過讀擴散和加機器硬撐下去。

所以有句話說的沒錯,“應用架構也是業務現狀的一個反映”。

參考資料:

[參考1] : Cassandra 資料庫官方文件:

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024924/viewspace-2993264/,如需轉載,請註明出處,否則將追究法律責任。

相關文章