Feed 流設計(四):儲存
Feed 後臺設計文章索引
- ofollow,noindex" target="_blank">Feed 流設計(一):如何對多型內容進行抽象?
- Feed 流設計(二):拉模式 Vs 推模式
- Feed 流設計(三):分發邏輯
- Feed 流設計(四):儲存
在前幾篇文章中,我們討論了用 event 來抽象動態流中的內容,用推模式來提高讀的效率,用 policy 模式來控制分發邏輯。
假如 Ryan 有 3 個 followers,Teddy,Sam 和 Tim。 當 Ryan 釋出一個新內容(event)時,該內容要推送到他 followers 的佇列中。
這一篇文章,我們來討論 feed 的儲存。
1. Redis
Redis 是一個開源的 key-value,記憶體資料庫,支援多種資料結構,如 strings,hashes,lists,sets,sorted sets。其中 List 是一個連結串列,可以非常方便操作在首尾部插入/刪除元素。
LPUSH: 在list 的左邊插入一個元素。 LPOP:在list的左邊刪除一個元素。 RPUSH: 在list的右邊插入一個元素。 RPOP:在list的右邊刪除一個元素。 LRANGE: 獲取 list 一個範圍內的元素, e.g. 'LRANGE key 0 99' 命令可以獲取第1-100個元素。
1.1 如何使用 Redis 儲存 feed 中的內容呢?
當 Ryan 釋出內容時,我可以非常方便的把內容(event_id)推送到他的三個follower 的佇列中。
step1: Ryan 釋出內容 「我今天感覺好極了」,插入到表 statuses
中,主鍵為10001
// Ryan 的 id 為 1 // 內容儲存在 statuses 表中 insert into statuses (body, user_id) values ("我今天感覺好極了", 1);
step2: 把這個「建立操作」抽象為事件,查入道 event 表中,主鍵為 333333。
events 表的結構如下:
CREATE TABLE `events` ( `id`bigint(20) NOT NULL AUTO_INCREMENT, `user_id`int(11) DEFAULT NULL, `operation`varchar(255) DEFAULT NULL, `table_name`varchar(255) DEFAULT NULL, `table_id`bigint(20) DEFAULT NULL, `column_name` varchar(255) DEFAULT NULL, `old_state`varchar(255) DEFAULT NULL, `new_state`varchar(255) DEFAULT NULL, `type`varchar(255) DEFAULT NULL, `is_deleted`tinyint(1) DEFAULT '0', `created_at`datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
insert into events(user_id, operation, table_name, table_id, column_name, old_state, new_state, type) values (1, 'insert', 'statuses', 10001, '', '', 'CreateStatus')
step3:把 event_id 分發到 Teddy(user_id: 113),Sam(user_id: 114),Tim(user_id: 115) 的佇列中。
lpush 'user_id_113_follow_feed' 333333 lpush 'user_id_114_follow_feed' 333333 lpush 'user_id_115_follow_feed' 333333
step4: 當 Teddy 下次登入時,使用以下命令獲取 Teddy feed 中要展示的 events,然後渲染出來即可。
LRANGE user_id_113_follow_feed 0 99
當 Teddy 翻頁時,使用 LRANGE 選取下一個 range 即可。
LRANGE user_id_113_follow_feed 100 199
1.2 Redis 缺點
Feed 流中的內容時效性特別強,內容很快的生產,然後流行傳播,然後很快消散。熱門的資料紅的發紫,所有人都在看,比如當前王寶強和馬蓉的離婚事件,全民消費。一個月之後,沒有人再去關注它,這些內容就沉寂下來。
Redis 是記憶體資料庫,用來儲存熱資料非常棒,效能很高。但是 Feed 中大部分資料都會逐漸變冷,無人問津,如果一直儲存在記憶體裡,儲存的成本非常高。
簡言之,Redis 不適合儲存冷熱不均的資料。
2. SQL/">MySQL
Redis,RabbitMQ 天然的有佇列(queue)資料結構,然而 MySQL 並沒有,我們只能模擬佇列,其本質是一張關係表,建好索引,做好 partition,儘可能的提高讀的速度。
2.1 用資料庫中的表 follow_feed
來儲存「follower 訂閱的 events」。
CREATE TABLE `follow_feeds` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `event_id` int(11) NOT NULL, `routed_at` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
如何把內容儲存到訂閱者的佇列中?
follow_feed
// 分發給 Teddy (user_id: 113) insert follow_feeds (user_id, event_id) values (333333, 113) // 分發給 Tim(user_id: 115) insert follow_feeds (user_id, event_id) values (333333, 115)
當 Teddy(user_id: 113) 登入時,他通過這個查詢即可獲得自己佇列的內容,Ryan 的更新排在 Feed 第一的位置。
select * from follow_feeds where user_id = 113 order by event_id desc
當 Tim Tim(user_id: 115) 登入時,他通過這個查詢即可獲得自己佇列的內容,Ryan 的內容排在 Feed 第一的位置。
select * from follow_feeds where user_id = 115 order by event_id desc
當 Sam (user_id: 114) 登入時,看不到 Ryan 的更新。
select * from follow_feeds where user_id = 114 order by event_id desc
2.2 用表 tag_feeds
來儲存「與某個tag 有關的 events」。
CREATE TABLE `tag_feeds` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `tag_id` int(11) NOT NULL, `event_id` int(11) NOT NULL, `routed_at` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
如何把內容儲存到佇列中?
- 比如 Ryan 釋出了一條動態 「#RocksDB is Great」。
- RouteService.route! 在分發時發現含有 RocksDB 這個標籤。
- 如果滿足各種條件,就在 tag_feeds 裡面建立一條記錄,關聯 tag 與 event。
如何在佇列中讀取資料?
當用戶要檢視 #RocksDB 的feed,只需要2條sql簡單查詢既可以獲取所有的feed內容。
select * from tag_feeds where tag_id = 標籤的主鍵 order by event_id desc
2.3 用 object_feeds
來儲存「與某個 object 有關的 events」。
object 可以是一本書,一隻股票,一個工作機會。
CREATE TABLE `tag_feeds` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `object_id` int(11) NOT NULL, `object_type`varchar(255) DEFAULT NULL, `event_id` int(11) NOT NULL, `routed_at` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
如何把內容儲存到佇列中?
- 呂小榮建立了一個新內容,產生一個 event。
- RouteService.route! 在分發時,發現與某個 object 相關。
- 如果滿足各種條件,就在 object_feeds 裡面建立一條記錄,關聯 object 與 event。
比如 Ryan 寫了一篇書評 讀《21世紀的管理挑戰》有感 ,那麼就會有如下的關係被建立:
insert object_feeds (object_type, object_id, event_id) values ('Book', 1, 5)
如何從佇列中讀取資料?
當用戶檢視 《21世紀的管理挑戰》的書評頁面時,通過這個簡單的查詢即可獲得所有的內容。(e.g. demo)
select * from object_feeds where object_type = 'Book' and object_id = 1 order by event_id desc
2.4 用 profile_feeds
來儲存「與個人頁面相關的 events」
在 Facebook 每個人都有個人主頁,在這個頁面可以看到他說過的話,分享的視訊和圖片等等。 e.g.呂小榮的facebook主頁
如何把內容儲存到佇列中?
- 呂小榮建立了一個新內容,產生新的 event。
- RouteService.route! 根據 Policy 決定是否在 profile_feeds 建立一條記錄。
- 如果滿足條件,則關聯 event 與 作者。
如何從佇列中讀取資料?
當你訪問呂小榮的個人主頁時,通過這個簡單的查詢即可獲得所有的內容。
select * from profile_feeds where user_id = 1 order by event_id desc
2.4 用 mention_feeds
來儲存「@某個人的 events」
我們經常會在社交工具上 @somebody,對方就會收到通知。
如何把內容儲存到佇列中?
mention_feeds
如何從佇列中讀取資料?
當呂小榮下次登入時,通過這個簡單的查詢即可獲得所有 @呂小榮 的內容。
select * from mention_feeds where user_id = 1 order by event_id desc
2.5 對 MySQL 的總結
-
一個 event 可能會分發到多個表裡。
-
MySQL 並沒有佇列(queue),是用關係表在模擬 queue。
- 在各個關係表中讀取 feed 內容時,按 event_id 逆序排序,所以不需要擔心關係表的寫入順序。 e.g.
select * from mention_feeds where user_id = 1 order by event_id desc
-
相對 Redis,MySQL 對冷資料和熱資料的處理遊刃有餘。
-
很多人擔心容量問題,我覺得是多慮。主鍵為 big integer 單張表的最大容量是 10 billion billion。大部分人的業務並沒有到新浪微博/facebook的體量,使用 MySQL 綽綽有餘。如果確實體量很大,可以做單張表的partition,使用 hash partition 預先分幾十 partition 即可。
-
為了應對不同的業務場景,你可以建立各種各樣的關係表,非常靈活。
-
MySQL 的寫入效率是一個值得擔心的事情。
- 每個關係表的索引要建好。
3. 行業內其他人的做法
我待過的公司,使用者量最大的就2000+萬,所以並沒有經歷過 Facebook/微博/微信 那種體量。對於他們肯定有更高階的做法,已經超過我的想象力了。
阿里雲介紹 TableStore 在 Feed 流系統中的使用,Pinterest 用了 HBase。不管採用哪種設計,通過推的方式冗餘資料,提高讀的效率都是很有必要的。
Reference
-
pingineering.tumblr.com/post/105293275179/building-a-scalable-and-available-home-feed" rel="nofollow,noindex" target="_blank">Pinterest: Building a scalable and available home feed
長按二維碼,打賞我個微信紅包。

27 September 2018