
一个名为 Plausible 的统计工具之所以在开发者圈子里流行不是因为它功能多而是因为它主动放弃了传统统计工具常用的一套做法不种 Cookie、不采集个人识别信息、不追踪跨站行为。它与 Google Analytics 是两条完全不同的路线。Show HN 上经常出现标注 Modern Alternative to Plausible 的项目这些项目的目的不是做一个更热闹的后台而是用现代技术栈重新设计采集、存储、聚合和展示链路让类似 Plausible 的统计能力更容易自托管、更容易扩展、也更可控。这篇文章不围绕某个具体的开源仓库做评测而是按工程实现的角度拆解一个自托管的现代分析服务应该怎么做。我们会建立一个最小闭环网页脚本上报访问事件后端接口接收并落库无 Cookie 识别会话按天聚合出 PV、UV、页面排名和来源排名最后通过 JSON 接口在前端图表中展示。文章后半部分会给出 Docker 和 Nginx 的部署配置以及线上常见的“没数据、数据重复、报表错位”的排查链路。读完以后你可以依据同样的思路评估或修改一个 Plausible 替代品而不是被某个项目的界面带着走。1. 先搞清楚 Plausible 为什么值得替代以及现代替代品要解决什么问题1.1 Plausible 的核心设计不依赖 Cookie也能统计访问Plausible 最早的卖点可以概括为三个方向轻量、隐私、开源。轻量指的是统计脚本体积小不在用户浏览器里加载大量第三方 SDK隐私指的是不采集 Cookie、不采集精确地理位置、不生成跨站用户画像开源指的是整个系统可以自托管数据不经过第三方统计平台。它的统计模式是“事件采集 明细聚合”。浏览器加载一段 JavaScript在页面浏览时向统计服务端发送一个 POST 请求服务端把请求里的域名、路径、来源、浏览器信息写入事件表再按时间维度做聚合。因为不需要登录、不需要记录用户的持久身份所以也不存在传统意义上的用户 ID。会话通常由“IP User-Agent 时间窗口”这段组合来近似识别。这种做法并不完美它牺牲了传统分析工具里很常见的“跨会话用户识别能力”。同一个用户换了浏览器或者从手机切换到电脑会被算成两个访问者同一个办公室出口 IP 的多人访问又可能被合并成一个会话。Plausible 这类工具的选择是与其为了精确识别用户而采集更多个人数据不如承认统计口径有误差把隐私保护放在第一位。1.2 替代品不是在写一套更花哨的统计后台而是在换数据链路很多人看到 Show HN 上的替代品第一反应是去对比仪表板功能比如有没有漏斗、有没有热力图、有没有事件分析。但实际上替代 Plausible 这类项目真正的差异通常藏在数据链路上。传统 Plausible 栈里面接收事件的后端和聚合查询的后端需要配合 ClickHouse 这样列式数据库才能在高流量下完成实时聚合。如果现代替代品选型时仍然把所有事件都写进一张普通 MySQL 表然后每次查询都COUNT(*)小流量没问题流量一大就会把数据库打满。所以“现代替代”通常体现在几个方向用更简单的流式写入替代同步写库比如先写消息队列再批量落库。用更适合聚合的列式存储比如 ClickHouse、DuckDB或者提前按天预聚合。把采集脚本从几十 KB 压缩到几 KB甚至用浏览器原生sendBeacon发送请求。采用容器化部署一个仓库里同时包含 API、前端、数据库初始化脚本。默认支持 SPA 中的路由切换而不是只统计整页刷新。因此判断一个替代品是否值得用不能只看截图上的折线图应该看它的事件写入链路、数据库设计、聚合任务以及部署方式是否真的适配你的访问量。1.3 从 Show HN 这类项目的角度看现代替代品通常选哪几类技术栈如果你只是给一个小型内容站做统计技术栈没有标准答案但我更推荐按这个顺序做选型维度推荐方向理由采集 APINode.js、Go、Rust处理并发请求方便适合小体积服务存储层SQLite、PostgreSQL 起步学习成本低易备份高流量存储ClickHouse、DuckDB、Parquet列式存储更适合聚合查询前端React/Vue Chart.js/ECharts图表生态成熟不需要造轮子部署Docker Compose自托管最容易落地的形式脚本原生 JavaScript不依赖框架体积可控这里要提醒一下原始项目如果没有给你明确版本信息落地前务必先确认依赖版本。不同 Node 版本对fetch、sendBeacon的支持不一样不同数据库驱动的 SQL 语法也略有差异。下面示例主要用于说明思路实际代码要结合自己的包名、路径和版本调整。2. 架构选型要以最小闭环跑通“脚本采集 - 入库 - 查询 - 展示”2.1 先划定功能边界避免一上来就做多租户和自定义事件一个现代分析系统可以包含很多功能多站点管理、自定义事件、漏斗分析、用户路径、热力图、留存分析、告警通知。但在初始版本里这些功能会迅速吃掉你的时间和精力。建议把最小闭环先定为五个能力页面浏览统计。按日期范围统计 PV 和 UV。页面 TOP 列表。来源 TOP 列表。退出率、跳出率等基础会话指标。先跑通这条链路再根据业务需要加入自定义事件。自定义事件不是不能做而是它会让数据表结构、查询 API 和前端筛选都复杂一个数量级。你可以在表里预留kind字段但不要在第一天就设计一套完整的事件属性 JSON Schema。2.2 数据链路与三个核心模块采集端、存储层、查询统计层整个系统可以拆成三个模块。第一个模块是采集端。浏览器加载tracker.js在页面加载时读取当前地址、标题、来源和语言然后向后端发送事件。这个模块的关键是保持无状态服务端不需要为每个浏览器维护连接。第二个模块是存储层。一个最小实现可以用 SQLite生产环境建议 PostgreSQL 或 ClickHouse。这里的核心表有两类明细表和聚合表。明细表保存每次访问的原始信息聚合表按小时或按天保存统计结果。查询页面报表时只读聚合表只有需要下钻时才去查明细表。第三个模块是查询统计层。它负责把聚合数据组织成前端可读的 JSON 结构比如overview、pages、sources等接口。这三个模块之间的依赖方向是单向的采集端写明细聚合任务读明细写聚合查询层只读聚合。不要设计成查询层直接扫全部明细否则流量一大一次报表请求就会产生全表扫描。2.3 选型建议Node.js 最小实现与生产系统的差距在哪里为了演示核心链路本文选择 Node.js Express better-sqlite3。这套组合在中小流量下完全够用而且代码量少。better-sqlite3 是一个同步 API 的 SQLite 驱动业务逻辑可读性很强很适合作为最小项目的起点。但生产系统通常要做几个额外处理异步化写入。better-sqlite3的同步写会阻塞事件循环高并发时需要用队列把写入请求攒起来再批量提交。连接池。如果使用 PostgreSQL必须配置连接池避免每次事件都新建连接。查询与写入分离。访问量大时要把事件写入放到一个队列或独立进程查询服务读取的是已经聚合好的结果。限流。统计接口应该限制来源域名、IP 和单站点请求频率否则很容易被刷出大量无效数据。学习环境里直接同步写 SQLite 没问题生产环境里这条路走不通需要在写入和存储之间加缓冲层。2.4 预先设计事件表、站点表和明细表后面做聚合会顺很多在最小实现里我先设计三张表站点表、事件明细表和聚合统计表。最开始的版本可以只设计前两张表但为了演示聚合方向这里把聚合表也列出来。CREATE TABLE IF NOT EXISTS sites ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT UNIQUE NOT NULL, timezone TEXT NOT NULL DEFAULT UTC, created_at TEXT NOT NULL DEFAULT (datetime(now)) ); CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER NOT NULL, kind TEXT NOT NULL DEFAULT pageview, path TEXT NOT NULL, title TEXT, referrer TEXT, ua TEXT, ip TEXT, session_id TEXT, happened_at TEXT NOT NULL, FOREIGN KEY (site_id) REFERENCES sites(id) ); CREATE INDEX IF NOT EXISTS idx_events_site_time ON events (site_id, happened_at);这里最关键的字段是session_id。它不是在浏览器端生成的而是在后端根据 IP、User-Agent、时间窗口生成的哈希值。events表里的每一条记录表示一次访问session_id用来把属于同一个会话的多次访问归到同一组。聚合表可以按天简单设计CREATE TABLE IF NOT EXISTS daily_stats ( site_id INTEGER NOT NULL, stat_date TEXT NOT NULL, pageviews INTEGER NOT NULL DEFAULT 0, visitors INTEGER NOT NULL DEFAULT 0, bounces INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (site_id, stat_date) );聚合任务每天早上把前一天的事件按site_id stat_date汇总。查询仪表板时直接读取daily_stats不需要实时扫描events。3. 搭建采集端用 30 行脚本完成页面浏览上报3.1 目录结构与基础依赖为了保持文章主线清晰示例项目使用一个非常紧凑的目录结构analytics-server/ package.json server.js public/ tracker.js在package.json中安装以下依赖npm install express better-sqlite3如果你的运行环境已经支持 Node 22 以上也可以使用 Node 自带的node:sqlite模块不安装better-sqlite3。但为了示例的通用性这里以better-sqlite3为例。版本选择要根据你本机 Node 环境决定不要直接复制最新版本号。3.2 编写 tracker.js不种 Cookie用 sendBeacon 上报tracker.js通过navigator.sendBeacon发送事件。sendBeacon的优点是请求在页面关闭时也会尽量发出且不需要等待响应。对于没有sendBeacon的旧浏览器可以退回到fetch并设置keepalive: true。(function (window, document) { var config window.__analyticsConfig || {}; var endpoint config.endpoint || /api/event; var site config.site || window.location.hostname; function send(payload) { var body JSON.stringify(payload); if (window.navigator.sendBeacon) { window.navigator.sendBeacon(endpoint, new Blob([body], { type: application/json })); } else { window.fetch(endpoint, { method: POST, body: body, keepalive: true, headers: { Content-Type: application/json } }); } } function pageview() { var payload { site: site, path: window.location.pathname, title: document.title || , referrer: document.referrer || , lang: window.navigator.language || , screen: window.screen.width x window.screen.height, ts: Date.now() }; send(payload); } function track(eventName, props) { var payload { site: site, kind: eventName, path: window.location.pathname, title: document.title || , referrer: document.referrer || , lang: window.navigator.language || , ts: Date.now(), props: props || {} }; send(payload); } if (document.readyState loading) { document.addEventListener(DOMContentLoaded, pageview); } else { pageview(); } window.__analytics { pageview: pageview, track: track }; })(window, document);这段脚本没有使用任何第三方依赖也没有写 Cookie。它把站点名、路径、标题、来源和语言交给后端后端只保留统计需要的字段不保存浏览器指纹级别的完整信息。注意同一个页面重复调用window.__analytics.pageview()会导致多条 PV。在自定义调用场景中前端需要自己控制调用时机避免在组件effect里重复上报。3.3 实现 /api/event 接口校验入参、生成会话标识并落库后端最核心的接口是POST /api/event。它的作用有三个校验请求是否合法、判断是否机器人、生成session_id然后写入events表。const express require(express); const crypto require(crypto); const path require(path); const Database require(better-sqlite3); const app express(); const db new Database(analytics.db); db.exec( CREATE TABLE IF NOT EXISTS sites ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT UNIQUE NOT NULL, timezone TEXT NOT NULL DEFAULT UTC, created_at TEXT NOT NULL DEFAULT (datetime(now)) ); CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER NOT NULL, kind TEXT NOT NULL DEFAULT pageview, path TEXT NOT NULL, title TEXT, referrer TEXT, ua TEXT, ip TEXT, session_id TEXT, happened_at TEXT NOT NULL, props TEXT, FOREIGN KEY (site_id) REFERENCES sites(id) ); CREATE INDEX IF NOT EXISTS idx_events_site_time ON events (site_id, happened_at); ); app.use(express.json({ type: [application/json, text/plain] })); function hashSession(ip, ua, dateStr) { const raw ip | ua | dateStr; return crypto.createHash(sha256).update(raw).digest(hex).slice(0, 32); } function isBot(ua) { const u (ua || ).toLowerCase(); return /bot|crawler|spider|slurp|bingpreview|headless/i.test(u); } function getClientIp(req) { const forwarded req.headers[x-forwarded-for]; if (forwarded) return String(forwarded).split(,)[0].trim(); return req.socket.remoteAddress || ; } app.post(/api/event, (req, res) { const payload req.body || {}; const domain String(payload.site || ).toLowerCase().trim(); if (!domain) { return res.status(400).json({ error: missing site }); } const site db.prepare(SELECT id FROM sites WHERE domain ?).get(domain); if (!site) { return res.status(404).json({ error: site not found }); } const ua req.headers[user-agent] || ; if (isBot(ua)) { return res.status(204).end(); } const ip getClientIp(req); const dateStr new Date().toISOString().slice(0, 10); const sessionId hashSession(ip, ua, dateStr); const pathName typeof payload.path string payload.path.startsWith(/) ? payload.path.slice(0, 500) : /; const title typeof payload.title string ? payload.title.slice(0, 500) : ; const referrer typeof payload.referrer string ? payload.referrer.slice(0, 1000) : ; const happenedAt new Date().toISOString(); db.prepare( INSERT INTO events (site_id, kind, path, title, referrer, ua, ip, session_id, happened_at, props) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ).run( site.id, payload.kind typeof payload.kind string ? payload.kind.slice(0, 50) : pageview, pathName, title, referrer, ua, ip, sessionId, happenedAt, payload.props ? JSON.stringify(payload.props) : null ); res.status(204).end(); });这里有几个关键点所有字段在入库前都做了长度限制避免恶意请求把数据库拖垮。session_id使用IP UA 当天日期的哈希哈希后不保留明文 IP。机器人检测虽然简单但能挡掉大量爬虫噪音。生产环境可以再叠加一份已知爬虫 UA 列表。x-forwarded-for字段只有在你的服务部署在可信网关之后才应该使用否则客户端能直接伪造。3.4 用 curl 模拟一次页面浏览并验证写入结果启动服务前先准备一条站点数据node -e const Drequire(better-sqlite3)(analytics.db); D.prepare(INSERT OR IGNORE INTO sites (domain) VALUES (?)).run(example.com);然后启动服务node server.js在另一个终端模拟页面访问curl -i -X POST http://localhost:3000/api/event \ -H Content-Type: application/json \ -H User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \ -d {site:example.com,path:/blog/hello,title:Hello,referrer:https://google.com/,ts:1730000000000}正常返回结果是204 No Content。此时查询数据库sqlite3 analytics.db select site_id, path, session_id, happened_at from events order by id desc limit 3;能看到新增一条事件记录说明采集链路已经通了。这里要注意curl只能模拟一次请求真实浏览器中的referrer和User-Agent会更多变。4. 无 Cookie 会话识别的原理与 SQL 聚合设计4.1 为什么去掉 Cookie 后会话识别会变成一道工程题传统统计工具用 Cookie 里的长字符串标识用户所以同一个浏览器回来访问时后台能认出“还是那个用户”。去掉 Cookie 以后服务端没有稳定标记只能根据请求里的环境信息猜测“哪些访问属于同一次会话”。常见的会话识别策略有以下几种按 IP User-Agent 识别。同一 IP、同一 UA在设定的时间窗口内视为同一会话。会话超时时间。通常取 30 分钟超过 30 分钟没有新事件下一次访问就开启新会话。按天重置。很多轻量统计工具把“一天内的访问”作为会话边界避免跨天会话计算复杂性。Plausible 这类工具不会追求完美识别因为它不想用更高精度的标识去交换用户隐私。你的替代品也不需要追求完美关键是选择一个口径并稳定执行。4.2 基于 IP UA 时间窗口的会话切分方法在最小实现里我们用“当天日期”作为会话边界简化成同一个 IP、同一个 UA、同一天内的所有访问归为一个session_id。如果你要做得更接近真实浏览器行为可以引入一个visits表记录每个会话的开始时间和最后活跃时间。CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, site_id INTEGER NOT NULL, ip TEXT, ua TEXT, started_at TEXT NOT NULL, last_active_at TEXT NOT NULL, pageviews INTEGER NOT NULL DEFAULT 1 );收到事件时先按照site_id ip ua在 sessions 表中查找last_active_at是否在 30 分钟内。如果存在且未超时就更新last_active_at和pageviews否则新建一条会话记录。这种做法的难点在于并发。同一会话的两个请求可能同时到达如果直接用“先查再写”的逻辑会出现重复插入。此时建议用数据库唯一索引配合事务或者在采集端先做一层内存去重。最小实现中可以先接受偶发的会话偏差。4.3 按天聚合 PV、UV、退出率和跳出率的 SQL会话识别完成后聚合任务就可以计算常见指标。PV 就是事件表中按天按站点统计的pageview事件数量。UV 依据session_id去重。跳出率可以定义为只访问了一个页面就离开的会话数占总会话数的比例。退出率则更复杂需要知道每个页面是会话中最后一个页面这里先以简单的“访客只产生一个 pageview”来表示跳出。示例聚合 SQLINSERT OR REPLACE INTO daily_stats (site_id, stat_date, pageviews, visitors, bounces) SELECT site_id, substr(happened_at, 1, 10) AS stat_date, COUNT(*) AS pageviews, COUNT(DISTINCT session_id) AS visitors, SUM( CASE WHEN pageview_count 1 THEN 1 ELSE 0 END ) AS bounces FROM ( SELECT e.site_id, e.session_id, e.happened_at, COUNT(*) OVER (PARTITION BY e.site_id, e.session_id) AS pageview_count FROM events e WHERE e.kind pageview AND substr(e.happened_at, 1, 10) ? ) GROUP BY site_id, stat_date;这条 SQL 使用了窗口函数统计每个会话内的页面浏览数然后把只访问一个页面的会话计入跳出。查询daily_stats时直接读取pageviews和visitorsSELECT stat_date, pageviews, visitors, ROUND(100.0 * bounces / NULLIF(visitors, 0), 2) AS bounce_rate FROM daily_stats WHERE site_id ? ORDER BY stat_date;在实际项目中聚合任务建议每小时运行一次而不是每天只跑一次。这样报表延迟可以控制在小时级别而数据库的聚合压力也不会太大。4.4 保留明细数据还是只保留汇总数据成本与灵活性权衡明细数据占用空间大但它能支持深度的下钻查询。聚合数据查询快但无法回答“某个用户具体访问了哪些页面”。对自托管分析产品最常见的选择是保留 30 到 90 天的明细数据。永久保留聚合数据。定期把超过保留期的明细导出到冷存储比如 Parquet 文件或 CSV 归档。查询仪表板只依赖聚合表需要分析历史明细时再恢复归档。这样做的好处是日常站点查询成本非常低需要做深度分析时又能拿到原始数据。5. 为仪表板提供查询 API并完成一个简单页面视图5.1 输出 JSON 的统计接口overview、pages、sources服务端需要给前端提供三个核心接口GET /api/stats/overview?siteexample.comperiod7d返回 PV、UV、跳出率。GET /api/stats/pages?siteexample.comperiod7d返回访问量最高的页面。GET /api/stats/sources?siteexample.comperiod7d返回来源域名排行。下面以overview为例app.get(/api/stats/overview, (req, res) { const domain String(req.query.site || ).toLowerCase().trim(); const period String(req.query.period || 7d).trim(); if (!domain) return res.status(400).json({ error: missing site }); const site db.prepare(SELECT id FROM sites WHERE domain ?).get(domain); if (!site) return res.status(404).json({ error: site not found }); const days period.endsWith(d) ? parseInt(period, 10) : 7; const since new Date(Date.now() - days * 86400 * 1000).toISOString().slice(0, 10); const row db.prepare( SELECT COALESCE(SUM(pageviews), 0) AS pageviews, COALESCE(SUM(visitors), 0) AS visitors, COALESCE(ROUND(100.0 * SUM(bounces) / NULLIF(SUM(visitors), 0), 2), 0) AS bounceRate FROM daily_stats WHERE site_id ? AND stat_date ? ).get(site.id, since); res.json(row); });这里有一个容易踩的坑period7d的含义是“最近 7 天”但在以天为粒度的聚合表里你是否包含今天取决于数据更新的时间。为了避免用户误解接口文档里要明确说明统计的是“截至昨天”还是“包含今天”。5.2 前端展示Chart.js 接入示例如果前端不想引入重型框架可以直接用 Chart.js 画折线图。页面加载后从接口取数据再把数据放入图表。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title统计仪表板/title script srchttps://cdn.jsdelivr.net/npm/chart.js4.4.0/dist/chart.umd.min.js/script /head body h1example.com 最近 7 天/h1 canvas idpvChart width800 height300/canvas script const ctx document.getElementById(pvChart).getContext(2d); fetch(/api/stats/daily?siteexample.comperiod7d) .then(res res.json()) .then(rows { new Chart(ctx, { type: line, data: { labels: rows.map(r r.stat_date), datasets: [{ label: PV, data: rows.map(r r.pageviews), borderColor: #2563eb }] } }); }) .catch(err console.error(load api failed, err)); /script /body /html/api/stats/daily在本文示例中还没有实现但它和overview的查询逻辑基本相同只是返回按天分组的数组。这里要注意Chart.js 的 CDN 地址需要与实际版本匹配不要在生产环境直接使用不确定版本的外链。5.3 时区、日期粒度和 UTC 存储的坑数据库里的事件时间建议统一使用 ISO 格式的 UTC 时间比如2025-01-01T12:00:00.000Z。前端展示时再转换为站点配置的时区。daily_stats表的stat_date字段存什么时区的日期取决于聚合任务执行时的配置。如果你的站点主要面向国内用户聚合任务应该按Asia/Shanghai来切分日期而不是按 UTC。两种口径的差异会导致报表在早上出现数据错位UTC 的某一天在本地时间其实是另一天。在 Node.js 中计算本地日期可以用Intl.DateTimeFormat但更可靠的方式是先把时间转换为站点所在时区再取日期部分。如果只是学习环境先用 UTC 也没问题但要在文档里写清口径避免后期为“数据差 8 小时”反复排查。6. 自托管部署一次可上线的 Docker 与 Nginx 配置6.1 用 Docker Compose 编排服务学习环境里直接启动 Node 进程就够了。生产环境里建议把服务打包成镜像通过 Docker Compose 编排。用一个最小例子来说明services: analytics: build: . ports: - 3000:3000 environment: - NODE_ENVproduction - DB_PATH/data/analytics.db - TRUST_PROXYtrue volumes: - ./data:/data restart: unless-stopped其中TRUST_PROXYtrue是在告诉服务请求从可信网关转发过来x-forwarded-for可以信任。如果服务直接暴露公网务必不要开启这个选项否则客户端可以伪造 IP。数据库文件通过 volume 挂载到宿主机容器重建后数据不会丢失。但不要只做 volume还要定期把analytics.db备份到独立存储。SQLite 是单文件数据库备份最简单的方式是复制文件或使用sqlite3 .backup。6.2 通过 Nginx 挂载静态脚本并处理跨域响应头tracker.js建议由后端服务直接提供静态文件这样站点的统计脚本域名和数据采集域名在同一个域名下跨域问题最少。用 Nginx 做前端服务器时需要为静态脚本设置缓存并为事件接口配置跨域响应头。server { listen 80; server_name analytics.example.com; location /tracker.js { alias /var/www/analytics/public/tracker.js; expires 1h; add_header Cache-Control public; add_header Access-Control-Allow-Origin *; } location /api/event { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods POST, OPTIONS; add_header Access-Control-Allow-Headers Content-Type; } }这里的Access-Control-Allow-Origin: *会让任意网站都能向你的统计服务发送事件这可能带来伪造数据。更稳妥的做法是只允许你已在站点表里注册的域名。现代浏览器在跨域发送前会先发一个 OPTIONS 预检请求需要让 Nginx 返回200并带上允许头否则浏览器会拦截实际 POST。注意不要在生产环境无限期缓存tracker.js。脚本每次升级后浏览器还停留在旧缓存会导致一段时间收集不到数据。建议文件名带上版本号比如trackerv2.js发布新版本时修改文件名。6.3 生产环境还需要哪些屏障鉴权、限流、机器人过滤、备份自托管分析服务的生产化不只是把容器跑起来。还要考虑几个实际屏障。第一报表接口必须鉴权。统计网站访问量也属于非公开数据不能允许任何人通过/api/stats/overview?siteexample.com查看。最小实现可以在前面加一个简单的 Token 校验app.use(/api/stats, (req, res, next) { const token req.headers[x-token]; if (token ! process.env.ADMIN_TOKEN) { return res.status(401).json({ error: unauthorized }); } next(); });第二事件接口要限流。即使注册域名白名单也可能被高频请求刷爆。可以用内存计数、Redis 或 Nginx 的limit_req来限制同一个 IP 的请求频率。第三机器人过滤不能只靠 UA 判断。无头浏览器可以伪造任意 UA更可靠的方式是后续分析请求时间分布和页面访问深度。生产环境再叠加一个活跃 IP 黑名单或验证码挑战但最小版本不必太复杂。第四备份要有恢复演练。很多人把备份文件生成出来就认为完成了结果恢复时才发现文件损坏或目录不对。SQLite 备份后至少找一台临时机器做一次恢复启动确认服务能正常启动、报表能正常打开。7. 常见问题排查从“没数据”到“数据不对”的处理链路7.1 页面报了请求但后端没有任何写入现象浏览器 Network 面板里能看到POST /api/event请求状态码是 204但数据库里查不到记录。排查优先级请求是否命中了正确的服务端口。Body 里的site是否和sites表里的域名完全一致。User-Agent是否被isBot拦掉了。服务器是否返回了 500但浏览器因为sendBeacon忽略了响应。是否存在多条服务进程同时写同一个 SQLite 文件导致的锁冲突。其中最常见的是站点域名不一致。你注册时写的是example.com但脚本里配置的是www.example.com两者不匹配就会拒绝写入。处理方式是站点注册时做域名归一化去掉www.前缀或者在匹配时做一次规范化。7.2 SPA 路由切换导致 PV 重复或漏报现象用 Vue 或 React 开发的应用用户从首页切到详情页统计里出现两条 PV或者根本没有 PV。原因tracker.js只在页面加载时发送一次 pageview而 SPA 的路由切换不会触发整页加载。如果你的代码在组件created或useEffect里手动调用__analytics.pageview()多次进入同一组件就会重复上报。处理方式在路由变化时调用一次__analytics.track(pageview)但需要自己维护一个“最近上报路径”的全局变量。在脚本内部增加简单去重如果 500ms 内上报的path与上一次相同就忽略。不要依赖页面刷新统一采用路由监听。更彻底的做法是让服务端在很短时间窗口内对相同site session_id path去重。如果实时去重成本高也可以在聚合任务里用COUNT(DISTINCT session_id || path)来统计页面访问量但这会改变 PV 的定义。7.3 数据入表了报表却显示 0 或错位现象events表里有记录但首页报表是 0。原因报表读的是daily_stats聚合表而聚合任务没有运行或者运行时间口径不匹配。检查顺序daily_stats是否已经产生记录。查询 SQL 的since日期是否晚于聚合表的stat_date。stat_date存的是 UTC 日期而应用查询时用了本地日期导致差一天。聚合任务里WHERE happened_at datetime(now, -1 day)被理解成了“最近 23 或 25 小时”而不是“当天”。建议把聚合任务的日志打印出来至少包含任务开始时间、扫描的事件量、写入的聚合记录数。这样报表为空时能很快判断是任务没跑还是任务跑了但条件错误。7.4 广告拦截器或浏览器隐私策略屏蔽了统计脚本现象浏览器安装广告拦截插件后统计请求完全不发出。原因很多广告拦截插件会把带有analytics、event、tracker等特征路径的请求直接拦截。处理方式把统计脚本放在自己的域名下路径尽量短例如/a.js。tracker.js最后执行不要放到head里阻塞页面。接受统计偏差隐私保护工具的用户本来就是要主动阻止统计采集这类用户的数据缺失并不一定需要修正。需要警惕的是不要把脚本伪装成核心业务文件避免对用户造成新的隐私影响。某些浏览器扩展会查看脚本文件内容如果发现它绕过了广告拦截可能会被加入更多过滤规则。7.5 高流量场景下数据库写入成为瓶颈现象访问量突然上涨后事件接口响应变慢数据库查询也变慢。原因事件写入和报表查询共用了同一个 SQLite 数据库。SQLite 的写入是排他的并发一高锁冲突就明显。处理方式在服务端加内存队列先异步批量写入。分离查询库和写入库通过订阅或定时复制同步聚合结果。访问量继续增长时换 PostgreSQL 或 ClickHouse。把所有“实时页面分析”需求改成“准实时”报表允许延迟几分钟。对个人站点来说SQLite 队列基本够用对商用服务来说采集端无状态化、存储层换列式数据库、聚合任务分布式化是必经之路。8. 落地建议评估现有替代品时的检查清单8.1 现在要选型应该看哪些功能如果你不是要自己从零写而是在 Show HN 里看到一个声称是 Plausible 现代替代品的项目可以对照下面这张清单快速判断它是否值得用。检查项重点看什么采集脚本体积是否控制在几 KB 内数据采集是否依赖第三方脚本和接口是否都自托管无 Cookie 方案是否明确会话识别用了什么字段事件表结构是否支持按站点分离是否有时间索引聚合方式是实时扫描明细还是预聚合报表接口鉴权是否可能被未授权访问部署复杂度是否一个 Docker Compose 就能启动机器人过滤有没有基本 UA 过滤和 IP 限流数据库可迁移性是否绑定特定数据库或供应商许可证自用、商业使用、二次修改的限制这十项不需要全部满足才能使用但至少应该清楚每一项的权衡。很多项目截图好看实际查询时要全表扫描访问量一大就会崩。8.2 自己做还是选开源项目自己实现的主要收益是掌控感数据模型、部署方式、上报逻辑都可以按自己的理解调整。代价是长期维护统计系统看起来简单但涉及隐私、时区、聚合、去重、权限、备份任何一个环节都能消耗大量时间。选开源项目的好处是有人已经踩过坑坏处是要接受别人的设计决策。如果你需要自定义事件、需要多租户、需要自定义报表但项目本身结构封闭后续改造的成本会非常高。一个合理的折中方案是先用开源项目跑通统计需求同时用本文最小链路写一个一次性原型用于验证你对数据链路的理解。等你真的决定自建时再用这个原型作为起点扩展。8.3 扩展方向从统计工具变成指标平台一旦最小闭环稳定运行后续扩展可以从下面几个方向选择自定义事件与指标定义让页面访问之外的按钮点击、下载、滚动行为也可以统计。漏斗分析用于看用户从首页到注册页面再到转化页面的流失。多租户站点管理让多个业务线共用一套统计服务。历史数据导出提供 CSV、JSON、Parquet 等格式。告警与接入支持 Webhook 在访问量异常时通知运维。Grafana 数据源把统计指标接入统一监控大盘。扩展顺序建议先做“自定义事件”和“多租户”。原因很简单这两个能力是业务方最常提出来的需求而且它们都建立在你已经设计好的events.kind和site_id字段之上改动相对可控。回到最初的问题一个 Plausible 的现代替代品到底“现代”在哪里不是图表更漂亮而是它用更简单的脚本、更明确的隐私边界、更高效的存储链路让分析能力回归到“帮网站主人了解访问情况”这件事本身。无论你决定自研还是选型先把采集、会话、聚合、排查这条链路想清楚后面的功能都只是增量。