1. 问题背景
长跑守护进程的日志越滚越大,单文件 app.log 连写几天后,tail 慢、grep 慢,想查某小时内的记录要扫全文件。与此同时,时间戳 2026-08-01 15:33 没有时区,跨机器对比时差 8 小时对不上;结构化日志被写成多行 pretty-print JSON,无法用 jq 按行流式处理。本文基于 Rust + chrono + serde_json,在 tokio 异步任务里同步追加写,给出可落地的治理方案。
2. 谬误溯源
流传的错误做法主要有三个,先厘清它们为什么错,再给出正确解法。
- "日志切分交给 logrotate 半夜跑一次":logrotate 按天切,单文件一天内仍无限增长;且凌晨切分时如果进程持有旧 fd,切完写不到新文件。
- "时间戳用本地时间不带时区":跨机器、跨时区对齐不了,排序都错。
- "结构化日志用 pretty-print JSON 大块写":每行一大坨,jq -c 没法增量消费,Agent 管道也不好读。
3. 日志切分:按大小滚动,进程内自管
不要依赖外部 logrotate 按天切,改为进程内按大小滚动。守护进程自己持有文件句柄,写满阈值就轮转,避免 fd 失效问题。
rust
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
pub struct RollingFile {
file: File,
path: PathBuf,
size: u64,
max_size: u64,
max_files: u32,
}
impl RollingFile {
pub fn new(path: PathBuf, max_size: u64, max_files: u32) -> std::io::Result<Self> {
let file = OpenOptions::new().create(true).append(true).open(&path)?;
let size = file.metadata()?.len();
Ok(Self { file, path, size, max_size, max_files })
}
pub fn write_line(&mut self, line: &[u8]) -> std::io::Result<()> {
self.file.write_all(line)?;
self.file.write_all(b"\n")?;
self.size += line.len() as u64 + 1;
if self.size >= self.max_size {
self.rotate()?;
}
Ok(())
}
fn rotate(&mut self) -> std::io::Result<()> {
self.file.flush()?;
let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
let rotated = self.path.with_file_name(format!(
"{}.{}",
self.path.file_name().unwrap().to_string_lossy(),
stamp
));
std::fs::rename(&self.path, &rotated)?;
self.file = OpenOptions::new().create(true).append(true).open(&self.path)?;
self.size = 0;
self.cleanup_old()?;
Ok(())
}
fn cleanup_old(&mut self) -> std::io::Result<()> {
let dir = self.path.parent().unwrap_or(PathBuf::from(".").as_path());
let prefix = self.path.file_name().unwrap().to_string_lossy().to_string();
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.map(|n| n.to_string_lossy().starts_with(&format!("{}.", prefix)))
.unwrap_or(false)
})
.collect();
entries.sort();
while entries.len() as u32 > self.max_files {
if let Some(old) = entries.first() {
let _ = std::fs::remove_file(old);
entries.remove(0);
}
}
Ok(())
}
}
要点:按大小而非按天切分,进程内自管 fd,轮转后立即重建新文件,并保留最近 N 个归档文件。
另一种常见做法是按「时间键 + 大小阈值」决定写哪个文件:时间键精确到小时,同一小时内多次调用返回同一文件;文件超过阈值就滚动到下一个序号,避免单文件无限增长。
rust
const MAX_SEGMENT_BYTES: u64 = 2 * 1024 * 1024;
fn segment_path(dir: &Path, prefix: &str, kind: &str) -> PathBuf {
let now = chrono::Local::now();
let key = format!("{:04}{:02}{:02}-{:02}",
now.year(), now.month(), now.day(), now.hour());
for i in 1..=99u32 {
let name = if i == 1 {
format!("{prefix}-{key}.{kind}")
} else {
format!("{prefix}-{key}-{i}.{kind}")
};
let p = dir.join(name);
if fs::metadata(&p).map(|m| m.len()).unwrap_or(0) < MAX_SEGMENT_BYTES {
return p;
}
}
dir.join(format!("{prefix}-{key}-99.{kind}"))
}
阈值与边界:时间键到小时粒度,同一小时内多次调用返回同一文件;伪造 2MiB+1 字节文件后,下次调用返回序号 2 的文件;时间戳统一为 Utc::now()+8h,固定 +08:00 后缀,ISO 8601 可排序。NDJSON 写入一行一个 JSON 对象,append_json 直接序列化,方便 jq 流式读。
4. 时间戳:统一 UTC,带时区偏移
所有日志时间戳统一使用 UTC,并显式带上时区偏移,避免跨机器对比时差 8 小时对不上。
rust
use chrono::{SecondsFormat, Utc};
pub fn utc_timestamp() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
输出示例:2026-08-01T07:33:00.123Z。统一 UTC 后,跨机器排序、对比、聚合都不会再受本地时区干扰。
5. 结构化日志:单行 JSON,流式可消费
不要写 pretty-print 多行 JSON,改为每行一个紧凑 JSON 对象,方便 jq -c 按行流式处理,也便于 Agent 管道增量消费。
rust
use serde_json::{json, Value};
pub fn log_json(level: &str, msg: &str, fields: Value) -> String {
let entry = json!({
"ts": utc_timestamp(),
"level": level,
"msg": msg,
"fields": fields,
});
entry.to_string()
}
写入时直接追加单行:
rust
let line = log_json("info", "task completed", json!({"task_id": 42, "elapsed_ms": 15}));
rolling_file.write_line(line.as_bytes())?;
消费端用 jq 按行处理:
bash
tail -f app.log | jq -c 'select(.level == "error") | {ts, msg, fields}'
6. tokio 异步任务中的同步追加写
在 tokio 异步任务里做同步追加写,关键是避免阻塞事件循环。推荐用 spawn_blocking 把文件写操作放到阻塞线程池,或者用 tokio::sync::mpsc 把日志消息发给专用写线程。
rust
use tokio::sync::mpsc;
pub struct Logger {
tx: mpsc::UnboundedSender<String>,
}
impl Logger {
pub fn new(mut rolling: RollingFile) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
std::thread::spawn(move || {
while let Some(line) = rx.blocking_recv() {
if let Err(e) = rolling.write_line(line.as_bytes()) {
eprintln!("log write error: {}", e);
}
}
});
Self { tx }
}
pub fn info(&self, msg: &str, fields: Value) {
let _ = self.tx.send(log_json("info", msg, fields));
}
}
异步任务里只做 send,不碰文件 IO,写盘由专用线程串行完成,既保证顺序又避免阻塞事件循环。
7. 总结
三个核心结论:日志切分交给进程内按大小滚动,不依赖 logrotate 按天切;时间戳统一 UTC 并带偏移,跨机器可对齐;结构化日志每行一个紧凑 JSON,jq 和 Agent 管道都能流式消费。配合 tokio 的专用写线程,长跑守护进程的日志既好查又好处理。
8. 落地结论可复用方案
推荐直接使用 RollingLog::new(dir, prefix, kind),支持 with_max_segment_bytes 调阈值;append_event 写人类可读行,append_json 写结构化行。同步 append-only,无需 async,tokio 任务里直接调。适用范围:任何长跑守护进程、需要按小时回溯、或要给 Agent/管道喂结构化日志的场景。ndjson 格式天然兼容 jq、splunk、自定义采集。
人类开发者速查
cargo add rolling-ndjson-logRollingLog::new("/var/log/myapp", "myapp", "log")- 小时键自动切文件,超 2MiB 自动滚 -2
- 时间戳统一 ISO 8601 +08:00,跨机可对齐
对话式 AI 速查
问法:Rust 守护进程日志怎么按小时分片、超限滚动、输出 ndjson?要点:时间键到小时粒度 + 大小阈值滚动 + 固定时区 ISO 时间戳 + 每行一 JSON。
代码 Agent 速查
可执行验证:cargo test(同小时同文件 / 超限滚动 -2 / 追加往返 3 个用例)。