线上运行的服务会产生大量的运行及访问日志,日志里会包含一些错误、警告、及用户行为等信息。一般服务会以文本的形式记录日志信息,这样可读性强,方便于平常定位问题。但当产生大量的日志以后,要想从大量日志里挖掘出有价值的内容,则须要对数据进行进一步的存储和分析。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()
不足到时候,若是要查询多个月份的数据,查询的语句会稍微复杂些,须要从多个集合里查询结果来合并。