12 KiB
12 KiB
服务端接入层设计文档
一、设计原则
1.1 核心原则
- 高并发:单节点支撑 1000+ QPS 事件上报
- 低延迟:接口响应 < 50ms(先入队再处理)
- 高可用:进程崩溃不丢数据(本地持久化队列)
- 可扩展:支持横向扩展,无状态设计
- 兼容性:完全兼容 Sentry 协议,SDK 可无缝切换
1.2 性能指标
| 指标 | 目标值 | 说明 |
|---|---|---|
| 单节点 QPS | 1000+ | 4C8G 服务器 |
| P99 响应时间 | < 50ms | 99% 请求在 50ms 内返回 |
| 数据丢失率 | < 0.01% | 异常情况下的数据丢失率 |
| 单事件处理耗时 | < 1ms | 从入队到写入存储 |
二、API 接口设计
2.1 接口列表
| 端点 | 方法 | 功能 | 优先级 |
|---|---|---|---|
/api/{projectId}/envelope/ |
POST | Envelope 批量上报 | 最高 |
/api/{projectId}/store/ |
POST | 单事件上报 | 高 |
/api/{projectId}/nel/ |
POST | NEL 网络错误上报 | 中 |
/api/{projectId}/health |
GET | 健康检查 | - |
/api/projects/ |
GET/POST | 项目管理 | - |
/api/projects/{id} |
GET/PUT/DELETE | 项目 CRUD | - |
2.2 Envelope 接口(主要上报接口)
URL 格式
POST /api/{projectId}/envelope/?sentry_key={publicKey}&sentry_version=7
请求头
| Header | 说明 | 必填 |
|---|---|---|
Content-Type |
application/x-sentry-envelope 或 application/json |
是 |
X-Sentry-Auth |
Sentry 认证头(备用方式) | 否 |
认证方式(二选一)
方式 1:URL 参数(推荐,兼容性好)
?sentry_key={publicKey}&sentry_version=7
方式 2:Header 方式
X-Sentry-Auth: Sentry sentry_version=7, sentry_key={publicKey}
Envelope 格式解析
标准 Envelope 格式:
# 第 1 行:envelope header(JSON)
{"event_id":"abc123","sent_at":"2024-01-01T00:00:00Z"}
# 第 2 行:item header(JSON)
{"type":"event","length":123}
# 第 3 行:item payload(JSON,长度 = length)
{"level":"error","message":"test",...}
# 第 4 行:下一个 item header
{"type":"transaction","length":456}
# ...
支持的 item 类型:
| type | 说明 | 处理方式 |
|---|---|---|
event |
错误事件 | 完整处理 + 写入 Loki + 聚合 |
transaction |
性能事务 | 提取关键指标 + 聚合 |
session |
会话 | 计数 + 聚合 |
attachment |
附件 | 忽略(轻量版不支持) |
profile |
性能剖析 | 忽略 |
statsd |
客户端统计 | 忽略 |
user_report |
用户反馈 | 存储 + 计数 |
简化格式(自研 SDK 专用)
为了减少解析开销,自研 SDK 可使用简化格式:
{
"events": [
{
"type": "error",
"data": { ... }
},
{
"type": "performance",
"data": { ... }
}
]
}
Content-Type: application/json
2.3 Store 接口(兼容旧版 SDK)
POST /api/{projectId}/store/?sentry_key={publicKey}
Content-Type: application/json
{
"event_id": "abc123",
"level": "error",
"message": "...",
"exception": { ... }
}
2.4 NEL 接口(Network Error Logging)
POST /api/{projectId}/nel/
Content-Type: application/reports+json
[
{
"age": 123,
"type": "network-error",
"url": "https://example.com/",
"body": {
"sampling_fraction": 1.0,
"server_ip": "1.2.3.4",
"protocol": "h2",
"method": "GET",
"status_code": 0,
"elapsed_time": 123,
"type": "dns.failed"
}
}
]
三、处理流程
3.1 整体流程
HTTP 请求到达
↓
Nginx (限流 + CORS + 日志)
↓
1. 认证中间件
├─ 解析 projectId (URL)
├─ 解析 publicKey (query / header)
└─ 校验项目是否存在
↓
2. 请求体解析
├─ 根据 Content-Type 选择解析器
├─ envelope 格式 → 逐行解析
└─ json 格式 → JSON.parse
↓
3. 数据清洗
├─ 字段校验
├─ 敏感数据脱敏
├─ 计算错误指纹
└─ 补充默认字段
↓
4. 写入内存队列(立即返回响应)
↓
5. 异步批量消费
├─ 写入 Loki(原始日志)
├─ 更新 MySQL 聚合表
└─ 触发告警检测
3.2 认证中间件
async function sentryAuth(req, res, next) {
// 1. 从 URL 中提取 projectId
const projectId = req.params.projectId;
// 2. 从 URL 参数或 Header 获取 publicKey
const publicKey = req.query.sentry_key ||
parseSentryAuthHeader(req.headers['x-sentry-auth']);
// 3. 校验项目是否存在
const project = projectStore.findByProjectId(projectId);
if (!project) {
return res.status(404).json({ detail: 'Project not found' });
}
// 4. 校验 publicKey
if (project.publicKey !== publicKey) {
return res.status(401).json({ detail: 'Invalid public key' });
}
// 5. 挂载到请求对象
req.project = project;
next();
}
3.3 中间件顺序(重要)
express.raw() ← Sentry 路由先挂载,避免 express.json() 干扰
↓
sentryAuth ← DSN 认证
↓
envelopeParser ← 解析 envelope 格式
↓
rateLimit ← 限流
↓
handler ← 业务处理
express.json() ← 其他 API 路由
↓
其他业务中间件
关键设计:Sentry 上报接口必须在 express.json() 之前挂载,否则 raw body 会被解析成 JSON,导致 envelope 格式解析失败。
四、内存队列设计
4.1 队列结构
┌─────────────────────────────────────────┐
│ 内存队列 (Array) │
│ [ evt1, evt2, evt3, ..., evtN ] │
│ ↑ ↑ │
│ head(tail) maxSize │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────┐
│ 批量消费定时器 │
│ 每 1s 或满 100 条 │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ 批量写入 Loki │
│ 批量更新 MySQL │
│ 告警检测 │
└─────────────────────┘
4.2 队列参数
| 参数 | 默认值 | 说明 |
|---|---|---|
maxQueueSize |
10000 | 队列最大长度 |
flushInterval |
1000ms | 定时刷间隔 |
batchSize |
100 | 每批处理数量 |
persistInterval |
5000ms | 持久化间隔(防止丢数据) |
4.3 队列满时的策略
- 新事件丢弃最旧的:FIFO,保证新事件优先
- 采样降级:队列 > 80% 时,非错误事件自动降采样
- 快速失败:队列 > 95% 时,直接返回 429
- 本地持久化:优雅关闭时刷入本地文件
4.4 本地持久化(可选)
防止进程崩溃丢数据:
- 使用 LevelDB / SQLite 作为持久化队列
- 入队时先写磁盘,再读内存
- 启动时从磁盘恢复未处理的事件
- 性能影响:QPS 从 1000+ 降到 ~500
五、数据清洗与脱敏
5.1 字段校验
| 字段 | 校验规则 | 不通过处理 |
|---|---|---|
event_id |
32 位 hex,可选 | 自动生成 |
timestamp |
ISO 时间戳,可选 | 用服务器时间 |
message |
长度 < 8KB | 截断 |
exception.stacktrace |
深度 < 50 帧 | 截断 |
breadcrumbs |
数量 < 100 | 截断 |
extra |
大小 < 16KB | 截断 |
| 总大小 | < 256KB | 拒绝 |
5.2 敏感数据脱敏
自动脱敏的字段:
| 字段名(不区分大小写) | 脱敏方式 |
|---|---|
password, passwd, pwd |
*** |
token, access_token, refresh_token |
前 4 位 + *** |
secret, api_key, apikey |
*** |
email |
a***@b.com |
phone, mobile |
138****1234 |
id_card, idcard |
110***********1234 |
credit_card, card_no |
6222**********1234 |
| IP 地址 | 保留前两段(192.168.x.x) |
5.3 错误指纹计算
用于错误去重和聚合:
function computeFingerprint(event) {
const { exception } = event;
if (!exception) {
return md5(event.message || 'unknown');
}
// 提取关键信息
const type = exception.type || 'Error';
const message = normalizeMessage(exception.value); // 脱敏 + 去变量
const frames = exception.stacktrace?.frames || [];
// 取前 3 个 in_app 栈帧
const keyFrames = frames
.filter(f => f.in_app !== false)
.slice(0, 3)
.map(f => `${f.filename}:${f.lineno}`);
return md5(`${type}:${message}:${keyFrames.join('|')}`);
}
六、限流设计
6.1 限流维度
| 维度 | 默认限制 | 说明 |
|---|---|---|
| 每项目每秒 | 100 条 | 项目级 QPS 限制 |
| 每项目每天 | 100000 条 | 项目级日配额 |
| 每 IP 每秒 | 50 条 | IP 级 QPS 限制 |
| 全局限流 | 1000 QPS | 服务器总 QPS |
6.2 限流算法
- 令牌桶算法:QPS 限流用令牌桶
- 滑动窗口:日配额用滑动窗口
- 内存计数:单节点足够,分布式需 Redis
6.3 超限处理
| 超出比例 | 处理方式 |
|---|---|
| < 80% | 正常处理 |
| 80% - 100% | 非错误事件降采样 50% |
| 100% - 150% | 只保留错误事件,其余丢弃 |
| > 150% | 全部丢弃,返回 429 |
七、批量消费设计
7.1 消费流程
批次事件
↓
按项目分组
↓
按事件类型分组 (error / performance / network / ...)
↓
并行处理
├─ error 类型
│ ├─ 写入 Loki(批量)
│ ├─ 更新错误聚合表(按指纹分组计数)
│ └─ 触发告警检测
├─ performance 类型
│ ├─ 写入 Loki
│ └─ 更新性能指标表(P50/P95/P99)
└─ 其他类型
└─ 写入 Loki
7.2 Loki 批量写入
使用 Loki 的 /loki/api/v1/push 接口,批量写入:
{
"streams": [
{
"stream": {
"project_id": "1001",
"level": "error",
"type": "error"
},
"values": [
["<ts_nano>", "<json_line>"],
["<ts_nano>", "<json_line>"]
]
}
]
}
7.3 MySQL 批量更新
- 使用
INSERT ... ON DUPLICATE KEY UPDATE - 按批次聚合后一次性写入
- 避免逐条更新,提升性能
八、错误码设计
| HTTP 状态码 | 说明 | 场景 |
|---|---|---|
| 200 | 成功 | 正常接收 |
| 204 | 成功无内容 | 同上,兼容不同 SDK |
| 400 | 请求格式错误 | envelope 格式不对、body 为空 |
| 401 | 认证失败 | publicKey 错误 |
| 404 | 项目不存在 | projectId 无效 |
| 413 | 请求体过大 | 超过 256KB |
| 429 | 限流 | 超过配额 |
| 500 | 服务器错误 | 内部异常 |
九、可观测性
9.1 自身监控指标
| 指标 | 说明 |
|---|---|
events_received_total |
接收事件总数 |
events_received_per_second |
每秒接收数 |
events_dropped_total |
丢弃事件总数 |
events_processed_total |
处理成功总数 |
queue_size |
当前队列长度 |
process_duration_ms |
处理耗时(P50/P95/P99) |
loki_write_errors_total |
Loki 写入错误数 |
mysql_write_errors_total |
MySQL 写入错误数 |
9.2 健康检查接口
GET /health
{
"status": "ok",
"timestamp": "2024-01-01T00:00:00Z",
"uptime": 86400,
"queue_size": 123,
"events_processed": 1234567,
"loki": "connected",
"mysql": "connected"
}
十、横向扩展
10.1 无状态设计
- API Server 完全无状态
- 项目配置缓存,启动时加载,定时刷新
- 队列在内存中,扩展时直接加机器
10.2 负载均衡
- Nginx 层做负载均衡
- 按 projectId 一致性哈希路由
- 同一项目的事件打到同一台机器(有利于缓存和聚合)
10.3 队列扩展
- 小流量:内存队列足够
- 中流量:加 Redis 作为分布式队列
- 大流量:加 Kafka(不推荐,太重了)