Zhaoyafei's Blog

构建轻量级日志系统

2018-03-06 21:12 
10149 
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-打日志的时间。具体的数据脚本如下:

  1. `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '日志信息表',
  2. `user_id` bigint(20) unsigned DEFAULT NULL COMMENT '操作用户的ID',
  3. `user_name` varchar(30) DEFAULT NULL COMMENT '用户名',
  4. `index` varchar(255) DEFAULT NULL COMMENT '自定的标记字段,用于自己标记自己特有的log',
  5. `msg` varchar(255) DEFAULT NULL COMMENT '注释:显示日志用途或者是消息内容',
  6. `request` text COMMENT '请求体内容',
  7. `response` text COMMENT '返回体内容',
  8. `level` varchar(50) DEFAULT NULL COMMENT '日志等级:error, warning, notice, debug',
  9. `channel` varchar(30) DEFAULT NULL COMMENT '渠道:buy, dms, drs, mall',
  10. `ip` char(15) DEFAULT NULL COMMENT '打日志所在系统的IP',
  11. `action` varchar(255) DEFAULT NULL COMMENT '打日志请求发生的动作(调用栈)',
  12. `line` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '打日志所在的行(写日志的地方)',
  13. `url` varchar(255) DEFAULT NULL COMMENT '打日志所在的项目的url',
  14. `file` varchar(255) DEFAULT NULL COMMENT '打日志文件的路径',
  15. `create_time` bigint(20) unsigned DEFAULT NULL COMMENT '创建时间',

  再考虑Redis
  Redis相当于第一级缓存,当用户需要记录日志的时候,首先会被记录到Redis中,但是Redis又不能存储大量的数据,数据量太大的话,运维的同学是不愿意的,小米的Redis 一般不会用作数据库,Redis缓存用的比较多,这也是业务决定,这里不多探究。所以我的第一级缓存使用Redis,这样能保证记录日志的效率,然后再把Redis中的数据同步到数据库,这一步也算是持久化,同步过的数据,在Redis中可以删除。所以我使用了Redis比较常见的列表,也相当于一个队列,先进先出,这样能保证记录日志的先后顺序。

  完善日志查询
  日志的用处是记录和查询到相关记录,现在我们已经能存储到数据库中了,下面就要考虑页面的事。页面很重要,这是对外的窗口,想要做出一个好用的日志系统,就必须有一个好用的页面,页面的难点在实时查询,要想做到实时查询就要把Redis中中暂存的日志拿出来做分页展示,这样做大致能解决分页的问题,但是是存在潜在的风险的,比如Redis中数据量过大,这时就不能边搜索边分页了,还有就是如果启用多个Redis实例,就要多个Redis同时分页,会涉及到排序的问题,这个两个问题我后面会讲到解决办法,第一版先采用这种简单的解决方案。

页面效果如图:

  1. // redis和mysql联合分页
  2. $redis_line = $this->getRedisLogLine($data);
  3. $page_size = self::PAGE_SIZE;
  4. $result = [];
  5. $redislog = [];
  6. // 只有当redis中的数据不够一页了再去取MySQL
  7. if ($redis_line > $startnum) {
  8. $end = $redis_line > $startnum + self::PAGE_SIZE ? $startnum + self::PAGE_SIZE : $redis_line;
  9. $redislog = $this->getRedisLogbuyLine($startnum, $end, $data);
  10. if (count($redislog) < $page_size) {
  11. $page_size = $page_size - count($redislog);
  12. $result = Yii::$app->db->createCommand(
  13. 'SELECT * FROM ' . self::LOG_NAME . $where . ' order by create_time desc
  14. limit ' . $startnum . ',' . $page_size
  15. )->bindValues($bindValues)->queryAll();
  16. }
  17. } else {
  18. $result = Yii::$app->db->createCommand(
  19. 'SELECT * FROM ' . self::LOG_NAME . $where . ' order by create_time desc
  20. limit ' . $startnum . ',' . $page_size
  21. )->bindValues($bindValues)->queryAll();
  22. }

  持久化日志
  同时还要考虑到持久化的问题,什么时候Redis中的数据写到MySQL?每次写多少?白天写还是晚上写?会不会影响正常的业务?写的时候会不会卡到数据库?

  一开始我是实时写入,因为害怕Redis中数据量过多,超出承受范围,因为开始bug太多,写入脚本会经常挂掉,所以Redis经常是积压非常多的日志数据,单个key有时候几十万,因为每一条数据占用空间比较大,以至于运维的同学总是找我,说某某key占用太多的内存。。。

  然后慢慢的重构,过一段时间发现,每分钟持久化一次基本上就能满足需求,然后不断的调整持久化策略,比如,一次持久化多少条数据,根据经验,每次可以根据量的大小,来定持久化数据条数的大小,脚本如下:

  1. /**
  2. * 保存日志到mysql, 每次持久化总条数的1/5
  3. * @param int $line
  4. */
  5. public static function saveLogToMysql($line = 0)
  6. {
  7. try {
  8. $num = self::getRedisLogLine();
  9. if ($num < 1000) {
  10. echo “不需要写入数据库 |”;
  11. return true;
  12. }
  13. if (empty($line)) {
  14. $line = intval($num / 5);
  15. }
  16. // 动态控制消费数量
  17. if ($num >= 200000) {
  18. if ($line > 40000) {
  19. $line = 40000;
  20. }
  21. } elseif ($num >= 100000) {
  22. if ($line > 30000) {
  23. $line = 30000;
  24. }
  25. } elseif ($num >= 30000) {
  26. if ($line > 6000) {
  27. $line = 6000;
  28. }
  29. } elseif ($num >= 10000) {
  30. if ($line > 3000) {
  31. $line = 3000;
  32. }
  33. }
  34. if ($line > $num) {
  35. $line = $num - 1;
  36. }
  37. $maxnum = 4000;
  38. for ($i = ceil($line / $maxnum); $i > 0; $i--) {
  39. if ($line <= 0) {
  40. break;
  41. }
  42. $all_num = $maxnum;
  43. if ($line < $maxnum) {
  44. $all_num = $line;
  45. }
  46. $log = Yii::$app->redis->lrange(self::LOG_REDIS_KEY, 0, $all_num);
  47. if (empty($log)) {
  48. throw new Exception('auto save: log empty');
  49. }
  50. foreach ($log as $key => &$item) {
  51. $tmp['ip'] = empty($item['ip']) ? '' : $item['ip'];
  52. $tmp['user_id'] = empty($item['user_id']) ? '' : $item['user_id'];
  53. $tmp['user_name'] = empty($item['user_name']) ? '' : $item['user_name'];
  54. $tmp['index'] = empty($item['index']) ? '' : $item['index'];
  55. $tmp['msg'] = empty($item['msg']) ? '' : $item['msg'];
  56. $tmp['request'] = empty($item['request']) ? '' : $item['request'];
  57. $tmp['response'] = empty($item['response']) ? '' : $item['response'];
  58. $tmp['level'] = empty($item['level']) ? '' : $item['level'];
  59. $tmp['channel'] = empty($item['channel']) ? '' : $item['channel'];
  60. $tmp['action'] = empty($item['action']) ? '' : $item['action'];
  61. $tmp['line'] = empty($item['line']) ? '' : $item['line'];
  62. $tmp['url'] = empty($item['url']) ? '' : $item['url'];
  63. $tmp['file'] = empty($item['file']) ? '' : $item['file'];
  64. $tmp['create_time'] = $item['create_time'];
  65. if (!is_string($tmp['request'])) {
  66. $tmp['request'] = json_encode($tmp['request']);
  67. }
  68. if (!is_string($tmp['response'])) {
  69. $tmp['response'] = json_encode($tmp['response']);
  70. }
  71. $item = $tmp;
  72. }
  73. $fields = [
  74. 'ip', 'user_id', 'user_name', 'index', 'msg', 'request', 'response', 'level',
  75. 'channel', 'action', 'line', 'url', 'file', 'create_time'
  76. ];
  77. $info = Buylib::Db()->createCommand()->batchInsert(self::TABLE_NAME, $fields, $log)->execute();
  78. if (empty($info)) {
  79. throw new Exception('auto save: insert mysql error');
  80. }
  81. // 删除已经添加数据库的log
  82. $line -= $maxnum;
  83. Yii::$app->redis->ltrim(self::LOG_REDIS_KEY, $all_num + 1, -1);
  84. echo date('Y-m-d H:i:s') . ' 写入MySQL:' . $all_num . '条 | ';
  85. }
  86. } catch (Exception $e) {
  87. vde($e->getMessage() . ' Line:' . $e->getLine() . 'File:' . $e->getFile());
  88. }
  89. }

  算法如上,通过调整策略,当日志量比较大的时候,写入量就会适当的加大,但是不会超过承受范围,当日止量特别小时就不在持久化了,比如晚上,晚上数据量小,1分钟内没有达到1000条,就不在持久化,这样就不会一直对数据库产生压力。当然策略会一直调整,直到适合当前的系统和业务需求。

  热库和冷库设计
  随着系统的运行,2个月后日志量就很大了,超过8000W以后MySQL的查询效率非常的底下,有索引和全文索引的还好,没有索引的或者是区间查询基本上出不来了,这时我就考虑热库和冷库并用。业务的日志使用场景一般是时效性较强,查bug和排查问题的时候使用日志较多,所以热库日志没有必要存太长的时间,1个月基本上够用,所以我就写一个脚本把热库中1个月以前的数据迁移到冷库中,这样就保证了查询频率高的都在热库,如果需要也能到冷库中查询。并且可以删除冷库中时间太久远的数据,迁移的脚本如下:

  1. // 9点前和17点后热裤冷库迁移,同步删除冷库80天前数据
  2. if ($htime < 9 || $htime > 17 ) {
  3. // 测试环境热裤只保留5天的数据,正式环境保留10天的
  4. if (Yii::$app->params['siteLocation'] == 'test') {
  5. $time = (time() - 3600 * 24 * 5) * 10000;
  6. } else {
  7. $time = (time() - 3600 * 24 * 10) * 10000;
  8. }
  9. $count = Yii::$app->db->createCommand(
  10. select count(1) as count from buy_log where create_time < . $time
  11. )->queryOne();
  12. if ($count['count'] > 0) {
  13. $allchannel = Yii::$app->db->createCommand('select id from buy_log limit 1')->queryOne();
  14. // 每次1000条,不影响性能
  15. $startid = $allchannel['id'] + 1500;
  16. $log_list = Yii::$app->db->createCommand(“select * from buy_log where id < . $startid)->queryAll();
  17. if (empty($log_list)) {
  18. echo 'log empty';exit;
  19. }
  20. if (count($log_list) > 1510) {
  21. echo 'log more 1510';exit;
  22. }
  23. $fields = ['id', 'user_id', 'user_name', 'index', 'msg', 'request', 'response',
  24. 'level', 'channel', 'ip', 'action', 'line', 'url', 'file', 'create_time'
  25. ];
  26. $trans = Yii::$app->db->beginTransaction();
  27. $insert = Yii::$app->db->createCommand()->batchInsert('buy_log_bak', $fields, $log_list)->execute();
  28. if (empty($insert)) {
  29. $trans->rollBack();echo 'insert error';exit;
  30. }
  31. $ret = Yii::$app->db->createCommand(“delete from buy_log where id <” . $startid)->execute();
  32. if (empty($ret)) {
  33. $trans->rollBack();echo 'delete error';exit;
  34. }
  35. $trans->commit();echo 'insert success, count:' . count($log_list) . ' ';
  36. } else {
  37. echo '不需要同步数据 | ';
  38. }
  39. }

  日志统计信息
  随着日志系统的运行,我们需要知道日志的统计信息,比如热库有多少数据,冷库有多少数据,Redis暂存量是多少,当前的数据迁移量能不能保证业务的正常运行,哪个系统日志量大,哪个渠道占用日志资源多等等,所以我们提供了一个统计脚本:

  1. $log_key = Yii::$app->config->log_redis_key();
  2. // 等级日志统计
  3. $alllevel = Yii::$app->db->createCommand(
  4. 'select level as name, count(1) as value from buy_log group by level'
  5. )->queryAll();
  6. Yii::$app->redis->set($log_key['all_level_num'], json_encode($alllevel));
  7. // 渠道日志统计
  8. $allchannel = Yii::$app->db->createCommand(
  9. 'select channel as name, count(1) as value from buy_log group by channel'
  10. )->queryAll();
  11. Yii::$app->redis->set($log_key['all_channel_num'], json_encode($allchannel));
  12. // 热库总日志统计
  13. $all_logcount = Yii::$app->db->createCommand(
  14. 'select count(1) as count from buy_log'
  15. )->queryOne();
  16. // 冷库总日志统计
  17. $all_logbakcount = Yii::$app->db->createCommand(
  18. 'select count(1) as count from buy_log_bak'
  19. )->queryOne();
  20. Yii::$app->redis->set($log_key['all_log_count'], json_encode([
  21. 'logcount' => $all_logcount, 'logbakcount' => $all_logbakcount
  22. ]));

  脚本只有一部分,还有柱状图的统计脚本,运行的时间间隔不一样,有的每天运行一次,有的每小时,有的每分钟,根据数据维度的不同定时间间隔。下面附上一张日志统计信息图:

  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对数据的聚合计算量级绝对满足你了)。

2#楼   2018-11-17 07:20      网友(----)

以后天天逛您的博客,真的学到了,也明白自己找实习为什么失败的原因了

3#楼   2019-03-22 01:32      http://mostclan.com

深夜来访,受益匪浅!解开了不少疑惑,感谢分享! ^ ^

添加评论

* (邮箱不会公开,只做回复通知用)

* (好的站点会添加到 友情链接

*

*

提交评论