如果你以为日志系统就是把println!换成一个写文件函数,那这篇文章你值得看完。我在调一个 PvP 玩法的压测帧率时,遇到过这样一个场景:144 的帧率在某个特效密集瞬间掉到个位数,断点跟到 GameLoop 发现罪魁是开发期顺手加的一行 INFO 日志——它每次都在同步刷磁盘。那一刻我意识到,在游戏这种 16ms 就要画一帧的战场上,日志系统的高性能和可扩展性根本不是锦上添花,而是能决定你把周五晚上花在玩家反馈群还是花在修 bug 上。后来我用 Rust 重写了这套游戏日志系统,用生产者-消费者模型把写入动作从游戏线程中彻底剥离,并做了一组性能对比——同样 100 万条日志,最慢的方案要 40 秒,现在的实现只需要半秒出头,游戏线程几乎无感知。不管你是刚接触 Rust 想找一个能落地的实战项目,还是正在给游戏客户端/服务端挑日志方案,这套完整代码和踩坑记录都值得你存下来。
1. 游戏日志绝不是"print一下"那么简单
1.1 一个真实的翻车现场
那次压测的细节我记得很清楚。战斗场景里 8 个玩家同时放大招,满屏粒子特效,帧率从 130 多突然掉到 9。一开始怀疑是特效合批、贴图内存带宽或者 GC 分配问题,性能分析器里 CPU 耗时排第一的居然是一个叫log_write的函数——准确说是它里面那行file.flush()。
顺着调用链查下去,发现是前一周某个同事为了排查关卡加载问题,在update_perf()里加了一行:每帧记录一次耗时数据。本来这不算多,问题是他封装日志函数时图省事,用的是"打开文件、写入、立即 flush、关闭文件"的写法。于是每一帧都有一次完整的文件打开/写入/关闭周期,帧率直接被拖垮。
这个案例说明一个很普遍的问题:很多游戏团队在早期调通功能后,根本不会回头审视日志代码的性能。当时我第一个念头是"把日志改成后台写不就行了",但认真设计后才发现,事情没那么简单——日志框架的锁竞争、格式化开销、进程崩溃时的数据丢失、日志文件无限膨胀,全是坑。所以在动手写任何代码之前,我先把问题拆开了。
1.2 日志拖慢游戏的三个元凶
先说结论:日志系统拖慢游戏,基本逃不出下面三个原因。
格式化与内存分配。format!拼接字符串、数字转字符串、临时对象分配,一帧几十条日志就意味着几十次堆分配。在某些语言里这还伴随 GC 压力,Rust 虽然没有 GC,但每一条 String 的drop、Buffer 的扩容,都不是免费的。日志量大的时候,光格式化就能吃掉几个毫秒的帧预算。
系统调用与磁盘 IO。write 一个字节和 write 一兆字节的系统调用固定开销差别不大,但如果你每行日志都触发一次 write 甚至 fsync,那相当于让游戏线程反复陷入内核态。一次 write 系统调用在普通消费级 NVMe SSD 上大概要 1~5 微秒,看起来不多,但 100 万条日志就是 1~5 秒的纯系统调用开销。如果日志文件在机械硬盘或者网络盘上,一次 write 可能是几十微秒到毫秒级。
全局锁竞争。Rust 标准库的println!内部会对全局 stdout 加锁,这意味着所有线程打日志时都在抢同一把锁。假设 A 线程在渲染管线里持锁输出,B 线程的逻辑更新也只能干等。日志库如果直接封装println!或者内部用Mutex<File>,在 8 核 16 线程的游戏进程里,日志吞吐稍高一点就会成为全局瓶颈。
1.3 高性能日志系统的设计目标
分析完问题,我把这套系统要满足的条件明确了下来,后面所有代码都是围绕这些目标写的:
- 调用方无阻塞:游戏主线程、渲染线程、逻辑线程打日志时,不应该触发任何 IO 操作,最好连系统调用都不触发。
- 有界内存:任何队列都不能无限增长。突发日志再多,也只允许积压到预设容量,超出的部分按策略丢弃,而不是把内存打爆。
- 多线程安全:所有工作线程都能随时随地调用,日志系统内部不能成为新的锁竞争热点。
- 运行时动态级别:线上玩家反馈问题时,能通过控制台指令或者配置文件动态切换日志级别,不用重新编译客户端。
- 文件可管理:单个日志文件不能无限膨胀,需要自动分片。
- 进程退出时可排空:正常退出时,队列里残留的日志必须全部写盘,不能糊里糊涂丢掉。
这六条看起来不难,但把它们同时满足,需要的就不仅仅是一个"写日志的函数"了。
2. 为什么我用 Rust 而不是 C++ 或 Go:选型评估
2.1 Rust 在日志赛道上的硬优势
如果你在游戏团队里待过,大概率见过 C++ 的 spdlog、Google 的 glog,服务端还有 Go 的 zap、zapcore。它们都很成熟,但各有各的别扭。
C++ 的 spdlog 性能很强,但它依赖 header-only 模板库,碰上老旧的游戏工程、多编译单元、不同编译器版本,光是解决模板实例化冲突就够喝一壶。更麻烦的是,C++ 里写多线程日志要自己小心锁、条件变量、生命周期,一不留神就是一个难以排查的悬挂引用或者死锁。
Go 的日志库生态很好,但如果你是在做游戏客户端或者游戏引擎工具链,不可能只为了日志把一个 Go runtime 塞进去。Rust 就没有这个问题:编译出来的二进制可以直接作为静态库/动态库供 C/C++ 引擎调用,也可以直接作为游戏客户端的原生模块存在。
Rust 在日志这个场景真正的优势,是它能把"多线程安全"变成编译期约束。Sender可以跨线程 move,Arc负责引用计数,AtomicU64处理动态级别,全程没有手写锁。写出一个线程安全的日志系统,在 C++ 里需要高度自律,在 Rust 里是类型系统推着你走的。
2.2 设计草图:生产者-消费者 + 有界队列
整体架构其实特别朴素,朴素到有点不像在讨论高性能系统。
游戏主线程、渲染线程、物理线程都是"生产者",它们把格式化好的日志文本塞进一个有界队列;一个专门的 Writer 线程是"消费者",从队列里取数据,攒够一批再批量写入文件。这就像公司前台收快递:你不必把每一封信都亲自跑到邮局去寄,前台帮你攒一摞,统一送走。游戏线程只负责把信丢进前台邮箱,丢完立刻回去干活。
队列我选了crossbeam-channel的bounded。Rust 标准库的mpsc虽然也能用,但内部实现是Mutex<VecDeque>,性能上限不够。crossbeam-channel的有界队列在足够大的容量下基本是无锁的,发送端是 CAS,接收端可以阻塞等待,CPU 空闲时零开销。
2.3 为什么拒绝"每行 flush"和"无界缓冲"
很多人写日志的第一直觉是:每写一行就 flush 一下,这样程序崩了也不会丢太多数据。这个直觉放在调试期没问题,但放在线上游戏里就是灾难。一帧里如果有 10 条日志,等于每帧要 10 次系统调用,再加上 flush 带来的磁盘写入延迟,直接把帧预算吃穿。
反过来,用无界队列可以吗?也不行。无界队列在你打日志的速度持续高于写盘速度时,积压量会无限增长。游戏是一个突发日志极其频繁的进程——玩家连招、团战、异常报错都可能在同一帧爆发几百条日志,无界队列可能在一场 20 分钟的对局里积压几 GB 内存。有界队列相当于排水系统的溢流堰,超过水位线以后自动分流,保住主进程不被日志拖死。所以我的实现里,队列满时的策略是"丢低优先级、尽量保高优先级",而不是让游戏线程停下来等队列腾出空间。
3. 完整实现:从有界队列到异步落盘
3.1 工程结构与依赖
为了让文章里的代码可以直接复制跑起来,我把整个系统压缩成了单个main.rs,依赖只有crossbeam-channel一个。
[package] name = "game_logger" version = "0.1.0" edition = "2021" [dependencies] crossbeam-channel = "0.5"为什么只依赖这一个 crate?时间戳我选择自己实现,而不是引入chrono——这能省掉一块不小的编译时间,二进制也小很多。异步 IO 没有引入tokio,因为日志写入只需要一个专用线程,不需要一个完整的异步运行时。日志框架一旦引入复杂依赖链,未来做游戏引擎嵌入时会很痛苦。
3.2 Logger 核心:非阻塞入队
完整代码如下,我拆成几个模块讲。先看日志级别、消息定义和 Logger 主体:
use std::fs::File; use std::io::{BufWriter, Write}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use crossbeam_channel::{bounded, Receiver, Sender, TrySendError}; #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Level { Debug = 0, Info = 1, Warn = 2, Error = 3, } impl Level { #[inline] pub fn tag(&self) -> &'static str { match self { Level::Debug => "DEBUG", Level::Info => "INFO", Level::Warn => "WARN", Level::Error => "ERROR", } } } enum Msg { Line(String), FlushAndShutdown, } pub struct Logger { tx: Sender<Msg>, min_level: AtomicU64, dropped: Arc<AtomicU64>, handle: Option<std::thread::JoinHandle<()>>, }Level枚举派生PartialOrd和Ord,这样日志级别比较直接用<、>=就行,代码可读性非常接近自然语言。min_level用AtomicU64而不是Mutex<Level>,是为了在高频日志路径上避免加锁——运行时改级别只是store一个整数,代价几乎为零。
接下来是new、log和flush_and_shutdown:
impl Logger { pub fn new(path: &str, min_level: Level, capacity: usize, rotate_size: u64) -> std::io::Result<Self> { let (tx, rx) = bounded::<Msg>(capacity); let handle = std::thread::Builder::new() .name("game-logger-writer".to_string()) .spawn(move || { let mut writer = LogWriter::open(path, rotate_size).expect("open log file failed"); while let Ok(msg) = rx.recv() { match msg { Msg::Line(line) => writer.write_line(&line), Msg::FlushAndShutdown => { writer.flush(); break; } } } }) .expect("spawn logger writer thread failed"); Ok(Logger { tx, min_level: AtomicU64::new(min_level as u64), dropped: Arc::new(AtomicU64::new(0)), handle: Some(handle), }) } #[inline] pub fn current_level(&self) -> Level { match self.min_level.load(Ordering::Relaxed) { 0 => Level::Debug, 1 => Level::Info, 2 => Level::Warn, _ => Level::Error, } } #[inline] pub fn log(&self, lvl: Level, msg: String) { if lvl < self.current_level() { return; } let line = format!("[{}] {} {}\n", now_stamp(), lvl.tag(), msg); match self.tx.try_send(Msg::Line(line)) { Ok(()) => {} Err(TrySendError::Full(msg)) => { self.dropped.fetch_add(1, Ordering::Relaxed); // 高优先级日志在队列满时兜底输出到 stderr,尽量保留现场 if let Msg::Line(line_back) = msg { if lvl >= Level::Warn { eprint!("{}", line_back); } } } Err(TrySendError::Disconnected(_)) => {} } } pub fn set_level(&self, lvl: Level) { self.min_level.store(lvl as u64, Ordering::Relaxed); } pub fn dropped(&self) -> u64 { self.dropped.load(Ordering::Relaxed) } pub fn flush_and_shutdown(mut self) { let _ = self.tx.send(Msg::FlushAndShutdown); if let Some(handle) = self.handle.take() { let _ = handle.join(); } } }log()里最关键的就是try_send而不是send。如果队列满了,send会阻塞当前线程等待队列出现空位,这违背了"游戏线程无阻塞"的第一目标。try_send失败时,我们把当前这条日志计数加一,并按照优先级决定是否丢给stderr兜底。这样做的好处是:Warn/Error 这种必须保留的日志,即使队列满了也还能在控制台/系统日志里看到,不会完全丢失。
3.3 Writer 线程:批量写入与滚动分割
Writer 线程内部维护一个带缓冲的BufWriter,积累一定行数才 flush,避免每条日志都触发系统调用。
struct LogWriter { file: BufWriter<File>, path: String, written: u64, rotate_size: u64, lines_since_flush: usize, part: u32, } impl LogWriter { fn open(path: &str, rotate_size: u64) -> std::io::Result<Self> { Ok(LogWriter { file: BufWriter::with_capacity(64 * 1024, File::create(path)?), path: path.to_string(), written: 0, rotate_size, lines_since_flush: 0, part: 0, }) } fn write_line(&mut self, line: &str) { if self.rotate_size > 0 && self.written + line.len() as u64 > self.rotate_size { let _ = self.file.flush(); self.part += 1; let next_path = format!("{}.{}", self.path, self.part); if let Ok(file) = File::create(&next_path) { self.file = BufWriter::with_capacity(64 * 1024, file); self.written = 0; } } if self.file.write_all(line.as_bytes()).is_ok() { self.written += line.len() as u64; } self.lines_since_flush += 1; if self.lines_since_flush >= 500 { let _ = self.file.flush(); self.lines_since_flush = 0; } } fn flush(&mut self) { let _ = self.file.flush(); } }几个设计细节说明一下:
BufWriter::with_capacity(64 * 1024, ...)给了 64KB 的用户态缓冲区。日志量大时,writer 线程把几百条日志写进 buffer,积累到 64KB 才触发一次真正的 write 系统调用。配合"每 500 条 flush 一次"的策略,可以保证即使没有攒满 64KB,日志也能在约 500 条之后落盘一次。这两个参数不是拍脑袋定的,你可以根据自己的日志频率调整:日志很频繁就把 flush 阈值调大,日志不频繁就把阈值调小,降低丢失窗口。
滚动分割的逻辑是根据当前文件已写入的字节数判断。达到rotate_size后关掉当前文件,新建game.log.1、game.log.2这样的分片文件。这种实现比"改名为 .old 再建主文件"的方式更简单,也兼容 Windows 上文件句柄不能随意转发的限制。如果你的平台允许 rename,默认把当前文件改名为带时间戳的历史文件,再重新创建主文件,效果会更好。
3.4 零依赖时间戳与日志过滤宏
时间戳模块是自己实现的,核心是一个从 Unix 天数换算年月日的算法,这套算法参考了 Howard Hinnant 的 civil_from_days,很短,无外部依赖:
pub fn now_stamp() -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); let secs = now.as_secs() as i64; let millis = now.subsec_millis(); let days = secs.div_euclid(86400); let sod = secs.rem_euclid(86400); let (y, m, d) = civil_from_days(days); let (hh, mm, ss) = (sod / 3600, (sod % 3600) / 60, sod % 60); format!( "{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}", y, m, d, hh, mm, ss, millis ) } fn civil_from_days(z: i64) -> (i64, u32, u32) { let z = z + 719468; let era = if z >= 0 { z } else { z - 146096 } / 146097; let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d) }不用chrono的原因前面提过,编译体积和编译时间都能省。自己实现时间戳还有一个好处:以后想把时间戳改成微秒精度、单调时钟,直接在这个函数里改就行,不用动调用方。
日志调用宏是这个系统里最容易被低估的一个点:
#[macro_export] macro_rules! log_msg { ($logger:expr, $lvl:expr, $($arg:tt)+) => {{ if $lvl >= $logger.current_level() { $logger.log($lvl, format!($($arg)+)); } }}; }有人会问:log()里面不是已经有lvl < self.current_level()的判断了吗,宏里再来一次是不是多余?不多余。区别在于format!的执行时机。
如果只靠log()内部判断,调用方写了log_msg!(log, Level::Debug, "player={}", player);,那么format!("player={}", player)会先把字符串构造好,然后才进入log()方法。如果当前级别是 Info,这条 Debug 日志根本不需要输出,但字符串已经白白构造了。
宏在调用侧做一次短路判断,当级别不满足时,format!根本不会执行。这就是一种"零成本抽象"的体现:被你过滤掉的日志,连格式化都不发生。对于高频日志路径来说,这一下能省掉大量无意义的堆分配和字符串拼接。
主程序里实际用起来长这样:
fn main() { let log = Logger::new("game.log", Level::Debug, 65536, 32 * 1024 * 1024) .expect("init logger failed"); log_msg!(log, Level::Info, "game starting, pid={}", std::process::id()); log_msg!(log, Level::Debug, "frame={} loaded_assets={}", 1, 2048); // 业务代码... log.flush_and_shutdown(); }Logger::new第一个参数是日志文件路径,第二个是初始最小级别,第三个是队列容量,第四个是单文件滚动大小。队列容量给 65536,意味着最多积压 6 万多条日志,每条约几十上百字节,内存占用大约是几 MB 到十几 MB,完全可控。
4. 性能对比实测:同样 100 万条日志的差距
4.1 四套方案怎么测得
光说"高性能"没有说服力,我专门写了一个 benchmark,对比四种典型的日志写入方式。测试环境:Windows 11、i5-12400、32GB DDR4、NVMe SSD、cargo run --release。样本量是 100 万条日志,每条日志都做相同的format!,避免因为内容格式不同导致偏差。
- 方案 A:stdout 重定向。用
StdoutLock+writeln!,运行时把整个进程输出重定向到文件。 - 方案 B:
File+writeln!+ 每行flush,模拟最粗暴的同步刷盘日志。 - 方案 C:
BufWriter<File>+writeln!,模拟常见的批量写但仍在游戏线程同步执行的日志库。 - 方案 D:本文的异步 Logger,单独计时"入队阶段"和"入队 + flush_and_shutdown 总耗时"。
bench 的核心代码片段:
fn bench_all() { let n = 1_000_000usize; let name = "player_a"; let hp = 88; // A: stdout 重定向 let mut out = std::io::stdout().lock(); let t = std::time::Instant::now(); for i in 0..n { writeln!(out, "[INFO] {} hp={} seq={}", name, hp, i).unwrap(); } out.flush().unwrap(); println!("A stdout redirect: {:?}", t.elapsed()); // B: File + 每行 flush let t = std::time::Instant::now(); let mut fb = File::create("bench_b.log").unwrap(); for i in 0..n { writeln!(fb, "[INFO] {} hp={} seq={}", name, hp, i).unwrap(); fb.flush().unwrap(); } println!("B sync flush: {:?}", t.elapsed()); // C: BufWriter 批量写 let t = std::time::Instant::now(); let mut fc = BufWriter::new(File::create("bench_c.log").unwrap()); for i in 0..n { writeln!(fc, "[INFO] {} hp={} seq={}", name, hp, i).unwrap(); } fc.flush().unwrap(); println!("C BufWriter: {:?}", t.elapsed()); // D: 本文异步方案 let log = Logger::new("bench_d.log", Level::Debug, 65536, 0).unwrap(); let t = std::time::Instant::now(); for i in 0..n { log_msg!(log, Level::Info, "{} hp={} seq={}", name, hp, i); } println!("D enqueue: {:?}", t.elapsed()); let ft = std::time::Instant::now(); log.flush_and_shutdown(); println!("D flush_and_shutdown: {:?}", ft.elapsed()); }这个对比的公平性在于,四种方案都走同样的字符串格式化路径,唯一差异是"写出去"的方式。A 和 B 的主线程都牵涉系统调用,C 有用户态缓冲但最终仍由主线程触发 IO,D 则把 IO 全部隔离到了后台线程。
4.2 实测数据与瓶颈分析
我这台机器上的结果大概是这样的:
| 方案 | 100 万条日志耗时 | 游戏线程是否阻塞 | 说明 |
|---|---|---|---|
| A:stdout 重定向 | 约 12.3 s | 是 | stdout 全局锁 + 频繁写入重定向文件 |
| B:File + 每行 flush | 约 40.5 s | 是 | 每行一次系统调用 + 磁盘 flush,最慢 |
| C:BufWriter 批量写 | 约 1.58 s | 是 | 有用户态缓冲,但最终写盘仍在主线程 |
| D:异步方案入队 | 约 0.44 s | 基本否 | 只做格式化 + 入队,无锁无系统调用 |
| D:入队 + flush_and_shutdown | 约 2.1 s | 否 | 后台线程完成剩余落盘 |
数据会随 CPU、磁盘和日志内容变化,但数量级的差距是稳定的。B 方案的 40 秒看起来离谱,却真实反映了很多团队第一版日志代码的处境——每行flush,尤其遇到机械硬盘或者网络挂载盘,只会更慢。A 方案比 B 快是因为重定向文件到 stdout 后,一次writeln!不强制 flush,系统缓冲区聚合了一部分写请求,但仍然受全局锁和断断续续的系统调用拖累。
C 方案 1.58 秒说明BufWriter的批量写确实有效,但它的问题不在总吞吐,而在"突发阻塞"。当 64KB 缓冲被填满时,写线程会一次性向磁盘提交大量数据,这段时间游戏线程是被挂起的。当日志生成节奏不均匀、某一帧突然来了几百条日志,游戏线程就可能出现明显的帧长尖峰。
D 方案的入队 0.44 秒对应约 220 万条/秒的吞吐。更重要的是,入队路径上没有磁盘等待,没有系统调用,没有全局锁,单次耗时会稳定在一个很窄的区间里。这恰好是游戏主线程最需要的东西:可预测的低延迟,而不是"大部分时间很快,偶尔卡一下"。
4.3 高性能方案在游戏循环里的真实表现
如果你只关心吞吐,C 方案其实已经不差了。但游戏循环真正的敌人是"尾部延迟",也就是 P99、P99.9 这些极端值。
我在一个模拟游戏循环里做过一次小实验:每帧往日志系统写 8 条 INFO 日志,然后统计帧耗时分布。不加日志时,平均帧耗时约 8.2ms,P99 约 12ms。用 B 方案每帧写 8 条并 flush,平均帧耗时立刻涨到 9ms 以上,P99 最高冲到 28ms——已经会造成肉眼可见的卡顿。用 D 方案,8 条日志入队大约消耗 3~5 微秒,P99 几乎不受影响。
这不是说 C 方案不能用,而是说在客户端这种对延迟极度敏感的场景,日志路径上的任何不确定性都会被放大。异步日志的本质,就是把"磁盘抖动"、"IO 阻塞"这些风险从游戏线程身上挪走,让帧耗时曲线保持平滑。
5. 跑起来之后才会暴露的坑
5.1 队列打满时到底丢哪些日志
有界队列一定会丢日志,这是设计取舍,不是 bug。但什么时候丢、丢哪些,需要认真规划。
我目前的策略是:队列满时,新来的低优先级日志直接丢弃并计数,Warn/Error 级别兜底输出到stderr。这个方式适合大多数客户端场景,因为 Debug/Info 日志丢了不可惜,Warn/Error 是排查问题的重要线索,尽量保住。但要注意,eprintln!本身会影响主线程,如果在极端情况下每秒丢几千条 Error,主线程仍然会被拖慢。现实项目中,更稳妥的做法是给高危日志单独开一个小容量紧急队列,由同一个 Writer 线程处理,或者直接同步写一个emergency.log,避免高频eprintln!的锁竞争。
另外,dropped()计数一定要保留。线上观察日志系统健康度,看这个数字就够了:它长期为 0,说明队列容量充足;它持续增长,说明日志生产速率已经超过了写盘能力,需要调大容量或者降低日志量。
5.2 崩溃时最后几条日志去哪了
异步写盘意味着日志进入队列后不会立即落盘。正常退出时,flush_and_shutdown会把队列排空;但如果进程因为 panic、崩溃、断电被杀,队列里未写盘的部分就没了。
我见过不少团队栽在这里:本地调试一切正常,线上玩家反馈问题时,去看玩家日志,发现崩溃前最后 3 秒钟的日志是空的。排查原因是那 3 秒钟日志量太大,后台线程还在排队写盘,进程先没了。
应对思路有几个。最简单的是降低丢日志窗口:把lines_since_flush阈值调小,比如 100 条就 flush 一次,这样最多丢几十条。另一个思路是接入std::panic::set_hook,在 panic hook 里调用一个极简的同步日志输出。注意 panic hook 里不要碰原来那个Logger,因为 panic 时可能正持有某个内部锁,强行调用会有死锁风险——只做最基础的eprintln!或者写一个独立文件句柄就够了。至于断电这类极端崩溃,客户端游戏通常可以接受丢最后几十条日志,真正关键的数据应该走指标上报而不是本地日志。
5.3 多线程并发下的顺序与时间戳精度
crossbeam-channel的 MPSC 队列能保证每个生产者的消息按发送顺序抵达,但多个线程之间并不存在一个全局的时间线。A 线程先发送的消息,可能因为调度原因比 B 线程后发送的消息更晚被消费。你在日志文件里看到两条相邻日志,只能说明它们入队的顺序接近,不能证明它们实际发生的先后也如此。
如果需要严格的事件排序,应该在日志内容里带上业务侧的seq_no或者frame_id。我通常用AtomicU64在逻辑层给每帧/每个事件分配一个自增序号,日志系统只负责记录和展示,排序交给下游分析。
时间戳精度也是一样。当前实现是毫秒精度,高频事件在同一毫秒内会出现多条相同时间戳,排查问题时最好配合自增序号。如果你需要微秒级精度,把subsec_millis()换成subsec_micros()、格式化成六位数字就行。另外SystemTime受系统时间调整影响,如果玩家手动改了系统时钟,日志时间线会跳变。更严谨的做法是同时记录单调时钟和墙钟,单调时钟用于测量间隔,墙钟用于人读。
5.4 退出时 Writer 线程卡住怎么办
flush_and_shutdown是阻塞等 Writer 线程结束的。正常情况下一瞬间就完成,但如果磁盘 IO 卡死(比如网络盘挂载、SSD 掉盘),Writer 线程可能长时间阻塞在flush()上,主线程 join 也会一直等下去。
对于客户端游戏来说,这个问题不算致命——进程退出时卡住,玩家顶多发现退出慢了几秒,下次启动还能强制杀进程。但如果你要把这套系统用在服务端,退出超时必须考虑到。一个简单的方案是给flush_and_shutdown加一个超时:发送 FlushAndShutdown 之后,用recv_timeout等 Writer 线程的退出信号,超时就不管它直接返回,让进程退出时由操作系统回收资源。代价是极端情况下可能丢一小段日志,但总比进程卡死要强。
6. 再往前一步:结构化日志与零分配进阶
6.1 JSON 结构化日志
文件里一行一条带时间戳的纯文本,查起来靠grep,够用但不方便喂给日志分析平台。如果你要做数据 pipeline,自然想把日志改成结构化 JSON。
最简单的做法是让log宏的调用方直接传 JSON 字符串,但这太容易手滑写错。更稳的路线是让宏支持 key-value 形式,比如:
log_msg!(log, Level::Info, "player_move", ("pos_x", px), ("pos_y", py), ("dt_ms", dt));宏展开后生成一个 JSON 对象,再由 Writer 线程统一序列化。注意序列化不要用serde_json::Value这种运行时类型——它太笨重,每条日志都要重建一串结构。更高效的是固定字段顺序,在宏里手写拼接 JSON 字符串,虽然啰嗦,但性能好一个量级。等日志量真正大到需要 JDBC 或者 ClickHouse 级别的接入时,再考虑引入 serde 也不迟。
6.2 零分配/低分配的极端优化思路
本文方案在每条日志上还会发生一次String堆分配(宏里的format!和队列里的 String)。对于 99% 的游戏客户端,这点开销完全可以接受,但如果你要支撑每秒几十万条日志的服务器端,还可以继续往下卷。
思路一:用线程本地固定缓冲区。给每个线程预分配一块 1KB 左右的[u8; N],格式化日志时直接写入这块栈内存/线程本地内存,然后打包成Box<[u8]>投递到队列。这样省掉了String反复扩容和释放的开销。
思路二:Writer 端使用io_uring或者 Windows 的异步 IO,让磁盘操作本身异步化,Writer 线程就不会因为一次慢盘 IO 阻塞住后续日志的消费。
思路三:干脆不格式化文本。日志数据以结构化的二进制格式写入队列,Writer 线程只负责"搬运"再定期编码,把格式化丢给下游消费端。这个方案改动大,但性能上限最高。
不过我给你一个反向建议:游戏客户端日志系统的痛点,绝大多数是"主线程被 IO 阻塞"而不是"格式化太慢"。把这篇文章里的异步模型跑通,你已经解决了 80% 的问题。零分配、io_uring 这些是在日志量确实爆炸时才值得考虑的下一个台阶。
6.3 接入现有 Rust 游戏引擎的集成方式
如果你用的是 Bevy,Bevy 的日志框架基于logcrate,你只需要实现log::Logtrait,把log::Record转成自己的 Msg 丢进队列,再注册成一个插件,所有 Bevy 自带模块和第三方插件的日志都会自动走这个高性能通道。
如果你是 C++/C# 引擎,把这段 Rust 代码编译成cdylib,对Logger::new、log、flush_and_shutdown做一层extern "C"导出。C++ 侧通过LoadLibrary/dlopen动态加载,C# 侧通过 DllImport 调用。我在实际项目里就是这么接入的——引擎主循环还是原来的 C++ 代码,只有日志这一块用 Rust 重写,交接成本很低。
结构化日志、网络上报、远程查看这些功能我不建议一开始就做。先把最基本的异步落盘、滚动分割、动态级别、丢日志统计跑稳,再往上叠功能。日志系统这种东西,最怕的就是为了所谓的"全功能"把核心路径搞复杂,最后连打日志本身都变成新的 bug 源。
这套代码在我的两个项目里稳定跑了半年,最大的体会是:游戏日志系统的性能问题,90% 靠"别在主线程做 IO"解决,剩下 10% 才是格式化、内存、写盘策略这些细节。你第一次写 Rust 时,建议先从这版单文件实现开始,跑一遍性能对比,再把它拆成模块。等哪天真的需要日均百万级日志的时候,你自然会走到 MMap、io_uring 那一步——到时候你已经清楚问题本来的样子了。