双缓冲日志模式

一、什么是双缓冲日志模式?

双缓冲日志模式,简单说就是:

准备两个缓冲区,一个负责让业务线程写日志,另一个负责让日志线程刷盘。

当一个缓冲区写满或满足刷盘条件时,两个缓冲区交换角色。

它的核心目标是:

  • 减少业务线程直接写磁盘的开销;
  • 降低锁竞争;
  • 提高日志写入吞吐;
  • 避免每条日志都调用 write()
  • 让日志写入变成批量写入。

二、为什么需要双缓冲?

如果每次打印日志都直接写文件,大概是这样:

cpp 复制代码
void log(const string& msg) {
    write(fd, msg.c_str(), msg.length());
}

这看起来简单,但问题很多:

  1. 磁盘 I/O 慢

    • write() 可能涉及系统调用;
    • 如果频繁调用,性能开销很大。
  2. 多线程竞争严重

    • 多个业务线程同时写日志,需要加锁;
    • 所有线程都在争抢同一个日志文件。
  3. 业务线程被阻塞

    • 日志本来是辅助功能;
    • 但直接写文件可能拖慢主业务逻辑。

所以更好的做法是:

业务线程只负责把日志写入内存缓冲区,真正写文件的事情交给后台日志线程。


三、双缓冲的基本结构

通常会有两个缓冲区:

cpp 复制代码
ostringstream streamA;
ostringstream streamB;

或者类似:

cpp 复制代码
string bufferA;
string bufferB;

再维护两个指针:

cpp 复制代码
ostringstream* currentStream;  // 当前业务线程写入的缓冲区
ostringstream* writeStream;    // 当前日志线程刷盘的缓冲区

逻辑大概是:

cpp 复制代码
currentStream = &streamA;
writeStream = &streamB;

业务线程不断写:

cpp 复制代码
*currentStream << logMessage;

日志线程发现需要写文件时,交换两个缓冲区:

cpp 复制代码
swap(currentStream, writeStream);

之后:

  • 业务线程继续往新的 currentStream 写日志;
  • 日志线程把旧的 writeStream 内容写入文件。

四、结合其他代码来看

你之前的代码里有这一段:

cpp 复制代码
lock();

ostringstream *stream = swapStream();

string logstr = stream->str();
stream->str("");

unlock();

这就是典型的双缓冲设计。

可以理解为:

cpp 复制代码
lock();

加锁,防止交换缓冲区时业务线程还在写。

cpp 复制代码
ostringstream *stream = swapStream();

切换缓冲区。

比如原来业务线程正在写 streamA,日志线程就把它切走,然后让业务线程改写 streamB

cpp 复制代码
string logstr = stream->str();

取出旧缓冲区中的日志内容。

cpp 复制代码
stream->str("");

清空旧缓冲区,方便下次继续使用。

cpp 复制代码
unlock();

释放锁。

然后日志线程在锁外写文件:

cpp 复制代码
int retval = write(fd, logstr.c_str(), logstr.length());

这一点很关键:

只在切换缓冲区时加锁,真正写磁盘时不持有锁。

这样做可以避免业务线程被磁盘 I/O 长时间阻塞。


五、双缓冲日志的运行过程

假设有两个缓冲区:

text 复制代码
Buffer A
Buffer B

初始状态:

text 复制代码
业务线程写入 -> Buffer A
日志线程备用 -> Buffer B

业务线程不断写日志:

text 复制代码
Buffer A:
[log1]
[log2]
[log3]

日志线程准备刷盘时:

text 复制代码
交换 Buffer A 和 Buffer B

交换后:

text 复制代码
业务线程写入 -> Buffer B
日志线程刷盘 -> Buffer A

日志线程把 Buffer A 写入文件:

text 复制代码
write(fd, Buffer A)

写完后清空:

text 复制代码
Buffer A = empty

下一次再交换:

text 复制代码
业务线程写入 -> Buffer A
日志线程刷盘 -> Buffer B

反复循环。


六、一个简化版例子

下面是一个简化版双缓冲日志模型,方便理解:

cpp 复制代码
class DoubleBufferLogger {
private:
    ostringstream bufferA;
    ostringstream bufferB;

    ostringstream* currentBuffer;
    ostringstream* flushBuffer;

    mutex mtx;
    int fd;

public:
    DoubleBufferLogger(int logFd)
        : currentBuffer(&bufferA),
          flushBuffer(&bufferB),
          fd(logFd) {}

    void append(const string& msg) {
        lock_guard<mutex> lock(mtx);
        (*currentBuffer) << msg << '\n';
    }

    void flush() {
        string logData;

        {
            lock_guard<mutex> lock(mtx);

            swap(currentBuffer, flushBuffer);

            logData = flushBuffer->str();
            flushBuffer->str("");
            flushBuffer->clear();
        }

        if (!logData.empty()) {
            write(fd, logData.c_str(), logData.size());
        }
    }
};

重点是这一段:

cpp 复制代码
{
    lock_guard<mutex> lock(mtx);

    swap(currentBuffer, flushBuffer);

    logData = flushBuffer->str();
    flushBuffer->str("");
    flushBuffer->clear();
}

它只在交换缓冲区和取数据的时候加锁。

而真正的磁盘写入:

cpp 复制代码
write(fd, logData.c_str(), logData.size());

是在锁外完成的。


七、再举一个生活化例子

可以把双缓冲日志想象成餐厅洗碗。

有两个盘子筐:

text 复制代码
筐 A
筐 B

服务员不断把脏盘子放进筐 A。

当筐 A 快满了,洗碗工说:

text 复制代码
筐 A 给我洗,你们先用筐 B。

于是:

text 复制代码
服务员继续往筐 B 放盘子;
洗碗工同时清洗筐 A。

等筐 B 快满了,再交换:

text 复制代码
筐 B 给洗碗工;
服务员继续用筐 A。

这样服务员不会因为洗碗工正在洗盘子而停下来。

对应到日志系统:

餐厅场景 日志系统
服务员 业务线程
脏盘子 日志消息
盘子筐 A/B 日志缓冲区 A/B
洗碗工 日志线程
洗盘子 写入日志文件
交换盘子筐 switchStream()

八、再举一个网络发送例子

双缓冲不仅能用于日志,也常用于网络发送。

比如游戏服务器要给客户端发送数据。

如果每产生一条消息就立刻发送:

cpp 复制代码
send(socketFd, msg.c_str(), msg.size(), 0);

性能会很差。

可以设计两个发送缓冲区:

text 复制代码
sendBufferA
sendBufferB

业务线程把待发送消息放到 sendBufferA

cpp 复制代码
sendBufferA.append(packet);

网络线程准备发送时,交换缓冲区:

cpp 复制代码
swap(activeSendBuffer, pendingSendBuffer);

然后网络线程批量发送:

cpp 复制代码
send(socketFd, pendingSendBuffer.data(), pendingSendBuffer.size(), 0);

这样业务线程不用等待网络 I/O。


九、再举一个图形渲染例子

双缓冲在图形渲染里也非常常见。

例如屏幕显示一帧画面时,如果直接在当前屏幕缓冲上画图,用户可能看到画面撕裂或闪烁。

所以图形系统通常有:

text 复制代码
前台缓冲区 front buffer
后台缓冲区 back buffer

程序先在后台缓冲区画完整的一帧:

text 复制代码
back buffer: 正在绘制
front buffer: 正在显示

画完之后交换:

text 复制代码
swap(front buffer, back buffer)

这样用户看到的是完整画面,而不是正在绘制一半的画面。

这个思想和双缓冲日志非常类似:

图形渲染 日志系统
back buffer 当前写入日志的缓冲区
front buffer 准备输出的缓冲区
绘图线程 业务线程
显示器刷新 日志线程写文件
buffer swap switchStream()

十、双缓冲日志的优点

1. 减少系统调用次数

不用每条日志都 write()

可以把很多日志合并成一次写入:

cpp 复制代码
write(fd, logstr.c_str(), logstr.length());

这是批量写入。


2. 减少锁持有时间

你的代码中锁的范围是:

cpp 复制代码
lock();

ostringstream *stream = swwapStream();
string logstr = stream->str();
stream->str("");

unlock();

写文件不在锁里面:

cpp 复制代码
write(fd, logstr.c_str(), logstr.length());

这很好。

因为磁盘 I/O 可能很慢,如果持锁写磁盘,其他线程写日志就会被卡住。


3. 提高业务线程性能

业务线程只负责写内存:

cpp 复制代码
*currentStream << msg;

内存操作很快。

真正慢的文件写入交给后台线程处理。


4. 更适合高并发日志

多个业务线程可以快速把日志放到缓冲区里,然后让日志线程统一刷盘。

这比所有线程直接抢日志文件要好很多。


十一、需要注意的问题

双缓冲日志虽然好,但也有一些坑。

1. 程序崩溃时可能丢日志

因为日志先写进内存缓冲区,不是马上落盘。

如果程序突然崩溃,缓冲区里的日志可能还没写入文件。

解决办法:

  • 重要错误日志立即刷盘;
  • 定时刷盘;
  • 程序退出前强制 flush;
  • 对严重日志调用 fsync(),但性能会下降。

2. 日志量太大时缓冲区可能撑爆

如果业务线程写得太快,而日志线程刷盘太慢,缓冲区可能不断变大。

解决办法:

  • 设置缓冲区最大容量;
  • 超过容量后丢弃低级别日志;
  • 阻塞业务线程;
  • 使用多缓冲区队列;
  • 按日志级别限流。

3. ostringstream 清理要注意

清空内容时常见写法是:

cpp 复制代码
stream->str("");
stream->clear();

str("") 是清空内部字符串内容。

clear() 是清除流状态,比如错误状态。

更稳妥的写法是两个都调用。


4. write() 不一定一次写完

你代码里这一句:

cpp 复制代码
int retval = write(fd, logstr.c_str(), logstr.length());

需要注意:write() 返回值可能小于 logstr.length()

也就是说,它可能只写入了一部分。

更稳妥的写法应该循环写:

cpp 复制代码
ssize_t writeAll(int fd, const char* data, size_t len) {
    size_t total = 0;

    while (total < len) {
        ssize_t n = write(fd, data + total, len - total);

        if (n < 0) {
            if (errno == EINTR) {
                continue;
            }
            return -1;
        }

        if (n == 0) {
            break;
        }

        total += n;
    }

    return total;
}

然后:

cpp 复制代码
ssize_t ret = writeAll(m_ltLogFD, logstr.c_str(), logstr.length());
if (ret < 0) {
    coutLog(__FILE__, __LINE__, LL_ERROR,
            "write to log file failed. errno=%d", errno);
    return ret;
}

十二、适合写到 blog 的总结

你可以在 blog 里这样描述:

双缓冲日志模式是一种常见的异步日志优化方案。它通过维护两个内存缓冲区,让业务线程和日志写入线程分别工作在不同的缓冲区上。当日志线程需要刷盘时,只需要短暂加锁并交换两个缓冲区,随后在锁外将旧缓冲区中的日志批量写入文件。这样既减少了磁盘 I/O 次数,又缩短了锁持有时间,从而提高多线程程序中的日志写入性能。

也可以再精简成一句:

双缓冲日志的核心思想是:业务线程只写内存,日志线程批量刷盘,二者通过交换缓冲区降低锁竞争和 I/O 开销。


你的那段代码里,最关键的双缓冲逻辑就是:

cpp 复制代码
lock();

ostringstream *stream = swapStream();

string logstr = stream->str();
stream->str("");

unlock();

其中 swapStream() 很可能就是将当前写入缓冲区和待刷盘缓冲区进行交换。

这就是双缓冲日志模式的核心。