ClickHouse 数据流分析报告

February 22, 2026 · View on GitHub

分支: refactor/clickhouse vs main 分析日期: 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 Buffer30s

当前优化效果:

  • 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。

但在 写入路径 存在以下风险:

  1. reduceWrites=true 时 SQLite 关键表缺失写入
  2. 完全依赖 ClickHouse 时,如果 CH 不可用,部分查询将无法降级

建议:

  1. reduceWrites=true 模式下,仍保留 minute_statshourly_dim_stats 的写入
  2. 或者在查询层做更精细的降级逻辑,避免数据空洞

附录:相关文件索引

模块文件路径
ClickHouse Writerapps/collector/src/modules/clickhouse/clickhouse.writer.ts
ClickHouse Readerapps/collector/src/modules/clickhouse/clickhouse.reader.ts
ClickHouse Configapps/collector/src/modules/clickhouse/clickhouse.config.ts
BatchBufferapps/collector/src/modules/collector/batch-buffer.ts
Gateway Collectorapps/collector/src/modules/collector/gateway.collector.ts
RealtimeStoreapps/collector/src/modules/realtime/realtime.store.ts
StatsServiceapps/collector/src/modules/stats/stats.service.ts
Stats Write Modeapps/collector/src/modules/stats/stats-write-mode.ts
TrafficWriterRepositoryapps/collector/src/database/repositories/traffic-writer.repository.ts
CleanupServiceapps/collector/src/modules/cleanup/cleanup.service.ts