ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

静态博客自建访问量统计:从零部署轻量busuanzi计数服务

静态博客自建访问量统计:从零部署轻量busuanzi计数服务 我博客最早是放在 GitHub Pages 上的Hexo 搭的每次想给文章加一个看过多少人的小计数器就得上网找方案。市面上的统计工具不少但大多数要么重一套 Matomo 跑起来能把 1G 内存的小机器拖到喘气要么数据完全在别人手里——你根本不知道它哪天接口挂掉、哪天改了返回格式。后来我干脆自己动手把不蒜子busuanzi这套极轻量的统计服务在自己服务器上跑了一份前端依然是经典的span 一小段脚本但后端从请求别人的接口变成了请求自己的接口数据、稳定性、口径全都自己说了算。这篇文章就把自建 busuanzi 的完整过程拆开讲从它到底怎么数数到服务端怎么部署到静态博客怎么接再到上线之后怎么防刷、备份、监控。适合这么几个人用 Hexo/Hugo/VuePress/Astro 等静态博客、不想把统计托管给第三方、手上有一台哪怕只有 1G 内存的云主机、想彻底掌控自己数据的人。没什么高深理论都是实操你照着做基本能跑通。1. 静态博客做访问量统计先想明白难点在哪1.1 没有后端统计这件事天然被卡住静态博客的本质是一堆 HTML/CSS/JS 文件部署到 GitHub Pages、Gitee Pages、Cloudflare Pages 或者对象存储上之后你的网站并没有一个可以执行代码的服务端。没有服务端就没法读写数据库没法按请求动态生成 HTML更没法记录刚才谁访问了哪一页。这不是加一段 JS 就能解决的事。前端脚本能发送请求但它总得发给某个服务端去处理、去存储。于是问题就变成了这个某个服务端是谁的用 Google Analytics / 百度统计 / 腾讯分析别人的服务端你往里面塞一段几十 KB 的脚本数据全部托管在第三方。页面加载会因此变重而且你拿不到干净、可导出的原始日志。看 Web 服务器日志但 GitHub Pages 这类托管平台根本不给你 SSH 权限也不提供 raw access log这条路直接断了。自托管 Matomo / Umami功能全能做行为分析、来源分析但它们的部署对机器配置和数据库都有要求。为一个每篇文章展示几个数字的需求显然杀鸡用了牛刀。这就是静态博客统计的尴尬你只想要一个计数器但市面上的重型方案给你一座钟楼。1.2 busuanzi 在这个位置上的独特优势busuanzi不蒜子的定位很清楚只管展示页面访问量。它不做用户行为分析、不做来源归因、不画漏斗后端就是收到请求 - 在 Redis 里做累加 - 返回 JSON前端就是发一次请求 - 把数字填进 DOM。整个链路非常薄静态博客只要引入一段外部脚本就行。官方提供的公共接口当然能用但长期依赖它有几点我比较在意数据在第三方手里哪天服务方停止维护所有历史数据都拿不回来。公共接口面向全国用户高峰期响应速度没有保证偶尔还会出现请求超时直接导致页面上数字一直显示成加载失败。你完全无法自定义统计口径。比如我想只看中国大陆 IP、过滤掉我自己博客后台预览的访问、按年月归档导出数据官方接口都不支持。所以自建 busuanzi 的核心动机就一句话我要一个足够轻的计数器而且它的数据、口径、寿命都在自己手里。1.3 先列清楚自建的收益和成本自建不是零成本的至少你得有成本项说明一台云主机1 核 1G 内存就够内存大头其实是 Redis 而不是 busuanzi 本身一个域名给统计 API 用子域名比如analytics.example.com方便以后随时迁移、加 HTTPS一点维护精力要处理证书续期、Redis 持久化、防刷策略这些事收益则是数据完全私有、接口响应时间可控大多时候比第三方更快、统计口径由你说了算而且因为协议极简单以后想导出、迁移、备份都非常方便。我自己的体会是只要熬过第一次搭建后续的维护成本几乎可以忽略不计。2. 不蒜子是怎么数数的协议拆解与自建可行性2.1 一次请求背后发生了什么busuanzi 的协议并不复杂前端脚本大致会发起这样一个请求GET /busuanzi Referer: https://blog.example.com/post/hello-world服务端拿到请求后从Referer里解析出站点域名和页面路径然后在 Redis 里做几件事site_pv站点总访问次数加 1如果页面路径非空page_pv当前文章访问次数加 1根据客户端标识判断是不是新访客如果是site_uv站点访客数加 1。最后返回一个 JSON{ site_uv: 12345, site_pv: 67890, page_pv: 123 }前端要做的事情更简单把 JSON 里的数字分别填进页面里那几个固定span的textContent里。整个流程没有 cookie 追踪没有跨屏指纹没有服务端渲染就是一个最朴素的计数器。2.2 UV 去重是怎么做的这里有个值得展开的点site_uv的唯一访客到底靠什么识别公共接口的方案通常靠 IP UserAgent 做近似去重。但这不精确同一公司出口 IP 下的人会被算成一个访问者而 NAT 环境下重新拨号又可能把同一个人算成多个人。自建之后你可以改善这一点前端在本地生成一个随机 UUID 存到localStorage请求时带给服务端服务端以日期, UUID为键做去重判断如果键不存在说明今天是新访客site_uv加 1。这是自建最大的自由——去重算法由你自己定准不准你自己心里有数。我实际用的是UUID 优先退化为 IP UA的双层方案。多数正常浏览器都支持localStorage只有极少数禁用存储的旧设备会退化为近似识别。2.3 自建方案的选型对比动手之前先看后端选型。社区里已经有不少 busuanzi 的开源实现主要分这么几类方案技术栈优点缺点官方服务PHP Redis零部署不满足自建诉求Go 版开源实现Go Redis单二进制部署、并发能力强、内存占用低需要自己跑 RedisNode.js 版实现Node Redis生态熟悉、改起来方便内存占用略高、线上进程管理麻烦些Nginx LuaOpenResty无需独立应用进程直接挂在已有 Nginx 上门槛高Lua 脚本出错排查成本大Cloudflare Workers KVJS 边缘函数不用买云主机天然支持 HTTPS免费额度有写入限制KV 有最终一致性时延不适合高并发我的选择是 Go 版 Redis跑在 Docker 里。理由很简单静态博客的博客量本来不大Go 二进制在 1G 内存的机器上运行毫无压力Redis 做 INCR 累加是原子操作不存在并发竞争问题Docker 化之后迁移、备份、回滚都很省心。如果你不想引独立后端进程OpenResty 方案也很优雅把计数逻辑直接塞进已有的 Web 服务器少一个端口就少一层暴露面。只是 Lua 的逻辑一旦复杂起来排查问题没有 Golang 那边看日志舒服。3. 自建落地VPS 上把后端跑起来3.1 准备工作一台小机器和一个子域名先说机器。如果你博客本来就部署在自己的服务器上直接在这台机器上增加两个服务Redis busuanzi 后端就行了不用新买机器。如果博客托管在 GitHub Pages你就需要一台能对外提供 HTTP 服务的云主机配置最低 1 核 1G 就够后面讲到监控会发现这个配置跑 Redis 和 Go 进程都很宽裕。然后是域名。我的建议是单独划一个子域名给统计接口例如analytics.example.com。原因有两个主站域名是你博客的门面以后想迁移博客到别的托管商别让统计接口域名跟着一起搬家反过来统计接口出问题也不影响主站。子域名可以单独配置 CORS 白名单、单独签发证书以后想给另一个站也用同一套统计服务再加白名单就行。把analytics.example.com解析到你的云主机公网 IP 上。3.2 Docker Compose 跑起 Redis 和 Go 后端在服务器上装好 Docker 和 Docker Compose 插件然后新建一个目录比如/opt/busuanzi在里面放一个docker-compose.yml。一个典型的配置长这样以社区常见的 Go 版实现为例具体镜像名请以你选择的项目 README 为准version: 3.8 services: redis: image: redis:7-alpine container_name: busuanzi-redis restart: unless-stopped command: redis-server --appendonly yes volumes: - ./data/redis:/data networks: - busuanzi-net busuanzi: image: ghcr.io/your-name/busuanzi:latest container_name: busuanzi-server restart: unless-stopped depends_on: - redis environment: - BZ_REDIS_ADDRredis:6379 - BZ_REDIS_PASSWORD - BZ_LISTEN_ADDR:8080 - BZ_ALLOWED_ORIGINShttps://blog.example.com,https://www.example.com ports: - 127.0.0.1:8080:8080 networks: - busuanzi-net networks: busuanzi-net: driver: bridge里面几个环境变量我要解释一下BZ_REDIS_ADDRGo 后端连 Redis 的地址写成 compose 服务名redis:6379Docker 内部网络会解析到 Redis 容器。BZ_LISTEN_ADDRGo 后端监听的地址和端口这里监听:8080。BZ_ALLOWED_ORIGINSCORS 白名单只允许你的博客域名跨域访问别的站点直接拒绝。这个一定要配好不然别人知道了你接口地址往上面随便刷请求你的计数就全脏了。启动cd /opt/busuanzi docker compose up -d docker compose ps看到两个容器都处于Up状态就说明服务端基本起来了。注意第一步就该把 Redis 的持久化打开。我在command里加了--appendonly yes对应 AOF 持久化。静态博客的 PV/UV 数据都是长期积累的资产我只怕 VPS 重启一次数据全部清零这个坑踩过的人都知道。3.3 用 curl 验证统计接口没配 Nginx 之前我们可以直接走后端端口测试。本地执行curl -H Referer: https://blog.example.com/post/hello \ -H Origin: https://blog.example.com \ http://127.0.0.1:8080/busuanzi正常情况下会返回类似这样的 JSON{site_uv:1,site_pv:2,page_pv:1}再请求几次观察site_pv和page_pv的变化确认累加逻辑是通的。到这一步后端已经能数数了但还不能让浏览器直接访问——我们缺 HTTPS。3.4 Nginx 反向代理与 HTTPS 配置前端页面部署在 HTTPS 环境下而现代浏览器对HTTPS 页面请求 HTTP 接口默认拦截混合内容拦截所以统计接口必须走 HTTPS。我直接给子域名签发 Lets Encrypt 证书用 Nginx 做反向代理。Nginx 核心配置如下server { listen 443 ssl http2; server_name analytics.example.com; ssl_certificate /etc/letsencrypt/live/analytics.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/analytics.example.com/privkey.pem; # 跨域头只允许自己的博客域名 add_header Access-Control-Allow-Origin https://blog.example.com always; add_header Access-Control-Allow-Methods GET, OPTIONS always; add_header Access-Control-Allow-Headers Origin, X-Requested-With, Content-Type, Accept always; # 统计接口响应绝不能进浏览器缓存或 CDN 缓存 add_header Cache-Control no-store always; location /busuanzi { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 处理 CORS 预检请求 if ($request_method OPTIONS) { return 204; } }配置里最容易被忽视的是no-store。统计接口每次返回的数字都在变一旦 Nginx 或者浏览器缓存了上一次的响应后续所有 PV 都不会累加页面上的数字就会永远停留在一个值上这是自建统计最容易犯的错。配好后别忘了nginx -t systemctl reload nginx然后用curl https://analytics.example.com/busuanzi再验证一次。证书签发用 certbot 一把梭即可apt install certbot python3-certbot-nginx certbot --nginx -d analytics.example.com4. 让静态博客接上自己家的计数器4.1 主题自带的不蒜子开关先关掉很多 Hexo 主题Next、Butterfly 等都内建了 busuanzi 支持默认加载的是官方公共接口脚本。如果你直接复用主题开关同时又引一份自己的脚本页面上的计数器会出现官方数字 自建数字的叠加而你根本无法分辨哪个数字是谁产生的。所以接入自建后端的第一件事去主题配置文件里把 busuanzi 相关开关全部关掉然后在自己的主题模板里显式写入新的调用代码。以 Next 主题为例通常是在_config.yml里找busuanzi_count: enable: false4.2 一段自带计数的挂载脚本在主题里找一个所有页面都会加载的地方一般是footer.pug或footer.ejs插入下面这段。以我自己接 VuePress 时的写法为例逻辑完全一样只是模板语法不同div classsite-info 本站总访问量 span idsite_pv--/span 次 本站访客数 span idsite_uv--/span 人 本文阅读量 span idpage_pv--/span 次。 /div script (function () { var api https://analytics.example.com/busuanzi; // 生成或读取本地访客 ID用于 UV 去重 var visitorKey busuanzi_visitor_id; var visitorId localStorage.getItem(visitorKey); if (!visitorId) { visitorId v_ Date.now().toString(36) _ Math.random().toString(36).slice(2, 10); localStorage.setItem(visitorKey, visitorId); } var params new URLSearchParams({ v: visitorId, p: location.pathname // 以页面路径作为“当前文章”的统计维度 }); var ready function (fn) { if (document.readyState loading) { document.addEventListener(DOMContentLoaded, fn); } else { fn(); } }; ready(function () { fetch(api ? params.toString(), { credentials: omit }) .then(function (r) { return r.json(); }) .then(function (data) { var pvEl document.getElementById(site_pv); var uvEl document.getElementById(site_uv); var pageEl document.getElementById(page_pv); if (pvEl) pvEl.textContent data.site_pv || 0; if (uvEl) uvEl.textContent data.site_uv || 0; if (pageEl) pageEl.textContent data.page_pv || 0; }) .catch(function () { // 统计失败不能影响页面正常展示静默降级 }); }); })(); /script这段逻辑不复杂但有三个细节值得说明访客 ID 用localStorage生成而不是每次页面刷新都随机生成。这保证了同一个浏览器今天打开十篇文章你只被记成一个访客而不是十个。页面路径location.pathname作为 page 维度的 key如果你的博客部署在子目录比如https://example.com/blog/建议改用location.pathname并自行拼接确保多篇不同文章不会共用同一个计数。延迟到 DOMContentLoaded 之后再 fetch计数器不影响页面的 LCP。统计脚本失败时静默降级绝不能让页面上出现报错或者一直显示加载中。4.3 处理 SPA 和 PJAX 场景下的刷新问题Hexo 的默认渲染是经典的多页面模式每次点击文章都是一次整页刷新脚本每次都会重新执行所以上面这段代码没问题。但如果你的博客用了 PJAX比如很多 Next 主题开着 PJAX 模式或者干脆是 VuePress/Astro 这类 SPA切换路由时并不会重新加载整个页面脚本只会在第一次进入时执行一次之后路由切换了页面上的数字不会跟着变。解决办法有两个在路由切换事件里重新调用一次update()函数把逻辑抽成独立的函数方便事件钩子调用。直接在visibilitychange事件里监听当页面从后台切回前台时重新请求一次顺手还能保证离开一段时间再回来时数字变准确。以 VuePress 为例最简单粗暴的方式是在mounted钩子里注册router.afterEachrouter.afterEach(function () { window.__updateBusuanzi window.__updateBusuanzi(); });原理不复杂把所有刷新逻辑封装成全局函数window.__updateBusuanzi路由每次变化后调用一次相当于手动触发了原本应该在整页加载时执行的逻辑。5. 上线之后的三场硬仗防刷、数据安全与监控5.1 防刷一定要在服务端做限流和过滤自建接口一旦暴露在公网一定会被各种扫描器、爬虫、监控探针访问到。我自己上线第一天就发现 Redis 里多了一批奇怪的键一看是某个目录扫描工具在遍历我的analytics.example.com。防刷我做了三层第一层UA 过滤。常见的空 UA、Python Requests、curl、Go-http-client 直接过滤掉。这些流量大概率不是真实访客对统计没有任何参考价值。第二层IP 限流。同一个 IP 在一分钟内的请求次数限制在某个阈值比如 30 次超出后直接返回 429。正常访客一分钟撑死请求几次页面这个阈值不会误伤。第三层CORS 白名单。只有你登记的域名能跨域调用接口其他来源一律无权限。接口在浏览器里跨域会被拦截但小心curl 这类工具不检查 CORS所以这一层在防浏览器偷用上有用并不能阻止恶意脚本直接打你的接口。如果你认真排查还可以在 Nginx 层直接 deny 掉不想要的高频 IP。但我的经验是IP 黑名单优于在应用层做 regex 过滤因为应用层拿到的是代理后的 IP容易误判。5.2 Redis 备份与数据迁移确保计数不归零Redis 持久化是数据安全的底线。除了 AOF我还建议每天凌晨用BGSAVE生成 RDB 快照然后把它同步到对象存储或者别的服务器。简单脚本如下#!/bin/bash docker exec busuanzi-redis redis-cli BGSAVE /dev/null 21 sleep 2 cp /opt/busuanzi/data/redis/dump.rdb /backup/busuanzi/dump_$(date %F).rdb万一服务器挂掉、磁盘损坏、或者你想把统计服务迁移到另一台机器直接把最新的 dump 拷过去再启动 Redis 就行。这套迁移流程我实测过非常无脑停服务 - 拷 rdb - 启动新容器 - 验证接口返回的历史数字还在完事。5.3 加一层简单的可用性监控计数器这种服务平时没人注意一旦挂了页面角落就会显示--但等到访客提醒你你的计数器不显示了通常已经挂了很久。所以一个最基本的探活很有必要。我没上重型监控就用 crontab curl 写了个死循环式的检查*/5 * * * * curl -fsS -m 10 https://analytics.example.com/busuanzi /dev/null || curl -fsS -m 10 https://api.dayu.qq.com/xxx /dev/null || echo busuanzi down后来发现 Shell 里写告警太粗糙就换成了 UptimeRobot 这类公共监控服务每隔 5 分钟请求一次你的统计接口挂了就邮件通知。公共监控服务的好处是它们从多个地区探测能发现接口没挂但某地访问不通的网络问题。你自己在服务器本地探测反而容易自我欺骗。再往下进阶有精力的人可以给服务器装prometheus/node_exporter把 Redis 的 key 数量、Go 进程的内存、容器存活状态接进 Grafana。老实说这个体量的服务不需要这么重的监控我后来只保留了 UptimeRobot 定期备份两件事图个省心。5.4 关于隐私做一个清白的计数器自建 busuanzi 最大的卖点除了数据自主还有一个很多人没意识到的优势它能做到真正的最小化统计。官方公共脚本会不会额外采集点东西你没法验证。但自建之后整个请求路径上的代码都是你自己的请求里不采集浏览器指纹、不读取 cookie、不发送 referrer连访客 ID 都是随机生成的 UUID 存到localStorage服务端只保留不能反推个人身份的计数聚合值。你甚至可以在页面的隐私说明里写清楚本站统计系统不采集任何个人信息。这一点在这个越来越在意数据合规的环境里很重要。我在自己博客页脚加了一行小字访客统计仅计数本站访问量与文章阅读次数不追踪个人身份信息。既对访客透明也让自己睡得安心。如果你实在连localStorage都不想要那 UV 去重只能退化为 IP 近似但会给单位网络下的访客带来严重的被合并问题。我的建议是保留localStorage它并不算侵犯隐私——它只是存储一个随机 ID不会访问你任何真实身份信息。最后说点实操体会自建 busuanzi 这个坑我前前后后跳了快两周现在回头想最值钱的经验反而是几条小到不能再小的细节。比如 Nginx 缓存导致的计数不更新比任何原理性难题都更容易让人抓狂——你怀疑后端、怀疑脚本、怀疑 Redis 配置最后发现是响应头少了no-store。再比如 Redis 忘开持久化VPS 一重启一万多的site_uv直接回到 1那种感觉就像辛苦攒的里程数被清零。如果你决定自建我的建议是第一次搭建时就把备份、监控、防刷这三件事和部署一起做完而不是上线后再补。静态博客的访问量统计看起来是个小功能但它牵扯到 HTTP、跨域、缓存、持久化、安全策略一整套链路非常锻炼人。而且一旦你把这套链路打通了以后给博客接评论系统、接入站搜索、加个文章热榜都会觉得顺理成章。最后再分享一个小技巧在 Nginx 配置里把analytics.example.com的 access log 关掉。统计接口本身就是高频率的小请求每天刷出来几 MB 的日志里面全是无意义的/busuanzi记录纯属浪费磁盘和 IOlocation /busuanzi { access_log off; proxy_pass http://127.0.0.1:8080; }这条配置我当初忘了写结果一台 40G 的机器光统计接口的访问日志一个月吃掉了 2G 空间。工具本身是轻量的但如果我们不把外围这些细节收拾干净轻量也会被慢慢拖成重量。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进