275 lines
9.0 KiB
Markdown
275 lines
9.0 KiB
Markdown
# 数据分区策略技术决策
|
||
|
||
## 背景
|
||
|
||
系统存储多电商平台(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`(业务时间),因为它反映订单的真实时间线,更适合时序查询。
|