当数据依赖于日期时间时,在数据库中保存日期时间和时区信息的最佳实践
Posted
技术标签:
【中文标题】当数据依赖于日期时间时,在数据库中保存日期时间和时区信息的最佳实践【英文标题】:Best practices with saving datetime & timezone info in database when data is dependant on datetime 【发布时间】:2017-12-11 10:38:07 【问题描述】:关于在 DB 中保存日期时间和时区信息存在很多问题,但总体而言更多。这里我想谈一个具体的案例。
系统规格
我们有一个订单系统数据库 这是一个多租户系统,租户可以使用任意时区(它是任意的,但每个租户只有一个时区,保存在租户表中一次并且永远不会更改)需要在 DB 中涵盖业务规则
当租户向系统下订单时,订单号会根据他们的本地日期时间计算(它不是字面意义上的数字,而是某种标识符,例如ORDR-13432-Year-Month-Day
)。目前精确计算并不重要,重要的是它取决于租户本地日期时间
我们还希望能够在系统级别选择所有订单,无论租户如何(用于一般系统统计/报告)
我们最初的想法
我们最初的想法是在整个数据库中保存 UTC 日期时间,当然,保持租户时区相对于 UTC 的偏移,并让使用数据库的应用程序始终将日期时间转换为 UTC,以便数据库本身始终使用 UTC。方法 1
为每个租户保存本地租户的日期时间会很好,但是我们遇到了以下查询的问题:
SELECT * FROM ORDERS WHERE OrderDateTime BETWEEN UTCDateTime1 AND UTCDateTime2
这是有问题的,因为此查询中的 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,但仍将 DateTime 列保存为 UTC。
问题
-
处理这种情况的首选方法是什么?
是否有一个很好的解决方案来保存 UTC 日期时间,因为系统级报告对我们来说非常好?
如果要保存 UTC,方法 2) 是处理这些情况的好方法,还是有更好/推荐的方法?
[更新]
基于 Gerard Ashton 和 Hugo 的 cmets:
最初的问题是关于租户是否可以更改时区以及如果政治当局更改时区属性或某些地区的时区会发生什么的细节并不清楚。 当然这是极其重要的,但它不在这个问题的中心。我们可能会在一个单独的问题中解决这个问题。
为了这个问题,假设租户不会更改位置。该位置的时区属性或时区本身可能会更改,这些更改将在系统中与此问题分开处理。
【问题讨论】:
本地时区永远不会改变的假设是有风险的。如果时区存储为标准化名称(东部标准时间),那么您还必须在整个数据库历史中跟踪夏令时的开始和结束日期。如果存储为与 UTC 的数字偏移量,它将在受夏令时影响的区域发生变化。在极少数情况下,政治分区可能会从一个时区更改为另一个时区。 “本地时区永不改变”的声明是每个租户的。这只是意味着一旦租户选择了他们的时区,他们将始终使用该时区。时区本身的所有潜在潜在变化。 也许时区问题超出了您的控制范围。但是如果我是租户并且美国国会和州立法机构决定将我办公室的时区从山区时间更改为中部时间,并且数据库提供商不允许我更改数据库中的时区,我会不开心。您是以名称还是数值来跟踪时区? @KevinChristopherHenry 我完全同意,但在这种情况下,它是必需的业务规则。客户希望 OrderNumber 以这种方式组成。它不是开发人员的选择。 在订单号中使用订单日期并没有错。规范化不适用于那个,无论如何都被高估了...... 【参考方案1】:Hugo 的回答大部分是正确的,但我会补充几个关键点:
当您存储客户的时区时,请勿存储数字偏移量。正如其他人指出的那样,与 UTC 的偏移量仅针对单个时间点,并且可以很容易地因 DST 和其他原因而改变。相反,您应该存储时区标识符,最好将 IANA 时区标识符存储为字符串,例如 "America/Los_Angeles"
。在the timezone tag 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 Time Zone Support 项目。
在查询时,避免使用BETWEEN
语句。它是完全包容的。它适用于整个日期,但是当您有时间时,最好进行半开范围查询,例如:
... WHERE OrderDateTime >= @t1 AND OrderDateTime < @t2
例如,如果@t1
是今天的开始,@t2
就是明天的开始。
关于 cmets 中讨论的用户时区发生变化的场景:
如果您选择在数据库中计算本地日期,您唯一需要担心的情况是位置或企业是否切换时区而没有发生“区域拆分”。区域拆分是指引入了新的时区标识符,该标识符涵盖了已更改的区域,包括其旧规则和新规则。
例如,在撰写本文时,添加到 IANA tzdb 的最新区域是 America/Punta_Arenas
,当智利南部决定留在 UTC-3 而智利其他地区 (@ 987654342@) 在夏令时结束时返回 UTC-4。
但是,如果两个时区边界上的一个小地方决定改变他们所遵循的一方,并且没有必要进行时区分割,那么您可能会使用他们的新时区规则来对抗他们的旧时区规则数据。
如果您单独存储本地日期(在应用程序中计算,而不是在数据库中计算),那么您不会有任何问题。用户将时区更改为新时区,所有旧数据仍然完好无损,新数据随新时区存储。
【讨论】:
很棒的答案,像往常一样来自你:)。对于需要本地日期时间的任何类型的报告,感觉分离本地日期时间列是最安全的方式。很大程度上是因为与时区的精确同步更改生效时刻。使用单独的列将其留给客户端处理他们认为本地的内容,这在服务器环境中听起来非常正确。 是的,就像我说的,我经常为此使用date
列。除非您按当地时间进行每小时过滤/分组,否则您可能只需要日期。如果您将列命名为 CreatedUTCDateTime
和 CreatedLocalDate
之类的名称,将会很清楚发生了什么。
我猜日期对于每日报告来说已经足够了,但如果在客户时区有任何事件要安排,例如订单到期、付款提醒或任何未来的本地事件,那么完整datetime 可能是一个更好的选择。而且由于通常很难预测未来可能会出现哪些客户功能请求,因此从一开始就使用完整的日期时间可能更安全。
对不起,但我不同意这一点“当您存储客户的时区时,请勿存储数字偏移量......与 UTC 的偏移量仅适用于单个时间点,并且可以很容易地改变”。是的,用于时区的偏移量可能会改变。但是,在特定时间点的偏移量永远不会改变。即使您弄错了(例如,根据您应该记录 +5 偏移量但您记录了 +6 偏移量的位置),如果偏移量准确,那么时间也是准确的。
@geneorama - 如果日期、时间和偏移量存储在一起(例如 SQL Server 中的 datetimeoffset
字段等),那很好。但是,如果它与任何特定时间点(例如在用户配置文件或办公室位置等)分离,则必须使用时区标识符而不是偏移量。【参考方案2】:
我建议始终在内部使用 UTC,并且仅在向用户显示日期时才转换为时区。所以我倾向于方法 2。
如果有一条业务规则规定租户的本地日期/时间必须是标识符的一部分,那就这样吧。但在内部,您将订单日期保留为 UTC。
使用您的示例:时区为UTC+06:00
的租户,因此该租户的本地时间为2017-01-01 02:00
,相当于UTC 中的2016-12-31 20:00
。
identifier 的顺序为 ORDR-13432-2017-1-1
,date 的顺序为 UTC 2016-12-31 20:00Z
。
要获取两个日期之间的所有订单,此查询非常简单:
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
。
要查询不同时区的多个租户,这两种方法都需要连接,因此对于这种情况,没有一种方法“更好”。
除非您使用租户的本地日期/时间创建额外的列(UTC OrderDateTime
转换为租户的时区)。这将是多余的,但它可以帮助您处理在多个时区中搜索的查询。如果这是一个合理的权衡,那将取决于这些查询的频率。
【讨论】:
非常好的答案!还有一个想法是用本地日期/时间保存额外的列。这将是多余的,但如果您想进行以下查询,它会让生活变得更加轻松:获取一天内订单最多的 10 个租户(一天被定义为当地时区的工作时间)。以上是关于当数据依赖于日期时间时,在数据库中保存日期时间和时区信息的最佳实践的主要内容,如果未能解决你的问题,请参考以下文章
如何在 Xamarin Forms 中将日期、日期时间和时间保存到 SQLite 数据库?
从firestore查询数据时,如何将保存的字符串格式的日期与当前日期进行比较?