Files
datahub/docs/数据分区策略.md
2026-02-10 12:59:37 +08:00

296 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 数据分区策略技术决策
## 背景
系统存储多电商平台(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;
→ 扫描所有 chunk2025、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`(业务时间),因为它反映订单的真实时间线,更适合时序查询。