ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

自建Busuanzi后端:静态博客访问量统计的完整实现

自建Busuanzi后端:静态博客访问量统计的完整实现 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不持久化每次访问都会生成新UUIDUV会虚高。我在项目里把“隐私模式会导致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.xNode版本建议18以上。服务入口app.jsconst 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 idbusuanzi_pv0/span 次 | 访客数span idbusuanzi_uv0/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。步骤如下把项目目录包含app.js、package.json、data目录传到服务器比如放在/opt/busuanzi。在项目目录安装依赖npm install --production。用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.comAPI在https://api.example.com这是跨域请求需要在Express里加CORS中间件。但我的建议是让页面和API共用同一个域名通过路径区分省去跨域这一堆麻烦。4.2 UV数字虚高几乎是PV的两倍这个是访客识别算法导致的正常现象。用户开了隐私模式、清过Cookie、换设备访问都会产生新的fingerprint导致UV虚高。如果你用的是Safari的“无痕浏览”每次关闭窗口再打开之前的Cookie全部失效UV自然每次都涨。避坑建议是调整统计口径。如果你更关注“独立设备数”而不需要精确到人可以在哈希算法里去掉uuid只保留IPUA组合。这样就能避免隐私模式带来的虚高但前文说了共享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目录打包备份到OSS0 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文件每天几万次请求毫无压力遇到问题日志一看就懂出了故障重启就能恢复。这套简单的方案大概率也够你再跑个三五年了。
返回列表