背景
我們使用MySQL儲存了FriendFeed的所有資料。資料庫隨著使用者基數的增長而增長了很多。現在已經儲存了超過2.5億條記錄與一堆涵蓋了從評論和“喜歡”到好友名單的其他資料。
隨著資料的增長,我們也曾迭代地解決了隨著如此迅猛的增長而帶來的擴充性問題。我們的嘗試很有代表性,例如使用唯讀mysql從節點和memcache來增加讀取輸送量,對資料庫進行分區來提高寫入輸送量。然而,隨著業務的增長,添加新功能比擴充既有功能以迎合更多的流量變得更加困難。
特別的,對 schema 做改動或為超過 1000-2000 萬行記錄的資料庫添加索引會將資料庫鎖住幾個小時。刪除舊索引也要佔用這麼多時間,但不刪除它們會影響效能;因為資料庫要持續地在每個INSERT上讀寫這些沒用的區塊,並將重要的區塊擠出了記憶體。為避免這些問題需要採取一些複雜的措施(例如在從節點上設定新的索引,然後將從節點與主節點對調),但這些措施會引發錯誤並且實施起來比較困難,它們阻礙了需要改動 schema/索引才能實現的新功能。由於資料庫的嚴重分散,MySQL 的關係特性(如join)對我們沒用,所以我們決定脫離 RDBMS。
雖然已有許多用於解決靈活 schema 資料存放區和運行時構建索引的問題(例如 CouchDB)的項目。但在大網站中卻沒有足夠廣泛地用到來說服人們使用。在我們看到和啟動並執行測試中,這些項目要麼不穩定,要麼缺乏足夠的測試(參見這個有點過時的關於 CouchDB 的文章)。MySQL 不錯,它不會損壞資料;複製也沒問題,我們已經瞭解了它的局限。我們喜歡將 MySQL 用於儲存,僅僅是非關係型的儲存。
幾經思量,我們決定在 MySQL 上採用一種無模式的儲存系統,而不是使用一個完全沒接觸過的儲存系統。本文試圖描述這個系統的進階細節。我們很好奇其他大型網站是如何處理這些問題的,另外也希望我們完成的某些設計會對其他開發人員有所協助。
綜述
我們在資料庫中儲存的是無模式的屬性集(例如JSON對象或python字典)。儲存的記錄只需一個名為id的16位元組的UUID屬性。對資料庫而言實體的其他部分是不可見的。我們可以簡單地存入新屬性來改變schema(可以簡單理解為資料表中只有兩個欄位:id,data;其中data儲存的是實體的屬性集)。
我們通過儲存在不同表中的索引來檢索資料。如果想檢索每個實體中的三個屬性,我們就需要三個資料表-每個表用於檢索某一特定屬性。如果不想再用某一索引了,我們要在代碼中停止該索引對應表的寫操作,並可選地刪除那個表。如果想添加個新索引,只需要為該索引建立個MySQL表,並啟動一個進程非同步地為該表添加索引資料(不影響運行中的服務)。
最終,雖然我們的資料表增多了,但添加和刪除索引卻變得簡單了。我們大力改善了添加索引資料的進程(我們稱之為“清潔工")使其在快速添加索引的同時不會影響網站。我們可以在一天內完成新屬性的儲存和索引,並且我們不需要對調主從MySQL資料庫,也不需要任何其他可怕的操作。
細節
MySQL 使用表儲存我們的實體,一個表就像這樣 :
CREATE TABLE entities ( added_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, id BINARY(16) NOT NULL, updated TIMESTAMP NOT NULL, body MEDIUMBLOB, UNIQUE KEY (id), KEY (updated)) ENGINE=InnoDB;
之所以使用 added_id 個欄位是因為 InnoDB 按物理主鍵順序儲存資料,自增長主鍵確保新執行個體在磁碟上按順序寫到老實體之後,這樣有助於分區讀寫(相對老的實體,新實體往往讀操作更頻繁,因為 FriendFeed 的 pages 是按時間逆序排列)。實體本身經 python 字典序列化後使用 zlib 壓縮儲存。
索引單獨存在一張表裡,如果要建立索引,我們建立一張新表格儲存體我們想要索引的資料分區的所有屬性。例如,一個 FriendFeed 實體通過看上去是這樣的:
{ "id": "71f0c4d2291844cca2df6f486e96e37c", "user_id": "f48b0440ca0c4f66991c4d5f6a078eaf", "feed_id": "f48b0440ca0c4f66991c4d5f6a078eaf", "title": "We just launched a new backend system for FriendFeed!", "link": "http://friendfeed.com/e/71f0c4d2-2918-44cc-a2df-6f486e96e37c", "published": 1235697046, "updated": 1235697046,}
我們索引實體的屬性 user_id,這樣我們可以渲染一個頁面,包含一個已提交使用者的所有屬性。我們的索引表看起來是這樣的:
CREATE TABLE index_user_id ( user_id BINARY(16) NOT NULL, entity_id BINARY(16) NOT NULL UNIQUE, PRIMARY KEY (user_id, entity_id)) ENGINE=InnoDB;
我們的資料存放區會自動為你維護索引,所以如果你要在我們儲存上述結構實體的資料存放區裡開啟一個執行個體,你可以寫一段代碼(用 python):
user_id_index = friendfeed.datastore.Index( table="index_user_id", properties=["user_id"], shard_on="user_id")datastore = friendfeed.datastore.DataStore( mysql_shards=["127.0.0.1:3306", "127.0.0.1:3307"], indexes=[user_id_index]) new_entity = { "id": binascii.a2b_hex("71f0c4d2291844cca2df6f486e96e37c"), "user_id": binascii.a2b_hex("f48b0440ca0c4f66991c4d5f6a078eaf"), "feed_id": binascii.a2b_hex("f48b0440ca0c4f66991c4d5f6a078eaf"), "title": u"We just launched a new backend system for FriendFeed!", "link": u"http://friendfeed.com/e/71f0c4d2-2918-44cc-a2df-6f486e96e37c", "published": 1235697046, "updated": 1235697046,}datastore.put(new_entity)entity = datastore.get(binascii.a2b_hex("71f0c4d2291844cca2df6f486e96e37c"))entity = user_id_index.get_all(datastore, user_id=binascii.a2b_hex("f48b0440ca0c4f66991c4d5f6a078eaf"))
上面的 Index 類在所有實體中尋找 user_id,自動維護 index_user_id 表的索引。我們的資料庫是切分的,參數 shard_on 是用來確定索引是儲存在哪個分區上(這種情況下使用 entity["user_id"] % num_shards)。
你可以使用索引執行個體(見上面的 user_id_index.get_all)查詢一個索引,使用 python 寫的資料存放區代碼將表 index_user_id 和表 entities 合并。首先在所有資料庫分區中查詢表 index_user_id 擷取實體 ID 列,然後在 entities 提出資料。
建立一個索引,比如,在屬性 link 上,我們可以建立一個新表:
CREATE TABLE index_link ( link VARCHAR(735) NOT NULL, entity_id BINARY(16) NOT NULL UNIQUE, PRIMARY KEY (link, entity_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8;
我們可以修改資料存放區的初始化代碼以包含我們的新索引:
user_id_index = friendfeed.datastore.Index( table="index_user_id", properties=["user_id"], shard_on="user_id")link_index = friendfeed.datastore.Index( table="index_link", properties=["link"], shard_on="link")datastore = friendfeed.datastore.DataStore( mysql_shards=["127.0.0.1:3306", "127.0.0.1:3307"], indexes=[user_id_index, link_index])
我可以非同步構建索引(特別是即時傳輸服務):
./rundatastorecleaner.py --index=index_link
一致性與原子性
由於採用分區的資料庫,實體的索引可能儲存在與實體不同的分區中,這引起了一致性問題。如果進程在寫入所有索引表前崩潰了會怎樣?
許多有野心的 FriendFeed 工程師傾向於構建一個事務性協議,但我們希望儘可能地保持系統的簡潔。我們決定放寬限制:
- 儲存在主實體表中的屬性集是規範完整的
- 索引不會對真實實體值產生影響
因此,往資料庫中寫入實體時我們採用如下步驟:
- 使用 InnoDB 的 ACID 屬性將實體寫入 entities 表。
- 將索引寫入所有分區中的索引表。
我們要記住從索引表中取出的資料可能是不準確的(例如如果寫操作沒有完成步驟2可能會影響舊屬性值)。為確保採用上面的限制能返回正確的實體,我們用索引表來決定要讀取哪些實體,但不要相信索引的完整性,要使用查詢條件對這些實體進行再過濾:
1.根據查詢條件從索引表中取得 entity_id
2.根據 entity_id 從 entities 表中讀取實體
3.根據實體的真實屬性(用 Python)過濾掉不符合查詢條件的實體
為保證索引的持久性和一致性,上文提到的“清潔工”進程要持續運行,寫入丟失的索引,清理失效的舊索引。它優先清理最新動向的實體,所以實際上維護索引的一致性非常快(幾秒鐘).
效能
我們對新系統的主索引進行了最佳化,對結果也很滿意。以下是上個月 FriendFeed 頁面的載入延時統計圖(我們在前幾天啟動了新的後端,你可以根據延時的顯著回落找到那一天)。
特別地,系統的延時現在也很穩定(哪怕是在午高峰期間)。如下是過去24小時FriendFeed頁面載入延時圖。
與上周的某天相比較:
系統到目前為止使用起來很方便。我們在部署之後也改動了幾次索引,並且我們也開始將這種模式應用於 MySQL 中那些較大的表,這樣我們在以後可以輕鬆地改動它們的結構。