線上運行的服務會產生大量的運行及訪問日誌,日誌裏會包含一些錯誤、警告、及用戶行爲等信息。一般服務會以文本的形式記錄日誌信息,這樣可讀性強,方便於平常定位問題。但當產生大量的日誌以後,要想從大量日誌裏挖掘出有價值的內容,則須要對數據進行進一步的存儲和分析。css
本文以存儲 web 服務的訪問日誌爲例,介紹如何使用 MongoDB 來存儲、分析日誌數據,讓日誌數據發揮最大的價值。本文的內容一樣適用於其餘的日誌存儲型應用。html
一個典型的web服務器的訪問日誌相似以下,包含訪問來源、用戶、訪問的資源地址、訪問結果、用戶使用的系統及瀏覽器類型等。web
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裏的存儲模式以下所示:docker
{
_id: ObjectId('4f442120eb03305789000000'),
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)"'
}
上述模式雖然能解決日誌存儲的問題,但這些數據分析起來比較麻煩,由於文本分析並非MongoDB所擅長的,更好的辦法是把一行日誌存儲到MongoDB的文檔裏前,先提取出各個字段的值。以下所示,上述的日誌被轉換爲一個包含不少個字段的文檔。apache
{
_id: ObjectId('4f442120eb03305789000000'),
host: "127.0.0.1",
logname: null,
user: 'frank',
time: ISODate("2000-10-10T20:55:36Z"),
path: "/apache_pb.gif",
request: "GET /apache_pb.gif HTTP/1.0",
status: 200,
response_size: 2326,
referrer: "[http://www.example.com/start.html](http://www.example.com/start.html)",
user_agent: "Mozilla/4.08 [en] (Win98; I ;Nav)"
}
同時,在這個過程當中,若是您以爲有些字段對數據分析沒有任何幫助,則能夠直接過濾掉,以減小存儲上的消耗。好比數據分析不會關心user信息、request、status信息,這幾個字段不必存儲。ObjectId裏自己包含了時間信息,不必再單獨存儲一個time字段 (固然帶上time也有好處,time更能表明請求產生的時間,並且查詢語句寫起來更方便,儘可能選擇存儲空間佔用小的數據類型)基於上述考慮,上述日誌最終存儲的內容可能相似以下所示:瀏覽器
{
_id: ObjectId('4f442120eb03305789000000'),
host: "127.0.0.1",
time: ISODate("2000-10-10T20:55:36Z"),
path: "/apache_pb.gif",
referer: "[http://www.example.com/start.html](http://www.example.com/start.html)",
user_agent: "Mozilla/4.08 [en] (Win98; I ;Nav)"
}
日誌存儲服務須要能同時支持大量的日誌寫入,用戶能夠定製writeConcern來控制日誌寫入能力,好比以下定製方式:安全
db.events.insert({
host: "127.0.0.1",
time: ISODate("2000-10-10T20:55:36Z"),
path: "/apache_pb.gif",
referer: "[http://www.example.com/start.html](http://www.example.com/start.html)",
user_agent: "Mozilla/4.08 [en] (Win98; I ;Nav)"
}
)
說明:服務器
- 若是要想達到最高的寫入吞吐,能夠指定writeConcern爲 {w: 0}。
- 若是日誌的重要性比較高(好比須要用日誌來做爲計費憑證),則可使用更安全的writeConcern級別,好比 {w: 1} 或 {w: 「majority」}。
同時,爲了達到最優的寫入效率,用戶還能夠考慮批量的寫入方式,一次網絡請求寫入多條日誌。格式以下所示:markdown
db.events.insert([doc1, doc2, ...])
網絡
當日志按上述方式存儲到MongoDB後,就能夠按照各類查詢需求查詢日誌了。
q_events = db.events.find({'path': '/apache_pb.gif'})
若是這種查詢很是頻繁,能夠針對path字段創建索引,提升查詢效率:
db.events.createIndex({path: 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})
q_events = db.events.find({
'host': '127.0.0.1',
'time': {'$gte': ISODate("2016-12-19T00:00:00.00Z"),'$lt': ISODate("2016-12-20T00:00:00.00Z" }
})
一樣,用戶還可使用MongoDB的aggregation、mapreduce框架來作一些更復雜的查詢分析,在使用時應該儘可能創建合理的索引以提高查詢效率。
當寫日誌的服務節點愈來愈多時,日誌存儲的服務須要保證可擴展的日誌寫入能力以及海量的日誌存儲能力,這時就須要使用MongoDB sharding來擴展,將日誌數據分散存儲到多個shard,關鍵的問題就是shard key的選擇。
使用時間戳來進行分片(如ObjectId類型的_id,或者time字段),這種分片方式存在以下問題:
按照_id字段來進行hash分片,能將數據以及寫入都均勻都分散到各個shard,寫入能力會隨shard數量線性增加。但該方案的問題是,數據分散毫無規律。全部的範圍查詢(數據分析常常須要用到)都須要在全部的shard上進行查找而後合併查詢結果,影響查詢效率。
假設上述場景裏 path 字段的分佈是比較均勻的,並且不少查詢都是按path維度去劃分的,那麼能夠考慮按照path字段對日誌數據進行分片,好處是:
不足的地方是:
固然上述不足的地方也有辦法改進,方法是給分片key裏引入一個額外的因子,好比原來的shard key是 {path: 1},引入額外的因子後變成:
{path: 1, ssk: 1} 其中ssk能夠是一個隨機值,好比_id的hash值,或是時間戳,這樣相同的path仍是根據時間排序的
這樣作的效果是分片key的取值分佈豐富,而且不會出現單個值特別多的狀況。上述幾種分片方式各有優劣,用戶能夠根據實際需求來選擇方案。
分片的方案能提供海量的數據存儲支持,但隨着數據愈來愈多,存儲的成本會不斷的上升。一般不少日誌數據有個特性,日誌數據的價值隨時間遞減。好比1年前、甚至3個月前的歷史數據徹底沒有分析價值,這部分能夠不用存儲,以下降存儲成本,而在MongoDB裏有不少方法支持這一需求。
MongoDB的TTL索引能夠支持文檔在必定時間以後自動過時刪除。例如上述日誌time字段表明了請求產生的時間,針對該字段創建一個TTL索引,則文檔會在30小時後自動被刪除。
db.events.createIndex( { time: 1 }, { expireAfterSeconds: 108000 } )
注意:TTL索引是目先後臺用來按期(默認60s一次)刪除單線程已過時文檔的。若是日誌文檔被寫入不少,會積累大量待過時的文檔,那麼會致使文檔過時一直跟不上而一直佔用着存儲空間。
若是對日誌保存的時間沒有特別嚴格的要求,只是在總的存儲空間上有限制,則能夠考慮使用capped collection來存儲日誌數據。指定一個最大的存儲空間或文檔數量,當達到閾值時,MongoDB會自動刪除capped collection裏最老的文檔。
db.createCollection("event", {capped: true, size: 104857600000}
好比每到月底就將events集合進行重命名,名字裏帶上當前的月份,而後建立新的events集合用於寫入。好比2016年的日誌最終會被存儲在以下12個集合裏:
events-201601
events-201602
events-201603
events-201604
....
events-201612
當須要清理歷史數據時,直接將對應的集合刪除掉:
db["events-201601"].drop()
db["events-201602"].drop()
不足到時候,若是要查詢多個月份的數據,查詢的語句會稍微複雜些,須要從多個集合裏查詢結果來合併。