Best practices with saving datetime & timezone info in database when data is dependant on datetime
关于将datetime&timezones信息保存在数据库中有很多问题,但总体上有更多问题。在这里,我想处理一个具体的案件。
系统规格
- 我们有订单系统数据库
- 它是一个多租户系统,租户可以使用任意时区(它是任意的,但每个租户只有一个时区,保存在租户表中一次,从不更改)
数据库中需要包含的业务规则
- 当租户向系统下订单时,订单号将根据其本地日期时间(它不是字面上的数字,而是某种标识符,如
ORDR-13432-Year-Month-Day )进行计算。精确的计算目前并不重要,重要的是它依赖于租户的本地日期时间。 - 我们还希望能够在系统级别上选择在某些UTC日期时间之间放置的所有订单,而不考虑租户(用于一般系统统计/报告)
我们最初的想法
- 我们最初的想法是在整个数据库中保存UTC日期时间,当然,保持租户时区相对于UTC的偏移量,并让使用数据库的应用程序始终将日期时间转换为UTC,以便数据库本身始终与UTC一起运行。
方法1
保存本地租户的日期时间对于每个租户来说是一个不错的选择,但是我们在以下查询方面遇到了问题:
1SELECT * FROM ORDERS WHERE OrderDateTime BETWEEN UTCDateTime1 AND UTCDateTime2
这是有问题的,因为这个查询中的
方法2
- 另一方面,在保存UTC日期时间时,当我们计算订单号时,由于UTC中的日/月/年可能与本地日期时间中的不同
让我们举个极端的例子:假设租户比UTC早6小时,而他的本地日期时间是
在这种情况下,在数据库中创建订单时,我们应该获取UTC日期时间、租户偏移量,并根据重新计算的租户本地时间编译订单号,但仍将日期时间列保存为UTC。
问题
[更新]
根据杰拉德·阿什顿和雨果的评论:
最初的问题在细节上还不清楚,承租人是否可以改变时区,如果政治权威改变了时区属性或某个极端时区,会发生什么。当然,这是一个非常重要的问题,但它不在这个问题的中心。我们可以在另一个问题中解决这个问题。
为了这个问题,我们假设租户不会改变位置。该位置的时区属性或时区本身可能会更改,这些更改将在系统中与此问题分开处理。
雨果的回答基本上是正确的,但我要补充一些要点:好的。
在存储客户时区时,不要存储数字偏移量。正如其他人所指出的,与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 ,这取决于您的需要,但不是datetimeoffset 或timestamp 。这可能是一个完全独立的列,可以单独填充,也可以是基于其他列的计算/计算列。在索引中使用此列,以便可以按本地日期筛选或分组。好的。如果使用计算列方法,则需要知道如何在数据库中的时区之间进行转换。有些数据库内置了一个
convert_tz 功能,可以理解IANA时区标识符。好的。如果您使用的是Microsoft SQL Server,则可以在SQL 2016和Azure SQL DB中使用新的
AT TIME ZONE 函数,但这只适用于Microsoft时区标识符。要使用IANA时区标识符,您需要第三方解决方案,如我的SQL Server时区支持项目。好的。在查询时,避免使用
BETWEEN 语句。它是完全包容性的。它对整个日期都适用,但是当您有时间参与时,最好进行半开放范围的查询,例如:好的。1... WHERE OrderDateTime >= @t1 AND OrderDateTime < @t2例如,如果
@t1 是今天的开始,那么@t2 将是明天的开始。好的。
关于用户时区已更改的注释中讨论的方案:好的。
如果您选择计算数据库中的本地日期,那么您需要担心的唯一情况是,位置或业务是否切换时区而不发生"区域分割"。区域分割是指引入一个新的时区标识符,该标识符覆盖已更改的区域,包括其旧规则和新规则。好的。
例如,在撰写本文时添加到IANA TZDB中的最新区域是
America/Punta_Arenas ,当智利南部决定留在UTC-3时,智利其余部分(America/Santiago )在DST结束时返回到UTC-4,这是一个区域分割。好的。但是,如果两个时区边界上的一个次要区域决定更改它们所遵循的边,并且不保证分区分割,那么您可能会使用它们的新时区规则来对抗它们的旧数据。好的。
如果单独存储本地日期(在应用程序中计算,而不是数据库),那么就不会有任何问题。用户将其时区更改为新时区,所有旧数据仍保持不变,新数据与新时区一起存储。好的。
好啊。
我建议在内部始终使用UTC,只有在向用户显示日期时才转换为时区。所以我倾向于使用方法2。
如果有业务规则规定租户的本地日期/时间必须是标识符的一部分,那么就这样做吧。但在内部,您将订单日期保留为UTC。
使用您的示例:时区在
订单标识符为
要获取2个日期之间的所有订单,此查询将很紧张:
1 | SELECT * FROM ORDERS WHERE OrderDateTime BETWEEN UTCDateTime1 AND UTCDateTime2 |
因为
如果要查找特定的租户,那么可以获取相应的时区,相应地转换日期并搜索它。使用上面的相同示例(租户的时区在
1 2 3 4 | --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 |
这将使
为了在不同的时区对多个租户进行查询,这两种方法都需要一个联接,因此对于这种情况,没有一种方法是"更好的"。
除非使用租户的本地日期/时间(UTC