# 可扩展性设计与路线图 (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`。