FriendFeed如何用MySQL儲存K-V數據

jopen 10年前發布 | 16K 次閱讀 MySQL 數據庫服務器

<p>@這是一篇比較老的文章,我現在的理解是使用MySQL實現了一個MongoDB,在思路上有借鑒意義。</p>

原文地址: http://backchannel.org/blog/friendfeed-schemaless-mysql

背景

我們使用MySQL儲存FriendFeed所有的數據。我們的數據庫隨著我們的用戶基數增長而增長了許多,現在存儲了超過2億5千萬(250 million)條目和數據串,這些數據來自評論和朋友列表的“喜歡”。

伴隨著數據的增長,我們反復處理因為數據過快增長而帶來的擴展問題。我們曾經使用了常規方法,比如使用從屬讀服務器和memcache,增加讀取能力;使用數據庫分片來提高寫入的能力。然而,隨著我們的發展,發現增加新功能比擴展現有系統容量更困難。

尤其在這些情況下:設計模式改變;給一個超過一兩千萬行的數據庫增加索引,數據庫一次完全鎖死幾小時。刪除舊索引也會占用同樣的時間,并且不刪除他們會損害性能。因為數據庫會在每次INSERT插入操作時,繼續讀寫這些不使用的塊,并把重要的塊擠出內存。存在避開這些問題的非常復雜的設計,比如在從服務器上生成索引,然后對調主從服務器。但是這些方法容易出錯,太過重量級,并且暗中阻止我們添加需要改變索引或設計模式的新功能。自從我們的數據庫深度分片開始,與MySQL相關的功能,比如JOIN對于我們毫無價值。所以,我們決定在關系型數據庫之外尋找答案。

為了使用靈活模式和快速索引特性存儲數據,誕生了許多項目,比如說CouchDB。然而,似乎他們中沒有一個擁有足夠的信任被大型網站廣泛使用。在測試中,我們運行表明,對于我們的需求,他們中沒有一款足夠穩定或經受考驗。MySQL可以滿足需求,并且從來不會損壞數據,或重復工作。我們已經認識到了它的局限性。我們喜歡MySQL用來存儲,而不是關系型數據庫的使用模式。

在一些考慮之后,我們決定在MySQL之上,實現一套“無模式”的存儲系統,而不是完整的使用其他新型存儲系統。這篇文字試圖在高層次上描述系統的細節。我們很好奇其他大型網站是如何解決這個問題的,并且我們認為一些我們做的設計工作可能會對其他開發者有所幫助。

概述

我們的數據庫存儲無模式的屬性包(比如JSON或Python中的字典)。存儲實體唯一需要的屬性是id,一個16字節的UUID(通用唯一標識符)。實體的其他屬性在數據庫連接之前是不透明的。我們可以簡單地通過存儲新屬性來改變模式。

我們通過在單獨的MySQL表中存儲索引,來索引這些實體的數據。如果我們想在每個實體中索引3個屬性,我們將會有3個MySQL表(每一個對應一個索引)。如果我們想要停止使用一個索引,先從代碼停止向這張表寫入,然后根據需要在MySQL中刪除這張表。如果我們想使用一個新索引,先在MySQL中為這個索引建立新表,然后運行一個程序異步填入索引,這樣不會干擾我們的正常服務。

因此,相對之前我們后來會有更多的表,但是添加和刪除索引非常容易。我們擁有深度優化過的程序用來填充新索引(被我們叫做”Cleaner”),因此可以在不干擾網站正常服務的情況下迅速填充索引。我們可以在一天的時間內存儲新屬性并建立索引,而不是一個周的時間。并且,我們不需要再交換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頁面按照年代反轉排序,所以新實體相對于舊實體,傾向于擁有更頻繁的讀取)。實體內容被使用zlib算法壓縮存儲,pickle化的python字典。

索引存儲在分開的單獨表里。為了創建一個新索引,要新建一個表,存儲我們想要索引的屬性,以便在所有數據庫群中查找。例如,一個標準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"))

上文的索引類在素有實體中尋找user_id屬性,并自動在index_user_id表中維護索引。自從我們的數據庫分片以后,shard_on屬性就被用來確認,索引被存儲在哪一個數據庫片上(在這個案例中,值為實體的ueser_id對分片數量取余)。

你可以使用索引實例查詢一個索引(請參考上文user_id_index.get_all)。數據存儲系統代碼會在python中完成index_user_id表和實體表之間的“join”工作,通過先在所有數據庫片中查詢index_user_id表,拿到實體ID的列表,然后再實體表中讀取這些ID。

為了添加一個新索引,例如,在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工程師是一個誘人的方案,但是我們希望保持系統盡可能的簡單。我們決定這樣放開約束:

  • 屬性包存儲在主實體表中作為標準規范

  • 索引可能不會反映實體的真實值

因此,我們用以下步驟向數據庫寫入了新的實體:

  1. 使用InnoDB的ACID屬性,向實體表寫入實體

  2. 向所有數據庫片上的所有索引表,寫入索引

當從索引表讀取時,我們知道結果不是非常精確的(也就是,如果寫入時沒有完成步驟2,索引可能反映舊的屬性值)。為了保證基于以上約束,我們不會返回無效的實體,我們使用索引表來確認要讀取哪一個實體,但是我們會在實體中重復提交過濾查詢,而不是相信索引的完整性:

  1. 基于查詢在所有索引表中讀取entity_id

  2. 根據給定的實體ID在實體表中讀取實體

  3. 過濾(in python)所有不與實際屬性值匹配的實體

為了確保索引不失去持久性,不一致最后會被修復,我上文提到過的“Cleaner”程序,在表間不斷運行,寫入丟失索引并清除舊的、無效的索引。它會先處理最近更新的實體,所以在實際中索引中的不一致會被非常快的修復(在幾秒之內)。

性能

我們在新系統中已經對主要索引進行了非常多的優化,并且對優化結果非常滿意。下面是上個月FriendFeed頁面延遲的圖表(我們在幾天前啟動了新后臺,你可以看到戲劇性的下降):

FriendFeed如何用MySQL儲存K-V數據

尤其是,我們系統的延遲現在非常穩定,即使在高峰的正午時間。下面是過去24小時FriendFeed頁面延遲的圖表:

FriendFeed如何用MySQL儲存K-V數據

對比一周前的數據:

FriendFeed如何用MySQL儲存K-V數據

到目前為止,系統真的容易完成了工作。自從我們發展了系統,已經改變了索引好多次,并且我們開始使用新模式轉換最大的的MySQL表,以便于我們可以隨著發展更自由的改變數據結構。

來自:http://my.oschina.net/jsan/blog/336532

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!