# 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 QPS(10% 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 } // 写回 Redis,TTL 加随机抖动 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 // Replica,Dashboard 查询 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 | SSD,Dashboard 只读查询 | | **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 切换 < 30s,API 降级可用 → 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 天 | +2(run_mode、business_date、任务/引用事实表) | | 16 | 查询服务 + 缓存层 | 3 天 | 6 天 | +3(singleflight、composite、L1、读写分离、scope 过滤) | | 17 | 采集流水线 | 3 天 | 7 天 | +4(Provider 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 项核心告警指标 + 阈值 |