构建轻量级日志系统
2018-03-06 21:12
|
10149
|
3
|
11
|
这篇文章给大家介绍构建小型的日志系统,算不上高大上,不能支撑企业级高并发,不能记录所有的服务器端日志,但是或许能解决一些特定场景下日志特定需求问题。系统是我自己从设计到开发再到维护,一人搞定,感谢团队的支持、认可、建议、帮助。
说明:业界比较常用的做法是使用ES或日志组件,通过日志采集服务,把服务器或者docker节点产生的日志文件收集到kafka,然后再通过ES存储,这种大公司通用的做法,有兴趣的同学,可以看看ES相关的知识
1:需求的现状
系统现状:系统庞大,业务复杂,功能点多,子系统多,业务反馈bug多,bug复现难,不是那么高并发,现有的日志系统没人愿意用。
日志需求:(开发团队和测试人员)能查所有的日志,能实时查日志,打日志不能影响性能,提供多维度模糊查询,提供查询入口页面,操作简单,可以开放给非技术人员、测试人员、产品等排查问题,能按照不同人的需求记录特定的条目。
现有日志:运维人员提供,业务和系统日志混杂在一起,非技术人员没有权限查看,不知道该怎么查询,日质量太大,模糊搜索是按照分词来的,无法精确定为自己打的日志,日志系统是运维提供,不能为我们系统提供特定的功能,查日志也需要到另外一个系统中,新人加入需要层层申请权限。
2:设计规划
现有日志架构
日志系统提供简单的打日志的功能,在各个系统中能方便调用,日志能分等级、分系统记录。首先我先想到的是最传统的文件记录日志的方式,文件记录日志是最常用,现在我们运维提供的那套日志就是基于存文件的,他的架构是先把日志记录到runtime中,以文件的形式存到各个服务器上,再有定时任务去收集各个系统服务器的日志信息,最后汇总到自己的日志系统中,这种形式确实可以,并且压力完全没有,因为每个服务器不会成为瓶颈,他们只有把日志记录到文件中,自然有人收集这条日志,缺点也很明显:量太大,不知道哪些是自己想要的, 没办法分等级,非技术人员不会使用这套系统,系统日志和业务日志混杂,最致命的是不能实时看到自己打的日志。
MySQL承载重任
所以这种主动收集的方式首先被排除,然后就是想利用mysql记录日志,MySQL 能提供丰富的查询功能,并且小米的MySQL是用得比较好的,完全可以胜任记录日志的工作,单表容量1亿就可以满足,经过调研,老的日志没有存在的必要。所以我就开始以MySQL 为基准来设计第一版。
MySQL的不足
第一版架构了很多,我看了现在日志的业务量,并发不高,因为我们是内部系统,外部系统也不是抢购性质的,大请求的量的系统只有一个,总算下来每秒的日志数在20-200左右,这个量还是不小的,单放到MySQL中还可以,但是打日志就要消耗大量的时间,每条sql执行记录日志5ms的话,量打的shihou算上sql缓存也得1s了,这样的话,如果一个业务记录100条日志,100次连续记录日志,那消耗的时间太多,记录日志会成为系统的瓶颈。
MySQL-Redis策略
想到性能这个问题,我就想起了Redis,解决性能问题,非Redis不可,Redis高并发能为我解决记录日志的性能问题,每秒钟请求1W次也不是问题,所以采用MySQL-Redis的架构设计是可以的。这样一来日志就会存在Redis中一部分,存在MySQL 中一部分,这样的话就会出现记录不一致的情况,严重的地方主要体现在页面上,比如:实时查询日志的时候,页面内容的显示必须一部分来源Redis,一部分来源于MySQL,必须在两个库里面做分页等。
3:实施方案
创建数据库
考虑到会按照日志的不同纬度查询,和传统的日志记录不同,我们把日志分成若干个信息点,比如:IP-请求IP,msg-消息体,request-请求体,response-返回体,level-日志等级,channel-系统渠道,action-哪个方法打的日志,line - 多少行打的日志,url-用户请求的url,file -打该条日志的文件,create_time-打日志的时间。具体的数据脚本如下:
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '日志信息表',
`user_id` bigint(20) unsigned DEFAULT NULL COMMENT '操作用户的ID',
`user_name` varchar(30) DEFAULT NULL COMMENT '用户名',
`index` varchar(255) DEFAULT NULL COMMENT '自定的标记字段,用于自己标记自己特有的log',
`msg` varchar(255) DEFAULT NULL COMMENT '注释:显示日志用途或者是消息内容',
`request` text COMMENT '请求体内容',
`response` text COMMENT '返回体内容',
`level` varchar(50) DEFAULT NULL COMMENT '日志等级:error, warning, notice, debug',
`channel` varchar(30) DEFAULT NULL COMMENT '渠道:buy, dms, drs, mall',
`ip` char(15) DEFAULT NULL COMMENT '打日志所在系统的IP',
`action` varchar(255) DEFAULT NULL COMMENT '打日志请求发生的动作(调用栈)',
`line` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '打日志所在的行(写日志的地方)',
`url` varchar(255) DEFAULT NULL COMMENT '打日志所在的项目的url',
`file` varchar(255) DEFAULT NULL COMMENT '打日志文件的路径',
`create_time` bigint(20) unsigned DEFAULT NULL COMMENT '创建时间',
再考虑Redis
Redis相当于第一级缓存,当用户需要记录日志的时候,首先会被记录到Redis中,但是Redis又不能存储大量的数据,数据量太大的话,运维的同学是不愿意的,小米的Redis 一般不会用作数据库,Redis缓存用的比较多,这也是业务决定,这里不多探究。所以我的第一级缓存使用Redis,这样能保证记录日志的效率,然后再把Redis中的数据同步到数据库,这一步也算是持久化,同步过的数据,在Redis中可以删除。所以我使用了Redis比较常见的列表,也相当于一个队列,先进先出,这样能保证记录日志的先后顺序。
完善日志查询
日志的用处是记录和查询到相关记录,现在我们已经能存储到数据库中了,下面就要考虑页面的事。页面很重要,这是对外的窗口,想要做出一个好用的日志系统,就必须有一个好用的页面,页面的难点在实时查询,要想做到实时查询就要把Redis中中暂存的日志拿出来做分页展示,这样做大致能解决分页的问题,但是是存在潜在的风险的,比如Redis中数据量过大,这时就不能边搜索边分页了,还有就是如果启用多个Redis实例,就要多个Redis同时分页,会涉及到排序的问题,这个两个问题我后面会讲到解决办法,第一版先采用这种简单的解决方案。
页面效果如图:
// redis和mysql联合分页
$redis_line = $this->getRedisLogLine($data);
$page_size = self::PAGE_SIZE;
$result = [];
$redislog = [];
// 只有当redis中的数据不够一页了再去取MySQL
if ($redis_line > $startnum) {
$end = $redis_line > $startnum + self::PAGE_SIZE ? $startnum + self::PAGE_SIZE : $redis_line;
$redislog = $this->getRedisLogbuyLine($startnum, $end, $data);
if (count($redislog) < $page_size) {
$page_size = $page_size - count($redislog);
$result = Yii::$app->db->createCommand(
'SELECT * FROM ' . self::LOG_NAME . $where . ' order by create_time desc
limit ' . $startnum . ',' . $page_size
)->bindValues($bindValues)->queryAll();
}
} else {
$result = Yii::$app->db->createCommand(
'SELECT * FROM ' . self::LOG_NAME . $where . ' order by create_time desc
limit ' . $startnum . ',' . $page_size
)->bindValues($bindValues)->queryAll();
}
持久化日志
同时还要考虑到持久化的问题,什么时候Redis中的数据写到MySQL?每次写多少?白天写还是晚上写?会不会影响正常的业务?写的时候会不会卡到数据库?
一开始我是实时写入,因为害怕Redis中数据量过多,超出承受范围,因为开始bug太多,写入脚本会经常挂掉,所以Redis经常是积压非常多的日志数据,单个key有时候几十万,因为每一条数据占用空间比较大,以至于运维的同学总是找我,说某某key占用太多的内存。。。
然后慢慢的重构,过一段时间发现,每分钟持久化一次基本上就能满足需求,然后不断的调整持久化策略,比如,一次持久化多少条数据,根据经验,每次可以根据量的大小,来定持久化数据条数的大小,脚本如下:
/**
* 保存日志到mysql, 每次持久化总条数的1/5
* @param int $line
*/
public static function saveLogToMysql($line = 0)
{
try {
$num = self::getRedisLogLine();
if ($num < 1000) {
echo “不需要写入数据库 |”;
return true;
}
if (empty($line)) {
$line = intval($num / 5);
}
// 动态控制消费数量
if ($num >= 200000) {
if ($line > 40000) {
$line = 40000;
}
} elseif ($num >= 100000) {
if ($line > 30000) {
$line = 30000;
}
} elseif ($num >= 30000) {
if ($line > 6000) {
$line = 6000;
}
} elseif ($num >= 10000) {
if ($line > 3000) {
$line = 3000;
}
}
if ($line > $num) {
$line = $num - 1;
}
$maxnum = 4000;
for ($i = ceil($line / $maxnum); $i > 0; $i--) {
if ($line <= 0) {
break;
}
$all_num = $maxnum;
if ($line < $maxnum) {
$all_num = $line;
}
$log = Yii::$app->redis->lrange(self::LOG_REDIS_KEY, 0, $all_num);
if (empty($log)) {
throw new Exception('auto save: log empty');
}
foreach ($log as $key => &$item) {
$tmp['ip'] = empty($item['ip']) ? '' : $item['ip'];
$tmp['user_id'] = empty($item['user_id']) ? '' : $item['user_id'];
$tmp['user_name'] = empty($item['user_name']) ? '' : $item['user_name'];
$tmp['index'] = empty($item['index']) ? '' : $item['index'];
$tmp['msg'] = empty($item['msg']) ? '' : $item['msg'];
$tmp['request'] = empty($item['request']) ? '' : $item['request'];
$tmp['response'] = empty($item['response']) ? '' : $item['response'];
$tmp['level'] = empty($item['level']) ? '' : $item['level'];
$tmp['channel'] = empty($item['channel']) ? '' : $item['channel'];
$tmp['action'] = empty($item['action']) ? '' : $item['action'];
$tmp['line'] = empty($item['line']) ? '' : $item['line'];
$tmp['url'] = empty($item['url']) ? '' : $item['url'];
$tmp['file'] = empty($item['file']) ? '' : $item['file'];
$tmp['create_time'] = $item['create_time'];
if (!is_string($tmp['request'])) {
$tmp['request'] = json_encode($tmp['request']);
}
if (!is_string($tmp['response'])) {
$tmp['response'] = json_encode($tmp['response']);
}
$item = $tmp;
}
$fields = [
'ip', 'user_id', 'user_name', 'index', 'msg', 'request', 'response', 'level',
'channel', 'action', 'line', 'url', 'file', 'create_time'
];
$info = Buylib::Db()->createCommand()->batchInsert(self::TABLE_NAME, $fields, $log)->execute();
if (empty($info)) {
throw new Exception('auto save: insert mysql error');
}
// 删除已经添加数据库的log
$line -= $maxnum;
Yii::$app->redis->ltrim(self::LOG_REDIS_KEY, $all_num + 1, -1);
echo date('Y-m-d H:i:s') . ' 写入MySQL:' . $all_num . '条 | ';
}
} catch (Exception $e) {
vde($e->getMessage() . ' Line:' . $e->getLine() . 'File:' . $e->getFile());
}
}
算法如上,通过调整策略,当日志量比较大的时候,写入量就会适当的加大,但是不会超过承受范围,当日止量特别小时就不在持久化了,比如晚上,晚上数据量小,1分钟内没有达到1000条,就不在持久化,这样就不会一直对数据库产生压力。当然策略会一直调整,直到适合当前的系统和业务需求。
热库和冷库设计
随着系统的运行,2个月后日志量就很大了,超过8000W以后MySQL的查询效率非常的底下,有索引和全文索引的还好,没有索引的或者是区间查询基本上出不来了,这时我就考虑热库和冷库并用。业务的日志使用场景一般是时效性较强,查bug和排查问题的时候使用日志较多,所以热库日志没有必要存太长的时间,1个月基本上够用,所以我就写一个脚本把热库中1个月以前的数据迁移到冷库中,这样就保证了查询频率高的都在热库,如果需要也能到冷库中查询。并且可以删除冷库中时间太久远的数据,迁移的脚本如下:
// 9点前和17点后热裤冷库迁移,同步删除冷库80天前数据
if ($htime < 9 || $htime > 17 ) {
// 测试环境热裤只保留5天的数据,正式环境保留10天的
if (Yii::$app->params['siteLocation'] == 'test') {
$time = (time() - 3600 * 24 * 5) * 10000;
} else {
$time = (time() - 3600 * 24 * 10) * 10000;
}
$count = Yii::$app->db->createCommand(
”select count(1) as count from buy_log where create_time < “ . $time
)->queryOne();
if ($count['count'] > 0) {
$allchannel = Yii::$app->db->createCommand('select id from buy_log limit 1')->queryOne();
// 每次1000条,不影响性能
$startid = $allchannel['id'] + 1500;
$log_list = Yii::$app->db->createCommand(“select * from buy_log where id < ” . $startid)->queryAll();
if (empty($log_list)) {
echo 'log empty';exit;
}
if (count($log_list) > 1510) {
echo 'log more 1510';exit;
}
$fields = ['id', 'user_id', 'user_name', 'index', 'msg', 'request', 'response',
'level', 'channel', 'ip', 'action', 'line', 'url', 'file', 'create_time'
];
$trans = Yii::$app->db->beginTransaction();
$insert = Yii::$app->db->createCommand()->batchInsert('buy_log_bak', $fields, $log_list)->execute();
if (empty($insert)) {
$trans->rollBack();echo 'insert error';exit;
}
$ret = Yii::$app->db->createCommand(“delete from buy_log where id <” . $startid)->execute();
if (empty($ret)) {
$trans->rollBack();echo 'delete error';exit;
}
$trans->commit();echo 'insert success, count:' . count($log_list) . ' ';
} else {
echo '不需要同步数据 | ';
}
}
日志统计信息
随着日志系统的运行,我们需要知道日志的统计信息,比如热库有多少数据,冷库有多少数据,Redis暂存量是多少,当前的数据迁移量能不能保证业务的正常运行,哪个系统日志量大,哪个渠道占用日志资源多等等,所以我们提供了一个统计脚本:
$log_key = Yii::$app->config->log_redis_key();
// 等级日志统计
$alllevel = Yii::$app->db->createCommand(
'select level as name, count(1) as value from buy_log group by level'
)->queryAll();
Yii::$app->redis->set($log_key['all_level_num'], json_encode($alllevel));
// 渠道日志统计
$allchannel = Yii::$app->db->createCommand(
'select channel as name, count(1) as value from buy_log group by channel'
)->queryAll();
Yii::$app->redis->set($log_key['all_channel_num'], json_encode($allchannel));
// 热库总日志统计
$all_logcount = Yii::$app->db->createCommand(
'select count(1) as count from buy_log'
)->queryOne();
// 冷库总日志统计
$all_logbakcount = Yii::$app->db->createCommand(
'select count(1) as count from buy_log_bak'
)->queryOne();
Yii::$app->redis->set($log_key['all_log_count'], json_encode([
'logcount' => $all_logcount, 'logbakcount' => $all_logbakcount
]));
脚本只有一部分,还有柱状图的统计脚本,运行的时间间隔不一样,有的每天运行一次,有的每小时,有的每分钟,根据数据维度的不同定时间间隔。下面附上一张日志统计信息图:
Redis分页问题
实时性是本日志系统的一大亮点,要想做到实时性,还真要费点劲!
如何解决Redis中日志量大不能完全分页的问题?如何解决多个Redis同时存储分页的问题?这个其实就是系统需要升级改造的地方,先说第一个:解决方法很简单,分页之前需要判断一下数据规模,只取一部分数据,但是这样也会存在问题,因为我们提供的有 查询的功能,模糊查询,会显示关键字在Redis中有多少个,在MySQL中有多少个,如果只是取一部分Redis数据,就无法统计Redis中这个关键字的数量,所以我们必须把Redis中的数据全部遍历统计,老的方法之所以不能使用,是因为老的逻辑是吧Redis中所有的数据同时取出,然后逐个遍历,这样就很容易导致内存溢出,然后我就采用取出一部分数据这种方式,遍历查询,然后再取出一部分,遍历查询,直到数据遍历一遍。
第二个问题,如果有多个Redis实例该怎么分页,单个Redis无法满足好多个系统同时打日志的需求,量比较大,单个Redis key会有限制,分成多个Redis承载是比较好的解决方案,但是分成多个就会造成分页困难,你必须把多个Redis数据同时取出,排好时间,共同分页,这种也能采用刚才的策略,分批取出再分页,为了保证取出的数据有序性,必须是取最早记录的日志,取出各个系统最早的日志,然后再拿这些最早的日志排序,在分页。这里还有一个问题,就是A系统的Redis最晚记录的日志比B系统Redis中记录的最早的日志还要早怎么办,这种情况就涉及到取数大小的问题,我们每次取数据的时候必要按照一个Redis取完数的思想,也就是说要拿出100条做分页,就在每个系统中都取100条,然后排序,这样就解决了刚才说的问题,避免了某个Redis日志过早的问题。
解决这种问题的思路有很多,我自己想的是这种解决办法,总而言之,Redis中不能暂存太大的数据量,但是我们又必须考虑到这种情况,如果你有好的方法,可以和我留言交流,共同学习!
注意:
本文属于原创内容,为了尊重他人劳动,转载请到文章原文地址,非常感谢:
http://www.zhaoyafei.cn/content.html?id=180399853795
评论列表
添加评论
1#楼 2018-05-01 16:56 http://birjemin.com
ELK用过么??我们之前的所有线上日志通过打点的方式进入文本(同时备份到mysql),由运维使用Logstash实时收集到ES中,再使用Kibana进行展示(可以建立相应的索引对数据进行过滤),如果需要更加的友好的展示(针对不会使用kabana的人群),可以由后端使用(java,php)对ES中的数据进行计算(ES对数据的聚合计算量级绝对满足你了)。