使用 MongoDB 存儲日志數據


線上運行的服務會產生大量的運行及訪問日志,日志里會包含一些錯誤、警告、及用戶行為等信息。通常服務會以文本的形式記錄日志信息,這樣可讀性強,方便於日常定位問題。但當產生大量的日志之后,要想從大量日志里挖掘出有價值的內容,則需要對數據進行進一步的存儲和分析。

本文以存儲 web 服務的訪問日志為例,介紹如何使用 MongoDB 來存儲、分析日志數據,讓日志數據發揮最大的價值。本文的內容同樣適用於其他的日志存儲型應用。

模式設計

一個典型的web服務器的訪問日志類似如下,包含訪問來源、用戶、訪問的資源地址、訪問結果、用戶使用的系統及瀏覽器類型等。

  1. 127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326 "[http://www.example.com/start.html](http://www.example.com/start.html)" "Mozilla/4.08 [en] (Win98; I ;Nav)"

最簡單存儲這些日志的方法是,將每行日志存儲在一個單獨的文檔里,每行日志在MongoDB里的存儲模式如下所示:

  1. {
  2. _id: ObjectId('4f442120eb03305789000000'),
  3. line: '127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326 "[http://www.example.com/start.html](http://www.example.com/start.html)" "Mozilla/4.08 [en] (Win98; I ;Nav)"'
  4. }

上述模式雖然能解決日志存儲的問題,但這些數據分析起來比較麻煩,因為文本分析並不是MongoDB所擅長的,更好的辦法是把一行日志存儲到MongoDB的文檔里前,先提取出各個字段的值。如下所示,上述的日志被轉換為一個包含很多個字段的文檔。

  1. {
  2. _id: ObjectId('4f442120eb03305789000000'),
  3. host: "127.0.0.1",
  4. logname: null,
  5. user: 'frank',
  6. time: ISODate("2000-10-10T20:55:36Z"),
  7. path: "/apache_pb.gif",
  8. request: "GET /apache_pb.gif HTTP/1.0",
  9. status: 200,
  10. response_size: 2326,
  11. referrer: "[http://www.example.com/start.html](http://www.example.com/start.html)",
  12. user_agent: "Mozilla/4.08 [en] (Win98; I ;Nav)"
  13. }

同時,在這個過程中,如果您覺得有些字段對數據分析沒有任何幫助,則可以直接過濾掉,以減少存儲上的消耗。比如數據分析不會關心user信息、request、status信息,這幾個字段沒必要存儲。ObjectId里本身包含了時間信息,沒必要再單獨存儲一個time字段 (當然帶上time也有好處,time更能代表請求產生的時間,而且查詢語句寫起來更方便,盡量選擇存儲空間占用小的數據類型)基於上述考慮,上述日志最終存儲的內容可能類似如下所示:

  1. {
  2. _id: ObjectId('4f442120eb03305789000000'),
  3. host: "127.0.0.1",
  4. time: ISODate("2000-10-10T20:55:36Z"),
  5. path: "/apache_pb.gif",
  6. referer: "[http://www.example.com/start.html](http://www.example.com/start.html)",
  7. user_agent: "Mozilla/4.08 [en] (Win98; I ;Nav)"
  8. }

寫日志

日志存儲服務需要能同時支持大量的日志寫入,用戶可以定制writeConcern來控制日志寫入能力,比如如下定制方式:

  1. db.events.insert({
  2. host: "127.0.0.1",
  3. time: ISODate("2000-10-10T20:55:36Z"),
  4. path: "/apache_pb.gif",
  5. referer: "[http://www.example.com/start.html](http://www.example.com/start.html)",
  6. user_agent: "Mozilla/4.08 [en] (Win98; I ;Nav)"
  7. }
  8. )

說明:

  • 如果要想達到最高的寫入吞吐,可以指定writeConcern為 {w: 0}。
  • 如果日志的重要性比較高(比如需要用日志來作為計費憑證),則可以使用更安全的writeConcern級別,比如 {w: 1} 或 {w: “majority”}。

同時,為了達到最優的寫入效率,用戶還可以考慮批量的寫入方式,一次網絡請求寫入多條日志。格式如下所示:

db.events.insert([doc1, doc2, ...])

查詢日志

當日志按上述方式存儲到MongoDB后,就可以按照各種查詢需求查詢日志了。

查詢所有訪問/apache_pb.gif 的請求

q_events = db.events.find({'path': '/apache_pb.gif'})

如果這種查詢非常頻繁,可以針對path字段建立索引,提高查詢效率:

db.events.createIndex({path: 1})

查詢某一天的所有請求

  1. q_events = db.events.find({'time': { '$gte': ISODate("2016-12-19T00:00:00.00Z"),'$lt': ISODate("2016-12-20T00:00:00.00Z")}})

通過對time字段建立索引,可加速這類查詢:

db.events.createIndex({time: 1})

查詢某台主機一段時間內的所有請求

  1. q_events = db.events.find({
  2. 'host': '127.0.0.1',
  3. 'time': {'$gte': ISODate("2016-12-19T00:00:00.00Z"),'$lt': ISODate("2016-12-20T00:00:00.00Z" }
  4. })

同樣,用戶還可以使用MongoDB的aggregation、mapreduce框架來做一些更復雜的查詢分析,在使用時應該盡量建立合理的索引以提升查詢效率。

數據分片

當寫日志的服務節點越來越多時,日志存儲的服務需要保證可擴展的日志寫入能力以及海量的日志存儲能力,這時就需要使用MongoDB sharding來擴展,將日志數據分散存儲到多個shard,關鍵的問題就是shard key的選擇。

按時間戳字段分片

使用時間戳來進行分片(如ObjectId類型的_id,或者time字段),這種分片方式存在如下問題:

  • 因為時間戳一直順序增長的特性,新的寫入都會分到同一個shard,並不能擴展日志寫入能力。
  • 很多日志查詢是針對最新的數據,而最新的數據通常只分散在部分shard上,這樣導致查詢也只會落到部分shard。

按隨機字段分片

按照_id字段來進行hash分片,能將數據以及寫入都均勻都分散到各個shard,寫入能力會隨shard數量線性增長。但該方案的問題是,數據分散毫無規律。所有的范圍查詢(數據分析經常需要用到)都需要在所有的shard上進行查找然后合並查詢結果,影響查詢效率。

按均勻分布的key分片

假設上述場景里 path 字段的分布是比較均勻的,而且很多查詢都是按path維度去划分的,那么可以考慮按照path字段對日志數據進行分片,好處是:

  • 寫請求會被均分到各個shard。
  • 針對path的查詢請求會集中落到某個(或多個)shard,查詢效率高。

不足的地方是:

  • 如果某個path訪問特別多,會導致單個chunk特別大,只能存儲到單個shard,容易出現訪問熱點。
  • 如果path的取值很少,也會導致數據不能很好的分布到各個shard。

當然上述不足的地方也有辦法改進,方法是給分片key里引入一個額外的因子,比如原來的shard key是 {path: 1},引入額外的因子后變成:

{path: 1, ssk: 1} 其中ssk可以是一個隨機值,比如_id的hash值,或是時間戳,這樣相同的path還是根據時間排序的

這樣做的效果是分片key的取值分布豐富,並且不會出現單個值特別多的情況。上述幾種分片方式各有優劣,用戶可以根據實際需求來選擇方案。

應對數據增長

分片的方案能提供海量的數據存儲支持,但隨着數據越來越多,存儲的成本會不斷的上升。通常很多日志數據有個特性,日志數據的價值隨時間遞減。比如1年前、甚至3個月前的歷史數據完全沒有分析價值,這部分可以不用存儲,以降低存儲成本,而在MongoDB里有很多方法支持這一需求。

TTL 索引

MongoDB的TTL索引可以支持文檔在一定時間之后自動過期刪除。例如上述日志time字段代表了請求產生的時間,針對該字段建立一個TTL索引,則文檔會在30小時后自動被刪除。

db.events.createIndex( { time: 1 }, { expireAfterSeconds: 108000 } )

注意:TTL索引是目前后台用來定期(默認60s一次)刪除單線程已過期文檔的。如果日志文檔被寫入很多,會積累大量待過期的文檔,那么會導致文檔過期一直跟不上而一直占用着存儲空間。

使用Capped集合

如果對日志保存的時間沒有特別嚴格的要求,只是在總的存儲空間上有限制,則可以考慮使用capped collection來存儲日志數據。指定一個最大的存儲空間或文檔數量,當達到閾值時,MongoDB會自動刪除capped collection里最老的文檔。

db.createCollection("event", {capped: true, size: 104857600000}

定期按集合或DB歸檔

比如每到月底就將events集合進行重命名,名字里帶上當前的月份,然后創建新的events集合用於寫入。比如2016年的日志最終會被存儲在如下12個集合里:

  1. events-201601
  2. events-201602
  3. events-201603
  4. events-201604
  5. ....
  6. events-201612

當需要清理歷史數據時,直接將對應的集合刪除掉:

  1. db["events-201601"].drop()
  2. db["events-201602"].drop()

不足到時候,如果要查詢多個月份的數據,查詢的語句會稍微復雜些,需要從多個集合里查詢結果來合並。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM