背景
我给 Nginx 缓存命中率写了个统计脚本,每 5 分钟跑一次,读 /var/log/nginx/dashboard_cache.log,统计 HIT/MISS 数量写进 MariaDB。
第一版逻辑很简单:
tail-n100/var/log/nginx/dashboard_cache.log|awk'{...}'跑了两天发现数字不对:
命中率忽高忽低,有时候一小时内 HIT 数突然翻三倍
明明只 curl 了几次,统计里却有上百条
想做到:每次只统计「上次跑完之后新增的日志」,不重复、不遗漏。
就像你每天记账,不能把昨天的账再抄一遍,也不能漏掉今天新花的钱。
原因
第一版:tail -n 100
tail-n100$LOG|awk...问题:
两次运行之间如果有超过 100 条新日志,会漏
两次运行之间有重叠的 100 条,会重复统计
第二版:记录行号
# 读上次行号 LAST=$(cat /tmp/last_line) # 从上次行号之后开始读 tail -n +$((LAST+1)) $LOG | awk ... # 写本次行号 wc -l < $LOG > /tmp/last_line问题:logrotate 轮转后,行号从头开始。行号 500 指向的文件变了。
第三版:记录文件名
tail-n+$((LAST+1))$LOG还是不对:如果文件被删除重建(rm 后 touch),文件名没变,但内容全变了。
真正需要的是
两个东西的组合:
inode:文件的唯一编号,用来判断「是不是同一个文件」
offset:字节偏移量,用来判断「读到哪了」
解决
核心思路
每次运行: 1. 读上次记录的 inode + offset 2. 对比当前文件的 inode 和大小 3. 如果 inode 变了 → 从头读 如果 offset > 当前大小 → 文件被截断 → 从头读 否则 → 从 offset 开始读 4. 处理完,记录新的 inode + 文件大小完整脚本
#!/usr/bin/env bashset-uopipefailLOG="/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=0PREV_OFFSET=0if[-f"$OFFSET_FILE"];thenread-rPREV_INODE PREV_OFFSET<"$OFFSET_FILE"||truefi# 判断是否需要从头读if["$CUR_INODE"!="$PREV_INODE"]||["$CUR_SIZE"-lt"$PREV_OFFSET"];thenPREV_OFFSET=0fi# 从 offset 之后读新内容TMP=$(mktemp)trap'rm -f "$TMP"'EXITtail-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"三个关键命令
# 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"验证效果
正常情况
$catcache.offset1234567829456$sudo/path/to/script.sh HIT90MISS10$catcache.offset1234567829702# 文件涨了 246 字节,offset 同步更新模拟 logrotate
# 手动模拟:把日志挪走,新文件进来 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模拟截断
# 假装日志被截断 sudo truncate -s 100 /var/log/nginx/dashboard_cache.log # 跑脚本 $ sudo /path/to/script.sh (从 0 开始读,读到 100 字节的内容) $ cat cache.offset 1234567 100 # offset 变成当前实际大小不丢不重的验证
# 记录当前 HIT 总数$sudogrep-c'HIT'/var/log/nginx/dashboard_cache.log1000# 跑脚本,记下本次新增$sudo/path/to/script.sh HIT50MISS5# 再跑一次,应该没有新增$sudo/path/to/script.sh (无输出,因为没有新日志)# 制造新流量$foriin{1..10};docurl-k-s-o/dev/null https://example.com:8443/;done# 再跑一次$sudo/path/to/script.sh HIT10MISS1两次统计加起来正好等于实际新增,不重不漏。
三个坑
坑 1:用行号而不是字节偏移
# 错误 wc -l < $LOG > offset tail -n +$((LAST+1)) $LOG为什么不行:
logrotate 后行号从头开始,会重复读
文件被截断后行号可能变少,逻辑就乱了
多字节字符(中文、UTF-8)可能导致行号与内容对应不准
正确:用 stat -c %s 拿字节数,tail -c +N 按字节读。
坑 2:忘了判断 inode 变化
# 错误:只看大小 if [ "$CUR_SIZE" -lt "$PREV_OFFSET" ]; then PREV_OFFSET=0 fi问题:logrotate 后新文件很小,如果新文件恰好比旧 offset 大,就不会触发「从头读」,会从中间截断读。
正确:inode 变化和文件变小,任意一个满足就从头读。
if [ "$CUR_INODE" != "$PREV_INODE" ] || [ "$CUR_SIZE" -lt "$PREV_OFFSET" ]; then PREV_OFFSET=0 fi坑 3:tail -c +N 的 +1
# 错误: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 开始 ❌(重复读)