当数据依赖于日期时间时,在数据库中保存日期时间和时区信息的最佳实践
Best practices with saving datetime & timezone info in database when data is dependant on datetime
有很多关于在数据库中保存日期时间和时区信息的问题,但更多的是关于整体层面的问题。这里我想说一个具体的案例。
系统规格
- 我们有一个订单系统数据库
- 这是一个多租户系统,租户可以使用任意时区(它是任意的,但每个租户只有一个时区,保存在租户 table 中一次,永远不会改变)
需要在数据库中涵盖业务规则
- 当租户在系统中下订单时,订单号会根据他们当地的日期时间计算(它不是字面上的数字,而是某种标识符,例如
ORDR-13432-Year-Month-Day
).精确计算暂时不重要,重要的是它取决于租户本地日期时间
- 我们也确实希望能够 select 所有订单,在系统级别,放置在一些 UTC 日期时间之间,而不考虑租户(对于一般系统 statistics/reporting)
我们的初步想法
- 我们最初的想法是在整个数据库中保存 UTC 日期时间,当然,保持租户相对于 UTC 的时区偏移,并让使用数据库的应用程序始终将日期时间转换为 UTC,以便数据库本身始终以 UTC 运行。
方法一
保存本地租户的日期时间对每个租户来说都很好,但是我们遇到了以下查询的问题:
SELECT * FROM ORDERS WHERE OrderDateTime BETWEEN UTCDateTime1 AND UTCDateTime2
这是有问题的,因为此查询中的 OrderDateTime
表示基于租户的不同时刻。当然,此查询可能包括加入 Tenants
table 以获取本地日期时间偏移量,然后动态计算 OrderDateTime
以进行调整。这是可能的,但不确定这是不是一个好方法?
方法二
- 另一方面,当保存 UTC 日期时间时,当我们计算 OrderNumber 时,因为 UTC 中的 day/month/year 可能与本地日期时间中的不同
举个极端的例子;假设租户比 UTC 早 6 小时,他的本地日期时间是 2017-01-01 02:00
。
UTC 将是 2016-12-31 20:00
。当时下的订单应该得到 OrderNumber 'ORDR-13432-2017-1-1'
但如果保存 UTC 它将得到 ORDR-13432-2016-12-31
.
在这种情况下,在数据库中创建订单时,我们应该获取 UTC 日期时间、租户偏移量并根据重新计算的租户本地时间编译 OrderNumber,但仍将 DateTime 列保存为 UTC。
问题
- 处理这种情况的首选方法是什么?
- 是否有保存 UTC 日期时间的好解决方案,因为系统级报告对我们来说非常好?
- 如果要保存 UTC,方法 2) 是处理这些情况的好方法还是有一些 better/recommended 方法?
[更新]
根据 Gerard Ashton 和 Hugo 的评论:
关于承租人是否可以更改时区的细节,以及如果政治当局更改时区属性或某些地区的时区会发生什么,最初的问题并不清楚。
当然,这是极其重要的,但它不是这个问题的核心。我们可能会在一个单独的问题中解决这个问题。
为了这个问题,我们假设租户不会改变位置。该位置的时区属性或时区本身可能会更改,这些更改将在系统中与此问题分开处理。
我建议始终在内部使用 UTC,并且仅在向用户显示日期时才转换为时区。所以我倾向于更喜欢方法 2.
如果有业务规则规定租户的本地 date/time 必须是标识符的一部分,那就这样吧。但在内部,您将订单日期保留为 UTC。
用你的例子:租户的时区是UTC+06:00
,所以租户的本地时间是2017-01-01 02:00
,相当于UTC的2016-12-31 20:00
。
订单 标识符 将是 ORDR-13432-2017-1-1
订单 日期 将是 UTC 2016-12-31 20:00Z
.
要获取 2 个日期之间的所有订单,此查询很简单:
SELECT * FROM ORDERS WHERE OrderDateTime BETWEEN UTCDateTime1 AND UTCDateTime2
因为 OrderDateTime
是 UTC。
如果要查找特定的租户,则可以获取相应的时区,相应地转换日期并进行搜索。使用上面相同的示例(租户的时区在 UTC+06:00
),以获取在 2017-01-01
(租户的本地时间)发出的所有订单:
--get tenant timezone
--startUTC=tenant's local 2017-01-01 00:00 converted to UTC (2016-12-31T18:00Z)
--endUTC=tenant's local 2017-01-01 23:59:59.999 converted to UTC (2017-01-01T17:59:59.999)
SELECT * FROM ORDERS WHERE OrderDateTime between startUTC and endUTC
这将得到 ORDR-13432-2017-1-1
正确。
要对不同时区的多个租户进行查询,两种方法都需要连接,因此对于这种情况 none 是 "better"。
除非您使用租户的本地 date/time 创建一个额外的列(UTC OrderDateTime
转换为租户的时区)。这将是多余的,但它可以帮助您处理在多个时区进行搜索的查询。如果这是一个合理的权衡,它将取决于这些查询的频率。
雨果的回答大部分是正确的,但我要补充几个要点:
当您存储客户的时区时,请勿存储数字偏移量。正如其他人指出的那样,与 UTC 的偏移量仅适用于单个时间点,并且可以很容易地因 DST 和其他原因而改变。相反,您应该存储时区标识符,最好是 IANA 时区标识符作为字符串,例如 "America/Los_Angeles"
。在 the timezone tag wiki.
中阅读更多内容
您的 OrderDateTime
字段应该绝对代表 UTC 时间。但是,根据您的数据库平台,您有多种存储方式的选择。
例如,如果使用 Microsoft SQL 服务器,一个好的方法是将本地时间存储在 datetimeoffset
列中,这样可以保留与 UTC 的偏移量。请注意,您在该列上创建的任何索引都将基于等效的 UTC,因此在执行范围查询时您将获得良好的查询性能。
如果使用其他数据库平台,您可能希望将 UTC 值存储在 timestamp
字段中。有的数据库也有timestamp with time zone
,但是要明白这并不意味着它存储时区或偏移量,它只是意味着它可以在你存储时隐式地为你做转换并检索值。如果您打算始终表示 UTC,那么通常 timestamp
(不带时区)或仅 datetime
更合适。
由于上述任一方法都会存储 UTC 时间,您还需要考虑如何执行需要本地时间值索引的操作。例如,您可能需要根据用户所在时区的日期创建每日报告。为此,您需要按本地日期分组。如果您尝试在查询时根据您的 UTC 值计算它,您将最终扫描整个 table.
处理此问题的一个好方法是为本地 date
(甚至可能是本地 datetime
创建一个单独的列,具体取决于您的需要,但 不是 一个datetimeoffset
或timestamp
)。这可以是您单独填充的完全隔离的列,也可以是基于您的其他列的 computed/calculated 列。在索引中使用此列,以便您可以按本地日期过滤或分组。
如果您采用计算列方法,则需要知道如何在数据库中的时区之间进行转换。一些数据库有一个内置的 convert_tz
函数,可以理解 IANA 时区标识符。
如果您使用的是 Microsoft SQL 服务器,则可以使用新的 AT TIME ZONE
function in SQL 2016 and Azure SQL DB, but that only works with Microsoft time zone identifiers. To use IANA time zone identifiers, you'll need a third party solution, such as my SQL Server Time Zone Support 项目。
查询时,避免使用BETWEEN
语句。它是完全包容的。它适用于整个日期,但是当你有时间参与时,你最好做一个半开放范围查询,例如:
... WHERE OrderDateTime >= @t1 AND OrderDateTime < @t2
例如,如果 @t1
是今天的开始,那么 @t2
就是明天的开始。
关于评论中讨论的用户时区发生变化的场景:
如果您选择计算数据库中的本地日期,您唯一需要担心的情况是位置或企业切换时区时没有 "zone split" 发生。时区拆分是指引入新的时区标识符,该标识符涵盖发生变化的区域,包括新旧规则。
例如,在撰写本文时添加到 IANA tzdb 的最新区域是 America/Punta_Arenas
,这是智利南部决定留在 UTC-3 时的区域拆分,而其余部分智利 (America/Santiago
) 在夏令时结束时回到 UTC-4。
但是,如果两个时区边界上的一个小地方决定改变他们所遵循的那一边,并且没有必要进行时区分割,那么您可能会使用他们新时区的规则来反对他们的旧数据。
如果您单独存储本地日期(在应用程序中计算,而不是在数据库中计算),那么您将没有问题。用户将他们的时区更改为新时区,所有旧数据仍然完好无损,新数据以新时区存储。
有很多关于在数据库中保存日期时间和时区信息的问题,但更多的是关于整体层面的问题。这里我想说一个具体的案例。
系统规格
- 我们有一个订单系统数据库
- 这是一个多租户系统,租户可以使用任意时区(它是任意的,但每个租户只有一个时区,保存在租户 table 中一次,永远不会改变)
需要在数据库中涵盖业务规则
- 当租户在系统中下订单时,订单号会根据他们当地的日期时间计算(它不是字面上的数字,而是某种标识符,例如
ORDR-13432-Year-Month-Day
).精确计算暂时不重要,重要的是它取决于租户本地日期时间 - 我们也确实希望能够 select 所有订单,在系统级别,放置在一些 UTC 日期时间之间,而不考虑租户(对于一般系统 statistics/reporting)
我们的初步想法
- 我们最初的想法是在整个数据库中保存 UTC 日期时间,当然,保持租户相对于 UTC 的时区偏移,并让使用数据库的应用程序始终将日期时间转换为 UTC,以便数据库本身始终以 UTC 运行。
方法一
保存本地租户的日期时间对每个租户来说都很好,但是我们遇到了以下查询的问题:
SELECT * FROM ORDERS WHERE OrderDateTime BETWEEN UTCDateTime1 AND UTCDateTime2
这是有问题的,因为此查询中的 OrderDateTime
表示基于租户的不同时刻。当然,此查询可能包括加入 Tenants
table 以获取本地日期时间偏移量,然后动态计算 OrderDateTime
以进行调整。这是可能的,但不确定这是不是一个好方法?
方法二
- 另一方面,当保存 UTC 日期时间时,当我们计算 OrderNumber 时,因为 UTC 中的 day/month/year 可能与本地日期时间中的不同
举个极端的例子;假设租户比 UTC 早 6 小时,他的本地日期时间是 2017-01-01 02:00
。
UTC 将是 2016-12-31 20:00
。当时下的订单应该得到 OrderNumber 'ORDR-13432-2017-1-1'
但如果保存 UTC 它将得到 ORDR-13432-2016-12-31
.
在这种情况下,在数据库中创建订单时,我们应该获取 UTC 日期时间、租户偏移量并根据重新计算的租户本地时间编译 OrderNumber,但仍将 DateTime 列保存为 UTC。
问题
- 处理这种情况的首选方法是什么?
- 是否有保存 UTC 日期时间的好解决方案,因为系统级报告对我们来说非常好?
- 如果要保存 UTC,方法 2) 是处理这些情况的好方法还是有一些 better/recommended 方法?
[更新]
根据 Gerard Ashton 和 Hugo 的评论:
关于承租人是否可以更改时区的细节,以及如果政治当局更改时区属性或某些地区的时区会发生什么,最初的问题并不清楚。 当然,这是极其重要的,但它不是这个问题的核心。我们可能会在一个单独的问题中解决这个问题。
为了这个问题,我们假设租户不会改变位置。该位置的时区属性或时区本身可能会更改,这些更改将在系统中与此问题分开处理。
我建议始终在内部使用 UTC,并且仅在向用户显示日期时才转换为时区。所以我倾向于更喜欢方法 2.
如果有业务规则规定租户的本地 date/time 必须是标识符的一部分,那就这样吧。但在内部,您将订单日期保留为 UTC。
用你的例子:租户的时区是UTC+06:00
,所以租户的本地时间是2017-01-01 02:00
,相当于UTC的2016-12-31 20:00
。
订单 标识符 将是 ORDR-13432-2017-1-1
订单 日期 将是 UTC 2016-12-31 20:00Z
.
要获取 2 个日期之间的所有订单,此查询很简单:
SELECT * FROM ORDERS WHERE OrderDateTime BETWEEN UTCDateTime1 AND UTCDateTime2
因为 OrderDateTime
是 UTC。
如果要查找特定的租户,则可以获取相应的时区,相应地转换日期并进行搜索。使用上面相同的示例(租户的时区在 UTC+06:00
),以获取在 2017-01-01
(租户的本地时间)发出的所有订单:
--get tenant timezone
--startUTC=tenant's local 2017-01-01 00:00 converted to UTC (2016-12-31T18:00Z)
--endUTC=tenant's local 2017-01-01 23:59:59.999 converted to UTC (2017-01-01T17:59:59.999)
SELECT * FROM ORDERS WHERE OrderDateTime between startUTC and endUTC
这将得到 ORDR-13432-2017-1-1
正确。
要对不同时区的多个租户进行查询,两种方法都需要连接,因此对于这种情况 none 是 "better"。
除非您使用租户的本地 date/time 创建一个额外的列(UTC OrderDateTime
转换为租户的时区)。这将是多余的,但它可以帮助您处理在多个时区进行搜索的查询。如果这是一个合理的权衡,它将取决于这些查询的频率。
雨果的回答大部分是正确的,但我要补充几个要点:
当您存储客户的时区时,请勿存储数字偏移量。正如其他人指出的那样,与 UTC 的偏移量仅适用于单个时间点,并且可以很容易地因 DST 和其他原因而改变。相反,您应该存储时区标识符,最好是 IANA 时区标识符作为字符串,例如
"America/Los_Angeles"
。在 the timezone tag wiki. 中阅读更多内容
您的
OrderDateTime
字段应该绝对代表 UTC 时间。但是,根据您的数据库平台,您有多种存储方式的选择。例如,如果使用 Microsoft SQL 服务器,一个好的方法是将本地时间存储在
datetimeoffset
列中,这样可以保留与 UTC 的偏移量。请注意,您在该列上创建的任何索引都将基于等效的 UTC,因此在执行范围查询时您将获得良好的查询性能。如果使用其他数据库平台,您可能希望将 UTC 值存储在
timestamp
字段中。有的数据库也有timestamp with time zone
,但是要明白这并不意味着它存储时区或偏移量,它只是意味着它可以在你存储时隐式地为你做转换并检索值。如果您打算始终表示 UTC,那么通常timestamp
(不带时区)或仅datetime
更合适。
由于上述任一方法都会存储 UTC 时间,您还需要考虑如何执行需要本地时间值索引的操作。例如,您可能需要根据用户所在时区的日期创建每日报告。为此,您需要按本地日期分组。如果您尝试在查询时根据您的 UTC 值计算它,您将最终扫描整个 table.
处理此问题的一个好方法是为本地
date
(甚至可能是本地datetime
创建一个单独的列,具体取决于您的需要,但 不是 一个datetimeoffset
或timestamp
)。这可以是您单独填充的完全隔离的列,也可以是基于您的其他列的 computed/calculated 列。在索引中使用此列,以便您可以按本地日期过滤或分组。如果您采用计算列方法,则需要知道如何在数据库中的时区之间进行转换。一些数据库有一个内置的
convert_tz
函数,可以理解 IANA 时区标识符。如果您使用的是 Microsoft SQL 服务器,则可以使用新的
AT TIME ZONE
function in SQL 2016 and Azure SQL DB, but that only works with Microsoft time zone identifiers. To use IANA time zone identifiers, you'll need a third party solution, such as my SQL Server Time Zone Support 项目。查询时,避免使用
BETWEEN
语句。它是完全包容的。它适用于整个日期,但是当你有时间参与时,你最好做一个半开放范围查询,例如:... WHERE OrderDateTime >= @t1 AND OrderDateTime < @t2
例如,如果
@t1
是今天的开始,那么@t2
就是明天的开始。
关于评论中讨论的用户时区发生变化的场景:
如果您选择计算数据库中的本地日期,您唯一需要担心的情况是位置或企业切换时区时没有 "zone split" 发生。时区拆分是指引入新的时区标识符,该标识符涵盖发生变化的区域,包括新旧规则。
例如,在撰写本文时添加到 IANA tzdb 的最新区域是
America/Punta_Arenas
,这是智利南部决定留在 UTC-3 时的区域拆分,而其余部分智利 (America/Santiago
) 在夏令时结束时回到 UTC-4。但是,如果两个时区边界上的一个小地方决定改变他们所遵循的那一边,并且没有必要进行时区分割,那么您可能会使用他们新时区的规则来反对他们的旧数据。
如果您单独存储本地日期(在应用程序中计算,而不是在数据库中计算),那么您将没有问题。用户将他们的时区更改为新时区,所有旧数据仍然完好无损,新数据以新时区存储。