Linux 日志增量统计:inode + offset 方案(不丢不重)

背景 我给 Nginx 缓存命中率写了个统计脚本,每 5 分钟跑一次,读 /var/log/nginx/dashboard_cache.log,统计 HIT/MISS 数量写进 MariaDB。

第一版逻辑很简单:

bash 复制代码
tail -n 100 /var/log/nginx/dashboard_cache.log | awk '{...}'

跑了两天发现数字不对:

命中率忽高忽低,有时候一小时内 HIT 数突然翻三倍

明明只 curl 了几次,统计里却有上百条

想做到:每次只统计「上次跑完之后新增的日志」,不重复、不遗漏。

就像你每天记账,不能把昨天的账再抄一遍,也不能漏掉今天新花的钱。 原因 第一版:tail -n 100

bash 复制代码
tail -n 100 $LOG | awk ...

问题:

两次运行之间如果有超过 100 条新日志,会漏

两次运行之间有重叠的 100 条,会重复统计 第二版:记录行号

bash 复制代码
# 读上次行号
LAST=$(cat /tmp/last_line)
# 从上次行号之后开始读
tail -n +$((LAST+1)) $LOG | awk ...
# 写本次行号
wc -l < $LOG > /tmp/last_line

问题:logrotate 轮转后,行号从头开始。行号 500 指向的文件变了。 第三版:记录文件名

bash 复制代码
tail -n +$((LAST+1)) $LOG

还是不对:如果文件被删除重建(rm 后 touch),文件名没变,但内容全变了。 真正需要的是 两个东西的组合:

inode:文件的唯一编号,用来判断「是不是同一个文件」

offset:字节偏移量,用来判断「读到哪了」 解决 核心思路

markdown 复制代码
每次运行:
  1. 读上次记录的 inode + offset
  2. 对比当前文件的 inode 和大小
  3. 如果 inode 变了 → 从头读
     如果 offset > 当前大小 → 文件被截断 → 从头读
     否则 → 从 offset 开始读
  4. 处理完,记录新的 inode + 文件大小

完整脚本

bash 复制代码
#!/usr/bin/env bash
set -uo pipefail

LOG="/var/log/nginx/dashboard_cache.log"
STATE_DIR="/home/user/metrics"
OFFSET_FILE="$STATE_DIR/cache.offset"

mkdir -p "$STATE_DIR"

# 当前文件状态
CUR_INODE=$(stat -c %i "$LOG")
CUR_SIZE=$(stat -c %s "$LOG")

# 上次状态(默认 0 0)
PREV_INODE=0
PREV_OFFSET=0
if [ -f "$OFFSET_FILE" ]; then
  read -r PREV_INODE PREV_OFFSET < "$OFFSET_FILE" || true
fi

# 判断是否需要从头读
if [ "$CUR_INODE" != "$PREV_INODE" ] || [ "$CUR_SIZE" -lt "$PREV_OFFSET" ]; then
  PREV_OFFSET=0
fi

# 从 offset 之后读新内容
TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT
tail -c +$((PREV_OFFSET+1)) "$LOG" > "$TMP"

# 处理
awk -F'\t' '$10!="" && $10!="-" { c[$10]++ } END { for (s in c) print s, c[s] }' "$TMP"

# 记录新的 offset(下次接着读)
NEW_SIZE=$(stat -c %s "$LOG")
echo "$CUR_INODE $NEW_SIZE" > "$OFFSET_FILE"

三个关键命令

bash 复制代码
# 1. 拿 inode 和文件大小
CUR_INODE=$(stat -c %i "$LOG")     # 例如 1234567
CUR_SIZE=$(stat -c %s "$LOG")      # 例如 829456 字节

# 2. 从 offset 之后读(注意 +1,tail -c 从 1 开始计数)
tail -c +$((PREV_OFFSET+1)) "$LOG"

# 3. 记录状态到文件
echo "$CUR_INODE $NEW_SIZE" > "$OFFSET_FILE"

验证效果 正常情况

bash 复制代码
$ cat cache.offset
1234567 829456

$ sudo /path/to/script.sh
HIT 90
MISS 10

$ cat cache.offset
1234567 829702       # 文件涨了 246 字节,offset 同步更新

模拟 logrotate

bash 复制代码
# 手动模拟:把日志挪走,新文件进来
sudo mv /var/log/nginx/dashboard_cache.log /var/log/nginx/dashboard_cache.log.1
sudo touch /var/log/nginx/dashboard_cache.log
sudo chown www-data:adm /var/log/nginx/dashboard_cache.log
sudo systemctl reload nginx

# 跑脚本
$ sudo /path/to/script.sh
(新文件没内容,输出空)

# offset 变了,因为 inode 变了
$ cat cache.offset
1234568 0            # inode 变了,offset 归 0

模拟截断

bash 复制代码
# 假装日志被截断
sudo truncate -s 100 /var/log/nginx/dashboard_cache.log

# 跑脚本
$ sudo /path/to/script.sh
(从 0 开始读,读到 100 字节的内容)

$ cat cache.offset
1234567 100          # offset 变成当前实际大小

不丢不重的验证

bash 复制代码
# 记录当前 HIT 总数
$ sudo grep -c 'HIT' /var/log/nginx/dashboard_cache.log
1000

# 跑脚本,记下本次新增
$ sudo /path/to/script.sh
HIT 50 MISS 5

# 再跑一次,应该没有新增
$ sudo /path/to/script.sh
(无输出,因为没有新日志)

# 制造新流量
$ for i in {1..10}; do curl -k -s -o /dev/null https://example.com:8443/; done

# 再跑一次
$ sudo /path/to/script.sh
HIT 10 MISS 1

两次统计加起来正好等于实际新增,不重不漏。 三个坑 坑 1:用行号而不是字节偏移

bash 复制代码
# 错误
wc -l < $LOG > offset
tail -n +$((LAST+1)) $LOG

为什么不行:

logrotate 后行号从头开始,会重复读

文件被截断后行号可能变少,逻辑就乱了

多字节字符(中文、UTF-8)可能导致行号与内容对应不准

正确:用 stat -c %s 拿字节数,tail -c +N 按字节读。 坑 2:忘了判断 inode 变化

bash 复制代码
# 错误:只看大小
if [ "$CUR_SIZE" -lt "$PREV_OFFSET" ]; then
  PREV_OFFSET=0
fi

问题:logrotate 后新文件很小,如果新文件恰好比旧 offset 大,就不会触发「从头读」,会从中间截断读。

正确:inode 变化和文件变小,任意一个满足就从头读。

bash 复制代码
if [ "$CUR_INODE" != "$PREV_INODE" ] || [ "$CUR_SIZE" -lt "$PREV_OFFSET" ]; then
  PREV_OFFSET=0
fi

坑 3:tail -c +N 的 +1

shell 复制代码
# 错误:tail -c +$PREV_OFFSET
# 这会重复读最后一个字节

原因:tail -c +N 从第 N 个字节开始(1 起始)。而 offset 记录的是「已经读完的字节数」,等价于「下一个未读字节的前一个位置」。

所以要用 +$((PREV_OFFSET+1))。

例子:

文件 abcdef,offset = 3(已读完 abc)

下次应该从第 4 个字节 d 开始读

tail -c +4 正好从 d 开始 ✅

tail -c +3 会从 c 开始 ❌(重复读)

相关推荐
软件工程师_罗小东1 小时前
我把这套 AI 落地方案,讲成一条任务闭环
架构
阡陌数智1 小时前
Llama 4 MoE 架构深度拆解:交替稀疏专家设计与生产环境推理落地实战
架构·llama
波加曼大王3 小时前
# vLLM不要迷信PagedAttention神话,聊聊线上藏着的内部碎片陷阱
java·架构
xn71333 小时前
EmbeddingGemma 2 270M 实测:278 Chunk、32 个查询与 RRF 反例
人工智能·后端·架构
漠野9233 小时前
画布的保存按钮背后,站着一个编译器
架构
两万五千个小时3 小时前
DeepSeek Harness 从 0 开始:20 goal模式
人工智能·程序员·架构
漠野9233 小时前
让每个节点自己决定:要不要跑,还是循环跑
架构
dcacheor3 小时前
现代桌面播放器的交互困境与架构演进:从单窗流媒体走向多模态视讯工作台
架构
Mahut3 小时前
我在 Mac 上做了个本地剪辑器
javascript·架构·产品