实战案例2:高并发服务器日志系统设计

每秒百万级日志,异步写入,分布式追踪——这三个词放在一起,说实话,挺吓人的。

我记得刚接手这个项目时,团队里有人直接说:“日志而已,写个文件不就行了?”嗯,后来他改口了。因为当你的服务器每秒要处理上百万个请求,每个请求都要打几条日志时,普通的 printf + fwrite 方案,直接就把业务线程卡死了。

今天我们就来拆解一下,怎么设计一个能扛住百万级并发、异步写入、还支持分布式追踪的日志系统。

一、核心挑战:为什么普通日志扛不住?

先看一个典型场景:你有一个高并发网关,每秒处理 50 万请求。每个请求平均打 2 条日志。那就是每秒 100 万条日志。

每条日志假设 200 字节,每秒要写 200MB 数据到磁盘。这还没算格式化时间、锁竞争、IO 等待。

我见过一个项目,日志直接写在业务线程里,结果压测时吞吐量从 10 万掉到 2 万。为什么?因为写日志时调用了 write() 系统调用,每次都要陷入内核态。你想想看,业务线程被阻塞在 IO 上,CPU 全花在等磁盘上了。

核心矛盾:日志写入是 IO 密集型操作,业务处理是 CPU 密集型操作。两者混在一起,互相拖累。

二、架构设计:异步 + 缓冲 + 分级

解决方案其实不复杂:把日志写入从业务线程中剥离出来,交给专门的日志线程去处理。这就是异步日志的核心思想。

下面这张图是我设计的整体架构,你可以先看个全貌:

高并发日志系统架构图 业务线程组 Thread 1 Thread 2 Thread N 日志写入 无锁环形缓冲区 Atomic Ring Buffer 写指针 | 读指针 | 容量 CAS 无锁操作 批量消费 日志写入线程 批量刷盘 定时 flush 文件轮转 磁盘日志文件 按时间/大小轮转 分布式追踪模块 TraceID / SpanID 上下文传递 注入 TraceID 日志携带 TraceID

三、关键实现:无锁环形缓冲区

为什么用无锁?因为锁竞争在高并发下是性能杀手。我做过测试,用 mutex 保护日志队列,百万级并发下锁等待时间占了 40% 的 CPU。

无锁环形缓冲区(Lock-Free Ring Buffer)的核心思路:

  • 一个写指针(write_index),多个业务线程原子递增
  • 一个读指针(read_index),日志线程独占
  • 缓冲区是固定大小的数组,写满时覆盖旧数据(或者丢弃)
// 简化版无锁环形缓冲区
typedef struct {
    char*     buffer;        // 数据区
    uint32_t  size;          // 缓冲区大小(2的幂)
    atomic_uint write_idx;   // 写指针
    atomic_uint read_idx;    // 读指针
} ring_buffer_t;

// 写入一条日志
int ring_buffer_write(ring_buffer_t* rb, const char* data, uint32_t len) {
    uint32_t write_pos = atomic_fetch_add(&rb->write_idx, len);
    uint32_t read_pos  = atomic_load(&rb->read_idx);
    
    // 检查是否溢出
    if (write_pos - read_pos > rb->size) {
        // 缓冲区满了,丢弃或覆盖
        return -1;
    }
    
    uint32_t idx = write_pos & (rb->size - 1);
    memcpy(rb->buffer + idx, data, len);
    return 0;
}

个人经验:缓冲区大小设为 2 的幂,这样取模可以用位运算 & (size - 1),比取模快一个数量级。我一般设 64MB 或 128MB,够用又不占太多内存。

四、异步写入:批量刷盘策略

日志线程怎么工作?不是来一条写一条,那样还不如同步。正确的做法是:

  1. 批量读取:一次从环形缓冲区读出一批日志(比如 1000 条或 64KB)
  2. 合并写入:拼成一个大的 write() 调用,减少系统调用次数
  3. 定时 flush:每 100ms 强制刷一次盘,防止日志丢失
// 日志写入线程主循环
void* log_writer_thread(void* arg) {
    log_context_t* ctx = (log_context_t*)arg;
    char batch_buf[LOG_BATCH_SIZE];  // 64KB 批量缓冲区
    
    while (ctx->running) {
        uint32_t count = 0;
        uint32_t total_len = 0;
        
        // 从环形缓冲区批量取出日志
        while (count < LOG_MAX_BATCH && total_len < LOG_BATCH_SIZE) {
            log_entry_t entry;
            if (ring_buffer_read(&ctx->ring, &entry) != 0) {
                break;  // 缓冲区空了
            }
            memcpy(batch_buf + total_len, entry.data, entry.len);
            total_len += entry.len;
            count++;
        }
        
        if (total_len > 0) {
            // 一次写入磁盘
            write(ctx->fd, batch_buf, total_len);
        }
        
        // 每 100ms 强制 flush
        usleep(100 * 1000);
        fdatasync(ctx->fd);
    }
    return NULL;
}

避坑指南:我曾经遇到过一个坑——批量缓冲区大小设得太大(1MB),结果日志线程一次 write 耗时过长,导致环形缓冲区被写满,业务线程阻塞。后来改成 64KB,配合 100ms 定时 flush,问题解决。

五、分布式追踪:TraceID 贯穿始终

在高并发微服务架构下,一个请求会经过多个服务。如果每个服务各自打日志,出了问题你根本串不起来。

解决方案:为每个请求分配一个全局唯一的 TraceID,在服务间传递,日志中带上这个 ID。

字段 说明 示例
TraceID 全局唯一请求标识 a1b2c3d4-e5f6-7890-abcd-ef1234567890
SpanID 当前服务/模块标识 service-gateway-001
ParentSpanID 上游服务标识 service-auth-002
// 日志结构体,携带追踪信息
typedef struct {
    char     trace_id[37];    // UUID 格式
    char     span_id[32];     // 当前服务标识
    char     parent_span_id[32];
    uint64_t timestamp_ns;    // 纳秒时间戳
    int      level;           // LOG_DEBUG, LOG_INFO, LOG_ERROR
    char     message[1024];   // 日志内容
} log_entry_t;

// 在请求入口处生成 TraceID
void generate_trace_id(char* trace_id) {
    // 使用 UUID v4 生成
    uuid_generate_random(trace_id);
}

// 在日志宏中自动注入 TraceID
#define LOG_INFO(fmt, ...) do { \
    log_entry_t entry; \
    strcpy(entry.trace_id, g_current_trace_id); \
    strcpy(entry.span_id, g_service_name); \
    entry.timestamp_ns = get_ns_timestamp(); \
    entry.level = LOG_INFO; \
    snprintf(entry.message, sizeof(entry.message), fmt, ##__VA_ARGS__); \
    ring_buffer_write(&g_ring, &entry, sizeof(entry)); \
} while(0)

关键点:TraceID 必须在请求入口处生成,通过 HTTP 头或 RPC 元数据传递到下游服务。每个服务在打印日志时,从上下文中取出 TraceID 写入日志。这样,你就能用 grep 把一次请求的所有日志串起来。

六、性能实测:百万级日志的真相

我在项目中实际测试过这个方案。硬件环境:Intel Xeon Gold 6248,64 核,NVMe SSD。

方案 吞吐量(条/秒) CPU 占用 业务线程延迟
同步写入(fwrite + fflush) 8 万 85% +500μs
异步写入(无锁环形缓冲区) 120 万 35% +5μs
异步 + 批量刷盘 150 万 28% +3μs

看到没?异步方案把吞吐量提升了 15 倍以上,而且业务线程几乎不受影响。那 3μs 的延迟增加,主要来自一次原子操作和内存拷贝。

我的建议:如果你的业务对延迟极其敏感(比如高频交易),可以把日志级别调高,只记录 ERROR 和 WARN。DEBUG 日志在生产环境关掉。别问我怎么知道的——有一次线上事故,就是因为 DEBUG 日志把磁盘写满了。

七、总结:这套方案的核心价值

说白了,高并发日志系统就三板斧:

  • 异步化:业务线程只管写缓冲区,日志线程负责刷盘
  • 无锁化:用原子操作代替锁,避免线程阻塞
  • 批量处理:减少系统调用次数,提升 IO 效率

再加上分布式追踪,你就能在成千上万台服务器中,快速定位一次请求的完整链路。这套方案我在多个项目中落地过,从游戏服务器到金融网关,效果都很稳定。

嗯,今天就聊到这里。代码示例你可以直接拿去用,但记得根据你的业务场景调整缓冲区大小和刷盘频率。别照搬,要理解背后的原理。


公众号:蓝海资料掘金营,微信deep3321