纳尼,持续时间是负数?赶紧给我查,马上修复

388 阅读10分钟

背景

某日,客户急冲冲的向我们发飙,他们在使用我们的系统时,发现某些事件的结束时间居然比开始时间早,这导致系统计算的持续时间变成了负数。领导赔礼道歉打哈哈,安抚好客户后,然后对我们发飙了:“赶紧给我排查问题,马上进行修复”。

问题复现 & 排查 & 分析

经过排查,我们发现问题的根源出在时区的处理上。以下是具体的分析:

1. 问题排查

经过分析,当系统存储和计算时间时,如果没有正确处理时区,就会导致偏差,具体表现如下:

(1)用户在 UTC+8 时区开始事件
  • 用户在北京时间(UTC+8)早上 8 点发起了一个事件。
  • 如果系统正确处理时间,它会将用户的当地时间 2025-01-24 08:00:00 转换成 UTC 时间 2025-01-24 00:00:00 进行存储。
(2)系统错误存储方式

假设系统在存储时间时出现了问题,比如:

  • 开始时间: 直接存储为 UTC 时间,不记录时区,即 2025-01-24T08:00:00Z
    这实际上意味着系统认为时间是 UTC 的上午 8 点,而不是北京时间上午 8 点(相差 8 小时)。
  • 结束时间: 用户结束事件时在本地记录,系统将结束时间直接存储为用户的当地时间(例如 UTC+8 的下午 3 点)。

这种错误存储方式导致了开始时间和结束时间在时间线上的位置不匹配

2. 案例

是不是觉得有点云里雾里,举个例子方便大家理解:

  • 开始时间: 用户在中国(UTC+8)上午 10 点开始事件,记录为 2025-01-24T10:00:00+08:00
  • 结束时间: 用户飞到美国(纽约,UTC-5)后,晚上 8 点结束事件,记录为 2025-01-23T20:00:00-05:00

如果直接比较时间戳而忽略时区转换,就可能出现“结束时间早于开始时间”的假象

为什么会这样?

因为这两个时间看似不合理,但如果用 UTC 统一比较就能理解:

  • 开始时间 UTC:2025-01-24T02:00:00Z
  • 结束时间 UTC:2025-01-24T01:00:00Z

显然结束时间确实在 UTC 早了一小时。

3. 为什么中国的很多程序员没有时区的概念

不知道大家平时住酒店,尤其是国际酒店的时候有没有留意大堂的时钟

他们一般长这样

1.jpg

不知道你们有没有想一下为什么同一时刻在不同的国家是不同的时间呢? 答案就是时区不一样

那你更迷惑了?为什么很多中国的程序员没有时区的概念为什么在我的程序没有时区也没有bug呀

这不得不说中国时区的一个特殊性 - 中国只有一个时区。即中国全国使用北京时间(UTC+8)而且不使用夏令时。因此,在日常生活和开发中,时间的显示和处理通常非常简单,大家不需要额外考虑时区偏移问题

而且,国内的应用程序开发基本服务于本地用户,因此只需要处理北京时间,不需要涉及跨时区的时间转换。

讲到这里了,广大的掘友估计已经建立起时区的概念了。恭喜你,解决了一个坑的同时,又开始踏入更多的坑。

附上全球时区图 t-2.jpg

(1) 时区不需要,bug不存在

你现在是不是很困惑,为什么我的程序中没有时区,很多地方都是使用new Date(), 程序中也没有bug呀?

是真的没有bug,还是没有暴露出来呢?

首先,我们分析为什么没有触发bug?

  • 单时区环境

    如果你的程序主要运行在中国,且所有用户和服务器都在同一个时区(UTC+8),那么即使程序忽略了时区问题,时间的显示和计算也不会出错。

  • 不涉及跨国或跨时区用户

    如果你的应用是一个本地化产品,用户群体都在同一个时区下使用,就不会触发跨时区的 bug。

    比如用户从中国飞到美国后创建事件,这种场景还没有发生,所以问题没有暴露。

  • 运气好,潜在问题未被用户反馈

    如果你的程序存在时区问题,但没有影响到用户体验或者业务逻辑,可能只是潜在问题没有被触发或者用户没有发现并反馈。这种“无 bug”的假象并不等于程序完全正确。

(2) 需要引入时区概念吗

如果你的系统长期运行稳定,用户和服务器都在同一时区,且未来没有国际化计划,那么保持现状就是最好的优化

那么问题又来了,以前的架构或者代码因为迭代了很多版本,错综复杂,引入时区概念可能需要付出较大的重构成本,那新的项目或者新的架构需要吗?

其实这个问题需要你们自己去评估

  • 业务是否会有跨时区需求
  • 当前用户群体是否涉及跨时区场景
  • 系统的运行周期是多久
  • 投入成本跟预期收益之间的平衡取舍

如果程序未来需要国际化或者支持多时区用户,建议提前考虑时区问题,尤其是采用统一的 UTC 存储和本地化展示的方式,避免潜在问题在未来放大!

解决方案

目前主流的解决方案有2种

  • ISO 8601 格式。如:2025-01-01T12:00:00+08:00
  • 时间戳(Unix 时间戳)。如:1735660800000,表示自 1970 年 1 月 1 日 00:00:00 UTC 起的毫秒数。

我们如何选择呢,且听我继续分析

技术特点对比

image.png

场景对比

ISO 8601
  • 跨时区系统:ISO 8601 包含时区信息(如 +08:00),可以清晰表达某时间点在哪个时区。
  • 日志和调试:记录时间点时,可以直观地看到时间和时区,便于人类阅读和分析。
  • REST API 和数据交换:常用于 HTTP 接口(如 JSON 数据)和标准化通信格式。
  • 数据库存储:有些数据库(如 MySQL 的 DATETIME WITH TIMEZONE 类型)支持直接存储 ISO 8601 格式。
时间戳(Unix 时间戳)
  • 高性能需求:如大规模日志处理、实时数据计算等,需要高效的时间比较和排序。
  • 跨平台系统:时间戳的格式简单,适用于任何语言和平台。
  • 无时区依赖:系统内部存储时间时,可以统一为 UTC,前端根据用户时区动态转换。

如何选择

  1. ISO 8601

    需要记录时间点,便于人类阅读和分析,希望遵守Swagger等接口规范等前端友好设计时,推荐选择 ISO 8601

  2. Unix 时间戳

    用于大量存储或者计算等高性能操作推荐使用Unix 时间戳

  3. 结合使用

    后端存储时间戳,接口和前端使用 ISO 8601 格式。

    这种方式结合了性能和可读性,同时支持跨时区操作。抛除成本和时间等因素,这是最优方案

前端格式化还是后端格式化

在实际开发中,我们还需要考虑时间格式化的场景。本来不想讲,但是考虑到有些前端是软妹子,习惯所有的东西都有后端处理,再碰上后端好说话那种,脑子一热,全给处理了(不要觉得可笑,我真见过这种情况)。

在此,我郑重表态:

关于时间格式化问题,由前端统一格式化处理

const formattedTime = dayjs.utc(time).tz('Asia/Shanghai').format('YYYY-MM-DD HH:mm:ss');

Day.js,Moment.js

在选择时间处理库时,我们分析下主流的几个工具库:

  • Moment.js: 功能全面但体积较大,且官方已停止新增功能开发。
  • Day.js: 轻量级,支持插件扩展,适合现代化的前端开发。

推荐使用Day.js

下面是关于时区格式化 & 存储的2个常见方法

/**
 * 根据后端返回的时间格式,根据时区格式化时间
 */
export const formatDateTime = (dateTime, format = "DD/MM/YYYY HH:mm:ss") => {
    if (dateTime === null) {
        return null
    }
    let parsedTime = undefined
    if (typeof dateTime === "number") {
        // 时间戳
        parsedTime = dayjs(dateTime);
    } else if (typeof dateTime === "string" || typeof dateTime === "object") {
        // 解析为 UTC 时间
        parsedTime = dayjs.utc(dateTime);
    } else {
        throw new Error("Invalid dateTime: must be a number (dateTime) or string (GMT).");
    }

    const formattedTime = dayjs(parsedTime).tz(getTimeZone()).format(format);
    return formattedTime;
}
/**
 * 根据时区获取时间戳,返回给后端
 */
export const formatToTimestamp = (date) => {
    const time = dayjs(date).tz(getTimeZone(), true)
    return new Date(time).getTime()
}

至此,时区的相关内容就讲解完了,解决这个问题就so easy

  1. 数据库设置时区:utc时区
  2. 引入时区概念: 将时区设置放在右上角, 默认使用浏览器时区,允许用户切换时区
Intl.DateTimeFormat().resolvedOptions().timeZone

3. 后端存储时间戳,接口和前端使用 ISO 8601 格式。

至此,完美解决,客户满意,散会!

知识讲堂

为了帮助各位掘友更好地理解时间和时区相关的概念,这里只是简单介绍一下,不做过多阐述。如果有兴趣,请查阅相关资料。

1. GMT

格林尼治标准时间(Greenwich Mean Time) 是基于地球自转的时间标准,常用于表示 UTC 的一种通俗表达方式。

我们常说的时间戳,timestamp就是指格林尼治标准时间

2. UTC

协调世界时(Coordinated Universal Time) 是国际时间标准,基于原子钟的时间计量,不受地球自转影响。

一般来说,当我们提到UTC时间而不带任何别的修饰时,常指UTC 0点(UTC+0)

协调世界时(UTC)不与任何地区位置相关,也不代表此刻某地的时间,所以在说明某地时间时要加上时区。比如说UTC + 8 = 北京时间

也就是说GMT并不等于UTC,而是等于UTC+0,只是格林威治刚好在0时区上,所以GMT = UTC+0

3. 冬令时,夏令时

高纬度和中纬度的许多国家在夏季到来前,把时针拨快一小时,新的时间就是夏令时,到下半季秋季来临前,再把时针拨回一小时,即形成冬令时

4. 中国时区

中国使用的是 北京时间(CST, UTC+8) 。注意,CST 也可以指美国的中部时间,因此在代码中需明确时区偏移量。

我们日常说的时间就是中国时区的时间,在中国也称为 - 本地时间

总结

时间和时区问题是开发中常见但容易被忽略的坑。通过本文的分析与实践,我们可以:

  1. 理解时区相关的背景知识;
  2. 制定合理的时间存储和格式化方式,避免负持续时间的问题再次发生。
  3. 建立时区的概念,拓宽国际化需求,

最后,希望这篇文章能为你在处理类似问题时提供参考!

参考资料

  1. GMT
  2. UTC
  3. 冬令时
  4. 夏令时
  5. Day.js
  6. Moment Timezone