- 面對海量數據的計數器要如何做?
刷微博、點贊熱搜,如果有抽獎活動,再轉發一波,其實就是微博場景下的計數數據,細說起來,它主要有幾類:
- 微博的評論數、點贊數、轉發數、瀏覽數、表態數等等;
- 用戶的粉絲數、關注數、發布微博數、私信數等等。
微博維度的計數代表了這條微博受歡迎的程度,用戶維度的數據(尤其是粉絲數),代表了這個用戶的影響力,因此大家會普遍看重這些計數信息。並且在很多場景下都需要查詢計數數據(比如首頁信息流頁面、個人主頁面),計數數據訪問量巨大,所以需要設計計數系統維護它。
但在設計計數系統時,不少人會出現性能不高、存儲成本很大的問題,比如,把計數與微博數據存儲在一起,這樣每次更新計數的時候都需要鎖住這一行記錄,降低了寫入的並發。
計數在業務上的特點
- 數據量巨大。微博系統中微博條目的數量早已經超過了千億級別,僅僅計算微博的轉發、評論、點贊、瀏覽等核心計數,其數據量級就已經在幾千億的級別。更何況微博條目的數量還在不斷高速地增長,並且隨着微博業務越來越復雜,微博維度的計數種類也可能會持續擴展(比如說增加了表態數),因此,僅僅是微博維度上的計數量級就已經過了萬億級別。除此之外,微博的用戶量級已經超過了10億,用戶維度的計數量級相比微博維度來說雖然相差很大,但是也達到了百億級別。那么如何存儲這些過萬億級別的數字,對我們來說就是一大挑戰。
- 訪問量大,對於性能的要求高。微博的日活用戶超過2億,月活用戶接近5億,核心服務(比如首頁信息流)訪問量級到達每秒幾十萬次,計數系統的訪問量級也超過了每秒百萬級別,而且在性能方面,它要求要毫秒級別返回結果。
- 最后,對於可用性、數字的准確性要求高。一般來講,用戶對於計數數字是非常敏感的,比如你直播了好幾個月,才漲了1000個粉,突然有一天粉絲數少了幾百個,那么你是不是會琢磨哪里出現問題,或者打電話投訴直播平台?
支撐高並發的計數系統要如何設計
剛開始設計計數系統的時候,假如要存儲微博維度(微博的計數,轉發數、贊數等等)的數據,可以這么設計表結構:以微博ID為主鍵,轉發數、評論數、點贊數和瀏覽數分別為單獨一列,這樣在獲取計數時用一個SQL語句就搞定了。
select repost_count, comment_count, praise_count, view_count from t_weibo_count where weibo_id = ?
后來,隨着微博的不斷壯大,之前的計數系統面臨了很多的問題和挑戰。
比如微博用戶量和發布的微博量增加迅猛,計數存儲數據量級也飛速增長,而MySQL數據庫單表的存儲量級達到幾千萬的時候,性能上就會有損耗。所以考慮使用分庫分表的方式分散數據量,提升讀取計數的性能。
用“weibo_id”作為分區鍵,在選擇分庫分表的方式時,考慮了下面兩種:
- 一種方式是選擇一種哈希算法對weibo_id計算哈希值,然后依據這個哈希值計算出需要存儲到哪一個庫哪一張表中,具體的方式你可以回顧一下第9講數據庫分庫分表的內容;
- 另一種方式是按照weibo_id生成的時間來做分庫分表,ID的生成最好帶有業務意義的字段,比如生成ID的時間戳。所以在分庫分表的時候,可以先依據發號器的算法反解出時間戳,然后按照時間戳來做分庫分表,比如,一天一張表或者一個月一張表等等。
與此同時,計數的訪問量級也有質的飛越。在微博最初的版本中,首頁信息流里面是不展示計數數據的,那么使用MySQL也可以承受當時讀取計數的訪問量。但是后來在首頁信息流中也要展示轉發、評論和點贊等計數數據了。而信息流的訪問量巨大,僅僅靠數據庫已經完全不能承擔如此高的並發量了。於是考慮使用Redis來加速讀請求,通過部署多個從節點來提升可用性和性能,並且通過Hash的方式對數據做分片,也基本上可以保證計數的讀取性能。然而,這種數據庫+緩存的方式有一個弊端:無法保證數據的一致性,比如,如果數據庫寫入成功而緩存更新失敗,就會導致數據的不一致,影響計數的准確性。所以完全拋棄了MySQL,全面使用Redis來作為計數的存儲組件。
除了考慮計數的讀取性能之外,由於熱門微博的計數變化頻率相當高,也需要考慮如何提升計數的寫入性能。比如,每次在轉發一條微博的時候,都需要增加這條微博的轉發數,那么如果明星發布結婚、離婚的微博,瞬時就可能會產生幾萬甚至幾十萬的轉發。如果是你的話,要如何降低寫壓力呢?
可能已經想到用消息隊列來削峰填谷了,也就是說,我們在轉發微博的時候向消息隊列寫入一條消息,然后在消息處理程序中給這條微博的轉發計數加1。這里需要注意的一點, 可以通過批量處理消息的方式進一步減小Redis的寫壓力,比如像下面這樣連續更改三次轉發數(用SQL來表示來方便你理解):
UPDATE t_weibo_count SET repost_count = repost_count + 1 WHERE weibo_id = 1; UPDATE t_weibo_count SET repost_count = repost_count + 1 WHERE weibo_id = 1; UPDATE t_weibo_count SET repost_count = repost_count +1 WHERE weibo_id = 1;
這個時候,可以把它們合並成一次更新:
UPDATE t_weibo_count SET repost_count = repost_count + 3 WHERE weibo_id = 1;
如何降低計數系統的存儲成本
Redis是使用內存來存儲信息,相比於使用磁盤存儲數據的MySQL來說,存儲的成本不可同日而語,比如一台服務器磁盤可以掛載到2個T,但是內存可能只有128G,這樣磁盤的存儲空間就是內存的16倍。而Redis基於通用性的考慮,對於內存的使用比較粗放,存在大量的指針以及額外數據結構的開銷,如果要存儲一個KV類型的計數信息,Key是8字節Long類型的weibo_id,Value是4字節int類型的轉發數,存儲在Redis中之后會占用超過70個字節的空間,空間的浪費是巨大的。
對原生Redis做一些改造,采用新的數據結構和數據類型來存儲計數數據。
- 一是原生的Redis在存儲Key時是按照字符串類型來存儲的,比如一個8字節的Long類型的數據,需要8(sdshdr數據結構長度)+ 19(8字節數字的長度)+1(’\0’)=28個字節,如果我們使用Long類型來存儲就只需要8個字節,會節省20個字節的空間;
- 二是去除了原生Redis中多余的指針,如果要存儲一個KV信息就只需要8(weibo_id)+4(轉發數)=12個字節,相比之前有很大的改進。
同時使用一個大的數組來存儲計數信息,存儲的位置是基於weibo_id的哈希值來計算出來的,具體的算法像下面展示的這樣:
插入時: h1 = hash1(weibo_id) //根據微博ID計算Hash
h2 = hash2(weibo_id) //根據微博ID計算另一個Hash,用以解決前一個Hash算法帶來的沖突
for s in 0,1000 pos = (h1 + h2*s) % tsize //如果發生沖突,就多算幾次Hash2
if(isempty(pos) || isdelete(pos)) t[ pos ] = item //寫入數組
查詢時: for s in 0,1000 pos = (h1 + h2*s) % tsize //依照插入數據時候的邏輯,計算出存儲在數組中的位置
if(!isempty(pos) && t[pos]==weibo_id) return t[pos] return 0 刪除時: insert(FFFF) //插入一個特殊的標
微博的計數有轉發數、評論數、瀏覽數、點贊數等等,如果每一個計數都需要存儲weibo_id,那么總共就需要8(weibo_id)*4(4個微博ID)+4(轉發數) + 4(評論數) + 4(點贊數) + 4(瀏覽數)= 48字節。可以把相同微博ID的計數存儲在一起,這樣就只需要記錄一個微博ID,省掉了多余的三個微博ID的存儲開銷,存儲空間就進一步減少了。
微博計數的數據具有明顯的熱點屬性:越是最近的微博越是會被訪問到,時間上久遠的微博被訪問的幾率很小。所以為了盡量減少服務器的使用,我們考慮給計數服務增加SSD磁盤,然后將時間上比較久遠的數據dump到磁盤上,內存中只保留最近的數據。當我們要讀取冷數據的時候,使用單獨的I/O線程異步地將冷數據從SSD磁盤中加載到一塊兒單獨的Cold Cache中。
總結:
1、一開始用mysql進行計數,后來加入了主從架構,分庫分表架構。
2、因為計數訪問量太大了,加入了緩存,但是這個會造成相應的那個緩存和數據庫數據不一致,如果要保證一性的話,就需要采用內存隊列,對於同一個id的數量只能用單線程進行處理,這個會造成性能問題。
3、后來直接拋棄了mysql,直接用redis cluster來支持計數服務,因為redis通過rdb和aof來支持持久化,可以通過設置保證至少有一台從redis機器同步了數據,從redis來做相應的那個持久化操作達到數據不丟失,因為原生的redis數據結構會占用比較多的字節,這里直接進行改造,讓redis的數據結構占用內存加少。
4、但是redis是全內存的,隨着量越來越大肯定沒法支持了,這里進行改造,引入ssd,支持把冷數據放到ssd中,熱數據在內存中,當要訪問冷數據時利用一個線程異步把冷數據加載到一個cold cache里面去。這個有很多開源的實現,如Pika,SSDB用ssd來替代內存存儲冷數據。
50萬QPS下如何設計未讀數系統?
未讀數也是系統中一個常見的模塊,以微博系統為例,可看到有多個未讀計數的場景,比如:
- 當有人@你、評論你、給你的博文點贊或者給你發送私信的時候,你會收到相應的未讀提醒;
- 在早期的微博版本中有系統通知的功能,也就是系統會給全部用戶發送消息,通知用戶有新的版本或者有一些好玩的運營活動,如果用戶沒有看,系統就會給他展示有多少條未讀的提醒。
- 在瀏覽信息流的時候,如果長時間沒有刷新頁面,那么信息流上方就會提示你在這段時間有多少條信息沒有看。
第一個需求,要如何記錄未讀數呢?
可以在計數系統中增加一塊兒內存區域,以用戶ID為Key存儲多個未讀數,當有人@ 你時,增加你的未讀@的計數;當有人評論你時,增加你的未讀評論的計數,以此類推。當你點擊了未讀數字進入通知頁面,查看@ 你或者評論你的消息時,重置這些未讀計數為零。
那么系統通知的未讀數是如何實現的呢?能用通用計數系統實現嗎?答案是不能的,因為會出現一些問題。
系統通知的未讀數要如何設計
假如你的系統中只有A、B、C三個用戶,那么你可以在通用計數系統中增加一塊兒內存區域,並且以用戶ID為Key來存儲這三個用戶的未讀通知數據,當系統發送一個新的通知時,會循環給每一個用戶的未讀數加1,這個處理邏輯的偽代碼就像下面這樣:
List<Long> userIds = getAllUserIds(); for(Long id : userIds) { incrUnreadCount(id); }
似乎簡單可行,但隨着系統中的用戶越來越多,這個方案存在兩個致命的問題。
獲取全量用戶就是一個比較耗時的操作,相當於對用戶庫做一次全表的掃描,這不僅會對數據庫造成很大的壓力,而且查詢全量用戶數據的響應時間是很長的,對於在線業務來說是難以接受的。如果你的用戶庫已經做了分庫分表,那么就要掃描所有的庫表,響應時間就更長了。不過有一個折中的方法, 那就是在發送系統通知之前,先從線下的數據倉庫中獲取全量的用戶ID,並且存儲在一個本地的文件中,然后再輪詢所有的用戶ID,給這些用戶增加未讀計數。
這似乎是一個可行的技術方案,然而它給所有人增加未讀計數,會消耗非常長的時間。你計算一下,假如你的系統中有一個億的用戶,給一個用戶增加未讀數需要消耗1ms,那么給所有人都增加未讀計數就需要100000000 * 1 /1000 = 100000秒,也就是超過一天的時間;即使你啟動100個線程並發的設置,也需要十幾分鍾的時間才能完成,而用戶很難接受這么長的延遲時間。
另外,使用這種方式需要給系統中的每一個用戶都記一個未讀數的值,而在系統中,活躍用戶只是很少的一部分,大部分的用戶是不活躍的,甚至從來沒有打開過系統通知,為這些用戶記錄未讀數顯然是一種浪費。
通過上面的內容,你可以知道為什么我們不能用通用計數系統實現系統通知未讀數了吧?那正確的做法是什么呢?
要知道,系統通知實際上是存儲在一個大的列表中的,這個列表對所有用戶共享,也就是所有人看到的都是同一份系統通知的數據。不過不同的人最近看到的消息不同,所以每個人會有不同的未讀數。因此,你可以記錄一下在這個列表中每個人看過最后一條消息的ID,然后統計這個ID之后有多少條消息,這就是未讀數了。
這個方案在實現時有這樣幾個關鍵點:
- 用戶訪問系統通知頁面需要設置未讀數為0,我們需要將用戶最近看過的通知ID設置為最新的一條系統通知ID;
- 如果最近看過的通知ID為空,則認為是一個新的用戶,返回未讀數為0;
- 對於非活躍用戶,比如最近一個月都沒有登錄和使用過系統的用戶,可以把用戶最近看過的通知ID清空,節省內存空間。
這是一種比較通用的方案,即節省內存,又能盡量減少獲取未讀數的延遲。 這個方案適用的另一個業務場景是全量用戶打點的場景,比如像下面這張微博截圖中的紅點。
這個紅點和系統通知類似,也是一種通知全量用戶的手段,如果逐個通知用戶,延遲也是無法接受的。因此可以采用和系統通知類似的方案。
首先為每一個用戶存儲一個時間戳,代表最近點過這個紅點的時間,用戶點了紅點,就把這個時間戳設置為當前時間;然后也記錄一個全局的時間戳,這個時間戳標識最新的一次打點時間,如果你在后台操作給全體用戶打點,就更新這個時間戳為當前時間。而在判斷是否需要展示紅點時,只需要判斷用戶的時間戳和全局時間戳的大小,如果用戶時間戳小於全局時間戳,代表在用戶最后一次點擊紅點之后又有新的紅點推送,那么就要展示紅點,反之,就不展示紅點了
這兩個場景的共性是全部用戶共享一份有限的存儲數據,每個人只記錄自己在這份存儲中的偏移量,就可以得到未讀數了。
你可以看到,系統消息未讀的實現方案不是很復雜,它通過設計避免了操作全量數據未讀數,如果你的系統中有這種打紅點的需求,那我建議你可以結合實際工作靈活使用上述方案。
最后一個需求關注的是微博信息流的未讀數,在現在的社交系統中,關注關系已經成為標配的功能,而基於關注關系的信息流也是一種非常重要的信息聚合方式,因此,如何設計信息流的未讀數系統就成了你必須面對的一個問題。
如何為信息流的未讀數設計方案
信息流的未讀數之所以復雜主要有這樣幾點原因。
- 首先,微博的信息流是基於關注關系的,未讀數也是基於關注關系的,就是說,你關注的人發布了新的微博,那么你作為粉絲未讀數就要增加1。如果微博用戶都是像我這樣只有幾百粉絲的“小透明”就簡單了,你發微博的時候系統給你粉絲的未讀數增加1不是什么難事兒。但是對於一些動輒幾千萬甚至上億粉絲的微博大V就麻煩了,增加未讀數可能需要幾個小時。假設你是楊冪的粉絲,想了解她實時發布的博文,那么如果當她發布博文幾個小時之后,你才收到提醒,這顯然是不能接受的。所以未讀數的延遲是你在涉及方案時首先要考慮的內容。
- 其次,信息流未讀數請求量極大、並發極高,這是因為接口是客戶端輪詢請求的,不是用戶觸發的。也就是說,用戶即使打開微博客戶端什么都不做,這個接口也會被請求到。在幾年前,請求未讀數接口的量級就已經接近每秒50萬次,這幾年隨着微博量級的增長,請求量也變得更高。而作為微博的非核心接口,我們不太可能使用大量的機器來抗未讀數請求,因此,如何使用有限的資源來支撐如此高的流量是這個方案的難點。
- 最后,它不像系統通知那樣有共享的存儲,因為每個人關注的人不同,信息流的列表也就不同,所以也就沒辦法采用系統通知未讀數的方案。
那要如何設計能夠承接每秒幾十萬次請求的信息流未讀數系統呢?你可以這樣做:
- 首先,在通用計數器中記錄每一個用戶發布的博文數;
- 然后在Redis或者Memcached中記錄一個人所有關注人的博文數快照,當用戶點擊未讀消息重置未讀數為0時,將他關注所有人的博文數刷新到快照中;
- 這樣,他關注所有人的博文總數減去快照中的博文總數就是他的信息流未讀數。
假如用戶A,像上圖這樣關注了用戶B、C、D,其中B發布的博文數是10,C發布的博文數是8,D發布的博文數是14,而在用戶A最近一次查看未讀消息時,記錄在快照中的這三個用戶的博文數分別是6、7、12,因此用戶A的未讀數就是(10-6)+(8-7)+(14-12)=7。
這個方案設計簡單,並且是全內存操作,性能足夠好,能夠支撐比較高的並發,事實上微博團隊僅僅用16台普通的服務器就支撐了每秒接近50萬次的請求,這就足以證明這個方案的性能有多出色,因此,它完全能夠滿足信息流未讀數的需求。
當然了這個方案也有一些缺陷,比如說快照中需要存儲關注關系,如果關注關系變更的時候更新不及時,那么就會造成未讀數不准確;快照采用的是全緩存存儲,如果緩存滿了就會剔除一些數據,那么被剔除用戶的未讀數就變為0了。但是好在用戶對於未讀數的准確度要求不高(未讀10條還是11條,其實用戶有時候看不出來),因此,這些缺陷也是可以接受的。