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

39 KiB
Raw Blame History

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 NULLapi_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

建议增加幂等索引:

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

推荐唯一键修订:

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_dailymonitoring_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,其余等待结果。

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

缓存过期后,先返回旧数据,后台异步刷新。用户不感知延迟。

// 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 采集重试与限流

// 每个 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_standardapi_search_grounded 使用不同的 prompt 模板版本。

4.6 采集容错

// 单条任务失败不阻塞批次
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.gopgxpool.Pool 是每个进程独立初始化的。

修正后的连接池规划

# 每个 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 配置模型,必须扩展为以下结构:

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 队列

推荐配置:

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

cacheRDB := newRedisClient(cfg.Redis.Cache)
queueRDB := newRedisClient(cfg.Redis.Queue)

5.4 PostgreSQL 读写分离

API 服务的监测查询走 PG Replica。通过在 MonitoringService 中注入两个 pool

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):

// 每个 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_modebusiness_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 项核心告警指标 + 阈值