diff --git a/docs/数据分区策略.md b/docs/数据分区策略.md new file mode 100644 index 0000000..ee7600c --- /dev/null +++ b/docs/数据分区策略.md @@ -0,0 +1,274 @@ +# 数据分区策略技术决策 + +## 背景 + +系统存储多电商平台(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 | 否 | 数据量小,暂不需要 | + +> 未来如 refunds 等表数据量增长显著,可按同样方式启用分区。 + +### 为什么选择 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`(业务时间),因为它反映订单的真实时间线,更适合时序查询。