使用FriendFeed來(lái)提升MySQL性能的方法
背景
我們使用MySQL存儲(chǔ)了FriendFeed的所有數(shù)據(jù)。數(shù)據(jù)庫(kù)隨著用戶基數(shù)的增長(zhǎng)而增長(zhǎng)了很多?,F(xiàn)在已經(jīng)存儲(chǔ)了超過(guò)2.5億條記錄與一堆涵蓋了從評(píng)論和“喜歡”到好友列表的其他數(shù)據(jù)。
隨著數(shù)據(jù)的增長(zhǎng),我們也曾迭代地解決了隨著如此迅猛的增長(zhǎng)而帶來(lái)的擴(kuò)展性問(wèn)題。我們的嘗試很有代表性,例如使用只讀mysql從節(jié)點(diǎn)和memcache來(lái)增加讀取吞吐量,對(duì)數(shù)據(jù)庫(kù)進(jìn)行分片來(lái)提高寫入吞吐量。然而,隨著業(yè)務(wù)的增長(zhǎng),添加新功能比擴(kuò)展既有功能以迎合更多的流量變得更加困難。
特別的,對(duì) schema 做改動(dòng)或?yàn)槌^(guò) 1000-2000 萬(wàn)行記錄的數(shù)據(jù)庫(kù)添加索引會(huì)將數(shù)據(jù)庫(kù)鎖住幾個(gè)小時(shí)。刪除舊索引也要占用這么多時(shí)間,但不刪除它們會(huì)影響性能;因?yàn)閿?shù)據(jù)庫(kù)要持續(xù)地在每個(gè)INSERT上讀寫這些沒(méi)用的區(qū)塊,并將重要的區(qū)塊擠出了內(nèi)存。為避免這些問(wèn)題需要采取一些復(fù)雜的措施(例如在從節(jié)點(diǎn)上設(shè)置新的索引,然后將從節(jié)點(diǎn)與主節(jié)點(diǎn)對(duì)調(diào)),但這些措施會(huì)引發(fā)錯(cuò)誤并且實(shí)施起來(lái)比較困難,它們阻礙了需要改動(dòng) schema/索引才能實(shí)現(xiàn)的新功能。由于數(shù)據(jù)庫(kù)的嚴(yán)重分散,MySQL 的關(guān)系特性(如join)對(duì)我們沒(méi)用,所以我們決定脫離 RDBMS。
雖然已有許多用于解決靈活 schema 數(shù)據(jù)存儲(chǔ)和運(yùn)行時(shí)構(gòu)建索引的問(wèn)題(例如 CouchDB)的項(xiàng)目。但在大站點(diǎn)中卻沒(méi)有足夠廣泛地用到來(lái)說(shuō)服人們使用。在我們看到和運(yùn)行的測(cè)試中,這些項(xiàng)目要么不穩(wěn)定,要么缺乏足夠的測(cè)試(參見(jiàn)這個(gè)有點(diǎn)過(guò)時(shí)的關(guān)于 CouchDB 的文章)。MySQL 不錯(cuò),它不會(huì)損壞數(shù)據(jù);復(fù)制也沒(méi)問(wèn)題,我們已經(jīng)了解了它的局限。我們喜歡將 MySQL 用于存儲(chǔ),僅僅是非關(guān)系型的存儲(chǔ)。
幾經(jīng)思量,我們決定在 MySQL 上采用一種無(wú)模式的存儲(chǔ)系統(tǒng),而不是使用一個(gè)完全沒(méi)接觸過(guò)的存儲(chǔ)系統(tǒng)。本文試圖描述這個(gè)系統(tǒng)的高級(jí)細(xì)節(jié)。我們很好奇其他大型網(wǎng)站是如何處理這些問(wèn)題的,另外也希望我們完成的某些設(shè)計(jì)會(huì)對(duì)其他開發(fā)者有所幫助。
綜述
我們?cè)跀?shù)據(jù)庫(kù)中存儲(chǔ)的是無(wú)模式的屬性集(例如JSON對(duì)象或python字典)。存儲(chǔ)的記錄只需一個(gè)名為id的16字節(jié)的UUID屬性。對(duì)數(shù)據(jù)庫(kù)而言實(shí)體的其他部分是不可見(jiàn)的。我們可以簡(jiǎn)單地存入新屬性來(lái)改變schema(可以簡(jiǎn)單理解為數(shù)據(jù)表中只有兩個(gè)字段:id,data;其中data存儲(chǔ)的是實(shí)體的屬性集)。
我們通過(guò)保存在不同表中的索引來(lái)檢索數(shù)據(jù)。如果想檢索每個(gè)實(shí)體中的三個(gè)屬性,我們就需要三個(gè)數(shù)據(jù)表-每個(gè)表用于檢索某一特定屬性。如果不想再用某一索引了,我們要在代碼中停止該索引對(duì)應(yīng)表的寫操作,并可選地刪除那個(gè)表。如果想添加個(gè)新索引,只需要為該索引新建個(gè)MySQL表,并啟動(dòng)一個(gè)進(jìn)程異步地為該表添加索引數(shù)據(jù)(不影響運(yùn)行中的服務(wù))。
最終,雖然我們的數(shù)據(jù)表增多了,但添加和刪除索引卻變得簡(jiǎn)單了。我們大力改善了添加索引數(shù)據(jù)的進(jìn)程(我們稱之為“清潔工")使其在快速添加索引的同時(shí)不會(huì)影響站點(diǎn)。我們可以在一天內(nèi)完成新屬性的保存和索引,并且我們不需要對(duì)調(diào)主從MySQL數(shù)據(jù)庫(kù),也不需要任何其他可怕的操作。
細(xì)節(jié)
MySQL 使用表保存我們的實(shí)體,一個(gè)表就像這樣 :
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 個(gè)字段是因?yàn)?InnoDB 按物理主鍵順序存儲(chǔ)數(shù)據(jù),自增長(zhǎng)主鍵確保新實(shí)例在磁盤上按順序?qū)懙嚼蠈?shí)體之后,這樣有助于分區(qū)讀寫(相對(duì)老的實(shí)體,新實(shí)體往往讀操作更頻繁,因?yàn)?FriendFeed 的 pages 是按時(shí)間逆序排列)。實(shí)體本身經(jīng) python 字典序列化后使用 zlib 壓縮存儲(chǔ)。
索引單獨(dú)存在一張表里,如果要?jiǎng)?chuàng)建索引,我們創(chuàng)建一張新表存儲(chǔ)我們想要索引的數(shù)據(jù)分片的所有屬性。例如,一個(gè) FriendFeed 實(shí)體通過(guò)看上去是這樣的:
{
"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,
}
我們索引實(shí)體的屬性 user_id,這樣我們可以渲染一個(gè)頁(yè)面,包含一個(gè)已提交用戶的所有屬性。我們的索引表看起來(lái)是這樣的:
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;
我們的數(shù)據(jù)存儲(chǔ)會(huì)自動(dòng)為你維護(hù)索引,所以如果你要在我們存儲(chǔ)上述結(jié)構(gòu)實(shí)體的數(shù)據(jù)存儲(chǔ)里開啟一個(gè)實(shí)例,你可以寫一段代碼(用 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 類在所有實(shí)體中查找 user_id,自動(dòng)維護(hù) index_user_id 表的索引。我們的數(shù)據(jù)庫(kù)是切分的,參數(shù) shard_on 是用來(lái)確定索引是存儲(chǔ)在哪個(gè)分片上(這種情況下使用 entity["user_id"] % num_shards)。
你可以使用索引實(shí)例(見(jiàn)上面的 user_id_index.get_all)查詢一個(gè)索引,使用 python 寫的數(shù)據(jù)存儲(chǔ)代碼將表 index_user_id 和表 entities 合并。首先在所有數(shù)據(jù)庫(kù)分片中查詢表 index_user_id 獲取實(shí)體 ID 列,然后在 entities 提出數(shù)據(jù)。
新建一個(gè)索引,比如,在屬性 link 上,我們可以創(chuàng)建一個(gè)新表:
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;
我們可以修改數(shù)據(jù)存儲(chǔ)的初始化代碼以包含我們的新索引:
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])
我可以異步構(gòu)建索引(特別是實(shí)時(shí)傳輸服務(wù)):
./rundatastorecleaner.py --index=index_link
一致性與原子性
由于采用分區(qū)的數(shù)據(jù)庫(kù),實(shí)體的索引可能存儲(chǔ)在與實(shí)體不同的分區(qū)中,這引起了一致性問(wèn)題。如果進(jìn)程在寫入所有索引表前崩潰了會(huì)怎樣?
許多有野心的 FriendFeed 工程師傾向于構(gòu)建一個(gè)事務(wù)性協(xié)議,但我們希望盡可能地保持系統(tǒng)的簡(jiǎn)潔。我們決定放寬限制:
- 保存在主實(shí)體表中的屬性集是規(guī)范完整的
- 索引不會(huì)對(duì)真實(shí)實(shí)體值產(chǎn)生影響
因此,往數(shù)據(jù)庫(kù)中寫入實(shí)體時(shí)我們采用如下步驟:
- 使用 InnoDB 的 ACID 屬性將實(shí)體寫入 entities 表。
- 將索引寫入所有分區(qū)中的索引表。
我們要記住從索引表中取出的數(shù)據(jù)可能是不準(zhǔn)確的(例如如果寫操作沒(méi)有完成步驟2可能會(huì)影響舊屬性值)。為確保采用上面的限制能返回正確的實(shí)體,我們用索引表來(lái)決定要讀取哪些實(shí)體,但不要相信索引的完整性,要使用查詢條件對(duì)這些實(shí)體進(jìn)行再過(guò)濾:
1.根據(jù)查詢條件從索引表中取得 entity_id
2.根據(jù) entity_id 從 entities 表中讀取實(shí)體
3.根據(jù)實(shí)體的真實(shí)屬性(用 Python)過(guò)濾掉不符合查詢條件的實(shí)體
為保證索引的持久性和一致性,上文提到的“清潔工”進(jìn)程要持續(xù)運(yùn)行,寫入丟失的索引,清理失效的舊索引。它優(yōu)先清理最近更新的實(shí)體,所以實(shí)際上維護(hù)索引的一致性非???幾秒鐘).
性能
我們對(duì)新系統(tǒng)的主索引進(jìn)行了優(yōu)化,對(duì)結(jié)果也很滿意。以下是上個(gè)月 FriendFeed 頁(yè)面的加載延時(shí)統(tǒng)計(jì)圖(我們?cè)谇皫滋靻?dòng)了新的后端,你可以根據(jù)延時(shí)的顯著回落找到那一天)。

特別地,系統(tǒng)的延時(shí)現(xiàn)在也很穩(wěn)定(哪怕是在午高峰期間)。如下是過(guò)去24小時(shí)FriendFeed頁(yè)面加載延時(shí)圖。

與上周的某天相比較:

系統(tǒng)到目前為止使用起來(lái)很方便。我們?cè)诓渴鹬笠哺膭?dòng)了幾次索引,并且我們也開始將這種模式應(yīng)用于 MySQL 中那些較大的表,這樣我們?cè)谝院罂梢暂p松地改動(dòng)它們的結(jié)構(gòu)。
相關(guān)文章
MySQL索引的一些常見(jiàn)面試題大全(2022年)
索引(Index)是幫助MySQL高效獲取數(shù)據(jù)的數(shù)據(jù)結(jié)構(gòu),是對(duì)表中一列或多列值進(jìn)行排序的結(jié)構(gòu),下面這篇文章主要給大家介紹了關(guān)于MySQL索引的一些常見(jiàn)面試題,文中介紹的非常詳細(xì),需要的朋友可以參考下2023-02-02
MySQL實(shí)現(xiàn)批量插入測(cè)試數(shù)據(jù)的方式總結(jié)
在開發(fā)過(guò)程中經(jīng)常需要一些測(cè)試數(shù)據(jù),?這個(gè)時(shí)候如果手敲的話,?十行二十行還好,?多了就很死亡了,?接下來(lái)介紹兩種常用的MySQL測(cè)試數(shù)據(jù)批量生成方式,希望對(duì)大家有所幫助2023-05-05
MySQL給新建用戶并賦予權(quán)限最簡(jiǎn)單的方法
在本篇文章里小編給大家整理的是一篇關(guān)于MySQL給新建用戶并賦予權(quán)限最簡(jiǎn)單的方法,需要的朋友們參考下。2019-11-11
Mysql啟動(dòng)報(bào)錯(cuò)Error1045(28000)的原因分析及解決
這篇文章主要介紹了Mysql啟動(dòng)報(bào)錯(cuò)Error1045(28000)的原因分析及解決,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2025-04-04
mysql臨時(shí)表用法分析【查詢結(jié)果可存在臨時(shí)表中】
這篇文章主要介紹了mysql臨時(shí)表用法,結(jié)合實(shí)例形式分析了MySQL將查詢結(jié)果存儲(chǔ)在臨時(shí)表中的相關(guān)操作技巧,需要的朋友可以參考下2019-08-08
MySQL數(shù)據(jù)庫(kù)查看數(shù)據(jù)表占用空間大小和記錄數(shù)的方法
這篇文章主要介紹了MySQL數(shù)據(jù)庫(kù)查看數(shù)據(jù)表占用空間大小和記錄數(shù)的方法,如果想知道MySQL數(shù)據(jù)庫(kù)中每個(gè)表占用的空間、表記錄的行數(shù)的話,可以打開MySQL的information_schema 數(shù)據(jù)庫(kù)查詢,本文就講解查詢方法,需要的朋友可以參考下2015-04-04
InnoDb 體系架構(gòu)和特性詳解 (Innodb存儲(chǔ)引擎讀書筆記總結(jié))
下面小編就為大家?guī)?lái)一篇InnoDb 體系架構(gòu)和特性詳解 (Innodb存儲(chǔ)引擎讀書筆記總結(jié))。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-03-03
解決mysql連接錯(cuò)誤errorCode 0,state 08S01
這篇文章主要介紹了解決mysql連接錯(cuò)誤errorCode 0,state 08S01,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2024-05-05

