两种异步日志方案的介绍

文章目录

  • 一、日志写入逻辑
    • 1.1 相关接口函数
    • 1.2 写入逻辑
  • 二、log4cpp 日志框架
    • 2.1 下载和编译
    • 2.2 日志级别
    • 2.3 日志格式
    • 2.4 日志输出
    • 2.5 日志回滚
  • 三、muduo 异步日志库
    • 3.1 异步日志机制
    • 3.2 双缓冲机制
    • 3.3 前端日志写入
    • 3.4 后端日志落盘
    • 3.5 coredump 查找未落盘的日志
    • 3.6 总结

一、日志写入逻辑

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把内核缓冲区数据刷新到磁盘。
两种异步日志方案的介绍_第1张图片

二、log4cpp 日志框架

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

Log4cpp中最重要概念有Category(种类)、Appender(附加器)、Layout(布局)、Priorty(优先级)、NDC(嵌套的诊断上下⽂)。
两种异步日志方案的介绍_第2张图片

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·),作为队列的元素。等队列满了,再唤醒消费者线程,将日志批量写入磁盘。
两种异步日志方案的介绍_第3张图片
因此,后端日志线程的唤醒有两个条件
1)buffer 写满唤醒:批量写入写满1个 buffer 后,唤醒后端日志线程,减少线程被唤醒的频率,降低系统开销。
2)超时被唤醒:为了及时将日志消息写入文件,防止系统故障导致内存中的日志消息丢失。超过规定的时间阈值,即使 buffer 未满,也会通过wait_timeout唤醒日志落盘线程,立即将 buffer 中的数据写入。

3.2 双缓冲机制

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

两种异步日志方案的介绍_第4张图片

3.3 前端日志写入

两种异步日志方案的介绍_第5张图片

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语义,避免深拷贝;双缓冲,前台后台都设有。

你可能感兴趣的:(Linux基础组件,服务器,muduo,后端,Linux)