第 1 课:trantor 日志系统

对应源文件:

  • trantor/utils/LogStream.h — 底层缓冲区 + 流写入
  • trantor/utils/Logger.h — 核心日志类 + 所有宏定义
  • trantor/utils/AsyncFileLogger.h — 异步文件写入

一、整体架构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
用户代码: LOG_INFO << "玩家登录" << playerId;
        [Logger 对象 (临时变量)]
          构造时:记录时间/文件名/行号/级别
          .stream() 返回 LogStream&
        [LogStream 对象]
          operator<< 链式写入
          数据落入 FixedBuffer (4000字节栈内存)
        [Logger 析构时]
          调用 outputFunc_(index)(buf, len)
          默认 → fwrite(stdout)
          生产环境 → AsyncFileLogger::output()

核心思想:Logger 是一个临时对象,构造写头,析构输出,<< 运算符把数据攒进缓冲区。整个过程利用 C++ 的 RAII 机制自动完成,用户只需一行代码。


二、LogStream:底层流缓冲区

2.1 FixedBuffer — 固定大小缓冲区

1
2
3
// LogStream.h 第 29-30 行
static constexpr size_t kSmallBuffer{4000};      // 4 KB,每条日志用
static constexpr size_t kLargeBuffer{4000 * 1000}; // ~4 MB,AsyncFileLogger 的批量缓冲

FixedBuffer<SIZE> 是一个模板类,内部就是一个栈上的字符数组

1
2
3
4
5
data_[0 ... SIZE-1]
 ↑                ↑
 data_            end()
      cur_  ← 当前写入位置
  • append(buf, len):把数据 memcpycur_ 处,然后 cur_ += len
  • avail():返回剩余可写字节数 = end() - cur_
  • reset():直接把 cur_ 拨回 data_O(1) 清空(不需要 memset)

为什么用栈内存? 日志是高频操作,每次 new/delete 开销大。栈上分配几乎零成本,且 4000 字节对于单条日志绰绰有余。

2.2 LogStream 的溢出保护

LogStream 内部有两个缓冲:

1
2
Buffer buffer_;         // FixedBuffer<4000>,正常情况用这个
std::string exBuffer_;  // 溢出时的备份缓冲(堆分配)

写入逻辑(append 方法,第 204-218 行):

1
2
3
4
5
6
如果 exBuffer_ 为空 (正常路径):
    尝试写入 buffer_
    如果 buffer_ 满了:
        把 buffer_ 内容 + 新数据 一起移到 exBuffer_
否则 (已经溢出过):
    直接追加到 exBuffer_

这是一种懒惰转移策略——正常情况下不分配堆内存,只有真正溢出才付出代价。

2.3 operator<< 重载

LogStream 对所有常用类型都重载了 <<,返回 self& 以支持链式调用:

1
2
LOG_INFO << "count=" << 42 << " name=" << playerName;
//        string     int      string     std::string

特别注意 nullptr 安全

1
2
3
4
5
self &operator<<(const char *str) {
    if (str)  append(str, strlen(str));
    else      append("(null)", 6);  // 不会崩溃
    return *this;
}

三、Logger:核心日志类

3.1 日志级别

1
2
3
4
5
6
7
8
9
enum LogLevel {
    kTrace = 0,  // 追踪,最详细,NDEBUG 下自动关闭
    kDebug,      // 调试,Debug 构建默认开启
    kInfo,       // 普通信息,Release 默认级别
    kWarn,       // 警告
    kError,      // 错误
    kFatal,      // 致命错误
    kNumberOfLogLevels
};

默认级别(静态函数 + 函数内 static 变量,第 282-290 行):

  • Debug 构建kDebug(TRACE/DEBUG 都输出)
  • Release 构建(定义了 RELEASE 宏):kInfo(只输出 INFO 及以上)

3.2 SourceFile — 编译期提取文件名

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class SourceFile {
    template <int N>
    inline SourceFile(const char (&arr)[N]) : data_(arr), size_(N - 1) {
        const char *slash = strrchr(data_, '/');  // 找最后一个 /
        if (slash) {
            data_ = slash + 1;  // 只保留文件名,去掉路径
            size_ -= static_cast<int>(data_ - arr);
        }
    }
};

__FILE__ 展开是完整路径如 /home/user/project/src/GameServer.cppSourceFile编译期通过模板参数 N 知道字符串长度,然后找最后一个 / 截取文件名,输出变成 GameServer.cpp

为什么是编译期? const char (&arr)[N] 模板参数 N 在编译时确定,strrchr 在运行时执行一次,但因为 __FILE__ 是字符串字面量,整个计算实际上是非常快的。

3.3 Logger 的构造函数族

1
2
3
4
5
6
7
8
9
Logger(SourceFile file, int line);                           // INFO 级别
Logger(SourceFile file, int line, LogLevel level);           // 指定级别
Logger(SourceFile file, int line, bool isSysErr);            // 系统错误(errno)
Logger(SourceFile file, int line, LogLevel level, const char *func); // TRACE/DEBUG(含函数名)

// LOG_COMPACT 系列(不含文件名/行号)
Logger();                    // COMPACT INFO
Logger(LogLevel level);      // COMPACT 指定级别
Logger(bool isSysErr);       // COMPACT 系统错误

构造时会调用 formatTime() 把当前时间写入 logStream_,析构时才调用输出函数。

3.4 多通道(Channel)设计

1
2
3
// 静态函数,内部用 static vector 存放每个通道的输出函数
static std::function<void(const char*, uint64_t)>& outputFunc_(size_t index);
static std::function<void()>& flushFunc_(size_t index);

index = -1 表示默认通道,正整数表示具名通道。这个设计允许:

  • 通道 0 写本地文件
  • 通道 1 发送到远端日志服务器
  • 通道 2 写到数据库

用法:

1
LOG_INFO_TO(1) << "发送到通道1";

当通道 index 超出已有 vector 范围时,自动扩容并用默认输出函数填充——这是一个懒初始化设计。


四、宏展开机制(精华)

4.1 TRANTOR_IF_ 的魔法

1
2
// Logger.h 第 31 行
#define TRANTOR_IF_(cond) for (int _r = 0; _r == 0 && (cond); _r = 1)

这是一个只执行一次的 for 循环,等价于 if (cond),但有一个关键优势:

1
2
3
#define LOG_INFO                                                       \
    TRANTOR_IF_(trantor::Logger::logLevel() <= trantor::Logger::kInfo) \
    trantor::Logger(__FILE__, __LINE__).stream()

展开后:

1
2
for (int _r = 0; _r == 0 && (logLevel() <= kInfo); _r = 1)
    trantor::Logger(__FILE__, __LINE__).stream() << "消息";

关键点

  1. Logger(...) 是一个临时对象,在 for 循环语句结束(分号处)立即析构
  2. 析构时调用输出函数,日志被发出
  3. 如果级别不满足,整个 Logger 对象根本不构造,零开销
  4. for 而不是 if 是为了避免宏在 if/else 中出现悬空 else 问题

4.2 完整展开示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 用户写的代码
LOG_INFO << "玩家 " << playerId << " 登录";

// 实际展开
for (int _r = 0;
     _r == 0 && (trantor::Logger::logLevel() <= trantor::Logger::kInfo);
     _r = 1)
    trantor::Logger(__FILE__, __LINE__).stream() << "玩家 " << playerId << " 登录";
//  ^^^^^^^^^^^^^^^^构造^^^^^^^^^^^^   ^^^^^^^返回LogStream&^^^^^^^^^^^^^^^链式写入
//  for 语句块结束 → Logger 析构 → outputFunc_(logStream_.data(), logStream_.len())

4.3 TRACE 在 Release 下的零开销

1
2
3
4
5
#ifdef NDEBUG  // Release 构建定义 NDEBUG
#define LOG_TRACE                                                          \
    TRANTOR_IF_(0)    // ← 条件永远为 false!编译器直接优化掉整个语句块
    trantor::Logger(__FILE__, __LINE__, trantor::Logger::kTrace, __func__) \
        .stream()

TRANTOR_IF_(0) 展开为 for (int _r = 0; _r == 0 && (0); _r = 1),条件恒为 false,编译器会完全删除这段代码,不产生任何运行时开销。

4.4 宏命名规律

前缀含义
LOG_xxx标准格式(时间 + 文件名:行号 + 级别 + 消息)
LOG_xxx_TO(n)同上,输出到通道 n
LOG_COMPACT_xxx紧凑格式(无文件名行号)
LOG_xxx_IF(cond)条件日志,cond 为真才输出
DLOG_xxxDebug-only 日志,NDEBUG 时全部编译为空
LOG_RAW原始输出,不附加任何头部信息
LOG_SYSERR自动附加 strerror(errno)

五、AsyncFileLogger:生产环境异步写日志

5.1 为什么需要异步?

同步写日志存在问题:

  • fwrite 是同步 I/O,在高并发下会阻塞调用线程
  • 磁盘 I/O 延迟可能达到毫秒级
  • 游戏服务器主线程不能等磁盘

5.2 双缓冲 + 后台线程设计

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
主线程 (output 调用):
  获取 mutex_
  把消息追加到 logBufferPtr_ (当前缓冲)
  释放 mutex_
  如果缓冲满 → notify_one() 唤醒后台线程

后台线程 (logThreadFunc):
  等待 cond_ 信号
  获取 mutex_
  swap(logBufferPtr_, nextBufferPtr_)  ← 双缓冲交换,极快
  把 logBufferPtr_ 放入 writeBuffers_ 队列
  释放 mutex_
  遍历 writeBuffers_ 逐个写入磁盘
1
2
3
4
5
6
7
8
                  ┌──────────────────┐
主线程写入 ───────►│  logBufferPtr_   │ ← 当前活跃缓冲
                  └──────────────────┘
                          │ 满或定时
                          ▼ swap
                  ┌──────────────────┐
后台线程消费 ◄────│  nextBufferPtr_  │ ← 切换后由后台线程写磁盘
                  └──────────────────┘

交换的魔法std::swap(logBufferPtr_, nextBufferPtr_) 是指针交换,O(1) 完成,主线程几乎不被阻塞。

5.3 LoggerFile — 日志文件管理

1
2
3
4
5
6
class LoggerFile {
    FILE *fp_;                       // 文件句柄
    Date creationDate_;              // 创建时间(用于生成文件名)
    static uint64_t fileSeq_;        // 全局序号(同一秒创建多个文件时区分)
    std::deque<std::string> filenameQueue_; // 已创建文件名队列(用于限制文件数)
};

文件名示例trantor.20240101_120000_1.log(基名 + 时间 + 序号 + 扩展名)

文件轮转逻辑

  • sizeLimit_ 默认 20 MB,超过则调用 switchLog() 切换到新文件
  • maxFiles_ 默认 0(不限制),设置后会调用 deleteOldFiles() 删除最旧的文件
  • switchOnLimitOnly_ = true 时,只有达到大小限制才换文件(不在析构时换)

5.4 使用方式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 程序启动时配置
trantor::AsyncFileLogger asyncLogger;
asyncLogger.setFileName("gameserver", ".log", "/var/log/game/");
asyncLogger.setFileSizeLimit(100 * 1024 * 1024);  // 100 MB
asyncLogger.setMaxFiles(10);                        // 最多保留 10 个文件
asyncLogger.startLogging();

// 把 Logger 的输出函数替换为异步文件写
trantor::Logger::setOutputFunction(
    [&asyncLogger](const char *msg, uint64_t len) {
        asyncLogger.output(msg, len);
    },
    [&asyncLogger]() {
        asyncLogger.flush();
    }
);

// 之后所有 LOG_xxx 宏自动写文件,不再输出到 stdout
LOG_INFO << "服务器启动完成";

六、RawLogger

RawLoggerLogger 的简化版,只有一个 LogStream,析构时直接调用 outputFunc_不附加任何时间戳、级别、文件名

用于输出需要原始格式的内容,如打印二进制数据的十六进制 dump。

1
LOG_RAW << "raw binary data: " << hexDump;

七、关键设计模式总结

设计模式体现
RAIILogger 析构时自动输出,用户不需要手动调用 flush
策略模式setOutputFunction() 可以替换输出后端(stdout / 文件 / 网络)
双缓冲AsyncFileLogger 用两个缓冲指针交替,降低主线程等待时间
函数内 staticlogLevel_() / outputFunc_() 用函数内 static 实现单例,避免静态初始化顺序问题
懒初始化多通道 vector 按需扩容,首次使用才分配
零开销抽象NDEBUGLOG_TRACE 编译为空,运行时零开销

八、常见使用场景

场景 1:游戏服务器快速接入

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// main.cpp
int main() {
    // 设置日志级别(线上用 Info,排查问题用 Debug)
    trantor::Logger::setLogLevel(trantor::Logger::kInfo);

    // 开启本地时间显示(默认 UTC)
    trantor::Logger::setDisplayLocalTime(true);

    LOG_INFO << "服务器启动,端口=" << port;
    // ...
}

场景 2:条件日志避免性能损耗

1
2
// 玩家数量大时不输出每次移动,小规模测试时才输出
LOG_DEBUG_IF(playerCount < 100) << "玩家移动: " << playerId;

场景 3:系统错误自动附加 errno

1
2
3
4
int fd = open("config.json", O_RDONLY);
if (fd < 0) {
    LOG_SYSERR << "打开配置文件失败";  // 自动附加: errno=2(No such file)
}

核心收获

  • LOG_INFO << "msg" 展开为临时 Logger 对象,析构时刷出,写入路径无格式化开销
  • FixedBuffer<4000> 是栈上固定缓冲,日志写入路径无堆分配
  • AsyncFileLogger 双缓冲交换:前台写内存队列,后台线程批量刷盘,I/O 线程不阻塞
  • Logger::setOutputFunction() 可对接任意后端(ELK、syslog、自定义),生产环境必须设置

九、思考题

  1. 为什么 TRANTOR_IF_for 而不是 if?(提示:考虑 if/else 嵌套场景)
  2. FixedBuffer::reset() 只移动指针,不清空内存,会有安全问题吗?
  3. 多线程环境下多个线程同时调用 LOG_INFOLogStream 是否需要加锁?(提示:Logger 是栈上临时变量)
  4. AsyncFileLoggerlostCounter_ 是用来做什么的?什么情况下日志会丢失?

十、思考题参考答案

1. 为什么 TRANTOR_IF_for 而不是 if

核心原因:避免悬空 else(dangling else)问题

假设用 if 实现:

1
2
3
4
5
6
7
#define TRANTOR_IF_(cond) if (cond)

// 用户代码
if (someCondition)
    LOG_INFO << "分支1";
else
    doSomething();

展开后变成:

1
2
3
4
5
if (someCondition)
    if (logLevel() <= kInfo)          // ← TRANTOR_IF_ 展开
        Logger(...).stream() << "分支1";
else                                  // ← 本意绑定外层 if,实际绑定了内层 if!
    doSomething();

C++ 的 else 匹配最近的未配对 if,所以 else 会错误地绑定到 TRANTOR_IF_ 展开的内层 if 上,语义完全改变。

for 循环是一个完整的语句,不会和外部 else 产生歧义:

1
2
3
4
5
if (someCondition)
    for (int _r = 0; _r == 0 && (logLevel() <= kInfo); _r = 1)
        Logger(...).stream() << "分支1";
else                                  // ← 正确绑定外层 if
    doSomething();

for 循环通过 _r 变量保证只执行一次,语义等价于 if,但没有悬空 else 的隐患。


2. FixedBuffer::reset() 只移动指针,不清空内存,会有安全问题吗?

不会有安全问题,原因如下:

  • FixedBufferLogStream 的内部实现,只通过 append() 写入、通过 data() + length() 读出。读取范围严格限定在 [data_, cur_) 之间,reset()cur_ 拨回 data_ 后,length() 返回 0,外部无法读到旧数据
  • FixedBuffer 分配在栈上(作为 Logger 临时对象的成员),Logger 析构后栈帧回收,旧数据自然不可达。
  • 日志内容本身不属于敏感数据(密码、密钥不应出现在日志中),即使栈上残留旧日志文本,也没有安全风险。
  • 不做 memset有意为之的性能优化——日志是高频操作,清零 4000 字节的开销完全没有必要。

如果是处理密码、密钥等安全敏感场景的缓冲区,则必须在释放前 memset 或使用 SecureZeroMemory 清零,防止通过内存残留泄露。但日志缓冲区不属于此类场景。


3. 多线程环境下多个线程同时调用 LOG_INFOLogStream 是否需要加锁?

不需要加锁。关键在于 Logger 是一个栈上的临时对象

1
2
3
4
LOG_INFO << "线程A的消息";
// 展开为:
for (int _r = 0; _r == 0 && (...); _r = 1)
    trantor::Logger(__FILE__, __LINE__).stream() << "线程A的消息";

每次 LOG_INFO 展开时,Logger(...) 都是在当前线程栈帧上构造的临时对象,其内部的 LogStream(包含 FixedBuffer)也在该线程的栈上

  • 线程 A 的 Logger 对象和线程 B 的 Logger 对象是完全独立的实例,各自有独立的 FixedBuffer
  • << 操作写入的是各自栈上的缓冲区,没有共享数据,天然线程安全
  • 不加锁意味着日志写入路径是无锁的(lock-free),这对高并发游戏服务器至关重要

需要注意的是Logger 析构时调用 outputFunc_() 将缓冲区内容输出到目标(stdout 或 AsyncFileLogger)。如果输出目标是 AsyncFileLogger,其 output() 方法内部通过 mutex_ 保护 logBufferPtr_——这个锁是由 AsyncFileLogger 负责的,不是 LogStream 的职责。整体设计遵循单一职责LogStream 负责无锁攒数据,AsyncFileLogger 负责线程安全地收集和落盘。


4. AsyncFileLoggerlostCounter_ 是用来做什么的?什么情况下日志会丢失?

lostCounter_日志丢失计数器,用于记录因缓冲区积压过多而被丢弃的日志条数。

源码逻辑(AsyncFileLogger.cc 第 89-105 行):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void AsyncFileLogger::output(const char *msg, uint64_t len) {
    // ...
    if (writeBuffers_.size() > 25)  // 超过 25 个待写缓冲(约 100 MB)
    {
        ++lostCounter_;             // 计数 +1
        return;                     // 直接丢弃这条日志!
    }

    if (lostCounter_ > 0)           // 有丢失记录
    {
        // 写入一条提示信息:"xxx log information is lost"
        snprintf(logErr, ..., "%llu log information is lost\n", lostCounter_);
        lostCounter_ = 0;
        logBufferPtr_->append(logErr, strlen);
    }
    logBufferPtr_->append(msg, len);  // 正常写入
}

日志丢失的场景

磁盘 I/O 速度远低于日志产生速度时,后台线程来不及将缓冲区写入磁盘,writeBuffers_ 队列不断堆积。当队列中积压超过 25 个缓冲(每个约 4 MB,总计约 100 MB)时,新日志被直接丢弃,lostCounter_ 递增。

典型触发场景:

  • 磁盘满了或 I/O 性能急剧下降(如 NFS 挂载的网络磁盘抖动)
  • 瞬时产生大量日志(如循环中误加了 LOG_INFO
  • 后台线程被阻塞或调度延迟

当缓冲区恢复正常(低于 25 个)后,下一次 output() 调用会先写入一条 "xxx log information is lost" 的提示,告知运维有日志丢失,然后将 lostCounter_ 清零。

这是一种有损降级(graceful degradation)策略——宁可丢日志,也不能让内存无限增长导致 OOM 崩溃,保护了进程的稳定性。


学习日期:2026-04-01 | 下一课:第02课_消息缓冲区MsgBuffer