又拍網架構中的分庫設計

來源:互聯網
上載者:User

原文:http://www.infoq.com/cn/articles/yupoo-partition-database

因為infoq會時不時的打不開,特轉一下。

又拍網和大多數Web2.0網站一樣,構建於大量開源軟體之上,包括MySQL、PHP、nginx、Python、memcached、redis、Solr、Hadoop和RabbitMQ等等。又拍網的伺服器端開發語言主要是PHP和Python,其中PHP用於編寫Web邏輯(通過HTTP和使用者直接打交道), 而Python則主要用於開發內部服務和背景工作。在用戶端則使用了大量的Javascript, 這裡要感謝一下MooTools這個JS架構,它使得我們很享受前端開發過程。 另外,我們把圖片處理過程從PHP進程裡獨立出來變成一個服務。這個服務基於nginx,但是是作為nginx的一個模組而開放REST API。

圖1:開發語言

由於PHP的單執行緒模式,我們把耗時較久的運算和I/O操作從HTTP請求周期中分離出來, 交給由Python實現的任務進程來完成,以保證請求響應速度。這些任務主要包括:郵件發送、資料索引、資料彙總和社交動向更新推送(稍候會有介紹)等等。通常這些任務由使用者觸發,並且,使用者的一個行為可能會觸發多種任務的執行。 比如,使用者上傳了一張新的照片,我們需要更新索引,也需要向他的朋友推送一條新的動態。PHP通過訊息佇列(我們用的是RabbitMQ)來觸發任務執行。

圖2:PHP和Python的協作

資料庫一向是網站架構中最具挑戰性的,瓶頸通常出現在這裡。又拍網的照片資料量很大,資料庫也幾度出現嚴重的壓力問題。 因此,這裡我主要介紹一下又拍網在分庫設計這方面的一些嘗試。

分庫設計

和很多使用MySQL的2.0網站一樣,又拍網的MySQL叢集經曆了從最初的一個主庫一個從庫、到一個主庫多個從庫、 然後到多個主庫多個從庫的一個發展過程。

圖3:資料庫的進化過程

最初是由一台主庫和一台從庫組成,當時從庫只用作備份和容災,當主庫出現故障時,從庫就手動變成主庫,一般情況下,從庫不作讀寫操作(同步除外)。隨著壓力的增加,我們加上了memcached,當時只用其緩衝單行資料。 但是,單行資料的緩衝並不能很好地解決壓力問題,因為單行資料的查詢通常很快。所以我們把一些即時性要求不高的Query放到從庫去執行。後面又通過添加多個從庫來分流查詢壓力,不過隨著資料量的增加,主庫的寫壓力也越來越大。

在參考了一些相關產品和其它網站的做法後,我們決定進行資料庫拆分。也就是將資料存放到不同的資料庫伺服器中,一般可以按兩個緯度來拆分資料:

垂直分割:是指按功能模組拆分,比如可以將群組相關表和照片相關表存放在不同的資料庫中,這種方式多個資料庫之間的表結構不同

水平分割:而水平分割是將同一個表的資料進行分塊儲存到不同的資料庫中,這些資料庫中的表結構完全相同

拆分方式

一般都會先進行垂直分割,因為這種方式拆分方式實現起來比較簡單,根據表名訪問不同的資料庫就可以了。但是垂直分割方式並不能徹底解決所有壓力問題,另外,也要看應用類型是否合適這種拆分方式。如果合適的話,也能很好的起到分散資料庫壓力的作用。比如對於豆瓣我覺得比較適合採用垂直分割, 因為豆瓣的各核心業務/模組(書籍、電影、音樂)相對獨立,資料的增加速度也比較平穩。不同的是,又拍網的核心業務對象是使用者上傳的照片,而照片資料的增加速度隨著使用者量的增加越來越快。壓力基本上都在照片表上,顯然垂直分割並不能從根本上解決我們的問題,所以,我們採用水平分割的方式。

拆分規則

水平分割實現起來相對複雜,我們要先確定一個拆分規則,也就是按什麼條件將資料進行切分。 一般2.0網站都以使用者為中心,資料基本都跟隨使用者,比如使用者的照片、朋友和評論等等。因此一個比較自然的選擇是根據使用者來切分。每個使用者都對應一個資料庫,訪問某個使用者的資料時, 我們要先確定他/她所對應的資料庫,然後串連到該資料庫進行實際的資料讀寫。

那麼,怎麼樣對應使用者和資料庫呢?我們有這些選擇:

按演算法對應

最簡單的演算法是按使用者ID的奇偶性來對應,將奇數ID的使用者對應到資料庫A,而偶數ID的使用者則對應到資料庫B。這個方法的最大問題是,只能分成兩個庫。另一個演算法是按使用者ID所在區間對應,比如ID在0-10000之間的使用者對應到資料庫A, ID在10000-20000這個範圍的對應到資料庫B,以此類推。按演算法分實現起來比較方便,也比較高效,但是不能滿足後續的伸縮性要求,如果需要增加資料庫節點,必需調整演算法或移動很大的資料集, 比較難做到在不停止服務的前提下進行擴充資料庫節點。

按索引/映射表對應

這種方法是指建立一個索引表,儲存每個使用者的ID和資料庫ID的對應關係,每次讀寫使用者資料時先從這個表擷取對應資料庫。新使用者註冊後,在所有可用的資料庫中隨機挑選一個為其建立索引。這種方法比較靈活,有很好的伸縮性。一個缺點是增加了一次資料庫訪問,所以效能上沒有按演算法對應好。

比較之後,我們採用的是索引表的方式,我們願意為其靈活性損失一些效能,更何況我們還有memcached, 因為索引資料基本不會改變的緣故,快取命中率非常高。所以能很大程度上減少了效能損失。

圖4:資料訪問過程

索引表的方式能夠比較方便地添加資料庫節點,在增加節點時,只要將其添加到可用資料庫列表裡即可。 當然如果需要平衡各個節點的壓力的話,還是需要進行資料的遷移,但是這個時候的遷移是少量的,可以逐步進行。要遷移使用者A的資料,首先要將其狀態置為遷移資料中,這個狀態的使用者不能進行寫操作,並在頁面上進行提示。 然後將使用者A的資料全部複製到新增加的節點上後,更新映射表,然後將使用者A的狀態置為正常,最後將原來對應的資料庫上的資料刪除。這個過程通常會在臨晨進行,所以,所以很少會有使用者碰到遷移資料中的情況。

當然,有些資料是不屬於某個使用者的,比如系統訊息、配置等等,我們把這些資料儲存在一個全域庫中。

問題

分庫會給你在應用的開發和部署上都帶來很多麻煩。

不能執行跨庫的關聯查詢

如果我們需要查詢的資料分佈於不同的資料庫,我們沒辦法通過JOIN的方式查詢獲得。比如要獲得好友的最新照片,你不能保證所有好友的資料都在同一個資料庫裡。一個解決辦法是通過多次查詢,再進行彙總的方式。我們需要盡量避免類似的需求。有些需求可以通過儲存多份資料來解決,比如User-A和User-B的資料庫分別是DB-1和DB-2, 當User-A評論了User-B的照片時,我們會同時在DB-1和DB-2中儲存這條評論資訊,我們首先在DB-2中的photo_comments表中插入一條新的記錄,然後在DB-1中的user_comments表中插入一條新的記錄。這兩個表的結構如所示。這樣我們可以通過查詢photo_comments表得到User-B的某張照片的所有評論, 也可以通過查詢user_comments表獲得User-A的所有評論。另外可以考慮使用全文檢索索引工具來解決某些需求, 我們使用Solr來提供全站標籤檢索和照片搜尋服務。

圖5:評論表結構

不能保證資料的一致/完整性

跨庫的資料沒有外鍵約束,也沒有事務保證。比如上面的評論照片的例子, 很可能出現成功插入photo_comments表,但是插入user_comments表時卻出錯了。一個辦法是在兩個庫上都開啟事務,然後先插入photo_comments,再插入user_comments, 然後提交兩個事務。這個辦法也不能完全保證這個操作的原子性。

所有查詢必須提供資料庫線索

比如要查看一張照片,僅憑一個照片ID是不夠的,還必須提供上傳這張照片的使用者的ID(也就是資料庫線索),才能找到它實際的存放位置。因此,我們必須重新設計很多URL地址,而有些老的地址我們又必須保證其仍然有效。我們把照片地址改成/photos/{username}/{photo_id}/的形式,然後對於系統升級前上傳的照片ID, 我們又增加一張映射表,儲存photo_id和user_id的對應關係。當訪問老的照片地址時,我們通過查詢這張表獲得使用者資訊, 然後再重新導向到新的地址。

自增ID

如果要在節點資料庫上使用自增欄位,那麼我們就不能保證全域唯一。這倒不是很嚴重的問題,但是當節點之間的資料發生關係時,就會使得問題變得比較麻煩。我們可以再來看看上面提到的評論的例子。如果photo_comments表中的comment_id的自增欄位,當我們在DB-2.photo_comments表插入新的評論時, 得到一個新的comment_id,假如值為101,而User-A的ID為1,那麼我們還需要在DB-1.user_comments表中插入(1, 101 ...)。 User-A是個很活躍的使用者,他又評論了User-C的照片,而User-C的資料庫是DB-3。 很巧的是這條新評論的ID也是101,這種情況很用可能發生。那麼我們又在DB-1.user_comments表中插入一行像這樣(1, 101 ...)的資料。 那麼我們要怎麼設定user_comments表的主鍵呢(標識一行資料)?可以不設啊,不幸的是有的時候(架構、緩衝等原因)必需設定。那麼可以以user_id、 comment_id和photo_id為組合主鍵,但是photo_id也有可能一樣(的確很巧)。看來只能再加上photo_owner_id了, 但是這個結果又讓我們實在有點無法接受,太複雜的按鍵組合在寫入時會帶來一定的效能影響,這樣的自然鍵看起來也很不自然。所以,我們放棄了在節點上使用自增欄位,想辦法讓這些ID變成全域唯一。為此增加了一個專門用來產生ID的資料庫,這個庫中的表結構都很簡單,只有一個自增欄位id。 當我們要插入新的評論時,我們先在ID庫的photo_comments表裡插入一條空的記錄,以獲得一個唯一的評論ID。 當然這些邏輯都已經封裝在我們的架構裡了,對於開發人員是透明的。 為什麼不用其它方案呢,比如一些支援incr操作的Key-Value資料庫。我們還是比較放心把資料放在MySQL裡。 另外,我們會定期清理ID庫的資料,以保證擷取新ID的效率。

實現

我們稱前面提到的一個資料庫節點為Shard,一個Shard由兩個台物理伺服器組成, 我們稱它們為Node-A和Node-B,Node-A和Node-B之間是配置成Master-Master相互複製的。 雖然是Master-Master的部署方式,但是同一時間我們還是只使用其中一個,原因是複製的延遲問題, 當然在Web應用裡,我們可以在使用者會話裡放置一個A或B來保證同一使用者一次會話裡只訪問一個資料庫, 這樣可以避免一些延遲問題。但是我們的Python任務是沒有任何狀態的,不能保證和PHP應用讀寫相同的資料庫。那麼為什麼不配置成Master-Slave呢?我們覺得只用一台太浪費了,所以我們在每台伺服器上都建立多個邏輯資料庫。 如所示,在Node-A和Node-B上我們都建立了shard_001和shard_002兩個邏輯資料庫, Node-A上的shard_001和Node-B上的shard_001組成一個Shard,而同一時間只有一個邏輯資料庫處於Active狀態。 這個時候如果需要訪問Shard-001的資料時,我們串連的是Node-A上的shard_001, 而訪問Shard-002的資料則是串連Node-B上的shard_002。以這種交叉的方式將壓力分散到每台物理伺服器上。 以Master-Master方式部署的另一個好處是,我們可以不停止服務的情況下進行表結構升級, 升級前先停止複製,升級Inactive的庫,然後升級應用,再將已經升級好的資料庫切換成Active狀態, 原來的Active資料庫切換成Inactive狀態,然後升級它的表結構,最後恢複復制。 當然這個步驟不一定適合所有升級過程,如果表結構的更改會導致資料複製失敗,那麼還是需要停止服務再升級的。

圖6:資料庫布局

前面提到過添加伺服器時,為了保證負載的平衡,我們需要遷移一部分資料到新的伺服器上。為了避免短期內遷移的必要,我們在實際部署的時候,每台機器上部署了8個邏輯資料庫, 添加伺服器後,我們只要將這些邏輯資料庫遷移到新伺服器就可以了。最好是每次添加一倍的伺服器, 然後將每台的1/2邏輯資料移轉到一台新伺服器上,這樣能很好的平衡負載。當然,最後到了每台上只有一個邏輯庫時,遷移就無法避免了,不過那應該是比較久遠的事情了。

我們把分庫邏輯都封裝在我們的PHP架構裡了,開發人員基本上不需要被這些繁瑣的事情困擾。下面是使用我們的架構進行照片資料的讀寫的一些例子:

<?php
$Photos = new ShardedDBTable('Photos', 'yp_photos', 'user_id', array(
'photo_id' => array('type' => 'long', 'primary' => true, 'global_auto_increment' => true),
'user_id' => array('type' => 'long'),
'title' => array('type' => 'string'),
'posted_date' => array('type' => 'date'),
));

$photo = $Photos->new_object(array('user_id' => 1, 'title' => 'Workforme'));
$photo->insert();

// 載入ID為10001的照片,注意第一個參數為使用者ID
$photo = $Photos->load(1, 10001);

// 更改照片屬性
$photo->title = 'Database Sharding';
$photo->update();

// 刪除照片
$photo->delete();

// 擷取ID為1的使用者在2010-06-01之後上傳的照片
$photos = $Photos->fetch(array('user_id' => 1, 'posted_date__gt' => '2010-06-01'));
?>

首先要定義一個ShardedDBTable對象,所有的API都是通過這個對象開放。第一個參數是物件類型名稱, 如果這個名稱已經存在,那麼將返回之前定義的對象。你也可以通過get_table('Photos')這個函數來擷取之前定義的Table對象。 第二個參數是對應的資料庫表名,而第三個參數是資料庫線索欄位,你會發現在後面的所有API中全部需要指定這個欄位的值。 第四個參數是欄位定義,其中photo_id欄位的global_auto_increment屬性被置為true,這就是前面所說的全域自增ID, 只要指定了這個屬性,架構會處理好ID的事情。

如果我們要訪問全域庫中的資料,我們需要定義一個DBTable對象。

<?php
$Users = new DBTable('Users', 'yp_users', array(
'user_id' => array('type' => 'long', 'primary' => true, 'auto_increment' => true),
'username' => array('type' => 'string'),
));
?>

DBTable是ShardedDBTable的父類,除了定義時參數有些不同(DBTable不需要指定資料庫線索欄位),它們提供一樣的API。

緩衝

我們的架構提供了緩衝功能,對開發人員是透明的。

<?php
$photo = $Photos->load(1, 10001);
?>

比如上面的方法調用,架構先嘗試以Photos-1-10001為Key在緩衝中尋找,未找到的話再執行資料庫查詢並放入緩衝。當更改照片屬性或刪除照片時,架構負責從緩衝中刪除該照片。這種單個對象的緩衝實現起來比較簡單。稍微麻煩的是像下面這樣的列表查詢結果的緩衝。

<?php
$photos = $Photos->fetch(array('user_id' => 1, 'posted_date__gt' => '2010-06-01'));
?>

我們把這個查詢分成兩步,第一步先查出合格照片ID,然後再根據照片ID分別尋找具體的照片資訊。 這麼做可以更好的利用緩衝。第一個查詢的緩衝Key為Photos-list-{shard_key}-{md5(查詢條件SQL語句)}, Value是照片ID列表(逗號間隔)。其中shard_key為user_id的值1。目前來看,列表緩衝也不麻煩。 但是如果使用者修改了某張照片的上傳時間呢,這個時候緩衝中的資料就不一定符合條件了。所以,我們需要一個機制來保證我們不會從緩衝中得到到期的列表資料。我們為每張表設定了一個revision,當該表的資料發生變化時(調用insert/update/delete方法), 我們就更新它的revision,所以我們把列表的緩衝Key改為Photos-list-{shard_key}-{md5(查詢條件SQL語句)}-{revision}, 這樣我們就不會再得到到期列表了。

revision資訊也是存放在緩衝裡的,Key為Photos-revision。這樣做看起來不錯,但是好像列表緩衝的利用率不會太高。因為我們是以整個資料類型的revision為緩衝Key的尾碼,顯然這個revision更新的非常頻繁,任何一個使用者修改或上傳了照片都會導致它的更新,哪怕那個使用者根本不在我們要查詢的Shard裡。要隔離使用者的動作對其他使用者的影響,我們可以通過縮小revision的作用範圍來達到這個目的。 所以revision的緩衝Key變成Photos-{shard_key}-revision,這樣的話當ID為1的使用者修改了他的照片資訊時, 只會更新Photos-1-revision這個Key所對應的revision。

因為全域庫沒有shard_key,所以修改了全域庫中的表的一行資料,還是會導致整個表的緩衝失效。 但是大部分情況下,資料都是有地區範圍的,比如我們的協助論壇的主題文章, 文章屬於主題。修改了其中一個主題的一個文章,沒必要使所有主題的文章緩衝都失效。 所以我們在DBTable上增加了一個叫isolate_key的屬性。

<?php
$GLOBALS['Posts'] = new DBTable('Posts', 'yp_posts', array(
'topic_id' => array('type' => 'long', 'primary' => true),
'post_id' => array('type' => 'long', 'primary' => true, 'auto_increment' => true),
'author_id' => array('type' => 'long'),
'content' => array('type' => 'string'),
'posted_at' => array('type' => 'datetime'),
'modified_at' => array('type' => 'datetime'),
'modified_by' => array('type' => 'long'),
), 'topic_id');
?>

注意建構函式的最後一個參數topic_id就是指以欄位topic_id作為isolate_key,它的作用和shard_key一樣用於隔離revision的作用範圍。

ShardedDBTable繼承自DBTable,所以也可以指定isolate_key。 ShardedDBTable指定了isolate_key的話,能夠更大幅度縮小revision的作用範圍。 比如相簿和照片的關聯表yp_album_photos,當使用者往他的其中一個相簿裡添加了新的照片時, 會導致其它相簿的照片列表緩衝也失效。如果我指定這張表的isolate_key為album_id的話, 我們就把這種影響限制在了本相簿內。

我們的緩衝分為兩級,第一級只是一個PHP數組,有效範圍是Request。而第二級是memcached。這麼做的原因是,很多資料在一個Request周期內需要載入多次,這樣可以減少memcached的網路請求。另外我們的架構也會儘可能的發送memcached的gets命令來擷取資料, 從而減少網路請求。

總結

這個架構使得我們在很長一段時間內都不必再為資料庫壓力所困擾。我們的設計很多地方參考了netlog和flickr的實現,因此非常感謝他們將一些實現細節發布出來。

關於作者:

周兆兆(Zola,不是你熟知的那個),又拍網架構師。6年IT從業經驗,不太專註於某項技術,對很多技術都感興趣。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.