Files
geo/docs/ai-brand-monitoring-tech-design-v3.md
T
root 446f865cdf feat: add knowledge management functionality with CRUD operations and database schema
- Implemented KnowledgeHandler for managing knowledge groups and items, including listing, creating, updating, and deleting operations.
- Added database migration scripts to create necessary tables for knowledge management, including knowledge_groups, knowledge_items, knowledge_parse_tasks, and knowledge_chunks_meta.
- Introduced prompt_rule_knowledge_groups table to associate prompt rules with knowledge groups.
2026-04-05 17:14:13 +08:00

935 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AI 品牌曝光监测系统技术方案 V3
## 1. 文档信息
| 项目 | 内容 |
| --- | --- |
| 文档名称 | AI 品牌曝光监测系统技术方案 V3(容量与口径修订稿) |
| 文档版本 | V3.0 |
| 文档状态 | 待评审 |
| 创建日期 | 2026-04-05 |
| 基线文档 | `docs/ai-brand-monitoring-tech-design-v2.md` |
| 适用范围 | 数据追踪模块全部页面 |
| 关联文档 | `docs/geo-platform-prd-v1.md` (PRD 8.4 数据追踪) |
| 关联文档 | `docs/question-driven-monitoring-design-v1.md` (V1 数据模型) |
| 容量目标 | 5 万并发 **活跃** 用户(同时打开页面、切换筛选、触发突刺流量) |
## 2. V3 修订范围
本文档不再只是 V2 的“容量补丁”。本版同时修正了 V2 与采集可行性方案之间的口径断层,尤其是:
1. `run_mode` 未入模,导致标准回答和搜索增强回答不可比。
2. 问题版本未完整进入汇总表唯一键,历史口径会被覆盖。
3. 采集时间窗与聚合时序冲突,无法同时满足 T+1 发布与一致性。
4. 在线缓存与离线任务队列共用同一 Redis 故障域,风险过高。
读者应先读完 V2 全文,再用本文档替换或补充以下章节:
| V2 章节 | V3 替换 / 补充章节 | 变更原因 |
| --- | --- | --- |
| 5. 数据库 Schema | 2.2 数据口径与 Schema Delta | V2 未建模 `run_mode`、版本键、引用事实 |
| 8. 缓存策略 | 3. 读流量模型与缓存架构 | V2 低估页面 fan-out,缺少防雪崩手段 |
| 10. 采集流水线设计 | 4. 采集流水线扩容 | V2 采集时间窗 / worker 容量不足 |
| 11. 部署拓扑 | 5. 部署拓扑与资源 | V2 连接池计算有误、单点问题 |
| 7. 后端架构 / 7.6 配置扩展 | 4.5、5.3、5.4 | V2 仍按单 provider / 单 Redis / 单 DB 结构假设 |
| (无) | 6. 降级与容灾 | V2 缺失 |
| 13. 实施计划 (Phase 21) | 7. 压测验收标准 | V2 缺失 |
V2 其他章节(页面需求、前端组件、API 响应结构、指标定义)原则上保持不变;但只要与本文中的口径分层、Schema Delta、采集时序冲突,应以 V3 为准。
### 2.1 数据口径分层与 UI 取数规则
为避免把不可比的数据混到同一张榜单中,V3 将监测数据拆成 3 种采集模式:
| `run_mode` | 含义 | 是否进入主统计 | 默认消费页面 |
| --- | --- | --- | --- |
| `api_standard` | 固定 prompt、低温、无搜索增强的标准 API 回答 | 是 | Dashboard、平台占比、竞争对手、业务主题、AI 对话问题、品牌印象 |
| `api_search_grounded` | 开启平台官方搜索 / 引文能力后的回答 | 是,但单独统计 | AI 引用排名;未来可选做“搜索增强视角”专题页 |
| `ui_sample` | 面向 PoC / 验证的前台 UI 采样结果 | 否 | 仅验证报表和 badcase,不进主 Dashboard |
核心规则:
1. **跨平台曝光、竞品、问题排行默认只看 `api_standard`。**
2. **引用相关页面默认只看 `api_search_grounded`。**
3. **不支持搜索增强或引文的平台,在引用页面中标记为“未接入 / 不支持”,不按 0 值并入分母。**
4. **`ui_sample` 只用于验证,不参与 `monitoring_*` 主汇总。**
### 2.2 Schema Delta(覆盖 V2 Schema 的必要修订)
V2 的基础表结构仍可复用,但以下修订是必需项。
#### 2.2.1 `ai_platforms`
增加平台能力字段,避免查询层硬编码:
1. `supports_standard BOOLEAN NOT NULL DEFAULT true`
2. `supports_search_grounded BOOLEAN NOT NULL DEFAULT false`
3. `supports_citation BOOLEAN NOT NULL DEFAULT false`
4. `supports_batch BOOLEAN NOT NULL DEFAULT false`
5. `default_run_mode VARCHAR(30) NOT NULL DEFAULT 'api_standard'`
#### 2.2.2 `question_monitor_runs`
新增以下字段:
1. `business_date DATE NOT NULL`:统计归属日,允许 `run_at` 发生在次日凌晨。
2. `run_mode VARCHAR(30) NOT NULL``api_standard` / `api_search_grounded` / `ui_sample`
3. `provider_model VARCHAR(100) NOT NULL`
4. `prompt_template_version VARCHAR(30) NOT NULL`
5. `search_enabled BOOLEAN NOT NULL DEFAULT false`
6. `request_id VARCHAR(100)`
7. `retry_count INT NOT NULL DEFAULT 0`
建议增加幂等索引:
```sql
CREATE UNIQUE INDEX uk_monitor_run_idempotent
ON question_monitor_runs(tenant_id, brand_id, question_version_id, ai_platform_id, run_mode, business_date);
```
#### 2.2.3 `question_monitor_parse_results`
新增以下字段:
1. `run_mode VARCHAR(30) NOT NULL`
2. `search_hit BOOLEAN NOT NULL DEFAULT false`
3. `search_info_json_key TEXT`
4. `extractor_version VARCHAR(30) NOT NULL DEFAULT 'v1'`
#### 2.2.4 新增任务与质量表
新增下列表:
1. `monitoring_collect_tasks`:调度任务、重试、死信、优先级、租约状态
2. `monitoring_citation_facts`:引用规范化事实表,保存 `normalized_url/domain/citation_hash`
3. `monitoring_parse_badcases`:低置信度样本与人工回标结果
#### 2.2.5 汇总表统一增加 `run_mode`
以下汇总表必须增加 `run_mode`,并进入唯一键:
1. `monitoring_brand_daily_overview`
2. `monitoring_platform_daily`
3. `monitoring_platform_question_daily`
4. `monitoring_competitor_daily`
5. `monitoring_competitor_platform_daily`
6. `monitoring_topic_daily`
7. `monitoring_question_daily`
8. `monitoring_citation_source_daily`
9. `monitoring_citation_page_daily`
10. `monitoring_brand_impression`
#### 2.2.6 问题版本进入汇总主键
以下表必须增加 `question_version_id` 并进入唯一键:
1. `monitoring_platform_question_daily`
2. `monitoring_question_daily`
推荐唯一键修订:
```sql
UNIQUE (tenant_id, brand_id, ai_platform_id, question_id, question_version_id, run_mode, metric_date)
UNIQUE (tenant_id, brand_id, question_id, question_version_id, run_mode, metric_date)
```
#### 2.2.7 引用汇总改为从规范化事实表聚合
`monitoring_citation_source_daily``monitoring_citation_page_daily` 不再直接从 `citation_urls JSONB` 聚合,而是从 `monitoring_citation_facts` 聚合:
1. 页面级汇总按 `normalized_url`
2. 站点级汇总按 `domain` / `site_name`
3. 原始 `page_url` 仅用于回放与调试
---
## 3. 读流量模型与缓存架构
### 3.1 页面 Fan-out 矩阵
V2 错误地按"每人 30 秒 1 个请求"建模。实际上,每个页面打开时会 **并行** 发出多个 API 请求。
| 页面 | 并行请求数 | 对应 API |
| --- | --- | --- |
| 主页 Dashboard | 6 | overview, exposure-trend, competitors, citation-sources, recent-questions, brand-impression |
| 平台占比分析 | 2 | platform-stats, platform-questions |
| 竞争对手分析 | 2 | competitor-exposure, competitor-detail |
| 业务主题分析 | 1 | topics |
| AI 对话问题 | 1 | questions (分页) |
| AI 引用排名 | 2 | citation-trend, citation-pages |
加权平均(按访问占比估算:主页 50%、其他页各 10%):
```
weighted_fan_out = 0.50 × 6 + 0.10 × 2 + 0.10 × 2 + 0.10 × 1 + 0.10 × 1 + 0.10 × 2
= 3.0 + 0.2 + 0.2 + 0.1 + 0.1 + 0.2
= 3.8 次/页面打开
```
### 3.2 QPS 模型
| 场景 | 计算 | QPS |
| --- | --- | --- |
| 稳态(50K 用户,平均 30 秒交互 1 次) | 50000 × 3.8 / 30 | **6,333** |
| 突刺(早会/开盘集中打开,10 秒内 30% 用户首屏加载) | 15000 × 6 / 10 | **9,000** |
| 筛选切换(20% 用户同时切换品牌/时间范围) | 10000 × 3.8 / 5 | **7,600** |
| 设计容量(取 P99 峰值 × 1.5 安全余量) | max(6333, 9000, 7600) × 1.5 | **~13,500** |
**结论**:系统必须在 Redis 层稳定承接 13,500 QPS。穿透到 PG 的流量目标 < 1,350 QPS10% miss rate)。
### 3.3 BFF 聚合层(减少 fan-out 的关键手段)
为降低首页 6 次并行请求的压力,引入 **BFF 聚合接口**
```
GET /api/tenant/monitoring/dashboard/composite
→ 服务端并行查询 6 个数据源,一次返回全部首页数据
→ 默认固定 `scope=standard`
→ 单次 Redis GET 命中完整 composite 缓存即可返回
→ 缓存 miss 时服务端内部 goroutine 并行查 6 个汇总表
```
前端首屏只发 **1 个请求**,极端情况下用户手动刷新某个区块再发单接口。
| 方案 | 首页 QPS (50K 用户) | Redis 压力 |
| --- | --- | --- |
| 6 个独立接口 | ~10,000 | 60K ops/s |
| composite 聚合 | ~1,667 | 1,667 ops/s |
其他页面 fan-out ≤ 2,不需要聚合。
### 3.4 缓存防雪崩机制
V2 仅使用 TTL + 聚合后全量删除,存在雪崩风险。V3 引入 4 层防护:
#### 3.4.1 Singleflight
同一 cache key 的并发 miss 请求,只放行 1 个打到 PG,其余等待结果。
```go
import "golang.org/x/sync/singleflight"
var sf singleflight.Group
func (s *MonitoringService) getWithCache(ctx context.Context, key string, ttl time.Duration, loader func() (any, error)) (any, error) {
// 1. 尝试 Redis GET
if cached, err := s.cache.Get(ctx, key); err == nil {
return cached, nil
}
// 2. Singleflight: 同 key 只执行一次 loader
result, err, _ := sf.Do(key, func() (any, error) {
data, err := loader()
if err != nil {
return nil, err
}
// 写回 RedisTTL 加随机抖动
jitteredTTL := ttl + time.Duration(rand.Int63n(int64(ttl/5)))
_ = s.cache.Set(ctx, key, data, jitteredTTL)
return data, nil
})
return result, err
}
```
#### 3.4.2 TTL 随机抖动
防止大量 key 同时过期导致集中穿透。
```
实际 TTL = 基础 TTL + random(0, 基础 TTL × 20%)
```
例如 5 分钟基础 TTL → 实际 300~360 秒。
#### 3.4.3 Stale-While-Revalidate
缓存过期后,先返回旧数据,后台异步刷新。用户不感知延迟。
```go
// Redis 存储两个 key:
// mon:{key} → 数据 (TTL = base_ttl)
// mon:{key}:stale → 数据 (TTL = base_ttl × 3,作为兜底)
//
// 读取流程:
// 1. GET mon:{key} → 命中 → 返回
// 2. miss → GET mon:{key}:stale → 命中 → 返回 stale 数据 + 异步触发刷新
// 3. 都 miss → 同步查 PG + singleflight
```
#### 3.4.4 热点保护(Hot Key
对高频访问的品牌(如首页默认品牌),使用 **进程内 L1 缓存**Go sync.Map 或 groupcache):
```
请求 → L1 进程缓存 (TTL 10s) → Redis (TTL 5min) → PG
```
L1 缓存的极短 TTL 保证数据不会太旧,但能挡住 Redis 的瞬时压力。
### 3.5 缓存 Key 修订
修复 V2 的分页缓存 key 缺少 `page_size` 的问题,同时将统计口径 `scope` 纳入缓存维度。
约定:
1. `scope=standard` 对应 `run_mode=api_standard`
2. `scope=search` 对应 `run_mode=api_search_grounded`
3. `ui_sample` 不进入线上缓存
```
# 分页接口必须包含 scope + page + page_size
mon:{tenant}:{brand}:platform:{pid}:questions:{days}:{scope}:{page}:{pageSize}
mon:{tenant}:{brand}:questions:{pid}:{days}:{scope}:{page}:{pageSize}
mon:{tenant}:{brand}:citation_pages:{days}:{scope}:{page}:{pageSize}
# composite 聚合接口
mon:{tenant}:{brand}:dashboard_composite:{days}:{scope}
# 非分页接口也加 scope
mon:{tenant}:{brand}:overview:{days}:{scope}
mon:{tenant}:{brand}:trend:{days}:{scope}
mon:{tenant}:{brand}:competitors:{days}:{scope}
...
```
### 3.6 缓存失效策略修订
V2 的"聚合后删除品牌全部缓存"策略改为 **渐进式失效**
| 时机 | 操作 | 原因 |
| --- | --- | --- |
| 聚合 Job 完成 | **不删除** 现有缓存 | 避免 5 万用户同时穿透 |
| 聚合 Job 完成 | 异步预热 Top 20 热门品牌的 composite 缓存 | 用新数据替换旧缓存 |
| 聚合 Job 完成 | 设置全局标记 `mon:agg_version:{tenant}` +1 | 通知各实例 L1 缓存过期 |
| 自然过期 | TTL 到期后正常 singleflight 回源 | 冷数据自然刷新 |
### 3.7 修订后的 TTL 矩阵
| 数据类型 | Redis TTL | Stale TTL | L1 TTL | 说明 |
| --- | --- | --- | --- | --- |
| Dashboard composite | 5 min | 15 min | 10 s | 首页入口,高频访问 |
| Overview 指标卡 | 5 min | 15 min | 10 s | 同上 |
| 趋势图 | 15 min | 45 min | 30 s | 日级数据,变化慢 |
| 竞品排名 | 15 min | 45 min | 30 s | 同上 |
| 引用来源 | 15 min | 45 min | 30 s | 同上 |
| 业务主题 | 30 min | 90 min | 60 s | 低频变更 |
| 问题列表(分页) | 10 min | 30 min | 不缓存 | 粒度太细,L1 命中率低 |
| 品牌印象 | 30 min | 90 min | 60 s | 日级聚合 |
| AI 平台列表 | 1 hour | 3 hour | 5 min | 几乎不变 |
---
## 4. 采集流水线扩容
### 4.1 V2 瓶颈分析
V2 的问题:
- 5 worker、50,000 任务 → 每任务仅 0.72 秒,不现实
- 凌晨 1 点采集、3 点聚合 → 2 小时时间窗太紧
- Collector ×1、Aggregator ×1 → 单点故障
### 4.2 任务量重新估算
```
标准模式:
100 品牌 × 100 问题/品牌 × 5 AI 平台 = 50,000 任务/天
搜索增强模式(首批仅 3 平台: qwen / doubao / hunyuan:
100 品牌 × 100 问题/品牌 × 3 AI 平台 = 30,000 任务/天
合计 = 80,000 任务/天
# 但问题 hash 去重后(V1 设计)
假设去重率 30% → 实际 56,000 唯一任务
# 每次 AI API 调用 + 解析 + 入库:
平均耗时: 8 秒 (含 AI 生成 5s + 解析 2s + 入库 1s)
P99 耗时: 20 秒 (含重试)
```
### 4.3 Worker 池设计
```
所需总处理时间 = 56,000 × 8s = 448,000 秒
可用采集时间窗 = 6 小时 = 21,600 秒 (0:00 ~ 6:00)
所需并发 worker = 448,000 / 21,600 ≈ 21
安全系数 1.4 → 30 worker
```
**架构**
```
┌─────────────────────────────────────────────────────┐
│ 采集调度器 (Scheduler) │
│ • 凌晨 0:00 启动 │
│ • 生成 business_date = yesterday 的全部任务 │
│ • 任务维度: 品牌 × 去重问题 × 平台 × run_mode │
│ • 写入 Queue Redis Stream: mon:collect:tasks │
└───────────────────────┬─────────────────────────────┘
┌─────────────┼─────────────┬─────────────┐
▼ ▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ Collector │ │ Collector │ │ Collector │ │ Collector │
│ Pod #1 │ │ Pod #2 │ │ Pod #3 │ │ standby HPA│
│ worker ×10 │ │ worker ×10 │ │ worker ×10 │ │ 按需扩容 │
└──────┬─────┘ └──────┬─────┘ └──────┬─────┘ └──────┬─────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────┐
│ Queue Redis Stream │
│ mon:collect:tasks (pending → ack) │
│ Consumer Group: collectors │
└─────────────────────────────────────────┘
```
**为什么用 Redis Stream 而不是内存队列**
- 多 Pod 竞争消费,自动负载均衡
- 消息持久化,Pod 重启不丢任务
- Consumer Group 的 XACK 机制提供 at-least-once 保证
- 但必须使用 **独立 Queue Redis 故障域**,不能和在线缓存共用实例
### 4.4 时间窗修订
```
0:00 调度器生成 business_date = yesterday 的全部采集任务 → Queue Redis Stream
0:00 ~ 6:00 Collector workers 消费任务,调用 AI API + 解析 + 写 PG/MinIO
6:00 检查未完成任务数量,告警如果 > 5%
6:10 将未完成任务转入 backfill 队列,不阻塞晨间主聚合
6:30 Aggregator 聚合 business_date = yesterday 且 status = succeeded 的数据
7:00 聚合完成 → 预热热门品牌缓存
7:30 T+1 数据上线,用户访问“昨日完整快照”
```
说明:
1. `run_at` 是实际执行时间,可能发生在次日凌晨。
2. `business_date` 是统计归属日,Dashboard 晨间看到的是 T+1 快照。
3. 晚到任务走 backfill,单独触发品牌级重聚合,不污染主发布窗口。
预留 6 小时采集窗口(vs V2 的 2 小时),即使 P99 延迟也有足够 buffer。
### 4.5 采集重试与限流
```go
// 每个 AI 平台独立的限流器
type PlatformRateLimiter struct {
limiters map[string]*rate.Limiter // provider_key → limiter
}
// 默认限流配置
var defaultLimits = map[string]rate.Limit{
"deepseek": 10, // 10 QPS
"qwen": 10,
"doubao": 10,
"hunyuan": 5,
"ernie": 5,
}
// 重试策略
const (
maxRetries = 3
retryBaseWait = 2 * time.Second // 指数退避: 2s, 4s, 8s
)
```
此外,Collector 必须基于 `run_mode` 和平台能力做任务路由:
1. 不支持 `api_search_grounded` 的平台不生成搜索增强任务。
2. `ui_sample` 任务只进入 PoC / badcase 流水线,不进入主聚合队列。
3. `api_standard``api_search_grounded` 使用不同的 prompt 模板版本。
### 4.6 采集容错
```go
// 单条任务失败不阻塞批次
func (w *CollectorWorker) processTask(ctx context.Context, task CollectTask) {
for attempt := 1; attempt <= maxRetries; attempt++ {
err := w.collect(ctx, task)
if err == nil {
w.stream.XAck(ctx, "mon:collect:tasks", "collectors", task.ID)
return
}
if isRateLimited(err) {
time.Sleep(retryBaseWait * time.Duration(1<<(attempt-1)))
continue
}
// 非限流错误直接标记失败
w.markFailed(ctx, task, err)
w.stream.XAck(ctx, "mon:collect:tasks", "collectors", task.ID)
return
}
w.markFailed(ctx, task, fmt.Errorf("max retries exceeded"))
w.stream.XAck(ctx, "mon:collect:tasks", "collectors", task.ID)
}
```
### 4.7 聚合 Job 扩容
```
┌───────────────────────────────────────────────────────┐
│ Aggregator Job │
│ • 6:30 启动(聚合 business_date = yesterday
│ • 按租户 × 品牌分片,每片独立事务 │
│ • 并发聚合 goroutine: 10 │
│ • 聚合完一个品牌 → 异步预热该品牌缓存 │
│ • 全部完成后 → 全局 agg_version +1 │
└───────────────────────────────────────────────────────┘
```
聚合 Job 部署 **2 个实例**(主备),使用 **PG advisory lock** 选主,只有一个实例实际运行,另一个待命。
---
## 5. 部署拓扑与资源
### 5.1 修正:连接池是 per-process 的
V2 错误地把 `max_open_conns: 50` 当作"3 个实例共享"。实际上 `bootstrap.go``pgxpool.Pool` 是每个进程独立初始化的。
**修正后的连接池规划**
```yaml
# 每个 API 实例
database:
max_open_conns: 20 # 每实例 20
max_idle_conns: 5
# 3 个 API 实例 → PG 总连接: 60
# 3 个 Collector 实例 → PG 总连接: 30 (各 10)
# 1 个 Aggregator 实例 → PG 总连接: 20
# 合计: 110 连接
# PostgreSQL 需配置:
# max_connections = 160 (留 50 余量给运维和监控)
```
### 5.1.1 配置模型 Delta
V3 不能继续沿用当前项目的单 `database`、单 `redis`、单 `llm` 配置模型,必须扩展为以下结构:
```yaml
database:
primary:
host: pg-primary
...
replica:
host: pg-replica
...
redis:
cache:
sentinel:
master_name: geo-cache-master
addrs: ["cache-sentinel-1:26379", "cache-sentinel-2:26379", "cache-sentinel-3:26379"]
db: 0
queue:
sentinel:
master_name: geo-queue-master
addrs: ["queue-sentinel-1:26379", "queue-sentinel-2:26379", "queue-sentinel-3:26379"]
db: 0
monitoring:
providers:
deepseek:
enabled: true
supports_standard: true
supports_search_grounded: false
qwen:
enabled: true
supports_standard: true
supports_search_grounded: true
doubao:
enabled: true
supports_standard: true
supports_search_grounded: true
hunyuan:
enabled: true
supports_standard: true
supports_search_grounded: true
ernie:
enabled: false # PoC 通过后再开启
```
这意味着:
1. `server/internal/shared/config/config.go` 必须扩展结构体,不是“小改动”。
2. `bootstrap.go` 必须初始化双 DB pool、双 Redis client、provider registry。
3. 监测模块必须摆脱全局单 provider `llm.Client` 的限制。
### 5.2 部署架构
```
┌─────────────────────────────┐
│ CDN (静态资源) │
│ admin-web 打包后的 JS/CSS │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ Nginx / 云 LB (SLB/ALB) │
│ API 路由 + 健康检查 │
│ 限流: 每 IP 200 req/s │
└──────────┬───────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ tenant-api │ │ tenant-api │ │ tenant-api │
│ Pod #1 │ │ Pod #2 │ │ Pod #3 │
│ API 20conn │ │ API 20conn │ │ API 20conn │
│ L1 cache │ │ L1 cache │ │ L1 cache │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
┌──────┴─────────────────────┴─────────────────────┴──────┐
│ Cache Redis 6+ (主从 Sentinel) │
│ 用途: API 缓存 + JWT 会话 │
└────────────────────────┬─────────────────────────────────┘
┌────────────────────────┼────────────────────────┬────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ PG Primary │────────▶│ PG Replica │ │ MinIO │ │ Queue Redis │
│ (读写) │ 流复制 │ (只读) │ │ (原始回答) │ │ Streams │
│ 160 conns │ │ 50 conns │ │ S3 兼容 │ │ 独立故障域 │
└─────────────┘ └─────────────┘ └─────────────┘ └──────┬──────┘
▲ ▲ │
│ │ │
│ ┌────────────────────┘ Aggregator 的重查询走 Replica │
│ │ │
┌──────┴───┴──────────────────────────────────────────────┐
│ 独立进程(不接入 LB) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ │ Collector Pod #1 │ │ Collector Pod #2 │ │ Collector Pod #3 │ ×3 Pod
│ │ worker ×10 │ │ worker ×10 │ │ worker ×10 │ (竞争消费)
│ │ PG conn: 10 │ │ PG conn: 10 │ │ PG conn: 10 │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │ │
│ └────────────────────┴────────────────────┘
│ │
│ ▼
│ Queue Redis Stream
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Aggregator #1 │ │ Aggregator #2 │ 主备(锁选主)│
│ │ (active) │ │ (standby) │ │
│ │ PG conn: 20 │ │ PG conn: 0 │ │
│ │ 读走 Replica │ │ │ │
│ └─────────────────┘ └─────────────────┘ │
╚══════════════════════════════════════════════════════════╝
```
### 5.3 Redis 高可用与故障域隔离
V2 使用 Redis 单点,且在线缓存与离线任务共用同一实例。V3 修正为 **双 Redis 故障域**
1. `redis.cache`:面向 API 读流量,承接缓存与会话
2. `redis.queue`:面向离线采集,承接 Stream 队列
推荐配置:
```yaml
redis:
cache:
sentinel:
master_name: "geo-cache-master"
addrs:
- "cache-sentinel-1:26379"
- "cache-sentinel-2:26379"
- "cache-sentinel-3:26379"
db: 0
queue:
sentinel:
master_name: "geo-queue-master"
addrs:
- "queue-sentinel-1:26379"
- "queue-sentinel-2:26379"
- "queue-sentinel-3:26379"
db: 0
```
Go 客户端(go-redis v9)原生支持 Sentinel,但这里需要初始化 **两个** client
```go
cacheRDB := newRedisClient(cfg.Redis.Cache)
queueRDB := newRedisClient(cfg.Redis.Queue)
```
### 5.4 PostgreSQL 读写分离
API 服务的监测查询走 **PG Replica**。通过在 `MonitoringService` 中注入两个 pool
```go
type MonitoringService struct {
writePool *pgxpool.Pool // Primary,仅供 aggregator 写入
readPool *pgxpool.Pool // ReplicaDashboard 查询
cache cache.Cache
sf singleflight.Group
}
```
`bootstrap.go` 中初始化时,如果配置了 `database.replica`,则创建第二个连接池。
### 5.5 资源规格修订
| 组件 | 实例数 | 规格 | PG 连接 | 说明 |
| --- | --- | --- | --- | --- |
| **tenant-api** | 3 | 4C8G | 20/实例 | API 含 L1 缓存、singleflight |
| **Cache Redis Sentinel** | 1主1从3哨兵 | 8G 内存 | - | API 缓存 + JWT 会话 |
| **Queue Redis Sentinel** | 1主1从3哨兵 | 4G 内存 | - | Stream 队列,独立故障域 |
| **PG Primary** | 1 | 8C32G | max 160 | SSD,汇总表 + 原始采集表 |
| **PG Replica** | 1 | 4C16G | max 50 | SSDDashboard 只读查询 |
| **MinIO** | 1 | 4C8G, 100G SSD | - | 原始回答对象存储 |
| **Collector** | 3 Pod | 2C4G | 10/Pod | Queue Redis Stream 竞争消费 |
| **Aggregator** | 2 Pod (主备) | 4C8G | 20 (active only) | 分布式锁选主 |
| **CDN** | - | - | - | 静态资源 + Nginx 限流 |
---
## 6. 降级与容灾
### 6.1 降级策略
| 故障场景 | 降级措施 | 用户感知 |
| --- | --- | --- |
| **Cache Redis 主节点故障** | Sentinel 自动切换从节点为主(< 30s) | 短暂超时后恢复 |
| **Cache Redis 完全不可用** | API 直接查 PG,跳过缓存;Nginx 层限流降至 50 req/s/IP | 响应变慢但可用 |
| **Queue Redis 故障** | 采集调度与 Collector 暂停;线上查询不受影响 | 当天数据延迟,但 Dashboard 继续服务旧数据 |
| **PG Primary 故障** | Dashboard 查询走 Replica 不受影响;采集/聚合暂停 | Dashboard 可用,数据冻结在最后聚合时间 |
| **PG Replica 故障** | Dashboard 查询回退到 Primary | 无感知,Primary 负载上升 |
| **单个 AI 平台 API 超时** | 该平台任务标记 failed,其他平台正常采集 | 该平台在对应模式下标记“数据缺失”,不强制展示为 0 |
| **全部 AI 平台不可用** | 当天采集任务全部失败,Dashboard 展示前一天数据 | 显示"数据更新时间:昨日" |
| **Collector Pod 全挂** | Queue Redis 消息堆积,等待 Pod 恢复后继续消费 | 当天数据延迟 |
| **Aggregator 异常** | 另一个 Aggregator Pod 抢锁接管 | 无感知 |
### 6.2 熔断器
对外部 AI 平台 API 使用熔断器(circuit breaker):
```go
// 每个 AI 平台独立的熔断器
type PlatformBreaker struct {
breakers map[string]*circuitbreaker.CircuitBreaker
}
// 配置
// 连续 5 次失败 → 打开熔断 → 30 秒后半开 → 1 次成功 → 关闭
```
### 6.3 监控告警
| 指标 | 告警阈值 | 通知方式 |
| --- | --- | --- |
| API P99 延迟 | > 2s 持续 5 分钟 | 钉钉/飞书 |
| Cache Redis 命中率 | < 80% 持续 10 分钟 | 钉钉/飞书 |
| Queue Redis backlog | > 10,000 持续 10 分钟 | 钉钉/飞书 |
| PG 连接池使用率 | > 80% | 钉钉/飞书 |
| 采集任务失败率 | > 10% 单平台 | 钉钉/飞书 |
| 聚合 Job 未在 8:00 前完成 | 超时 | 电话 + 钉钉 |
| Cache/Queue Redis Sentinel 主从切换 | 任何切换事件 | 电话 + 钉钉 |
### 6.4 数据一致性保障
| 场景 | 措施 |
| --- | --- |
| 聚合 Job 被中断 | 每个品牌的聚合是独立事务,失败后可重跑该品牌 |
| 采集与聚合时间窗重叠 | 聚合只处理 `business_date = yesterday AND status = succeeded` 的数据 |
| 缓存与 PG 不一致 | stale-while-revalidate 保证最终一致,不保证强一致 |
| 原始回答写 MinIO 失败 | 标记该 run 为 failed,解析结果不写入 |
| 晚到补采任务 | 进入 backfill 队列,只重聚合受影响品牌,不改写已发布快照的全局口径 |
---
## 7. 压测验收标准
### 7.1 压测场景模型
| 场景编号 | 场景名称 | 虚拟用户数 | 行为 | 持续时间 |
| --- | --- | --- | --- | --- |
| S1 | 稳态浏览 | 50,000 | 每 30 秒打开 1 个页面(加权 fan-out 3.8 | 30 min |
| S2 | 早高峰突刺 | 50,000 | 0~60 秒内全部打开首页(composite 接口) | 1 min ramp-up |
| S3 | 筛选风暴 | 20,000 | 每 5 秒切换品牌/时间范围 | 10 min |
| S4 | 缓存失效 | 50,000 | 手动清空 Cache Redis 后观察恢复 | 5 min |
| S5 | Cache Redis 故障 | 50,000 | kill Cache Redis master,观察 Sentinel 切换 | 2 min |
| S6 | Queue Redis 故障 | 0 | kill Queue Redis master,观察采集暂停与恢复 | 30 min |
### 7.2 性能目标(SLA
| 指标 | 目标值 | 说明 |
| --- | --- | --- |
| API P50 延迟 | < 50 ms | 稳态,Redis 命中 |
| API P95 延迟 | < 200 ms | 稳态 |
| API P99 延迟 | < 500 ms | 含缓存 miss + PG 查询 |
| API P99 延迟(突刺) | < 2 s | S2 场景 |
| 错误率 | < 0.1% | 5xx 错误 |
| Cache Redis 命中率 | > 90% | 稳态 |
| PG QPS(穿透) | < 1,500 | 10% miss rate |
| 采集完成率 | > 95% | 日采集任务成功率 |
| 聚合 SLA | 8:00 前完成 | 用户上班前数据可用 |
| Cache Redis 故障恢复 | < 30 s | Sentinel 自动切换 |
| Queue Redis 恢复后 backlog 清空 | < 60 min | 采集可恢复性 |
### 7.3 压测工具与脚本
```
# 推荐工具: k6 (grafana/k6)
# 脚本位置: tests/load/
tests/load/
├── k6.config.js # 共享配置
├── scenarios/
│ ├── s1_steady_browse.js # 稳态浏览
│ ├── s2_peak_burst.js # 早高峰突刺
│ ├── s3_filter_storm.js # 筛选风暴
│ ├── s4_cache_flush.js # 缓存失效
│ └── s5_cache_redis_failover.js # Cache Redis 故障
├── helpers/
│ ├── auth.js # 登录获取 token
│ └── brands.js # 随机品牌选择
└── README.md # 运行说明(S6 Queue Redis 故障为运维演练,无 k6 脚本)
```
### 7.4 压测前置条件
1. **数据准备**:100 品牌 × 100 问题 × 5 平台 × 30 天汇总数据已填充
2. **用户池**:50,000 个预创建的测试用户 token
3. **环境**:与生产同规格的独立压测环境
4. **监控**Prometheus + Grafana 大盘实时观察
### 7.5 验收流程
```
Phase 1: 基线测试
→ 5,000 用户稳态,确认 P99 < 100ms
→ 记录基线指标
Phase 2: 容量爬坡
→ 逐步提升: 10K → 20K → 30K → 40K → 50K
→ 每级停留 5 分钟,观察指标是否劣化
→ 记录每级的 P50/P95/P99、错误率、Redis/PG 指标
Phase 3: 极限测试
→ 50K 稳态 30 分钟
→ 中间插入 S2 突刺、S3 筛选风暴
→ 确认所有 SLA 指标达标
Phase 4: 容灾演练
→ S4 缓存失效:确认 singleflight 生效,PG 不被打爆
→ S5 Cache Redis 故障:确认 Sentinel 切换 < 30sAPI 降级可用
→ S6 Queue Redis 故障:确认在线 API 不受影响,采集恢复后 backlog 可消化
Phase 5: 报告
→ 输出压测报告,包含:
- 每个场景的延迟分布图
- 资源使用率(CPU/内存/连接池/磁盘IO)
- 瓶颈分析与调优建议
- 通过/未通过结论
```
---
## 8. 实施计划修订
在 V2 原有 Phase 15~21 的基础上,调整以下 Phase:
### Phase 15 修订:DB 迁移 + 数据口径
新增:
- `run_mode``business_date`、平台能力字段 migration
- `question_version_id` 进入问题级汇总唯一键
- 新增 `monitoring_collect_tasks` / `monitoring_citation_facts` / `monitoring_parse_badcases`
- 引用聚合改为从规范化事实表出发
### Phase 16 修订:后端查询服务
新增:
- `getWithCache()` 通用缓存方法 + singleflight + stale-while-revalidate
- Dashboard composite 聚合接口
- `MonitoringService` 注入 readPool/writePool
- L1 进程内缓存层
- 所有查询默认显式带 `run_mode` / `scope`
### Phase 17 修订:采集流水线
新增:
- Queue Redis Stream 任务队列替代内存队列
- `MonitoringProvider` 平台适配器注册表
- `PlatformRateLimiter` 每平台独立限流
- `PlatformBreaker` 熔断器
- 多 Pod 竞争消费架构
- 重试 + 容错逻辑
- `business_date` + backfill 流程
- `api_standard` / `api_search_grounded` 分流调度
### Phase 18 修订:聚合 Job
新增:
- 分布式锁选主
- 聚合后预热热门品牌缓存(替代全量删除)
- 并发 goroutine 聚合(10 并发)
- 聚合读走 PG Replica
- 晚到任务品牌级重聚合
- 引用事实表 → 日汇总表的聚合逻辑
### 新增 Phase 22: 基础设施
- Cache Redis Sentinel 部署配置
- Queue Redis Sentinel 部署配置
- PG 流复制 + Replica 配置
- Nginx 限流配置
- 监控告警配置(Prometheus + Grafana
- 估时:3 天
### 新增 Phase 23: 压测与验收
- 编写 k6 压测脚本(5 个压测场景)+ 1 个运维演练场景
- 数据准备脚本(100 品牌 × 30 天种子数据)
- 执行 Phase 1~4 验收流程
- 输出压测报告
- 估时:3 天
### 新增 Phase 24: 平台 PoC 与口径验收
- 3 品牌 × 30 问题 × 3 天的平台 PoC
- 验证搜索增强引用质量与波动
- 决定百度千帆是否进入首批上线白名单
- 估时:4 天
### 修订后总估时
| Phase | 内容 | V2 估时 | V3 估时 | 增量 |
| --- | --- | --- | --- | --- |
| 15 | DB 迁移 + 数据口径 | 1 天 | 3 天 | +2run_mode、business_date、任务/引用事实表) |
| 16 | 查询服务 + 缓存层 | 3 天 | 6 天 | +3singleflight、composite、L1、读写分离、scope 过滤) |
| 17 | 采集流水线 | 3 天 | 7 天 | +4Provider Registry、双 run_mode、Queue Redis、多 Pod、限流熔断) |
| 18 | 聚合 Job | 2 天 | 4 天 | +2(分布式锁、预热、并发聚合、backfill) |
| 19 | 种子数据 | 1 天 | 1 天 | 0 |
| 20 | 前端 6 页面 + ECharts | 4 天 | 4 天 | 0 |
| 21 | 前端验证 + 集成测试 | 1 天 | 1 天 | 0 |
| 22 | 基础设施(新增) | - | 3 天 | +3 |
| 23 | 压测与验收(新增) | - | 3 天 | +3 |
| 24 | 平台 PoC 与口径验收(新增) | - | 4 天 | +4 |
| **合计** | | **15 天** | **32 天** | **+17 天** |
---
## 9. V2 → V3 变更汇总
| 问题编号 | V2 问题 | V3 解决方案 |
| --- | --- | --- |
| **H0** | `run_mode` 未入模,标准回答与搜索增强回答混算 | 主统计按 `api_standard` / `api_search_grounded` 分层,默认页面显式取数规则 |
| **H1** | 读流量按 1 req/30s 估算,实际首页 6 个并行请求 | 重新建模 fan-out,引入 BFF composite 接口将首页降为 1 请求 |
| **H2** | TTL + 聚合后全删缓存,无防雪崩 | 4 层防护:singleflight + TTL 抖动 + stale-while-revalidate + L1 热点缓存 |
| **H3** | 5 worker、2 小时时间窗,采集跑不完 | 30 worker、6 小时窗口、Queue Redis 多 Pod 竞争消费 |
| **H4** | 问题版本未进入全部汇总唯一键 | `question_version_id` 进入问题级汇总唯一键,避免历史口径覆盖 |
| **H5** | 日采集与日聚合时序矛盾 | 使用 `business_date = yesterday` 的 T+1 发布模型 + backfill |
| **M1** | max_open_conns 50 被当作共享,实际 per-process | 显式规划每进程连接数,PG max_connections=160 |
| **M2** | 缺少压测验收 | 6 个场景模型 + SLA 指标 + k6 脚本 + 4 阶段验收流程 |
| **M3** | 分页缓存 key 缺 page_size | 所有分页 key 加入 `scope + page_size` 维度 |
| **M4** | 引用按原始 URL 聚合,易重复计数 | 引入 `monitoring_citation_facts`,按 `normalized_url/domain` 聚合 |
| **M5** | 在线缓存与离线队列共用 Redis 故障域 | Cache Redis / Queue Redis 拆分,分别高可用 |
| **新增** | 无降级容灾策略 | 双 Redis Sentinel + PG 读写分离 + 熔断器 + 降级措施矩阵 |
| **新增** | 无监控告警 | 7 项核心告警指标 + 阈值 |