標籤:現象 否則 with blog 中間 索引 lang 部分 好的
MongoDB分頁處理方案(適用於一般資料庫的分頁方法) (2012-11-06 17:59:55)
轉載▼
| 標籤: mongodb 分頁 資料庫 跳轉 |
分類: MongoDB |
轉載請註明出處:http://blog.sina.com.cn/s/blog_56545fd30101442b.html
MongoDB的分頁效能是廣大使用者所詬病的大問題之一,在大資料量環境下,如果一次跳轉的頁數過多,如10W多頁,可能使用者要等上幾十秒(瞎掰的資料),有興趣的可以去看一下這篇文章Paging & Ranking With Large Offsets: MongoDB vs Redis vs PostgreSQL。
看完了你是不是對MongoDB的效能很失望,對Redis充滿了崇敬?
其實這種對比是完全不公平的。
首先,看一下Redis,研究NoSQL的多少會瞭解一些吧,這是一種完全的記憶體緩衝的儲存系統,他完全稱不上是個資料庫,為什麼這麼說呢,因為資料庫最基本的一個特徵——持久化Redis是沒有的。Redis和Memcached一樣,是一種將資料全部放在記憶體中用於緩衝的儲存系統,因此它的效能就是記憶體的效能。
而MongoDB,乃至其他一切資料庫,都會將資料存入硬碟,雖然MongoDB也會將部分熱資料放入記憶體,但是面對千萬級甚至上億的資料量,讓記憶體放下所有熱資料是不可能的,所以如果一個查詢匹配的資料過多的話,可能大部分的效能瓶頸都在頁面交換(從硬碟上讀資料)上了。
最後,你可以測一下關係型資料庫的效能,相信很難有哪個關係型資料庫的分頁效能比MongoDB還好。
當然,這不是為MongoDB開脫,我認為10gen應該找到一些辦法來最佳化一下這種分頁之後大位移量的效能嚴重下降問題,雖然我還沒想到。
資料庫效能瓶頸分析
出現這一現象的原因,在於使用者的這種查詢需要資料庫根據條件做一次篩選或排序,這是非常耗時間的,比如:
db.user.find({age:{$gt:20, $lt:30}}).sort({registdate:1, name:-1})
你如果在Google的使用者資料中(據說是億級)進行了這種查詢,假設這些資料使用MongoDB(實際應該是BigTable)儲存的,匹配了上億的資料量,這個過程,MongoDB需要一個compound index,類似於{age:1, registdate:1, name:-1}這種,首先要根據age過濾出符合{age:{$gt:20,$lt:30}}的資料,然後根據兩個條件{ registdate:1, name:-1}進行排序,這個排序很可能會與原來的索引順序不同,因此MongoDB需要花時間來進行這種排序操作,更重要的是基於記憶體大小的限制,這種排序的結果不可能用於儲存在記憶體中,甚至不會記錄而是每次都排序一遍(這是很正常的),如果你查詢的是前一百條的資料還好,MongoDB也許只需要排序數百條資料就能返回排序好的前一百。
舉個例子,減小一下資料規模,如:
MongoDB由兩個Shard組成,user這個Collection有{age:1, registdate:1, name:-1}的索引,假設查詢前三條資料,即
db.user.find({age:{$gt:20, $lt:30}}).sort({registdate:1, name:-1}).limit(3)
那麼,最理想情況下(因為我不太清楚MongoDB的查詢機制),MongoDB只需要尋找三步就可以返回結果。如(前面的序號可以理解為指標跳轉序號):
如果使用者進行了跳轉,如:
db.user.find({age:{$gt:20, $lt:30}}).sort({registdate:1, name:-1}).skip(3),limit(2)
那麼MongoDB必須把前5條排序完才能給使用者返回第4-5條。如(前面的序號可以理解為指標跳轉序號):
s
Google執行個體
首先看一下Google老大哥是怎麼做到的。
關鍵詞為:nba
這是搜尋到的結果數:
以及導航翻頁欄:
手動點了一下翻頁,速度還挺快的,不過這隻不過是1000條資料以內的翻頁,沒有任何參考意義,於是我不停的往後翻,直到:
嗯,這個令我頓時語塞……
只能翻到70頁,之後的全被Cut掉了。不信的可以自己試試,基本不會讓你翻超過80頁。而且注意關於搜尋結果數:
獲得約:675,000,000,000條結果……
Google用了個“約”字,資料庫中肯定不會是這個值。
綜上,Google關於分頁的處理方法是,採用一定的方法擷取匹配到的結果的大概值,這種方法類似於:只匹配重要度排名靠前的部分資料,然後根據這部分所佔比例估算出總匹配結果數。在資料呈現時也只顯示700條左右的資料,因為使用者基本不會翻那麼多頁。
針對翻頁需求的分析
在解決翻頁問題之前,必須首先解決一些問題:
問題1:翻頁功能有必要嗎?
是的,一般來說是很有必要的。
問題2:翻幾十頁的功能也必要嗎?
嗯,也許有些情況下是需要的。
問題3:翻幾百頁的功能真的有必要嗎?
這個,真的很難想出相應的需求來。
需求:有人會提出需求說,我公司有上千萬的物品資訊,我要尋找的物品正好在第1W頁,所以我要求有能夠一次翻1W頁的功能。
解決辦法:這真的是一個要求翻頁功能的需求嗎?客戶是如何在未一頁一頁翻頁的情況下就知道該物品在資訊系統的第1W頁的,一定有物品的相應屬性資訊約束了它必定會出現在第1W頁,比如ID資訊,其ID自增,並且該物品ID為100002,資訊系統每頁顯示10件物品的話,升序情況下,該物品會出現在第1W頁。我們不一定會知道那麼詳細,但一定會有個大概的資訊,例如ID是10000X,就可以用ID > 100000 && ID < 100010來快速尋找到。
綜上,解決這種需求的方法是,告訴客戶,我有更好的方法為您找到這件物品,那就是用查詢代替翻頁。
很多情況下的需求都要經過這種轉變,如果客戶要你做什麼你就做什麼,那你就不是產品經理了,而只是傳話筒罷了。Google之所以敢只提供給使用者700條的資料會是因為他的資料量不夠嗎?當然不是,這是一種效能與客戶體驗的折中方案,一般來說,使用者在搜尋結果的前10頁就能解決自己想要的問題。即使沒找到,Google認為翻到70頁還沒找到結果就應該修改或者更換關鍵詞再次搜尋了。
解決方案
下面我們來處理些實際的,不再耍嘴皮子了。
方案1:類Google式效能與使用者體驗折中
註:Google的處理方案肯定不是這樣,而是採用類似分散式運算的方式,我只是以呈現給使用者的方式來定義本種方案的。
首先,要保證使用者查詢的排序條件一定要有索引,然後:
db.test.find({“context”:”nba”}).sort({“date”:-1}).limit(1000)
只查詢前1000條資料
1)如果返回的結果數 < 1000,那麼直接呈現給使用者。
2) 否則根據最後一條資料的排序條件的資料資訊進行分析,得到估算值。以本例來說,假設返回的最後一條資料的日期是lastDate = “2012-11-05 08:00:00”,而你的資料庫中儲存的資料是從originDate = “2010-01-01 08:00:00”至今的,目前時間(資料庫中最新的資料時間)是nowDate = “2012-11-06 18:00:00”,那麼:
這隻是粗略的一個公式,例中資料在時間上是基本均勻分布的,實際應用中可能不是這樣,比如按年齡排序,資料庫中儲存的使用者資料是中間多兩邊少的情況就需要調整這一公式,為每一年齡段的使用者資料量加上一定的權重再計算。
針對此種情況舉一例:
年齡段 |
使用者比例 |
0~10 |
0% |
10~20 |
6.4% |
20~30 |
29.1% |
30~40 |
46.9% |
40~50 |
14.7% |
50~60 |
2.9% |
60+ |
0% |
你的網站中有如上的使用者構成,然後進行了一次按使用者年齡排序的查詢:
db.user.find({“name”:/立/,”age”:{“$gt”:18}}).sort({age:1}).limit(1000)
查詢出最後一條資料的年齡為21,那麼可以粗略算出匹配到的數目約為:
在記憶體中進行如此簡單的數學公式計算可要比遍曆約24倍的資料快多了。
方案2:限定翻頁的頁數
上面這種方案過於討巧,本方案完全實現了全部資料的翻頁,方法的精髓在於:
不要讓使用者一次進行20頁以上的翻頁操作。
這個20頁不是固定的,可以根據效能進行調整。進行這種限定的原因在於:在知道目前使用者正在查看的資料的前提下,向後進行一定數量的翻頁其效能是可控的。舉個栗子:
db.test.find().sort(date:-1).limit(200)
在date有索引的前提下,這個查詢是相當快的,因為資料是已經排好序的,指標只需要遍曆前200條(或後200)就可以返回結果。再看下面的查詢語句:
db.test.find({“date”:{“$lt” 1352194000:}}).sort(date:-1).limit(200)
註:date的資料存放區格式為時間戳記,單位為秒
這條查詢依然很快,因為指標會在date的索引數上定位1352194000這條資料所在位置,然後順序讀200條資料即可,與前面的查詢相比效能差不多。
下面說一下實際操作的方法:
首先,屏蔽掉支援使用者手動指定翻頁頁數的功能,當使用者目前處在第15頁時,導航條只顯示最多前後20頁的導覽列(其實10頁就足夠了,如baidu)。
然後你必須能夠擷取本頁最後一條資料的相應排序條件的資料資訊,比如查詢是以ID排序的,就需要知道本頁最後一條資料的ID值,設為queryID。
假設每頁顯示20條,使用者翻到24頁,那麼執行:
db.test.find({ID:{$gt:queryID}}).sort({ID:1}).limit(180)
取最後的20條就可以了。
此外,可以為導覽列加上“向前20頁”、“向後20頁”這樣的功能,是完全沒有問題的。還有“首頁”“末頁”也是可以實現的。
方案3:以空間換取時間
簡單說就是將查詢到的相關資訊緩衝起來,當然,緩衝也有兩種不同的方式:
1. 緩衝全部查詢到的資訊。這個視每次查詢到的資料量大小以及你的伺服器記憶體大小而定,不行的話就用第二種方式。
2. 緩衝關鍵索引資訊。主要是使用者用於排序的欄位。
這裡講一下第二種方式,再舉例:
例一:
以ID為唯一索引的表為例,使用者查詢後也是按照ID進行排序的,不提供其他排序方式,那麼可以採用每10個ID緩衝一次的資料結構進行儲存,採取10的原因是一般每頁顯示數量為10,20或50比較常見。如果採用的是索引值對方式的緩衝方案,如MemberCached或Redis,可以儲存為:
key |
value |
4X0001 |
1 |
4X0011 |
2 |
4X0021 |
3 |
4X0031 |
4 |
…… |
…… |
前面的ID即是要查詢的資料表的主鍵ID,value為分頁用的頁碼/序號,比如Redis中有一種帶權值的ZSET資料結構,查詢任何一頁的效能都為常數複雜度。
時間:緩衝回應時間(常數)+資料庫回應時間(常數)。
空間:設ID為64位,資料量在200億以內,則需緩衝n * (64 + 32) / 10位大小,大約為n * 1.2 Byte。一個億也就是120MB的佔用。
例二:
可排序欄位有:價格(price)、數量(num)、時間(date)。另外若出現排序欄位值相同,則按ID進行排序。
每種排序都需要儲存為一個資料結構,以價格為例,key為[price]:[ID],value仍為翻頁用的頁數:
key |
value |
1:4X0219 |
1 |
1:4X9555 |
2 |
3:4X1500 |
3 |
6:4X3038 |
4 |
…… |
…… |
數量和時間也以這種方式進行儲存。
時間:緩衝回應時間(常數)+資料庫回應時間(常數)。
空間:總體和例一計算方式一樣,因為多了首碼,所以每個資料結構要大1/3到2/3,要排序的欄位越多,需要儲存的資料結構也就越多。理想情況下,一億資料量3欄位排序需要480MB左右。
轉載請註明出處:http://blog.sina.com.cn/s/blog_56545fd30101442b.html
MongoDB分頁處理方案(適用於一般資料庫的分頁方法)