简介
Unix时间戳是计算机科学中最基础的概念之一。它将某一时刻表示为一个简单整数——自Unix纪元(1970年1月1日 00:00:00 UTC)以来所经过的秒数。这种简洁、通用的格式支撑着从数据库记录、API响应到日志文件和定时任务的一切。
与人类可读的日期字符串(如"2024年4月9日")不同,Unix时间戳没有时区、没有地区格式、没有歧义。数字1712620800在地球上的任何地方都精确指向同一个时刻。正是这一特性,使它成为软件工程中日期与时间处理的通用语言。
Unix纪元的历史
Unix操作系统由Ken Thompson、Dennis Ritchie等人在20世纪60年代末至70年代初于贝尔实验室(Bell Labs)开发。时间追踪从一开始就内置于内核,但纪元日期的确切选择在早期开发中经历了演变。
最早的一些Unix版本使用1971年1月1日作为纪元;另一些则尝试了1969年1月1日。最终,1970年1月1日 00:00:00 UTC被标准化——这是一个"整数"日期,距系统创建时间足够近,使得真实事件的时间戳都是较小的正整数。
1970-01-01的选择本质上是任意的。重要的不是日期本身,而是约定的一致性。由于所有系统都认同同一个纪元,时间戳可以在程序、语言和平台之间交换,无需任何转换开销。
这一约定后来被POSIX(可移植操作系统接口)正式规定:Unix时间戳定义为自1970-01-01T00:00:00Z以来的非闰秒数。
秒与毫秒
并非所有"时间戳"都是相同单位。最重要的区别在于单位:
- Unix时间戳(POSIX): 自纪元以来的秒数,例如
1712620800 - JavaScript
Date: 自纪元以来的毫秒数,例如1712620800000 - Java
System.currentTimeMillis(): 自纪元以来的毫秒数 - Go
time.Now().Unix(): 秒;time.Now().UnixNano()提供纳秒精度 - Python
time.time(): 浮点秒数
秒和毫秒之间1000倍的差异是常见的错误来源。将毫秒时间戳传入期望秒的代码会产生遥远未来的日期(约56000年);反之则产生1970年1月的日期。
转换方法非常简单:
const unix_ms = unix_s * 1000;
const unix_s = Math.floor(unix_ms / 1000);
实用判断规则:如果时间戳是10位数,通常是秒;如果是13位数,通常是毫秒。
ISO 8601格式及其变体
ISO 8601是国际日期和时间表示标准,定义了一系列明确的字符串格式:
2024-04-09 # 仅日期
2024-04-09T12:00:00 # 本地日期时间(无时区信息)
2024-04-09T12:00:00Z # UTC(Z = Zulu时间 = UTC+0)
2024-04-09T20:00:00+08:00 # 带UTC偏移(如亚洲/上海)
2024-04-09T12:00:00.123Z # 带毫秒
2024-04-09T12:00:00.123456789Z # 带纳秒
T分隔日期和时间部分。Z后缀(来自北约音标"Zulu",即UTC)表示时间为UTC。偏移量如+08:00表示本地时间比UTC早8小时。
ISO 8601是REST API、日志文件以及任何跨边界交换时间数据的系统的推荐格式。它人类可读、可作为字符串排序,且无歧义。
时区与UTC
UTC(协调世界时) 是世界主要时间标准。它本身不是一个时区,而是所有时区定义的基准。UTC+0与冬季的格林尼治标准时间(GMT)相同。
时区表示为UTC的偏移量:UTC+5:30(印度)、UTC-8(美国太平洋标准时间)、UTC+9(日本)。然而,仅有原始偏移量不足以完整描述一个时区,因为偏移量会随夏令时(DST)而变化。
IANA时区数据库(也称tz数据库或zoneinfo)是所有世界时区的权威列表。它使用America/New_York、Europe/Berlin、Asia/Tokyo、Asia/Kolkata等标识符。这些标识符不仅封装了当前UTC偏移量,还包含了DST规则和政治变更的完整历史记录(例如某国更改时区时)。
所有主要编程语言和操作系统都包含IANA数据库:
- JavaScript(Node.js):使用IANA标识符的
Intl.DateTimeFormat - Python:
zoneinfo模块(Python 3.9+)或pytz - Java:
java.time.ZoneId(如ZoneId.of("Asia/Tokyo")) - Go:
time.LoadLocation("America/New_York")
永远不要在应用逻辑中使用原始UTC偏移量(如+05:30)来表示时区。应使用IANA标识符,因为偏移量会随季节变化。
夏令时(DST)的复杂性
夏令时是在夏季将时钟拨快一小时的做法,以延长傍晚的日照时间。北美和欧洲大部分地区以及南美、中东和大洋洲的部分地区都遵守夏令时。日本、中国和印度等许多国家根本不实行夏令时。
DST引入了两个经典异常:
春季拨快(Spring forward): 时钟从凌晨2:00直接跳到3:00。本地时间的2:00–3:00这60分钟从不存在。如果调度了凌晨2:30的任务,它要么在3:30运行,要么被完全跳过,取决于调度器的实现。
秋季拨慢(Fall back): 时钟从凌晨2:00退回到1:00。本地时间1:00–2:00这60分钟出现两次。如果记录了"本地时间凌晨1:45"的日志条目,它是模糊的——可能来自该小时的第一次或第二次出现。
Unix时间戳完全不受DST影响,因为它始终相对于UTC。数字1712620800始终指向同一个时刻,无论您在哪里或是什么季节。
黄金法则: 始终以UTC存储和传输时间戳。仅在展示层(直接显示给人类用户之前)才转换为本地时间。
2038年问题
2038年问题(也称Y2K38或Unix千年虫)是一种与2000年Y2K漏洞性质相似的软件漏洞。
根本原因:许多遗留系统将Unix时间戳存储为32位有符号整数。32位有符号整数的最大值为2,147,483,647,对应:
2038年1月19日 03:14:07 UTC
在那一刻之后的一秒,32位有符号整数溢出并回绕到最小负值:-2,147,483,648,对应1901年12月13日 20:45:52 UTC。发生溢出的系统会突然认为日期在1901年。
可能受影响的系统包括:
- 为32位架构编译的遗留嵌入式系统和IoT设备
- 使用MySQL
TIMESTAMP类型的旧数据库结构(8.0版本之前使用32位存储) - 32位Linux内核(64位平台已在内核层面解决)
- 一些以32位整数记录修改时间的旧文件系统
解决方案很简单:将所有时间戳存储迁移到64位有符号整数。64位时间戳可以表示到约292,277,026,596年——远超任何实际需求。大多数现代64位系统已经正确处理了这一问题。
在不同编程语言中使用时间戳
JavaScript
// 当前时间
const now = Date.now(); // 自纪元以来的毫秒数
const unix = Math.floor(now / 1000); // 转换为秒
// 解析Unix时间戳
const date = new Date(unix * 1000);
console.log(date.toISOString()); // "2024-04-09T12:00:00.000Z"
console.log(date.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }));
Python
import time
import datetime
# 当前Unix时间戳(秒)
unix = int(time.time())
# 转换为UTC日期时间
dt = datetime.datetime.fromtimestamp(unix, tz=datetime.timezone.utc)
print(dt.isoformat()) # "2024-04-09T12:00:00+00:00"
# 使用zoneinfo处理IANA时区
from zoneinfo import ZoneInfo
dt_local = dt.astimezone(ZoneInfo("Asia/Shanghai"))
print(dt_local.isoformat())
Go
package main
import (
"fmt"
"time"
)
func main() {
unix := time.Now().Unix() // int64 秒
t := time.Unix(unix, 0).UTC()
fmt.Println(t.Format(time.RFC3339)) // "2024-04-09T12:00:00Z"
loc, _ := time.LoadLocation("Asia/Shanghai")
fmt.Println(t.In(loc).Format(time.RFC3339)) // "2024-04-09T20:00:00+08:00"
}
Java
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
long unix = Instant.now().getEpochSecond(); // 秒
Instant instant = Instant.ofEpochSecond(unix);
System.out.println(instant.toString()); // "2024-04-09T12:00:00Z"
ZonedDateTime zdt = instant.atZone(ZoneId.of("Asia/Shanghai"));
System.out.println(zdt.toString());
Rust
use std::time::{SystemTime, UNIX_EPOCH};
fn main() {
let unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("时间发生了回退")
.as_secs(); // u64 秒
println!("{}", unix); // 例如 1712620800
}
常见时间戳格式
| 格式 | 示例 | 使用场景 |
|---|---|---|
| Unix(秒) | 1712620800 |
Unix/Linux、POSIX API |
| Unix(毫秒) | 1712620800000 |
JavaScript、Java |
| Unix(微秒) | 1712620800000000 |
PostgreSQL、部分API |
| Unix(纳秒) | 1712620800000000000 |
Go、Rust |
| ISO 8601 UTC | 2024-04-09T12:00:00Z |
REST API、数据库 |
| ISO 8601带偏移 | 2024-04-09T20:00:00+08:00 |
日历应用 |
| RFC 2822 | Tue, 09 Apr 2024 12:00:00 +0000 |
电子邮件头 |
| RFC 3339 | 2024-04-09T12:00:00Z |
互联网协议 |
| HTTP日期 | Tue, 09 Apr 2024 12:00:00 GMT |
HTTP头 |
时间戳运算
由于Unix时间戳只是一个数字,对其进行算术运算非常简单。
计算两个时间戳之间的持续时间:
start = 1712620800
end = 1712707200
duration_seconds = end - start # 86400秒 = 整整1天
计算未来或过去的日期:
const now = Math.floor(Date.now() / 1000);
const oneWeekLater = now + 7 * 24 * 60 * 60; // +604800秒
const thirtyDaysAgo = now - 30 * 24 * 60 * 60; // -2592000秒
常用时间段的秒数:
| 时间段 | 秒数 |
|---|---|
| 1分钟 | 60 |
| 1小时 | 3,600 |
| 1天 | 86,400 |
| 1周 | 604,800 |
| 30天 | 2,592,000 |
| 1年(365天) | 31,536,000 |
注意:对于日历感知运算(如"加1个月"),请使用日期库而非原始秒数,因为月份长度不同,且DST可能使某些天为23或25小时。
使用场景
应用日志: 带有Unix时间戳的日志条目可以在不同时区运行的分布式系统中进行排序、过滤和比较——完全没有歧义。
REST API: 以Unix整数形式返回时间戳,避免了服务器端的时区解释问题。客户端读取整数并以用户本地时区格式化。
数据库: 将时间戳存储为整数(或ISO 8601字符串)比特定平台的日期类型更具可移植性。PostgreSQL的TIMESTAMPTZ内部以UTC存储;MySQL的DATETIME(优于TIMESTAMP)避免了Y2038限制。
定时任务和cron作业: 以UTC计算"每天凌晨3:00运行"可避免DST意外。许多调度框架(Kubernetes CronJobs、GitHub Actions计划)按惯例使用UTC。
缓存过期和TTL: HTTP Cache-Control: max-age=3600和Expires头使用绝对Unix时间戳或相对秒数;CDN和浏览器依靠精确的时间戳运算来使缓存失效。
事件溯源和审计跟踪: 不可变的事件日志需要能够明确排序事件的时间戳。Unix时间戳,特别是纳秒分辨率,即使对于高吞吐量系统也能提供这种保证。
最佳实践
始终以UTC存储时间戳。 永远不要在数据库中存储本地时间。仅在展示层才转换为本地时间。
使用64位整数。 在任何新代码中避免使用
int32存储时间戳。即使您的系统目前只处理近期日期,64位也是安全的默认选择。使用ISO 8601进行人类可读的序列化。 当您需要在日志或API中使用字符串表示时,ISO 8601无歧义且按字典顺序排序。
使用IANA时区标识符,而非偏移量。
"Asia/Shanghai"是正确的;"+08:00"是脆弱的,因为偏移量每年变化两次(对于观察DST的地区)。验证时间戳单位。 在使用外部时间戳之前,验证它是秒、毫秒还是其他单位。快速检查:10位数字通常是秒;13位数字通常是毫秒。
在生产代码中不要用正则表达式解析日期。 使用您所用语言的标准库或经过充分测试的第三方库。
小心"午夜"。 在观察DST的时区中,某些日期可能不存在午夜(春季拨快时)。对于仅日期的计算,使用中午(UTC 12:00)作为安全的"代表性"时间。
在DST转换时间点附近进行测试。 如果您的应用涉及调度或时间计算,请编写专门针对相关时区的春季拨快和秋季拨慢边界的测试。
常见问题
Q:Unix时间戳0代表什么?
A:1970年1月1日 00:00:00 UTC——Unix纪元。负值时间戳代表1970年之前的日期。
Q:可以用时间戳进行排序吗?
A:可以。由于Unix时间戳是单调递增的整数,按时间戳排序等同于按时间顺序排序。
Q:Unix时间受闰秒影响吗?
A:POSIX将Unix时间定义为每天恰好有86,400秒,这意味着闰秒不被计入。POSIX时间戳在技术上是"Unix时间"或"POSIX时间",而非"真正的"国际原子时(TAI)。在实践中,这对应用代码几乎没有影响。
Q:Unix时间戳可以表示的最大日期是什么?
A:使用64位有符号整数,最大值对应年份292,277,026,596年。使用32位有符号整数,最大值为2038年1月19日 03:14:07 UTC。
Q:如何在浏览器中获取当前Unix时间戳?
A:在浏览器控制台中执行Math.floor(Date.now() / 1000)即可获取当前以秒为单位的Unix时间戳。
Q:为什么转换时间戳时显示1970-01-01?
A:您几乎肯定是将毫秒传入了期望秒的函数(或反之)。如果看到遥远未来的日期,请除以1000;如果看到1970年1月1日,请乘以1000。