Web Vitals是Google推出的一组衡量用户体验的核心指标,包括LCP(最大内容绘制)、INP(交互到下一次绘制)、CLS(累积布局偏移)等。这些指标只在用户真实访问时才有意义,单次的手动测试远远不够,必须长期、大规模地采集。而采集到数据之后,存到哪里、怎么查、怎么分析,就成了一个绕不开的问题。相比直接上MySQL或ClickHouse,SQLite在这种单机写入、数据量中等的场景下其实非常合适,零部署、单文件、SQL能力完整。本文就带大家完整走一遍这套方案。

一、整体架构与表结构设计
整个方案的链路很简单:浏览器端使用官方的web-vitals库采集指标,通过navigator.sendBeacon或fetch上报到一个轻量API服务,服务端把数据写入SQLite,后续再用SQL做聚合分析。之所以选SQLite,是因为性能监控数据的写入模式非常典型——追加写、批量插入、几乎不更新,单机QPS通常在几百以内,SQLite完全扛得住,而且省去了维护独立数据库服务的成本。
表结构设计是关键一步。一条Web Vitals记录至少要包含指标名、指标值、页面路径、设备类型、访问时间和会话标识。下面是一个经过实践的建表语句:
CREATE TABLE IF NOT EXISTS vitals_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
metric_name TEXT NOT NULL, -- 指标名: LCP / INP / CLS / TTFB / FCP
metric_value REAL NOT NULL, -- 指标值,单位毫秒(CLS为无量纲)
page_path TEXT NOT NULL, -- 页面路径
device_type TEXT NOT NULL, -- 设备类型: mobile / desktop / tablet
rating TEXT NOT NULL, -- 评级: good / needs-improvement / poor
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
-- 查询高频的列建索引,避免全表扫描
CREATE INDEX IF NOT EXISTS idx_vitals_metric_time
ON vitals_events(metric_name, created_at);
CREATE INDEX IF NOT EXISTS idx_vitals_page
ON vitals_events(page_path, created_at);
这里有几个设计细节值得说明。rating字段是在上报时就根据Google官方阈值算好的,比如LCP小于2.5秒为good,2.5到4秒之间为needs-improvement,超过4秒为poor。把评级冗余存储到表里,后面做统计时就不需要每次都写CASE WHEN判断,查询语句干净很多。session_id用来区分独立访客,可以取自Cookie或后端生成的UUID。metric_name和created_at建立复合索引,是因为绝大多数统计查询都会按指标加时间范围过滤,这个索引能命中最常见的查询模式。
二、前端采集与服务端写入
前端采集推荐直接用官方的web-vitals库,它对各个指标的兼容性处理已经做得很好。注意上报时机很讲究:用户可能随时关闭页面,所以最好用sendBeacon,它在页面卸载时也能保证请求发出去。
<script type="module">
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'https://unpkg.com/web-vitals/dist/web-vitals.module.js';
function report(metric) {
const body = JSON.stringify({
session_id: getSessionId(), // 从Cookie读取或自行生成
metric_name: metric.name,
metric_value: metric.value,
metric_rating: metric.rating,
page_path: location.pathname,
device_type: /Mobi|Android/i.test(navigator.userAgent) ? 'mobile' : 'desktop'
});
// sendBeacon在页面关闭时仍能可靠发送
navigator.sendBeacon('/api/vitals', body);
}
onLCP(report);
onINP(report);
onCLS(report);
onTTFB(report);
onFCP(report);
</script>
服务端以Node.js为例,推荐使用better-sqlite3这个驱动,它是同步API但性能出色,在写入场景下比异步驱动更简单直接。接收上报后不要一条一条INSERT,先在内存里攒一个小缓冲区,凑够一批或者定时刷新再批量写入:
const Database = require('better-sqlite3');
const db = new Database('./vitals.db');
db.pragma('journal_mode = WAL'); // 开启WAL模式,写不阻塞读
db.pragma('synchronous = NORMAL'); // 兼顾性能与安全
let buffer = [];
const FLUSH_SIZE = 50;
const insertStmt = db.prepare(`
INSERT INTO vitals_events
(session_id, metric_name, metric_value, page_path, device_type, rating)
VALUES (?, ?, ?, ?, ?, ?)
`);
function pushEvent(evt) {
buffer.push(evt);
if (buffer.length >= FLUSH_SIZE) flush();
}
function flush() {
if (buffer.length === 0) return;
const insertMany = db.transaction((rows) => {
for (const r of rows) {
insertStmt.run(r.session_id, r.metric_name, r.metric_value,
r.page_path, r.device_type, r.metric_rating);
}
});
insertMany(buffer); // 事务包裹,失败整体回滚
buffer = [];
}
// 定时兜底,避免低流量时数据长时间滞留
setInterval(flush, 5000);
这段代码里有两个重要优化点。第一是WAL模式,这是SQLite写多读多场景的标配,开启后写入不再阻塞读取,分析查询可以和上报写入并发进行。第二是用db.transaction把一批数据包在事务里,SQLite单独执行一条INSERT和执行一百条的事务开销几乎一样,批量事务能把写入吞吐提升一到两个数量级。另外预编译的prepare语句也要复用,不要在每次插入时重新prepare,那是很浪费的。
三、用SQL分析性能数据
数据落库之后,SQL的分析能力就体现出来了。最基础的是看各指标的好坏占比,判断整体健康度:
-- 各指标最近7天的评级分布
SELECT metric_name,
rating,
COUNT(*) AS cnt,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (PARTITION BY metric_name), 1) AS pct
FROM vitals_events
WHERE created_at >= datetime('now', 'localtime', '-7 days')
GROUP BY metric_name, rating
ORDER BY metric_name, rating;
但平均值往往会骗人,性能数据的长尾特征很明显,少数极慢的请求会被大量正常请求稀释。更专业的做法是看P75分位数,Google官方也是按P75来评估Web Vitals的。SQLite从3.25开始内置窗口函数,算分位数可以这么做:
-- 按页面统计LCP的P75分位数,找出最慢的页面
WITH ranked AS (
SELECT page_path,
metric_value,
ROW_NUMBER() OVER (PARTITION BY page_path ORDER BY metric_value) AS rn,
COUNT(*) OVER (PARTITION BY page_path) AS total
FROM vitals_events
WHERE metric_name = 'LCP'
AND created_at >= datetime('now', 'localtime', '-30 days')
)
SELECT page_path,
CAST(total * 0.75 AS INTEGER) AS p75_pos,
ROUND(AVG(metric_value), 0) AS avg_ms
FROM ranked
WHERE rn IN (CAST(total * 0.75 AS INTEGER), CAST(total * 0.75 AS INTEGER) + 1)
GROUP BY page_path
ORDER BY avg_ms DESC
LIMIT 20;
除了分位数,维度下钻也很有价值。比如对比移动端和桌面端的INP表现,往往能发现移动端由于设备性能弱、网络慢,交互延迟明显更高,这时就可以针对性地对移动端做优化。再比如按时间维度做日聚合,画成趋势图,能直观看到某次发版后性能是变好还是变坏——这是回归监控的基础。如果数据量增长到千万级,可以加一张按天预聚合的汇总表,用定时任务在凌晨算好,日常查询只读汇总表,速度会快很多。
四、运维注意事项
使用SQLite做监控存储,有几件事必须留意。首先是数据膨胀问题,Web Vitals事件是持续增长的,建议定期归档,比如只保留最近90天的明细数据,更早的数据聚合到日汇总表后删除明细,可以用DELETE FROM vitals_events WHERE created_at < datetime('now', 'localtime', '-90 days')配合VACUUM来回收空间。其次是备份,由于WAL模式下数据分散在主文件和wal文件中,备份时要使用.backup命令或者VACUUM INTO,而不是简单复制文件。最后,如果未来写入量真的上来了,比如多节点上报,SQLite的单文件特性会成为瓶颈,这时候再平滑迁移到PostgreSQL也不迟,表结构和SQL基本可以原样搬过去。
SQLiteWeb Vitals性能监控修改时间:2026-09-15 09:58:40