System Design : Comment System
功能模塊
架構設計最重要的就是理解整個產品體系在系統中的定位。搞清楚系統背後的背景,才能做出最佳的設計和抽象。不要做需求的翻譯機,先理解業務背後的本質,事情的初衷。 評論系統,我們往小裡做就是視頻評論系統,往大裡做就是評論平台,可以接入各種業務形態。
- 發布評論: 支持回覆樓層、樓中樓。
- 讀取評論: 按照時間、熱度排序。
- 刪除評論: 用戶刪除、作者刪除。
- 管理評論: 作者置頂、後台運營管理(搜索、刪除、審核等)。
在動手設計前,反覆思考,真正編碼的時間只有5%。
架構設計
架構設計 overview
- BFF (Backend for Frontend): comment 複雜評論業務的服務編排,比如訪問賬號服務進行等級判定,同時需要在 BFF 面向移動端/WEB場景來設計 API,這一層抽象把評論的本身的內容列表處理(加載、分頁、排序等)進行了隔離,關注在業務平台化邏輯上。
- Service: comment-service 服務層,去平台業務的邏輯,專注在評論功能的 API 實現上,比如發布、讀取、刪除等,關注在穩定性、可用性上,這樣讓上游可以靈活組織邏輯把基礎能力和業務能力剝離。
- Job: comment-job 消息隊列的最大用途是消峰處理,

- Admin: comment-admin 管理平台,按照安全等級劃分服務,尤其劃分運營平台,他們會共享服務層的存儲層(MySQL、Redis)。運營體系的數據大量都是檢索,我們使用 canal 進行同步到 ES 中,整個數據的展示都是通過 ES,再通過業務主鍵更新業務數據層,這樣運營端的查詢壓力就下方給了獨立的 fulltext search 系統。
- Dependency: account-service、filter-service 整個評論服務還會依賴一些外部 gRPC 服務,統一的平台業務邏輯在 comment BFF 層收斂,這裡 account-service 主要是賬號服務,filter-service 是敏感詞過濾服務。
架構設計等同於數據設計,梳理清楚數據的走向和邏輯。盡量避免環形依賴、數據雙向請求等。
Source from : API Strategy: Architecture
comment service
comment-service,專注在評論數據處理(認真想下 Separation of Concerns)。 我們一開始是 comment-service 和 comment 是一層,業務耦合和功能耦合在一起,非常不利於迭代, 當然在設計層面可以考慮目錄結構進行拆分,但是架構層次來說,迭代隔離也是好的。
讀的核心邏輯:
Cache-Aside 模式,先讀取緩存,再讀取存儲。早期 cache rebuild 是做到服務裡的,
對於重建邏輯,一般會使用 read ahead 的思路,即預讀,用戶訪問了第一頁,很有可能訪問第二頁,
所以緩存會超前加載,避免頻繁 cache miss。
當緩存抖動是否,特別容易引起集群 hundering herd 現象,大量的請求會觸發 cache rebuild,因為使用了預加載,
容易導致服務 OOM。所以我們開到回源的邏輯裡,我們使用了消息隊列來進行邏輯異步化,對於當前請求只返回 mysql 中部分數據即止。

寫的核心邏輯:
我們擔心類似“明星出軌”等熱點事件的發生,而且寫和讀相比較,寫可以認為是透穿到存儲層的,系統的瓶頸往往就來自於存儲層,或者有狀態層。 對於寫的設計上,我們認為剛發布的評論有極短的延遲(通常小於幾 ms)對用戶可見是可接受的, 把對存儲的直接衝擊下放到消息隊列,按照消息反壓的思路, 即如果存儲 latency 升高,消費能力就下降,自然消息容易堆積,系統始終以最大化方式消費。
最常見的就是微博的熱搜,比如XX明星結婚/出軌。那麼關於XX明星的Key就會瞬間增大,就會出現熱資料問題。微博也時不時的來個崩潰。
Kafka 是存在 partition 概念的,可以認為是物理上的一個小隊列,一個 topic 是由一組 partition 組成的, 所以 Kafka 的吞吐模型理解為: 全局並行,局部串行的生產消費方式。 對於入隊的消息,可以按照 hash(comment_subject) % N(partitions) 的方式進行分發。 那麼某個 partition 中的 評論主題的數據一定都在一起,這樣方便我們串行消費。 同樣的,我們處理回源消息也是類似的思路
comment admin
mysql binlog 中的數據被 canal 中間件流式消費,獲取到業務的原始 CRUD 操作, 需要回放錄入到 es 中,但是 es 中的數據最終是面向運營體系提供服務能力,需要檢索的數據維度比較多, 在入 es 前需要做一個異構的 joiner,把單表變寬預處理好 join 邏輯,然後倒入到 es 中。
一般來說,運營後台的檢索條件都是組合的,使用 es 的好處是避免依賴 mysql 來做多條件組合檢索, 同時 mysql 畢竟是 oltp 面向線上交易處理的。 通過冗餘數據的方式,使用其他引擎來實現。 es 一般會存儲檢索、展示、primary key 等數據, 當我們操作編輯的時候,找到記錄的 primary key,最後交由 comment-admin 進行運營測的 CRUD 操作。 我們內部運營體系基本都是基於 es 來完成的。

comment
comment 作為 BFF,是面向端,面向平台,面向業務組合的服務。 所以平台擴展的能力,我們都在 comment 服務來實現,方便統一和准入平台,以統一的接口形式提供平台化的能力。
- 依賴其他 gRPC 服務,整合統一平台測的邏輯(比如發布評論用戶等級限定)。
- 直接向端上提供接口,提供數據的讀寫接口,甚至可以整合端上,提供統一的端上 SDK。
- 需要對非核心依賴的 gRPC 服務進行降級,當這些服務不穩定時。

存儲設計
數據庫設定
數據寫入
事務更新 comment_subject,comment_index,comment_content 三張表,
comment_subject_[0-49]: 文章主題, 預分配50張表(sharding, 分片)comment_index: 評論 indexcomment_content: 評論內容
其中 content 屬於非強制需要一致性考慮的。可以先寫入 content,之後事務更新其他表。即便 content 先成功,後續失敗僅僅存在一條 ghost 數據。
新增一筆評論 -> 先讀 comment_subject (SELECT ... FOR UPDATE; 行鎖) -> 寫 comment_subject 的 count -> 寫 comment_index
在SELECT 的讀取鎖定主要分為兩種方式:
- SELECT ... LOCK IN SHARE MODE
- SELECT ... FOR UPDATE 這兩種方式在事務(Transaction) 進行當中SELECT 到同一個數據表時,都必須等待其它事務數據被提交(Commit)後才會執行。
而主要的不同在於LOCK IN SHARE MODE 在有一方事務要Update 同一個表單時很容易造成死鎖。
簡單的說,如果SELECT 後面若要UPDATE 同一個表單,最好使用SELECT ... FOR UPDATE。
由於InnoDB 預設是Row-Level Lock,所以只有「明確」的指定主鍵,MySQL 才會執行Row lock (只鎖住被選取的數據) ,否則MySQL 將會執行Table Lock (將整個數據表單給鎖住)。
MySQL大致可歸納為以下3種鎖:
- 表級鎖:開銷小,加鎖快;不會出現死鎖;鎖定粒度大,發生鎖衝突的概率最高,並發度最低。
- 行級鎖:開銷大,加鎖慢;會出現死鎖;鎖定粒度最小,發生鎖衝突的概率最低,並發度也最高。
- 頁面鎖:開銷和加鎖時間界於表鎖和行鎖之間;會出現死鎖;鎖定粒度界於表鎖和行鎖之間,並發度一般
原文網址:https://kknews.cc/code/3apjm2a.html
數據讀取
基於 obj_id + obj_type 在 comment_index 表找到評論列表,WHERE root = 0 ORDER BY floor。
之後根據 comment_index 的 id 字段撈出 comment_content 的評論內容。
對於二級的子樓層,WHERE parent/root IN (id...)。
因為產品形態上只存在二級列表,因此只需要迭代查詢兩次即可。
對於嵌套層次多的,產品上,可以通過二次點擊支持。
是不是可以 Graph 存儲? DGraph、HugeGraph 類似的圖存儲思路。
comment_content.comment_id 直接拿 comment_index.id 來用
索引內容分離
comment_index: 評論樓層的索引組織表,實際並不包含內容。
comment_content: 評論內容的表,包含評論的具體內容。其中 comment_index 的 id 字段和 comment_content 是1對1的關係,這裡面包含幾種設計思想。
- 表都有主鍵,即 cluster index,是物理組織形式存放的,comment_content 沒有 id,是為了減少一次 二級索引查找,直接基於主鍵檢索,同時 comment_id 在寫入要盡可能的順序自增。
- 索引、內容分離,方便 mysql datapage 緩存更多的 row,如果和 context 耦合,會導致更大的 IO。長遠來看 content 信息可以直接使用 KV storage 存儲。
緩存設計

comment_subject_cache : [string]
對應主題的緩存,value 使用 protobuf 序列化的方式存入。我們早期使用 memcache 來進行緩存,因為 redis 早期單線程模型,吞吐能力不高。
comment_index_cache : [sorted set (zset) ]
使用 redis sortedset 進行索引的緩存,索引即數據的組織順序,而非數據內容。參考過百度的貼吧,他們使用自己研發的拉鍊存儲來組織索引,我認為 mysql 作為主力存儲,利用 redis 來做加速完全足夠,因為 cache miss 的構建,我們前面講過使用 kafka 的消費者中處理,預加載少量數據,通過增量加載的方式逐漸預熱填充緩存,而 redis sortedset skiplist 的實現,可以做到 O(logN) + O(M) 的時間複雜度,效率很高。 sorted set 是要增量追加的,因此必須判定 key 存在 (expire延長),才能 zadd。
comment_content_cache : [string]
對應評論內容數據,使用 protobuf 序列化的方式存入。類似的我們早期使用 memcache 進行緩存。
mget獲取評論內容
增量加載 + lazy 加載
可用性設計
scaling memcache at facebook Youtube: Scaling Memcache at Facebook(1)/Facebook如何增强memcache的收缩性-(1)/System Design-cache/系统设计-缓存 Scaling Memcache in Facebook 笔记(一) Scaling Memcache in Facebook 笔记(二) Scaling Memcache in Facebook 笔记(三)
SingleFlight
對於熱門的主題,如果存在How_to_Design_Redis_Cache#緩存穿擊的情況, 會導致大量的同進程、跨進程的數據回源到存儲層,可能會引起存儲過載的情況,
如何只交給同進程內,一個人去做加載存儲? 使用歸併回源的思路: https://pkg.go.dev/golang.org/x/sync/singleflight / prevent hostpot invaild
同進程只交給一個人去獲取 mysql 數據,然後批量返回。 同時這個 lease owner 投遞一個 kafka 消息,做 index cache 的 recovery 操作。 這樣可以大大減少 mysql 的壓力,以及大量穿擊導致的密集寫 kafka 的問題。
更進一步的,後續連續的請求,仍然可能會短時 cache miss,我們可以在進程內設置一個 short-lived flag, 標記最近有一個人投遞了 cache rebuild 的消息,直接 drop。
為什麼我們不用分佈式鎖之類的思路? 比較複雜,容易出問題
解法
- 我們可以從緩存的過期時間入口,將原來的固定過期時間,調整為過期時間=基礎時間+隨機時間,讓緩存慢慢過期,避免瞬間全部過期,對DB產生過大壓力。
- 異步定時更新
- 在緩存處理上,同理,比如某一個熱點數據的過期時間是1小時,那麼每59分鐘,通過定時任務去更新這個熱點key,並重新設置其過期時間。
- 互斥鎖
- 在緩存處理上,通常使用一個互斥鎖來解決緩存擊穿的問題。簡單來說就是當Redis中根據key獲得的value值為空時,先鎖上,然後從數據庫加載,加載完畢,釋放鎖。若其他線程也在請求該key時,發現獲取鎖失敗,則先阻塞。. 只有一個線程去後端查詢
- 但是會降低吞吐量
熱點
緩存熱點 流量熱點是因為突然熱門的主題(明星出軌),被高頻次的訪問, 因為底層的 cache 設計,一般是按照主題 key 進行一致性 hash 來進行分片, 但是熱點 key 一定命中某一個節點,這時候 remote cache 可能會變為瓶頸, 因此做 cache 的升級 local cache 是有必要的, 我們一般使用單進程自適應發現熱點的思路,附加一個短時的 ttl local cache,可以在進程內吞掉大量的讀請求。
在內存中使用 hashmap 統計每個 key 的訪問頻次, 這裡可以使用滑動窗口統計,即每個窗口中, 維護一個 hashmap,之後統計所有未過去的 bucket,匯總所有 key 的數據。 之後使用小堆計算 TopK 的數據,自動進行熱點識別。

解法
- 首先能先找到這個熱key來,比如通過Spark實時流分析,及時發現新的熱點key。
- 將集中化流量打散,避免一個緩存節點過載。由於只有一個key,我們可以在key的後面拼上有序編號,比如key#01、key#02。 。 。 key#10多個副本,這些加工後的key位於多個緩存節點上。
- 每次請求時,客戶端隨機訪問一個即可
可以設計一個緩存服務治理管理後台,實時監控緩存的SLA,並打通分佈式配置中心,對於一些hot key可以快速、動態擴容。