Files
datahub/docs/数据分区策略.md
T
2026-01-29 14:20:15 +08:00

9.0 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 数据量小,暂不需要

未来如 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;
→ 扫描所有 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(业务时间),因为它反映订单的真实时间线,更适合时序查询。