最近我们线上一个收集日志的服务运行不是很稳定,由于和我负责的服务有关,于是排查的任务就交给了我。
服务架构
首先介绍一下这个服务的前后接口。这个服务,我们内部叫做 LogWatcher,用来处理线上的一些日志。开发人员在调试线上服务的时候,经常需要打印调试日志,由于线上机器很多,开发不可能到真实机器上去看日志,因此我们的系统会把开发的调试日志从各个线上服务器实时的发送到一台日志存储机器上,LogWatcher 就工作在这台日志存储机器上,它本身会通过 Linux 系统的 inotify 监控调试日志文件,当有新的调试日志写入到日志文件后,会把这些日志解析,然后写入到 Mysql 中,此后我们会通过 sphinx 生成日志索引,给开发提供一个实时查看调试日志的系统。
LogWatcher 的架构如上图:main 线程负责进行 inotify 监控,监控到日志文件变动后,会把文件信息当做一个任务发送到 processor 线程。实际工作中,日志文件有多个,processor 线程也会有多个,main 线程会建立一个文件名和 processor 线程的 hash 映射,把任务发送给对应的处理线程。
故障现象
LogWatcher 出现的问题,主要有三点:
- 调试日志的系统有延迟,开发人员没办法实时的查看自己的调试日志。
- 有时候调试日志会丢失,开发人员找不到自己对应的调试日志。
- 运维也发现 LogWatcher 进程有时会 crash(1天左右)。
第一次排查
我简单看了下 LogWatcher 自身的日志,发现 main 线程向 processor 线程传入 task 的队列有积压,因此怀疑线上日志变多,导致 processor 线程处理不过来。那么应该可以通过增加 processor 线程数解决问题,于是,我把 processor 的线程数调大了1倍(50 -> 100)。
调大进程数并没有解决延迟的问题,队列依然积压,而且 crash 的频率还变高了,很奇怪。由于日志缺少我关心的信息,所以我开始边分析代码,边增加日志信息。
由于多线程架构,打印日志是个麻烦的任务,因此 LogWatcher 采用了一种简单的办法:每个线程单独打开一个独立的日志文件,分别记录日志,这样每个线程互不影响,不容易出问题。但这里的实现出现了 bug。
LogWatcher 把 processor 线程实现为一个类,这个类包括一个任务队列,一个处理日志、写数据库的函数,既然这个类是一个单独线程,那么这个类的对象,理所当然都会有一个自己的日志文件了,但是这里有个问题,main 线程会往 processor 的任务队列中发送任务,发送任务实际是调用 procesor 的 pushTask 函数,这个函数运行在 main 线程中,按照设计思路,如果 pushTask 函数要打印日志,需要打印到 main 线程的日志文件中,可实际上却没有,pushTask 直接使用了 processor 类的日志输出方法,把日志打印到了 processor 线程的日志中,此时两个线程同时写一个文件,导致了 crash。
一般日志文件都需要有切割的功能,比如一个小时切割成一个文件,这个过程可以简化为如下代码:
void WriteLog(log) {
if (HourChange(now)) {
close(fd);
}
if (IsNotOpen(fd)) {
open(fd);
}
print(fd, log);
}
假如 A、B 线程同时执行到了 close 这一行,就出现 fd 被 close 了两回的现象,这个问题叫做资源双重释放,是个未定义的操作,一般情况就是 crash。通过 core 文件的堆栈,可以验证我们的猜测,堆栈信息显示 crash 发生在 close 这一行。
分析出问题的原因,我选择把 LogWatcher 的日志全部替换成 glog,尝试修复 crash。glog这是一个 google 开源的日志库,能够处理多线程的日志输出问题。
第二次排查
使用 glog 上线以后,crash 没有了,但是调试日志缺失的问题还存在。这次我通过 gdb attach 到 LogWatcher 的进程上,看了现场,发现有多个 processor 线程死锁了,由于 main 线程是分别和每个 processor 线程建立队列,传输任务,那么一个 processor 线程死锁,这部分的日志就无法写到数据库,也就出现了部分日志丢失的问题。那么问题的原因呢?
排查发现,processor 使用了 pthread_mutex_t 处理队列的竞争,因为 main 线程和 processor 线程都会操作队列,一个 pushTask,一个 popTask,因此需要一个锁,来解决竞争,但是代码中使用 pthread_mutex_t 时,却忘了初始化,这导致可能 processor 类初始化的时候,锁就已经处于锁住的状态,自然也就死锁了。这个问题也是很好修复的。
第三次排查
初始化的问题上线后,部分日志缺失的问题解决了,那么还有没有日志延迟的问题呢?我们写了一个监控,分析日志生成时间和写入 Mysql 的时间延迟,分析发现还是有很大的延迟,此时我们怀疑 processor 写数据库是会一个瓶颈,因为日志量很大,一次写操作通常会写几十上百条日志,这可能是系统的瓶颈,那么,需要验证一下,我在 processor 的 doTask 函数中,增加了一些打印时间的信息,同时,在发现队列积压的时候,也通过 gdb attach
去看线程在做什么,这次发现很多线程都卡在了一个系统调用mktime
上,反而,记录的写 Mysql 耗时也并不是很长。
一般日志文件中都会有个肉眼可读的时间字符串,用于记录日志时间,但是写入到数据库中,我们可能需要统计一段时间的日志数量,此时这个字符串就不好处理了,那么就需要转成 unix timestamp,这个过程会用到 mktime,从现象来看,这个函数的内部实现一定是使用了锁,才导致各个线程频发调用这个函数时出现了竞争,解决这个问题可以自己实现一个字符串转 timestamp 的无锁方法,网上有现成的算法。我采取了一个取巧的办法,就是加缓存。日志的输出往往都是一秒中之内有大量日志写入,因此我在 processor 中增加一个成员变量,记录上一次日志中的时间串,和对应的 timestamp 值,如果当前日志的时间串和上次一样,就直接使用保存的值,不需要调用 mktime 函数了,修改上线好,延迟问题就解决了。
尾声
这次上线以后,LogWatcher 的问题基本解决完了,正好组里希望大家做一些 case study,我觉得这次 LogWatcher 的问题,虽然现象很严重,但最后发现都是一些小问题,问题的原因容易理解,很适合用来学习排查问题的思路和方法,因此做下记录,希望大家能从中学到一些分析问题的方法,自己有所提高。