
做后端开发和数据库运维的朋友大概率都遇到过这种诡异的场景业务系统明明跑得好好的某天突然发现新插入的数据时间比真实时间慢或快了 8 个小时日志里的时间和数据库里的时间对不上排查了半天代码也没看出个所以然最后发现源头就在 MySQL 的time_zone参数上。这个参数看着不起眼很多人在安装配置 MySQL 时甚至懒得动它但它直接影响NOW()、CURRENT_TIMESTAMP这类时间函数返回什么值也决定了TIMESTAMP类型列在读写时怎么换算。换句话说凡是和“当前时间”有关的功能比如订单创建时间、用户最后登录时间、消息推送时间全都会被它牵着鼻子走。这篇文章就是要把time_zone彻底讲透它有哪些取值、全局级和会话级到底怎么区分、为什么有时候改了不生效、JDBC 连接串里为什么要配 serverTimezone、Docker 部署 MySQL 时最容易踩的时区坑在哪里。适合所有用过 MySQL 但没仔细研究过时区机制的同学尤其建议正在排查时间偏差问题的人直接对照着自查一遍。1. 初识 time_zone三个取值与两级作用域1.1 三类取值SYSTEM、偏移量和命名时区先看time_zone参数到底能填什么。最常用的取值其实就三类。第一类是SYSTEM这也是 MySQL 安装后的默认值意思是“跟随操作系统时区”。MySQL 启动时会读取系统时区设置之后所有时间函数都按这个时区来算。问题在于很多人并不知道自己的服务器系统时区默认是UTC还是CST更没想过云厂商给的机器可能默认配成UTC。一旦系统时区是 UTC而业务方在中国那所有时间函数天然就差 8 个小时这就是最常见的“莫名其妙少了 8 小时”的根源。第二类是偏移量写法形如08:00、-05:30。这种写法最直接不依赖操作系统也不依赖时区表MySQL 拿到偏移量就能计算。比如SET GLOBAL time_zone 08:00意思就是告诉 MySQL“我这个库要按东八区来处理时间”。第三类是命名时区比如Asia/Shanghai、Europe/London、UTC。这种写法比偏移量更标准而且能够处理夏令时等历史规则但前提是 MySQL 的时区表里已经导入了系统时区数据。很多新手直接SET time_zone Asia/Shanghai结果报错Unknown or incorrect time zone就是因为时区表还是空的。三类取值的适用场景其实很清晰追求省事就用SYSTEM但前提是你先确认服务器系统时区是对的想要稳、不依赖外部环境就用偏移量要走正规化管理、让不同环境保持一致就统一用命名时区Asia/Shanghai。我个人强烈建议用命名时区理由后面细说。1.2 全局与会话级排查时最容易忽略的生效范围time_zone不是那种“改一把就全库生效”的参数它分两个级别全局级global和会话级session。全局级的意思是“针对整个 MySQL 实例的新连接生效”。你执行SET GLOBAL time_zone 08:00之后已经存在的旧连接不会受影响只有之后新建的连接才会用新值。会话级的意思是“只对当前这个数据库连接生效”执行SET time_zone 08:00省略 SESSION 关键词时默认就是会话级只影响你当前正在用的这个连接。这个机制在实际排查中埋过不少坑。比如你通过命令行客户端敲了SET GLOBAL time_zone 08:00然后继续用同一个窗口查SELECT NOW()发现时间还是不对就以为设置没生效。其实是因为这个连接是在修改之前建立的会话级参数还停留在旧值。验证的方法很简单重新连接一次数据库或者先执行SET time_zone 08:00把当前会话也切过去再看时间就正常了。所以排查时一定要先分清楚你看到的“时间不对”到底是在哪个连接上发生的。如果是应用服务器连过来的连接那你需要关注的是全局参数有没有改对、应用有没有建立新连接如果是你自己敲命令的这个窗口那要先看会话级的值。1.3 理解差异的前提TIMESTAMP 与 DATETIME 的存储逻辑聊time_zone之前必须先搞定 MySQL 两种时间类型的底层逻辑否则很难理解为什么有人改了时区参数后历史数据的显示时间变了而另一部分数据完全没变。TIMESTAMP类型在存储时会把时间换算成 UTC 时间戳保存读取时再根据当前会话的time_zone换算成本地时间展示。这意味着同一个TIMESTAMP值在time_zone 00:00和time_zone 08:00两个会话里查出来显示结果会差 8 个小时。它的底层是一个自 1970 年开始的秒数范围限制在 1970 年到 2038 年。DATETIME类型则完全不搞换算你写入什么就存什么读取时也原样返回不关心会话时区。所以同一个DATETIME值不管会话时区怎么改查出来都是一样的。这两者的差异是我见过最多的“mysql面试题”之一但在实战中很多老手也会记混。记住一个通俗类比TIMESTAMP像是“全球统一时间的记录仪”进去时统一换算成标准时间取出来时再翻译成当地语言DATETIME则像“一张拍好了照片的纸条”写上去是什么就是什么不翻译。理解了这一层后面所有的时区配置和验证操作就都有了依据。2. 动手配置前先把环境检查做扎实2.1 系统时区、进程时区、MySQL 参数三者先对清楚配置time_zone之前我强烈建议你把下面三层时区状态一次性全部查清楚操作系统时区、MySQL 进程实际使用的系统时区、MySQL 参数里的时区。这三者经常不一致也是很多疑难杂症的源头。在 Linux 服务器上用timedatectl或查看/etc/timezone可以确认系统时区。比如执行timedatectl输出里带Time zone: Asia/Shanghai说明系统时区是上海如果显示UTC那就有问题了。MySQL 进程实际使用的系统时区可以通过 MySQL 自己的命令查。执行SELECT system_time_zone;这个变量是只读的它返回 MySQL 启动时从操作系统继承的时区。如果服务器是 UTC这里大概率就是 UTC。最后是SELECT global.time_zone;和SELECT session.time_zone;看 MySQL 参数层的全局值和当前会话值。我把这三层信息的查询命令整理成一个固定套路每次排查先跑一遍-- 查看 MySQL 继承自操作系统的时区 SELECT system_time_zone; -- 查看 MySQL 全局时区参数 SELECT global.time_zone; -- 查看当前会话时区参数 SELECT session.time_zone; -- 查看当前时间相关的函数返回值 SELECT NOW(), UTC_TIMESTAMP(), CURRENT_TIMESTAMP;如果系统时区是 UTC全局参数是 SYSTEM那么NOW()和UTC_TIMESTAMP()会几乎一样这立刻就能解释为什么业务时间差 8 小时。这个检查过程用不了 1 分钟但能帮你节省大量的排查时间。2.2 时区表初始化mysql_tzinfo_to_sql 的完整用法前面提到命名时区依赖 MySQL 内部的时区表具体来说就是mysql库下的time_zone、time_zone_name、time_zone_transition等一系列表。如果你直接查mysql.time_zone_name发现是空的那就没法使用Asia/Shanghai这种命名时区。正常情况下MySQL 安装后不会自动加载系统时区数据需要手动导入。导入工具是 MySQL 自带的mysql_tzinfo_to_sql命令如下# 将系统 /usr/share/zoneinfo 下的时区数据导入 mysql 库 mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql执行时需要输入 root 密码。导完后可以验证一下SELECT Name FROM mysql.time_zone_name WHERE Name Asia/Shanghai;如果能查到记录就说明命名时区可用了。需要注意的是这条命令把整个系统时区数据都导入进去了包括大量历史时区变更记录所以导入后mysql.time_zone_transition表会比较大这是正常现象。如果你的服务器上没有/usr/share/zoneinfo目录说明缺少 tzdata 这个基础包。在 CentOS 上可以用yum install -y tzdata在 Debian/Ubuntu 上用apt install -y tzdata安装然后再执行导入。2.3 修改时区的几条路径与持久化抉择查清楚现状、确认时区表没问题之后就该决定用哪种方式修改了。MySQL 里改时区有几条路径各自适合不同场景。动态修改的路径有两种。第一种是针对当前实例的全局修改执行SET GLOBAL time_zone Asia/Shanghai;这种方式即时生效对之后的新连接有效但不会写入配置文件MySQL 重启后会恢复原状。第二种是改当前会话执行SET time_zone Asia/Shanghai;适合临时验证或者只让某个连接用特定时区。持久化修改的路径也有两种。在 MySQL 8.0 里可以直接用SET PERSIST time_zone Asia/Shanghai;这个命令不仅会修改全局值还会把参数写入mysqld-auto.cnf文件重启后依然保留。而在 5.7 及更早版本SET PERSIST不可用只能手动修改配置文件my.cnf在[mysqld]段下面加一行default-time-zone Asia/Shanghai然后重启 MySQL。两条持久化路径各有优劣。SET PERSIST不用重启、方便快捷但有些运维规范不允许对线上实例执行这种带持久化性质的动态修改怕出问题不好回退。改my.cnf更传统、更可控但必须重启数据库对连续性要求高的业务不太友好。我的建议是测试环境随便用SET PERSIST练手生产环境走配置变更评审流程改my.cnf后放在低峰期重启稳妥优先。3. 实操配置与验证从命令行到连接串的完整闭环3.1 五分钟快速配置完整命令组合假设现在有一台刚装好的 MySQL系统时区是 UTC业务要求按照北京时间东八区处理所有时间逻辑。我习惯按下面的顺序操作。先确认当前状态SELECT system_time_zone, global.time_zone, session.time_zone;大概率结果会是UTC SYSTEM SYSTEM。这说明 MySQL 完全跟着操作系统 UTC 在走正是时间差 8 小时的典型状态。然后确认时区表里有数据SELECT COUNT(*) FROM mysql.time_zone_name;如果为 0先执行mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql导入。导入完成后执行全局修改和当前会话修改SET GLOBAL time_zone Asia/Shanghai; SET time_zone Asia/Shanghai;这里我特意写了两条第一条影响新连接第二条影响当前这个连接避免自己验证的时候被“改了没生效”的假象误导。最后验证SELECT NOW(), UTC_TIMESTAMP(), session.time_zone;如果NOW()比UTC_TIMESTAMP()快 8 个小时说明配置成功了。这套流程走下来5 分钟以内肯定能搞定。3.2 PERSIST 与 my.cnf 的取舍生产环境怎么选动态修改只能应付临时需求真正上线前一定要考虑重启后的表现。在 MySQL 8.0 环境里我推荐优先尝试SET PERSIST time_zone Asia/Shanghai;然后确认一下持久化文件是否生成cat /var/lib/mysql/mysqld-auto.cnf正常会看到里面对time_zone的赋值记录。而在 5.7 环境里只能在my.cnf的[mysqld]段写配置。这里有个细节需要提醒default-time-zone这个参数名不要记成time_zone。虽然SET GLOBAL命令用的是time_zone但配置文件里的键名是default-time-zone写错了 MySQL 启动时会直接报unknown variable。生产环境我强烈建议不要只依赖某一种方式而是“配置文件和动态修改”双管齐下先在my.cnf里写好default-time-zone Asia/Shanghai准备重启窗口如果短期不能重启就先在实例上执行SET GLOBAL time_zone Asia/Shanghai顶着至少能让新连接的时间正确然后安排维护窗口重启。这样既保证了当前业务不受影响又保证了重启后参数不丢。3.3 JDBC 连接串的 serverTimezone 参数才是应用侧关键很多人在 MySQL 服务端把时区改对了结果应用连上来时间还是不对问题往往出在 JDBC 连接串上。现在的 MySQL Connector/J 驱动在连接时会对 time_zone 做检测和转换如果连接串里没有明确指定serverTimezone驱动会尝试读取服务器的时区信息但不同版本的行为不一致经常会引发“驱动自动识别出来的时区和实际业务时区不一致”的混乱。最稳妥的写法是在 JDBC URL 里显式加上serverTimezoneAsia/Shanghaijdbc:mysql://127.0.0.1:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai如果你的驱动版本比较老对Asia/Shanghai这种命名时区的支持不太好也可以写成serverTimezoneGMT%2B8%2B 是加号的转义因为 URL 里加号会被解析成空格或者干脆用serverTimezoneCTT这是中国标准时区的别名。另外一个容易踩的坑是连接串还支持useTimezone和useLegacyDatetimeCode这两个老参数很多老教程会让你把useTimezonetrue打开我实测下来没必要反而容易造成二次转换导致时间偏移更混乱。现在的主流驱动只认serverTimezone其余旧参数能不加就不加。4. 容器环境下的时区处理Docker 部署 MySQL 的专属坑4.1 镜像默认时区与系统时区的关系Docker 部署 MySQL 已经成为常态但容器化带来的时区问题比裸机部署更隐蔽。官方 MySQL 镜像默认的基础系统是 Debian而 Debian 的默认时区是 UTC。也就是说哪怕你宿主机是北京时间容器里的系统时间默认也是 UTC。这就带来一个经典现象用docker exec -it mysql_container date看容器系统时间和宿主机差 8 小时进到 MySQL 里查SELECT system_time_zone看到的是 UTC而SELECT NOW()也自然比业务时间慢 8 小时。很多人在docker run的时候只映射了端口和挂载了数据目录没管时区跑完发现数据库时间不对就开始怀疑 MySQL 镜像有问题。其实镜像没问题只是容器内的“操作系统时区”和宿主机不一致而 MySQL 的time_zone如果取默认值SYSTEM就会跟着容器系统时区走。4.2 Docker 启动参数统一时间的最简方案解决容器内时区问题最直接的办法是在启动容器时通过环境变量TZ指定时区一条命令搞定docker run -d \ --name mysql8 \ -e TZAsia/Shanghai \ -e MYSQL_ROOT_PASSWORDyourpassword \ -p 3306:3306 \ mysql:8.0加上-e TZAsia/Shanghai之后容器内系统时区会变成东八区MySQL 的system_time_zone也会相应变成CST。如果此时 MySQL 参数time_zone还是默认的SYSTEM那NOW()就正常了。如果用的是 docker-compose写法也简单services: mysql: image: mysql:8.0 container_name: mysql8 environment: - TZAsia/Shanghai这里有个容易被忽略的点TZ环境变量只影响容器系统的时区标记但 MySQL 参数层的time_zone仍然可能是SYSTEM。虽然因为系统时区改对了SYSTEM就是东八区理论上没问题但从管理规范角度我建议在容器里也显式把 MySQL 参数改一遍让 MySQL 层不依赖容器系统设置docker exec -it mysql8 mysql -uroot -p进入 MySQL 后执行SET GLOBAL time_zone Asia/Shanghai; SET PERSIST time_zone Asia/Shanghai;这样即使以后容器系统时区被外部改动MySQL 自身也不会跟着变。尤其是维护多个 Docker 实例时显式配置 MySQL 参数能避免很多隐性不一致。5. 场景化验证同一时刻三种表现方式逐一对比5.1 会话时区切换后NOW()、CURRENT_TIMESTAMP 的表现理论说了那么多不如实际演一次。建一个测试库和测试表通过切换会话时区来观察各时间函数和字段类型的表现差异。先看时间函数。打开一个 MySQL 命令行窗口依次执行SET time_zone 00:00; SELECT NOW(), CURRENT_TIMESTAMP, UTC_TIMESTAMP;此时NOW()和UTC_TIMESTAMP()的值基本一致因为当前会话就在 UTC 时区。接着切换会话时区SET time_zone 08:00; SELECT NOW(), CURRENT_TIMESTAMP, UTC_TIMESTAMP;这时NOW()和CURRENT_TIMESTAMP都会比原来的值多 8 个小时而UTC_TIMESTAMP()保持不变。结论很清晰NOW()和CURRENT_TIMESTAMP都是“会话时区优先”的会话时区一变它们返回的本地时间就跟着变。5.2 TIMESTAMP 列读写链路的完整追踪再来看TIMESTAMP列。在会话时区为08:00时插入一条数据CREATE TABLE t_time_test ( ts TIMESTAMP NULL, dt DATETIME NULL ); SET time_zone 08:00; INSERT INTO t_time_test (ts, dt) VALUES (NOW(), NOW());然后切换会话时区到00:00再查SET time_zone 00:00; SELECT ts, dt FROM t_time_test;你会看到ts列显示的值比插入时少了 8 个小时因为 MySQL 内部把它换算成 UTC 时间戳存储了现在读取时按 UTC 会话显示自然就回退 8 小时。而dt列完全不变还是插入时的那个字面值。这就是TIMESTAMP列的完整读写链路写入时从会话时区换算到 UTC读取时再从 UTC 换算回会话时区。链路里任何一个环节的时区不一致都会导致最终显示值出问题。5.3 DATETIME 列的行为边界与业务选型建议DATETIME在刚才的测试里表现得像个“老实人”写入什么就显示什么不随会话时区变化。这个特性在某些场景非常有用但也暗藏风险。如果你的业务希望在展示时能跟随查看者的本地时区变化那TIMESTAMP更合适因为它天然带着“UTC 存储 本地展示”的语义。如果你的业务希望所有人都看到同一个明确的时间值比如订单的开售时间、活动开始时间那DATETIME更稳妥因为它不依赖任何会话时区写进数据库的是什么就是什么。还有一个现实约束TIMESTAMP的范围只能到 2038 年如果业务要存 2040 年甚至更远的日期比如长期有效的会员到期时间只能选DATETIME。选型时不能只看时区换算的便利性还要考虑数据范围限制。6. 常见问题与排查技巧实录6.1 时区类问题定位速查表我把自己遇到过的和身边同事踩过的典型时区问题整理成了一张速查表建议收藏备用。现象可能原因排查方向业务系统时间比真实时间慢 8 小时服务器系统时区为 UTCMySQL 参数为默认 SYSTEM检查系统时区使用08:00或Asia/Shanghai修改 MySQL 参数修改全局参数后当前连接时间不变当前会话是修改前建立的旧连接会话级参数仍是旧值重新连接数据库或先执行SET time_zone Asia/Shanghai设置命名时区报Unknown or incorrect time zoneMySQL 时区表未导入数据执行mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql数据库重启后时区又变回 UTC只执行了动态修改没有持久化用SET PERSIST或修改my.cnf的default-time-zone应用连上后时间依然差 8 小时JDBC 连接串缺少serverTimezone参数连接串显式加serverTimezoneAsia/Shanghai容器部署后 MySQL 时间差 8 小时容器系统时区默认 UTC启动时加-e TZAsia/ShanghaiMySQL 层显式设time_zone错误日志时间与业务时间差 8 小时log_timestamps参数默认 UTC动态设置SET GLOBAL log_timestamps SYSTEM或改配置持久化慢查询日志时间比实际慢 8 小时还是log_timestamps参数问题不是time_zone同上修改log_timestamps历史TIMESTAMP数据查询结果整体偏移会话时区或全局时区被改动存储时与读取时换算基准不同确认两个时间点的时区设置统一所有环节的时区这里特别提醒不要混淆log_timestamps和time_zone。MySQL 错误日志和慢查询日志的时间戳由log_timestamps控制默认值是 UTC和time_zone不是一回事。很多人把time_zone改对了看日志还是差 8 小时就开始怀疑人生其实是另一个参数的问题。6.2 排查方法论从外部到内部从底层到上层时区问题排查最忌讳东一榔头西一棒子。我总结了一套固定方法叫“从外到内四步走”。第一步查系统环境看操作系统时区、容器环境变量、服务器所在地域确认“墙体”有没有歪。第二步查 MySQL 实例本身执行SELECT system_time_zone, global.time_zone, session.time_zone;确认“地基”平不平。第三步查客户端和驱动重点看 JDBC 连接串、客户端工具时区设置、应用服务器系统时区确认“楼上”有没有对齐。第四步再回来看业务代码确认写入和读取时有没有手动加过偏移量比如代码里写new Date()然后又手动8h之类的骚操作。按照这个顺序排查九成以上时区问题能在前两步暴露出来。我见过很多同事一上来就翻业务代码翻半天找不到问题其实就是服务器系统时区是 UTC白折腾。6.3 规范化管理的三个习惯最后分享三个我长期坚持的时区管理习惯如果你能照做能省掉大量未来排障的时间。第一个习惯是所有环境的时区标准统一服务器系统时区、MySQL 参数、JDBC 连接串、应用日志时区全部对齐到Asia/Shanghai。不要一会儿用SYSTEM、一会儿用08:00看着都是东八区但排查时容易制造混乱。第二个习惯是在 MySQL 参数层尽量不用SYSTEM而是显式写死Asia/Shanghai。这样即使服务器系统时区被误改数据库自身的时间逻辑也不会崩。尤其容器化环境显式设置参数是成本最低的保险。第三个习惯是定期巡检时把时间验证写进自动化脚本比如通过 SQL 查询对比NOW()和UTC_TIMESTAMP()的差值如果差值不是预期的 8 小时就告警。这个检查成本极低却能在问题影响业务之前就发现苗头。我个人在实际操作中最深刻的体会是时区问题看起来只是一个小参数但牵一发动全身从操作系统到数据库再到应用连接串任何一个环节掉链子都会让最终用户看到错误的时间。改参数永远只是表层把整套环境的时区逻辑理顺才是根治之道。如果你现在正好被时间差问题困扰建议先把SELECT global.time_zone, session.time_zone, NOW(), UTC_TIMESTAMP();这行命令跑一遍答案基本就藏在这里面了。