前言
ByteFisher 博客第一期优化上线了热门文章、AI 助手、PWA,版面上看起来像模像样了。但有样东西在第一期优化中一直没动——页脚的站点访问计数(UV/PV)。
之前用的是不蒜子(busuanzi),一行 <script> 搞定。但不蒜子近几年稳定性堪忧——经常报 503,而且不蒜子是闭源的,数据存在别人服务器上,哪天服务挂了或者维护了,计数器直接就没影了。
换 LeanCloud 呢?数据在自己手里,但国际版延迟高(我在亚洲,请求绕去美西),国内版要求域名已备案。而且 LeanCloud 免费额度很紧。
数据量又不高——一个个人博客能有多少访问?三个月几千 PV 撑死了。为这点数据上付费方案有点杀鸡用牛刀。
所以决定自建。用 TiDB Serverless 的免费额度做数据库,Vercel 跑云函数,GitHub Actions 构建时生成静态快照兜底,前端用智能阶梯加载策略。听起来有点重,但实际落地之后发现——每个环节刚好是能力边界内的最优解。
这套系统涉及 3 个后端文件、1 个前端统计脚本、1 个 Hexo Generator、1 个数据库配置模块,完整数据链路从浏览器到 TiDB 再到用户屏幕,全程可控。
一、方案选型:为什么不直接用现成的
在决定自建之前,系统性地评估了市面上能用的方案:
不蒜子——用户量最大的博客计数方案。一行脚本接入,但:
- 闭源黑盒,数据挂在别人服务器上
- 近半年来频繁 503,响应时间超过 5 秒
- 没有数据导出接口,哪天想迁移只能清零重来
LeanCloud——曾经是 Hexo/NexT 生态的标准方案,但现在:
- 国际版延迟 300ms-800ms,从国内访问体验很差
- 国内版要求已备案域名,个人博客不一定满足
- 免费额度很紧张,超出后要付费
Waline 自带计数——已经在用 Waline 做评论系统,它自带了文章阅读量统计。但阅读量是单篇文章维度的,不能提供站点级别的 UV/PV 汇总,而且数据耦合在评论系统里,耦合度过高。
Google Analytics / 百度统计——数据分析平台,但不适合做页脚的实时计数展示。API 需要 OAuth,小体量的站点计数走这套流程太重了。
Build-time 静态方案——有人用构建时从 API 拉取计数值写入 HTML,但这样的话每次部署才会更新,丢失了”实时感”。
综合评估下来,自建是最合适的。核心权衡点:
| 方案 | 数据可控 | 实时性 | 维护成本 | 费用 |
|---|---|---|---|---|
| 不蒜子 | ❌ | ✅ | 零 | 免费 |
| LeanCloud | ✅ | ❌ | 低 | 有限免费 |
| GA/百度统计 | ❌ | ✅ | 低 | 免费 |
| 自建 TiDB + Vercel | ✅ | ✅ | 中 | 免费 |
自建的费用基本为零:TiDB Serverless 提供 5GB 行存 + 5GB 列存 + 5000 万行读取/月,对个人博客来说用不完。Vercel Hobby 计划免费 100 小时/月函数执行时间。唯一要付的是一年几十块的域名 + Cloudflare DNS。
二、数据链路设计
整套系统的数据流动分两条路径:一次是”读”(展示),一次是”写”(记录)。
展示链路(读)
1 | 用户访问页面 |
三层阶梯:内存 > 本地缓存 > 构建快照 > 实时 API,每层都有对应的降级逻辑,确保用户永远能第一时间看到数字,而不是等待网络请求完成才渲染。
记录链路(写)
1 | 页面加载 → 生成访客 ID + 浏览 ID |
事件上报用三种方法依次降级:<img> beacon → fetch(no-cors) → sendBeacon。这是经历了移动端踩坑后形成的最终方案,后面会专门讲。
三、数据库设计:三张表
数据库用了 TiDB Serverless,三张表加起来不到 100 行 SQL。
bytefisher_site_stats —— 汇总表
1 | CREATE TABLE bytefisher_site_stats ( |
就一行数据,id = 1,记录了站点总的 PV 和 UV。每次事件命中后用 ON DUPLICATE KEY UPDATE 递增。
因为 TiDB 兼容 MySQL 协议,直接用 mysql2/promise 连接:
1 | // shared/site-stats-db.js |
bytefisher_site_visitors —— 访客表
1 | CREATE TABLE bytefisher_site_visitors ( |
访客 ID 是前端生成的一个 32 位 UUID,存在 localStorage 里。UV 去重的关键在这里:利用了 ON DUPLICATE KEY UPDATE 来判断是否为新访客:
1 | const [result] = await conn.execute( |
bytefisher_site_views —— 浏览明细表
1 | CREATE TABLE bytefisher_site_views ( |
每条浏览记录有一个唯一的 view_id,同样是前端生成的。用 INSERT IGNORE 来保证一条浏览记录只被统计一次。即使用户刷新页面或者脚本重复触发了上报,也不会被重复计数。
这里有一个设计细节值得多说两句:为什么不直接用 IP + UserAgent 做 UV 去重?
IP 去重在移动端是完全不可靠的——手机切换 WiFi/4G/5G 时 IP 会变,同一运营商 NAT 下多个用户共享同一个 IP。用前端 UUID 虽然可以被清除(清除 localStorage 就是新访客),但在个人博客这种数据精度要求不高的场景下已经足够了。我们没有做任何设备指纹追踪,访客 ID 就是一个 UUID。
四、服务端:两个 Vercel Function
所有代码都在 api/ 目录下,部署到 Vercel 自动识别为 Serverless Function。
GET api/site-stats.js —— 查询实时计数
1 | // api/site-stats.js |
缓存策略值得注意:max-age=30 表示浏览器缓存 30 秒,s-maxage=60 表示 CDN 缓存 60 秒,stale-while-revalidate=600 表示在 600 秒内可以用过期缓存同时在后台刷新。这是降低函数调用次数的关键——Vercel 的免费额度 100 小时/月,不加 CDN 缓存很快就会用光。
POST api/site-stats-event.js —— 接收访问事件
这个函数完整走了一个数据库事务:
- 校验请求来源(CORS + Origin 白名单)
- 校验参数(访客 ID + 浏览 ID 的格式正则)
- 写入
bytefisher_site_visitors(ON DUPLICATE KEY → 判断是否新访客) - 写入
bytefisher_site_views(INSERT IGNORE → 防重放) - 递增
bytefisher_site_stats - 返回最新计数
如果在任何一步出错,事务回滚,客户端不会感知到错误——前端有自己的展示兜底,不需要依赖响应数据。
1 | // api/site-stats-event.js —— 事务核心 |
五、客户端:site-stats.js 的设计策略
前端统计脚本 source/js/site-stats.js 是整个系统里最绕的一块。它要兼顾性能、准确性和健壮性,涉及几个关键设计决策。
5.1 展示优先
页脚初始显示 -,脚本加载后立即展示缓存数据(如果有),而不是等网络请求回来再渲染。
1 | function loadPageStats() { |
5.2 乐观递增
上报事件后不等待服务器响应,立刻在本地把计数 +1,让用户看到即时变化。
1 | var pendingPv = 0; |
5.3 事件串行队列
页面中可能存在多个并发的 postVisitEvent() 调用(PJAX 切换、自动刷新等)。用 Promise 链串行执行,避免并发请求导致服务端竞态:
1 | var eventQueue = Promise.resolve(); |
5.4 缓存与自动刷新
localStorage 缓存设置 10 分钟 TTL。每 10 分钟自动拉取一次最新数据:
1 | var CACHE_TTL_MS = 10 * 60 * 1000; |
pendingPv === 0 检查确保在有未确认的事件时不去刷新,避免 pending 计数和刷新回来的数据打架。
六、构建时快照:静态站最后的倔强
静态博客的弱点是”没有后端”。如果你的统计 API 挂了,用户端应该怎么展示?
不蒜子的做法是:挂了就是挂了,页脚显示 -。而我们的做法是:在构建阶段就去 TiDB 拉取一次最新计数,生成一个静态 JSON 文件,随博客部署到 GitHub Pages。
1 | // scripts/generators/site-stats-snapshot.js |
构建时快照 + localStorage 缓存 + 实时 API——三层保障。即使实时 API 挂了,用户看到的也是构建时刻的数据,最多落后几个小时,而不会显示 -。
更关键的是基线值(BASELINE_PV = 11200, BASELINE_UV = 4400)的存在。即使 TiDB 在构建时也不可用,也能输出一个合理的数字,确保计数器在不同构建版本间不会跳变回 0。
七、移动端不更新的踩坑与修复
讲了那么多设计,真正的故事在这里。
问题现象
系统上线后,桌面端一切正常。但某天用手机浏览器打开博客,发现页脚的计数器始终是构建时的快照值,访问了几次都不涨。换了几款浏览器——Chrome Android 正常,但 QQ 浏览器、UC 浏览器、手机百度——全都不涨。
排查过程
一开始怀疑是 CORS 问题。检查了 Vercel 函数的 CORS 头配置:
1 | // api/site-stats-event.js |
SITE_ORIGIN 是 https://www.bytefisher.top,桌面端正常工作也证明了 CORS 配置没问题。
用抓包工具看了手机浏览器的请求,发现了一个现象:客户端根本没有发出 POST 请求到 api.bytefisher.top。不是服务器拒绝了,而是浏览器压根没发。
又测试了 fetch()、sendBeacon() 和 XMLHttpRequest——在 QQ 浏览器和 UC 浏览器上,这三种方式对跨域域名全都静默拦截。国内手机浏览器的”安全防护”功能会默认阻止对未归类域名的跨域请求,而且没有任何用户提示。
修复方案
修复思路很直接:找一个浏览器不拦截的数据上报方式。
研究了一圈发现,图片加载(<img> 标签)是所有浏览器里最底层的网络能力。国内手机浏览器的安全机制主要拦截的是 script/fetch/XHR,而 <img> 被认为是无害内容。
第一层:改用 <img> 标签上报
客户端生成一个 GET 请求 URL,把事件参数放在 query 里,新建一个 Image 对象:
1 | function imgBeacon(event) { |
服务器端新增一个 GET 端点来处理图片上报,返回 1×1 透明 GIF:
1 | // api/site-stats-event.js GET handler |
第二层:fetch(no-cors) 兜底
如果 <img> 上报失败(ad-blocker 拦截),降级到 fetch() 的 no-cors 模式:
1 | function sendVisitEvent(event) { |
mode: 'no-cors' 会发送一个”不透明请求”——浏览器仍然发送请求到服务器,服务器仍然能处理,但浏览器隐藏了响应内容。对我们来说够用了:事件的”记录”比”确认”更重要,况且后面还有 refreshStats 去拉最新值。
效果验证
修复后在多款手机上验证:
| 浏览器 | img beacon | fetch(no-cors) | 计数更新 |
|---|---|---|---|
| Chrome Android | ✅ | ✅ | ✅ |
| Safari iOS | ✅ | ✅ | ✅ |
| 微信内置浏览器 | ✅ | ❌ | ✅ |
| QQ 浏览器 | ✅ | ❌ | ✅ |
| UC 浏览器 | ✅ | ❌ | ✅ |
| 手机百度 | ✅ | ❌ | ✅ |
关键结论:<img> 标签在所有测试的浏览器中都能正常上报。用最”古老”的方式解决了最”现代”的问题。
八、总结
这套自建计数系统从最初设计到最终落地,有几个经验值得记录:
1. 静态站的灵魂是”预生成”
Hexo Generator 不只用来生成页面,还可以生成 JSON 索引(给 AI 助手用)、计数快照、站点地图。把运行时依赖降到最低,静态站才能真正”静态”。
2. 前端上报的降级策略比主策略更重要
三层降级就够了:img → fetch(no-cors) → sendBeacon。三层全部失败的概率极低。
3. 手机兼容性是”对抗”不是”适配”
桌面端 API 调不通最多是网络问题,而国内手机浏览器是主动拦截跨域请求。这种情况下不能用”标准方案”去适配,得从浏览器底层能力去找突破口。从 fetch 退到 <img>,本质上是沿着浏览器安全模型往上回溯——找到那个不会被封锁的能力层。
4. 数据链路的每层都要有”好”的默认值
基线值 11200/4400 的作用很大:在 TiDB 不可用、缓存过期、快照未生成的多重故障叠加时,用户看到的不是 - 或 0,而是一个接近真实的数字。给用户展示一个”差不多对”的数字,比展示”错了”或者”没有”要好得多。
当前系统运行状态:
- TiDB 数据量:~4400 UV / ~11200 PV(基线以上持续增长中)
- API 响应时间:平均 120ms(Vercel 新加坡节点 + TiDB Serverless)
- 构建时快照更新频率:随 GitHub Actions 每日两次部署
- 缓存命中率:前端 10 分钟缓存 + CDN 60s + 浏览器 30s
📖 查看 热门文章排行榜
🔧 完整代码见source/js/site-stats.js|api/site-stats*.js|scripts/generators/site-stats-snapshot.js
🎣 访问 钓鱼相册 看看新鱼获