58同城作為中國最大的生活服務平臺,涵蓋了房產、招聘、二手、二手車、黃頁等核心業務。58同城發展之初,大規模使用關系型數據庫(SQL Server、MySQL等),隨著業務擴展速度增加,數據量和并發量演變的越來越有挑戰,此階段58的數據存儲架構也需要相應的調整以更好的滿足業務快速發展的需求。MongoDB經過幾個版本的迭代,到2.0.0以后,變的越來越穩定,它具備的高性能、高擴展性、Auto-Sharding、Free-Schema、類SQL的豐富查詢和索引等特性,非常誘惑,同時58同城在一些典型業務場景下使用MongoDB也較合適,2011年,我們開始使用MongoDB,逐步擴大了使用的業務線,覆蓋了58幫幫、58交友、58招聘、信息質量等等多條業務線。隨著58每天處理的海量數據越來越大,并呈現不斷增多的趨勢,這為MongoDB在存儲與處理方面帶來了諸多的挑戰。面對百億量級的數據,我們該如何存儲與處理,本文將詳細介紹MongoDB遇到的問題以及最終如何“完美”解決。
本文詳細講述MongoDB在58同城的應用實踐:MongoDB在58同城的使用情況;為什么要使用MongoDB;MongoDB在58同城的架構設計與實踐;針對業務場景我們在MongoDB中如何設計庫和表;數據量增大和業務并發,我們遇到典型問題及其解決方案;MongoDB如何監控。
MongoDB在58同城的使用情況
MongoDB在58同城的眾多業務線都有大規模使用:58轉轉、58幫幫、58交友、58招聘、58信息質量、58測試應用等,如[圖1]所示。
圖1 MongoDB典型的使用場景:轉轉
為什么要使用MongoDB?
MongoDB這個來源英文單詞“humongous”,homongous這個單詞的意思是“巨大的”、“奇大無比的”,從MongoDB單詞本身可以看出它的目標是提供海量數據的存儲以及管理能力。MongoDB是一款面向文檔的NoSQL數據庫,MongoDB具備較好的擴展性以及高可用性,在數據復制方面,支持Master-Slaver(主從)和Replica-Set(副本集)等兩種方式。通過這兩種方式可以使得我們非常方便的擴展數據。MongoDB較高的性能也是它強有力的賣點之一,存儲引擎使用的內存映射文件(MMAP的方式),將內存管理工作交給操作系統去處理。MMAP的機制,數據的操作寫內存即是寫磁盤,在保證數據一致性的前提下,提供了較高的性能。除此之外,MongoDB還具備了豐富的查詢支持、較多類型的索引支持以及Auto-Sharding的功能。在所有的NoSQL產品中,MongoDB對查詢的支持是最類似于傳統的RDBMS,這也使得應用方可以較快的從RDBMS轉換到MonogoDB。
在58同城,我們的業務特點是具有較高的訪問量,并可以按照業務進行垂直的拆分,在每個業務線內部通過MongoDB提供兩種擴展機制,當業務存儲量和訪問量變大,我們可以較易擴展。同時我們的業務類型對事務性要求低,綜合業務這幾點特性,在58同城使用MongoDB是較合適的。
如何使用MongoDB?
MongoDB作為一款NoSQL數據庫產品,Free Schema是它的特性之一,在設計我們的數據存儲時,不需要我們固定Schema,提供給業務應用方較高的自由度。那么問題來了,Free Schema真的Free嗎?第一:Free Schema意味著重復Schema。在MongoDB數據存儲的時候,不但要存儲數據本身,Schema(字段key)本身也要重復的存儲(例如:{“name”:”zhuanzhuan”, “infoid”:1,“infocontent”:”這個是轉轉商品”}),必然會造成存儲空間的增大。第二:Free Schema意味著All Schema,任何一個需要調用MongoDB數據存儲的地方都需要記錄數據存儲的Schema,這樣才能較好的解析和處理,必然會造成業務應用方的復雜度。那么我們如何應對呢?在字段名Key選取方面,我們盡可能減少字段名Key的長度,比如:name字段名使用n來代替,infoid字段名使用i來代替,infocontent字段名使用c來代替(例如:{“n”:”zhuanzhuan”, “i”:1, “c”:”這個是轉轉商品”})。使用較短的字段名會帶來較差的可讀性,我們通過在使用做字段名映射的方式( #defineZZ_NAME? ("n")),解決了這個問題;同時在數據存儲方面我們啟用了數據存儲的壓縮,盡可能減少數據存儲的量。
MongoDB提供了自動分片(Auto-Sharding)的功能,經過我們的實際測試和線上驗證,并沒有使用這個功能。我們啟用了MongoDB的庫級Sharding;在CollectionSharding方面,我們使用手動Sharding的方式,水平切分數據量較大的文檔。
MongoDB的存儲文檔必須要有一個“_id”字段,可以認為是“主鍵”。這個字段值可以是任何類型,默認一個ObjectId對象,這個對象使用了12個字節的存儲空間,每個字節存儲兩位16進制數字,是一個24位的字符串。這個存儲空間消耗較大,我們實際使用情況是在應用程序端,使用其他的類型(比如int)替換掉到,一方面可以減少存儲空間,另外一方面可以較少MongoDB服務端生成“_id”字段的開銷。在每一個集合中,每個文檔都有唯一的“_id”標示,來確保集中每個文檔的唯一性。而在不同集合中,不同集合中的文檔“_id”是可以相同的。比如有2個集合Collection_A和Collection_B,Collection_A中有一個文檔的“_id”為1024,在Collection_B中的一個文檔的“_id”也可以為1024。
MongoDB集群部署
MongoDB集群部署我們采用了Sharding+Replica-Set的部署方式。整個集群有Shard Server節點(存儲節點,采用了Replica-Set的復制方式)、Config Server節點(配置節點)、Router Server(路由節點、Arbiter Server(投票節點)組成。每一類節點都有多個冗余構成。滿足58業務場景的一個典型MongoDB集群部署架構如下所示[圖2]:
圖2 58同城典型業務MongoDB集群部署架構
在部署架構中,當數據存儲量變大后,我們較易增加Shard Server分片。Replica-Set的復制方式,分片內部可以自由增減數據存儲節點。在節點故障發生時候,可以自動切換。同時我們采用了讀寫分離的方式,為整個集群提供更好的數據讀寫服務。
圖3 Auto-Sharding MAY is not that Reliable
針對業務場景我們在MongoDB中如何設計庫和表
MongoDB本身提供了Auto-Sharding的功能,這個智能的功能作為MongoDB的最具賣點的特性之一,真的非常靠譜嗎[圖3]?也許理想是豐滿的,現實是骨干滴。首先是在Sharding Key選擇上,如果選擇了單一的Sharding Key,會造成分片不均衡,一些分片數據比較多,一些分片數據較少,無法充分利用每個分片集群的能力。為了彌補單一Sharding Key的缺點,引入復合Sharing Key,然而復合Sharding Key會造成性能的消耗;第二count值計算不準確的問題,數據Chunk在分片之間遷移時,特定數據可能會被計算2次,造成count值計算偏大的問題;第三Balancer的穩定性&智能性問題,Sharing的遷移發生時間不確定,一旦發生數據遷移會造成整個系統的吞吐量急劇下降。為了應對Sharding遷移的不確定性,我們可以強制指定Sharding遷移的時間點,具體遷移時間點依據業務訪問的低峰期。比如IM系統,我們的流量低峰期是在凌晨1點到6點,那么我們可以在這段時間內開啟Sharding遷移功能,允許數據的遷移,其他的時間不進行數據的遷移,從而做到對Sharding遷移的完全掌控,避免掉未知時間Sharding遷移帶來的一些風險。
如何設計庫(DataBase)?
我們的MongoDB集群線上環境全部禁用了Auto-Sharding功能。如上節所示,僅僅提供了指定時間段的數據遷移功能。線上的數據我們開啟了庫級的分片,通過db.runCommand({“enablesharding”: “im”});命令指定。并且我們通過db.runCommand({movePrimary:“im”, to: “sharding1”});命令指定特定庫到某一固定分片上。通過這樣的方式,我們保證了數據的無遷移性,避免了Auto-Sharding帶來的一系列問題,數據完全可控,從實際使用情況來看,效果也較好。既然我們關閉了Auto-Sharding的功能,就要求對業務的數據增加情況提前做好預估,詳細了解業務半年甚至一年后的數據增長情況,在設計MongoDB庫時需要做好規劃:確定數據規模、確定數據庫分片數量等,避免數據庫頻繁的重構和遷移情況發生。那么問題來了,針對MongoDB,我們如何做好容量規劃?MongoDB集群高性能本質是MMAP機制,對機器內存的依賴較重,因此我們要求業務熱點數據和索引的總量要能全部放入內存中,即:Memory > Index + Hot Data。一旦數據頻繁地Swap,必然會造成MongoDB集群性能的下降。當內存成為瓶頸時,我們可以通過Scale Up或者Scale Out的方式進行擴展。第二:我們知道MongoDB的數據庫是按文件來存儲的:例如:db1下的所有collection都放在一組文件內db1.0,db1.1,db1.2,db1.3……db1.n。數據的回收也是以庫為單位進行的,數據的刪除將會造成數據的空洞或者碎片,碎片太多,會造成數據庫空間占用較大,加載到內存時也會存在碎片的問題,內存使用率不高,會造成數據頻繁地在內存和磁盤之間Swap,影響MongoDB集群性能。因此將頻繁更新刪除的表放在一個獨立的數據庫下,將會減少碎片,從而提高性能。第三:單庫單表絕對不是最好的選擇。原因有三:表越多,映射文件越多,從MongoDB的內存管理方式來看,浪費越多;同理,表越多,回寫和讀取的時候,無法合并IO資源,大量的隨機IO對傳統硬盤是致命的;單表數據量大,索引占用高,更新和讀取速度慢。第四:Local庫容量設置。我們知道Local庫主要存放oplog,oplog用于數據的同步和復制,oplog同樣要消耗內存的,因此選擇一個合適的oplog值很重要,如果是高插入高更新,并帶有延時從庫的副本集需要一個較大的oplog值(比如50G);如果沒有延時從庫,并且數據更新速度不頻繁,則可以適當調小oplog值(比如5G)。總之,oplog值大小的設置取決于具體業務應用場景,一切脫離業務使用場景來設置oplog的值大小都是耍流氓。
如何設計表(Collection)?
MongoDB在數據邏輯結構上和RDBMS比較類似,如圖4所示:MongoDB三要素:數據庫(DataBase)、集合(Collection)、文檔(Document)分別對應RDBMS(比如MySQL)三要素:數據庫(DataBase)、表(Table)、行(Row)。
圖4 MongoDB和RDBMS數據邏輯結構對比
MongoDB作為一支文檔型的數據庫允許文檔的嵌套結構,和RDBMS的三范式結構不同,我們以“人”描述為例,說明兩者之間設計上的區別。“人”有以下的屬性:姓名、性別、年齡和住址;住址是一個復合結構,包括:國家、城市、街道等。針對“人”的結構,傳統的RDBMS的設計我們需要2張表:一張為People表[圖5],另外一張為Address表[圖6]。這兩張表通過住址ID關聯起來(即Addess ID是People表的外鍵)。在MongoDB表設計中,由于MongoDB支持文檔嵌套結構,我可以把住址復合結構嵌套起來,從而實現一個Collection結構[圖7],可讀性會更強。
圖5 RDBMSPeople表設計
圖6 RDBMS Address表設計
圖7 MongoDB表設計
MongoDB作為一支NoSQL數據庫產品,除了可以支持嵌套結構外,它又是最像RDBMS的產品,因此也可以支持“關系”的存儲。接下來會詳細講述下對應RDBMS中的一對一、一對多、多對多關系在MongoDB中我們設計和實現。IM用戶信息表,包含用戶uid、用戶登錄名、用戶昵稱、用戶簽名等,是一個典型的一對一關系,在MongoDB可以采用類RDBMS的設計,我們設計一張IM用戶信息表user:{_id:88, loginname:musicml, nickname:musicml,sign:love},其中_id為主鍵,_id實際為uid。IM用戶消息表,一個用戶可以收到來自他人的多條消息,一個典型的一對多關系。我們如何設計?一種方案,采用RDBMS的“多行”式設計,msg表結構為:{uid,msgid,msg_content},具體的記錄為:123, 1, 你好;123,2,在嗎。另外一種設計方案,我們可以使用MongoDB的嵌套結構:{uid:123, msg:{[{msgid:1,msg_content:你好},{msgid:2, msg_content:在嗎}]}}。采用MongoDB嵌套結構,會更加直觀,但也存在一定的問題:更新復雜、MongoDB單文檔16MB的限制問題。采用RDBMS的“多行”設計,它遵循了范式,一方面查詢條件更靈活,另外通過“多行式”擴展性也較高。在這個一對多的場景下,由于MongoDB單條文檔大小的限制,我們并沒采用MongoDB的嵌套結構,而是采用了更加靈活的類RDBMS的設計。在User和Team業務場景下,一個Team中有多個User,一個User也可能屬于多個Team,這種是典型的多對多關系。在MongoDB中我們如何設計?一種方案我們可以采用類RDBMS的設計。一共三張表:Team表{teamid,teamname, ……},User表{userid,username,……},Relation表{refid, userid, teamid}。其中Team表存儲Team本身的元信息,User表存儲User本身的元信息,Relation表存儲Team和User的所屬關系。在MongoDB中我們可以采用嵌套的設計方案:一種2張表:Team表{teamid,teamname,teammates:{[userid, userid, ……]},存儲了Team所有的User成員和User表{useid,usename,teams:{[teamid, teamid,……]}},存儲了User所有參加的Team。在MongoDB Collection上我們并沒有開啟Auto-Shariding的功能,那么當單Collection數據量變大后,我們如何Sharding?對Collection Sharding 我們采用手動水平Sharding的方式,單表我們保持在千萬級別文檔數量。當Collection數據變大,我們進行水平拆分。比如IM用戶信息表:{uid, loginname, sign, ……},可用采用uid取模的方式水平擴展,比如:uid%64,根據uid查詢可以直接定位特定的Collection,不用跨表查詢。通過手動Sharding的方式,一方面根據業務的特點,我們可以很好滿足業務發展的情況,另外一方面我們可以做到可控、數據的可靠,從而避免了Auto-Sharding帶來的不穩定因素。對于Collection上只有一個查詢維度(uid),通過水平切分可以很好滿足。但是對于Collection上有2個查詢維度,我們如何處理?比如商品表:{uid, infoid, info,……},存儲了商品發布者,商品ID,商品信息等。我們需要即按照infoid查詢,又能支持按照uid查詢。為了支持這樣的查詢需求,就要求infoid的設計上要特殊處理:infoid包含uid的信息(infoid最后8個bit是uid的最后8個bit),那么繼續采用infoid取模的方式,比如:infoid%64,這樣我們既可以按照infoid查詢,又可以按照uid查詢,都不需要跨Collection查詢。
數據量、并發量增大,遇到問題及其解決方案
大量刪除數據問題及其解決方案
我們在IM離線消息中使用了MongoDB,IM離線消息是為了當接收方不在線時,需要把發給接收者的消息存儲下來,當接收者登錄IM后,讀取存儲的離線消息后,這些離線消息不再需要。已讀取離線消息的刪除,設計之初我們考慮物理刪除帶來的性能損耗,選擇了邏輯標識刪除。IM離線消息Collection包含如下字段:msgid, fromuid, touid, msgcontent, timestamp, flag。其中touid為索引,flag表示離線消息是否已讀取,0未讀,1讀取。當IM離線消息已讀條數積累到一定數量后,我們需要進行物理刪除,以節省存儲空間,減少Collection文檔條數,提升集群性能。既然我們通過flag==1做了已讀取消息的標示,第一時間想到了通過flag標示位來刪除:db.collection.remove({“flag” :1}};一條簡單的命令就可以搞定。表面上看很容易就搞定了?!實際情況是IM離線消息表5kw條記錄,近200GB的數據大小。悲劇發生了:晚上10點后部署刪除直到早上7點還沒刪除完畢;MongoDB集群和業務監控斷續有報警;從庫延遲大;QPS/TPS很低;業務無法響應。事后分析原因:雖然刪除命令db.collection.remove({“flag” : 1}};很簡單,但是flag字段并不是索引字段,刪除操作等價于全部掃描后進行,刪除速度很慢,需要刪除的消息基本都是冷數據,大量的冷數據進入內存中,由于內存容量的限制,會把內存中的熱數據swap到磁盤上,造成內存中全是冷數據,服務能力急劇下降。遇到問題不可怕,我們如何解決呢?首先我們要保證線上提供穩定的服務,采取緊急方案,找到還在執行的opid,先把此命令殺掉(kill opid),恢復服務。長期方案,我們首先優化了離線刪除程序[圖8],把已讀IM離線消息的刪除操作,每晚定時從庫導出要刪除的數據,通過腳本按照objectid主鍵(_id)的方式進行刪除,并且刪除速度通過程序控制,從避免對線上服務影響。其次,我們通過用戶的離線消息的讀取行為來分析,用戶讀取離線消息時間分布相對比較均衡,不會出現比較密度讀取的情形,也就不會對MongoDB的更新帶來太大的影響,基于此我們把用戶IM離線消息的刪除由邏輯刪除優化成物理刪除,從而從根本上解決了歷史數據的刪除問題。
圖8離線刪除優化腳本
大量數據空洞問題及其解決方案
MongoDB集群大量刪除數據后(比如上節中的IM用戶離線消息刪除)會存在大量的空洞,這些空洞一方面會造成MongoDB數據存儲空間較大,另外一方面這些空洞數據也會隨之加載到內存中,導致內存的有效利用率較低,在機器內存容量有限的前提下,會造成熱點數據頻繁的Swap,頻繁Swap數據,最終使得MongoDB集群服務能力下降,無法提供較高的性能。通過上文的描述,大家已經了解MongoDB數據空間的分配是以DB為單位,而不是以Collection為單位的,存在大量空洞造成MongoDB性能低下的原因,問題的關鍵是大量碎片無法利用,因此通過碎片整理、空洞合并收縮等方案,我們可以提高MongoDB集群的服務能力。那么我們如何落地呢?方案一:我們可以使用MongoDB提供的在線數據收縮的功能,通過Compact命令(db.yourCollection.runCommand(“compact”);)進行Collection級別的數據收縮,去除Collectoin所在文件碎片。此命令是以Online的方式提供收縮,收縮的同時會影響到線上的服務,其次從我們實際收縮的效果來看,數據空洞收縮的效果不夠顯著。因此我們在實際數據碎片收縮時沒有采用這種方案,也不推薦大家使用這種空洞數據的收縮方案。既然這種數據方案不夠好,我們可以采用Offline收縮的方案二:此方案收縮的原理是:把已有的空洞數據,remove掉,重新生成一份無空洞數據。那么具體如何落地?先預熱從庫;把預熱的從庫提升為主庫;把之前主庫的數據全部刪除;重新同步;同步完成后,預熱此庫;把此庫提升為主庫。具體的操作步驟如下:檢查服務器各節點是否正常運行 (ps -ef |grep mongod);登入要處理的主節點 /mongodb/bin/mongo--port 88888;做降權處理rs.stepDown(),并通過命令 rs.status()來查看是否降權;切換成功之后,停掉該節點;檢查是否已經降權,可以通過web頁面查看status,我們建議最好登錄進去保證有數據進入,或者是mongostat 查看; kill 掉對應mongo的進程: kill 進程號;刪除數據,進入對應的分片刪除數據文件,比如: rm -fr /mongodb/shard11/*;重新啟動該節點,執行重啟命令,比如:如:/mongodb/bin/mongod --config /mongodb/shard11.conf;通過日志查看進程;數據同步完成后,在修改后的主節點上執行命令 rs.stepDown() ,做降權處理。通過這種Offline的收縮方式,我們可以做到收縮率是100%,數據完全無碎片。當然做離線的數據收縮會帶來運維成本的增加,并且在Replic-Set集群只有2個副本的情況下,還會存在一段時間內的單點風險。通過Offline的數據收縮后,收縮前后效果非常明顯,如[圖9,圖10]所示:收縮前85G存儲文件,收縮后34G存儲文件,節省了51G存儲空間,大大提升了性能。
圖9收縮MongoDB數據庫前存儲數據大小
圖10收縮MongoDB數據庫后存儲數據大小
MongoDB集群監控
MongoDB集群有多種方式可以監控:mongosniff、mongostat、mongotop、db.xxoostatus、web控制臺監控、MMS、第三方監控。我們使用了多種監控相結合的方式,從而做到對MongoDB整個集群完全Hold住。第一是mongostat[圖11],mongostat是對MongoDB集群負載情況的一個快照,可以查看每秒更新量、加鎖時間占操作時間百分比、缺頁中斷數量、索引miss的數量、客戶端查詢排隊長度(讀|寫)、當前連接數、活躍客戶端數量(讀|寫)等。
圖11MongoDB mongostat監控
mongstat可以查看的字段較多,我們重點關注Locked、faults、miss、qr|qw等,這些值越小越好,最好都為0;locked最好不要超過10%;造成faults、miss原因主要是內存不夠或者內冷數據頻繁Swap,索引設置不合理;qr|qw堆積較多,反應了數據庫處理慢,這時候我們需要針對性的優化。
第二是web控制臺,和MongoDB服務一同開啟,它的監聽端口是MongoDB服務監聽端口加上1000,如果MongoDB的監聽端口33333,則Web控制臺端口為34333。我們可以通過http://ip:port(http://8.8.8.8:34333)訪問監控了什么[圖12]:當前MongoDB所有的連接數、各個數據庫和Collection的訪問統計包括:Reads, Writes, Queries等、寫鎖的狀態、最新的幾百行日志文件。
圖12 ?MongoDB Web控制臺監控
第三是MMS(MongoDBMonitoring Service),它是2011年官方發布的云監控服務,提供可視化圖形監控。工作原理如下:在MMS服務器上配置需要監控的MongoDB信息(ip/port/user/passwd等);在一臺能夠訪問你MongoDB服務的內網機器上運行其提供的Agent腳本;Agent腳本從MMS服務器獲取到你配置的MongoDB信息;Agent腳本連接到相應的MongoDB獲取必要的監控數據;Agent腳本將監控數據上傳到MMS的服務器;登錄MMS網站查看整理過后的監控數據圖表。具體的安裝部署,可以參考:http://mms.10gen.com。
圖13 MongoDB MMS監控
第四是第三方監控,MongoDB開源愛好者和團隊支持者較多,可以在常用監控框架上擴展,比如:zabbix,可以監控CPU負荷、內存使用、磁盤使用、網絡狀況、端口監視、日志監視等;nagios,可以監控監控網絡服務(HTTP等)、監控主機資源(處理器負荷、磁盤利用率等)、插件擴展、報警發送給聯系人(EMail、短信、用戶定義方式)、手機查看方式;cacti,可以基于PHP,MySQL,SNMP及RRDTool開發的網絡流量監測圖形分析工具。
最后我要感謝公司和團隊,在MongoDB集群的大規模實戰中積累了寶貴的經驗,才能讓我有機會撰寫了此文,由于MongoDB社區不斷發展,特別是MongoDB 3.0,對性能、數據壓縮、運維成本、鎖級別、Sharding以及支持可插拔的存儲引擎等的改進,MongoDB越來越強大。文中可能會存在一些不妥的地方,歡迎大家交流指正。