2021 年,一次"我把服务器时区从 UTC 改成北京时间,结果数据库里【已经存好的时间】,集体错了 8 小时"的事故,把我对"时间"这件事的理解,从头到尾翻新了一遍。那台新装的服务器,date 一看,显示的是 UTC 时间——比北京时间慢 8 小时。日志里的时间戳全是 UTC,排查问题时我得在脑子里手动 +8,烦得要死。我想都没想:把时区改成北京时间不就行了。timedatectl set-timezone Asia/Shanghai,一行命令,date 立刻显示成了正确的北京时间。我很满意。可没过多久,业务同事炸了:他们说,系统里【那些老订单】的下单时间,全都不对了——比真实下单时间,整整【晚了 8 小时】。我冲到数据库一查,确实:那些在我改时区【之前】就写进去的时间,显示出来全错了。我懵了。我只是改了一下服务器"显示"时间的方式,我【根本没碰过数据库里那些数据】啊。一个我从没动过的、躺在数据库里的旧时间,怎么会因为我改了操作系统的时区,就跟着变了?如果"时间"是存死在数据库里的一个值,它不该雷打不动吗?如果它会变——那数据库里存的,到底【是什么】?我改时区,改的又到底是什么?这件事逼着我把"一个时间到底是怎么存的"、UTC 时间戳与时区的关系、时区只是一层"翻译"、还有时钟同步,彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一台新服务器 + 一个业务数据库
事故现象:
- 新服务器 date 显示 UTC,比北京时间慢 8 小时
- ★ 改时区为 Asia/Shanghai 后,date 对了
- ★★ 但数据库里"改时区之前"存的旧时间,全错了 8 小时
现场排查:
# 1. ★ 看服务器当前时区和时间
$ timedatectl
# Local time: Mon 2021-06-07 03:20:11 UTC
# Universal time: Mon 2021-06-07 03:20:11 UTC
# Time zone: UTC (UTC, +0000) # ★ 时区是 UTC
# 2. ★ 改时区
$ timedatectl set-timezone Asia/Shanghai
$ date
# Mon Jun 7 11:20:30 CST 2021 # ★ 现在对了,+8
# 3. ★★ 关键:看数据库里一条旧记录的时间
# 这条订单,真实下单时间是 6 月 1 日 10:00(北京时间)
$ mysql -e "SELECT id,created_at FROM orders WHERE id=8001"
# created_at: 2021-06-01 18:00:00 # ★★ 显示成 18:00 了!晚了 8 小时
# 4. ★ 看这个数据库列的类型
$ mysql -e "SHOW COLUMNS FROM orders LIKE 'created_at'"
# Field: created_at Type: timestamp # ★ 是 timestamp 类型
# 5. ★ 看 MySQL 的时区设置
$ mysql -e "SHOW VARIABLES LIKE '%time_zone%'"
# system_time_zone: CST
# time_zone: SYSTEM # ★ MySQL 跟随系统时区
根因(后来想清楚的):
1. ★ 一个"时间",在计算机底层,存的【不是】
"2021-06-01 10:00:00"这样的字符串。它存的是
一个【数字】:从 1970-01-01 00:00:00 UTC 那个
固定起点,到现在,过了【多少秒】。这个数,叫
Unix 时间戳。它是【绝对的、和时区无关的】。
2. ★ "2021-06-01 10:00:00" 这种人能读的写法,是
把那个时间戳,★ 按【某个时区】翻译出来的结果。
同一个时间戳,用不同时区翻译,得到的"年月日
时分秒"不一样 —— 但它们指的是【同一个绝对时刻】。
3. ★ MySQL 的 timestamp 类型,底层存的就是 UTC
时间戳。它显示给你时,会按【当前会话的时区】
把这个时间戳翻译成"年月日时分秒"。
4. ★ 那条订单写入时,系统时区是 UTC。MySQL 把
"北京时间 6/1 10:00"对应的时刻,存成时间戳。
5. ★★ 我把系统时区改成 Asia/Shanghai 后,MySQL
的 time_zone=SYSTEM 跟着变成了 CST。于是它
再【翻译】那个老时间戳时,用的是 +8 的尺子 ->
同一个时间戳,被翻译成了不同的"时分秒"。
真相:数据库里的时间值没变,变的是"翻译它的
时区尺子"。时间分两层:底层时间戳是绝对的,
时区只是把它翻译给人看的一层"显示规则"。
修复 1:时间差 8 小时——先分清是"时区"还是"时钟"问题
# === ★ "时间不对"是个笼统的说法,先拆成两类完全不同的问题 ===
# === ★ 问题 A:时区问题(本文)===
# ★ 现象:date 显示的时间,和你期望的差【整数个
# 小时】—— 差 8 小时、差 12 小时、差 5 小时半。
# ★ ★ 本质:这台机器的【绝对时刻是对的】,只是它
# 用【另一个时区的尺子】在显示。差的那 8 小时,
# 恰好是两个时区的时差。
# ★ 关键特征:★ 差值是【固定的整数小时】(或半小时),
# 而且【不会漂移】—— 今天差 8 小时,明天还是差 8。
# === ★ 问题 B:时钟问题(时钟漂移 / 没同步)===
# ★ 现象:date 显示的时间,和真实时间差【几秒、几
# 十秒、几分钟】这种【不规整】的值。
# ★ ★ 本质:这台机器的【绝对时刻本身就是错的】——
# 它的硬件时钟走得不准,或者从来没和标准时间源
# 对过表。
# ★ 关键特征:★ 差值【不规整】,而且会【慢慢漂移】
# —— 今天差 12 秒,过一周差 30 秒,越拉越大。
# === ★ 怎么快速判断你遇到的是哪一类 ===
$ timedatectl
# Local time: Mon 2021-06-07 03:20:11 UTC
# Universal time: Mon 2021-06-07 03:20:11 UTC
# Time zone: UTC (UTC, +0000)
# System clock synchronized: no # ★ 时钟有没有在同步
# NTP service: inactive
# ★ 看两个地方:
# - ★ Time zone 那行:如果不是你要的时区(比如你
# 在中国,它却是 UTC)-> 是【时区问题】(问题 A);
# - ★ System clock synchronized: 那行:如果是 no,
# 且 Universal time 和你手机/标准时间对不上 ->
# 是【时钟问题】(问题 B)。
# ★ ★ 两个问题可能【同时存在】:时区错 + 时钟也没
# 同步。本文事故主要是 A,但修完 A 我又发现了 B。
# === ★ 为什么必须先分清 ===
# ★ 时区问题,改时区就好,★ 机器的绝对时刻一秒都
# 不用动。
# ★ 时钟问题,要去【校准绝对时刻】(NTP 同步),
# ★ 改时区对它毫无帮助。
# ★ 把时钟问题当时区问题去改,或反过来 —— 都是
# 南辕北辙,越改越乱。
# === 认知 ===
# ★ "时间不对"先拆成两类:① 时区问题 —— 绝对时刻
# 是对的,只是用了别的时区尺子显示,差值是【固定
# 整数小时】不漂移,改时区即可;② 时钟问题 —— 绝对
# 时刻本身错了,差值【不规整且会漂移】,要 NTP 校准。
# 用 timedatectl 一眼分辨:看 Time zone 行判断时区
# 对不对,看 System clock synchronized 行判断时钟
# 同步没有。两者可能同时存在,但修法完全不同。
修复 2:核心认知——一个时间分两层:UTC 时间戳与时区
# === ★ 这一节是全文的认知核心:时间到底是怎么"存"的 ===
# === ★ 第一层(底层):Unix 时间戳 —— 绝对的、唯一的 ===
# ★ 计算机底层,记录一个时刻,用的【不是】"年月日
# 时分秒"这种字符串。它用一个【整数】:
# ★ 从 1970-01-01 00:00:00 UTC 这个全人类约定的
# 固定起点,到那个时刻,一共过了【多少秒】。
# ★ 这个整数,叫 Unix 时间戳(Unix timestamp /
# epoch time)。
$ date +%s
# 1623036011 # ★ 此刻的时间戳,就是这么个数
# ★ ★ 关键性质:时间戳是【绝对的】。同一个物理
# 时刻,全世界任何一台机器、任何一个时区,算出来
# 的时间戳【完全一样】。它【不含任何时区信息】,
# 它就是"距离那个起点的秒数",一个纯粹的数。
# === ★ 第二层(显示层):把时间戳"翻译"成人能读的 ===
# ★ "1623036011"这个数,人看不懂。要给人看,得把
# 它翻译成"2021-06-07 某时某分某秒"。
# ★ ★ 而翻译,必须【指定一个时区】。因为同一个
# 时间戳,在不同时区,对应的"墙上时钟读数"不同:
$ date -d @1623036011 -u
# Mon Jun 7 03:20:11 UTC 2021 # ★ 用 UTC 翻译
$ TZ='Asia/Shanghai' date -d @1623036011
# Mon Jun 7 11:20:11 CST 2021 # ★ 用北京时区翻译
$ TZ='America/New_York' date -d @1623036011
# Mon Jun 7 23:20:11 EDT 2021 (前一天) # ★ 用纽约时区翻译
# ★ ★★ 看清楚:上面三行,是【同一个时间戳】、
# 【同一个绝对时刻】 —— 只是被三把不同的时区
# 尺子,翻译成了三个不同的"年月日时分秒"。
# 它们谁都没错,它们说的是同一件事。
# === ★ 于是,本文的事故,彻底解释清楚了 ===
# ★ 数据库 timestamp 列里,存的是【第一层】——
# 那个绝对的 UTC 时间戳。它【没有变过】。
# ★ 我改系统时区 -> MySQL 翻译时间戳用的【时区尺子】
# 从 UTC 换成了 CST(+8)。
# ★ 同一个没变过的时间戳,换了把尺子翻译,显示出来
# 的"时分秒"自然就不同了 —— 差的正好是 8 小时。
# ★ ★ 所以"数据不会因为我改时区而改变"这句话,
# 对了一半:【底层时间戳】确实没变;但【显示出来
# 的样子】变了,因为翻译规则变了。我误把"显示"
# 当成了"数据本身"。
# === ★ 一个比喻,把这两层钉死 ===
# ★ 时间戳,就像一段【录音的绝对时长】(比如"第
# 3601 秒")—— 这是客观的、唯一的。
# ★ 时区,就像【你用哪个国家的语言去念出这个时刻】。
# 同一个时刻,中文念是"上午十一点",英文念是
# "3 AM" —— 念法不同,但说的是同一刻。
# ★ 改时区 = 换一种语言来念。录音本身,一秒没动。
# === 认知 ===
# ★ 一个时间分【两层】:底层是 Unix 时间戳 —— 从
# 1970-01-01 00:00:00 UTC 起经过的秒数,是【绝对的、
# 不含时区、全世界一致】的一个整数;显示层是把这个
# 时间戳【按某个时区翻译】成的"年月日时分秒"。同一
# 个时间戳用不同时区翻译,得到不同的墙上读数,但指
# 的是【同一个绝对时刻】。★ 数据库 timestamp 存的是
# 底层时间戳,改时区改的只是"翻译它的尺子",数据
# 本身没动 —— 别把"显示"误当成"数据本身"。
修复 3:看时区、设时区——timedatectl 与 TZ
# === ★ 把时区的查、设,系统地过一遍 ===
# === ★ 看当前时区 ===
$ timedatectl
# Time zone: Asia/Shanghai (CST, +0800)
# ★ 或直接看那个符号链接:
$ ls -l /etc/localtime
# /etc/localtime -> /usr/share/zoneinfo/Asia/Shanghai
# ★★ 关键:/etc/localtime 是个【软链接】,指向
# /usr/share/zoneinfo/ 下的某个时区数据文件。
# 系统的"默认时区",本质就是这个软链接指向谁。
# === ★ 设系统时区(推荐用 timedatectl)===
$ timedatectl list-timezones | grep -i shanghai
# Asia/Shanghai
$ timedatectl set-timezone Asia/Shanghai
# ★ 它做的事:把 /etc/localtime 这个软链接,重新
# 指向 .../zoneinfo/Asia/Shanghai。
# ★ ★ 再强调:set-timezone 只改"显示规则",机器的
# 绝对时刻(UTC 时间戳)【一秒都不动】。
# === ★ TZ 环境变量:临时、局部地换一把尺子 ===
# ★ 不想改全局,只想让某条命令、某个程序用别的时区,
# 用 TZ 环境变量:
$ TZ='America/New_York' date # 只这一条命令用纽约时区
$ export TZ='Asia/Shanghai' # 当前 shell 之后都用这个
# ★ 程序读时间时,会优先看 TZ 环境变量;没有 TZ,
# 才用 /etc/localtime。
# === ★ 时区的命名:别用 GMT+8 那种,用地区名 ===
# ★ ★ 推荐:Asia/Shanghai、America/New_York 这种
# "大区/城市"格式。
# ★ 为什么不用 "GMT+8" / "CST"?
# - CST 是个【有歧义】的缩写:它既是中国标准时间,
# 也是美国中部时间,还是古巴标准时间 —— 同名不同时;
# - ★ "Asia/Shanghai"这种地区名,背后是一个完整的
# 时区数据库,它【还包含了这个地区历史上的夏令时
# 规则】。用地区名,夏令时切换会自动处理对;用
# "GMT+8"这种固定偏移,夏令时就错了。
# === ★ 硬件时钟(RTC)用 UTC 还是本地时间 ===
$ timedatectl
# RTC in local TZ: no
# ★ RTC 是主板上那块断电也走的硬件时钟。★ 强烈
# 建议让 RTC 存【UTC】(上面显示 no 即为 UTC)。
# Linux 服务器默认如此。只有和 Windows 双系统时,
# 才可能需要 RTC 用本地时间 —— 单系统服务器别碰。
# === 认知 ===
# ★ 系统时区的本质是 /etc/localtime 这个软链接指向
# /usr/share/zoneinfo/ 下哪个时区文件。用 timedatectl
# set-timezone 设置(只改显示规则,不动绝对时刻)。
# 想临时/局部换时区用 TZ 环境变量。★ 时区名一定用
# "Asia/Shanghai"这种地区/城市格式 —— 别用 CST、
# GMT+8:CST 有歧义、固定偏移处理不了夏令时,地区名
# 背后的时区库才会自动处理夏令时等历史规则。
修复 4:程序里的时间——日志、容器、数据库的时区坑
# === ★ 时区的坑,大量出现在"程序"层面,逐个看 ===
# === ★ 坑 1:日志时间戳是 UTC,排查时对不上 ===
# ★ 很多程序(尤其 Java、跑在容器里的服务)打日志
# 用的是 UTC,你按本地时间去对,差 8 小时。
# ★ 排查思路:确认日志时间到底是哪个时区。最稳的
# 做法 —— 让程序【日志里直接带时区标识】(打印
# +08:00 或 Z),别让人猜。
# === ★★ 坑 2:容器(Docker)里的时区,和宿主机无关 ===
# ★ ★ 一个极高频的坑:宿主机时区是 Asia/Shanghai,
# 但容器里跑的程序,时间却是 UTC。
# ★ 原因:容器是【独立的文件系统】,它【不会自动
# 继承宿主机的 /etc/localtime】。容器镜像里默认
# 往往就是 UTC。
# ★ 修法(几种):
# - 启动容器时挂载宿主机的时区文件:
$ docker run -v /etc/localtime:/etc/localtime:ro ...
# - 或在镜像里设 TZ 环境变量、装好 tzdata;
# - ★ K8s 里可以用 hostPath 挂载,或设 TZ 环境变量。
# ★ 一句话:容器的时区要【显式配置】,别指望它
# 自动和宿主机一致。
# === ★ 坑 3:数据库的时区,是另一套独立设置 ===
# ★ ★ 数据库(MySQL/PG)有它【自己的时区设置】,
# 和操作系统时区是【两回事】。
$ mysql -e "SHOW VARIABLES LIKE '%time_zone%'"
# time_zone: SYSTEM (跟随系统) 或 +08:00 (固定)
# ★ MySQL 的 timestamp 类型:存 UTC,按【连接会话
# 的时区】翻译显示 —— 所以同一条记录,不同时区的
# 客户端连上去看,显示的时分秒不同。
# ★ MySQL 的 datetime 类型:★ 不一样!datetime
# 【不带时区概念】,你存进去什么字符串,取出来
# 就是什么 —— 它不做任何时区翻译。
# ★ ★ 这个区别是大坑:timestamp 会随时区变,datetime
# 不会。选哪个,取决于你要不要"时区无关"。
# === ★ 坑 4:代码里"现在几点" vs "某个绝对时刻" ===
# ★ 编程时牢记那两层:
# - ★ 在【存储、传输、比较】时间时 —— 永远用
# 【时间戳 / UTC】。两个系统之间传时间,传时间戳,
# 不传"2021-06-07 11:00"这种带时区歧义的字符串。
# - ★ 只在【最后要显示给人看】的那一刻,才按用户
# 所在时区,把时间戳翻译成本地时间。
# ★ 这叫"内部 UTC,边界翻译" —— 能躲掉九成时区坑。
# === ★ 坑 5:改了系统时区,长期运行的进程没生效 ===
# ★ 很多程序在【启动时】就把时区读进内存了。你改了
# /etc/localtime,★ 已经在跑的进程不会自动感知 ——
# 要【重启进程】才用上新时区。
# === 认知 ===
# ★ 程序层的时区坑:① 日志常用 UTC,最好让日志直接
# 带时区标识别让人猜;②★ 容器不自动继承宿主机时区,
# 要显式挂载 /etc/localtime 或设 TZ;③ 数据库有自己
# 独立的时区设置,MySQL timestamp 存 UTC 会随会话
# 时区翻译、datetime 不带时区不翻译,两者区别是大坑;
# ④ 代码遵循"内部 UTC、边界翻译":存储传输比较都用
# 时间戳,只在显示给人看时才按时区翻译;⑤ 改系统
# 时区后,已运行的进程要重启才生效。
修复 5:时钟同步——机器之间的时钟漂移与 NTP
# === ★ 修完时区,我又撞上了第二个问题:时钟漂移 ===
# === ★ 现象:两台机器,时区都对了,时间却差几十秒 ===
# ★ 改完时区后,我发现 A、B 两台机器,date 出来差
# 了大概 40 秒。时区都是 Asia/Shanghai,没问题 ——
# ★ 是它们的【绝对时刻本身】不一样。
# ★ 后果很严重:我们有个分布式 token,带"签发时间"
# 和"有效期"。A 机签发的 token 拿到 B 机校验,B
# 机的钟比 A 快 40 秒,就可能判定"这 token 还没
# 到生效时间"或"已过期" -> 验证莫名失败。
# ★ ★ 分布式锁、JWT、TLS 证书、HTTP 签名…… 全都
# 依赖"各机器时钟基本一致"。时钟漂移是隐形杀手。
# === ★ 为什么机器时钟会"漂" ===
# ★ 每台机器的硬件时钟,靠晶振计时,而晶振的频率
# 有微小误差。这点误差日积月累,机器的时间就会
# 一点点【偏离真实时间】—— 这叫"时钟漂移"。
# ★ 所以机器必须【定期和一个权威时间源对表】——
# 这就是 NTP(网络时间协议)干的事。
# === ★ 检查时钟有没有在同步 ===
$ timedatectl
# System clock synchronized: yes # ★ yes 才是好的
# NTP service: active
# ★ synchronized: no / NTP service: inactive
# -> 这台机器【没在和时间源对表】,迟早漂。
# === ★ 用 chrony 做时间同步(CentOS 7+ 默认)===
$ systemctl status chronyd # 看 chrony 跑没跑
$ systemctl enable --now chronyd # 没跑就启用并启动
$ chronyc sources -v # 看它在和哪些时间源对
$ chronyc tracking # 看当前的偏差有多大
# ★ tracking 里的 "System time" 一行,告诉你本机
# 现在和标准时间差多少 —— 应该是毫秒级。
# ★ (老系统可能用 ntpd,看 ntpq -p。新系统多用
# chrony,它在网络不稳时表现更好。)
# === ★ 立即强制对一次表 ===
$ chronyc makestep # 让 chrony 立刻把时间拨准
# ★ 平时 chrony 是【慢慢调】(把钟微微调快/调慢,
# 让它平滑追上),不会突然跳 —— 因为时间【突然
# 跳变】会让很多程序出问题(定时器、日志乱序)。
# makestep 是允许它"猛地拨一下",首次同步偏差很
# 大时用。
# === ★ 纪律 ===
# ★ ★ 每一台服务器,装好就【必须】开 NTP 同步
# (chronyd / ntpd),并确认 synchronized: yes。
# 这不是可选项 —— 一个集群里时钟不齐,会冒出
# 一堆诡异到让你怀疑人生的 bug。
# === 认知 ===
# ★ 时区对了不代表时间对了 —— 机器硬件时钟靠晶振计时
# 有微小误差,日积月累会【漂移】偏离真实时间。两台
# 机器时钟差几十秒,会让 JWT、分布式锁、TLS 证书、
# 签名校验等莫名失败。必须靠 NTP 定期和权威时间源
# 对表:用 chrony(chronyc sources/tracking 看状态),
# timedatectl 确认 System clock synchronized: yes。
# ★ 每台服务器装好就必须开 NTP 同步,这是硬性纪律。
修复 6:时间问题排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ "时间不对"先分两类:时区问题(差整数小时)vs 时钟问题(差不规整且漂移)===
$ timedatectl # 看 Time zone 和 System clock synchronized 两行
# === 2. ★ 时间分两层:底层 UTC 时间戳是绝对的,时区只是翻译它的"显示规则" ===
# === 3. ★ 改时区不动绝对时刻,只换"翻译尺子",已存的时间戳值不变但显示会变 ===
# === 4. ★ 时区名用 Asia/Shanghai 这种地区格式,别用 CST / GMT+8(有歧义、不处理夏令时)===
# === 5. ★ 容器不继承宿主机时区,要挂载 /etc/localtime 或设 TZ 环境变量 ===
$ docker run -v /etc/localtime:/etc/localtime:ro ...
# === 6. ★ 数据库有独立时区设置;MySQL timestamp 随时区翻译,datetime 不带时区 ===
$ mysql -e "SHOW VARIABLES LIKE '%time_zone%'"
# === 7. ★ 代码遵循"内部 UTC、边界翻译":存储传输比较用时间戳,只在显示时翻译 ===
# === 8. ★ 改系统时区后,已运行的进程要重启才生效 ===
# === 9. ★★ 每台服务器装好必须开 NTP 同步,确认 synchronized: yes ===
$ systemctl enable --now chronyd ; chronyc tracking
# === 10. 排查时间问题的步骤链 ===
$ timedatectl # ① 时区对不对、时钟同步没有
$ date ; date -u # ② 本地时间 和 UTC 时间
$ date +%s # ③ 当前时间戳,和别的机器比对
$ chronyc sources ; chronyc tracking # ④ NTP 同步状态和偏差
$ ls -l /etc/localtime # ⑤ 时区软链接指向哪
命令速查
需求 命令
=============================================================
看时区/时间/同步状态 timedatectl
设系统时区 timedatectl set-timezone Asia/Shanghai
列出所有可用时区 timedatectl list-timezones
看当前时间戳(秒) date +%s
看 UTC 时间 date -u
用指定时区显示时间 TZ='America/New_York' date
把时间戳翻译成可读时间 date -d @1623036011
看时区软链接指向 ls -l /etc/localtime
看 NTP 同步源 chronyc sources -v
看时钟偏差 chronyc tracking
立即强制对表 chronyc makestep
启用时间同步服务 systemctl enable --now chronyd
口诀:时间分两层 底层 UTC 时间戳是绝对的 时区只是翻译给人看的尺子
改时区不动绝对时刻 每台机器必须开 NTP 同步否则时钟会漂
避坑清单
- 时间不对先分两类,差固定整数小时是时区问题,差不规整且会漂移是时钟没同步问题
- 一个时间分两层,底层是绝对的 UTC 时间戳,显示出来的年月日时分秒是按时区翻译的结果
- 改时区不会改动底层时间戳,只是换了翻译它的时区尺子,但显示出来的时分秒会变
- 数据库 timestamp 列存的是 UTC 时间戳,改系统时区后旧数据显示会变这不是数据损坏
- 时区名要用 Asia/Shanghai 这种地区格式,别用 CST 有歧义也别用 GMT+8 处理不了夏令时
- 容器不会自动继承宿主机时区,要挂载 /etc/localtime 或在镜像里设 TZ 环境变量
- MySQL 的 timestamp 会随会话时区翻译而 datetime 不带时区不翻译,选型时务必分清
- 代码里时间遵循内部 UTC 边界翻译,存储传输比较都用时间戳只在显示给人看时才翻译
- 改了系统时区后已经在运行的进程不会自动感知,要重启进程才会用上新时区
- 每台服务器装好必须开 NTP 时间同步,机器之间时钟漂移会让 token 和分布式锁莫名失效
总结
这次"改个时区,数据库旧数据集体错乱"的事故,纠正了我一个关于"时间"的、藏得极深的错觉。在我过去的脑子里,一个时间,就是"2021-06-01 10:00:00"这样一串字符。我看见数据库某一行写着这个,我就认定:这一行,【就是】这个时间,板上钉钉,一个字符都不会变。它是一个【静态的、写死的事实】。所以当我只是改了操作系统显示时间的方式、根本没碰数据库,那些旧时间却"自己"变了 8 小时,我整个人是懵的——一个我从没动过的、白纸黑字的事实,怎么会自己改写自己?这在我"时间 = 一串写死的字符"的世界观里,是闹鬼。直到我把时间戳那一层挖出来,我才看清,我一直盯着的那串"年月日时分秒",根本【不是数据本身】——它是数据被【翻译出来】的一个【样子】。数据库里那个真正写死的、雷打不动的事实,是底下那个我从来没看见过的整数:一个从 1970 年那个起点数过来的、纯粹的秒数。它和"时区"没有半点关系,它就是宇宙里那一个客观的、唯一的时刻。而"10:00:00"也好、"18:00:00"也好,都只是这个秒数,经过某一把"时区尺子"翻译之后,呈现给我的【译文】。我改时区,改的从来不是那个事实,我改的是【翻译那个事实的语言】。译文变了,原文一个标点都没动。我之所以会"闹鬼",是因为我这十几年,从来只看译文,从来不知道原文的存在,于是我理所当然地,把译文当成了原文。复盘到最深,我意识到这件事真正教给我的,是"事实"和"事实的表示"之间,那一道我一直视而不见的缝。一个时刻是事实,"10:00"是它在某个时区下的表示;一个数值是事实,"1,024"和"1.024k"和"0x400"是它不同的表示;一段文字是事实,它存成 UTF-8 还是 GBK 是不同的表示;一个颜色是事实,#FF0000 和 rgb(255,0,0) 是不同的表示。我们天天打交道的,几乎全是"表示",而不是"事实"本身——而表示,是【依赖某个上下文/某把尺子/某种编码】才成立的。一旦那个上下文换了,表示就变了,可事实纹丝未动。我那天的恐慌,本质是我把一个【依赖上下文的表示】,误认成了一个【脱离上下文的事实】。这次最大的收获,是我养成了一个新习惯:每当我面对一个"值"——一个时间、一个数字、一段编码、一个状态——我都会多问自己一句:我眼前这个,是那个【事实本身】,还是它在【某个上下文下被翻译出来的样子】?如果是后者,那个上下文是什么?它会不会变?这个习惯让我后来躲过了无数坑:跨时区传时间,我只传时间戳那个"原文",绝不传带歧义的"译文";系统之间交换数据,我会先问清楚双方的编码、单位、精度这些"翻译规则"对不对得上。UTC 那个朴素的时间戳教给我的,不是一个时间知识点,而是一个更普遍的清醒:你以为你抓住的是事实,你抓住的,很可能只是事实在某面镜子里的倒影。想真正抓牢一个东西,你得先看见那个【唯一不依赖任何镜子】的本体——然后才敢说,你知道它,是什么。
—— 别看了 · 2026