數據庫一貫是網站架構中最具挑戰性的,瓶頸一般出如今這裏。又拍網的照片數據量很大,數據庫也幾度出現嚴重的壓力問題。 所以,這裏我主要介紹一下又拍網在分庫設計這方面的一些嘗試。 php
又拍網是一個照片分享社區,從2005年6月至今積累了260萬用戶,1.1億張照片,目前的日訪問量爲200多萬。5年的發展歷程裏經歷過許多起伏,也積累了一些經驗,在這篇文章裏,我要介紹一些咱們在技術上的積累。 html
又拍網和大多數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:開發語言 python
因爲PHP的單線程模型,咱們把耗時較久的運算和I/O操做從HTTP請求週期中分離出來, 交給由Python實現的任務進程來完成,以保證請求響應速度。這些任務主要包括:郵件發送、數據索引、數據聚合和好友動態推送(稍候會有介紹)等等。一般這些任務由用戶觸發,而且,用戶的一個行爲可能會觸發多種任務的執行。 好比,用戶上傳了一張新的照片,咱們須要更新索引,也須要向他的朋友推送一條新的動態。PHP經過消息隊列(咱們用的是RabbitMQ)來觸發任務執行。 mysql
圖2:PHP和Python的協做 nginx
數據庫一貫是網站架構中最具挑戰性的,瓶頸一般出如今這裏。又拍網的照片數據量很大,數據庫也幾度出現嚴重的壓力問題。 所以,這裏我主要介紹一下又拍網在分庫設計這方面的一些嘗試。 redis
和不少使用MySQL的2.0站點同樣,又拍網的MySQL集羣經歷了從最初的一個主庫一個從庫、到一個主庫多個從庫、 而後到多個主庫多個從庫的一個發展過程。 算法
最初是由一臺主庫和一臺從庫組成,當時從庫只用做備份和容災,當主庫出現故障時,從庫就手動變成主庫,通常狀況下,從庫不做讀寫操做(同步除外)。隨着壓力的增長,咱們加上了memcached,當時只用其緩存單行數據。 可是,單行數據的緩存並不能很好地解決壓力問題,由於單行數據的查詢一般很快。因此咱們把一些實時性要求不高的Query放到從庫去執行。後面又經過添加多個從庫來分流查詢壓力,不過隨着數據量的增長,主庫的寫壓力也愈來愈大。 sql
在參考了一些相關產品和其它網站的作法後,咱們決定進行數據庫拆分。也就是將數據存放到不一樣的數據庫服務器中,通常能夠按兩個緯度來拆分數據: 數據庫
垂直拆分:是指按功能模塊拆分,好比能夠將羣組相關表和照片相關表存放在不一樣的數據庫中,這種方式多個數據庫之間的表結構不一樣。
水平拆分:而水平拆分是將同一個表的數據進行分塊保存到不一樣的數據庫中,這些數據庫中的表結構徹底相同。
通常都會先進行垂直拆分,由於這種方式拆分方式實現起來比較簡單,根據表名訪問不一樣的數據庫就能夠了。可是垂直拆分方式並不能完全解決全部壓力問題,另外,也要看應用類型是否合適這種拆分方式。若是合適的話,也能很好的起到分散數據庫壓力的做用。好比對於豆瓣我以爲比較適合採用垂直拆分, 由於豆瓣的各核心業務/模塊(書籍、電影、音樂)相對獨立,數據的增長速度也比較平穩。不一樣的是,又拍網的核心業務對象是用戶上傳的照片,而照片數據的增長速度隨着用戶量的增長愈來愈快。壓力基本上都在照片表上,顯然垂直拆分並不能從根本上解決咱們的問題,因此,咱們採用水平拆分的方式。
水平拆分實現起來相對複雜,咱們要先肯定一個拆分規則,也就是按什麼條件將數據進行切分。 通常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框架裏了,開發人員基本上不須要被這些繁瑣的事情困擾。下面是使用咱們的框架進行照片數據的讀寫的一些例子:
$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從業經驗,不太專一於某項技術,對不少技術都感興趣。