# 数据分区策略技术决策 ## 背景 系统存储多电商平台(20+ 平台)的业务数据,包括订单、退款、产品、库存等实体。预期数据量随业务增长将持续扩大,需要选择合适的分区策略以保证查询性能和数据管理的可维护性。 ### 历史数据参考 - 2025 年全平台订单量:**100W+** - 即使未来每年保持 10 倍增长,单年数据量约 1000W 行 - 按年分区(chunk)可满足当前及中期业务需求 ## 技术决策 ### 方案:TimescaleDB Hypertable + 按年 Chunk ```sql -- 将 orders 表转为 hypertable,按年自动分区 -- 分区字段使用业务时间字段 created_date(订单创建时间) SELECT create_hypertable('orders', 'created_date', chunk_time_interval => INTERVAL '1 year', migrate_data => true -- 迁移现有数据 ); ``` > **注意**:分区字段应使用业务时间字段(如 `created_date`),而非框架自动生成的 `created_at`。业务时间字段记录的是数据在平台的创建时间,更适合作为分区依据。 ### 当前分区范围 | 数据实体 | 是否分区 | 原因 | |---------|---------|------| | orders | **是** | 数据量大(年 100W+),查询频繁 | | order_items | **是** | 与 orders 关联,数据量更大 | | refunds | **否(不建议)** | 见下方说明 | | products | 否 | 数据量小,暂不需要 | | inventory | 否 | 数据量小,暂不需要 | > 未来如 products、inventory 等表数据量增长显著,可按同样方式启用分区。 #### refunds 表不采用 Hypertable 的原因 refunds 表与 orders 表有本质区别:**业务聚合的时间维度与退款自身时间不一致**。 | 维度 | orders 表 | refunds 表 | |------|----------|-----------| | 分区键候选 | `created_date`(订单创建时间) | `created_date`(退款创建时间) | | 业务聚合过滤字段 | `created_date`(一致) | `order_created_date`、`order_paid_date`、`completed_date`(不一致) | 退款的核心业务场景是**营收聚合**,查询条件主要按 `order_created_date`(订单创建时间)、`order_paid_date`(订单付款时间)和 `completed_date`(退款完结时间)过滤,而非退款单自身的 `created_date`。例如: - "2025年12月已付款订单的退款总额" → 按 `order_paid_date` 过滤 - "Q4 订单的退款率" → 按 `order_created_date` 过滤 - "本月实际发生的退款金额" → 按 `completed_date` 过滤(退款完结才确认营收扣减) 如果按退款的 `created_date` 做 Hypertable 分区,上述查询无法利用分区裁剪(一笔 2025-12 的订单,退款可能发生在 2026-02,跨多个 chunk),反而增加了复杂度。 此外,退款数据量远小于订单量,普通表 + B-tree 索引(`order_created_date`、`order_paid_date`、`completed_date`)足以满足查询性能要求。 > 如果未来退款数据量显著增长,建议使用 PostgreSQL **原生 RANGE 分区**按 `order_paid_date` 手动分区,而非 Hypertable,以保证业务聚合查询的分区裁剪生效。 ### 为什么选择 Hypertable 而非原生分区 | 考量 | Hypertable | PostgreSQL 原生分区 | |------|-----------|-------------------| | 连续聚合 | **支持** | 不支持 | | 分区管理 | 自动 | 手动创建 | | 压缩策略 | 内置 | 需手动处理 | | 动态调整 | 一条命令 | 需重建表结构 | **关键限制**:TimescaleDB 连续聚合只能基于 hypertable 创建,系统已设计使用连续聚合(如 `orders_daily_by_created`),因此必须使用 hypertable。 ### 不按平台分区的原因 | 考量 | 说明 | |------|------| | 平台数量有限 | 约 25 个平台,分区收益有限 | | 跨平台查询频繁 | 按公司查询所有平台数据会扫描全部分区 | | 数据分布不均 | 部分平台数据量远大于其他平台,易形成热点 | ### 选择按年 Chunk 的原因 | 考量 | 说明 | |------|------| | 查询模式匹配 | 业务查询通常按时间范围(近期、本月、本季度) | | 数据量适中 | 单年 100W~1000W 行,单 chunk 性能可控 | | 维护简单 | 历史 chunk 可压缩、可归档,生命周期清晰 | ## 写入与查询优化 ### 写入优化 系统采用 `ON CONFLICT` 实现幂等性写入,无需"先查询再写入": ```sql INSERT INTO orders (..., created_date) VALUES (..., '2025-06-15') ON CONFLICT (store_id, platform_order_id, created_date) DO UPDATE SET ... WHERE orders.data_version < EXCLUDED.data_version; ``` Hypertable 下写入只影响当前 chunk 的索引,性能稳定。 > **业务约束**:同一订单的 `created_date` 不会变化,因此 `(store_id, platform_order_id, created_date)` 实际效果等同于全局唯一。 ### 查询优化 | 查询类型 | 优化方式 | |---------|---------| | 单行查询 | WHERE 条件包含时间范围,触发分区裁剪 | | 分页查询 | 基于时间游标分页,避免深 OFFSET | | 聚合查询 | 使用连续聚合视图,增量刷新 | ```sql -- 单行查询:带上时间范围触发分区裁剪 SELECT * FROM orders WHERE store_id = 200 AND platform_order_id = 'DY123' AND created_date >= '2025-01-01'; -- 分页查询:游标分页替代 OFFSET SELECT * FROM orders WHERE created_date < :last_created_date ORDER BY created_date DESC LIMIT 50; ``` ### 索引设计 TimescaleDB 要求**唯一约束必须包含分区键**(`created_date`): ```sql -- 唯一约束(满足 hypertable 要求) -- 业务保证:同一订单 created_date 不变,等效于全局唯一 UNIQUE (store_id, platform_order_id, created_date) -- 复合索引:支持按公司+时间查询 CREATE INDEX idx_orders_company_created ON orders (company_id, created_date DESC); ``` **ON CONFLICT 写法**(需包含分区键): ```sql INSERT INTO orders (..., created_date) VALUES (..., '2025-06-15') ON CONFLICT (store_id, platform_order_id, created_date) DO UPDATE SET ... WHERE orders.data_version < EXCLUDED.data_version; ``` ### 压缩策略 ```sql -- 历史数据压缩(可选) ALTER TABLE orders SET ( timescaledb.compress, timescaledb.compress_segmentby = 'store_id' ); -- 自动压缩 1 年前的数据 SELECT add_compression_policy('orders', INTERVAL '1 year'); ``` ## 性能预期 | 数据量 | 普通表 | Hypertable(按年 chunk) | |--------|--------|-------------------------| | 100W | 无压力 | 无压力 | | 1000W | 开始感受压力 | 无压力 | | 1 亿 | 明显瓶颈 | 可控(单 chunk ~1000W) | ## 未来扩展路径 当单年数据量超过 1000W 行时,可动态调整为按月 chunk: ```sql -- 仅影响新创建的 chunk,历史 chunk 保持不变 SELECT set_chunk_time_interval('orders', INTERVAL '1 month'); ``` ## 结论 - **当前方案**:orders、order_items 表使用 Hypertable + 按年 chunk - **其他实体**:refunds、products、inventory 暂不分区,避免过度设计 - **写入优化**:`ON CONFLICT` 原子操作,无需先查询 - **查询优化**:分区裁剪 + 游标分页 + 连续聚合 - **聚合查询**:连续聚合增量刷新,性能稳定 - **约束限制**:不支持外键,唯一约束需包含分区键,应用层保证数据完整性 - **扩展性**:保留按月 chunk 的能力,一条命令即可调整 --- ## 开发注意事项 ### 为什么 created_date 如此重要 TimescaleDB 是**时序数据库**,其核心设计理念是:**数据按时间组织,查询按时间过滤**。 `created_date` 是 hypertable 的**分区键**,它决定了: | 作用 | 说明 | |------|------| | **数据存储位置** | 数据根据 created_date 写入对应的 chunk | | **查询路由** | 查询条件中的时间范围决定扫描哪些 chunk | | **索引约束** | 唯一约束必须包含 created_date | | **分区裁剪** | 带时间范围的查询可跳过无关 chunk | ### 查询时必须携带 created_date ``` 不带时间范围: SELECT * FROM orders WHERE store_id = 200; → 扫描所有 chunk(2025、2026、2027...) → 数据量越大,性能越差 带时间范围: SELECT * FROM orders WHERE store_id = 200 AND created_date >= '2025-01-01'; → 只扫描 2025 年 chunk → 分区裁剪生效,性能稳定 ``` ### 开发规范 | 场景 | 要求 | |------|------| | **写入** | `ON CONFLICT` 必须包含 `created_date` | | **单行查询** | WHERE 条件尽量包含 `created_date` 范围 | | **列表查询** | 必须包含 `created_date` 范围,使用游标分页 | | **聚合统计** | 优先使用连续聚合视图,避免直接聚合原表 | ### Hypertable 约束限制 **1. 不支持外键引用** TimescaleDB hypertable **不支持被外键引用**。因此: - `order_items.order_id` 不能有指向 `orders.id` 的外键约束 - 数据完整性需在**应用层保证**: - 删除 order 时,同步删除关联的 order_items - 更新 order 时,先删除旧的 order_items,再创建新的 **2. 唯一约束必须包含分区键** 所有唯一约束必须包含 `created_date`: ```sql -- orders 表 UNIQUE (store_id, platform_order_id, created_date) -- order_items 表 UNIQUE (order_id, sub_order_id, created_date) UNIQUE (store_id, platform_order_id, sub_order_id, created_date) ``` **业务保证**:同一订单/子订单的 `created_date` 不会变化,因此上述约束实际效果等同于不含 `created_date` 的全局唯一约束。 ### 代码示例 ```php // ❌ 不推荐:缺少时间范围 Order::where('store_id', $storeId) ->where('platform_order_id', $orderId) ->first(); // ✅ 推荐:带上时间范围 Order::where('store_id', $storeId) ->where('platform_order_id', $orderId) ->where('created_date', '>=', $startDate) ->first(); // ✅ 列表查询:游标分页 Order::where('company_id', $companyId) ->where('created_date', '>=', $startDate) ->where('created_date', '<', $endDate) ->where('created_date', '<', $lastCreatedDate) // 游标 ->orderBy('created_date', 'desc') ->limit(50) ->get(); ``` ### 常见问题 **Q: 如果确实不知道订单的创建时间怎么办?** A: 这种情况应该很少见。如果发生,可以: 1. 从其他数据源获取时间范围(如平台 API 返回的更新时间) 2. 使用较大的时间范围(如最近 1 年) 3. 接受全 chunk 扫描(数据量小时影响有限) **Q: created_date 和 created_at 有什么区别?** | 字段 | 含义 | 来源 | |------|------|------| | `created_date` | 订单在平台的创建时间 | 平台 API | | `created_at` | 记录写入数据库的时间 | 框架自动生成 | 分区键使用 `created_date`(业务时间),因为它反映订单的真实时间线,更适合时序查询。