背景 我给 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 开始 ❌(重复读)