当数据依赖于日期时间时,在数据库中保存日期时间和时区信息的最佳做法

dan*_*ela 30 timezone datetime utc datetimeoffset dst

关于在DB中保存日期时间和时区信息有很多问题,但总体水平更多.在这里,我想谈谈一个具体的案例.

系统规格

  • 我们有一个订单系统数据库
  • 这是一个多租户系统,租户可以使用任意时区(每个租户是任意的,但是单个时区,一次保存在租户表中,永不改变)

DB中需要涵盖业务规则

  • 当租户将订单放入系统时,订单编号将根据其本地日期时间计算(它不是字面上的数字,而是某种类型的标识符ORDR-13432-Year-Month-Day).精确计算目前并不重要,重要的是它取决于租户的本地日期时间
  • 我们还希望能够在系统级别上选择所有订单,放置在某些UTC日期时间之间,而不管租户如何(对于一般系统统计/报告)

我们最初的想法

  • 我们最初的想法是在整个数据库中保存UTC日期时间,当然,保持租户时区相对于UTC的偏移量,并且使用DB的应用程序总是将日期时间转换为UTC,以便DB本身始终使用UTC.

方法1

  • 保存当地租户的日期时间对每个租户来说都不错,但是我们遇到的问题包括:

    SELECT * FROM ORDERS WHERE OrderDateTime BETWEEN UTCDateTime1 AND UTCDateTime2
    
    Run Code Online (Sandbox Code Playgroud)

这是有问题的,因为OrderDateTime在这个查询中意味着基于租户的不同时刻.当然,此查询可能包括连接到Tenants表以获取本地日期时间偏移量,然后将在运行中进行计算OrderDateTime以进行调整.这是可能的,但不确定这是否是一个好方法呢?

方法2

  • 另一方面,当保存UTC日期时间时,那么当我们计算OrderNumber时,因为UTC中的日/月/年可能与本地日期时间不同

让我们举一个极端的例子; 让我们说租户比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.

在这种情况下,在DB中创建Order时,我们应该根据重新计算的租户本地时间获得UTC日期时间,租户偏移和编译OrderNumber,但仍然以UTC格式保存DateTime列.

问题

  1. 处理这种情况的首选方法是什么?
  2. 是否有一个很好的解决方案,保存UTC日期时间,因为系统级报告,这对我们来说会很好吗?
  3. 如果使用保存UTC,是方法2)处理这些情况的好方法还是有一些更好/推荐的方式?

[UPDATE]

根据Gerard Ashton和Hugo的评论:

如果租户可以改变时区,那么最初的问题就细节而言并不明确,如果政治当局改变时区属性或某个时区的时区会发生什么.当然,这是非常重要的,但它不是这个问题的核心.我们可以在另一个问题中解决这个问题.

为了这个问题,让我们假设租户不会改变位置.该位置的时区属性或时区本身可能会发生变化,这些更改将在系统中与此问题分开处理.

Mat*_*int 27

雨果的答案大多是正确的,但我会补充一些要点:

  • 当您存储客户的时区时,请勿存储数字偏移.正如其他人所指出的那样,与UTC的偏移仅适用于单个时间点,并且可以很容易地因DST和其他原因而改变.相反,您应该存储时区标识符,最好是IANA时区标识符作为字符串,例如"America/Los_Angeles".阅读时区标签wiki中的更多内容.

  • 您的OrderDateTime字段绝对应代表UTC的时间.但是,根据您的数据库平台,您有多种选择来存储它.

    • 例如,如果使用Microsoft SQL Server,一种好方法是将本地时间存储在datetimeoffset列中,该列保留与UTC的偏移量.请注意,您在该列上创建的任何索引都将基于UTC等效项,因此在进行范围查询时,您将获得良好的查询性能.

    • 如果使用其他数据库平台,您可能希望将UTC值存储在timestamp字段中.有些数据库也有timestamp with time zone,但要明白它并不意味着它存储时区或偏移量,它只是意味着它可以在存储和检索值时隐式地为您进行转换.如果您打算始终代表UTC,那么经常timestamp(没有时区)或者datetime更合适.

  • 由于上述任一方法都将存储UTC时间,因此您还需要考虑如何执行需要本地时间值索引的操作.例如,您可能需要根据用户时区的日期创建每日报告.为此,您需要按本地日期分组.如果您尝试在查询时根据UTC值计算,则最终会扫描整个表格.

    解决这个问题的一个好方法是为本地date(或者甚至是本地创建一个单独的列,datetime具体取决于您的需求,但不是 a datetimeoffsettimestamp).这可以是单独填充的完全隔离的列,也可以是基于其他列的计算/计算列.在索引中使用此列,以便按本地日期过滤或分组.

  • 如果您使用计算列方法,则需要知道如何在数据库中的时区之间进行转换.某些数据库具有convert_tz内置的功能,可以理解IANA时区标识符.

    如果您使用的是Microsoft SQL Server,则可以使用AT TIME ZONESQL 2016和Azure SQL DB中的新功能,但这仅适用于Microsoft时区标识符.要使用IANA时区标识符,您需要第三方解决方案,例如我的SQL Server时区支持项目.

  • 在查询时,请避免使用该BETWEEN语句.它是完全包容的.它适用于整个日期,但是当你有时间参与时,你最好做一个半开放范围的查询,例如:

    ... WHERE OrderDateTime >= @t1 AND OrderDateTime < @t2
    
    Run Code Online (Sandbox Code Playgroud)

    例如,如果@t1是今天的开始,那@t2将是明天的开始.

关于用户时区已更改的注释中讨论的方案:

  • 如果选择计算数据库中的本地日期,则唯一需要担心的情​​况是位置或业务切换时区而不发生"区域拆分".区域拆分是指引入新的时区标识符,其中包含已更改的区域,包括其旧规则和新规则.

    例如,在撰写本文时添加到IANA tzdb的最新区域America/Punta_Arenas是智利南部决定保留在UTC-3时智利其他地区(America/Santiago)返回UTC-4 时的区域分割在夏令时结束时.

    然而,如果对两个时区的边境小地方决定改变他们跟随哪一方,和区域分割并不恰当,那么你可能会使用他们的新时区的规则对他们的旧数据.

  • 如果您单独存储本地日期(在应用程序中计算,而不是数据库),那么您将没有任何问题.用户将其时区更改为新时区,所有旧数据仍然完好无损,新数据将与新时区一起存储.

  • 是的,就像我说的,我经常只使用`date`栏.除非您按当地时间进行每小时过滤/分组,否则您可能只需要日期.如果你将列命名为`CreatedUTCDateTime`和`CreatedLocalDate`,那就很清楚了. (2认同)
  • 抱歉,但我不同意这一点“当您存储客户的时区时,不要存储数字偏移量......与 UTC 的偏移量仅适用于单个时间点,并且可以轻松更改”。是的,用于时区的偏移量可能会改变。然而,*在特定时间点*的偏移量永远不会改变。即使您弄错了(例如,根据位置,您应该记录 +5 偏移量,但实际记录了 +6 偏移量),如果偏移量准确,那么时间也是准确的。 (2认同)
  • @geneorama - 如果日期、时间和偏移量存储在一起(例如 SQL Server 中的 `datetimeoffset` 字段等),那很好。但是,如果它与任何特定时间点(例如在用户配置文件或办公室位置等中)分离,则必须使用时区标识符而不是偏移量。 (2认同)

小智 15

我建议始终在内部使用UTC,并仅在向用户显示日期时转换为时区.所以我倾向于选择方法2.

如果有一个业务规则说租户的本地日期/时间必须是标识符的一部分,那就这样吧.但在内部,您将订单日期保留为UTC.

使用您的示例:时区所在UTC+06:00的租户,因此租户的本地时间2017-01-01 02:00等于2016-12-31 20:00UTC.

订单标识符将是ORDR-13432-2017-1-1,订单日期将是UTC 2016-12-31 20:00Z.

要获得2个日期之间的所有订单,此查询是直截了当的:

SELECT * FROM ORDERS WHERE OrderDateTime BETWEEN UTCDateTime1 AND UTCDateTime2
Run Code Online (Sandbox Code Playgroud)

因为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
Run Code Online (Sandbox Code Playgroud)

这将ORDR-13432-2017-1-1正确.


要在不同时区中对多个租户进行查询,两种方法都需要连接,因此对于这种情况,没有一种方法更"好".

除非您使用租户的本地日期/时间(UTC OrderDateTime转换为租户的时区)创建额外的列.这将是多余的,但它可以帮助您查询在多个时区搜索.如果这是一个合理的权衡,那将取决于这些查询的频率.

  • 非常好的答案!一个很好的想法是保存带有本地日期/时间的附加列。这将是多余的,但如果您想进行以下查询,它会让生活变得更加轻松:获取一天中订单最多的前 10 个租户(一天被定义为当地时区的工作时间)。 (2认同)