MySQL 字符集与时区踩坑:乱码与时间差 8 小时

MySQL 字符集与时区,是生产库里最典型的两类「隐性坑」:前者让带 emoji 的昵称直接写入失败、或者写进去读出来是问号;后者让报表时间整体偏移 8 小时。它们不会让服务立刻崩溃,却会持续污染数据,等业务发现时往往已经写坏了几十万行。本文复盘一次真实排查,按「现象 → 分层定位 → 修复 → 上线 Checklist」给出可直接执行的 SQL 与配置。

故障现场:两个看似无关的报障

同一个下午收到两条工单。一条来自客户端:用户改昵称时带了一个 emoji,接口返回 500,服务端日志是 java.sql.SQLException: Incorrect string value: '\xF0\x9F\x98\x80'。另一条来自运营:昨天导出的订单报表里,凌晨 2 点的单子全变成了前一天上午 10 点,整整差 8 小时。

两条工单指向同一个根因家族——字符集和时区都不是「一个配置项」,而是一条从操作系统到 JDBC 连接的多层继承链。任何一层没对齐,都会在最末端表现为乱码或时间偏移。排查的第一步不是改配置,而是把每一层的实际值都打出来。

字符集的四层继承:server / database / table / column

MySQL 的字符集是逐层继承、就近覆盖的:建库不指定就继承 server,建表不指定就继承库,建列不指定就继承表。所以「my.cnf 里已经写了 utf8mb4」完全不代表老表就是 utf8mb4。定位必须查到列这一级。

-- 1. 服务端全局与连接层实际生效值(重点看 character_set_client/connection/results)
SHOW VARIABLES LIKE 'character\_set\_%';
SHOW VARIABLES LIKE 'collation\_%';

-- 2. 库级别
SELECT SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME
FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = 'order_db';

-- 3. 表级别:一次列出所有非 utf8mb4 的表
SELECT TABLE_NAME, TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'order_db' AND TABLE_COLLATION NOT LIKE 'utf8mb4%';

-- 4. 列级别:真正存数据的那一层
SELECT TABLE_NAME, COLUMN_NAME, CHARACTER_SET_NAME, COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'order_db' AND CHARACTER_SET_NAME IS NOT NULL
  AND CHARACTER_SET_NAME <> 'utf8mb4';

本次排查的结果是:server 和新建的库都是 utf8mb4,但 user_profile 是 2021 年建的老表,表和 nickname 列都还是 utf8(也就是 utf8mb3)。

坑一:MySQL 的 utf8 是「假 utf8」

MySQL 历史上的 utf8 实际是 utf8mb3,每个字符最多 3 字节,装不下需要 4 字节的 emoji、部分生僻汉字和一些少数民族文字。写入时 MySQL 不会静默截断,而是直接抛 Incorrect string value——这就是昵称接口 500 的原因。

对比项utf8 (utf8mb3)utf8mb4
单字符最大字节34
emoji / 4 字节汉字写入报错正常
默认排序规则utf8_general_ciMySQL 8 起 utf8mb4_0900_ai_ci
VARCHAR 索引最大字符数
(单列索引 3072 字节)
1024768
建议不要再用于新表新库唯一选择

改造:CONVERT TO 而不是只改默认字符集

# 只改表默认字符集:新增列生效,已有列不动 —— 这是最常见的误操作
ALTER TABLE user_profile DEFAULT CHARACTER SET utf8mb4;

# 正确做法:连已有列一起转换
ALTER TABLE user_profile
  CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

# 转换前先估算索引长度风险:utf8mb4 下 varchar(1000) 会超 3072 字节
SELECT TABLE_NAME, INDEX_NAME, SUM(SUB_PART_OR_LEN) AS bytes_est
FROM (
  SELECT s.TABLE_NAME, s.INDEX_NAME,
         COALESCE(s.SUB_PART, c.CHARACTER_MAXIMUM_LENGTH) * 4 AS SUB_PART_OR_LEN
  FROM information_schema.STATISTICS s
  JOIN information_schema.COLUMNS c
    ON c.TABLE_SCHEMA = s.TABLE_SCHEMA AND c.TABLE_NAME = s.TABLE_NAME
   AND c.COLUMN_NAME = s.COLUMN_NAME
  WHERE s.TABLE_SCHEMA = 'order_db' AND c.CHARACTER_SET_NAME IS NOT NULL
) t
GROUP BY TABLE_NAME, INDEX_NAME HAVING bytes_est > 3072;

CONVERT TO 会重建整张表,千万级大表在业务高峰执行等于自杀。生产上一律走在线 DDL 工具,参考在线改大表实战:pt-osc 与 gh-ost 零停机;改造语句本身要进版本化迁移,别在客户端手敲,做法见数据库迁移实战:Flyway 与 Liquibase 版本化管理

坑二:连接层不一致,写进去是好的、读出来是乱的

如果表已经是 utf8mb4,数据却还是乱码,问题几乎一定在连接层。MySQL 在客户端与服务端之间会按 character_set_clientcharacter_set_connection → 列字符集 做两次转码,读取时再按 character_set_results 转回去。中间任何一环被设成 latin1,都会造成不可逆的字节损坏。

# 服务端:my.cnf 一次性钉死,避免依赖客户端自觉
[mysqld]
character_set_server = utf8mb4
collation_server     = utf8mb4_0900_ai_ci
skip_character_set_client_handshake = ON   # 忽略客户端声明,强制用服务端字符集

[client]
default-character-set = utf8mb4

# 会话内临时确认(三个变量应同时为 utf8mb4)
SET NAMES utf8mb4;
SHOW VARIABLES LIKE 'character_set_client';

# JDBC:MySQL Connector/J 8.x 默认已是 utf8mb4,但被显式写坏的情况很多,检查这一行
jdbc:mysql://db:3306/order_db?characterEncoding=utf8&useUnicode=true&connectionCollation=utf8mb4_0900_ai_ci

判断「数据坏没坏」有一个可靠办法:用 SELECT HEX(nickname) 看原始字节。如果字节本身是合法 UTF-8 序列,只是显示乱,那属于读取端转码问题,改连接即可;如果字节已经是二次编码的产物,就只能靠 binlog 或备份回滚,恢复手法见MySQL 备份恢复实战:全量备份与 binlog 恢复

坑三:排序规则混用,JOIN 直接不走索引

字符集对齐了,排序规则(collation)也可能不齐——尤其是一半表从 MySQL 5.7 迁上来带着 utf8mb4_general_ci,新表是 utf8mb4_0900_ai_ci。两者 JOIN 时会报 Illegal mix of collations,或者更阴险:MySQL 隐式转换后放弃索引,全表扫描。

-- 症状:type=ALL、key=NULL,明明两边都有索引
EXPLAIN SELECT o.id FROM orders o
JOIN user_profile u ON o.user_no = u.user_no
WHERE u.status = 1;

-- 临时兜底(能跑通,但仍然不走索引,只当救火)
... ON o.user_no = u.user_no COLLATE utf8mb4_0900_ai_ci

-- 根治:把落后的一方统一过来
ALTER TABLE user_profile
  CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

这类「索引突然失效」的排查思路,和慢 SQL 打满 CPU 是一套方法论,可对照慢 SQL 拖垮生产库:一次 CPU 100% 排查实录MySQL 死锁排查实录:从日志定位到事务优化一起看。

时区的三层:OS → MySQL → 会话 → 驱动

时间差 8 小时的排查同理,先把每一层的实际值打出来,不要凭印象。注意 SYSTEM 这个值本身不说明任何东西,它只是「跟随操作系统」。

-- MySQL 侧:global 与 session 时区可能不同
SELECT @@global.time_zone, @@session.time_zone,
       NOW() AS now_session, UTC_TIMESTAMP() AS now_utc,
       TIMEDIFF(NOW(), UTC_TIMESTAMP()) AS offset_check;

-- 期望:offset_check = 08:00:00(East 8);若为 00:00:00 则库在跑 UTC

-- 操作系统侧
$ date +"%Z %z"           # 期望 CST +0800
$ timedatectl | head -3   # 容器里常见 UTC

-- 应用侧(Java):JVM 默认时区也要对齐,否则同一时刻两边算出不同字符串
System.out.println(java.time.ZoneId.systemDefault());

本次的实际情况是:ECS 主机是 CST,MySQL 容器镜像默认 UTC,而应用容器加了 TZ=Asia/Shanghai。应用把「2026-09-03 02:00」按东八区理解写入 DATETIME 列,读取时又用 UTC 解释,一来一回就成了差 8 小时。

DATETIME 与 TIMESTAMP:行为完全不同

维度DATETIMETIMESTAMP
存储本质字面量,不带时区UTC 秒数
读写是否按时区换算不换算,存啥读啥按会话时区自动换算
改时区后旧数据表现不变(但含义变了)显示值随之平移
范围1000–9999 年1970–2038 年
多时区业务建议存 UTC,展示层换算可用,但注意 2038

坑四:差 8 小时的四种成因与定位顺序

把上面的输出对照下表,基本一查就能定位到具体哪一层。定位顺序建议自下而上:先 OS,再 MySQL global/session,最后驱动与 JVM。

现象判定依据处置
库整体慢 8 小时TIMEDIFF(NOW(),UTC_TIMESTAMP()) 为 00:00:00my.cnf 设 default-time-zone='+08:00'
只有某个应用偏移该应用连接的 @@session.time_zone 异常检查驱动 URL 与连接池初始化 SQL
写入正确、读出偏移列类型是 TIMESTAMP统一读写两侧会话时区
容器重建后突然出现镜像内 date 为 UTC镜像里装 tzdata 并设 TZ

坑五:容器里没有时区数据库

time_zone 设成 Asia/Shanghai 而不是 +08:00 时,MySQL 需要查 mysql.time_zone_name 表。官方精简镜像默认没导入这些数据,设置会直接报 Unknown or incorrect time zone。两条路:要么用固定偏移,要么把系统时区库导进去。

# 方案 A:固定偏移,最省事,不受 tzdata 影响(推荐用于单一时区业务)
[mysqld]
default-time-zone = '+08:00'

# 方案 B:导入命名时区(需要夏令时/多时区换算时才必要)
$ mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -uroot -p mysql
$ mysql -uroot -p -e "SET GLOBAL time_zone='Asia/Shanghai';"

# 容器镜像:时区数据 + TZ 环境变量一起给,缺一不可
FROM mysql:8.4
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

顺带提醒:连接池的 connectionInitSql 也是常被忽略的一层,Spring Boot 升级过程中这类配置迁移遗漏很常见,可参考Spring Boot 3 升级踩坑实录:Jakarta、Security 6 与 7 大雷区

新库标准配置模板

[mysqld]
character_set_server = utf8mb4
collation_server     = utf8mb4_0900_ai_ci
skip_character_set_client_handshake = ON
default-time-zone    = '+08:00'
explicit_defaults_for_timestamp = ON

[client]
default-character-set = utf8mb4

-- 建库建表一律显式声明,别赖继承
CREATE DATABASE order_db
  DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

CREATE TABLE user_profile (
  id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  user_no VARCHAR(32) NOT NULL,
  nickname VARCHAR(64) NOT NULL,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  UNIQUE KEY uk_user_no (user_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

跨数据库迁移时这两类问题会成倍放大,异构库的语义差异可对照PostgreSQL vs SQL Server:语法差异与迁移避坑指南;PostgreSQL 侧的 timestamptz 与执行计划细节见PostgreSQL 慢查询优化:执行计划解读与索引调优实战

上线前 8 条 Checklist

  1. 用 information_schema 扫一遍,确认没有列级别残留 utf8mb3。
  2. 全库 collation 统一,不允许 general_ci 与 0900_ai_ci 混用。
  3. my.cnf 开启 skip_character_set_client_handshake,不信任客户端声明。
  4. 连接串、连接池初始化 SQL、ORM 配置三处的字符集口径一致。
  5. TIMEDIFF(NOW(), UTC_TIMESTAMP()) 作为巡检项,纳入监控告警。
  6. 应用容器与数据库容器都显式设置 TZ,禁止依赖镜像默认。
  7. DATETIME 与 TIMESTAMP 在一个项目里只选一种口径并写进规范。
  8. 大表 CONVERT 一律走在线 DDL,且先在预发全量演练一遍。

小结

字符集和时区的坑,本质都不是「配错了一个值」,而是「多层配置各自为政,没有人做过一致性校验」。排查的核心动作只有一个:把 OS、服务端、库、表、列、会话、驱动这几层的实际值全部打出来对齐,而不是相信文档或印象。修完之后,把 TIMEDIFF 巡检和字符集扫描做成定期检查,才能保证下一次建表、下一次换镜像时不会重新掉进同一个坑。

上一篇 AI 智能体记忆系统实战:从上下文到长期记忆
下一篇 2026 世界模型 World Model:具身智能引擎