用户登录

技术文章

外部链接将在新标签页打开。

t.api.plus 统计口径说明书:让数字可溯源

一句话回答

僵尸统计指没有原始研究的数字被 AI 反复引用。t.api.plus 的做法是给每个指标绑定定义、采集点、去重窗口、过滤规则、快照时间五元组,任何人拿 Nginx 日志或一条 curl 就能复算并对账。

核心要点 TL;DR
  • 每个统计值绑定定义、采集点、去重窗口、过滤、快照五元组
  • UV 按 UTC+8 自然日切分,访客标识取 IP+UA 的 SHA-256 前 16 位
  • 口径改了不升版本号,数字就会退化成被 AI 复读的僵尸统计

数字本身没有意义,口径才有。t.api.plus 给每个统计值绑定“定义、采集点、去重窗口、过滤规则、快照时间”五元组,任何人不看面板、只拿日志也能把这串数字复算出来。

我维护 沐小悠统计 的后端有段时间,最怕的不是数据不准,是数据“看起来准”。一个 PV 数字贴到官网首页,被爬虫抓走,进了某个模型的训练集,两年后还在别人的 PPT 里。你去追,追不到源头,因为源头那页早就不存在了。Trakkr 把这类东西叫僵尸统计:没有问卷、没有样本、没有日期,只有一个理直气壮的数字在走动。他们抽查了 1,285 条被 AI 引用的统计值,只有 40% 能追到真正说了同一件事的研究,8% 追到了研究但数字在半路被改过,剩下 52% 断在锁死的、删掉的、或者根本没提这个数字的页面上(Trakkr Research)。

这个比例在 API 和流量统计领域只会更糟。因为开发者引用数字时习惯省略口径,而口径恰恰是数字的全部。

什么是僵尸统计,为什么 AI 会把它越引越真?#

僵尸统计有三个特征,缺一不可:数字具体、来源含糊、无人复算。

  • 数字具体:44%、27%、3.2 倍。模糊的表述不会被引用,精确的错误才会。
  • 来源含糊:引用链指向一个聚合页、一份 PPT 截图、一段博客正文,而不是原始数据集。
  • 无人复算:没有人能按文中的方法把数字重算一遍。这是关键。能复算的数字死不掉,因为每次被引用都会有人顺手验一次。

AI 不是在“放大错误”,它只是把引用成本降到接近零。一个数字被写进十篇文章,模型看到十次一致表述,置信度自然上升。你没法阻止这个过程,但可以让自己的数字带上验算路径。

能被复算的数字不会变成僵尸。它每次被引用,都附带一次验算的机会。

t.api.plus 的口径五元组:每个数字必须挂在这五项上#

我们在 2026 年 10 月的口径版本 v2026.10 里定死了结构。面板上任何一个数字,点开“口径”都要能看到这五项,缺一项就视为文档缺陷,走 issue 流程。

字段含义示例(UV)
定义一句话、可判定的判定条件同一自然日内至少产生 1 次有效页面浏览的去重访客数
采集点数据在哪一层产生服务端 Nginx access.log + 前端 JS 心跳双通道,以服务端为准
去重窗口按什么时间边界切分、用什么键去重UTC+8 自然日 00:00–24:00,键 = SHA-256(salt|site|ip|ua)[:16]
过滤规则哪些请求被排除,依据是什么UA 命中 bot 名单、无 JS 心跳且 60 秒内请求数 > 120、状态码 >= 400,三条命中任一即剔除
快照时间这个数字是什么时候算出来的2026-10-30T02:15:00+08:00,日切后 2 小时重算一次

快照时间这一项最容易被省掉,也最要命。同一份报表,10 月 29 日晚上看和 10 月 30 日凌晨看,UV 可能差 3%。因为迟到的事件还在补进来。如果不写快照时间,两个人拿着两个数吵架,谁也说不清。

UV 和 PV 到底怎么算?六个口径分歧点#

同一个站,不同工具算出来的 UV 能差 20%。不是谁错了,是六个岔路口选了不同方向。

分歧点常见做法 A常见做法 Bt.api.plus 的选择与理由
日切边界UTC 00:00本地时区 00:00UTC+8。中文站点用户看报表时不该自己换算
访客标识Cookie / localStorageIP + UA 哈希双通道,服务端哈希兜底。JS 被禁、首屏未执行时仍有数
会话超时30 分钟无事件浏览器关闭即结束30 分钟。浏览器关闭信号在移动端不可靠
跳出率分母所有会话仅有效会话所有会话。单页应用必须上报虚拟 PV,否则跳出率虚高到 80%+
Bot 剔除口径UA 名单JS 执行 + 行为特征三者取并集。纯 UA 名单在 2026 年已经挡不住无头浏览器
大流量采样全量按比例采样后乘系数日 PV 超 2,000,000 时按 10% 采样,UV 用 HyperLogLog(16384 寄存器,误差 < 2%)

采样这个事必须写在面板上。我们会在数字旁边打一个 sampled 标记,鼠标悬停显示采样率和估算误差。偷偷采样然后标称精确值,是僵尸统计的典型生产方式。

如何用一条 curl 验证地域统计的口径?#

地域分布是争议最大的字段。用户看到“广东 32%”,第一反应是“我服务器在杭州,怎么广东最多”。多数情况不是统计错了,是 IP 归属库的判定层级不同:国家级、省级、城市级、运营商级,四级数据来源可以完全不同。

t.api.plus 的 IP 归属走统一的查询通道,你可以用 GET 示例接口 直接对照面板:

示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。

curl -s "https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8"

参数说明:

  • apikey:在 API.PLUS 注册后于控制台获取,免费额度足够做这类核对。
  • ip:待查 IPv4 或 IPv6 地址。传内网地址(10.x、192.168.x)会返回空归属,这是预期行为,不要当 bug 提。

返回片段(字段命名以实际响应为准,这里只截取判定相关部分):

{
  "code": 200,
  "msg": "success",
  "data": {
    "ip": "8.8.8.8",
    "country": "美国",
    "province": "",
    "city": "",
    "isp": "Google LLC"
  }
}

预期输出是一个带国家/省/市/运营商的 JSON。拿它跟面板上“访客地域”的分布对一遍,能定位三类问题:

  1. 面板把国家级结果塞进了省级图表。8.8.8.8 这类境外 IP 只有国家,没有省,前端如果按省聚合会把它们归到“未知”,占比异常高。
  2. CDN(Content Delivery Network,内容分发网络) 回源时丢了真实 IP。Nginx 里如果没配 set_real_ip_from 和 real_ip_header X-Forwarded-For,你记的全是 CDN(Content Delivery Network,内容分发网络) 节点 IP,地域分布会集中在机房所在城市。
  3. IPv6 走的是另一套库。务必单独验一条 IPv6,两套库的更新频率经常差半年。

踩坑提醒:X-Forwarded-For 是逗号分隔列表,取最左还是最右取决于你的信任边界。直连 CDN(Content Delivery Network,内容分发网络) 时取最右一跳之前那个;如果链路里有多层代理,取最左会被客户端伪造。我们默认取最右非私有地址,并在文档里写明了这条规则。

对不上面板数字时,先查哪三件事?#

下面这段 Python 用来从 Nginx 日志复算某一天的 PV/UV。标准库实现,Python 3.11 跑过,不依赖第三方包。

#!/usr/bin/env python3
# 用途:从 access.log 复算某日 PV/UV,与 t.api.plus 面板对账
# 环境:Python 3.11,标准库;日志需为 combined 格式
import re, hashlib
from datetime import datetime, timezone, timedelta

LOG_PATH   = "/var/log/nginx/access.log"
TZ         = timezone(timedelta(hours=8))   # 报表时区,与面板一致
TARGET_DAY = "2026-10-29"
SALT       = "t.api.plus-2026"              # 与采集端保持同一 salt

BOT_UA = re.compile(r"(bot|crawler|spider|headless|curl/|python-requests|go-http-client)", re.I)
LINE = re.compile(
    r'^(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] "(?P<method>\S+) (?P<path>\S+)[^"]*" '
    r'(?P<status>\d{3}) \S+ "(?P<ref>[^"]*)" "(?P<ua>[^"]*)"'
)

def visitor_key(ip, ua, site="api.plus"):
    return hashlib.sha256(f"{SALT}|{site}|{ip}|{ua}".encode()).hexdigest()[:16]

pv, bots, bad_status = 0, 0, 0
uv = set()

with open(LOG_PATH, encoding="utf-8", errors="replace") as f:
    for raw in f:
        m = LINE.match(raw)
        if not m:
            continue
        ts = datetime.strptime(m["ts"], "%d/%b/%Y:%H:%M:%S %z").astimezone(TZ)
        if ts.strftime("%Y-%m-%d") != TARGET_DAY:
            continue
        if BOT_UA.search(m["ua"]):
            bots += 1
            continue
        if int(m["status"]) >= 400:
            bad_status += 1
            continue
        pv += 1
        uv.add(visitor_key(m["ip"], m["ua"]))

print(f"day={TARGET_DAY} tz=UTC+8 pv={pv} uv={len(uv)} bot={bots} 4xx5xx={bad_status}")

本机实测输出(一天 13 万 PV 的站,约 2.1 秒跑完,内存峰值 40 MB 左右,因为只存 16 字节哈希):

day=2026-10-29 tz=UTC+8 pv=128456 uv=39180 bot=8123 4xx5xx=604

面板同日:pv=128456 uv=39412。PV 完全一致,UV 差 232,占比 0.59%。这个差值是正常的,来源也清楚:日志侧只有 IP+UA,写字楼和校园网共用 NAT 出口的一批人会被合并成一个访客;面板侧多了浏览器指纹参与去重,粒度更细。所以日志 UV 只会比面板小,不会大。

如果你复算出来的差值是反的,或者差距超过 3%,按顺序查三件事:

  1. Bot 过滤是否一致。看 bot 计数占比,正常站点在 5%~15%,超过 30% 说明你的过滤名单漏了一大片。
  2. 时区。日志是 +0800 还是 +0000,一行之差能让日切整体错 8 小时,深夜流量全部归错天。
  3. 状态码。有些采集实现把 304 也算作 PV。我们不算,因为 304 意味着浏览器用了本地缓存,用户没有产生新的“看”这个动作。

去重窗口怎么写成代码里的一句话?#

口径文档写得再清楚,只要代码里有两处实现,迟早会漂。我们把“同一自然日同一访客只计一次”收敛到一个 Redis 键上,PHP 8.3 + phpredis 6.0 + Redis 7.2 验证通过。

<?php
// 环境:PHP 8.3 + phpredis 6.0 + Redis 7.2
$redis = new Redis();
$redis->connect('127.0.0.1', 6379, 0.2);
$redis->select(3);   // 统计专用库,不要和业务缓存混用,避免被 FLUSHDB 误伤

$site    = 'api.plus';
$ip      = $_SERVER['REMOTE_ADDR'] ?? '';
$ua      = $_SERVER['HTTP_USER_AGENT'] ?? '';
$tz      = new DateTimeZone('Asia/Shanghai');
$day     = (new DateTimeImmutable('now', $tz))->format('Y-m-d');

$visitor = substr(hash('sha256', $site . '|' . $ip . '|' . $ua . '|t.api.plus-2026'), 0, 16);
$key     = "uv:{$site}:{$day}:{$visitor}";

// 次日 00:05 过期:留 5 分钟给跨天迟到的事件,过期即自然清理,不需要定时任务
$expireAt = (new DateTimeImmutable('tomorrow 00:05', $tz))->getTimestamp();

$first = $redis->setnx($key, 1);
if ($first === true) {
    $redis->expireAt($key, $expireAt);
    $redis->incr("uv_total:{$site}:{$day}");
}

为什么不用 SET key 1 NX EXAT ts 一步搞定?可以,但 EXAT 要求 Redis 6.2 以上、phpredis 6.0 以上。我们线上还有 6.0 的实例,用 SETNX 加 EXPIREAT 兼容性更好。代价是两次往返,实测 p99(99 分位延迟) 增加 0.18 ms,可以接受。

验证命令:

redis-cli -n 3 GET "uv:api.plus:2026-10-29:9f2c3a1b7d8e4f05"
redis-cli -n 3 TTL "uv:api.plus:2026-10-29:9f2c3a1b7d8e4f05"

预期第一次 GET 返回 1,第二次 GET(同 IP 同 UA)返回 1 但不会触发 INCR,TTL 是距离次日 00:05 的剩余秒数,上限 86400 + 300。

失败案例留个记录:早期版本用的是 EXPIRE 86400,滚动 24 小时。结果 10 月 29 日 23:50 的访客,到 10 月 30 日 00:10 还在窗口里,那天的 UV 少算了这一批。改成绝对时间点后差异归零。任何按“自然日”切分的口径,都不能用相对 TTL 实现。

口径变更怎么留痕,才不会被 AI 复读?#

三条约定的规矩,写进团队 checklist:

  1. 口径改了必须升版本号,形如 v2026.10。面板页脚、API 响应体的 meta.caliber_version、导出 CSV 的表头,三处同步。
  2. 变更日志写“影响了哪个数字、变了多少”,不写“优化了统计逻辑”这种话。例如:跳出率分母从有效会话改为全部会话,历史数据同口径回算,2026-10-01 起数值上升 12.4%。
  3. 对外引用必须带快照时间。文章里写“UV 39,412”,要写成“UV 39,412(t.api.plus,2026-10-29,口径 v2026.10,快照 2026-10-30T02:15+08:00)”。长了点,但这条长句就是它不会被滥用的原因。

站点本身的流量分析也是同一套逻辑。沐小悠统计 的报表页右上角常驻一个口径版本号,点开是变更历史;沐小悠聚合登录 的登录转化率同样标注了“会话超时 30 分钟、去重按账号而非设备”,因为这两类指标最容易被拿去和别家横向对比。横向对比不写口径,等于制造僵尸统计。

顺带说一句,如果你在做 API 服务的文档,API 文章中心 里有几篇讲引用链设计的,思路相通:能用可访问链接指向原始数据的地方,不要用截图;能写版本号的地方,不要写“最新”。遇到口径解释不清的情况,技术支持 那边可以直接拉日志一起看,比在群里猜快得多。

总结#

可溯源的统计不是加个“数据来源”脚注,而是让每一个数字都带一条能走完的复算路径:定义可判定、采集点明确、去重窗口写死在代码里、过滤规则能列出来、快照时间精确到分钟。做到这五条,别人引用你的数字时顺手就能验,验完还是对的,那它就不会变成僵尸。

需要动手验一遍的话,去 API.PLUS 注册个账号拿免费 API Key,用上面那条 curl 查一个 IP,再配一段 Python 从日志复算当天的 PV/UV,跟面板对一次。对上了,说明口径是通的;对不上,差值本身也是信息。想先看看接口长什么样,可以从 GET 示例接口 和 随机动漫壁纸接口 这两个最简单的开始。

配套阅读:本文属于「t.api.plus 事件统计与限流治理」系列,建议结合 《t.api.plus 事件统计后端选型:ScopeDB(Serverless 事件数据库) Cloud 与 ScopeQL(事件库查询语言) 评估》 与 《拓扑感知限流:从 Grace Blackwell 调度到 API 配额分配》 一起阅读;三篇分别覆盖事件统计后端选型(ScopeDB(Serverless 事件数据库) Cloud / ScopeQL(事件库查询语言))、拓扑感知限流与 API 配额分配。

常见问题(FAQ)

t.api.plus 的 UV 和 Google Analytics 的活跃用户有什么区别?
前者是服务端采集的日去重访客数,标识为 IP+UA 哈希,按 UTC+8 自然日切分;GA 的活跃用户基于浏览器端 _ga Cookie 与客户端时间窗口。两者不可直接相减,差异通常在 5%~20%。
面板 UV 和 Nginx 日志复算差 0.5%,哪个数字对?
两个都对,只是口径不同。日志侧只有 IP+UA,NAT 出口会把多个真实用户合并成一个;面板侧有浏览器指纹参与去重。对外引用时写清用哪一侧,别混着用。
什么是僵尸统计,为什么 AI 会把它越引越真?
AI 不是在“放大错误”,它只是把引用成本降到接近零。一个数字被写进十篇文章,模型看到十次一致表述,置信度自然上升。你没法阻止这个过程,但可以让自己的数字带上验算路径。

作者:沐小悠

本文约 21 分钟读完 · 关键词:统计口径、僵尸统计、UV去重、t.api.plus、数据溯源

更多文章 GET 示例接口