diff --git a/docs/scalability.md b/docs/scalability.md new file mode 100644 index 0000000..d579879 --- /dev/null +++ b/docs/scalability.md @@ -0,0 +1,112 @@ +# 可扩展性设计与路线图 (Scalability Roadmap) + +本文档回答一个长期问题:随着商品越来越多、品类越来越杂(食品 / 电子 3C / 药品 / +……),**检索与新增会不会压垮数据库?要不要按品类「分表」?** + +> 结论先行:**现阶段不要按品类手动分表。** 现有「单表 + JSONB + archive_kind 框架」 +> 的设计方向是对的。扩展应当靠 **分区(非分表) + 读副本 + 缓存 + 专用搜索引擎**, +> 按数据量分阶段推进,避免提前过度设计。 + +--- + +## 1. 现状盘点 + +### 1.1 数据模型 +- 所有商品落在**一张 `product` 表**;非食品的领域字段存 `product.attributes`(JSONB)。 +- 食品有独立明细表 `food_detail`(配料 / 营养 / 过敏原等结构化字段)。 +- `0010_archive_kinds` 引入 **archive_kind 框架**:每个品类带一个 `archive_kind` + (`food` / `electronics` / `generic`),`kind_field` 表按 kind 定义字段模板, + 驱动后台动态表单与合格度评分。 +- **加新品类(如药品)不需要新建表**:只要新增一组 `kind_field` 行 + 一棵品类子树; + 仅当某品类有大量需被独立筛选/排序的结构化字段时,才考虑像 `food_detail` 那样补一张 + 明细表。 + +### 1.2 已有索引(检索性能的基础) +| 对象 | 索引 | 用途 | +|------|------|------| +| `product.gtin` | 唯一索引 | 条码精确查 | +| `product.name` | trigram GIN (`pg_trgm`) | 名称模糊/相似匹配 | +| `product.search_tsv` | 全文 GIN (`tsvector`) | 全文检索 | +| `product.attributes` | JSONB GIN | 属性过滤 | +| `brand.name` | trigram GIN | 品牌模糊匹配 | +| `product.country_of_origin` | btree | 产地精确/前缀过滤 | +| category / brand / updated_at | btree | 关联与增量 | + +### 1.3 检索方式 +- 有关键词时:`name ILIKE` ∪ `word_similarity ≥ 阈值(~0.42)` ∪ 条码匹配, + 排序按 `相似度 × (0.5 + quality_score)`。 +- 无关键词时:按 `quality_score` 排序。 +- 翻页:`LIMIT / OFFSET`。 + +### 1.4 写入特征 +- 写库**只有** Python ingestion 一条路径(批量 ETL),不是高并发 OLTP。 +- 插入瓶颈极低;`search_tsv` 由触发器逐行重算,正常增量下开销可忽略。 + +--- + +## 2. 为什么不建议按品类「分表」 + +1. **核心场景是全局检索**:用户通常不知道商品属于哪个品类,搜索要跨所有品类。 + 按品类拆成多表后,一次搜索得 `UNION ALL` 所有表,**更慢、代码更复杂、排序更难统一**。 +2. **单表足够能打**:Postgres 单表配好索引,**几千万行**量级的检索完全可承载。 + "表大"很少是真正瓶颈,"搜索方式"和"读并发"才是。 +3. **分表会侵蚀框架优势**:archive_kind 框架的价值就是"加品类零建表";手动分表等于 + 把这套通用能力又拆碎。 + +> 区分两个概念:**分表(sharding,应用层拆多张表)** ≠ **分区(Postgres 原生 +> declarative partitioning,对上层透明的一张逻辑表)**。后者在超大规模时才有意义, +> 见第 3 阶段。 + +--- + +## 3. 分阶段路线图(按数据量触发,不提前做) + +### 阶段 0 — 现在 ~ 数百万条:维持现状 + 低成本优化 +触发:当前规模。改动小、收益稳,建议尽早做: +- **深翻页改 keyset 分页**:`OFFSET` 越翻越慢(需扫描并丢弃前 N 行);改用 + `WHERE (score, id) < (:last_score, :last_id)` 形式的游标分页。 +- **部分索引**:绝大多数查询限定 `status='active'`,可建 + `CREATE INDEX ... WHERE status='active'` 缩小索引、提速。 +- **常用筛选复合索引**:如 `(category_id, quality_score DESC)`、 + `(archive_kind, quality_score DESC)` 配合域内列表。 +- **Redis 缓存**(已在技术栈内):缓存热门搜索结果与商品详情,挡住重复读。 + +### 阶段 1 — 千万级以上:读扩展 + 调优 +触发:单库读 QPS 升高、P99 变慢。 +- **只读副本(read replica)**:本服务是**只读公益 API**,天然适合一主多从, + 把检索/详情读流量分到副本,主库只承接 ingestion 写入。 +- **索引与查询调优**:按慢查询日志补/删索引,`EXPLAIN ANALYZE` 校核计划。 +- **(可选)Postgres 原生分区**:若多数检索"限定单一域"(只搜药品 / 只搜食品), + 可按 `archive_kind` 做 **LIST 分区**(对上层透明,仍是一张逻辑表)。主要利好 + **维护**(分区级 vacuum / 归档)与**域内查询裁剪**;对真正的全局搜索帮助有限。 + +### 阶段 2 — 搜索相关性/规模成为痛点:引入专用搜索引擎 +触发:`pg_trgm`/`tsvector` 在相关性排序、跨字段检索、规模上吃力。 +- 把**检索**迁到专用倒排引擎:**OpenSearch / Meilisearch / Typesense**, + 或 Postgres 内的 **ParadeDB(`pg_search`,BM25)**。 +- **Postgres 仍是唯一事实来源**;搜索引擎只做索引,由 ingestion 在写库后同步。 +- 这才是"搜索量大"的正解,**比分表有效得多**。 + +--- + +## 4. 大批量导入的建议 +- 海量初始化/回填用 `COPY` 而非逐行 `INSERT`。 +- 超大批量时可"先停建二级索引 → COPY → 重建索引",比边插边维护索引快得多。 +- ETL 控制并发与批大小,避免与在线读争抢。 + +--- + +## 5. 药品档案怎么落地(回到最初的问题) +在上述设计下,加"药品"属于**阶段 0 的常规扩展**,不触动架构: +1. 新增 `drug` 的 `kind_field` 模板(批准文号 / 通用名 / 商品名 / 剂型 / 规格 / + 生产企业 / OTC 分类 / 适应症 / 用法用量 / 不良反应 / 禁忌 / 注意事项 / 贮藏 / + 有效期 等),`qualified` 标记关键字段参与合格度评分。 +2. 加一棵药品品类子树,并把这些品类的 `archive_kind` 置为 `drug`。 +3. 仅当药品需要**被独立筛选/排序的强结构化字段**(如按批准文号精确查、按 OTC 分类 + 过滤)时,才考虑补一张 `drug_detail` 明细表;否则继续走 `attributes` JSONB。 + +--- + +## 6. 版本 +- 本路线图随规模演进更新;任何落地改动需同步:迁移(SQL) + 本文档 +(涉及对外字段时) + `docs/data-contract.md` / `docs/openapi.yaml`。