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

10 KiB
Raw Blame History

数据分区策略技术决策

背景

系统存储多电商平台(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 数据量小,暂不需要

未来如 products、inventory 等表数据量增长显著,可按同样方式启用分区。

refunds 表不采用 Hypertable 的原因

refunds 表与 orders 表有本质区别:业务聚合的时间维度与退款自身时间不一致

维度 orders 表 refunds 表
分区键候选 created_date(订单创建时间) created_date(退款创建时间)
业务聚合过滤字段 created_date(一致) order_created_dateorder_paid_datecompleted_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_dateorder_paid_datecompleted_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 实现幂等性写入,无需"先查询再写入":

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;
→ 扫描所有 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

-- 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: 这种情况应该很少见。如果发生,可以:

  1. 从其他数据源获取时间范围(如平台 API 返回的更新时间)
  2. 使用较大的时间范围(如最近 1 年)
  3. 接受全 chunk 扫描(数据量小时影响有限)

Q: created_date 和 created_at 有什么区别?

字段 含义 来源
created_date 订单在平台的创建时间 平台 API
created_at 记录写入数据库的时间 框架自动生成

分区键使用 created_date(业务时间),因为它反映订单的真实时间线,更适合时序查询。