多线程环境并发写日志,首先需要保证线程安全,就是说,多个线程一起写日志时,内容不能出现交织。
要做到这一点,最最简单的办法,就是每条线程单独写一个文件,这个方案在淘宝是不现实的,因为应用的线程非常多(仅 HSF 线程池就已经有 600 个线程),如果采用这个方案,会产生很多日志文件。
再简单一点的办法是把写日志作为临界区,进入临界区时用锁来保证每一时刻只有一个线程在写日志,例如 BufferedOutputStream 就是一个写时同步的实现。
应用用 log4j、logback 打日志,如果没特殊配置过,一般就是同步写的。
异步写的方案:任何线程要写日志,只需要把日志事件对象加入日志队列就行了,后台会专门起一个线程从队列取日志、写文件。
这是一个典型的多生产者对单消费者的问题,关键就在于这个队列的实现,因为这里面涉及消费者等待队列不空,生产者等待队列空的逻辑,当时就选择了比较直观的 BlockingQueue。
j.u.c 里比较常见的 BlockingQueue 实现,有 ArrayBlockingQueue、LinkedBlockingQueue、LinkedTransferQueue、SynchronousQueue 这几个:
SynchronousQueue 常用于生产者消费者一对一交换的场景,不适合。
LinkedTransferQueue 是 Java7 里新加的队列实现,在 NIO 框架、线程池框架里常亮相,性能应该不错,不过提交日志到队列,完全不需要等待消费者做 transfer 动作,因此用不上。
LinkedBlockingQueue 是基于链表实现的队列,一头一尾各有一把锁,缺点就是入队有内存分配开销。
ArrayBlockingQueue 是一个定长的环形数组,队列创建之后,就没有内存开销了,但缺点是这个队列共用一把锁,竞争比 LinkedBlockingQueue 激烈。
实测发现用 LinkedBlockingQueue 做队列,吞吐量比 ArrayBlockingQueue 高,但考虑到 ArrayBlockingQueue 本身的定长特性,在写日志 qps 很高时内存波动更稳定,而且队列定长也正好可以作为写日志的“节流阀”(队列满时,新增的日志无法放入,会直接被丢弃,从而起到控制日志写入速度的作用),因此最终选择了 ArrayBlockingQueue。
log4j、logback 等日志框架,有对应的 AsyncAppender 实现异步队列,都选择了 ArrayBlockingQueue。
使用 BlockingQueue 入队有一个细节,就是用 add、put、offer 中的哪一个。add 会抛异常是不合适的,关键还是看 put 还是 offer。put 是一直等,直到入队为止,offer 是不等,或者只尝试等一段时间。相比之下,offer 的实现更合适,对于调用埋点日志,如果队列满,可以直接把日志丢掉不用等;对于更重要一些的业务日志,尽量不丢弃,可以尝试等几百毫秒。如果使用了等的策略,日志队列又经常满的话,会拖慢业务的响应时间,logback 用了 put 来保证重要日志不丢失,在高 qps 时,吞吐量是比用 offer 要差的。
在日志队列这个场景,消费者的速度一般高于生产者速度,因此队列经常处于空状态,消费者每次起来只处理一条或几条日志之后队列又空了只好重新挂起等待,而生产者每放入一条日志,都会唤醒消费者,结果就会导致系统 cs 偏高。
如果把 BlockingQueue 的功能拆开来看,一个功能是在维护队列在并发场景的出队、入队,另一个功能是做生产者、消费者之间的同步策略。
前一个功能可以另外用无锁队列来实现,降低锁争用开销,后一个功能可以针对记日志的场景做专门优化。
j.u.c 里面的 ConcurrentLinkedQueue 就是默认的无锁队列。另外,这篇论文也提出了一个对无锁队列的改进。不过,这些实现都是在无界队列上实施的算法,更适合的数据结构应该是对 ArrayBlockingQueue 的定长环形数组进行改造,把它变为无锁。看到这里,相信有不少同学能想到最近比较火的 Disruptor,这个库的核心就是使用环形数组(RingBuffer)实现无锁队列,性能号称比 ArrayBlockingQueue 要好不少。
多进程写日志的方案有下面几个:
每个进程单独写一个文件。这个方案简单可靠,缺点是对日志收集来说有点不方便,但可以接受。
写文件时,每个进程都先获取文件锁 FileChannel.lock(),写完之后 release。logback 用的是这个策略(prudent=true)。
依赖 write(2) O_APPEND 的原子性:open(2)/write(2) 在设置了 O_APPEND 之后,操作系统可以保证写入是原子操作(一次写出的内容不要超过 PAGE_SIZE=4K),在这里有详细讨论。JVM 里面,如果使用 new FileOutputStream(file, append) 这个 API,第二个参数 append 设置为 true,会带有 O_APPEND 参数。