☰
自建Busuanzi后端:静态博客访问量统计的完整实现
2026/10/4 13:08:06 网站建设 项目流程

1. 静态博客做访问量统计,为什么这么费劲

用静态博客的朋友都清楚,Gridea、Hexo、Hugo这一类站点生成器产出的就是一堆纯HTML、CSS、JS文件,扔到GitHub Pages、Gitee Pages或者自己的Nginx上就能跑。好处是快、省资源、不怕被黑,但代价也很直接——没有后端,没有数据库,连个最基本的访问计数都做不了。

早期大家想统计访问量,普遍靠第三方方案,比如LeanCloud、Firebase这类BaaS服务,或者直接挂个不蒜子、友链那种公共统计接口。BaaS的问题是配置复杂,动不动就要建Class、设权限、填AppID,很多人折腾半天最后还是放弃了。而公共统计接口的好处是开箱即用,但也有隐患:毕竟是别人家的服务,哪天域名换掉、接口升级、服务关停,你的博客数据说没就没,而且大多数公共节点在国内访问并不稳定。

这就是我决定自己折腾一套busuanzi后端的原因。busuanzi,也叫“不蒜子”,本身是一个极简的统计方案,页面上引两行JS就能记录页面浏览量(PV)和访客数(UV)。我做的不只是“用”,而是把它的服务端逻辑完整跑起来,自己掌控数据。这篇文章就把整个思路、代码、部署和踩坑过程完整写出来,给同样想“把数据握在自己手里”的人一条能直接照做的路。

文章适合谁看?两类人。一类是已经用了不蒜子的前端脚本、想搞清楚背后到底怎么工作的;另一类是想给博客做一个轻量、自托管的统计服务、不想再依赖外部接口的。不需要你有很深的后端功底,Node.js基础语法能看懂就行,整个项目也就几百行代码。

2. busuanzi原理解析:你每天看到的数字是怎么算出来的

2.1 不蒜子统计的核心逻辑拆解

咱们先把不蒜子的工作方式掰开来看。你在博客里引入的那段脚本,核心就干了两件事:

第一件事,向服务端上报一次“访问事件”。服务端收到之后,会给当前页面的PV数字加1。第二件事,判断这个访客是不是新面孔。如果这家伙第一次来,UV加1;如果是回头客,UV不变。

这里面的技术关键在于“怎么判断是否为新访客”。不蒜子的做法是基于Cookie加User-Agent加IP做一个签名,服务端记录这个签名,每次请求都对比一遍。说白了,它没办法真正做到100%精确,比如用户清了浏览器Cookie、换了设备、或者通过不同网络来访问,都会被算成新访客。但作为博客级别的统计,这个精度已经足够了,你不需要知道精确到个位数的人头数,看个趋势和量级就够了。

这个思路其实和传统服务器日志分析是两码事。Nginx日志分析(比如GoAccess)是离线解析日志文件,看的是已经发生的记录;而不蒜子是即时的,用户每次打开页面,服务器就动态更新数据。前者适合深度分析,后者适合前台展示一个轻量数字。

2.2 为什么官方版虽好,但还是有人想自建

可能有人会问:官方不蒜子明明免费,直接用不就好了?说实话,官方版确实做了很多年,稳定性也在线,但有几个事情让我不太放心。

第一是数据所有权问题。你的访问量数据全部存在别人的服务器上,没有导出接口,没有管理后台,万一服务终止,这些辛辛苦苦积累的数字就永远消失了。第二是可控性问题。官方接口的域名、协议如果调整,你的历史数据不会迁移;而且你无法自己修改统计逻辑,比如“只统计文章页不统计标签页”“排除特定IP段”这类定制需求,官方版全都做不了。第三是完全自定义的能力。自建之后,你可以开着Debug日志看每一个请求的来龙去脉,可以随时改统计口径,也不受任何第三方配额限制。

这不是说官方版不好,而是说“用于生产环境”和“自己玩得舒坦”是两回事。我自己是属于那种“能用源码解决的事绝不依赖黑盒”的人,所以自建这个方案对我来说几乎是必然选择。

3. 自建服务端完整实现:从架构设计到一行行敲代码

3.1 技术选型:为什么选择Node.js加SQLite

技术选型这块,我考虑过Python Flask、Go、Node.js三套方案,最后定了Node.js加Express。理由很简单,静态博客玩家本来就经常跟Node打交道(Hexo就是Node写的),环境不用额外配,而且Express到了2025年依然是最成熟、资料最多的Node框架。Python和Go当然也能实现,但如果你只为了这个统计服务去装Python环境,学习成本就上去了。

存储本来可以用JSON文件,数据量小的时候完全够用,但考虑到要记录UV判重、要支持多站点区分,我选了SQLite。SQLite是嵌入式数据库,文件就是一个.db,备份就是复制文件,不用装MySQL、不用配账号密码,特别适合中小博客。用better-sqlite3这个库是因为它是同步API,代码写起来很直观,不像sqlite3那样回调层层嵌套,对于这种低并发的场景,同步阻塞完全不是问题。

3.2 接口设计:两个API撑起所有统计场景

整个服务端只需要两个接口。

第一个是POST /api/count,用来上报访问。请求体是一个JSON,包含siteId(站点ID)、pageUrl(页面URL)、pageTitle(页面标题,用来显示在文章标题旁边)、referrer(来源页面)。服务端拿到这个请求之后,先给对应的pageUrl的PV加1,然后判断UV是否需要加1。

第二个是GET /api/getData,用来查询数据。参数也是siteId、pageUrl,服务端返回这个页面的PV、UV和站点总UV。

另外还要有一个/api/getTotal接口,返回全站统计,前端可以显示“本站总访问量xx次、总访客数xx人”这种信息。

这里有一个设计细节:为什么上报和查询要分开?因为上报是高频请求,查询是低频请求。分开设计的好处是,你可以只让上报接口处理写入逻辑,把复杂度隔离;查询接口加缓存也方便,不至于每次用户刷新页面都要实时查数据库。我个人是在查询接口上加了5秒的缓存,实测高并发下响应时间从十几毫秒降到一两毫秒。

3.3 访客识别算法:如何用Hash判断一个用户是否来过

UV的判重是整个服务的核心。我的实现方案是:把ip + userAgent + cookie中的uuid拼接成一个字符串,然后做SHA256哈希,得到一个固定长度的签名值。

为什么不能只用IP?因为同一个公司、同一个校园网的所有人可能共享一个出口IP,只按IP判断会把几百个用户算成同一个人。为什么不能只用User-Agent?因为同一款浏览器、同一个操作系统版本的UA基本都一样,会把不同的人合并。所以一定要三者结合。

UUID是首次访问时由服务端生成、写进Cookie的。这样用户的签名是稳定且唯一的,除非用户清Cookie,否则第二次访问时计算出来的哈希和第一次一模一样,服务端在visitors表里查一下就知道是不是老熟人。

但这里要说明白,哈希判重是存在一定误判的。理论上不同字符串可能碰撞出同一个哈希值,虽然概率极低;另外隐私模式下Cookie不持久化,每次访问都会生成新UUID,UV会虚高。我在项目里把“隐私模式会导致UV虚高”这件事写进了README,算是给用户一个心理预期。

3.4 数据库设计:两张表搞定所有统计需求

数据库里只需要两张表。

第一张表叫page_views,字段是id(自增主键)、site_id(站点ID)、page_url(页面URL,存去掉域名之后的路径部分)、pv(访问量)、uv(访客数)、update_time(最后更新时间)。这张表以site_id + page_url做唯一索引,同一个页面的计数不会重复创建。

第二张表叫visitors,字段是id、site_id、fingerprint(访客指纹哈希)、first_visit_time(首次访问时间)、last_visit_time(最后访问时间)。这张表是UV判重的依据,fingerprint就是前面说的SHA256结果。

建表语句写上UNIQUE约束,写入时用INSERT OR IGNORE,如果这条指纹已经存在,就忽略操作。配合visitors表中的首次访问时间,将来还能扩展出“新访客占比”“每日访客趋势”这类图表功能。

3.5 核心代码逐段解读:主服务、计数器、查询逻辑

下面直接贴可运行的代码。Express版本是4.x,Node版本建议18以上。

服务入口app.js:

const express = require('express'); const Database = require('better-sqlite3'); const crypto = require('crypto'); const cookieParser = require('cookie-parser'); const path = require('path'); const app = express(); const db = new Database(path.join(__dirname, 'data', 'busuanzi.db')); db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS page_views ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id TEXT NOT NULL, page_url TEXT NOT NULL, pv INTEGER DEFAULT 0, uv INTEGER DEFAULT 0, update_time TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(site_id, page_url) ); CREATE TABLE IF NOT EXISTS visitors ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id TEXT NOT NULL, fingerprint TEXT NOT NULL, first_visit_time TEXT DEFAULT CURRENT_TIMESTAMP, last_visit_time TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(site_id, fingerprint) ); `); app.use(express.json()); app.use(cookieParser()); function hashFingerprint(ip, ua, uuid) { return crypto.createHash('sha256') .update(`${ip}|${ua}|${uuid}`) .digest('hex'); } function getOrCreateUUID(req, res) { let uuid = req.cookies.busuanzi_uuid; if (!uuid) { uuid = crypto.randomUUID(); res.cookie('busuanzi_uuid', uuid, { maxAge: 365 * 24 * 60 * 60 * 1000, httpOnly: true, sameSite: 'lax' }); } return uuid; } app.post('/api/count', (req, res) => { const { siteId, pageUrl } = req.body; if (!siteId || !pageUrl) { return res.status(400).json({ error: 'siteId and pageUrl are required' }); } const uuid = getOrCreateUUID(req, res); const ip = req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress; const ua = req.headers['user-agent'] || 'unknown'; const fingerprint = hashFingerprint(ip, ua, uuid); const insertCount = db.prepare(` INSERT INTO page_views (site_id, page_url, pv, uv) VALUES (?, ?, 1, 0) ON CONFLICT(site_id, page_url) DO UPDATE SET pv = pv + 1, update_time = CURRENT_TIMESTAMP `); insertCount.run(siteId, pageUrl); const visitInsert = db.prepare(` INSERT OR IGNORE INTO visitors (site_id, fingerprint) VALUES (?, ?) `); const result = visitInsert.run(siteId, fingerprint); if (result.changes > 0) { db.prepare(` UPDATE page_views SET uv = uv + 1 WHERE site_id = ? AND page_url = ? `).run(siteId, pageUrl); } res.json({ success: true }); }); app.get('/api/getData', (req, res) => { const { siteId, pageUrl } = req.query; if (!siteId || !pageUrl) { return res.status(400).json({ error: 'siteId and pageUrl are required' }); } const row = db.prepare(` SELECT pv, uv FROM page_views WHERE site_id = ? AND page_url = ? `).get(siteId, pageUrl); const siteRow = db.prepare(` SELECT SUM(pv) as total_pv, COUNT(DISTINCT fingerprint) as total_uv FROM page_views, visitors WHERE page_views.site_id = ? AND visitors.site_id = ? `).get(siteId, siteId); res.json({ pagePv: row?.pv || 0, pageUv: row?.uv || 0, sitePv: siteRow?.total_pv || 0, siteUv: siteRow?.total_uv || 0 }); }); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`busuanzi server running at http://localhost:${PORT}`); });

这段代码有几个点值得展开说一下。ON CONFLICT DO UPDATE是SQLite的原子性写法,并发请求同一个页面时不会出现“读出来是1,写回去覆盖成1”这种丢失更新问题。INSERT OR IGNORE则保证了同一访客重复访问时不会重复插入记录,result.changes返回0就说明这条指纹已经存在。这两个机制是统计准确性的基石。

这里还有一个容易踩的坑:如果服务部署在Nginx后面,req.socket.remoteAddress拿到的永远是127.0.0.1,必须靠x-forwarded-for请求头来获取真实IP。所以在代码里我先检查转发头,取不到再退回socket地址。部署时Nginx的配置加上proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;这行就行了。

3.6 前端接入脚本:两段代码引入博客主题

服务端跑起来之后,前端接入就很简单了。在博客模板的footer位置加一段脚本:

<script> (function() { var siteId = 'my-blog'; var pageUrl = location.pathname; fetch('/api/count', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteId: siteId, pageUrl: pageUrl }), credentials: 'same-origin' }); fetch('/api/getData?siteId=' + siteId + '&pageUrl=' + encodeURIComponent(pageUrl)) .then(function(res) { return res.json(); }) .then(function(data) { document.getElementById('busuanzi_pv').textContent = data.pagePv; document.getElementById('busuanzi_uv').textContent = data.pageUv; }); })(); </script>

在文章页合适位置放两个span标签:

本文阅读量:<span id="busuanzi_pv">0</span> 次 | 访客数:<span id="busuanzi_uv">0</span> 人

如果你用的是Hexo的Next主题,直接在layout/_partials/footer.swig里改;用的是Butterfly主题,就在footer.pug里改。这些主题本来就有不蒜子的位置,你只需要把原来指向第三方脚本的引用改成自己服务的接口就行。

如果你的博客用的是静态文件部署在Nginx上,而API服务跑在同一台机器的3000端口,那么Nginx还需要做一层反向代理,把/api/路径转发到3000端口:

location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }

3.7 部署上线:用PM2守护进程并配置开机自启

本地测试通过之后,就该部署到服务器了。我用的服务器是2核2G的轻量云,部署的是Ubuntu 22.04。步骤如下:

  1. 把项目目录(包含app.js、package.json、data目录)传到服务器,比如放在/opt/busuanzi。
  2. 在项目目录安装依赖:npm install --production。
  3. 用PM2启动并设置守护:pm2 start app.js --name busuanzi,然后pm2 save && pm2 startup让它开机自动拉起。

PM2的关键配置这里多说一句,我加了一个--max-memory-restart 150M参数,防止这个Node进程因内存泄漏跑太久把整台服务器拖垮。对于这种轻量级服务,150M内存上限已经绰绰有余了,再往上调反而不利于及早发现问题。

部署完成后,先用curl测一下服务是否正常:

curl -X POST http://localhost:3000/api/count \ -H "Content-Type: application/json" \ -d '{"siteId":"test","pageUrl":"/hello"}'

返回{"success":true}就说明服务已经跑起来了,然后访问自己的博客页面,看那个数字是不是从0变成了1。

4. 常见问题与排查技巧实录

4.1 页面计数永远显示0,刷新也不变

这应该是自建统计遇到最多的问题。排查思路是先确认请求是否真的到达了服务端。打开浏览器的开发者工具,切到Network面板,刷新页面,找api/count这条请求,看返回状态码是不是200。如果请求都看不到,说明前端脚本没有正确加载,检查script标签是不是被主题模板过滤了。

如果请求存在但返回404,说明Nginx反向代理路径没配对,去看Nginx的error.log。如果返回200但页面数字不更新,那就是缓存问题,检查你的浏览器缓存或Nginx的proxy_cache配置,我建议在Nginx里对/api/getData配置proxy_cache_valid 200 5s;,避免每次刷新都打数据库。

另外一个隐蔽的原因是跨域。如果前端页面在https://blog.example.com,API在https://api.example.com,这是跨域请求,需要在Express里加CORS中间件。但我的建议是让页面和API共用同一个域名,通过路径区分,省去跨域这一堆麻烦。

4.2 UV数字虚高,几乎是PV的两倍

这个是访客识别算法导致的正常现象。用户开了隐私模式、清过Cookie、换设备访问,都会产生新的fingerprint,导致UV虚高。如果你用的是Safari的“无痕浏览”,每次关闭窗口再打开,之前的Cookie全部失效,UV自然每次都涨。

避坑建议是调整统计口径。如果你更关注“独立设备数”而不需要精确到人,可以在哈希算法里去掉uuid,只保留IP+UA组合。这样就能避免隐私模式带来的虚高,但前文说了,共享IP会把多个人合并成一个人,这个误差是另一个方向的。具体怎么权衡,要看你对数据的实际需求。

4.3 并发请求导致计数丢失

如果同一时刻有多个人访问同一个页面,并发插入时可能因为SQLite的锁机制报错。解决办法是开启WAL模式(代码里已经写了db.pragma('journal_mode = WAL'))。WAL模式下读写互不阻塞,写入操作也可以并发执行,再加上ON CONFLICT DO UPDATE的原子性,基本不会出现计数丢失。

如果发现页面偶尔报500错误,大概率是数据库被锁了。去服务端日志里看有没有SQLITE_BUSY的报错,如果有,给better-sqlite3的构造函数加上超时配置:

const db = new Database(dbPath, { timeout: 5000 });

这样就算并发高峰,最多等5秒会让出锁,而不是直接报错。

4.4 时区显示不对,统计日期错乱

SQLite存时间默认是UTC,如果你的服务器时区不是东八区,查询出来的时间会比北京时间晚8小时。我建议统一存UTC,在前端展示时用new Date(value).toLocaleString('zh-CN')做本地化转换,不要在服务端做时区偏移换算。这样无论服务器在哪个区域,用户看到的都是自己本地时区的时间。

4.5 数据安全性:SQLite文件需要定期备份

自建服务最大的责任就是自己做好备份。SQLite的数据库文件在写入过程中如果断电损坏,可能导致全部数据丢失。我写了一个简单的crontab任务,每天凌晨3点把data目录打包备份到OSS:

0 3 * * * tar -czf /backup/busuanzi_$(date +\%Y\%m\%d).tar.gz /opt/busuanzi/data

保留最近30天的备份就够了,磁盘占用不会超过几十兆。这是纯文件备份,不需要停机,因为SQLite支持热备份,直接复制文件在WAL模式下也不会不一致。

5. 静态博客访问量统计的进一步扩展玩法

统计服务跑通之后,前面还有更大的想象空间。

第一个扩展方向是把数据接入一个简单的管理后台。目前项目是纯API服务,想看数据只能调接口。可以加一个极简的管理页面,在服务端渲染一个Dashboard,展示全站PV趋势图、热门文章TOP10、每日新访客占比这些指标。数据都有了,只是需要一个展示层。

第二个方向是异常流量过滤。现在的代码无条件累加PV,如果某个IDC的爬虫疯狂抓取页面,数字会虚高得很离谱。你可以维护一个黑名单IP段列表,在/api/count入口先做判断,是爬虫就直接返回{success: true}但实际不写入。识别方法也不难,User-Agent里包含bot、spider、crawler关键字的直接过滤。

第三个方向是多站点支持。siteId字段已经预留了,你可以把多个博客放到同一个服务端上,每个站点一个ID,互不干扰。我之前就是先把个人博客和另外一个项目文档站放在同一套统计服务里,管理起来很方便,只需要维护一份数据库和一份代码。

第四个方向是数据导出。可以在服务端加一个/api/export接口,把数据导出成CSV或者JSON,方便做离线分析。格式上最简单的就是page_url,pv,uv,update_time这样一个表结构,导出后扔到Excel里做透视表,快捷又直观。

写在最后的实操心得

我把这套系统跑了几个月,整体感受就两个词:省心、可控。以前用第三方统计,总担心一个政策调整或者接口升级,数字就归零了;现在数据就在自己服务器里,想怎么折腾都行,MySQL/Redis/Cookie方案随便换,后端逻辑自己说了算。

最后分享一个小经验,也提醒后来者:统计服务的可靠性不在于它写得多么花哨,而在于那个“两点之间尽可能用直线连接”的能力。如果你只是需要一个能看的访问量数字,就别一开始就上ClickHouse、没必要的消息队列那些重装备。一个Express进程加一个SQLite文件,每天几万次请求毫无压力,遇到问题日志一看就懂,出了故障重启就能恢复。这套简单的方案,大概率也够你再跑个三五年了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询