- ISO 8601 - wikipedia.org
- ISO 8601 duration format
- Class DateTimeFormatter
- Date - MDN
- UTC Time Now
- 分布式系统中的时间
背景知识
GMT/UTC/CST
- GMT:格林尼治时间(前世界标准时),依靠天文观测
- UTC:协调世界时(现世界标准时),依靠原子钟,所以地球自转一天的时间也不一定等于 86400 秒,可能会出现闰秒,例如
23:59:60
- CST:视上下文可解释为
Central Standard Time (USA) UT-6:00
或China Standard Time UT+8:00
或其他(英文缩写,很奇妙吧)
ISO 8601(-like) 标准
国际标准 ISO 8601 是国际标准化组织的日期和时间的表示方法,全称为《数据存储和交换形式·信息交换·日期和时间的表示方法》。然而 ISO 8601 并不是一个可以免费公开获取的标准,因此通常情况下我们会使用 RFC 3339,以及某些情况下,GB/T 7408.1-2023 日期和时间 信息交换表示法(等同 ISO 8601-1:2019)。
我们在软件开发过程中,通常只会用到日期时间表示法和时间区间表示法,因此本文会略去顺序日期表示法、星期日历表示法、日历星期表示法之类的东西,时间部分也不会介绍缺省小时或降低精度的表示方法,如有需求可以直接阅读 RFC 文档。
ISO 8601 与 RFC 3339 的主要区别
ijmacd - GitHub 做了一个很好的图,为了防止页面失效,我先放个截图在这里。
注意这张图不完全是准确的,或者说不能供开发人员不加验证地参考,例如图上 RFC 3339 的 2024-04-11_06:45:27.458Z
就不能被 Safari 的 Date.parse()
方法支持。
ISO 8601 允许 24:00
和 00:00
同时存在,而 RFC3339 为了减少混淆,限制小时必须在 0 至 23 之间,23:59
过 1 分钟是第二天的 0:00
。
此外根据 RFC 3339 的 Appendix A. ISO 8601 Collected ABNF,ISO 8601 中作为日期时间分隔符的 T
可以省略,而 RFC 3339 允许使用空格或不区分大小写的 t
。这个问题在早期的 Safari 浏览器中也得到过讨论,例如:Date: parse: ISO 8601 format YYYY-MM-DD HH:mm:ss
- iOS Safari incompatibility #15401 。
日历日期、时间与时区表示法
- 使用数字
YYYYMMdd
为基本格式,ISO 8601:2004 不再允许用两位数字表示年 - 推荐使用短横线
-
间隔开年月日YYYY-MM-dd
的扩展格式 - 使用数字
HHmmss
为基本格式,推荐使用:
隔开小时、分、秒的扩展格式HH:mm:ss
- 还可以添加
.SSS
表示毫秒 - 添加
.SSSSSS
表示微秒 - 添加
.SSSSSSSSS
表示纳秒
- 还可以添加
- 日期和时间之间使用
T
分隔
如果时间在零时区,并恰好与 UTC 相同,那么不加空格地在时间最后加一个大写字母 Z
。Z
是相对 UTC 时间 0 偏移的代号。如下午 2 点 30 分 5 秒表示为 14:30:05Z
或143005Z
;其他时区用实际时间加时差表示,如北京时间下午 2 点 30 分 5 秒表示为 22:30:05+08:00
或 223005+0800
。
如此这般,北京时间 2024 年 4 月 11 日 14 点 57 分 45 秒 943 毫秒就可以表示为:
2024-04-11T06:57:45.943Z
2024-04-11T14:57:45.943+08:00
时间间隔表示法(Periods)
用来表示一段时间间隔,一般的格式是 P(n)Y(n)M(n)DT(n)H(n)M(n)S
,例如 P1D
表示 1 天时间间隔。
时间范围表示法(Ranges)
从一个时间开始到另一个时间结束,或者从一个时间开始持续一个时间间隔,要在前后两个时间(或时间间隔)之间放置斜线符 /
,如 19850412/19860101
。
为什么推荐使用 ISO 8601 标准定义的日期时间表示法
很简单,各种软件包都支持,各种各样的 DateTimeFormatter 肯定支持名为 ISO_XXXXX
的模式,而 parse
方法几乎可以百分百确定能还原正确的数据。
而其他格式,就算是今天的现代浏览器,如果你的代码构造出什么奇特的日期时间字符串,在 Chromium 中也许是正常的,在 Safari 也有可能返回 Invalid Date
。
(例如某个用的比较多的后台管理框架的 parseTime
方法在一堆基于正则表达式的字符替换后会尝试这样构造 Date
对象 😂)
Unix Epoch & Unix Timestamp(in Milliseconds)
Unix Epoch,或称 Unix 时间/时间戳,从 1970-01-01T00:00:00Z
起至现在,不考虑闰秒的总秒数。在多数 Unix 系统上通过 date +%s
查看。
Unix Epoch 转换为以毫秒为单位,可称为 Unix Timestamp 或 Unix 时间戳。
RFC 2822
RFC 2822 includes the shortened day of week, numerical date, three-letter month abbreviation, year, time, and time zone, displaying as
01 Jun 2016 14:31:46 -0700
RFC 2822 是电子邮件标准,因此不太可能会在其他地方看到这种方法表示的时间。
墙上时间与单调时间
程序运行期间,如果服务器进行了 NTP 校时,就有可能造成后创建的记录在时间上早于先创建的记录。这种时钟称为「墙上时间」。Java 中的 System.currentTimeMills()
就是一种墙上时间。
与之相对地,「单调时钟」可以保证时间只会递增,但是无法保证时间的准确性。在 Java 中可以用 System.nanoTime()
做 benchmark 相关的应用。
有关分布式系统中的单调时钟(或称逻辑时钟),可参考 Lamport timestamp 主题。
Use In Java
Java Time API(from JDK 8)
几个常用的时间类
Date-time classes | Java Time API | legacy |
---|---|---|
Moment in UTC | Java.time.Instant | |
Moment with offset-from-UTC (HH:mm:ss) |
java.time.OffsetDateTime | - |
Moment with time zone | java.time.ZonedDateTime | |
Date & Time-of-day (no offset, no time zone) Not a moment |
java.time.LocalDateTime | - |
java.time.Instant
代表了时间轴上的一个确定点,原点为 1970-01-01T00:00:00.000Z
,精确到纳秒级别。
应用不需要跨时区工作时,使用 LocalDateTime
存储时间可以安全地丢弃时区信息;否则应当在 Instant
, OffsetDateTime
, ZonedDateTime
之间择一使用。
// parse
assert Instant.ofEpochSecond(1640931907L)
.equals(OffsetDateTime.of(LocalDateTime.of(2021, 12, 31, 6, 25, 7), ZoneOffset.UTC)
.toInstant());
assert Instant.ofEpochMilli(1640931907L * 1000)
.equals(OffsetDateTime.of(LocalDateTime.of(2021, 12, 31, 6, 25, 7), ZoneOffset.UTC)
.toInstant());
);
// format
assert OffsetDateTime.of(LocalDateTime.of(2021, 12, 31, 6, 25, 7), ZoneOffset.UTC)
.toInstant().getEpochSecond()
== 1640931907L;
DateTimeFormatter
DateTimeFormatter
支持在 java.time
类型和格式化字符串间转换
Predefined Formatters
DateTimeFormatter
预先定义了一些常用格式,可见于 Predefined Formatters。DateTimeFormatter.ISO_OFFSET_DATE_TIME
即为预定义的 ISO 8601 扩展格式转换器。
// parse
assert ZonedDateTime.parse("2021-12-02T08:59:03Z", DateTimeFormatter.ISO_OFFSET_DATE_TIME)
.isEqual(ZonedDateTime.of(
LocalDateTime.of(2021, 12, 2, 8, 59, 3),
ZoneId.of("UTC")));
assert ZonedDateTime.parse("2021-12-02T08:59:03+00:00", DateTimeFormatter.ISO_OFFSET_DATE_TIME)
.isEqual(ZonedDateTime.of(
LocalDateTime.of(2021, 12, 2, 8, 59, 3),
ZoneId.of("UTC")));
assert ZonedDateTime.parse("2021-12-02T08:59:03+08:00", DateTimeFormatter.ISO_OFFSET_DATE_TIME)
.isEqual(ZonedDateTime.of(
LocalDateTime.of(2021, 12, 2, 8, 59, 3),
ZoneId.of("UTC+8")));
assert ZonedDateTime.parse("2021-12-02T08:59:03+08:00", DateTimeFormatter.ISO_OFFSET_DATE_TIME)
.isEqual(ZonedDateTime.of(
LocalDateTime.of(2021, 12, 2, 8, 59, 3),
ZoneId.of("CTT", SHORT_IDS)));
// format
assert ZonedDateTime.of(
LocalDateTime.of(2021, 12, 2, 8, 59, 3),
ZoneId.of("UTC+8")
).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME).equalsIgnoreCase("2021-12-02T08:59:03+08:00");
注意: 尽管标准本身支持简化模式,DateTimeFormatter.ISO_OFFSET_DATE_TIME
只支持扩展模式。parse("20211202T085903+0800")
将抛出 DateTimeParseException
。
注意:LocalDateTime
丢弃了 UTC 偏移信息,不能代表一个时刻,因此:
- 尽管是两个不同的时刻,对
2021-12-02T08:59:03+08:00
和2021-12-02T08:59:03Z
的解析都会得到LocalDateTime.of(2021, 12, 2, 8, 59, 3)
LocalDateTime
不支持通过format
方法获得 ISO 8601 标准的字符串。
Pattern
当预定义的格式不能满足需求时,也可以使用字母和符号组合模式构建自定义的 Formatter
。简单情况下可以使用 DateTimeFormatter
的 ofPattern
方法。
LocalDate date = LocalDate.of(2022, 1, 1);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd");
assert date.format(formatter).equalsIgnoreCase("2022/01/01");
assert LocalDate.parse("2022/01/01", formatter).isEqual(date);
复杂情况下可以使用 DateTimeFormatterBuilder
,如下方示例就创建了一个行为与 DateTimeFormatter.ISO_OFFSET_DATE_TIME
基本等效的 Formatter
。
new DateTimeFormatterBuilder()
.appendPattern("yyyy-MM-dd'T'HH:mm:ss")
.parseLenient()
.appendOffset("+HH:MM", "Z")
.toFormatter();
可使用的字母符号组合模式见 Patterns for Formatting and Parsing
Jackson Date
要令 Jackson 支持 java time API,需额外引入 com.fasterxml.jackson.datatype:jackson-datatype-jsr310
依赖,并且 ObjectMapper
需要注册 JavaTimeModule
。
new ObjectMapper().registerModule(new JavaTimeModule());
Jackson 3.0 的计划支持的最低 JDK 版本为 1.8,届时将不再需要特地引用该依赖。
@JsonFormat
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd-MM-yyyy hh:mm:ss")
public LocalDateTime eventDateTime;
extends StdDeserializer<T>
& extends StdSerializer<T>
总是可以通过扩展 StdDeserializer<T>
接口实现自定义的 deserializer
@Override
public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
return LocalDateTime.parse(jsonParser.getText(), ISO8601DateTimeFormatter);
}
在 POJO 上使用注解指明即可
@JsonDeserialize(using = MyCustomizedStdDeserializer.class)
private LocalDateTime currentDeviceTime;
对于序列化过程,扩展 StdSerializer<T>
接口并使用 @JsonSerialize
注解。
Use In JavaScript
Date
对象基于 Unix Timestamp,即自 1970-01-01T00:00:00.000Z
起经过的毫秒数。
因此 Unix Epoch 需要换算成 Unix Timestamp(即毫秒单位)才能用于直接创建 Date
对象。
> new Date(1640931907)
1970-01-19T23:48:51.907Z
> new Date(1640931907 * 1000)
2021-12-31T06:25:07.000Z
Date
对象的 getTime
方法可获取 Unix 时间戳。
一般情况下不推荐使用 Date
构造函数(或与其等价的 Date.parse
)来解析日期字符串,除非是比较清晰的格式。以下代码在 Node.js v20.5.1、Safari 17.4.1 上进行了测试,可以看到符合 ISO 8601 / RFC 3339 的表达式构造出了正确的对象:
> new Date("2024-04-11T10:11:55.000+08:00")
2024-04-11T02:11:55.000Z
> new Date("2024-04-11T10:11:55+08:00")
2024-04-11T02:11:55.000Z
> new Date("2024-04-11T02:11:55.000Z")
2024-04-11T02:11:55.000Z
如果使用 HTML 中 <input />
控件产生的 value
:
<input type="date" id="date-selector">
<input type="datetime-local" id="datetime-selector">
> new Date("2024-04-11T10:11")
2024-04-11T02:11:00.000Z
> new Date("2024-04-11")
2024-04-11T00:00:00.000Z
没有时区/时间偏移信息的字符串会按系统时区的当地时间处理,相当于拼接 +08:00
。仅有日期的字符串会被添加一个 UTC 零时,相当于拼接 T00:00:00.000Z
。
到目前为止,大部分行为还符合预期,或者虽然有悖直觉,也不会出错。不过有的框架喜欢用 /
做日期间的分隔,那么没有时间偏移信息的字符串会按系统时区的当地时间处理,相当于拼接 +08:00
。仅有日期的字符串会被添加一个系统时区的零时,拼接 T00:00:00.000+08:00
。
> new Date("2024/04/11 10:11:55")
2024-04-11T02:11:55.000Z
> new Date("2024/04/11")
2024-04-10T16:00:00.000Z
如果你还没看出什么问题的话:
> new Date("2024/04/11").getTime() == new Date("2024/04/11 00:00:00").getTime()
true
> new Date("2024-04-11").getTime() == new Date("2024-04-11 00:00:00").getTime()
false
要进行比较复杂的日期时间处理,还是推荐一些第三方库。Moment.js 已进入维护状态,不建议使用。可以使用 Luxon 或者 Day.js。
计算时间间隔
支持使用两个 Date
对象或其 getTime
方法输出相减获得两个时间点的间隔(按毫秒记)。然而,如果浏览器支持 Web Performance API ,Performance.now()
会比 Date.now()
获得的结果更加可靠、精确。
Temporal
本文写作时,Temporal 仍处于 stage 2 in the TC39 standards approval process,意味着在大部分浏览器和 Node.js 中都会是 Temporal is not defined
,需要 polyfill。
Use In MySQL
TIMESTAMP
是一种保存日期和时间的数据格式,格式为YYYY-MM-DD HH:MM:SS
。
通过 SET time_zone='+00:00'
设置会话使用的时区,写入数据时 MySQL 会结合时区与插入数据将其转换为 UTC 后存储。查询时 MySQL 会按会话时区计算当地时间并返回。
其他时间数据类型如 DATETIME
并不涉及时区的换算,记录的是本地时间。
DDL 中使用 DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
确保插入新行和修改行时更新时间戳。
TIMESTAMP 在 MySQL 中的默认行为
-
11.2.6 Automatic Initialization and Updating for TIMESTAMP and DATETIME
-
auto-initialized:字段没有被指定值就会使用当前的时间戳;
-
auto-updated:行中的其他字段被修改时,字段会重置为当前的时间戳;如果字段按当前值被复制,不会触发字段更新。
如果原本的目的只是要记录行被修改过而不需要关心值的变化,auto-updated
就不可被信任了,这时候可以手动对其赋值为 CURRENT_TIMESTAMP
或使用 NOW()
。
explicit_defaults_for_timestamp
决定了 MySQL 对 TIMESTAMP
类型字段默认值的处理方式。在 MySQL 5.7 中默认为 OFF
,在 MySQL 8.0 中默认为 ON
。当 explicit_defaults_for_timestamp
设置为 OFF
时:
TIMESTAMP
默认使用NOT NULL
修饰- 表中第一个没有使用
NULL
修饰且未指定初始化和更新方法的TIMESTAMP
列将会自动添加DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
- 表中其余没有使用
NULL
修饰的TIMESTAMP
字段默认会分配DEFAULT '0000-00-00 00:00:00'
,取决于NO_ZERO_DATE
的设置,赋值为0000-00-00 00:00:00
或0
在某些情况下是非法的,这会导致 DDL 执行失败