为保护数据自主并减少外链,站点不装第三方统计,改用 nginx 日志自建方案:仅放一个带 token 的只读 PHP 日志出口,内网机器定时增量拉取、解析、入库 sqlite,再用纯标准库 Python 出图,站点零模板改动。日志显示扫描采集远多于真人,但停留时长只能估算,适合有 VPS 和常开机器者。
站点跑了一年多,我始终没装第三方统计。不是不想看数据,是不愿意在每个页面里塞一段外来的脚本:多一次 DNS 解析、多一个外链请求,读者的浏览记录还要顺便喂给别人。后来想明白一件事——nginx 每天都在写访问日志,IP、时间、路径、状态码、UA、来源全在里面。数据一直躺在服务器上,只是从来没人去读它。
同主题的另一篇:如果你是冲着「怎么自己解析 nginx 日志、怎么入库、怎么在本地机器跑起来」来的,看这篇 不埋点也能做网站访问统计看板:解析 nginx 日志全流程。本文接着往下讲看板本身的设计、爬虫流量识别和常见踩坑。
于是花了大半天,把日志原样搬回家里那台常年开机的 Debian 小机,自己解析、入库、出图。整个过程没有改动站点任何一个模板文件,也没在页面里加过一行统计代码。如果你也纠结要不要接第三方统计,这篇可以照着做一遍。
这套「自己拉数据、自己画图」的路子我不是第一次用了。之前给 DNS 做过一个纯 Python 标准库、零依赖的实时统计面板,这次思路完全一样,只是数据源从 DNS 查询换成了 nginx 日志。
一、为什么不用第三方统计
不是说第三方不好,而是对一个个人站点来说,它的代价有点具体:
- 多一个外链请求。再小的脚本也要走一遍 DNS + TLS 握手,移动端弱网下这一下的体感很明显。
- 数据只有一份且在你手上不了。哪天对方调整了口径、关闭了免费额度,历史数据就断了。
- 它只能看见”执行了脚本的浏览器”。真正跑到服务器面前的请求有多少、扫描器盯得多紧,它一个字都不会告诉你。
第三点是我最终决定自己做的直接原因。日志里能看出很多第三方看不到的东西:一天 1.4 万次请求里只有两百多次是人发的,剩下全是扫描、采集、探测。这个认知落差,用埋点统计是永远感觉不到的。
当然代价也很实在:停留时长只能估算。日志里没有”离开页面”这个动作,这点后面单独说。
先讲清楚这条路适合谁
你有自己的 VPS 或者云主机,能登上去看 nginx 日志,并且有一台常年开着的机器(家里的小主机、软路由、NAS 都行)用来跑数据。三个条件缺一个,这套方案就不如直接接第三方。
二、整体思路:日志在哪,数据就在哪
整套东西拆成五个环节,站点侧只负担第一个,剩下的全在内网机器上:

- 站点:nginx 照常写日志,我只在网站根目录多放一个 PHP 文件,它只负责按偏移量往后吐日志。
- 导出端点:带 token 校验,只认 POST,返回这一批新增的行、当前偏移量、文件大小和 inode。
- 内网机器:每 5 分钟拉一次增量,解析出设备、系统、浏览器、来源类型,查归属地,标记机器流量,写进 sqlite。
- 看板:一个纯标准库的 Python 服务,端口开在 8089,内网浏览器直接打开。
- 回填:把算好的四个数字推回站点,侧边栏那张卡片直接读,两套数字从此同源。
整套代码加起来不到 2000 行,除了 ip2region 的归属地数据,没有任何外部依赖——连 pip 都没装过包。
三、站点侧:一个只读的日志出口
这个 PHP 文件是整个方案里唯一落在公网服务器上的东西,所以它必须尽可能”笨”。
1. 它只做三件事
- 校验 token,不对就直接 403;
- 按调用方传来的字节偏移量往后读,最多读 1 MB;
- 回传新的偏移量、文件大小、inode,以及有没有读到文件尾。
2. 代码
<?php
// stats-export.php —— 只读日志出口,放在网站根目录
const TOKEN = '换成你自己生成的一长串随机字符串';
header('Content-Type: application/json; charset=utf-8');
header('X-Accel-Expires: 0'); // 别让页面缓存碰这个地址
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
if (!hash_equals(TOKEN, $_POST['token'] ?? '')) { http_response_code(403); exit; }
$file = '/www/wwwlogs/997wan.com.log';
$from = (int)($_POST['offset'] ?? 0);
$fh = fopen($file, 'rb');
$st = fstat($fh);
$size = $st['size'];
// inode 变了说明日志被切割轮转过,原来的偏移量已经没有意义,从头再来
$inode = (int)($_POST['inode'] ?? 0);
if ($inode && $inode !== $st['ino']) { $from = 0; }
if ($from > $size) { $from = 0; }
fseek($fh, $from);
$chunk = fread($fh, 1024 * 1024);
fclose($fh);
// 最后一行很可能是半行,截断留到下一次
$nl = strrpos($chunk, "n");
$raw = ($nl === false) ? '' : substr($chunk, 0, $nl + 1);
$new = $from + strlen($raw);
echo json_encode([
'ok' => true,
'inode' => $st['ino'],
'size' => $size,
'offset' => $new,
'eof' => $new >= $size,
'lines' => $raw === '' ? [] : explode("n", rtrim($raw, "n")),
]);
3. 几个容易翻车的点
- 必须回传 inode。日志按天或按大小切割之后 inode 会变,客户端记的偏移量还是旧的,读出来的是一片乱码或者干脆空。我把”客户端 inode 与当前不符就归零重来”这一条加上之后,才彻底告别补数据。
- 最后一行要截断。你读到的那一刻,nginx 可能正在写这一行。按最后一个换行符切掉,剩下半行留到下一轮。
- 请求必须走 POST。我一开始用 GET,结果发现 nginx 的整页缓存把这个地址也缓存了,拉回来的永远是第一次的内容。加上
X-Accel-Expires: 0并改用 POST 才正常。 - token 校验放在最前面。这个端点虽然只读日志,但日志里有完整的 IP 列表,不该裸奔。
关于 HTTPS
导出端点走的是域名本身的 HTTPS,不用另开端口。如果你跟我一样在内网之间有证书需求,可以看看 用 PVE 内部 CA 给内网 IP 签证书的局域网 HTTPS 绿锁教程,一次配好之后内网服务也能有绿锁。
四、内网机器:增量拉取、解析、入库
1. 表结构
一张宽表足够,剩下的全靠 SQL 现场聚合。40 万行在 sqlite 里做按天统计是毫秒级。
CREATE TABLE visits(
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER, date TEXT, hour INTEGER,
ip TEXT, country TEXT, region TEXT, city TEXT, isp TEXT,
method TEXT, path TEXT, query TEXT,
status INTEGER, bytes INTEGER,
referer TEXT, ref_host TEXT, ref_type TEXT,
ua TEXT, device TEXT, os TEXT, browser TEXT,
bot INTEGER DEFAULT 0, kind TEXT,
sid TEXT, dwell INTEGER
);
CREATE INDEX idx_visits_date ON visits(date);
CREATE INDEX idx_visits_ip ON visits(ip);
CREATE INDEX idx_visits_path ON visits(path);
CREATE INDEX idx_visits_sid ON visits(sid);
kind 把请求分成四类:page(真实页面)、static(css/js/图片)、system(robots.txt、sitemap、favicon)、scan(/cgi-bin/、.env、wp-config 这类一眼就知道来者不善的路径)。scan 直接标记为机器流量,不看 UA。
2. 每 5 分钟跑一轮
# /etc/systemd/system/stats-fetch.service
[Unit]
Description=access log incremental sync
After=network.target
[Service]
Type=oneshot
WorkingDirectory=/opt/stats
ExecStart=/bin/sh /opt/stats/sync.sh
StandardOutput=append:/var/log/stats-fetch.log
StandardError=append:/var/log/stats-fetch.log
# /etc/systemd/system/stats-fetch.timer
[Unit]
Description=Sync access log every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
Persistent=true
Unit=stats-fetch.service
[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now stats-fetch.timer
systemctl list-timers | grep stats
用 timer 而不是 crontab 的好处是能看 —— systemctl status 里上一次运行时间、返回值、日志位置全都有。相关命令记不全的时候我会翻一下 Linux 常用命令分类速查。
改完 unit 记得装到 /etc/systemd/system/
我在这一步栽过跟头:把改好的 .service 直接 scp 到 /opt/stats/,然后 daemon-reload、重启服务,跑出来的还是老命令——因为 systemd 读的根本不是那份,/opt 里那个只是我的源文件副本。正确做法是 scp 之后先 cp /opt/stats/x.service /etc/systemd/system/,再 daemon-reload。
3. 会话 ID 千万别用 Python 内置的 hash()
会话的定义是「同一个 IP + 同一个浏览器,30 分钟没有活动算一次新会话」。为了把多条记录归到一个会话,我最初图省事直接 hash(ip + ua)。结果发现每次重启服务,会话数和停留时长都在跳。
原因是 Python 的 hash() 对字符串默认带随机种子(PYTHONHASHSEED),同一个字符串在不同进程里算出来不一样。改成稳定摘要才修好:
import hashlib
sid = hashlib.md5((ip + "|" + ua[:120]).encode("utf-8", "ignore")).hexdigest()[:16]
改完必须整库重导一次,之前存的会话全是对不上的。
五、归属地:离线库,不联网
归属地我选了 ip2region 的纯文本源,一共 518279 行,每行长这样:
0|16777215|0|0|0|内网IP
16777216|16777471|澳大利亚|0|0|0
...
start | end | 国家 | 省 | 市 | 运营商
导进 sqlite,把首尾 IP 转成整数建索引,查的时候一句区间查询就够:
SELECT country, region, city, isp
FROM ranges WHERE ? BETWEEN start_num AND end_num LIMIT 1;
再套一层五万条的 LRU 缓存,单机解析一天的量完全无感。拿百度蜘蛛的段验过:220.181.108.1 → 北京市 · 北京市 · 电信,对得上。
用离线库的好处不只是快。归属地查询如果走在线接口,你的日志里的每一个 IP 都要发给别人——那就本末倒置了。
六、机器流量:这台站 95% 的请求不是人
第一次全量导完,41 万条记录里 39 万条是机器。这不是个例,任何一个有几年历史的站点,被扫描探测的强度都会超出想象。
识别分五层,逐层收紧:
| 规则 | 判定方式 | 能抓到什么 |
|---|---|---|
| 1. UA 关键词 | 含 bot / spider / crawler / curl / python-requests 等 | 老实的爬虫和批量脚本 |
| 2. 命令行注入痕迹 | UA 里出现 $(、反引号、x5C 之类 |
拿漏洞 payload 当 UA 的探测 |
| 3. 异常路径 | /cgi-bin/、.env、wp-config、phpmyadmin |
不看 UA 也直接判定 |
| 4. 行为判定 | 单 IP 单日请求 ≥400、或不同路径 ≥60、或会话数 ≥20 | 轮换 UA 装成 Chrome 的扫描器 |
| 5. 速率判定 | 任意 60 秒窗口内请求 ≥25 次 | 短时间爆发的抓取 |
前三条是常识,真正的分水岭是第四条。有个意大利 IP 一天打了 12.4 万次请求,UA 每次都一样地伪装成 Windows + Chrome,前三条一条都碰不到。加上行为判定之后,机器比例从 93.5% 提到 95.0%,TOP 榜里才开始出现真正的读者(比如广州电信那位,47 次请求、累计 2 分 14 秒)。
UPDATE visits SET bot = 1
WHERE date = ? AND ip IN (
SELECT ip FROM visits WHERE date = ?
GROUP BY ip
HAVING COUNT(*) >= 400
OR COUNT(DISTINCT path) >= 60
OR COUNT(DISTINCT sid) >= 20
);
还有一类很隐蔽:整站从来没有返回过 200 的路径。比如 /wp、/wordpress、/api/health——正常读者不会访问这些,只有探测脚本会。整表扫一遍,凡是这类路径的请求全部标记。
别指望一次标记完
机器流量识别是个持续对抗的事。bot 字段我设计成可以反复重算(再来一轮就把新的也要标记上),而不是一次性打完就封版。每次看数据觉得”人怎么突然多了”,多半是新的扫描器进场了。
七、停留时长:能算,但要清楚算的是什么
这是日志口径最大的局限,得先说清楚:日志里没有”离开页面”这个动作。所有人最终都有一页是”最后一次访问”,这一页停留多久,服务器永远不会知道。
能做的估算:把同一个会话的记录按时间排好,用下一条记录的时间减去当前这条,当作在这一页的停留。最后一条没有下一条,算不出来;总共只访问一页的,也算不出来。
UPDATE visits
SET dwell = s.d
FROM (
SELECT id,
LEAD(ts) OVER (PARTITION BY sid ORDER BY ts) - ts AS d
FROM visits
WHERE ts >= ?
) s
WHERE visits.id = s.id
AND s.d IS NOT NULL
AND s.d <= 1800; -- 超过 30 分钟多半是挂机,不计
这里踩过一个性能坑:最初我用相关子查询写,41 万行跑了 93 秒;改成上面这种 UPDATE ... FROM(SQLite 3.33 以上支持)之后,同样的量级秒级完成。O(n²) 和 O(n) 的差距在这种数据量下很明显。
什么时候必须用 JS 埋点
如果你需要精确到最后一页停了多少秒、单页访问也要算进去,那就只能用前端的页面卸载事件上报,日志做不到。我的取舍是:宁可少一部分精度,也不想在页面里加脚本。
八、看板长什么样
一个 Python 标准库的 HTTP 服务,端口 8089,内网直接开——手机浏览器也能看,不用装 App。

顶部是六个指标卡,每个都带环比:浏览量、独立 IP、会话数、平均停留、每次会话页数、跳出率。下面依次是趋势(日 / 周 / 月切换)、24 小时分布、热门页面、独立 IP、来源与设备分布、访问明细共六块。
1. 日 / 周 / 月怎么切
不用另建日历表,sqlite 的字符串函数直接够用:
-- 按周(ISO 风格,用 %W)
SELECT strftime('%Y-W%W', date) AS label, COUNT(*) pv
FROM visits GROUP BY label ORDER BY label;
-- 按月
SELECT substr(date, 1, 7) AS label, COUNT(*) pv
FROM visits GROUP BY label ORDER BY label;
「本周」我最后选的是滚动 7 天而不是自然周。原因有点好笑:自然周在周一这一天会等于「今日」,侧边栏卡片上两个格子显示同一个数字,看着像坏了。
2. 独立 IP,以及单个 IP 的完整轨迹
一开始我只给了一个「访客 TOP 20」的柱子。但真要排查问题的时候,想知道的是某一个 IP 到底干了些什么——于是加了一张完整的独立 IP 列表,可以翻页,点任意一条弹它的访问轨迹。

弹出来的轨迹按时间正序排列,逐条列出页面、停留、来源、设备、状态码。这一步能回答很多实际问题:某个 IP 是在正经读文章,还是在从头扫 /wp- 开头的路径——看一眼状态码分布就知道。
最底下还有一张全量明细表,可以按时间/IP/页面/UA 关键词搜索,导出 CSV:

这一版是「拉一次算一次」的 5 分钟粒度,看历史趋势够用了。如果你想要秒级刷新,可以参考 MosDNS 实时统计面板从零搭建里用的 SSE 推送方案——那套是服务端主动往浏览器推,代价是要常驻一条连接。
九、回填:侧边栏那张卡片
站点侧边栏原来有一张统计卡片,靠浏览器上报计数。它和我这套日志口径的数字从来就没对上过——同一天,卡片说 PV 26、IP 13,日志说 PV 276、IP 148。
| 对比项 | JS 上报(浏览器埋点) | nginx 日志口径 |
|---|---|---|
| 谁能被记到 | 执行了脚本的真实浏览器 | 所有到达服务器的请求 |
| 伪装成 Chrome 的采集器 | 记不到 | 记得到,但要靠规则识别 |
| 机器流量 | 天然过滤掉大部分 | 需要自己标记(我这套标出 95%) |
| 停留时长 | 可精确到秒 | 相邻请求间隔估算,末页算不出 |
| 同一天实测 | PV 26 / IP 13 | PV 276 / IP 148 |
差了十倍的根因,是大量采集器用的是标准 Chrome UA 但根本不执行 JS——它们在日志里看着像人,在埋点里完全不存在。
后来我做了一个决定:卡片不再自己计数,改成只读统计库推过来的快照。链路是:每 5 分钟的同步任务跑完之后,紧接着算一份快照 POST 给站点的一个写入端点,存成一条 option;卡片的 AJAX 直接读这条 option。这样两个地方同源同口径,而且不怕 nginx 的整页缓存。
顺带还解决了一个隐患:旧卡片把去重后的 IP 用逗号拼成一个 TEXT 字段,并发写入时会互相覆盖。n 段 md5 拼出来是 n 个逗号,代码按「逗号数 + 1」算,天然多算一个;真跑到几百 IP 一天的时候,单个字段就要上万字符了。现在卡片压根不写库,这个隐患也就不存在了。
十、踩过的坑,按心疼程度排序
- systemd unit 改了不生效。改的是
/opt里的副本,systemd 读的是/etc/systemd/system/那一份。改完必须cp过去再daemon-reload。 - 会话 ID 用内置 hash()。跨进程结果不一致,会话数和停留时长全乱。必须 md5。
- 停留时长用相关子查询。93 秒改成秒级,就差一个
UPDATE ... FROM。 - GET 请求被页面缓存吃掉。导出端点和回填端点都要改成 POST,并且带
X-Accel-Expires: 0。 - 前端表格被自己的兜底覆盖。代码写成了
render(...) || (兜底 div),而渲染函数没有返回值——undefined恒为假,表格刚画好就被替换成「无数据」。改成先判断行数再决定渲染还是兜底。 - 日志轮转没识别。iNode 变了还按旧偏移量读,补出来的数据全是错的。返回体里带上 inode,客户端发现不一致就归零。
十一、跑起来之后
下面是最近 8 天的真实数据。左边两列是”人”的部分(已经剔掉机器流量),第三列是当天机器请求数:
| 日期 | 浏览 PV | 独立 IP | 机器请求 |
|---|---|---|---|
| 2026-09-28 | 215 | 140 | 15,760 |
| 2026-09-29 | 280 | 178 | 83,902 |
| 2026-09-30 | 262 | 164 | 10,706 |
| 2026-10-01 | 344 | 220 | 11,924 |
| 2026-10-02 | 485 | 252 | 12,335 |
| 2026-10-03 | 365 | 222 | 8,312 |
| 2026-10-04 | 284 | 205 | 10,930 |
| 2026-10-05 | 276 | 148 | 13,890 |
9 月 29 日那次 8.3 万的机器请求,查下来是一个固定 IP 在轮 UA 猛扫,加了速率判定之后这种量级的 spike 会被更早标记。
资源占用方面:数据文件 180 MB(含索引),归属地库 40 MB,服务常驻内存不到 40 MB,单次同步 1 秒左右。放在家里那台机器上完全无感——顺便说一下,如果你也想找台机器常年跑点东西,物理机装 PVE 之后开个 Debian 小虚拟机是最省心的做法。
十二、你也可以照着做
总结一下这套方案的取舍:
- 换来的:数据完全在自己手上,机器流量看得见,站点零侵入、零性能损耗,口径想怎么定就怎么定。
- 付出的:停留时长只能估算,得写一点 Python 和 SQL,第一次部署大概要花半天。
- 不适合:只有一台虚拟主机、不能登 SSH 的环境。
如果你本来就有服务器,我觉得这笔交易很划算——至少我现在终于知道,每天到底有多少次请求是真正的人在看着。
延伸阅读
- SSH 密钥登录新手教程:从生成密钥到启用——往服务器上 scp 脚本、远程执行的第一步
- Linux 常用命令大全(分类速查)——systemd / sqlite / 日志相关命令随手查
- 局域网 HTTPS 绿锁:用 PVE 内部 CA 签证书——让内网看板也有绿锁
- 物理机安装 PVE 9.1 图文攻略——给自己找一台常年开机的 Linux
- 文章名称:用 Nginx 访问日志自建网站统计面板
- 文章永久网址:https://www.997wan.com/nginx-access-log-stats-dashboard/
- 本网站的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系站长 997笔记 进行删除处理。
- 本站一切资源不代表本站立场,并不代表本站赞同其观点和对其真实性负责。
- 本站一律禁止以任何方式发布或转载任何违法的相关信息,访客发现请向站长举报
- 本站资源大多存储在云盘,如发现链接失效,请联系我们我们会第一时间更新。
- 如无特别声明本文即为原创文章仅代表个人观点,版权归《997笔记》所有,欢迎转载,转载请保留原文链接。