2026-01-29 14:20:15 +08:00
|
|
|
|
# 数据分区策略技术决策
|
|
|
|
|
|
|
|
|
|
|
|
## 背景
|
|
|
|
|
|
|
|
|
|
|
|
系统存储多电商平台(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 关联,数据量更大 |
|
2026-02-10 12:59:37 +08:00
|
|
|
|
| refunds | **否(不建议)** | 见下方说明 |
|
2026-01-29 14:20:15 +08:00
|
|
|
|
| products | 否 | 数据量小,暂不需要 |
|
|
|
|
|
|
| inventory | 否 | 数据量小,暂不需要 |
|
|
|
|
|
|
|
2026-02-10 12:59:37 +08:00
|
|
|
|
> 未来如 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,以保证业务聚合查询的分区裁剪生效。
|
2026-01-29 14:20:15 +08:00
|
|
|
|
|
|
|
|
|
|
### 为什么选择 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`(业务时间),因为它反映订单的真实时间线,更适合时序查询。
|