Postgres: 在时间戳字段上创建索引

38

我是新手使用postgres,关于时间戳类型有一个问题。

为了说明情况,我有一个如下所示的表:

CREATE TABLE IF NOT EXISTS tbl_example (
    example_id bigint not null,
    example_name text,
    example_timestamp timestamp,
    primary key (example_id)
);

现在我希望运行一个查询,基于一个特定日期,并使用时间戳找到示例列表。

例如,常见的查询是:

SELECT example_id, example_name, example_timestamp
 WHERE example_timestamp = date_trunc('datepart', example_timestamp)
 ORDER BY example_timestamp DESC;

不过,为了加快搜索过程,我考虑在 example_timestamp 字段上添加索引:

CREATE INDEX idx_example_timestamp
          ON tbl_example (example_timestamp);

我的问题是,PostgreSQL如何对时间戳创建索引 - 换句话说,它会根据日期/时间对时间戳进行索引,还是会考虑秒/毫秒等细节?

或者,我正在考虑创建一个名为“example_date”的新列,并在该列上创建索引以简化事情。我不想同时拥有日期和时间戳字段,因为我可以从时间戳字段中获取日期,但是就索引目的而言,也许创建一个单独的字段可能更好。

如果有人对此有任何想法,将不胜感激?


文档中提到时间戳以8字节整数形式存储,而日期则以4字节形式存储,因此理论上仅查看4字节进行排序和搜索比查看8字节更快。但实际上,这可能并不足够重要或值得额外的存储、较慢的插入/更新以及必须记住链接的额外字段,这会使维护编码变得更加复杂。 - Dave S
选择 example_id, example_timestamp 从 tbl_example 表中,其中 example_timestamp = <今天的日期>。 - rm12345
@rm12345请将进一步的细节作为对您的问题的编辑,而不是作为评论提供。 - Basil Bourque
@BasilBourque,问题已按要求更新。 - rm12345
如果您想了解基本的PostgreSQL索引,请访问以下链接:https://hello-worlds.in/2021/05/03/understanding-postgresql-indexes-part-1/ 希望这能帮助您找到答案。 - techagrammer
显示剩余2条评论
3个回答

64

不要担心,保持快乐

Postgres如何对时间戳进行索引 - 换句话说,它会根据日期/时间对时间戳进行索引,还是会进入秒/毫秒等细节?

Postgres使用的索引方案的内部实现通常对您来说是透明的,无需关注。请记住,您今天学习的实现可能会在Postgres的未来版本中发生变化。

您可能会陷入过早优化的陷阱。在您知道自己存在可证明的性能问题之前,请相信Postgres及其默认行为。

时刻

日期时间处理比您想象的要复杂。

首先,您正在使用TIMESTAMP,它实际上是TIMESTAMP WITHOUT TIME ZONE的缩写名称。这种类型不能表示一个时刻。此类型仅存储日期和一天中的时间。例如,2020年1月23日中午12:00。但这是否意味着日本东京的中午?或者法国巴黎的中午,几个小时后?或者美国俄亥俄州托莱多市的中午,几个小时后?

我建议始终完整地展开类型名称,以便在SQL中非常清晰。使用TIMESTAMP WITHOUT TIME ZONE而不是TIMESTAMP

但是,如果您实际上正在尝试表示某个时刻,即时间轴上的特定点,则必须使用TIMESTAMP WITH TIME ZONE。这个名称来自SQL标准。但在Postgres和其他一些数据库中,它有点不准确。Postgres实际上并没有存储时区。相反,Postgres使用与输入一起提交的任何时区或UTC偏移信息进行调整到UTC。写入存储的值始终为UTC。如果您关心原始区域名称或偏移数字(小时-分钟-秒),则需要将其存储在第二列中。
从数据库检索时,该值也以UTC形式出现。但请注意,一些中间件工具坚持在检索后应用默认时区值。虽然出于善意,但这种反功能可能会引起很多混淆。使用如下所示的java.time对象时,您将不会遇到此类混淆。

时间跨度查询

Postgres将一个时刻存储为UTC,可能作为epoch-reference日期时间的计数,因为数据类型被记录为64位(8八位字节)的整数。根据维基百科,Postgres使用2000-01-01作为纪元参考,可能是该日期在UTC中的第一个时刻,即2000-01-01T00:00:00.0Z。我们没有任何理由关心使用的时代参考,但是您可以了解一下。
重点是,Postgres 中的日期时间值仅存储为数字,即微秒计数。时间戳类型不是特定的日期和时间,正如您可能想象的那样。您的查询确实可以从时间戳列上的索引中受益,但不带时间的日期导向查询不会得到具体的好处。该索引不是面向日期的,也不能因为我将在下面解释而是这样的。
从瞬间确定日期需要时区。对于任何给定瞬间,日期因时区而变化。在法国巴黎午夜后几分钟是新的一天,而在魁北克蒙特利尔仍然是“昨天”。
要按日期查询瞬间,需要确定一天的第一个瞬间和随后一天的第一个瞬间。然后我们使用半开放方法来定义一个时间跨度,其中开始是包含的,而结束是排除的。我们搜索等于或晚于开始,同时位于结束之前的瞬间。提示:说“等于或晚于开始”的另一种方式是“不早于”。
您正在使用 Java,因此可以利用业界领先的java.time类。
Java.time 类使用比 Postgres 中使用的纳秒更细的分辨率。因此,您将没有问题将 Postgres 值加载到 Java 中。但是,当进行反向操作时,请注意数据丢失,因为纳秒将被静默截断以仅存储微秒。

在确定一天的第一个时刻时,不要假定一天从00:00:00.0开始。某些区域的某些日期从另一个时间开始,例如01:00:00.0。始终让java.time确定一天的第一个时刻。

ZoneId z = ZoneId.of( "Asia/Tokyo" ) ;                          // Or `Africa/Tunis`, `America/Montreal`, etc.
LocalDate today = LocalDate.now( z ) ;
ZonedDateTime zdtStart = today.atStartOfDay( z ) ;              // First moment of the day.
ZonedDateTime zdtStop = today.plusDays( 1 ).atStartOfDay( z ) ; // First moment of the following day.

请编写半开区间的SQL语句。不要使用SQL命令BETWEEN,因为它不是半开区间。

String sql = "SELECT * FROM tbl WHERE event !< ? && event < ? ;" ;  // Half-Open query in SQL.

将起始和结束值传递给预处理语句。支持 JDBC 4.2 及更高版本的 JDBC 驱动程序 可以使用 PreparedStatement::setObjectResultSet::getObject 处理大多数 java.time。奇怪的是,JDBC 规范不需要支持两种最常用的类型:Instant(始终为 UTC)和ZonedDateTime。这些可能适用于特定驱动程序,也可能不适用。标准要求支持 OffsetDateTime,因此让我们转换为该类型。
preparedStatement.setObject( 1 , zdtStart.toOffsetDateTime() ) ;
preparedStatement.setObject( 2 , zdtStop.toOffsetDateTime() ) ;

传递给PreparedStatementOffsetDateTime对象将携带该时区在该日期时间使用的偏移量。为了调试或好奇,您可能想在UTC中查看这些值。因此,让我们通过提取Instant并应用零小时-分钟-秒的偏移量来调整到UTC,以获得携带UTC本身偏移量的OffsetDateTime

OffsetDateTime start = zdtStart.toInstant().atOffset( ZoneOffset.UTC ) ;
OffsetDateTime stop = zdtStop.toInstant().atOffset( ZoneOffset.UTC ) ;

传递给预处理语句。

preparedStatement.setObject( 1 , start ) ;
preparedStatement.setObject( 2 , stop ) ;

一旦这些startstop值到达数据库服务器,它们将被转换为表示自纪元以来的计数的数字,一个简单的整数。然后,Postgres执行简单的数字比较。如果存在这些整数数字的索引,则该索引可能会根据Postgres查询规划器的需要使用或不使用。
如果您只有相对较少的行且有大量RAM可缓存它们,则可能不需要索引。进行测试,并使用EXPLAIN / ANALYZE查看实际性能。

通过Java的日期列

如果您已经完成了证明面向日期的查询存在性能问题的工作,可以添加第二个DATE类型的列。然后对该列进行索引,并在面向日期的查询中明确引用它。
插入时,还应包含一个计算出的值,表示在适合您的应用程序的任何时区中感知日期。只需确保清楚地记录您的意图以及用于确定日期的时区的具体信息即可。提示:Postgres提供了一项功能,可以在列名和其数据类型旁边将文本片段作为列定义的一部分包含在内。
由于第二个DATE列是从另一列派生出来的,因此它在定义上是冗余的并且去规范化了。通常情况下,您应该仅在最后一步考虑去规范化。
插入值时的Java代码。
String sql = "INSERT INTO tbl ( event , date_tokyo ) VALUES ( ? , ? ) ;" ;

确定当前时刻,并将当前时刻的日期视为在时区Asia/Tokyo中感知的日期。

Instant now = Instant.now() ;  // Always in UTC, no need to specify a time zone here.
OffsetDateTime odt = now.atOffset( ZoneOffset.UTC ) ;  // Convert from `Instant` to `OffsetDateTime` if your JDBC driver does not support `Instant`.
ZoneId z = ZoneId.of( "Asia/Tokyo" ) ;
ZonedDateTime zdt = now.atZone( z ) ;
LocalDate localDate = zdt.toLocalDate() ; // Extract the date as seen at this moment by people in the Tokyo time zone.

传递给你的预处理语句。

preparedStatement.setObject( 1 , odt ) ;
preparedStatement.setObject( 2 , localDate ) ;

现在你可以在date_tokyo列上进行基于日期的查询。如有需要,请创建索引。

通过SQL使用日期列

或者,您可以在Postgres中自动填充date_tokyo列。

触发器

您可以编写一个触发器,使用内置于Postgres中的日期时间函数来确定该时刻在时区Asia/Tokyo中的日期。然后,触发器可以将结果日期值写入该第二列。

生成值列

或者,在Postgres 12中,您可以更简单地使用新的生成列功能。这个新功能可以完成相同的工作,但不需要定义和附加触发器。关于此新功能的讨论,请参见:

在Postgres 12中,具有GENERATED ALWAYS AS (…) STORED的列其值被物理存储,并且可以被索引。

注意事项

重要的是正确了解当前时区定义的信息,这对日期时间处理至关重要。通常,这些信息通过由ICANN/IANA维护的tz data获取。
Java和Postgres都包含自己的tz data副本。
世界各地的政治家们经常喜欢重新定义他们的时区,往往没有或几乎没有提前警告。因此,请确保跟踪您关心的时区变化。当您更新Java或Postgres时,您可能会得到最新的tz数据副本。但在某些情况下,您可能需要手动更新Java和Postgres环境中的一个或两个(Java & Postgres)。另外,您的主机OS也有一个tz数据副本。

1
很好,非常重要的一点是需要指出UTC日期不是用户所认为的日期,因此需要全部使用8个字节。如果真的需要优化,在创建仅包含日期字段时,你需要存储从UTC转换为用户所关心的时区后的日期,正确吗? - Dave S
如果证明需要对日期导向的查询进行性能优化,那么我会添加第二个(冗余的,去规范化的)类型为“DATE”的列,存储从“ZonedDateTime”对象中提取的“LocalDate”日期。或编写触发器,或使用Postgres 12中的新生成列功能。也许我会在这里添加另一节。 - Basil Bourque
关于“时刻”,我认为你的说法,即一个没有时区的时间戳(TIMESTAMP WITHOUT TIME ZONE)不能表示一个时刻是不正确的。看起来,有时区和没有时区的时间戳在大多数情况下是相同的,除了一些查询级别的行为之外,但两者都可以表示时间点,并具有类似的基础表示形式。 - Ivan G.
@IvanG. 请猜猜这是什么时间:2022-01-23T12:00:53 — 是日本东京的中午,法国图卢兹的中午还是美国俄亥俄州托莱多的中午?这三个时刻相差数小时,你不知道哪个时刻是正确的,因为它们在26-27小时的范围内。这就是TIMESTAMP WITHOUT TIME ZONE,绝对不能代表一个时刻。 - Basil Bourque
@BasilBourque 我的论点是,就像“long”类型可以用来表示一个时刻一样,“TIMESTAMP WITHOUT TIME ZONE”也可以用来表示一个时刻,只需要以正确的方式一致地解释它(例如“自固定点开始的毫秒数”或“UTC中此时的日期和时间”,分别),而数据库驱动程序和库似乎是能够胜任的。即使 NOW()::TIMESTAMP 也是以UTC提供的。 - Ivan G.
N.B. 在Java中强烈推荐使用https://www.joda.org/joda-time/index.html,在dotnet中使用https://nodatime.org/(以便更轻松地处理上述复杂性)。 - MemeDeveloper

18

这是Percona所推荐内容的摘录

他们推荐使用

BRIN索引

我需要证明可以通过timestamptz排序记录集。尽管示例使用timestamp,但我使用timestamptz。

  1. 我的记录按时间顺序排列,旧的timestamptz列未更新或删除。

  2. 仅最近记录中的其他列被更新。旧记录没有被触及。

我的表将有几百万条记录。

你可以测试你的查询。我使用pgAdmin。

CREATE TABLE testtab (id int NOT NULL PRIMARY KEY,date TIMESTAMP NOT NULL, level INTEGER, msg TEXT);

create index testtab_date_idx  on testtab(date);

"Gather  (cost=1000.00..133475.57 rows=1 width=49) (actual time=848.040..862.638 rows=0 loops=1)"
"  Workers Planned: 2"
"  Workers Launched: 2"
"  ->  Parallel Seq Scan on testtab  (cost=0.00..132475.47 rows=1 width=49) (actual time=832.108..832.109 rows=0 loops=3)"
"        Filter: ((date >= '2019-08-08 14:40:47.974791'::timestamp without time zone) AND (date <= '2019-08-08 14:50:47.974791'::timestamp without time zone))"
"        Rows Removed by Filter: 2666667"
"Planning Time: 0.238 ms"
"Execution Time: 862.662 ms"

explain analyze select * from public.testtab where date between '2019-08-08 14:40:47.974791' and '2019-08-08 14:50:47.974791';

"Gather  (cost=1000.00..133475.57 rows=1 width=49) (actual time=666.283..681.586 rows=0 loops=1)"
"  Workers Planned: 2"
"  Workers Launched: 2"
"  ->  Parallel Seq Scan on testtab  (cost=0.00..132475.47 rows=1 width=49) (actual time=650.661..650.661 rows=0 loops=3)"
"        Filter: ((date >= '2019-08-08 14:40:47.974791'::timestamp without time zone) AND (date <= '2019-08-08 14:50:47.974791'::timestamp without time zone))"
"        Rows Removed by Filter: 2666667"
"Planning Time: 0.069 ms"
"Execution Time: 681.617 ms"

create index testtab_date_brin_idx  on rm_owner.testtab using brin (date);

explain analyze select * from public.testtab where date between '2019-08-08 14:40:47.974791' and '2019-08-08 14:50:47.974791';

"Bitmap Heap Scan on testtab  (cost=20.03..33406.84 rows=1 width=49) (actual time=0.143..0.143 rows=0 loops=1)"
"  Recheck Cond: ((date >= '2019-08-08 14:40:47.974791'::timestamp without time zone) AND (date <= '2019-08-08 14:50:47.974791'::timestamp without time zone))"
"  ->  Bitmap Index Scan on "testtab_date_brin_idx "  (cost=0.00..20.03 rows=12403 width=0) (actual time=0.141..0.141 rows=0 loops=1)"
"        Index Cond: ((date >= '2019-08-08 14:40:47.974791'::timestamp without time zone) AND (date <= '2019-08-08 14:50:47.974791'::timestamp without time zone))"
"Planning Time: 0.126 ms"
"Execution Time: 0.161 ms"

更新:我看到的所有示例都类似于这里所描述的示例。


1

开始吧!

Postgres的默认索引存储在排序的B树中。

因此,在example_timestamp列上放置索引会导致更有效的查询。请记住,索引的缺点是插入操作会更加繁重(需要平衡树)。

祝好运!

获取更多信息,请查看此视频 https://youtu.be/clrtT_4WBAw


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接