# 数据分区策略技术决策

## 背景

系统存储多电商平台（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`（业务时间），因为它反映订单的真实时间线，更适合时序查询。
