ClickHouse 数据流分析报告
February 22, 2026 · View on GitHub
分支:
refactor/clickhousevsmain分析日期: 2026-02-21
一、整体架构概览
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 数据流架构图 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 数据源头 │
│ ┌──────────────┐ WebSocket ┌────────────────┐ │
│ │ Clash Gateway│ ──────────────► │GatewayCollector│ │
│ │ (连接数据) │ │ (delta计算) │ │
│ └──────────────┘ └───────┬────────┘ │
│ │ │
│ ▼ │
│ 处理层 ┌────────────────┐ │
│ │ BatchBuffer │ ◄── 内存聚合 │
│ │ (分钟级buffer) │ │
│ └───────┬────────┘ │
│ │ │
│ ┌───────────────────────┼───────────────────────┐ │
│ ▼ ▼ ▼ │
│ 写入层 ┌───────────────┐ ┌───────────────┐ ┌─────────────┐ │
│ │ RealtimeStore │ │SQLite Writer │ │CH Writer │ │
│ │ (实时缓存) │ │ (UPSERT事务) │ │ (Buffer表) │ │
│ └───────┬───────┘ └───────┬───────┘ └──────┬──────┘ │
│ │ │ │ │
│ │ ▼ ▼ │
│ 存储层 │ ┌───────────────┐ ┌─────────────┐ │
│ │ │ SQLite │ │ ClickHouse │ │
│ │ │ (stats.db) │ │ (Buffer→MT) │ │
│ │ └───────┬───────┘ └──────┬──────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ 查询层 ┌──────────────────────────────────────────────────────────┐ │
│ │ StatsService │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │RealtimeStore│ + │ClickHouse │ + │ SQLite │ │ │
│ │ │ (内存merge) │ │Reader(主查) │ │Reader(降级) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
二、各环节详细分析
1. 数据源头 (GatewayCollector)
当前实现 (gateway.collector.ts:136-521)
- WebSocket 连接 Clash Gateway,接收
connections消息 - 计算增量流量:
uploadDelta = conn.upload - existing.lastUpload - 连接断开后移除追踪
潜在问题:
| 问题 | 影响 | 建议 |
|---|---|---|
| 无数据校验 | metadata.host 可能为空 | ✅ 已有默认值处理 |
| 连接状态跟踪内存泄漏风险 | 高并发下 activeConnections Map 可能膨胀 | ✅ 已有 STALE_CONNECTION_TIMEOUT 清理 |
IO 影响: 无磁盘 IO,纯内存操作
2. 处理层 (BatchBuffer)
当前实现 (batch-buffer.ts:59-210)
- 内存聚合:按
(backendId, minute, domain, ip, chain, rule)组合键聚合 - 聚合时机:定时 flush (30s) 或 buffer 满 (5000条)
优化亮点:
// batch-buffer.ts:139-141 - 已实现 SQLite 写入减少
const reduceSQLiteWrites = clickHouseWriter.isEnabled() &&
process.env.CH_DISABLE_SQLITE_REDUCTION !== '1';
潜在问题:
| 问题 | 影响 | 严重程度 |
|---|---|---|
| 双写放大 | 即使启用 ClickHouse,仍写入 SQLite 12+ 张表 | ⚠️ 高 |
| 内存压力 | buffer.size() 无上限 | 中 |
IO 影响:
- 写入前: 无磁盘 IO
- flush 时: SQLite 事务 + ClickHouse HTTP POST
3. 存储层
3.1 SQLite 写入 (TrafficWriterRepository)
当前实现:
- 使用
better-sqlite3事务批量 UPSERT - 当
reduceWrites=true时,跳过部分表写入
问题分析:
SQLite 写入表数量统计:
┌─────────────────────────────────────────────────────────────────┐
│ reduceWrites=false (默认) │ reduceWrites=true (CH启用) │
├─────────────────────────────────────────────────────────────────┤
│ 1. domain_stats │ 1. hourly_stats │
│ 2. ip_stats │ 2. proxy_stats │
│ 3. proxy_stats ✓ │ │
│ 4. rule_stats │ │
│ 5. rule_chain_traffic │ │
│ 6. rule_domain_traffic │ │
│ 7. rule_ip_traffic │ │
│ 8. hourly_stats ✓ │ │
│ 9. minute_stats │ │
│ 10. minute_dim_stats │ │
│ 11. hourly_dim_stats │ │
│ 12. domain_proxy_stats │ │
│ 13. ip_proxy_stats │ │
│ 14. device_stats │ │
│ 15. device_domain_stats │ │
│ 16. device_ip_stats │ │
├─────────────────────────────────────────────────────────────────┤
│ 总计: 16 张表 UPSERT │ 总计: 2 张表 UPSERT │
│ IO 写放大: 高 │ IO 写放大: 大幅降低 │
└─────────────────────────────────────────────────────────────────┘
关键发现:
// traffic-writer.repository.ts:420-443
// 当 reduceWrites=true 时,仍写入 hourly_stats 和 proxy_stats
// 但 minute_stats 和 minute_dim_stats 被跳过了!
问题: minute_stats 被跳过后,SQLite 的 getTrafficInRange 将无法正确获取分钟级数据
3.2 ClickHouse 写入 (ClickHouseWriter)
架构设计:
写入流程:
┌──────────────────────────────────┐
│ ClickHouseWriter │
│ ┌────────────────────────┐ │
写入请求 ──────────►│ │ enqueue(task, rows) │ │
│ │ - pendingBatches 检查 │ │
│ │ - pendingRows 检查 │ │
│ └──────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ writeChain (串行) │ │
│ │ Promise.then 链式 │ │
│ └──────────┬─────────────┘ │
│ │ │
└──────────────┼────────────────────┘
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│traffic_detail_ │ │traffic_agg_ │ │country_buffer │
│buffer (Buffer) │ │buffer (Buffer) │ │(Buffer) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│traffic_detail │ │traffic_agg │ │country_minute │
│(MergeTree) │ │(SummingMergeTree│ │(SummingMergeTree│
└─────────────────┘ └─────────────────┘ └─────────────────┘
Buffer 表配置分析 (clickhouse.config.ts:265-302):
-- Buffer 参数:
-- min_time=10s, max_time=60s → 最多60秒刷新一次
-- min_rows=100, max_rows=10000 → 最多累积10000行刷新
-- min_bytes=10KB, max_bytes=1MB → 最多1MB刷新
优化亮点:
- Buffer 表机制将写入频率从 ~40次/分钟 降低到 ~2次/分钟
SummingMergeTree自动聚合,避免 UPSERT 读放大
潜在问题:
| 问题 | 影响 | 严重程度 |
|---|---|---|
| 写入队列满时丢弃数据 | maxPendingBatches=200 时可能丢数据 | ⚠️ 中 |
| Buffer 表刷新延迟 | 查询可能看不到最新 60 秒数据 | 低 (RealtimeStore 补偿) |
4. 缓存层 (RealtimeStore)
当前实现 (realtime.store.ts)
- 存储当前批次未 flush 的数据
- 支持内存限制:
MAX_DOMAIN_ENTRIES=50000,MAX_IP_ENTRIES=50000 - 支持过期清理:
maxMinutes=180
问题分析:
// realtime.store.ts:1315-1318
clearTrafficSummary(backendId: number): void {
this.summaryByBackend.delete(backendId);
this.minuteByBackend.delete(backendId);
}
关键逻辑 (gateway.collector.ts:205-214):
// 只有 CH 写入成功才清除 realtime store
if (trafficDetailOk && trafficAggOk) {
realtimeStore.clearTraffic(id);
} else if (trafficDetailOk && !trafficAggOk) {
realtimeStore.clearTrafficDimensions(id); // 只清除维度
} else if (!trafficDetailOk && trafficAggOk) {
realtimeStore.clearTrafficSummary(id); // 只清除汇总
}
设计优点: 写入失败时保留内存数据,下次查询仍可合并返回
5. 查询路由层 (StatsService)
当前实现 (stats.service.ts)
路由策略:
查询路由决策树:
┌─────────────────────────────────────────────────────────────────┐
│ shouldUseClickHouse(timeRange) │
│ ├── timeRange.active == false → SQLite │
│ └── timeRange.active == true │
│ └── shouldUseClickHouseForRange(start, end) │
│ ├── ClickHouse 可用 → ClickHouse │
│ └── ClickHouse 失败 → SQLite fallback │
└─────────────────────────────────────────────────────────────────┘
问题: getSummaryWithRouting 非原子回退
// stats.service.ts:321-346
const allCHReady =
!!summaryCH &&
!!topDomainsCH &&
!!topIPsCH &&
// ... 其他 7 个并发查询
if (!allCHReady) {
// 部分失败时整体回退到 SQLite
return this.getSummary(backendId, timeRange);
}
风险: 如果 summaryCH 成功但 topDomainsCH 失败,所有数据回退 SQLite,可能导致页面闪烁
三、IO 性能分析
1. 写入路径 IO 分析
| 阶段 | 操作 | 频率 | IO 量级 |
|---|---|---|---|
| 数据采集 | WebSocket → 内存 | 实时 | 0 |
| BatchBuffer | 内存聚合 | 30s/5000条 | 0 |
| SQLite 写入 | 16表 UPSERT 事务 | 30s | ⚠️ 高 |
| ClickHouse 写入 | HTTP POST Buffer | 30s | 低 |
当前优化效果:
reduceWrites=true时 SQLite 表从 16 → 2 张- ClickHouse Buffer 表聚合写入
2. 读取路径 IO 分析
| 查询类型 | SQLite (原) | ClickHouse (新) | IO 改善 |
|---|---|---|---|
| Summary | 全表扫描聚合 | SummingMergeTree | ✅ 显著 |
| Top Domains | 索引扫描 | 列存聚合 | ✅ 显著 |
| Trend | 分钟表扫描 | agg 表聚合 | ✅ 显著 |
| Chain Flow | 多表 JOIN | 单表聚合 | ✅ 显著 |
四、发现的问题与建议
🔴 高优先级问题
1. minute_stats 写入缺失
位置: traffic-writer.repository.ts:446-451
问题描述: 当 reduceWrites=true 时,minute_stats 表不再写入,但 SQLite 的 getTrafficInRange 依赖此表:
// db.ts - getTrafficInRange 依赖 minute_stats
const rows = this.db.prepare(`
SELECT COALESCE(SUM(upload), 0), COALESCE(SUM(download), 0)
FROM minute_stats WHERE ...
`).get(...);
影响: 如果 timeRange.active=false,SQLite 回退查询将返回 0
建议:
- 方案 A: 即使
reduceWrites=true也保留minute_stats写入 - 方案 B:
getTrafficInRange改为查询 ClickHouse
2. hourly_dim_stats 写入缺失
问题描述: 当 reduceWrites=true 时,hourly_dim_stats 不写入,导致按小时维度的查询无法回退 SQLite。
建议: 保留写入或修改降级逻辑
🟡 中优先级问题
3. 查询路由的非原子回退
位置: stats.service.ts:321-346
问题描述: 当前实现在任意子查询失败时整体回退。
建议:
- 引入"部分降级"策略
- 或在 UI 层做 Loading 状态统一
4. CH_ONLY_MODE 环境变量冲突
位置: stats-write-mode.ts
export function isClickHouseOnlyModeEnabled(): boolean {
return process.env.CH_ONLY_MODE === '1';
}
问题描述: 文档中未提及此变量,与 CH_WRITE_ENABLED 功能重叠。
建议: 整理环境变量文档,明确各变量用途
🟢 低优先级问题
5. ClickHouse 查询参数化
位置: clickhouse.reader.ts:1254-1255
问题描述: 当前使用字符串拼接 + esc() 转义:
private esc(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/'/g, "''");
}
建议: 后续引入官方参数化查询
五、优化建议汇总
| 优先级 | 问题 | 建议 | IO 影响 |
|---|---|---|---|
| 🔴 高 | minute_stats 写入缺失 | 保留写入或修改查询逻辑 | 中 |
| 🔴 高 | hourly_dim_stats 缺失 | 保留写入 | 中 |
| 🟡 中 | 非原子回退 | 引入部分降级策略 | 无 |
| 🟡 中 | 环境变量重叠 | 整理 CH_ONLY_MODE | 无 |
| 🟢 低 | 参数化查询 | 使用官方 API | 无 |
六、结论
本次 ClickHouse 改造在 读取 IO 方面取得了显著优化,通过 SummingMergeTree 和 Buffer 表机制,将分析查询的 IO 压力从 SQLite 转移到了更适合的 ClickHouse。
但在 写入路径 存在以下风险:
reduceWrites=true时 SQLite 关键表缺失写入- 完全依赖 ClickHouse 时,如果 CH 不可用,部分查询将无法降级
建议:
- 在
reduceWrites=true模式下,仍保留minute_stats和hourly_dim_stats的写入 - 或者在查询层做更精细的降级逻辑,避免数据空洞
附录:相关文件索引
| 模块 | 文件路径 |
|---|---|
| ClickHouse Writer | apps/collector/src/modules/clickhouse/clickhouse.writer.ts |
| ClickHouse Reader | apps/collector/src/modules/clickhouse/clickhouse.reader.ts |
| ClickHouse Config | apps/collector/src/modules/clickhouse/clickhouse.config.ts |
| BatchBuffer | apps/collector/src/modules/collector/batch-buffer.ts |
| Gateway Collector | apps/collector/src/modules/collector/gateway.collector.ts |
| RealtimeStore | apps/collector/src/modules/realtime/realtime.store.ts |
| StatsService | apps/collector/src/modules/stats/stats.service.ts |
| Stats Write Mode | apps/collector/src/modules/stats/stats-write-mode.ts |
| TrafficWriterRepository | apps/collector/src/database/repositories/traffic-writer.repository.ts |
| CleanupService | apps/collector/src/modules/cleanup/cleanup.service.ts |