上个月帮一个做工具站的朋友看数据,他给我看了两套报表:一套是第三方统计后台,说昨天来了 2400 个访客;另一套是他自己从 Nginx 日志里跑出来的,说昨天只有 700 多个真实会话。两套数据差了三点四倍,他的第一反应是「统计代码装错了」,第二反应是「是不是被攻击了」。我让他先别急着改代码,因为在小站身上,这种差异几乎是常态,原因也不是单一的一个。
这半年我给自己的站和另外几个朋友的站都做了同一件事:不依赖任何第三方后台,用日志加一点点前端埋点,把「流量从哪来」这个问题拆到能对账的程度。过程里踩的坑不少,也推翻了自己之前的几个判断。这篇把方法、参数和判据都写出来,如果你也在看自己的流量报表,希望能帮你少走一遍弯路。
一、先分清三个「访客」的定义
所有流量统计的混乱,根源都在这里:不同工具嘴里的「访客」根本不是同一个东西。
第一个定义是请求数(hits)。Nginx 日志里每一行就是一个请求,一个页面引了 30 个静态资源,就算 31 个请求。这个数字只能用来衡量服务器压力,拿来当流量指标会离谱得没法看。
第二个定义是页面浏览量(PV)。只统计返回 text/html 且状态码 200 的那些请求,才算一次页面浏览。这个数字相对干净,但它会把同一批爬虫、预加载、甚至浏览器 prefetch 都算进去。
第三个定义是独立访客(UV)。按 IP、按 Cookie、按浏览器指纹,会得到三个完全不同的数。第三方统计脚本通常按 Cookie 走,技术上是「同一个浏览器同一天算一个人」。日志里如果只按 IP 去重,一个公司或一个校园网出口后面的几十个人会变成一个;如果按 IP 加 UA 去重,同一个人切换 WiFi 和流量就会变成两个人。
我给自己的站定的口径是:日志侧统计「去重后的会话」,前端埋点统计「可执行的 Cookie 访客」,两个数允许差 15% 以内。差异超过这个范围,才开始怀疑是代码问题或者刷量。为什么允许差 15%?因为浏览器 DNT、广告拦截插件、无 JS 环境天然会让前端统计少一截,同时 CDN 回源和监控探针又会让日志侧多一截,这 15% 是这两股力量抵消之后留下的正常噪声。
二、从日志里把「人」和「机器」分开
日志是原始凭证,但原始凭证里混着大量非人类流量。我现在的做法是先做一层硬规则过滤,再做一层行为判定,两层都过不了才进「可疑」桶。
硬规则这层比较直接,按 User-Agent 排除已知的爬虫和工具,同时把没有 UA 的请求单独拎出来——正常浏览器一定会带 UA,空 UA 的请求九成是脚本。
# 从昨天的访问日志中筛出「可能是人」的页面请求
# 前提:log_format 里已经包含 $request_time 和 $http_user_agent
zcat /var/log/nginx/access.log.*.gz /var/log/nginx/access.log \
| awk '$7 == "GET" && ($9 == 200 || $9 == 304)' \
| grep -iv -E 'bot|spider|crawl|slurp|python|curl|wget|headless|phantom|scrapy|go-http' \
| grep -v '"-"$' \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20上面这段只是粗筛,重点看最后导出的 Top IP 分布。如果某一个 IP 占了当日请求的 20% 以上,那基本不用怀疑,是采集器或者某个抓取任务,直接看它的 UA 和请求路径就能定性。
行为判定这层更值得花时间。真实用户的会话有几个很难伪造的特征:同一会话内会出现对 CSS 和 JS 的请求、会有 Referer 且 Referer 通常指向站内、页面停留时间有分布而不是恒定值。脚本抓页面往往只请求 HTML,不会去拿样式表,也不会加载图片。所以我的判据是:一个 IP 当天请求了 20 个以上 HTML 页面,却一个静态资源都没请求过,就把它标记成「疑似采集」,不计入流量统计。
这条规则上线之后,我某个站的「日访客」从 1800 掉到了 900 出头。掉下去的这一半,检查了十几个 IP,全是各种聚合站和 RSS 阅读器的抓取,它们的请求头伪装得挺像浏览器,但从来不加载资源。这个数字的变化不是坏事,之前那个数一直是虚的。
三、来源归因:Referer 靠不住,得自己补
日志里的 Referer 字段理论上能告诉你「用户从哪个页面点进来的」,但现实里它有三个大洞。
第一个洞是搜索引擎已经不给关键词了。大约从 2011 年前后开始,各大搜索引擎对自然搜索结果的跳转陆续加了 HTTPS 和跳转中间页,导致 Referer 里只剩一个来源域名,具体搜的什么词变成了「not provided」。到今天,如果你的站还有办法看到自然搜索的关键词分布,那要么是买了站长平台的数据,要么就是数据来源有问题。所以「用户搜什么词进来」这件事,从日志里永远查不到了,只能靠站长平台或者自己做的落地页参数反推。
第二个洞是移动端 App 内打开经常没有 Referer。微信、各种资讯客户端的内置浏览器,出于隐私策略,很多时候压根不发 Referer。这部分流量在日志里会归到「直接访问」,如果它占比很高,你就分不清是「忠实用户收藏了书签」还是「被人在群里分享了一次」。
第三个洞是广告拦截插件和隐私模式会顺手把 Referer 抹掉。这部分比例不高,但会让归因数据整体偏「直接访问」。
我的补法是自己加一个来源标记。所有从站外跳转进站内的链接(包括我在别人评论区留的链接、在社交平台贴的链接、在邮件里发的链接)都带一个统一的查询参数,然后在服务端把这个参数记进日志的自定义字段,同时把它写进 Cookie,让同一次会话里后续的页面都能继承这个来源。
# nginx.conf 里加一个 map,把 utm_source 收敛成有限的几个合法值
# 避免参数被随便填导致数据脏掉,也顺手避免日志被注入
map $arg_utm_source $src_tag {
default "unknown";
"~*(baidu|google|bing|sogou)" "search";
"~*(wechat|weixin|wx)" "wechat";
"~*(zhihu|x|twitter|weibo)" "social";
"~*(newsletter|mail)" "email";
"~*(forum|bbs|v2ex)" "forum";
}
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" rt=$request_time src=$src_tag';注意这里有两个细节。一是 map 用的是白名单正则以「前缀匹配」,不是精确相等,因为实际参数值经常会带上活动名,比如 utm_source=zhihu_2026。二是收敛成有限的几个值再落日志,不要直接把原始参数原样写进去,否则日志字段会被乱七八糟的字符串塞满,而且原始参数是外部可控的,直接落盘存在注入和日志膨胀的风险。
有人会问,那用 UTM 参数会不会影响 SEO,比如同一篇文章出现多个 URL 造成重复内容。这个担心是对的,处理办法是在页面里加一条 canonical 指向不带参数的原始地址,同时确认 robots.txt 没有把参数页放出去。只要 canonical 正确,带参数的地址不会被当成独立的重复页面。
四、三个每月必看的比值
绝对数没意义,比值才有判断力。我每月初固定看三个比值,加起来不超过十分钟。
第一个:直接访问占比。如果「直接访问」超过总访问的 40%,而你的站又没有形成品牌记忆,那几乎可以确定有两件事之一:一是大量 Referer 丢失导致数据被归到这里,二是有人在用无 Referer 的方式刷量。判断方法很简单,看这部分流量的落地页分布——真实用户直接访问,落地页绝大多数是首页;刷量和采集,落地页会分散到各种内页。
第二个:单会话页面数。一个人一次访问看几页,这个数在半年前是 1.9,现在掉到 1.4。这种变化通常不是「内容变差了」,而是移动端占比上升——移动端用户的阅读行为天然更碎片化。如果这个数突然掉到 1.1 以下,就要去看是不是相关文章全挂了、或者移动端布局出问题导致用户看不到下一篇。
第三个:跳出率与停留时间的组合。只看跳出率高会误判,高跳出加长停留往往是好事(用户看了这一篇就够了),高跳出加极短停留才是问题。我在日志里用 request_time 猜不出来停留时间,因为静态页返回都是几毫秒,真正的停留时间必须靠前端埋点。这也是为什么我坚持在日志之外还要加一小段埋点脚本——不是为了做花哨的看板,只是为了拿到这两个只有浏览器才知道的数。
五、埋点怎么写才不添乱
前端埋点最常见的错误是「同步加载一个几百 KB 的统计 SDK」,这会直接拖慢首屏。我的做法是自己写一个不到一 KB 的小脚本,用 navigator.sendBeacon 在页面卸载时发一次,请求走一个不缓存的接口,服务端只记四个字段。
// 极简埋点:页面隐藏时发一次,不阻塞卸载
(function () {
var t0 = Date.now();
var sent = false;
function send() {
if (sent) return;
sent = true;
var ms = Date.now() - t0;
// 停留时间超过 30 秒的记为 30 秒,避免挂机页面污染均值
var dur = Math.min(ms, 30000);
var payload = new Blob([JSON.stringify({
p: location.pathname, d: dur, r: document.referrer
})], { type: 'application/json' });
navigator.sendBeacon('/_stat?t=' + Date.now(), payload);
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden') send();
});
window.addEventListener('pagehide', send);
})();这段代码有三个刻意的设计。第一,用 visibilitychange 而不是 unload,因为移动端浏览器对 unload 的支持越来越差,很多情况下根本不触发。第二,停留时间封顶 30 秒,否则一个开着标签页去吃饭的用户会让平均值彻底失真。第三,用 sendBeacon 而不是 fetch,因为页面正在卸载时普通请求会被中断。
服务端那个 /_stat 接口要做四件事:只接受 POST、限制请求体大小(比如 1KB)、不记录 Query String 之外的任何请求头、返回 204 且不带 Cookie。最忌讳的是给这个接口留一个可以写入任意内容的入口,历史上不少统计系统的事故都是这么来的——攻击者往统计表里塞几百万条脏数据,直接把磁盘写满。
六、一次真实的对账过程
回到开头那个朋友的问题。他的两套数据差三点四倍,我们按上面的顺序查了一遍。
先看日志总量,Nginx 记录昨天有 21000 个请求,其中 text/html 且 200 状态的有 4200 个。再跑硬规则过滤,剔除 bot、curl、空 UA 之后就剩 980 个。到这里已经能解释掉四分之三的差异了:第三方统计的 2400 里,包含了大量它没识别出来但其实是工具流量的请求。
接着看行为判定,980 个页面请求里,有 210 个 IP 出现了「请求多个 HTML 但零静态资源」的情况,全部剔除之后剩下 770,和他自己跑的 700 出头基本吻合,差异来自他去重时多算了一个会话。
最后看来源分布,这 770 个会话里,直接访问占 62%,搜索占 18%,社交占 11%,其余是其它。直接访问占比 62% 对一个没什么品牌的小站来说明显偏高,我们抽查了 30 个直接访问的 IP,发现其中 9 个的落地页是内页且停留时间为 0——这些是通过某些渠道分享进来但 Referer 被抹掉的流量,属于真实用户,只是归因丢了。
整件事的结论是:日志口径的 770 是可信的,第三方统计的 2400 是虚高,而「直接访问占比过高」的根因是 Referer 丢失,不是刷量。如果当时直接去改统计代码或者加防火墙,方向就全错了。
七、几个容易踩的坑
坑一:把 304 当成「没有访问」。回访用户的浏览器带 If-Modified-Since 来,服务端返回 304,日志里确实有一行记录,但 body_bytes_sent 是 0。如果统计脚本只挑 bytes > 0 的行,会把所有老访客全都算丢。正确做法是 200 和 304 都算页面浏览,只是分别标记。
坑二:CDN 回源导致重复计数。如果站前面挂了 CDN,同一批请求可能会出现「CDN 边缘日志一份 + 源站回源日志一份」。要么只统计源站并且把 CDN 回源特征(比如特定的 X-Forwarded-For 头或者回源专用 UA)过滤掉,要么干脆只统计 CDN 提供的日志。两边都算,数据直接翻倍。
坑三:日志切割时把统计脚本切在两个文件上。凌晨切割日志的时候,如果统计任务正好在跑,会漏掉或少算几分钟的数据。我的做法是统计昨天的日志文件(已经切割完、文件不再变化),而不是统计「正在写的那个」。
坑四:把预加载和预制请求当成真实访问。浏览器会在空闲时预取页面,搜索引擎也会预渲染,这些请求在日志里看起来和人一模一样,但用户根本没看。判据是看这条请求之后有没有后续的资源请求——纯 prefetch 只有 HTML,没有子资源。这和前面那条「请求了 HTML 但没请求静态资源」的规则其实是同一个道理,可以合并处理。
把这四件事都处理完之后,日志侧的统计基本可以对得上账了。它不会比商业统计工具更漂亮,但它有一个商业工具给不了的好处:口径是你自己定的,任何一个数字都能追到一条原始日志。当某个月的流量突然翻倍或者腰斩时,你能在十分钟内说清楚是哪个来源变了,而不是对着一个黑盒后台猜。
最后提醒一句:不要试图追求「两套数据完全一致」。它们衡量的本来就不是完全相同的对象,能解释清楚差异在哪、差异有多少、是什么原因造成的,就已经够用了。