PAYATHON 2026

TimescaleDB 按 dt 分区时,如何高效查询 modified 最近变更?

数据阿明

建议保留按 dt 分区的历史审计表,并为按 modified 查询建立独立的存储入口。如果这类查询只覆盖最近一段时间,可以增加一张保存近期审计事件的普通 PostgreSQL 表;如果还需要频繁查询任意历史 modified 区间,则需要保留完整的第二份事件数据,或重新评估是否使用单张普通 PostgreSQL 表。

这个建议基于题目描述的访问模式。具体选择还取决于近期窗口、峰值变更量、保留期限和允许增加的写入成本。

为什么已有 modified 索引仍然慢?

按 dt 分区时,modified 条件通常无法推导出需要访问哪些 dt chunks。很久以前的数据被更新时,审计事件会写入旧的 dt chunk,却带有最新的 modified 值。因此,“最近十分钟有变更”的查询可能涉及大量历史 chunks。

hypertable 的索引落实在 chunk 层级。modified 索引可以降低各个 chunk 内的查找成本,但无法提供覆盖所有 chunks 的单一全局 B-tree 访问路径。扫描很多 chunks 并不意味着每个 chunk 都发生了全表扫描,需要结合执行计划区分。TimescaleDB hypertable 与 chunk 文档

chunk skipping 依赖各个 chunk 的列值范围。如果许多历史 chunks 的 modified 最大值都接近当前时间,这些范围就会与近期查询窗口重叠,排除效果有限。官方文档也建议优先对与分区列相关的列使用这一机制。chunk skipping 工作原理

近期查询:增加一张保留窗口有限的事件副本表

“hot 表”应按 modified 判断新旧。旧 dt 数据今天产生的变更,也必须进入该表。

可以采用以下结构:

  • log.data 保存完整审计历史,继续按 dt 分区,承担 id + dt 查询。
  • log.data_recent 保存保留窗口内的完整审计事件,承担按 modified 查询。
  • 每次触发器生成审计记录时,在同一个事务中写入两张表。

可以先用普通 PostgreSQL 表实现,让近期查询不再依赖遍历历史 dt chunks:

CREATE TABLE log.data_recent
(
    LIKE log.data INCLUDING DEFAULTS INCLUDING CONSTRAINTS
);

CREATE INDEX data_recent_modified_idx
    ON log.data_recent (modified);

这条语句创建的是普通表,不会将它转换成 hypertable,也没有复制原表索引。

下面是参数化写入示例。它应替换现有审计记录的写入步骤,并与源表操作处于同一个事务中。实际 trigger 函数需要用对应变量替换 $1 至 $7。

WITH written AS (
    INSERT INTO log.data (
        table_schema, table_name, id, dt,
        operation, cols, row_data
    )
    VALUES ($1, $2, $3, $4, $5, $6, $7)
    RETURNING
        modified, table_schema, table_name, id, dt,
        operation, cols, row_data
)
INSERT INTO log.data_recent (
    modified, table_schema, table_name, id, dt,
    operation, cols, row_data
)
SELECT
    modified, table_schema, table_name, id, dt,
    operation, cols, row_data
FROM written;

通过 RETURNING 传递实际插入的记录,两份数据会使用同一个 modified 值,避免分别调用 clock_timestamp() 产生差异。PostgreSQL 支持通过数据修改 CTE 的 RETURNING 结果向外层语句传递数据。数据修改 CTE 文档

近期查询直接访问副本:

SELECT *
FROM log.data_recent
WHERE modified >= now() - interval '10 minutes';

这样可以把近期查询的访问范围限制在副本表中。实际耗时仍取决于窗口内返回的数据量、索引选择和缓存情况。

保留窗口与上线切换需要明确

近期副本可以保留数天,具体时长应覆盖业务查询窗口,并为长事务和清理延迟预留余量。普通表可以分批删除过期记录,同时关注 autovacuum 和表膨胀。

上线时必须处理已有记录。如果直接启用双写,副本只包含启用后的事件,不能立即代表完整的“最近十分钟”。可以在受控写入切换期间回填,也可以等待查询窗口完全被新写入覆盖后再切换读取。并发回填需要明确去重与一致性策略。

这张表保存的是完整历史中的近期副本,清理副本不应删除 log.data 中的历史记录。如果合并查询两张表,必须划定不重叠的边界,避免同一事件返回两次。

如果 modified 查询覆盖任意历史区间

有限保留的近期表无法解决所有历史区间查询。如果任意 modified 区间也需要稳定的查询性能,可以考虑两种设计。

第一种是保留两个完整的查询副本:一个 hypertable 按 dt 分区,另一个按 modified 分区。每条审计事件写入两份,分别服务两类时间查询。代价是增加存储、索引、WAL 和写入维护成本。

批量更新可能让按 modified 划分的 chunks 大小不均,需要结合峰值写入量评估。缩短时间间隔可以降低单个 chunk 的体量,但无法保证大小均匀。调整 chunk interval 只影响之后新建的 chunks,也不能消除另一个时间维度上的查询分散问题。chunk 大小与间隔说明

第二种是使用单张不分区的普通 PostgreSQL 审计表,分别建立 (modified) 和 (id, dt) 索引。两类查询都可以使用同一张表上的 B-tree,无需复制完整事件。是否能够满足长期规模,需要用真实数据量验证,并评估索引体积、清理成本,以及放弃 hypertable 分块管理和压缩能力的影响。

索引与压缩还能怎样调整?

对于 id = ? AND dt >= ? AND dt <= ?,可以评估 (id, dt) 索引,将等值条件放在前面、范围条件放在后面:

CREATE INDEX data_id_dt_idx ON log.data (id, dt);

这主要优化 chunk 内的查询。原有 (dt, id) 是否保留,应根据其他查询和执行计划决定,不必直接叠加所有索引。PostgreSQL 多列索引文档

压缩配置中的 compress_orderby = 'dt DESC, modified DESC' 不会产生跨 chunks 的全局 modified 排序。压缩后的访问机制与普通 rowstore 索引不同,不能仅凭存在 (modified) 索引就判断压缩数据的读取成本。具体行为需要结合已安装的 TimescaleDB 版本和执行计划确认。rowstore 与 columnstore 索引说明

题目中的 INSERT、UPDATE、DELETE 是源表操作。如果每次变化都向审计表追加新行,审计表本身仍是追加写入。应单独测量向旧 dt chunks,特别是已压缩 chunks,追加事件的写入成本。

如何验证设计是否有效?

使用同一个明确的时间区间,分别检查原表和近期副本的执行计划:

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM log.data
WHERE modified >= $1 AND modified < $2;

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM log.data_recent
WHERE modified >= $1 AND modified < $2;

这里的 $1、$2 需要由客户端绑定。重点比较规划时间、执行时间、访问 chunks 数、缓冲区读取量和实际返回行数,同时测量双写对源表事务的影响。当前没有完整执行计划,无法判断数秒延迟中规划、索引查找、解压和结果读取各占多少。

现有 id 是业务行标识,不能假定它唯一标识一次审计事件。如果后续需要副本去重或精确关联,应增加独立的事件标识,并保留源表身份信息。modified 也不是事务提交顺序;如果用途扩展为保证不漏事件的增量消费,仅按时间戳轮询需要额外的一致性设计。

TimescaleDB 可以继续承担按 dt 查询的历史审计存储。对于题目中两个相关性较弱的时间维度,独立查询副本通常比继续增加分区维度更值得优先验证。

备注:内容仅供参考。