先把结论摆在前面。一是,这个桌面软件官网日均两千到三千的 PV、一百四到三百一十个独立 IP,大头是已安装客户端的更新心跳和扫描器探测,真人浏览比看板上的数字小得多,搜索来的来源近乎为零。二是,"下载量"这件事,我认可的判据是:响应状态码 200 且实际发出字节数不低于 30MB;按请求次数去数,会被断点续传和分段下载虚增好几倍。三是,曾经有一天"下载量"冲到 81 次,复盘下来 80 次是 GPTBot 在批量抓历史版本安装包,把爬虫和自家备份脚本全剔干净之后,两周里真人完整拉走安装包的数量,约等于 0 到 1。
先如实交代来源。这是一个卖桌面电商合规检测工具的软件官网,一台机器上一个 nginx,主站加下载目录都在它手里;没有埋点,没有分析平台,所有数字来自同一个地方——access.log,主日志加轮转出来的 14 个 gz 文件。热榜上那类"自建数据分析平台"的方案我没走,不是它不好,而是它回答不了我较真的那个问题:看板说"今天有三百人访问",那到底是三百个人,还是三百台机器?这个问题,只有原始日志能给你一个不会撒谎的裁决。
一、为什么不装分析平台,却又离不开日志
各类自建分析平台的思路都差不多:页面里注入一段脚本,浏览器执行了脚本才算一次访问,事件、来源、停留时长按会话拼起来。它的长处是省事,开箱就有一块能看的板子。但我盯上的指标它天生给不了——这个网站的核心转化动作不是"浏览",是"把一个几十 MB 的安装包完整拿走"。浏览器执行脚本和你有没有拿到完整文件,中间隔着十万八千里:用户点了下载、下到 40% 关掉窗口、换个网络重来,分析平台只记得到"点击",而 nginx 把每一段传输了多少字节都写在了日志里。传输发生在哪里,真相就在哪里。
还有一条更私心的理由:数据主权。日志躺在自己的服务器上,格式自己定的,保留多久自己说了算,不存在"分析服务挂了看板归零"这种依赖。所以我的选择是不装平台,直接面对原始日志。
但原始日志有个原始日志的代价:它不会主动告诉你任何结论,每一行都一视同仁,真人的点击、客户端的心跳、扫描器的探测、AI 爬虫的扫库,全混在同一个文件里,长得几乎一模一样。口径不立起来,你从日志里算出来的每一个数都可以是错的,而且错得很自信。这篇文章就是把这套口径怎么立起来的完整过程:先盘材料,再给流量分身份,然后立下载量的判据,最后复盘那次 81 次的尖峰。
二、材料:14 天全量日志,先得读对
2.1 轮转文件的合并顺序是个坑
日志在系统默认的日志目录下,结构很朴素:当天写 access.log,每天轮转一次,昨天的变 .1,前天的变 .2,依此类推,压成 gz。要统计"最近两周",得把主文件加十四个压缩包拼成一条流:
# 统一读取入口:zcat -f 对未压缩文件原样透传,压缩文件边解边吐# 注意花括号展开要写成 {14..1}:轮转序号是"越小越新",正着拼时间就是乱的zcat-faccess.log.14.gz access.log.13.gz access.log.12.gz>/tmp/old.txt# 上面的显式写法太笨,实际用大括号展开(bash):zcat-faccess.log.{14..1}.gz access.log>/tmp/two_weeks.txt# 顺手确认几件事:总行数、时间跨度、文件是否齐全wc-l/tmp/two_weeks.txthead-1/tmp/two_weeks.txt;tail-1/tmp/two_weeks.txt按天分桶的统计其实不怕乱序,时间戳就在每行里;但凡是"顺着时间线走"的分析——比如看某个 IP 的会话行为、判断一次下载是不是中断后续传——就必须先把顺序理顺。我有一次对着乱序文件排"下载中断"的问题,得出的结论完全颠倒,重查了半小时才怀疑到合并顺序上。这种坑不致命,但专治自信。
2.2 字段表:先把每个位置是什么钉死
日志格式是默认的组合格式,只做了两处定制:开头加了站点主机名字段,因为同一台机器上还挂着别的站,统计前必须按 host 过滤;另外,如果前面挂了 CDN 或反代,客户端地址拿的是转发头里的真实 IP,nginx 自己记的那一列是边缘节点的地址——这个坑我们踩过,后面所有统计的起点都是"过滤对了 host、认对了 IP"。
以这份格式为准,把字段的物理位置钉死(下文所有 awk 都按这个表取列)。一条日志长这个样子,协议版本记号和 UA 细节用省略号略过——本文自己有条规矩:不贴整串的链接型字符串。字段编号不受影响:
主机名 203.0.113.7 - - [22/Sep/2026:03:11:42 +0800] "GET /dl/setup-3.2.1.exe …" 206 15728640 "-" "Mozilla/5.0 …"| 位置 | 含义 | 读的时候最容易搞错的地方 |
|---|---|---|
| $1 | 站点主机名 | 不加过滤,几站的流量混着算,PV 直接翻倍起步 |
| $2 | 客户端地址 | NAT 后面一个地址能顶一个办公室;拨号用户一天换好几个;它是线路,不是人 |
| $5 | 日期加时间 | 取日期用 substr($5,2,11),前头有个左方括号 |
| $8 | 请求路径,含查询串 | 参数全藏在这一列里,只看路径不看参数会漏掉关键身份 |
| $10 | 响应状态码 | 200 完整、206 分段、304 缓存有效、404 不存在;206 不是错误 |
| $11 | 实际发出的字节数 | 它是本次传输量,不是文件大小;206 的一行只有分段的字节 |
| $12 | 来源页字段 | “-” 是直连或机器请求;非 “-” 再分"站内跳转"和"站外引荐" |
| $13 起 | UA 字段 | 内部有空格,靠空格切列在 UA 上必翻车,一律用整行正则匹配 |
这张表里最值钱的是 $11 那一行。凭直觉,谁都愿意把"有人来下载文件"理解成"日志里 .exe 那一行的字节数是文件大小"——错了。$11 记录的是这一次响应实际吐出去了多少字节;一次下载被切成五段,就是五行日志,每行只有自己的那份字节。这个认知差,就是第四节整节的内容。
三、先把"三千 PV"拆开:心跳、扫描器、爬虫和真人
3.1 一行流先把总量立起来
先按天数一遍 PV 和独立 IP,跟自己的印象对齐,确认读的数据没缺:
# 按日统计 PV / UV(独立地址数),awk 按字段表取列zcat-faccess.log.{14..1}.gz access.log\|awk-vh='你的host字段值''$1==h { d = substr($5,2,11) pv[d]++ if (!seen[d,$2]++) uv[d]++ } END { for (k in pv) printf "%s pv=%-6d ip=%-4d\n", k, pv[k], uv[k] }'|sort跑出来的量级:日均 PV 两千到三千,独立 IP 一百四到三百一十。乍看是个还算热闹的小站。但接着把来源页字段一分组,凉意就上来了:非 “-” 的来源里,站外引荐合计接近于零——搜索进来的流量约等于没有。热闹是真的,但热闹不是"访问"。
3.2 心跳:latest.yml 不是人,是装过软件的人在用软件
翻请求路径的分布,会发现三个眼熟的家伙霸着前排:latest.yml、RELEASES、.blockmap。这是客户端自动更新机制的三件套——latest.yml 是更新清单,体积几十 KB;blockmap 配合增量更新,让客户端只下载变动的块;RELEASES 是发版素材。已装机的客户端会周期性地来拉这三个文件,比对版本,没新版本就什么也不干。
它们不是人下载,但它们在日志里和"下载"长得几乎一样:全是 200,全是 GET,天天有、夜里也有。它们贡献了日均 PV 里非常可观的一块;反过来看,拉心跳的独立 IP 数,就是这个软件装机后还在保持联网的存量盘子——这倒是意外收获:心跳量是"还有多少人在用",完整下载量才是"有多少新人在来",两个数完全不是一回事,混着看哪个都不对。
3.3 扫描器:PV 里混着一整支探测队
再翻一遍路径分布,第二个成分浮出来:大量业务上根本不存在的地址在被反复请求——/.env、/.git/config、/wp-config.php、/actuator/env,还有 phpmyadmin、备份文件猜解之类。这是互联网的基础噪声:自动化扫描器拿着特征库满世界挨家敲门,想捡配置泄露和框架漏洞。它们没有 UA 礼貌可言,也不看来路,请求干脆利落。
这部分流量不去掉,"访问量"就是虚的。验证方法很朴素:看这些路径的请求量占 PV 的比例,再看它们的 IP 分布——十几个地址贡献几十万次探测是常态,和真人流量的分布模式完全不同。这里只陈述现象:识别出来、从统计口径里剔掉;至于防火墙层怎么处置,是另一个话题,本文不展开。
3.4 一张表认全日志里的面孔
把三类噪声加上下载相关的几类请求放到一起,就是下面这张身份对照表。后来每次有人问"这个数是怎么算的",我都先甩这张表:
| 日志里长这样的请求 | 看上去像 | 实际身份 | 统计处置 |
|---|---|---|---|
| /dl/setup-x.y.z.exe,206,多行同 IP 同路径 | 多次下载 | 一次断点续传/多线程分段 | 按"去重后的发起"计,绝不按行计 |
| /dl/setup-x.y.z.exe,200,字节约等于安装包大小 | 一次下载 | 完整下载,这才是能作数的形态 | 计入完成下载 |
| /dl/setup-x.y.z.exe?cb=一串随机值,200 整文件 | 用户直连 | 自家备份脚本绕缓存拉包 | 按参数剔除 |
| latest.yml / RELEASES / .blockmap,200,体积很小 | 页面访问/下载 | 已装客户端的更新心跳 | 单独计心跳,不算下载不算 PV 主体 |
| /.env /.git/config /wp-config.php /actuator/env | 流量 | 漏洞扫描器探测 | 剔除,单列 |
| robots.txt / favicon.ico | 访问 | 浏览器和爬虫的后台行为 | 剔除 |
| 整站版本被顺序拉完,UA 是 GPTBot、ClaudeBot | 用户活跃 | 大模型爬虫抓训练素材 | 剔除,单列(见第五节) |
有了这张表,任何一天的"访问构成"都能当场拆干净。拆的方法就是整行正则对 UA 打标:先点名 GPTBot、ClaudeBot 这类大模型爬虫(判据在第五节),再用一张关键词表兜住其他机器——bot、spider、crawl、python、curl、scrapy、zgrab、headless,一律先按机器处理再谈其他;扫描路径和心跳文件在打标之前就该被截走。这套判据先记在脑子里,第七节会把它整段固化进脚本,这里不重复贴。两周分类下来的体感是:真正值得叫"访问"的那部分,比看板直觉小一个量级;剩下的流量各有各的身份,只是都没名字而已。日志不会骗人,但日志也不会自报家门。
四、下载量核算:判据立错,一切白搭
4.1 206 分段:一次下载能给你写出五行日志
桌面软件的安装包有几十 MB,下载过程里断点续传和分段并发太常见了:浏览器自己就能把一个大文件拆成几路并行拉,下载器更是如此;中途断网,恢复后再从断点续。每一种情况,nginx 都老老实实地记一条 206 的日志——状态码 206 的意思是"服务器接受了区间请求,只发这一段",它是成功响应,不是错误。于是一个人下一次完整的安装,日志里可能是五六个 206 行,外加开头可能的几个探测行。
我第一次核算下载量的时候,用的就是图省事的判据:路径匹配 .exe 且状态码以 2 开头,数行。结果比实际大得离谱,分段越多错得越多,而且错得没规律——取决于用户的网络和下载器心情。判据必须换成能钉死"完整拿到文件"这一件事的形态。
4.2 完整下载的硬判据:200 且字节数过阈值
安装包一个版本三十 MB 出头。完整下载在日志里的形态只有一种:状态码 200(整文件一次发出,没有区间请求),且 $11 不低于一个贴着包体大小的阈值。30MB 这个位置是这么定的:比整包小一档、又远大于任何"下了开头就断"的字节数,中间留出的安全余量足够宽;同时它天然把心跳文件(几十 KB)隔离在外——就算哪天有人手滑把 latest.yml 也统计进去,这个阈值也能兜住。
# 完整下载:200 + 字节数 >= 30MB + 安装包后缀,剔除带绕缓存参数的自家备份# 再按 UA 把人机分开——GPTBot、ClaudeBot 也满足"完整下载"的物理判据,必须点名剔除zcat-faccess.log.{14..1}.gz access.log\|awk-vh='你的host字段值'-vT=31457280'$1==h { if ($10==200 && $11>=T && $8 ~ /\.(exe|dmg)$/ && $8 !~ /\?cb=/) { ua = tolower($0) if (ua ~ /gptbot|claudebot/) { ai++; next } if (ua ~ /bot|spider|crawl|python|curl|headless/) { bot++; next } d = substr($5,2,11) if (!hit[d,$2,$8]++) dl[d]++ # 同人同日同文件去重,只算一次 } } END { printf "AI爬虫完整拉包=%d 其他机器完整拉包=%d\n", ai, bot for (k in dl) printf "%s 完成下载(去重)=%d\n", k, dl[k] }'|sort注意两个细节。其一,$11 是"发出字节",200 的时候它约等于整个文件大小,这正是判据的依据;所以这个方案要求你的日志格式必须记录这个字段,别在定制 log_format 的时候把它省了。其二,“同人同日同文件去重”——一个人网络抖了重下一次、或者自己清缓存重来,按业务意义只算一个完成下载;要统计"尝试"又是另一套键,那是发起数的口径,放在下一小节。
4.3 发起与完成:漏斗得两段分开数
只看完成,会漏掉另一半信息:有多少人点了下载但没拉完?把 206 也算进来、按"IP + 文件"做键去重,就是"发起过下载的人数";再对照完成数,一上一下才是个漏斗。206 的行不能直接数次数——回到 4.1 那个坑——必须按 (IP, 路径) 归并。做产品久了会发现,下载类转化里"发起"和"完成"的差值,比任何单一数字都诚实:它同时反映了你的包有多大、用户的网络有多差、以及你的页面有没有把人引到错误的按钮上。
4.4 回源参数与缓存:日志口径的边界
?cb= 这个查询串参数要说一下:备份脚本拉包的时候故意带一个每次不同的随机值,让缓存认不出来、逼着请求回源取完整文件——这是运维脚本保证拿到最新整包的常规手段。副作用是:这十次八次的备份拉取,在日志里和真人下载一模一样,全是 200 加整文件字节,破绽只有一处:那个参数。识别手段也就这一处:按参数剔除。凡是"服务器上的自己人"发的请求,都要给它留个可识别的记号,否则它迟早混进你的统计给你难看。
另一个方向的问题也要如实交代:这套口径统计的是"源站实际发出的完整传输"。如果前面挂了 CDN,用户命中边缘缓存的那些下载,压根不会走到源站的日志里;源站日志只记回源的那部分。所以下面所有数字的准确说法是"源站视角的下载量",不是"全网真实下载量"。要在日志里区分命中还是回源,办法是定制 log_format 加一个缓存状态字段($upstream_cache_status),加上之后本节的脚本按它再切一刀即可。口径可以简单,但口径的边界必须写清楚——这是我看任何一份数据报告的头一条要求。
五、单日 81 次"下载"的复盘:AI 爬虫会伪装成增长
5.1 尖峰那天,差点开庆功
14 天窗口里有一天,按"200 + 整包字节"数出来的下载次数冲到 81。前一个工作日还是个位数。当时的第一反应是很正常的:是不是哪篇文章被转了、有没有人在什么群里发了链接?我甚至开始盘要不要给服务器加带宽。
复盘动作后来固化成了口诀:尖峰先别高兴,UA 分组、IP 分组、时间分布,三刀下去再下结论。三刀砍完,81 次里的成分大致是这样:80 次来自 GPTBot,一个 ClaudeBot 的请求在当天露头(它在整个窗口里一共出现过 2 次),剩下那次是自家备份脚本——备份在整个两周里一共拉了 10 次,赶巧那天也摊上了一回。把机器和自家流量全部剔除,那天真人零完成;把整个 14 天都洗完,真人完成下载合起来约等于 1 次——运气不好就是 0。
5.2 GPTBot 那 80 次都抓了什么
UA 是 GPTBot 的请求,路径分布非常有规律:下载列表页里挂着的所有历史版本的安装包,按版本号顺序,一个不落,每个都是 200 整包拉满。这不是行为像用户,这是爬虫在做全站抓取,只不过它抓的对象恰好是我们最大的静态文件。按一包 31MB 折算,那一天它从源站拖走 2.4GB 还多——这不是用户增长,这是带宽账单。ClaudeBot 的行为模式同款,只是量小。
AI 爬虫批量拉二进制包这件事,是这两年新长出来的流量成分。它和传统爬虫的区别在于"胃口":传统爬虫主要抓页面文本,成本可忽略;大模型爬虫会把可下载文件一并纳入抓取范围,一个安装包就是一个几百兆的素材库。识别倒不难——UA 字段规规矩矩写着 GPTBot、ClaudeBot,不装人;难的是心态:这些请求的字节数、状态码和真人下载完全同构,不点名剔除的统计软件(以及不用功的人)都会把它算进"下载量"。它伪装成增长的方式不是撒谎,是长得太像。
5.3 真尖峰和假尖峰的指纹不一样
那次之后我给自己立了一条判别规则,写进了第七节的脚本备注里:真实传播带来的尖峰,指纹是分散的——来源页多样、IP 分散在不同运营商和地域、UA 五花八门、时间上跟着某个事件起落;机器造成的尖峰,指纹是集中的——少量 IP、单一 UA、请求间隔均匀得像节拍器、抓取顺序严格跟随列表顺序。两种曲线在"请求量"维度上可以画出一模一样的山峰,在结构维度上一个是烟花一个是探照灯。看量不动脑,烟花和探照灯都叫"大涨"。
六、数字背后:这站到底是门什么生意
把口径立正之后,两周日志交代的现状其实挺扎心,但值钱:
搜索引荐约等于零,说明官网在这个阶段根本没吃到搜索红利,直连流量里还有一大块是机器和心跳;真人视角的日均 PV 是个很小的数。存量这边倒是很诚实:心跳拉取的 IP 数每天都在,说明装机在用的用户是稳定存在的,软件本身没有被装完就弃。增量那边也诚实:两周约 0 到 1 的完成下载,说明官网基本不承担新用户获取。
这就是这个站的真实定位:它不是一个流量场,它是交付和承接的基础设施——老客户升级靠它,新客户从别的渠道来最后也经它落地。这个结论,看板永远给不了你,因为看板的默认单位是"访问",而访问这个单位本身就需要先审身份。日志不给你结论,日志只给你原料;结论是口径拧出来的。
七、把口径固化成脚本
复盘做到这一步,所有判据已经齐了,最后一步是把它们从"我脑子里"搬进"机器里",变成每天自动产出的一行报表。人是靠不住的:今天忘剔备份,明天把心跳当下载,后天又忘了换 host 过滤器——口径这种东西,不落进代码就一定会漂移。
#!/usr/bin/env bash# traffic.sh —— 官网流量口径日报(每天 00:10 由 cron 跑,输出追加到报表文件)# 口径版本 2026-09:host 过滤 | 扫描剔除 | 心跳单列 | 完整下载=200且>=30MB且非cb且非botset-euopipefailLOG_DIR="${1:-/var/log/nginx}"HOST_FIELD='你的host字段值'THRESHOLD=$((30*1024*1024))cd"$LOG_DIR"zcat-faccess.log.{14..1}.gz access.log\|awk-vh="$HOST_FIELD"-vT="$THRESHOLD"' $1 != h { next } { d = substr($5,2,11) } # 漏洞扫描探测:单列计数,不进 PV $8 ~ /^\/(\.env|\.git|wp-config|actuator|phpmyadmin)/ { scan[d]++; next } # 客户端更新心跳:按 IP 去重,得"当日在线存量" $8 ~ /latest\.yml|RELEASES|\.blockmap/ { if (!hb[d,$2]++) hb_ip[d]++; next } # 完整下载:200 + 整包字节,剔备份参数,再剔 AI 爬虫和普通 bot $10==200 && $11>=T && $8 ~ /\.(exe|dmg)$/ && $8 !~ /\?cb=/ { ua = tolower($0) if (ua ~ /gptbot|claudebot/) { ai[d]++; next } if (ua ~ /bot|spider|crawl|python|curl|headless/) { mdcm[d]++; next } if (!dlv[d,$2,$8]++) dl[d]++ next } # 剩余流量才配叫 PV { pv[d]++; if (!uvs[d,$2]++) uv[d]++ } END { for (k in pv) { split(k, a, SUBSEP); d = a[1] printf "%s pv=%-6d ip=%-4d 扫描=%-5d 心跳IP=%-4d AI拉包=%-4d 机器拉包=%-3d\n", d, pv[k], uv[k], scan[d]+0, hb_ip[d]+0, ai[d]+0, mdcm[d]+0 } } '|sort配套一行 cron,写进 crontab:
10 0 * * * /opt/tools/traffic.sh /var/log/nginx >> /var/log/site_daily.txt 2>&1脚本固化下来之后,报表字段也就五六个,但每一个的口径都经过这一路的毒打:
| 指标 | 判据 | 回答的业务问题 |
|---|---|---|
| 真人 PV / UV | 剔除扫描、心跳、备份、机器 UA 后的请求数与去重 IP | 这站今天到底有没有人来 |
| 完整下载(去重) | 200 且字节 >= 30MB 且无 cb 参数且非 bot,按日+IP+文件去重 | 今天有几个新用户拿到了软件 |
| 下载发起(含分段) | 安装包路径命中,按 IP+文件归并(206 计入) | 有多少人点了但没下完 |
| 心跳在线存量 | 心跳三件套按 IP 去重 | 装过的人还有多少在用 |
| AI 爬虫拉包量 | 完整下载判据 + 爬虫 UA 点名 | 带宽被谁吃掉了 |
有件小事值得记在最后:这套口径的每个阈值和每条正则,都对应一次交过的学费——30MB 对应 206 分段那次的离谱虚增,cb 参数对应备份脚本混进下载的尴尬,GPTBot 点名对应那场 81 次的假庆功。日志里的数据从来不需要美化,需要的是审问:每一行都要先问一句"你是谁",再决定它进不进哪个桶。口径是审出来的,不是算出来的。这套东西没有平台,没有依赖,备份就是 gzip 的十几个文件——流量到底是不是自己的,看一眼机器上有没有这份原始日志,比看任何看板都准。