接手过数据库的人应该都体会过“时间差8小时”的焦虑。某次凌晨线上告警业务方截图发过来对账报表里所有订单时间都比实际晚了8个小时排查了一圈最后定位到 MySQL 的时区设置上。这种事几乎每个用 MySQL 做业务的团队都会碰到而 mysql时间时区修改这个话题说简单也简单无非是set global time_zone、配置文件里写default-time-zone但真正折腾人的是为什么改完了不生效、为什么一部分时间对了另一部分还错、为什么重启后又变回去了。这篇文章我把自己的实操经验完整写出来包括命令、配置、验证方式以及几个特别容易踩的衍生坑。适合正在被时区问题折磨的 DBA、后端开发也适合刚接手 MySQL 维护没多久、对 time_zone 和 system_time_zone 还傻傻分不清的人。1. 先弄明白 MySQL 的时区是怎么“分层”的1.1 两个容易搞混的系统变量system_time_zone 与 time_zone很多人的第一反应是直接执行set global time_zone 08:00结果发现当前查询还是不对于是开始怀疑命令没生效。问题往往出在没搞清楚 MySQL 里其实有两个层面的时区变量。登录 MySQL 后执行SHOW VARIABLES LIKE %time_zone%;输出一般会是这样-------------------------- | Variable_name | Value | -------------------------- | system_time_zone | UTC | | time_zone | SYSTEM | --------------------------这里system_time_zone是 MySQL 启动时从操作系统读取的时区它是个只读变量启动后就不变了。time_zone才是 MySQL 内部真正用于时间计算的变量它又分为global和session两层。time_zone SYSTEM的意思就是“跟随操作系统时区”也就是跟随system_time_zone的值。用命令可以分别查询SELECT global.time_zone; SELECT session.time_zone;session.time_zone在建立连接时继承global.time_zone。也就是说你在某个会话里改了session.time_zone影响的只是当前这个连接你在全局层改了global.time_zone影响的是“之后新建的连接”已经存在的连接不会跟着变。这就是很多线上故障的根源DBA 执行了全局修改但应用连接池里的老连接根本没刷新业务看到的还是旧时区。1.2 timestamp 和 datetime 在时区面前的行为完全不同为什么时区问题会给人“改了也没用”或者“数据错乱”的错觉因为 MySQL 的两种时间类型对时区的敏感度完全不一样。TIMESTAMP内部按 UTC 存储查询显示时根据当前会话的time_zone转成本地时间。时区变了显示值就会跟着变。DATETIME存什么就显示什么是一个字面值不随会话时区变化MySQL 把它当成一个字符串一样的内容来存。可以用一个例子感受一下。假设数据库当前time_zone 00:00执行CREATE TABLE time_test ( ts TIMESTAMP NULL, dt DATETIME NULL ); INSERT INTO time_test VALUES (NOW(), NOW());然后把会话时区改成08:00再查SET time_zone 08:00; SELECT ts, dt FROM time_test;结果大概率是ts比原来的时间多了8小时而dt一动不动。这不是数据损坏而是TIMESTAMP的显示逻辑变了。如果表里两种类型混着用业务方看到一部分时间对、一部分时间错就会非常困惑。提示如果你只想让某个连接临时按某个时区显示用SET time_zone就够了但如果想让整个服务器统一就必须考虑全局设置和持久化配置。2. 临时救急用 set global但别指望它持久2.1 命令怎么敲改的到底是什么先看一套最常用的临时修改序列-- 当前会话临时改 SET time_zone 08:00; -- 全局默认时区改为东八区新连接生效 SET GLOBAL time_zone 08:00; -- 验证 SELECT global.time_zone; SELECT session.time_zone;在 MySQL 8.0 中执行SET GLOBAL time_zone需要有SET GLOBAL权限或SET_SYSTEM_VARIABLE权限否则会报权限不足。另外时区参数除了08:00这种偏移量写法也支持命名时区比如Asia/Shanghai但前提是 MySQL 已经加载了系统时区表否则会报错ERROR 1298 (HY000): Unknown or incorrect time zone: Asia/Shanghai命名时区表的加载命令一般是这样mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql执行完再刷新权限表的缓存并不需要关键是加载后命名时区才可以使用。不过日常绝大多数场景用偏移量写法就够了简单、不依赖时区表、不用考虑夏令时。2.2 set global 的生命周期重启即失效SET GLOBAL修改的是运行时全局变量这个值存在内存里。MySQL 服务一重启它就会恢复到启动配置里的值通常是SYSTEM。也就是说如果只执行了SET GLOBAL time_zone 08:00而没有写配置文件重启后一切恢复原样。另外还有一个很容易忽略的点SET GLOBAL只影响新连接。对已经存在的应用连接池连接会话时区在连接创建时就已经确定了不会跟随全局值变化。所以“临时救急”的正确操作顺序是执行SET GLOBAL time_zone 08:00。让应用重新建立连接或者重启应用服务。如果应用不方便重启也有一个折中办法在连接池初始化时执行SET time_zone。很多连接池支持配置连接初始化 SQL比如 HikariCP 的connectionInitSqlDruid 的connectionInitSqls。这样每次新建连接都会主动执行一次时区调整HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/demo); config.setUsername(root); config.setPassword(password); config.setConnectionInitSql(SET time_zone 08:00);但注意这个办法只能解决新连接的时区问题已经存在的连接依然不会被强制改变只能等连接池回收或重启应用。3. 配置文件 default-time-zone 才是持久化正路3.1 找到真正被 MySQL 读取的配置文件要让时区修改在重启后依然生效正确的做法是修改 MySQL 配置文件。Linux 上常见路径是/etc/my.cnf、/etc/mysql/my.cnf不同发行版和安装方式路径不一样最稳妥的办法是查看 MySQL 读取配置的路径mysql --help | grep -A 1 Default options或者直接登录 MySQL 查询SHOW VARIABLES LIKE basedir; SHOW VARIABLES LIKE datadir;在基于 systemd 的发行版上也可能分布在/etc/my.cnf.d/或/etc/mysql/conf.d/这类目录里。要小心的是配置文件可能存在多个MySQL 会按顺序读取后面的值覆盖前面的值。所以如果你改了/etc/my.cnf没生效先检查是不是还有别的配置文件把参数又覆盖了一遍。3.2 配置写法与验证步骤在配置文件的[mysqld]段下加一行[mysqld] default-time-zone 08:00注意是写在[mysqld]下面不是[client]也不是[mysql]。如果你使用的是云厂商的数据库服务一般没有直接改文件的权利需要到控制台的“参数组”里找default_time_zone或default-time-zone这个参数修改然后提交变更通常会自动重启实例。改完配置文件后重启 MySQLsystemctl restart mysqld或者service mysql restart重启完再验证SHOW VARIABLES LIKE %time_zone%; SELECT global.time_zone;输出里time_zone应该已经变成08:00而不是SYSTEM。这才能确认持久化生效。3.3 MySQL 8.0 的 SET PERSIST不碰配置文件也能持久化如果你是 MySQL 8.0 及以上版本除了改配置文件还有一条路SET PERSIST。这类命令会把变量值写到数据目录下的mysqld-auto.cnf文件里重启后自动应用效果类似“持久化不落 my.cnf”。SET PERSIST time_zone 08:00;执行后立即对全局生效同时写入持久化文件。如果你希望只写入文件、不影响当前运行值可以用SET PERSIST_ONLY time_zone 08:00;这个机制在运维上挺方便不需要重启就能完成配置修改而且下一次重启依然保留。不过要注意mysqld-auto.cnf的优先级高于普通配置文件如果两边配置冲突可能带来意想不到的结果。排查时看到这个文件存在先确认里面的值是不是你想要的状态避免“改了个寂寞”。4. 实战排查改了 set global 后时间还是差 8 小时4.1 现象全局已经改了业务依然报时间不对这是我实际遇到的一个案例某项目上线后凌晨的定时任务生成的所有时间戳都比北京时间晚了8小时。DBA 在数据库里执行了SET GLOBAL time_zone 08:00;确认返回成功后业务方再查数据发现还是差8小时。当时第一反应是“命令没生效”其实并不是。4.2 排查链路session 与 global 变量分别确认正确的排查顺序如下。第一步确认全局变量确实改了SELECT global.time_zone;输出是08:00说明全局确实改了。第二步确认当前会话变量SELECT session.time_zone;输出却是SYSTEM。这里就说明问题了当前连接是修改全局之前建立的会话变量在连接建立时已经复制了旧值哪怕现在全局变了它依然保持旧配置。第三步看一眼系统时区SELECT system_time_zone;如果输出UTC就进一步解释了为什么业务端差8小时应用连接的 session 时区跟随 SYSTEM而系统的时区是 UTC所以 MySQL 返回的时间全部是 UTC 时间。第四步断开重连再查SELECT session.time_zone;重连后输出变成08:00。这时候再执行SELECT NOW()时间就正常了。关键点在于SHOW VARIABLES LIKE %time_zone%和SELECT session.time_zone查到的都是当前会话的值不看这个很难定位到“全局已改、连接未刷新”的中间状态。4.3 连接池里的存量连接才是罪魁祸首业务系统绝大多数连接都来自连接池。连接池会在应用启动时建立一批物理连接这些连接在创建那一刻就把session.time_zone继承了过去。数据库全局变量改了这些老连接不会重新执行一次“继承”逻辑除非连接被关闭重建。具体到常见的连接池HikariCP默认maxLifetime是 30 分钟也就是说最多30分钟会有一次物理连接重建。在这之前老连接仍使用旧时区。Druid默认连接不会主动销毁除非配置了timeBetweenEvictionRunsMillis和minEvictableIdleTimeMillis否则可能一直不重建。最快的解决办法是重启应用服务所有连接以新的全局时区建立。如果不能立即重启也可以等连接池自然淘汰或者手动调低maxLifetime让它尽快刷新。但要注意这只是应急手段数据库侧和连接池侧最终还是要统一配置。注意修改时区之前先把SELECT global.time_zone; SELECT session.time_zone; SELECT system_time_zone;三个值都查出来截图避免反复修改后说不清到底哪一层还在生效。5. 时区这事的衍生坑比改命令更常见5.1 JDBC 连接串的 serverTimezone 与 MySQL 时区强相关Java 应用接入 MySQL 时连接串里经常会看到这样的参数jdbc:mysql://localhost:3306/demo?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8serverTimezone的作用是告诉驱动“数据库服务器在哪个时区”它影响的是驱动侧对Timestamp的本地化处理。常见的问题是数据库服务器实际时区变了但连接串里的serverTimezone还写的是旧时区或者反过来数据库时区没变、连接串写错了。当你在连接串里显式指定serverTimezoneMySQL Connector/J 会按这个值做解释和转换。如果数据库时区和连接串不一致应用层拿到的Timestamp在转换时可能再次产生偏移。比如数据库时区是东八区连接串却写UTC应用从结果集里读到的Timestamp就可能被驱动“纠正”成 UTC 再换算成 JVM 默认时区导致最终显示又差8小时。所以排查时区问题不要只盯着数据库命令还要把应用侧的 JVM 时区、连接串参数、操作系统时区一起拉出来看。三处必须最终指向同一个标准否则任何一个地方不一致都会让问题以不同形式冒出来。5.2 老数据的“变化”timestamp 显示变了不代表数据坏了前面讲过TIMESTAMP内部按 UTC 存储展示时按会话时区换算。数据库从 UTC 改成东八区后表里已有的TIMESTAMP字段查询结果会整体“多8小时”这是正常的显示逻辑变化并不是数据损坏。但DATETIME字段不会跟着变因为它存的就是字面值。如果一个表里既有TIMESTAMP又有DATETIME改完时区后这两类字段会出现8小时的差值。这不是 bug是两种类型设计上的差异。业务方如果坚持认为所有时间字段都该一起变就需要解释清楚必要时把DATETIME也换成TIMESTAMP或者统一业务层的时间转换规则。这里有一个务实的建议新表核心业务时间字段尽量用TIMESTAMP让数据库层面的时间展示能跟随时区统一调整DATETIME适合存“业务事实时间”比如生日、活动开始时间这类与用户时区无关的值不要混用。5.3 Docker 容器里的 MySQL时区比想象中更“顽固”容器化部署现在很普遍但容器的时区问题往往被忽略。很多官方镜像默认时区是 UTC即使宿主机是东八区容器里的/etc/localtime依然是 UTC。MySQL 启动时读取system_time_zone就来自容器环境所以即使你在配置里写了default-time-zone 08:00MySQL 的默认时间没问题了但容器内一些依赖系统时间的操作比如日志时间戳、内部脚本执行时间依然可能是 UTC。所以容器环境里建议两条腿走路部署时给容器设置时区环境变量docker run -e TZAsia/Shanghai -v /etc/localtime:/etc/localtime:ro \ -v /etc/timezone:/etc/timezone:ro \ mysql:8.0MySQL 配置中依然写上default-time-zone避免只依赖系统时区。这两个操作一个是改“操作系统层”一个是改“MySQL 层”缺一个都可能出问题。5.4 日志、binlog 和主从复制时区影响会往下游延伸修改time_zone不只是影响业务查询。MySQL 的错误日志、慢查询日志输出时间也受时区相关变量影响。MySQL 5.7.2 之后有一个log_timestamps变量专门控制日志里记录时间的时区默认值是UTC所以很多人会发现数据库日志里的时间总比本地时间早8小时这其实和业务数据无关但排查问题时会增加障碍。可以改成SYSTEM让日志时间跟随系统时区SET GLOBAL log_timestamps SYSTEM;同样需要确认这个变量的持久化方式。主从复制里TIMESTAMP在 binlog 中的记录方式也要注意。MySQL 在 binlog 里对TIMESTAMP使用的是 UTC 时间从库重放时再换算成自身的时区。如果主库和从库的时区设置不一致从库上查出来的数据就可能和主库不一样造成数据“看起来不一致”的假象。高可用集群的时区必须统一这不是一句空话是会实实在在影响数据一致性的。5.5 我最常用的一套无损验证法最后分享一个排查时区问题很有效的小技巧。改完任何时区配置之后不要急着刷新业务页面先在数据库里跑一遍SELECT NOW(), UTC_TIMESTAMP(), CURRENT_TIMESTAMP;如果NOW()和UTC_TIMESTAMP()相差8小时而你的业务时区恰好是东八区说明配置是正常的如果两者相同说明当前会话还在 UTC 状态。这条语句能快速暴露 session 层的真实时区比反复看变量参数要直观得多。我自己现在处理时区问题的固定套路是先查global.time_zone和session.time_zone确认分层状态再查system_time_zone确认系统层然后想清楚要“临时改”还是“永久改”临时改直接SET GLOBAL并重启应用连接永久改就走配置文件或SET PERSIST。最后一定会用SELECT NOW(), UTC_TIMESTAMP()做一次全局验证确认业务看到的最终结果。这套流程走下来至少在 MySQL 时区这件事上能少熬很多个“差8小时”的凌晨。