14 KiB
14 KiB
数据聚合与存储设计文档
一、设计原则
1.1 存储分层策略
热数据(7天内) ←→ Loki + MySQL(高频查询)
温数据(7-30天) ←→ Loki(低频查询)
冷数据(30天+) ←→ 对象存储归档(极少查询)
1.2 存储选型
| 数据类型 | 存储 | 保留时长 | 说明 |
|---|---|---|---|
| 原始错误事件 | Loki | 7-30 天 | 堆栈、上下文、面包屑 |
| 原始性能事件 | Loki | 7 天 | 性能明细数据 |
| 原始行为事件 | Loki | 3 天 | PV、点击等(量大) |
| 错误聚合统计 | MySQL | 永久 | 按指纹 + 时间聚合 |
| 性能指标统计 | MySQL | 90 天 | 按指标 + 时间聚合 |
| 项目配置 | MySQL | 永久 | 项目、用户、告警规则 |
| 告警记录 | MySQL | 90 天 | 告警历史 |
1.3 设计目标
- Loki 存储成本:100万错误事件 ≈ 5GB/月
- MySQL 存储成本:聚合数据 ≈ 100MB/月/项目
- 查询性能:聚合查询 < 100ms,原始日志查询 < 2s
二、Loki 存储设计
2.1 Label 设计
Loki 的 Label 是查询索引,设计原则:低基数、高区分度。
| Label | 说明 | 基数 | 示例 |
|---|---|---|---|
project_id |
项目 ID | 低(项目数) | 1001 |
type |
事件类型 | 极低 | error / performance / network / behavior |
level |
日志级别 | 极低 | fatal / error / warning / info |
platform |
平台 | 低 | javascript / node / python |
environment |
环境 | 低 | production / staging / development |
fingerprint |
错误指纹 | 中 | abc123def |
反模式(不要用做 Label):
- ❌
event_id:基数太高,每个事件都不同 - ❌
message:基数太高,且是文本 - ❌
url:基数太高 - ❌
user_id:基数太高
这些应该放在日志内容里,用 grep 查询。
2.2 日志格式(JSON)
每条日志是一行 JSON,方便 Loki 的 json 解析器提取字段。
错误事件格式
{
"event_id": "abc123...",
"type": "error",
"level": "error",
"timestamp": 1704067200000,
"project_id": "1001",
"platform": "javascript",
"environment": "production",
"release": "1.0.0",
"fingerprint": "a1b2c3d4e5f6",
"message": "Cannot read property 'foo' of undefined",
"exception": {
"type": "TypeError",
"value": "Cannot read property 'foo' of undefined",
"stacktrace": {
"frames": [
{
"filename": "https://example.com/app.js",
"function": "onClick",
"lineno": 123,
"colno": 45,
"in_app": true
}
]
}
},
"user": {
"id": "123",
"username": "testuser"
},
"tags": {
"page": "/home",
"browser": "Chrome 120"
},
"extra": {},
"breadcrumbs": [],
"request": {
"url": "https://example.com/page",
"headers": {
"user_agent": "Mozilla/5.0..."
}
}
}
性能事件格式
{
"type": "performance",
"level": "info",
"timestamp": 1704067200000,
"project_id": "1001",
"metric": "lcp",
"value": 2500,
"unit": "ms",
"rating": "good",
"tags": {
"page_url": "https://example.com/page",
"route": "/home",
"browser": "Chrome 120",
"os": "Mac OS X"
}
}
网络事件格式
{
"type": "network",
"level": "info",
"timestamp": 1704067200000,
"project_id": "1001",
"sub_type": "fetch",
"method": "GET",
"url": "/api/users",
"status_code": 200,
"duration": 123,
"success": true,
"tags": {
"route": "/home"
}
}
2.3 Loki 查询示例
查询某项目的错误总数(1小时内)
count_over_time(
{project_id="1001", type="error"}[1h]
)
查询某错误指纹的出现次数
count_over_time(
{project_id="1001", fingerprint="a1b2c3d4"}[24h]
)
查询某页面的 LCP P95
quantile_over_time(
0.95,
{project_id="1001", type="performance"}
| json value=value
| metric="lcp"
| unwrap value
[5m]
)
三、MySQL 聚合表设计
3.1 错误聚合表
error_stats_hourly - 错误小时统计表
按错误指纹 + 项目 + 小时聚合,用于趋势图。
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BIGINT PK | 主键 |
project_id |
VARCHAR(64) | 项目 ID |
fingerprint |
VARCHAR(64) | 错误指纹 |
stat_hour |
DATETIME | 统计小时(整点) |
error_type |
VARCHAR(128) | 错误类型(TypeError 等) |
error_message |
VARCHAR(512) | 错误消息(截断) |
count |
INT | 发生次数 |
affected_users |
INT | 影响用户数(估算) |
first_seen |
DATETIME | 首次出现时间 |
last_seen |
DATETIME | 最后出现时间 |
created_at |
DATETIME | 创建时间 |
updated_at |
DATETIME | 更新时间 |
索引:
(project_id, stat_hour)- 按项目+时间查询(project_id, fingerprint, stat_hour)- 按指纹+时间查询- 唯一键:
(project_id, fingerprint, stat_hour)
error_issues - 错误 Issue 表
按错误指纹聚合,用于错误列表管理。
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BIGINT PK | 主键 |
project_id |
VARCHAR(64) | 项目 ID |
fingerprint |
VARCHAR(64) UNIQUE | 错误指纹 |
error_type |
VARCHAR(128) | 错误类型 |
error_message |
TEXT | 错误消息 |
stack_trace |
TEXT | 堆栈摘要(前几帧) |
level |
VARCHAR(32) | 级别 |
platform |
VARCHAR(32) | 平台 |
total_count |
BIGINT | 总次数 |
today_count |
INT | 今日次数 |
yesterday_count |
INT | 昨日次数 |
affected_users |
INT | 影响用户数 |
status |
VARCHAR(32) | 状态:active / resolved / ignored |
assignee |
VARCHAR(64) | 处理人 |
first_seen |
DATETIME | 首次出现 |
last_seen |
DATETIME | 最后出现 |
created_at |
DATETIME | 创建时间 |
updated_at |
DATETIME | 更新时间 |
状态流转:
active ──→ resolved ──→ active(再次出现时复活)
│
└──→ ignored
3.2 性能统计表
performance_stats_hourly - 性能小时统计表
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BIGINT PK | 主键 |
project_id |
VARCHAR(64) | 项目 ID |
metric |
VARCHAR(32) | 指标名:lcp / fcp / cls / fid |
stat_hour |
DATETIME | 统计小时 |
page_url |
VARCHAR(256) | 页面 URL(可选,空=全部) |
sample_count |
INT | 样本数 |
p50 |
DOUBLE | 中位数 |
p75 |
DOUBLE | 75 分位 |
p90 |
DOUBLE | 90 分位 |
p95 |
DOUBLE | 95 分位 |
p99 |
DOUBLE | 99 分位 |
avg |
DOUBLE | 平均值 |
good_rate |
DOUBLE | Good 比例(0-1) |
poor_rate |
DOUBLE | Poor 比例(0-1) |
created_at |
DATETIME | 创建时间 |
updated_at |
DATETIME | 更新时间 |
索引:
(project_id, metric, stat_hour)- 主查询索引- 唯一键:
(project_id, metric, stat_hour, page_url)
performance_stats_daily - 性能日统计表
同上,按天聚合,用于长期趋势。
3.3 网络请求统计表
network_stats_hourly - 网络小时统计表
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BIGINT PK | 主键 |
project_id |
VARCHAR(64) | 项目 ID |
stat_hour |
DATETIME | 统计小时 |
method |
VARCHAR(16) | 请求方法 |
url_pattern |
VARCHAR(512) | URL 模式(归一化后) |
total_count |
INT | 总请求数 |
error_count |
INT | 错误数(4xx + 5xx) |
error_rate |
DOUBLE | 错误率 |
avg_duration |
DOUBLE | 平均耗时(ms) |
p50_duration |
DOUBLE | P50 耗时 |
p95_duration |
DOUBLE | P95 耗时 |
created_at |
DATETIME | 创建时间 |
URL 归一化:
/api/users/123→/api/users/:id/static/app.abc123.js→/static/app.[hash].js
3.4 环境维度表
用于减少 Loki 中环境信息的冗余,配合 SDK 的字典编码和会话级共享使用。
env_dimensions - 环境维度表
每条唯一的环境组合只存 1 条,日志中通过 env_id 关联。
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BIGINT PK | 维度 ID |
project_id |
VARCHAR(64) | 项目 ID |
browser_name |
VARCHAR(32) | 浏览器名称 |
browser_version |
VARCHAR(32) | 浏览器主版本 |
os_name |
VARCHAR(32) | 操作系统 |
os_version |
VARCHAR(32) | OS 主版本 |
device_family |
VARCHAR(64) | 设备系列 |
device_model |
VARCHAR(64) | 设备型号 |
screen_resolution |
VARCHAR(32) | 屏幕分辨率 |
language |
VARCHAR(16) | 浏览器语言 |
hash |
VARCHAR(64) UNIQUE | 所有维度的哈希值,用于快速查找 |
first_seen |
DATETIME | 首次出现 |
last_seen |
DATETIME | 最后出现 |
count |
BIGINT | 出现次数(用于热度排序) |
唯一键:(project_id, hash)
为什么用维度表?
- Loki 的 JSON 日志里只存
env_id(1 个数字),不存完整的浏览器/OS/设备信息 - 每条日志节省 100-200 字节,百万级事件节省几十 GB
- 查询时 JOIN 维度表,或者直接用 Grafana 的变量查询
服务端处理流程:
事件到达 → 计算环境维度的 hash →
├─ 已存在 → 取 env_id,更新 last_seen + count
└─ 不存在 → 插入新记录,返回新 env_id
→ 日志写入 Loki,只带 env_id
page_dimensions - 页面维度表
同理,页面 URL 也可以做维度化:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BIGINT PK | 页面 ID |
project_id |
VARCHAR(64) | 项目 ID |
url_pattern |
VARCHAR(512) | 归一化后的 URL 模式 |
path |
VARCHAR(512) | 路由路径 |
title |
VARCHAR(256) | 页面标题 |
hash |
VARCHAR(64) UNIQUE | 哈希 |
count |
BIGINT | 访问次数 |
api_dimensions - 接口维度表
网络请求的 URL、method 等也可以维度化,日志里只存 api_id:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BIGINT PK | 接口 ID |
project_id |
VARCHAR(64) | 项目 ID |
method |
VARCHAR(16) | 请求方法(GET/POST/...) |
url_pattern |
VARCHAR(512) | 归一化后的 URL 模式 |
domain |
VARCHAR(128) | 域名 |
hash |
VARCHAR(64) UNIQUE | method + url_pattern 的哈希 |
total_count |
BIGINT | 总请求数 |
error_count |
BIGINT | 错误数 |
avg_duration |
DOUBLE | 平均耗时 |
last_seen |
DATETIME | 最后出现时间 |
为什么要做接口维度化?
- 日志里只存
api_id(8 字节),不用存完整 URL - URL 归一化后,同一路由的不同参数只会产生 1 条维度记录
- 按接口统计错误率、耗时等指标时,直接 JOIN 维度表即可
3.5 项目配置表
projects - 项目表
| 字段 | 类型 | 说明 |
|---|---|---|
id |
VARCHAR(64) PK | 内部 ID(proj_xxx) |
project_id |
VARCHAR(32) UNIQUE | Sentry 项目 ID(纯数字) |
public_key |
VARCHAR(64) | 公钥(默认=内部 ID) |
name |
VARCHAR(128) | 项目名称 |
platform |
VARCHAR(32) | 平台 |
environment |
VARCHAR(32) | 默认环境 |
description |
TEXT | 描述 |
status |
VARCHAR(32) | 状态:active / disabled |
rate_limit |
INT | 速率限制(事件/天) |
sample_rate |
DOUBLE | 采样率(0-1) |
created_at |
DATETIME | 创建时间 |
updated_at |
DATETIME | 更新时间 |
alert_rules - 告警规则表
见告警引擎设计文档。
四、聚合任务设计
4.1 聚合方式
| 方式 | 实时性 | 复杂度 | 适用场景 |
|---|---|---|---|
| 实时增量聚合 | 秒级 | 中 | 错误计数、Issue 更新 |
| 定时批量聚合 | 分钟级 | 低 | 性能分位数、小时统计 |
| 离线重算 | 天级 | 高 | 数据修正、历史回刷 |
4.2 实时增量聚合(错误计数)
每次事件处理时,直接更新 MySQL:
INSERT INTO error_issues
(project_id, fingerprint, error_type, error_message,
total_count, today_count, last_seen, first_seen, status)
VALUES
(?, ?, ?, ?, 1, 1, NOW(), NOW(), 'active')
ON DUPLICATE KEY UPDATE
total_count = total_count + 1,
today_count = today_count + 1,
last_seen = NOW(),
status = CASE WHEN status = 'resolved' THEN 'active' ELSE status END;
优点:实时性好,数据立刻可见 缺点:高并发下 MySQL 压力大
优化:
- 内存中先做 1 秒级别的微批聚合,再批量写 MySQL
- 使用 Redis 做计数缓冲,定期刷入 MySQL
4.3 定时批量聚合(性能统计)
使用 Cron 定时任务,每 5 分钟从 Loki 拉取数据聚合:
每 5 分钟执行:
1. 从 Loki 查询过去 5 分钟的性能事件
2. 按 project + metric + 页面 分组
3. 计算 P50/P90/P95/avg 等指标
4. 写入 performance_stats_hourly 表
为什么用定时任务而不是实时?
- 性能指标不需要秒级实时
- 分位数计算需要一定数据量才准确
- 定时批量更省资源
4.4 数据过期与归档
Loki 数据过期
通过 Loki 的 retention 配置自动删除:
limits_config:
retention_period: 168h # 7 天
MySQL 数据过期
- 小时统计表:保留 30 天
- 日统计表:保留 1 年
- 错误 Issue 表:永久保留(只存聚合数据,量很小)
定时任务每天凌晨清理过期数据。
五、数据迁移与兼容
5.1 当前状态
目前项目使用 JSON 文件存储项目配置,Loki 存储原始日志,没有 MySQL 聚合层。
5.2 演进路径
阶段 1:JSON → SQLite(单机版)
- 零依赖,开箱即用
- 适合个人项目、小团队
- 单节点足够
阶段 2:SQLite → MySQL(生产版)
- 支持并发
- 性能更好
- 适合多项目、中大型团队
阶段 3:增加 ClickHouse(大规模)
- 亿级数据量
- 复杂分析查询
- 一般不需要
六、Grafana 数据源配置
6.1 Loki 数据源
- URL:
http://loki:3100 - 开启 JSON 解析
- 配置 Derived fields(从日志跳转到追踪)
6.2 MySQL 数据源
- 用于展示聚合数据(性能趋势、错误趋势)
- 比 Loki 查询更稳定、更快
七、数据安全
7.1 数据脱敏
- 入库前脱敏:SDK 端 + 服务端双重脱敏
- 查询时脱敏:敏感字段查询结果自动打码
- 导出时脱敏:导出数据默认脱敏
7.2 数据隔离
- 项目间完全隔离(通过 project_id label)
- 查询时强制带 project_id 条件
- 管理后台有项目权限控制
7.3 数据备份
- MySQL:每日全量备份 + binlog 增量备份
- Loki:定期快照到对象存储
- 配置文件:Git 版本管理