
之前在调研自托管网站统计方案时我一直在用 Plausible 作为默认选项。它在隐私友好、轻量部署这些维度上确实做得很出色但随着业务复杂度上升我渐渐遇到了一些不太舒服的边界事件类型扩展不够灵活、看板维度相对固定、自托管实例在多人协作时缺少细粒度权限。于是我开始系统性调研“现代替代品”应该具备哪些能力并顺手做了一个小型的可运行原型。这篇文章会把我的调研结论、架构拆解和一套完整的最小实现写成教程帮助你理解现代网站分析工具的核心机制也能自己动手搭一个隐私友好的统计服务。文章适合三类读者第一类是正在选型网站统计工具的独立开发者或中小团队第二类是对 Plausible 这类自托管项目感兴趣、想了解内部实现原理的后端工程师第三类是打算从零实现埋点采集系统的同学。读完你会掌握事件采集接口怎么设计、会话与去重怎么做、轻量看板查询怎么写以及生产环境应该注意哪些工程问题。1. Plausible 究竟是什么为什么出现“现代替代品”1.1 Plausible 解决的痛点Plausible 是一个开源的、隐私友好的网站流量分析工具主要对标 Google Analytics。它的核心卖点可以用几句话概括不依赖 Cookie不需要弹窗征求用户同意。脚本体积小对页面性能影响可以忽略。数据属于网站所有者支持自托管。界面简洁只看几个关键指标没有复杂的漏斗和人群分析。它的出现恰逢隐私法规收紧、用户对 Cookie 追踪越来越敏感的时期。很多站长并不需要 Google Analytics 那样庞大的功能集只需要知道“每天有多少人访问、访问了哪些页面、来自什么渠道、访客大致在哪个地区”Plausible 正好满足这些需求而且不会把数据送给第三方广告系统。1.2 为什么现在需要“Modern Alternative”“现代替代品”并不是说 Plausible 不好而是不同阶段的业务会提出不同需求。我总结了几类常见场景这些场景正是“现代替代品”发挥价值的地方场景Plausible 的表现对现代替代品的期望事件追踪以 pageview 为主自定义事件能力有限支持自定义事件名称、属性和参数看板分析图表固定扩展需改源码可编程看板支持 SQL 或类 SQL 查询多团队协作账号体系简单权限粒度粗项目级、应用级、只读/读写权限数据导出与集成有 API但批量能力一般支持数据仓库同步、Webhook实时分析有实时面板但维度有限支持实时过滤、分组、告警这里需要澄清一个概念Modern现代在数据分析领域通常意味着“事件驱动 实时处理 可编程查询”而不是单纯指“界面好看”。现代统计系统更接近一个轻量级的数据平台采集层负责标准化事件存储层负责高效聚合查询层负责灵活分析。1.3 现代网站分析工具的核心能力清单结合我调研到的主流开源项目特征一个合格的现代替代品至少应该具备以下能力标准化事件采集。页面浏览是默认事件但系统必须允许自定义事件比如“点击注册按钮”“播放视频”“提交表单”。隐私保护。默认不收集个人信息支持 IP 匿名化、用户代理清洗、Do Not Track 尊重。会话与去重。能够在没有 Cookie 的情况下识别“同一次访问”和“同一个用户”常用方案是会话级哈希。实时聚合。事件写入后数秒内可查询不需要长时间等待批处理任务。灵活查询接口。既能提供预置看板也能让开发者通过 API 自行聚合。轻量部署。资源占用低一台小型云主机就能支撑中等流量站点。这些能力听起来很多但拆开后会发现核心只有三个模块采集、存储、查询。接下来我们逐个模块进行技术选型和设计。2. 环境准备与技术选型2.1 演示环境说明本文提供的完整示例是一个基于 Node.js 的最小分析系统数据存储使用 SQLite目的是让读者在本地快速跑通整个流程。生产环境建议替换为 PostgreSQL 或 ClickHouse这一点会在第 6 章详细讨论。版本要求如下Node.js 18 或以上版本建议使用 LTS 版本。不同 Node 版本的 API 差异较大本文代码在 Node.js 18 下验证。npm 9 或以上版本用于安装依赖。任意现代浏览器用于打开测试页面并触发埋点事件。操作系统不限Windows、macOS、Linux 均可SQLite 是嵌入式数据库无需单独安装服务。如果你已经安装了其他版本 Node.js建议通过 nvm 管理版本避免项目间版本冲突。2.2 技术栈选择与理由我们先看一张选型表格然后逐个解释为什么选择这些技术。模块选型理由采集接口Node.js Express事件采集是 I/O 密集型场景Node.js 异步模型适合高并发写入数据存储better-sqlite3本地演示零配置生产可替换为 PostgreSQL/ClickHouse埋点脚本原生 JavaScript不依赖第三方加载器体积最小隐私最好查询接口Express SQL简单场景直接写 SQL复杂场景可引入查询 DSL前端测试页原生 HTML聚焦后端逻辑避免引入构建工具选择 Node.js 的核心原因是事件采集接口往往需要支撑较高的 QPS每秒请求数而 Node.js 的事件驱动模型在这种场景下很合适。Express 是生态最成熟的 Web 框架中间件丰富便于后续扩展鉴权、限流等功能。better-sqlite3 是一个同步 API 的 SQLite 驱动性能比异步驱动更好因为 SQLite 本身是单写多读的嵌入式数据库同步接口更符合它的工作方式。生产环境如果流量较大应该把数据写入 ClickHouse 这类列式数据库因为事件数据的分析场景通常是“按时间范围聚合大量记录”这正是列式存储的优势。2.3 项目初始化先创建项目目录并初始化 npm 配置。mkdir analytics-demo cd analytics-demo npm init -y安装 Express 和 better-sqlite3npm install express better-sqlite3安装完成后项目结构如下analytics-demo/ ├── node_modules/ ├── package.json ├── server.js ├── db.js ├── public/ │ ├── index.html │ └── script.js └── .env.example这个结构很简洁核心文件只有三个db.js负责数据库初始化server.js负责接口路由public/目录存放埋点脚本和测试页面。3. 核心概念拆解事件、会话、去重与隐私3.1 事件模型设计现代分析系统普遍采用“事件驱动”模型。一个事件就是“用户在某个时刻做了某件事”它至少包含以下字段字段类型说明site_idstring站点标识区分不同网站的数据event_namestring事件名称如 pageview、click 等pathstring页面路径如 /blog/post-1referrerstring来源地址uastringUser-Agent用于解析设备类型viewportstring视口尺寸如 1440x900session_idstring会话标识用于去重created_atinteger事件时间戳毫秒级这个模型的优点在于“业务无关”。采集层不关心事件具体是什么业务含义只负责标准化存储。业务层通过event_name和自定义属性来区分不同场景。3.2 无 Cookie 的会话识别隐私友好分析工具不使用 Cookie怎么识别“同一次访问”呢常用方案是基于浏览器指纹的会话哈希。具体做法是前端脚本生成一个会话 ID这个 ID 由随机数 时间戳 页面路径组合后取哈希生成。它只存在于当前页面会话中不写入 Cookie也不持久化存储。当用户刷新页面时同一会话内的多次请求共享同一个会话 ID。这里有一个容易混淆的点会话 ID 不等于用户 ID。会话 ID 只表示“同一个浏览器标签页在短时间内的一系列行为”它不能跨天识别同一用户。因此“访客数Visitors”实际上是“会话去重后的数量”而不是“独立用户数量”。这是无 Cookie 方案的天然限制也是隐私与精度之间的权衡。3.3 隐私保护机制我们需要在架构层面做几件关键事情IP 地址不落库。采集接口收到请求后可以记录 IP 用于请求日志但不会写入事件表。User-Agent 只存原始值展示端再做解析不存解析后的设备型号。遵守 Do Not Track。前端脚本检测到navigator.doNotTrack 1时直接停止发送事件。保留期控制。生产环境应该设置数据保留策略比如 30 天自动清理原始事件。这些机制叠加起来达到的效果是系统采集的数据无法直接定位到具体个人既降低合规风险也减轻用户隐私顾虑。4. 完整实战构建隐私友好的最小分析系统4.1 数据库初始化创建db.js文件负责数据库的表结构初始化和查询封装。// 文件路径analytics-demo/db.js const Database require(better-sqlite3); const db new Database(analytics.db); db.exec( CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id TEXT NOT NULL, event_name TEXT NOT NULL, path TEXT NOT NULL, referrer TEXT DEFAULT , ua TEXT DEFAULT , viewport TEXT DEFAULT , session_id TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_events_site_time ON events (site_id, created_at); CREATE INDEX IF NOT EXISTS idx_events_site_session ON events (site_id, session_id); ); function insertEvent(event) { const stmt db.prepare( INSERT INTO events (site_id, event_name, path, referrer, ua, viewport, session_id, created_at) VALUES (site_id, event_name, path, referrer, ua, viewport, session_id, created_at) ); return stmt.run(event); } function queryPageviews(siteId, since) { const stmt db.prepare( SELECT COUNT(*) AS pageviews, COUNT(DISTINCT session_id) AS visitors FROM events WHERE site_id ? AND event_name pageview AND created_at ? ); return stmt.get(siteId, since); } function queryTopPages(siteId, since, limit 10) { const stmt db.prepare( SELECT path, COUNT(*) AS pageviews, COUNT(DISTINCT session_id) AS visitors FROM events WHERE site_id ? AND event_name pageview AND created_at ? GROUP BY path ORDER BY pageviews DESC LIMIT ? ); return stmt.all(siteId, since, limit); } function queryReferrers(siteId, since, limit 10) { const stmt db.prepare( SELECT referrer, COUNT(*) AS pageviews, COUNT(DISTINCT session_id) AS visitors FROM events WHERE site_id ? AND event_name pageview AND referrer ! AND created_at ? GROUP BY referrer ORDER BY pageviews DESC LIMIT ? ); return stmt.all(siteId, since, limit); } module.exports { insertEvent, queryPageviews, queryTopPages, queryReferrers };这个文件的关键点在于建表时创建了两个索引。第一个索引(site_id, created_at)用于加速按时间范围查询第二个索引(site_id, session_id)用于加速去重统计。没有索引的情况下SQLite 会做全表扫描数据量上来后查询会明显变慢。insertEvent使用命名参数绑定避免 SQL 注入风险。better-sqlite3 的prepare方法会预编译 SQL 语句重复执行时性能比每次拼接字符串好很多。4.2 采集接口实现创建server.js实现事件采集接口、统计查询接口、静态文件服务。// 文件路径analytics-demo/server.js const express require(express); const path require(path); const crypto require(crypto); const db require(./db); const app express(); app.use(express.json()); app.use(express.static(path.join(__dirname, public))); // 生成会话 ID function generateSessionId(ua, ip, seed) { const raw ${ua}|${ip}|${seed}; return crypto.createHash(sha256).update(raw).digest(hex).slice(0, 32); } // 兼容 Do Not Track function shouldTrack(req) { return req.get(dnt) ! 1; } // 采集事件 app.post(/api/collect, (req, res) { if (!shouldTrack(req)) { return res.status(204).end(); } const { site_id, event_name pageview, path: pagePath /, referrer , viewport } req.body || {}; if (!site_id || !pagePath) { return res.status(400).json({ error: site_id and path are required }); } const ua req.get(user-agent) || ; const seed req.body.session_seed || Math.random().toString(36).slice(2); const sessionId generateSessionId(ua, req.ip, seed); db.insertEvent({ site_id: site_id, event_name: event_name, path: pagePath, referrer: referrer, ua: ua, viewport: viewport, session_id: sessionId, created_at: Date.now() }); res.status(204).end(); }); // 统计查询 app.get(/api/stats, (req, res) { const siteId req.query.site_id; const hours parseInt(req.query.hours || 24, 10); const since Date.now() - hours * 60 * 60 * 1000; if (!siteId) { return res.status(400).json({ error: site_id is required }); } const pageviews db.queryPageviews(siteId, since); const topPages db.queryTopPages(siteId, since); const referrers db.queryReferrers(siteId, since); res.json({ site_id: siteId, period: last ${hours} hours, pageviews: pageviews.pageviews, visitors: pageviews.visitors, top_pages: topPages, top_referrers: referrers }); }); const PORT process.env.PORT || 3000; app.listen(PORT, () { console.log(Analytics demo server running at http://localhost:${PORT}); });采集接口的响应状态码是204 No Content。这是因为埋点请求不需要返回任何数据204 可以减小响应体积。前端埋点脚本使用sendBeacon发送请求时204 是最理想的响应。会话 ID 的生成方式值得注意它使用User-Agent IP session_seed三个输入做 SHA-256 哈希。其中session_seed由前端生成每次页面加载都会重新生成这样既能保证同一页面会话内的多次事件共享 ID又不会跨会话追踪用户。4.3 前端埋点脚本创建public/script.js这是嵌入到目标网站的埋点脚本。// 文件路径analytics-demo/public/script.js (function () { const SITE_ID window.ANALYTICS_SITE_ID || demo-site; function shouldTrack() { return navigator.doNotTrack ! 1 navigator.doNotTrack ! yes; } function getViewport() { return ${window.innerWidth}x${window.innerHeight}; } function getReferrer() { return document.referrer || ; } function getSessionSeed() { const existing sessionStorage.getItem(__analytics_seed); if (existing) { return existing; } const seed Math.random().toString(36).slice(2) Date.now().toString(36); sessionStorage.setItem(__analytics_seed, seed); return seed; } function sendEvent(eventName, extraData {}) { if (!shouldTrack()) { return; } const payload { site_id: SITE_ID, event_name: eventName, path: window.location.pathname window.location.search, referrer: getReferrer(), viewport: getViewport(), session_seed: getSessionSeed(), ...extraData }; if (navigator.sendBeacon) { const blob new Blob([JSON.stringify(payload)], { type: application/json }); navigator.sendBeacon(/api/collect, blob); } else { fetch(/api/collect, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload), keepalive: true }); } } window.Analytics { track: sendEvent }; // 页面加载完成后自动发送 pageview 事件 window.addEventListener(load, function () { sendEvent(pageview); }); })();这个脚本有两个关键设计决策。第一使用sessionStorage而不是Cookie来存储会话种子。sessionStorage的生命周期是“标签页会话”关闭标签页即销毁不跨站点共享天然满足隐私要求。它不发送到服务器端只是让客户端每次刷新页面前后能复用同一个种子。第二优先使用navigator.sendBeacon。这个 API 专为“页面卸载时发送数据”设计比fetch更可靠不会因为页面跳转而中断请求。sendBeacon的请求体是Blob需要手动设置Content-Type这一点在代码中已经处理好了。4.4 测试页面创建public/index.html用于模拟一个被统计的网站页面。!-- 文件路径analytics-demo/public/index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleAnalytics Demo - 测试页面/title script window.ANALYTICS_SITE_ID demo-site; /script script src/script.js defer/script /head body h1这是一个测试页面/h1 p打开本页面会自动发送一个 pageview 事件。/p button idbtn点击按钮发送自定义事件/button script document.getElementById(btn).addEventListener(click, function () { window.Analytics.track(button_click, { button_id: main-cta }); }); /script /body /html这里在 HTML 头部通过window.ANALYTICS_SITE_ID设置站点标识目的是让埋点脚本可以复用同一个文件服务多个站点而不需要为每个站点单独复制脚本。这是一种常见的多租户设计思路。4.5 运行与验证启动服务node server.js看到如下输出说明服务启动成功Analytics demo server running at http://localhost:3000打开浏览器访问http://localhost:3000页面加载时会自动发送 pageview 事件。点击页面上的按钮会发送一个自定义的button_click事件。然后访问统计接口curl http://localhost:3000/api/stats?site_iddemo-sitehours24预期返回结果类似{ site_id: demo-site, period: last 24 hours, pageviews: 2, visitors: 1, top_pages: [ { path: /, pageviews: 2, visitors: 1 } ], top_referrers: [] }这里pageviews是事件总数visitors是去重后的会话数。如果你在一个浏览器标签页中刷新多次pageviews会增加但visitors可能保持不变因为sessionStorage中的种子没有变化服务端算出的session_id相同。4.6 结果说明与验证要点这一步验证的是整个链路的正确性不只是接口是否可用。我们实际上验证了三件事采集链路浏览器 - 埋点脚本 - 采集接口 - SQLite。会话去重逻辑同一标签页多次刷新算作一个访客。自定义事件扩展除了 pageview还能追踪按钮点击等任意事件。如果你想验证不同会话的去重效果可以开一个无痕窗口再次访问测试页面这时候sessionStorage是空的会生成新的会话种子visitors就会变成 2。5. 常见问题与排查思路5.1 问题排查表格问题现象常见原因解决思路页面没有任何数据埋点脚本未加载打开浏览器开发者工具查看 Network 面板是否有 /api/collect 请求只有第一次访问有数据刷新后没有sessionStorage 被禁用检查浏览器隐私设置或降级为内存变量CORS 报错埋点脚本与采集接口不在同一个域名在 Express 中配置 CORS 中间件允许目标域名跨域访问visitors 数量等于 pageviews每次请求都生成新的 session_id检查前端是否正确复用 sessionStorage 种子数据量变大后查询很慢缺少索引为 site_id created_at 建立联合索引自定义事件没有记录事件名参数传递错误确认请求体包含 event_name并且服务端正确读取5.2 典型问题详解跨域采集实际项目中埋点脚本往往部署在静态站点比如 GitHub Pages而采集接口部署在自建服务器上两者域名不同会触发浏览器跨域限制。解决方式是让采集服务支持 CORS。// 在 server.js 中添加 CORS 支持核心片段 app.use(function (req, res, next) { res.setHeader(Access-Control-Allow-Origin, *); res.setHeader(Access-Control-Allow-Methods, POST, GET, OPTIONS); res.setHeader(Access-Control-Allow-Headers, Content-Type); if (req.method OPTIONS) { return res.status(204).end(); } next(); });注意生产环境不应该使用*通配符而是配置为具体的可信域名列表。这一点在下一章会详细说明。5.3 排查清单如果你接入后数据一直不对按以下顺序排查浏览器 Network 面板是否有采集请求发出没有说明脚本未执行或doNotTrack被开启。采集请求是否返回 204返回 400 说明请求体缺字段。数据库表中是否有记录使用sqlite3 analytics.db SELECT COUNT(*) FROM events;查询。统计接口的site_id是否与埋点脚本的SITE_ID一致不一致会导致查不到数据。时间范围是否正确如果hours参数传的是 24但事件是 2 小时前产生的应该能查到如果时间戳有误需要检查服务器时钟。6. 最佳实践与工程建议6.1 数据存储升级路径SQLite 适合本地演示和极低流量场景但生产环境建议尽早切换到专用数据库。这里给出一个简单的选型建议场景推荐方案说明单机部署月 PV 百万级以下PostgreSQL功能全JSON 支持好索引能力强单机部署分析查询较重ClickHouse列式存储聚合查询快适合事件分析多区域采集采集端多地域部署存储端统一汇聚保证写入就近查询统一切换存储层时可以先把db.js中的查询函数抽象为接口然后分别实现 SQLite 版和 PostgreSQL 版业务层代码不用改动。这种“存储适配器”模式可以降低后期迁移成本。6.2 安全与合规边界在实现分析系统时安全与合规是最需要提前设计的部分不能等到上线后再补。第一采集接口必须做来源校验。最简单的方式是校验site_id是否在服务端注册过并且校验请求来源域名是否属于该站点。否则任何人构造请求都可以向你的数据库写入垃圾数据。第二IP 地址不能写入事件表。IP 属于个人信息如果需要临时使用 IP 做会话去重应该在内存中计算后立即丢弃只保留哈希结果。也可以用 RFC 1918 地址保留策略在代码层面直接过滤。第三建议增加限流机制。事件采集接口面向公网开放容易被恶意刷量。可以在 Nginx 层配置limit_req或者在应用层使用令牌桶算法限制每个 IP 或每个site_id的请求速率。第四数据保留策略。原始事件数据不建议永久保留。可以在数据库中写一个定时清理任务定期删除超过 30 天或 90 天的数据减小存储压力同时降低数据泄露风险。6.3 标准化事件命名与字段规范业务代码中事件名很容易写得五花八门比如clickBtn、button_click、click、btnClick。事件名不一致后续分析会非常痛苦。建议在项目初期就定义一份事件字典。事件名规范使用小写加下划线例如button_click、form_submit。动词放在前面对象放在后面例如video_play、video_pause。不同端Web、iOS、Android使用同一套事件名不做前缀拆分。属性名规范使用小写加下划线。公共属性统一命名例如page_path、referrer、viewport。自定义属性值只允许字符串、数字、布尔值不允许嵌套对象。如果团队规模较大可以在采集接口增加一个事件校验中间件对未知事件名返回警告日志帮助前端尽早发现问题。6.4 性能优化方向当单机事件写入量变大时以下几个优化方向值得关注批量写入。采集接口不逐条 INSERT而是把事件先放入内存队列每隔几秒批量写入数据库可以大幅减少 I/O 开销。数据分区。在 PostgreSQL 中按时间分区表查询时只扫描最近分区删除过期数据直接 drop 分区。预聚合。实时查询的指标如今日 PV/UV可以每 5 分钟聚合一次存入汇总表查询走汇总表原始表只用于明细分析。缓存热点查询。对于看板类的固定查询可以在 Redis 中缓存 1 分钟降低数据库压力。有一点需要提醒性能优化的前提是先测量再优化。不要一开始就把架构做得很复杂。先用最简单的方案跑通数据量真的上来后再针对性优化。6.5 埋点可靠性的兜底方案sendBeacon虽然可靠但也不是 100% 保证送达。移动端弱网环境、浏览器崩溃、用户快速关闭页面等情况都可能导致事件丢失。常见的兜底方案有两种本地持久化重试。把待发送事件写入localStorage下次打开页面时统一补发。服务端日志兜底。如果你的站点本来就有访问日志可以定期解析日志补充丢失的事件。第一种方案实现简单适合事件量不大的场景。第二种方案适合有运维能力的团队可以当作数据质量校验手段但要注意日志解析会带来额外延迟。7. 总结与下一步学习方向本文从一个实际调研问题出发分析了 Plausible 的价值和局限性梳理出现代隐私友好分析系统的核心能力然后从零实现了一个包含事件采集、埋点脚本、会话去重、统计查询的最小系统。你现在应该能回答这几个问题事件模型怎么设计、无 Cookie 会话识别怎么做、为什么sendBeacon比fetch更适合埋点、生产环境与演示环境在存储和部署上有什么差异。如果继续深入我建议按以下顺序探索学习 ClickHouse 的查询语法尝试把事件表迁移到 ClickHouse体验列式存储的分析速度。给系统增加实时看板通过轮询或 WebSocket 展示最近 5 分钟的 PV 曲线。设计一个简单的多租户权限模型让不同用户只能看到自己站点的数据。参考 Plausible 的开源代码了解它如何实现城市级地理定位通过 IP 数据库而不用落库原始 IP。实际项目中最难的不是代码本身而是数据规范和安全边界的把握。建议先在自己或团队的项目里埋点运行一个月积累足够的数据量后再决定是否需要引入更重度的数据仓库方案。动手永远比调研更重要现在就可以把本文的示例跑起来改一改代码感受一下从埋点到看板的完整链路。