# 数据聚合与存储设计文档 ## 一、设计原则 ### 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 版本管理