534 lines
14 KiB
Markdown
534 lines
14 KiB
Markdown
# 数据聚合与存储设计文档
|
||
|
||
## 一、设计原则
|
||
|
||
### 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` 解析器提取字段。
|
||
|
||
#### 错误事件格式
|
||
|
||
```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..."
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 性能事件格式
|
||
|
||
```json
|
||
{
|
||
"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"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 网络事件格式
|
||
|
||
```json
|
||
{
|
||
"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小时内)**
|
||
```logql
|
||
count_over_time(
|
||
{project_id="1001", type="error"}[1h]
|
||
)
|
||
```
|
||
|
||
**查询某错误指纹的出现次数**
|
||
```logql
|
||
count_over_time(
|
||
{project_id="1001", fingerprint="a1b2c3d4"}[24h]
|
||
)
|
||
```
|
||
|
||
**查询某页面的 LCP P95**
|
||
```logql
|
||
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:
|
||
|
||
```sql
|
||
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` 配置自动删除:
|
||
|
||
```yaml
|
||
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 版本管理
|