線上啟動並執行服務會產生大量的運行及訪問日誌,日誌裡會包含一些錯誤、警告及使用者行為等資訊。通常服務會以文本的形式記錄日誌資訊,這樣可讀性強,方便於日常定位問題。但當產生大量的日誌之後,要想從大量日誌裡挖掘出有價值的內容,則需要對資料進行進一步的儲存和分析。
本文以儲存Web服務的訪問日誌為例,介紹如何使用MongoDB來儲存、分析日誌資料,讓日誌資料發揮最大的價值。本文的內容同樣適用於其他的日誌儲存型應用。
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裡的儲存模式如下所示:
{
_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的文檔裡前,先提取出各個欄位的值。如下所示,上述的日誌被轉換為一個包含很多個欄位的文檔。
{
_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: 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})
查詢某一天的所有請求:
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欄位),這種分區方式存在如下問題:
因為時間戳記一直順序增長的特性,新的寫入都會分到同一個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個集合裡:
events-201601 events-201602 events-201603 events-201604 .... events-201612
當需要清理歷史資料時,直接將對應的集合刪除:
db["events-201601"].drop() db["events-201602"].drop()
不足的地方是,如果要查詢多個月份的資料,查詢的語句會稍微複雜些,需要從多個集合裡查詢結果來合并。