自建访客统计系统:PV/UV埋点设计与轻量后端实现
发布时间:2026/10/1 17:51:08来源:尧图网络
你有没有算过自己的站点里到底塞了多少第三方统计脚本我自己的博客之前挂着一个统计插件一个请求就能完成的埋点工作它硬生生发起了20多个请求首屏加载直接掉了半秒。后来我决定自己动手做一套访客统计系统不图别的就想要一个轻量、可控、数据完全归自己的统计工具。这篇文章会把我从需求梳理、方案选型到埋点设计、数据库落地、前端脚本编写再到上线后踩过的各种坑完整拆开来讲。整理出来的东西可以直接照着抄适合那些想摆脱第三方统计依赖、又不想折腾重型分析系统的个人站长和独立开发者。整个项目不依赖任何重型框架一个轻量后端进程加一张数据库表就能跑起来。1. 先别急着写代码理清统计指标和业务目标1.1 四大基础指标PV、UV、IP、会话很多站长朋友来找我聊访问统计开口第一句基本是怎么给网站加个计数器。但计数器只是表面真正要理清的是计数背后的口径。做自建统计第一步一定是把统计口径定死否则后面写出来的代码也是乱七八糟的。统计系统绕不开四个基础指标各个含义完全不同PVPage View页面浏览量。用户打开页面算一次刷新也算一次。这个指标反映站点的内容被查看了多少次也是很多流量主评估内容曝光的依据。UVUnique Visitor独立访客数。同一个访客在统计周期内多次访问只算一个人。这里最关键的问题是怎么判定他们是同一个人通常依靠浏览器Cookie里存的一个随机ID。IP独立IP数按访问来源的IP维度计数。这个指标在复用公网IP的环境下会严重失真比如办公室里所有人共用一个出口IP数据会缩水得没法看我后面会详细说。会话Session / 访问次数访客从进入站点到离开的一个完整过程。行业惯例是超过30分钟没有新的页面访问动作就算上一次会话结束。拿实体店来类比就好懂了PV是卷帘门被推开的次数同一个顾客上午进来一次、下午又进来一次PV就是2UV是进店的顾客人数不管谁来几趟都算1个会话是顾客逛店的趟数上午那趟、下午那趟分别算两次访问IP则是收银台从同一视角看到的画面数量一个监控视角里可能有一群完全不同的顾客。对绝大多数个人站点来说PV、UV、会话、来源渠道、热门页面这五项数据已经覆盖90%以上的分析需求。与其去追求那些看起来很酷的漏斗模型和热力图表不如先把这些基础指标的准确度做好。1.2 不同站点类型对统计维度的侧重点不一样我给不同类型的站点做统计方案时指标重心完全不同个人博客和内容站更关注PV趋势、来源渠道、热门文章排名。它们的商业价值通常体现在内容被多少人持续阅读上所以时间维度的PV走向特别重要。工具类、产品类站点更关注UV和关键路径的访问深度。因为用户来这里是用工具不是看内容当天来了多少独立的人比页面被刷了多少次更有参考价值。开发者文档站更关注搜索词进入的落地页面、页面停留时长和跳出率。用户能否快速找到答案决定了文档站的质量。这部分的结论很简单别为了看起来专业去堆砌指标。个人站用不上那么多高大上的分析维度统计系统也是越简单越不容易出错。2. 方案选型对照自建统计 vs 第三方统计 vs 边缘日志分析2.1 三条技术路线各有什么优劣访客统计不是只有自己写一套这一个选项。我当初在动手之前把市面上能走的路都过了一遍大致可以分成三条路线。第一是直接挂第三方统计工具。这是绝大部分站长的选择。优点是几乎零成本功能极其丰富从实时访客到热力图应有尽有。缺点也很明显脚本通常很重动辄几十KB而且是一个独立域名下的链接对首屏性能影响不可忽视数据全都存在别人服务器上你能看到的只是对方给你的报表至于你的数据会不会被拿去训练模型、喂给广告系统你完全没控制权。第二是用Nginx访问日志加分析工具。例如把access log接入GoAccess生成HTML报告。这条路改造成本最低服务器天然记录了每个请求的来源IP、访问路径、User-Agent和状态码不用动前端一行代码。但问题在于日志里只有IP没有访客ID。同一台路由器后面的人、同一个办公室的同事在日志分析里会被算成同一个访客。而且爬虫、监控探针、API轮询全都混合在里面数据清洗很折磨人。第三就是自建埋点统计。可控性最强页面加载成本可以被压到一张1x1像素的GIF请求数据完全存在自己的数据库里想怎么分析就怎么分析。代价是需要自己处理一系列暗坑比如广告拦截、浏览器缓存、爬虫污染。但对独立开发者来说这套系统的复杂度完全在可接受范围内。我直接给个对比表格对比维度第三方统计Nginx访问日志分析自建埋点数据准确性较高仍受插件拦截影响最高服务端记录中等受广告拦截影响前端性能开销高几十个请求无极低1个图片请求实现成本极低低中维护成本无低中需自托管数据归属平台所有自己自己功能丰富度高中完全自定义2.2 我为什么最终选了自己写说实话我一开始用的是Nginx日志方案。部署确实快装上GoAccess定时生成一份报告感觉一切都很美好。但用了两周就发现问题了某个IP一段时间的访问量高得离谱仔细一看是小区宽带的出口IP。我的读者可能就住同一个小区人家看了我几篇文章日志里全算成了来自同一个人的几百次访问这种数据粒度根本没法用。后来换成第三方统计又回到了性能焦虑。一个统计工具扩展脚本能在我的页面上拉起20多个请求这本身就是对访客不负责。其实我的需求很朴素知道今天有多少人来了、看了哪些页面、从哪来、趋势怎么样。这种需求完全可以用一个小到极致的自建服务覆盖掉。2.3 技术栈选型确定骨架的底层逻辑技术选型我盯住三个原则足够轻、足够省心、后续导出方便。最终选了Node.js Express SQLite。SQLite整个数据库就是服务器上的一个文件不需要单独装数据库服务没有密码和权限配置备份时直接拷贝文件就行。个人站哪怕日PV涨到上万SQLite都扛得住。Express轻量、生态成熟。它在这里只承担一个任务接收若干GET请求写数据库返回一张GIF。如果你更熟悉Python用Flask或者FastAPI实现同样的逻辑也完全没问题思路一模一样。进程托管用systemd或PM2跑起来就行保证进程常驻即可。有一个建议我必须放在这里说先有指标定义再选技术栈。这个顺序一旦颠倒很容易把项目拖死。因为你在写代码过程中会不断纠结要不要加Redis缓存要不要上时序数据库但实际上对于访客统计来说SQLite和普通索引已经绰绰有余了。3. 核心机制解剖埋点数据是怎么被记录和识别的3.1 埋点请求带什么参数每个字段都有什么用自建统计的本质就一句话前端页面发起一个图片请求后端把这个请求的关键信息写进数据库。为什么要用图片请求因为浏览器对图片请求有天然的跨域支持不需要配置CORS不需要处理复杂的前置请求而且可以做到异步非阻塞不影响页面渲染。一个1x1透明GIF大约只有43字节对带宽的影响可以忽略不计。我设计的埋点请求长这样https://yoursite.com/api/track.gif?sitemyblogurl/post/123refhttps://example.comw1920h1080langzh-CNt1710000000000每个参数的含义site站点标识。一个统计服务可以同时接收多个站点的数据后续按这个字段分开查询。url当前页面路径不含域名。用于生成热门页面排行。ref来源URL也就是用户在到达当前页面前的那个页面用来做流量来源分析。这个值来自document.referrer可能为空后端要做好兜底。w和h屏幕宽度和高度可以用来粗略判断用户是手机还是电脑。lang浏览器语言比如zh-CN对于了解访客分布有帮助。t客户端时间戳。这个值最容易被忽略但作用至关重要——防止浏览器因为缓存合并请求导致PV漏记。3.2 Cookie与访客身份生成最容易做错的地方UV统计的基石是怎么判断不同请求来自同一个访客。行业通用方案是首次访问时生成一个随机ID写入浏览器Cookie后续每次请求都带上这个ID。具体的服务端逻辑从请求的Cookie里取_vid字段。如果取到了说明是老访客直接在数据库里记录上这个ID。如果没取到用crypto.randomUUID()生成一个36位随机ID通过Set-Cookie响应头写回浏览器同时这个请求本身也算作一个首次访问。这段逻辑看起来很简单但有一个坑十个人有八个会踩Cookie跨域问题。如果你的埋点接口挂在独立域名比如stats.example.com那么对用户正在访问的example.com来说这个统计接口属于第三方域名浏览器设置Cookie时会触发第三方Cookie限制。现代浏览器对第三方Cookie的默认策略越来越严格结果就是Cookie根本写不进去每次访问都生成一个新访客ID你的UV直接变成PV。我最终的解法是把埋点接口放在主站同域下例如https://yoursite.com/api/track.gif。这样Set-Cookie属于第一方Cookie最稳定不用跟浏览器策略斗智斗勇。代价是统计请求会占用主站一点点带宽但一个43字节的GIF请求几乎可以忽略不计。3.3 会话超时和分析统计逻辑会话Session的行业默认定义是30分钟内没有新的页面访问动作会话结束下一次访问就算新会话。这个数字在统计领域已经成了事实标准类似活跃用户的判定窗口一样。实现方式有两种实时方式在访客信息表里存一个last_seen字段每次请求到达时对比当前时间与上次访问时间的差值。如果超过30分钟就为这次访问生成一个新的会话ID否则沿用当前会话ID。这种方式的优点是可以直接查到当前在线会话数。查询时聚合不单独维护会话字段查询时按visitor_id和时间间隔来做分组聚合。优点是建表简单缺点是查询SQL会比较复杂数据量大时计算开销较高。个人站我推荐实时方式。原因很简单不光是统计报表要用会话数据有时候你想实时看一眼现在站上有多少人在看实时维护会话字段能让这个功能变得很简单。4. 逐模块实现从埋点接口到可视化面板4.1 数据库表设计我整个统计系统只用了一张主表设计如下CREATE TABLE page_views ( id INTEGER PRIMARY KEY AUTOINCREMENT, site TEXT NOT NULL DEFAULT default, visitor_id TEXT, session_id TEXT, page_url TEXT NOT NULL, referrer TEXT, user_agent TEXT, screen_width INTEGER, screen_height INTEGER, language TEXT, ip_hash TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_views_created ON page_views(created_at); CREATE INDEX idx_views_visitor ON page_views(visitor_id); CREATE INDEX idx_views_page ON page_views(page_url);这里几个设计的考虑visitor_id就是Cookie里的_vidsession_id是会话标识。page_url只存路径不存域名因为同一个站点的域名是固定的存了反而浪费空间。referrer直接存原始来源URL后续展示层再解析域名。created_at用的SQLite的CURRENT_TIMESTAMP存储的是UTC时间查询展示时要加上北京时间时区偏移这个坑会在后面讲。索引的取舍也值得说一下查询的核心场景基本是按时间范围、访客、页面这三个维度来做所以索引只加了这三个没有上来就加一堆。对于个人站的写入量过度索引反而影响写入速度。4.2 埋点接口实现完整可用的Node.js代码服务端核心逻辑如下我用Express实现const express require(express); const crypto require(crypto); const sqlite3 require(sqlite3).verbose(); const app express(); const db new sqlite3.Database(./stats.db); app.set(trust proxy, true); // 1x1 透明GIF的Base64编码 const PIXEL_GIF Buffer.from(R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7, base64); function hashIp(ip) { // 将IPv4最后一组置为0再做SHA-256取前16位 // 既保留了一定的网段分析维度又避免保存完整IP let normalized (ip || ).replace(/:\d$/, ); const m normalized.match(/^(\d\.\d\.\d)\.\d$/); if (m) normalized m[1] .0; return crypto.createHash(sha256).update(normalized).digest(hex).slice(0, 16); } app.get(/track.gif, (req, res) { const { site default, url /, ref , w 0, h 0, lang } req.query; // 1. 取或创建访客ID let visitorId req.cookies._vid; if (!visitorId) { visitorId crypto.randomUUID(); res.setHeader(Set-Cookie, _vid${visitorId}; Path/; Max-Age63072000; HttpOnly); } // 2. 写入数据库 db.run( INSERT INTO page_views (site, visitor_id, page_url, referrer, user_agent, screen_width, screen_height, language, ip_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime(now)), [ site, visitorId, decodeURIComponent(url).slice(0, 500), ref.slice(0, 500), req.headers[user-agent] || , Number(w), Number(h), lang, hashIp(req.ip) ] ); // 3. 返回GIF强制不缓存 res.setHeader(Content-Type, image/gif); res.setHeader(Cache-Control, no-store, no-cache, must-revalidate); res.send(PIXEL_GIF); }); app.listen(3200, () { console.log(stats server running on port 3200); });几个实现细节我要特别强调app.set(trust proxy, true)这行代码必须有。因为埋点接口挂在Nginx后面Express默认拿到的req.ip是反代服务器的IP而不是访客真实IP不开trust proxy你记录的所有IP都是127.0.0.1。HttpOnly标记可以加。前端脚本没有读取Cookie的需求加这个标记能降低XSS窃取Cookie的风险。url和ref字段截断到500字符防止有人恶意提交超长参数把数据库撑爆。如果埋点服务和主站同域Cookie不需要设置SameSite属性。如果你非要把埋点服务放在独立子域就要面对第三方Cookie限制的困境这也是我建议同域部署的根本原因。4.3 前端埋点脚本代码写在哪、怎么写前端埋点脚本销售做到最简不需要引入任何第三方库原生JavaScript几十行足够了script (function () { var site myblog; var trackUrl /api/track.gif; var img new Image(); var params [ site encodeURIComponent(site), url encodeURIComponent(location.pathname location.search), ref encodeURIComponent(document.referrer || ), w screen.width, h screen.height, lang navigator.language, t new Date().getTime() ]; img.src trackUrl ? params.join(); })(); /script这个脚本放在/body之前就行。几个设计点的说明new Image()方式创建的请求是异步且非阻塞的页面渲染完全不受影响。不要用img标签写在HTML里那样会让它在DOM树里留下一个无用节点。参数t拼上每次执行的时间戳防止浏览器加载缓存版本。尤其是用户点击浏览器前进和后退按钮时页面可能从bfcache恢复没有时间戳会漏记PV。document.referrer在两个页面间跳转时会带上上一个页面的URL这个信息不用白不用。给SPA应用的特别提醒如果站点是Vue或React单页应用路由切换不会触发页面的整体刷新上面的脚本只会在首次加载时执行一次。后续用户从/post/a切到/post/b统计根本不会上报。必须在路由切换的钩子里重新调用这段上报逻辑比如Vue Router的router.afterEach钩子或者React Router的location变化监听里。我见过不少自建统计项目在SPA站点上数据直接腰斩基本都是漏了这一步。4.4 统计面板核心查询SQL与一点经验埋点数据入库存了接下来就是怎么把它变成能看懂的报告。我的面板查询就是几个SQL组合今日PVSELECT COUNT(*) FROM page_views WHERE site myblog AND date(created_at, 8 hours) date(now, 8 hours);这里的8 hours就是之前埋下的时区坑。SQLite的CURRENT_TIMESTAMP存的是UTC时间如果直接用date(created_at)北京时间的凌晨0点到8点会被算到前一天每天早上的数据看起来都是断更状态。今日UVSELECT COUNT(DISTINCT visitor_id) FROM page_views WHERE site myblog AND date(created_at, 8 hours) date(now, 8 hours);最近14天PV/UV趋势SELECT date(created_at, 8 hours) AS day, COUNT(*) AS pv, COUNT(DISTINCT visitor_id) AS uv FROM page_views WHERE site myblog AND created_at datetime(now, -14 days) GROUP BY day ORDER BY day;热门页面TOP 10SELECT page_url, COUNT(*) AS views FROM page_views WHERE site myblog AND created_at datetime(now, -30 days) GROUP BY page_url ORDER BY views DESC LIMIT 10;来源域名TOP 10这个SQL稍微绕一些因为referrer存的是完整URL。我在查询时用字符串函数把协议和路径去掉再分组统计SELECT CASE WHEN referrer THEN (direct) ELSE replace(substr(referrer, instr(referrer, ://) 3), /, ) END AS source, COUNT(*) AS visits FROM page_views WHERE site myblog AND created_at datetime(now, -30 days) GROUP BY source ORDER BY visits DESC LIMIT 10;这条查询会把https://example.com/post/1简化成example.com post 1看起来不够干净。所以我实际的做法是查询的时候把referrer字段直接取出在自己的Node.js代码里用new URL(ref).hostname解析出域名再在内存中做聚合。代码多几行但结果干净得多。4.5 用Nginx挂到主站同域下埋点接口要挂在主站同域Nginx配置如下location ^~ /api/track.gif { proxy_pass http://127.0.0.1:3200/track.gif; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }^~修饰符保证这个路径优先匹配不会把请求交给SPA前端路由去处理。如果主站还在前面挂了CDN记得在CDN控制台对/api/track.gif这个路径设置缓存策略为不缓存。5. 实际部署中的坑我一个个踩给你看5.1 广告拦截器如何悄悄吃掉你的数据统计系统上线第一周我兴冲冲跑去看数据发现PV比预想的少了一大截。后来开了广告拦截插件的无痕模式一对比才明白埋点请求被拦截了。主流广告拦截插件的过滤规则比如EasyList中凡是请求路径里带有/analytics、/track、/stat这类关键字的基本默认当作跟踪器处理。很多第三方统计工具的接口就命名为/analytics.js或/collect正好被一网打尽。应对策略很简单埋点路径尽量用中性词比如/collect.gif、/pixel.gif、/c.gif绕开高概率拦截关键词。尽量部署在第一方域名下也就是你的主站同域。实测第一方请求被拦截的概率远低于第三方域名请求损耗能少20%左右。不要试图去绕过用户的广告拦截选择那是踩红线的行为。把埋点请求做得低调、规范在合理范围内降低误伤就好。5.2 浏览器缓存与CDN导致的数据滞后你以为动态接口就不会被缓存吗我告诉你太天真了。某些浏览器对GIF图片有启发式缓存策略如果用户在页面里刷新几次浏览器发现这个GIF刚才已经拉过了就直接用本地缓存根本不会发起新请求。前端加的t时间戳可以解决大部分问题但如果主站挂了CDN还要在CDN层面对这个路径设置no-cache。所以在服务端的响应头里三个缺一不可Cache-Control: no-storeCache-Control: no-cacheCache-Control: must-revalidate只要加全了再配合前端时间戳双保险基本不会再漏。5.3 爬虫流量污染的滤除策略只要站点是公开的爬虫就不可能消失。我去看原始日志的时候发现大量User-Agent带有常见的爬虫标识还有各种健康检查探针。最直接的后果就是PV虚高来源分析被垃圾referrer刷屏。我的过滤策略分两层第一层是User-Agent关键词过滤。在插入数据库之前先检查UA中是否包含bot、spider、crawler、python-requests、curl、wget等常见标识命中就直接不写入数据。想做得更精细可以用ua-parser-js或者专门的爬虫识别库个人站用一个关键词过滤器已经能过滤掉七八成。第二层是行为特征过滤。如果同一个visitor_id在极短时间比如1秒内发起多个不同页面的请求基本可以判定是脚本行为而非真人浏览。这个策略对个人站来说属于可选项如果UA层的过滤效果已经够用可以先不加。这里有个反模式要提醒一下不要因为某个IP访问得多就简单粗暴地把整个IP段拉黑。很多云厂商和CDN的出口IP是共享的一个IP后面可能坐着成百上千的真实用户拉黑一个段有可能误伤一大批人。5.4 隐私合规与IP匿名化处理自建统计在数据隐私方面反而有天然优势所有数据都存在你自己的服务器上没有第三方转手。但就算这样该有的底线还是要守住。我给自己定的操作原则就三条不存完整IP。数据库里只存IP经过处理后的哈希值。我在hashIp里已经把IPv4最后一组置0再做SHA-256取前16位这样既保留了一些网段级的分析维度又可以避免存储完整IP带来的敏感数据风险。访客标识不要做成永久追踪。Cookie的有效期我设置成一年到期后浏览器会重新生成访客ID。统计的本质是了解趋势不需要做到永远知道这个用户是谁。做一个统计说明页面。在你的站点上放一个静态页面说明自己使用了什么统计方案、收集了什么数据、数据的用途是什么。不需要搞什么复杂的同意弹窗流程但对访客保持透明是自建统计方案的基本礼貌。这些操作不会让统计功能变复杂反而能让你在向别人介绍自己站点的时候可以理直气壮地说一句我不用第三方统计数据都在我自己服务器上。最后再分享两个我后来加的小功能。第一个是在统计面板里加了一个近30天的趋势折线图用纯JavaScript的canvas手绘没引入任何图表库总共不到200行代码第二个是在埋点URL后面加了一个debug1参数开启后服务端直接返回一行文本而不是GIF方便我测试各种参数是否正常解析。给自己站点做统计这件事做到什么程度才算成功我的答案就三个字不添乱。页面加载不拖后腿服务不崩溃数据随手能导出。如果你也准备动手建议先把一个埋点请求完整跑通再加查询逻辑。统计系统的复杂度不是设计出来的是业务跑起来之后自然长出来的到了那个阶段你自然会知道该加什么。
网站建设高端定制企业官网