两种异步日志方案的介绍

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: 两种异步日志方案的介绍

一、日志写入逻辑


1.1 相关接口函数

fwrite() 函数

用于将数据块按字节写入到文件中,返回实际成功写入的数据块个数。

size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);

参数说明:

ptr:指向要写入的数据块的指针。

size:每个数据块的字节数。

count:要写入的数据块个数。

stream:指向要写入的文件的指针。


使用 fwrite() 函数时,它会将 size 字节的数据块从 ptr 写入到指定的文件流 stream 中,并重复这个过程 count 次。


fread() 函数

用于从文件中按字节读取数据块,返回实际成功读取的数据块个数。

size_t fread(void *ptr, size_t size, size_t count, FILE *stream);

参数说明:

ptr:指向存储读取数据的内存块的指针。

size:每个数据块的字节数。

count:要读取的数据块个数。

stream:指向要读取的文件的指针。


使用 fread() 函数时,它会从指定的文件流 stream 中读取 size 字节的数据块,并重复这个过程 count 次,将读取的数据存储到 ptr 指向的内存块中。


fclose()函数

用于关闭打开的文件流,若成功关闭文件,则返回0;若关闭文件失败,则返回非零值。

int fclose(FILE *stream);

使用 fclose() 函数时,它会关闭指定的文件流,并刷新缓冲区中的数据。在关闭文件之前,它会将缓冲区中的数据写入到文件中。


fflush()函数

用于刷新输出缓冲区。若成功刷新缓冲区,则返回0;若刷新缓冲区失败,则返回非零值。

int fflush(FILE *stream);

参数说明:

stream:指向要刷新缓冲区的文件的指针。如果传入 NULL,则会刷新所有打开的文件流的缓冲区。


使用 fflush() 函数时,它会将输出缓冲区的内容立即写入到文件中(对于输出流)或者屏幕上(对于标准输出流 stdout)。这个函数通常用于确保数据被及时写入,而不是等到缓冲区满或者程序结束时才自动刷新。


fsync()函数

用于将文件数据同步到磁盘上的永久存储空间。若成功同步到磁盘,则返回0;若同步失败,则返回非零值。

int fsync(int fd);

参数说明:

fd:要同步到磁盘的文件描述符。


使用 fsync() 函数时,它会强制将文件缓冲区中的数据写入到磁盘上的永久存储空间。这个函数主要用于确保在系统崩溃或断电等异常情况下,文件数据不会丢失或损坏。

需要注意的是,fsync() 函数的调用可能会导致性能下降,因为它会强制进行磁盘写入操作。


setbuf()函数

用于在打开的文件上设置自定义的缓冲区。


void setbuf(FILE *stream, char *buffer);

参数说明:

stream:指向要设置缓冲区的文件的指针。

buffer:指向用于设置缓冲区的字符数组的指针。如果传入 NULL,则禁用缓冲区。


使用 setbuf() 函数时,它可以用于将自定义的字符数组作为缓冲区与文件关联。缓冲区提供了一种临时存储数据的方式,可以提高文件的读写性能。

需要注意的是,如果传入的 buffer 参数为 NULL,则会禁用缓冲区,即设置为无缓冲模式。在无缓冲模式下,每次写入或读取都会立即进行 I/O 操作,而不会暂存数据。这种模式适合于需要实时数据交换或者文件较小的情况。


write()函数

用于将数据写入文件或文件描述符的系统调用函数。成功时,返回写入的字节数。失败时,返回-1。

ssize_t write(int fd, const void *buf, size_t count);

参数说明:

fd:文件描述符,表示要写入的目标文件或设备。

buf:指向要写入数据的缓冲区的指针。

count:要写入的字节数。


write() 函数会尽可能将指定数量的字节从缓冲区 buf 写入到文件或设备中。它是一个阻塞函数,即在数据完全写入之前会一直等待。


1.2 写入逻辑

fwrite 与 write 的区别


  • fwrite() 是库函数,默认使用缓冲区,即将数据先写入缓冲区,可以使用 setbuf() 或 setvbuf() 函数自定义缓冲区。缓冲区可以提高写入效率,在遇到文件关闭、缓冲区满、调用 fflush() 等情况时会触发真正的写入操作,一次写入磁盘。
  • write() 函数 是系统调用,无缓冲的,数据直接写入到磁盘,涉及用户态和内核态的切换。


fwrite通过fflush(内部触发 write )把用户缓冲区数据刷新到内核缓冲区中,通过fsync把内核缓冲区数据刷新到磁盘。

cf08c91c974fbc5e9eece784675cc413_64e90ca526df4e52a342f16c5c6f0cee.png


二、log4cpp 日志框架

log4cpp是个基于LGPL的开源项⽬,移植⾃Java的⽇志处理跟踪项⽬log4j,并保持了API上的⼀致。log4cpp 性能很低,可以在客户端使用,不适合服务器端使用。Log4cpp不是批量写入模式,而是直接调用write() 进行实时写入,这样的性能就不会非常高。


Log4cpp中最重要概念有Category(种类)、Appender(附加器)、Layout(布局)、Priorty(优先级)、NDC(嵌套的诊断上下⽂)。

820fb54479c367cfafc6befa5b0f4001_98f8a1ff238e48adbbde7e06163adb21.png


2.1 下载和编译

下载地址:

https://sourceforge.net/projects/log4cpp/files/latest/download


解压

tar zxf log4cpp-1.1.3.tar.gz

编译


cd log4cpp
./configure
make
make check
sudo make install
sudo ldconfig

默认安装路径:

/usr/local/include/log4cpp # 头文件

/usr/local/lib/liblog4cpp # 库文件


2.2 日志级别

log4cpp为例,具体的级别看具体的日志库

◼ EMERG

◼ FATAL

◼ ALERT

◼ CRIT

◼ ERROR

◼ WARN

◼ NOTICE

◼ INFO

◼ DEBUG

有些日志库 DEBUG和INFO的级别是反过来的。

日志输出级别应当在运行时可调,在必要的时候可以临时在线调整日志的输出级别。


muduo 库来说,调整日志的输出级别不需要重新编译,也不需要重启进程,只需要调用 muduo::Logger::setLoglevel()就能即时生效。


2.3 日志格式

一般日志输出时会携带一些关注的信息,比如

20210410 14:18:15.299684Z 30836 INFO NO.1506710 Root Error Message! - log_test.cpp:17

格式为:年月日 时分秒 微妙 时区 日志级别 日志内容 文件名 行号

Log4cpp支持的转义定义:

◼ %% - 转义字符’%’

◼ %c - Category

◼ %d - 日期;日期可以进一步设置格式,用花括号包围,例如%d{%H:%M:%S,%l}。日期的格式符号与ANSI C函数strftime中的一致。但增加了一个格式符号%l,表示毫秒,占三个十进制位。

◼ %m - 消息

◼ %n - 换行符;会根据平台的不同而不同,但对用户透明。

◼ %p - 优先级

◼ %r - 自从layout被创建后的毫秒数

◼ %R - 从1970年1月1日开始到目前为止的秒数

◼ %u - 进程开始到目前为止的时钟周期数

◼ %x - NDC

◼ %t - 线程id


muduo 库默认日志的格式是固定的,不需要运行时配置,这样可以节省每条日志解析格式字符串的开销,如果需要调整消息格式,直接修改代码重新编译即可


log4cpp 可以支持多种格式 Layout,如 SimpleLayout(简单布局), BasicLayout(基本布局)、 PatternLayout(格式化布局)。

1)BasicLayout::format

基本布局。它会为你添加“时间”、“优先级”、“种类”、“NDC”。相当于PatternLayout格式化为:“%R %p %c %x: %m%n”。


2) PassThroughLayout::format

直通布局。顾名思义,这个就是没有布局的“布局”,你让它写什么它就写什么,它不会为你添加任何东西,连换行符都懒得为你加。但是,它支持自定义的布局,我们可以继承他实现自定义的日志格式。


3) PatternLayout::format log4cpp

格式化布局,支持用户配置日志格式。它的使用方式类似C语言中的printf,使用格式化它符串来描述输出格式;目前支持的转义定义。


4) SimpleLayout::format

简单布局。比BasicLayout还简单的日志格式输出,它只会为你添加“优先级”的输出。相当于PatternLayout格式化为:“%p:%m%n”。


2.4 日志输出

日志有不同的输出方式,以log4cpp为例:

1)日志输出到控制台。ConsoleAppender。

2)日志输出到本地文件。FileAppender,值得注意的是,log4cpp使用write()来输出到文件,这种方式需要频繁的切换用户态和内核态,性能不高。这是一个可以优化的地方。

3)日志通过网络传输到远程服务器。RemoteSyslogAppender。


2.5 日志回滚

日志库一般都具备如下功能:

1)本地日志支持最大文件限制。

2)当本地日志达到最大文件限制的时候新建一个文件。

3)每天至少一个文件。


log4cpp回滚日志时,采用文件改名的方式,以文件名数字的大小表示创建的先后,数字越大文件越旧。比如若支持5个日志备份文件,有新的日志文件到来时,先删除最旧的log.5,然后把log.4改名为log.5,log.3改名为log.2,log.2改名为log.3,log.1改名为log.2,log.改名为log.1,最后新建一个log文件存储新的日志。


muduo 库的日志回滚没有采用文件改名,dmesg.log 始终是最新日志,便于编写及时解析日志的脚本


在性能方面,统计文件大小时,log4cpp 调用 lseek来实现,这导致其性能极低。而 muduo 库在应用层增加当前写入日志的大小参数。


三、muduo 异步日志库

3.1 异步日志机制

异步日志机制,是将日志写入操作放入一个独立的线程或者进程中来提高性能,而其他业务线程只需要往这个线程发送日志消息即可,避免因为日志写入操作导致的主线程阻塞。


通常,异步日志机制包含以下几个核心组件:

1)日志缓冲区(Log Buffer):用于临时存储日志消息的缓冲区。


2)日志队列(Log Queue):用于存放待写入日志文件的日志消息。


3)后台写入线程(Logging Thread):负责从日志队列中取出日志缓冲区,并将其中的日志消息写入到日志文件中。该线程通常是一个专门的线程或者进程,与主线程并发运行。


需要注意的是,这是一个典型的消费者生产者模型。生产者线程不是将日志消息逐条传递给消费者线程,而是积攒日志,将多条日志消息缓存组成一个大的buffer(默认4M·),作为队列的元素。等队列满了,再唤醒消费者线程,将日志批量写入磁盘。

3fb4cd38303c54692df5d39fe08c9dbf_7e774effef6f4046ba7d602982b79484.png

因此,后端日志线程的唤醒有两个条件

1)buffer 写满唤醒:批量写入写满1个 buffer 后,唤醒后端日志线程,减少线程被唤醒的频率,降低系统开销。

2)超时被唤醒:为了及时将日志消息写入文件,防止系统故障导致内存中的日志消息丢失。超过规定的时间阈值,即使 buffer 未满,也会通过wait_timeout唤醒日志落盘线程,立即将 buffer 中的数据写入。


3.2 双缓冲机制

双缓冲机制,是准备两个缓冲区,bufferA 和 bufferB。前端负责向 A 中写入日志消息,后端负责将 B 中的数据写入文件。

当 bufferA 写满或者达到一定的时间间隔后,交换 A 和 B。此时让后端将 A 的数据写入文件,前端向 B 中写入新的日志消息,如此往复。这样在追加日志消息的时候不必等待磁盘 IO 操作,同时也避免了每条新日志消息都触发唤醒后端日志线程。


126e2e669220141a48ccf780a44343d4_bca6a72e73444e72b3997e54ac712f5c.png


3.3 前端日志写入


c174456809b041d5b78d78c24fee8de9.png

AsyncLogging::append():前端生成一条日志消息。


前端准备一个前台缓冲区队列 buffers_和两个 buffer。前台缓冲队列 buffers_用来存放积攒的日志消息。两个 buffer,一个是当前缓冲区 currentBuffer,追加的日志消息存放于此;另一个作为当前缓冲区的备份,即预备缓冲区 nextBuffer,减少内存的开销。


函数执行逻辑如下:


判断当前缓冲区 currentBuffer_是否已经写满


  • 若当前缓冲区未满,追加日志消息到当前缓冲,这是最常见的情况
  • 若当前缓冲区写满,首先,把它移入前台缓冲队列 buffers_。其次,尝试把预备缓冲区 nextBuffer_移用为当前缓冲,若失败则创建新的缓冲区作为当前缓冲。最后,追加日志消息并唤醒后端日志线程开始写入日志数据。
int append_cnt = 0;
void AsyncLogging::append(const char *logline, int len) {
    // 1. 多线程加锁,线程安全
    std::lock_guard<std::mutex> lock(mutex_);
    // 2.判断是否写满buffer,批量数据的积攒阶段
    if (currentBuffer_->avail() > len) // 判断buffer还有没有空间写入这条日志
    {
        // 2.1 buffer未满直接写入buffer
        currentBuffer_->append(logline, len); // 直接写入
    } 
    // 2、当前缓冲写满,两件事
    // 其一,将写满的当前缓冲的日志消息写入前台缓冲队列 buffers
    // 其二,追加日志消息到当前缓冲,唤醒后台日志落盘线程
    else {
        // 其一,当前缓冲移入(move)前台缓冲队列 buffers
        buffers_.push_back(std::move(currentBuffer_)); // buffers_是vector,把buffer入队列
        // 判断预备缓冲nextBuffer是否写满,如果未满则复用,如果满了则重新分配
        if (nextBuffer_) // 用了双缓存
        {   
            // 未满,复用
            currentBuffer_ = std::move(nextBuffer_); // 如果不为空则将buffer转移到currentBuffer_
        } else {
            // 满了,重新分配buffer
            currentBuffer_.reset(new Buffer); // Rarely happens如果后端写入线程没有及时读取数据,那要再分配buffer
        }
        // 其二,追加日志信息到当前缓冲,唤醒日志落盘线程
        currentBuffer_->append(logline, len);
        cond_.notify_one(); // 唤醒日志落盘线程
    }
}

3.4 后端日志落盘

AsyncLogging::threadFunc():后端日志落盘线程的执行函数。


后端同样也准备了一个后台缓冲区队列 buffersToWrite 和两个备用 buffer。后台缓冲区队列 buffersToWrite 存放待写入磁盘的数据。两个备用 buffer,newBuffer1和newBuffer2,分别用来替换前台的当前缓冲和预备缓冲,而这两个备用 buffer 最后会被buffersToWrite内的两个 buffer 重新填充,减少了内存的开销。


参考 异步日志模块的实现


void AsyncLogging::threadFunc() {
  assert(running_ == true);
  latch_.countDown();
  // logFile 类负责将数据写入磁盘
  LogFile output(basename_, rollSize_, false);
  BufferPtr newBuffer1(new Buffer); // 用于替换前台的当前缓冲 currentbuffer
  BufferPtr newBuffer2(new Buffer); // 用于替换前台的预备缓冲 nextbuffer 
  newBuffer1->bzero();  
  newBuffer2->bzero();
  BufferVector buffersToWrite;  // 后台缓冲队列
  buffersToWrite.reserve(16);   // 两个不同的缓冲队列,涉及到锁的粒度问题
  // 异步日志开启,则循环执行
  while (running_) {
    assert(newBuffer1 && newBuffer1->length() == 0);
    assert(newBuffer2 && newBuffer2->length() == 0);
    assert(buffersToWrite.empty());
    // <---------- 交换前台缓冲队列和后台缓冲队列 ---------->
    { // 锁的作用域,放在外面,锁的粒度就大了,日志落盘的时候都会阻塞 append
      // 1、多线程加锁,线程安全,注意锁的作用域
      MutexLockGuard lock(mutex_);
      // 2、判断前台缓冲队列 buffers 是否有数据可读
      // buffers 没有数据可读,休眠
      if (buffers_.empty()) {
        // 触发日志的落盘 (唤醒) 的两个条件:1.超时 or 2.被唤醒,即前台写满 buffer
        cond_.waitForSeconds(flushInterval_); // 内部封装 pthread_cond_timedwait
      }
      // 只要触发日志落盘,不管当前的 buffer 是否写满都必须取出来,写入磁盘
      // 3、将当前缓冲区 currentbuffer 移入前台缓冲队列 buffers。
      // currentbuffer 被锁住 -> currentBuffer 被置空  
      buffers_.push_back(std::move(currentBuffer_)); 
      // 4、将空闲的 newbuffer1 移为当前缓冲,复用已经分配的空间
      currentBuffer_ = std::move(newBuffer1); // currentbuffer 需要内存空间
      // 5、核心:把前台缓冲队列的所有buffer交换(互相转移)到后台缓冲队列 
      // 这样在后续的日志落盘过程中不影响前台缓冲队列的插入
      buffersToWrite.swap(buffers_);      
      // 若预备缓冲为空,则将空闲的 newbuffer2 移为预备缓冲,复用已经分配的空间
      // 这样前台始终有一个预备缓冲可供调配
      if (!nextBuffer_) { 
        nextBuffer_ = std::move(newBuffer2);  
      }
    } // 注意这里加锁的粒度,日志落盘的时候不需要加锁了,主要是双队列的功劳
    // <-------- 日志落盘,将buffersToWrite中的所有buffer写入文件 -------->
    assert(!buffersToWrite.empty());
    // 6、异步日志消息堆积的处理。
    // 同步日志,阻塞io,不存在堆积问题;异步日志,直接删除多余的日志,并插入提示信息
    if (buffersToWrite.size() > 25) {
      printf("Dropped\n");
      // 插入提示信息
      char buf[256];
      snprintf(buf, sizeof buf, "Dropped log messages at %s, %zd larger buffers\n",
               Timestamp::now().toFormattedString().c_str(),
               buffersToWrite.size()-2);    
      fputs(buf, stderr);
      output.append(buf, static_cast<int>(strlen(buf)));
      // 只保留2个buffer(默认4M)
      buffersToWrite.erase(buffersToWrite.begin()+2, buffersToWrite.end());   
    }
    // 7、循环写入 buffersToWrite 的所有 buffer
    for (const auto& buffer : buffersToWrite) {
      // 内部封装 fwrite,将 buffer中的一行日志数据,写入用户缓冲区,等待写入文件
      output.append(buffer->data(), buffer->length());  
    }
    // 8、刷新数据到磁盘文件?这里应该保证数据落到磁盘,但事实上并没有,需要改进 fsync
    // 内部调用flush,只能将数据刷新到内核缓冲区,不能保证数据落到磁盘(断电问题)
    output.flush();   
    // 9、重新填充 newBuffer1 和 newBuffer2
    // 改变后台缓冲队列的大小,始终只保存两个 buffer,多余的 buffer 被释放
    // 为什么不直接保存到当前和预备缓冲?这是因为加锁的粒度,二者需要加锁操作
    if (buffersToWrite.size() > 2) {
       // 只保留2个buffer,分别用于填充备用缓冲 newBuffer1 和 newBuffer2
      buffersToWrite.resize(2);  
    }
    // 用 buffersToWrite 内的 buffer 重新填充 newBuffer1
    if (!newBuffer1) {
      assert(!buffersToWrite.empty());
      newBuffer1 = std::move(buffersToWrite.back()); // 复用 buffer
      buffersToWrite.pop_back();
      newBuffer1->reset();    // 重置指针,置空
    }
    // 用 buffersToWrite 内的 buffer 重新填充 newBuffer2
    if (!newBuffer2) {
      assert(!buffersToWrite.empty());
      newBuffer2 = std::move(buffersToWrite.back()); // 复用 buffer
      buffersToWrite.pop_back();
      newBuffer2->reset();   // 重置指针,置空
    }
    // 清空 buffersToWrite
    buffersToWrite.clear();  
  }
  // 存在问题
  output.flush();
}

3.5 coredump 查找未落盘的日志

muduo异步日志——core dump查找未落盘的日志


3.6 总结

如何实现高性能的日志


1)批量写入:同步方式通过积攒一定的数据(如4M)或者设定超时时间,以此触发写入。如glog日志库。异步方式通过append积攒数据,异步落盘线程负责数据写入磁盘,如moduo日志库。


2)唤醒机制:通知唤醒 notify + 超时唤醒 wait_timeout


3)锁的粒度:为减少锁的粒度,减少刷新磁盘的时候日志接口阻塞,采用双队列方式。前台队列实现日志接口,后台队列实现刷新磁盘。


4)内存分配:通过move语义,避免深拷贝;双缓冲,前台后台都设有。

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
目录
相关文章
|
4月前
|
存储 监控 Serverless
阿里泛日志设计与实践问题之Grafana Loki在日志查询方案中存在哪些设计限制,如何解决
阿里泛日志设计与实践问题之Grafana Loki在日志查询方案中存在哪些设计限制,如何解决
|
29天前
|
消息中间件 存储 监控
微服务日志监控的挑战及应对方案
【10月更文挑战第23天】微服务化带来模块独立与快速扩展,但也使得日志监控复杂。日志作用包括业务记录、异常追踪和性能定位。
|
3月前
|
Kubernetes API Docker
跟着iLogtail学习容器运行时与K8s下日志采集方案
iLogtail 作为开源可观测数据采集器,对 Kubernetes 环境下日志采集有着非常好的支持,本文跟随 iLogtail 的脚步,了解容器运行时与 K8s 下日志数据采集原理。
|
4月前
|
XML 监控 Java
异步日志:性能优化的金钥匙
本文主要介绍了Log4j2框架的核心原理、实践应用以及一些实用的小Tips,力图揭示Log4j2这一强大日志记录工具在现代分布式服务架构运维中的关键作用。
|
4月前
|
Java 编译器 数据库
异步日志方案——spdlog
异步日志方案——spdlog
|
4月前
|
SQL JavaScript 前端开发
【Azure 应用服务】Azure JS Function 异步方法中执行SQL查询后,Callback函数中日志无法输出问题
【Azure 应用服务】Azure JS Function 异步方法中执行SQL查询后,Callback函数中日志无法输出问题
|
4月前
|
存储 Prometheus Kubernetes
在K8S中,如何收集K8S日志?有哪些方案?
在K8S中,如何收集K8S日志?有哪些方案?
|
4月前
|
存储 Kubernetes Java
阿里泛日志设计与实践问题之在写多查少的降本场景下,通过SLS Scan方案降低成本,如何实现
阿里泛日志设计与实践问题之在写多查少的降本场景下,通过SLS Scan方案降低成本,如何实现
|
5月前
|
Java API Apache
通用快照方案问题之Feign对日志的记录如何解决
通用快照方案问题之Feign对日志的记录如何解决
31 0
|
23天前
|
XML 安全 Java
【日志框架整合】Slf4j、Log4j、Log4j2、Logback配置模板
本文介绍了Java日志框架的基本概念和使用方法,重点讨论了SLF4J、Log4j、Logback和Log4j2之间的关系及其性能对比。SLF4J作为一个日志抽象层,允许开发者使用统一的日志接口,而Log4j、Logback和Log4j2则是具体的日志实现框架。Log4j2在性能上优于Logback,推荐在新项目中使用。文章还详细说明了如何在Spring Boot项目中配置Log4j2和Logback,以及如何使用Lombok简化日志记录。最后,提供了一些日志配置的最佳实践,包括滚动日志、统一日志格式和提高日志性能的方法。
170 30
【日志框架整合】Slf4j、Log4j、Log4j2、Logback配置模板