9.0 KiB
数据分区策略技术决策
背景
系统存储多电商平台(20+ 平台)的业务数据,包括订单、退款、产品、库存等实体。预期数据量随业务增长将持续扩大,需要选择合适的分区策略以保证查询性能和数据管理的可维护性。
历史数据参考
- 2025 年全平台订单量:100W+
- 即使未来每年保持 10 倍增长,单年数据量约 1000W 行
- 按年分区(chunk)可满足当前及中期业务需求
技术决策
方案:TimescaleDB Hypertable + 按年 Chunk
-- 将 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 实现幂等性写入,无需"先查询再写入":
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 |
| 聚合查询 | 使用连续聚合视图,增量刷新 |
-- 单行查询:带上时间范围触发分区裁剪
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):
-- 唯一约束(满足 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 写法(需包含分区键):
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;
压缩策略
-- 历史数据压缩(可选)
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:
-- 仅影响新创建的 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:
-- 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 的全局唯一约束。
代码示例
// ❌ 不推荐:缺少时间范围
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: 这种情况应该很少见。如果发生,可以:
- 从其他数据源获取时间范围(如平台 API 返回的更新时间)
- 使用较大的时间范围(如最近 1 年)
- 接受全 chunk 扫描(数据量小时影响有限)
Q: created_date 和 created_at 有什么区别?
| 字段 | 含义 | 来源 |
|---|---|---|
created_date |
订单在平台的创建时间 | 平台 API |
created_at |
记录写入数据库的时间 | 框架自动生成 |
分区键使用 created_date(业务时间),因为它反映订单的真实时间线,更适合时序查询。