存进去是中文,取出来是一串问号:数据库字符集不一致怎么排
数据截至 2026-07,各数据库产品的默认字符集、严格模式行为与报错口径以官方最新说明为准。
这类问题十次里有七八次坏在连接层,不在数据库本身。 你把库和表都改成了 utf8mb4,重启、重连、重试,写进去的中文照样是一串问号——因为字符集这件事在链路上有四个各自独立的开关:应用内存里的字符串、驱动建立连接时声明的编码、服务端会话级的编码变量、以及列真正的存储字符集。任何两个开关对不齐,都会在中间某一环发生一次「转换」,而转换是有损的。先定位是哪一段转的,比一股脑把所有地方都改成 utf8mb4 要快得多,也安全得多。
先说清这篇和站内另外两篇的分工:终端里中文输出乱码怎么排 讲的是显示层,数据本身没坏,是终端、字体、管道编码把它渲染歪了;文件编码与 BOM 引发的问题 讲的是文件读写这条路径。本篇只管一件事:数据从应用写进数据库、再读出来的这段往返里,字节被谁改了。判断方法也不一样——显示层问题重启终端就好了,落库问题重启没用,因为磁盘上的字节已经变了。
一、先把「乱」分清形态,混着治必然绕远路
肉眼能看见的现象有三种,成因和处置动作完全不同;还有一种看不见的,得查字节才认得出来,放在本节末尾说。
第一类:变成半角问号。 中文全部塌成 ?,长度还对得上(三个汉字变三个问号)。这是典型的「目标字符集装不下源字符」——转换函数遇到无法映射的码点,用替代字符顶了上去。关键在于:问号是替代产物,原始信息已经不在了,从数据库这一侧永远救不回来。
顺带解释一个常见困惑:同样是往装不下中文的列里写,有的环境静默变问号,有的环境直接甩一个「Incorrect string value」把写入拒了。差别在于服务端的 SQL 模式是否开了严格模式——严格模式下这类转换失败会升级成错误并回滚该语句,非严格模式下则降级成警告、用替代字符写进去。所以「没报错」不代表写对了,值得顺手确认一下当前会话的 SQL 模式,具体开关名与默认值以官方文档为准。
第二类:变成一串怪符号。 比如「你好」变成六个拉丁重音字母那样的东西。这是双重编码或解码错位:UTF-8 的字节被当成单字节字符集读了一遍,又按 UTF-8 重新写了一遍。这一类是可逆的,因为字节没丢,只是被多包了一层。
第三类:变成空心方块或豆腐块。 这多半根本不是编码问题,是渲染字体里没有这个字形。换个客户端看同一行数据,方块就没了。碰到这类先别动数据库。
分不清就查一次原始字节。以 MySQL 为例:
SELECT id, col, HEX(col), LENGTH(col), CHAR_LENGTH(col) FROM your_table WHERE id = 1;
对照本地算出来的正确值:
python -c "print('你好'.encode('utf-8').hex())"
# e4bda0e5a5bd
HEX 的结果如果是 3F3F,那是第一类,问号已经落盘。如果是 C3A4C2BDC2A0... 这种明显变长的形态,那是第二类——UTF-8 字节 E4 被当成单字节字符再编成 UTF-8 就成了 C3A4,一个汉字从三字节膨胀到六字节。
如果 HEX 完全正确(e4bda0e5a5bd 一位不差),先别急着归到第三类,再看一眼这一列声明的字符集。声明是 utf8mb4、只有屏幕上是方块,那才是第三类字体问题;声明却是某个单字节字符集,那是第四种形态——字节是对的,声明是错的:数据库以为这一列装的是西欧字母,实际躺着的是完整的 UTF-8 字节。它平时藏得很深,因为读的时候如果连接也声明成同一个单字节字符集,字节会原样返回,看着一切正常;直到有人用正确编码的连接来读,或者对这张表做一次字符集转换,才当场炸开。这一种是所有形态里最好修的,也最容易被后面的修复语句改坏,所以必须在动手之前先分出来。
二、顺着链路逐段把编码读出来
四段链路,每段都有自己的读法,别跳步。
第一段,应用内存里的字符串。 在写库之前先打印一次字节,确认进程拿到的就是正确的 UTF-8。这一步经常被跳过,但源头如果已经错了(比如从 CSV 读进来时用了错的编码),后面查再多也是白查。
第二段,连接声明的编码。 这是最容易出错也最容易被忽略的一段。驱动建立连接时会向服务端声明「我这条连接说什么编码」,没声明就用驱动或服务端的默认值,而默认值在很多环境里并不是 utf8mb4。Python 侧连接参数里的 charset、JDBC 连接串里的 characterEncoding、命令行客户端的 --default-character-set,说的都是这件事。
第三段,服务端会话变量。 连上之后直接问它:
SHOW VARIABLES LIKE 'character\_set\_%';
SHOW VARIABLES LIKE 'collation\_%';
重点看 client、connection、results 这三项。它们描述的是「服务端认为你发来的字节是什么编码」「内部按什么编码中转」「返回给你时按什么编码编」。三者和列的字符集之间只要有一处不同,就会发生转换。PostgreSQL 的对应查法是 SHOW server_encoding; 和 SHOW client_encoding;,两者不一致时服务端会在两种编码间自动转换。这里有个和 MySQL 很不一样的地方值得记住:PostgreSQL 遇到转换不了的字符倾向于直接抛错终止,而不是悄悄替换成问号。所以 PostgreSQL 上更少见到「问号落盘」,更常见的是双重编码——因为库端字节没被改,只是解释层错位。反过来说,如果你在 PostgreSQL 上真的查到了满屏问号,那多半是应用侧或某个中间层先替换过一轮,得往上游找,而不是往库里找。
第四段,列的存储字符集。 库的默认字符集、表的默认字符集、列的字符集是三层继承关系,改了库不会自动改已有的表,改了表不会自动改已有的列。所以「我明明把库改成 utf8mb4 了」这句话在排查时没有信息量,要一直查到列:
SELECT column_name, character_set_name, collation_name
FROM information_schema.columns
WHERE table_schema = 'your_db' AND table_name = 'your_table';
把四段读完,基本就能对上号。下面这张表是常见组合的判别方式。
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 中文全变半角问号,字符数不变 | 列的字符集装不下中文(单字节字符集),或结果集编码不支持中文 | HEX(col) 得到 3F;对比列的 character_set_name | 改列字符集并从上游重灌该批数据,库内无法还原 |
| 中文变成一串拉丁怪符号 | 双重编码:连接声明的编码与实际发送的字节不符 | HEX 长度约为正常值的两倍,出现大量 C3/C2 前缀 | 先修连接编码,存量按第四节的三步顺序还原(只做二进制中转修不好) |
| 一直读着正常,某次改连接编码或迁移之后突然全乱 | 列声明的是单字节字符集,里面躺的却是完整 UTF-8 字节 | HEX 与本地算出的 UTF-8 一模一样,但 character_set_name 是单字节字符集 | 用二进制中转把列的声明改对,字节一个不动 |
| emoji 或生僻字写入被拒,普通中文正常 | 列用的是三字节上限的旧 UTF-8 变体,装不下四字节字符 | 单独插入一个 emoji 复现;查 collation 名称 | 表和列都转到四字节 UTF-8,注意索引长度上限 |
| 写入正常,只有某个服务读出来是乱码 | 该服务的连接编码或运行环境编码与其他服务不同 | 同一行数据用命令行客户端读一次做对照 | 修那个服务的连接参数,别动数据库 |
| 只有定时任务/容器里跑出来是乱码 | 运行环境的 locale 与开发机不同,驱动取了系统默认编码 | 在容器里打印 LANG、LC_ALL | 显式设置环境变量或在连接串里写死编码 |
| 数据正常,只是屏幕显示方块 | 字体缺字形 | HEX 完全正确 | 换客户端或换字体,不动数据 |
最后两行值得单独说一句。同一份代码在开发机好好的、上了容器就乱,几乎都是环境差异,排查思路可以参考 运行环境不一致导致的诡异问题——数据库层是清白的,改它只会引入新问题。
三、动手顺序:先冻结写入,再从连接往回改
确认成因之后,动作有固定顺序,颠倒了会一边修一边产生新脏数据。
第一步是止血,不是修复。 把还在往这张表写数据的入口停掉,或者切到只读。这一步经常被省略,结果修数据的同时新的脏数据还在进来,最后分不清哪些是修过的、哪些是新坏的。如果业务不允许停写,退而求其次:记下当前的最大主键或时间戳,把它作为修复的边界线。
第二步改连接,不是改库。 连接层改起来最快、影响面最小、回滚也最容易——改个参数重启一次就行。改法就是把编码在连接上显式写死,不依赖任何默认值:
# 连接参数里显式声明,别依赖驱动默认值
conn = pymysql.connect(host=..., user=..., database=..., charset="utf8mb4")
# 容器或定时任务里,把 locale 显式固定下来
export LANG=C.UTF-8
export LC_ALL=C.UTF-8
这里用 C.UTF-8 而不是 zh_CN.UTF-8 是有讲究的:精简版基础镜像里通常没有装中文 locale,而指向一个系统里并不存在的 locale 时,程序多数会退回 C 那套(也就是 ASCII 行为),提示往往只是 stderr 上一行 cannot change locale 之类的警告,容器日志里很容易被漏看。于是你看到环境变量明明设对了、行为却没变。要用中文 locale 就得在镜像里先装上并生成;不确定的话,容器内跑一次 locale -a 看列表里到底有没有,比查文档快。顺带一提,C.UTF-8 也不是每个基础镜像都自带,同样以 locale -a 的实际输出为准。
改完立刻写一条测试数据,用 HEX 验证字节,别靠肉眼看客户端显示——客户端自己也可能在做转换,看着对不代表存对了。
第三步才轮到表结构。 如果确实是列的字符集不对,转换语句本身不难:
ALTER TABLE your_table CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
但这条语句有个陷阱:它会把列里现有的字节按「原字符集」解释一遍再重新编码。如果你的数据是第二类(双重编码),这一转就把错误固化了,原来还能还原的数据变成还原不了。所以顺序必须是先判断数据形态,再决定要不要转。大表上执行它还会锁表或触发表重建,上线前要评估耗时和窗口。
四、存量脏数据能不能救,看 HEX 说了算
判断标准只有一条:原始字节还在不在。
HEX 结果是 3F 的,字节已经被替换成问号了,任何 SQL 都变不回来,只能从上游重灌——原始文件、上游系统、日志、备份,哪个还有原文就从哪个来。这种情况下不要在数据库里继续尝试各种转换函数,那是在浪费时间。
字节还在的,分两种修法,用错了会白跑一趟。这是整个流程里最容易踩空的地方,值得分开讲清楚。
先说简单的那种:字节是对的,只是列声明错了。 也就是上面第四种形态——HEX 与本地算出的 UTF-8 完全一致,只有列的 character_set_name 是单字节字符集。这时候不需要做任何编码换算,只要让数据库把这些字节重新按 UTF-8 解释一遍。二进制中转干的就是这件事:先转成二进制类型,数据库对它不做任何字符解释、字节原样保留;再转成目标字符集,它会按新声明重新解释一次。
-- 仅适用于:HEX 已经是正确的 UTF-8 字节,只有列声明不对
ALTER TABLE tmp_table MODIFY col VARBINARY(255);
ALTER TABLE tmp_table MODIFY col VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
这两条语句里的类型和长度不是抄来就能用的,有两个地方要跟着你的表改。一是类型要配对:VARCHAR 对 VARBINARY,TEXT 要用 BLOB 系列去中转,用错了会在转换时被截断或报错。二是长度单位变了:VARCHAR(255) 数的是字符,VARBINARY(255) 数的是字节,中转时列里躺的是原始 UTF-8 字节,一个汉字占三到四字节。原列如果本来就是多字节字符集,直接沿用同一个数字就可能把长的行截掉一截,而截断在非严格模式下只是一条警告,很容易被忽略过去。稳妥做法是先用 SELECT MAX(LENGTH(col)) FROM your_table; 量一下实际最大字节数,中转类型的长度按它往上取。这条长度规矩对下面第二种修法同样成立。
再说难的那种:双重编码,二进制中转对它无效。 很多人直接照抄上面两条语句去修 C3A4C2BD... 形态的数据,语句执行成功、一个错都不报,读出来还是原来那串怪符号。原因是二进制中转的语义只是「原样保留字节、换个声明重新解释」,而双重编码的病根恰恰在字节本身已经被撑大了一倍,原样保留等于把错误保留下来。必须先做一次反向解码:把列先转成当初实际生效的那个单字节字符集,让 C3A4 这一对字节收回成一个字符、落回单字节 E4,这时列里躺的才是原始 UTF-8 字节,然后才轮到二进制中转。
-- 三步,顺序不能颠倒;中间那个字符集要填当初实际生效的值(多数环境是 latin1)
ALTER TABLE tmp_table MODIFY col VARCHAR(255) CHARACTER SET latin1;
ALTER TABLE tmp_table MODIFY col VARBINARY(255);
ALTER TABLE tmp_table MODIFY col VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
第一步是整段修复里唯一带有损风险的动作:如果当初生效的并不是 latin1,或者这一列里混进了本来就正确的行,转过去会有字符落不下来。所以中间字符集不能靠猜——回头看第二节读出来的连接编码和会话变量,那才是当初真正生效的值;实在拿不准,就先挑两三个典型样本在小表上跑一遍,用 HEX 对着本地算出的字节比对之后再推广。
验收同样只认字节:修完立刻对同一行跑一次 HEX,结果必须和 '你好'.encode('utf-8').hex() 这类本地算出的值完全一致,一位都不能差。客户端上看着中文正常了不算数,前面已经说过为什么。
这几条语句之间不要有任何写入。执行前务必先备份,并且先在少量样本上跑通——不同的历史成因会导致不同的层数,有的数据被编了两层,有的只有一层,混在同一列里的情况也存在。混杂时别指望一条语句全修好,按 HEX 前缀分批处理,一批一批验证。
修复过程本身也要留回滚路径:备份一定要是可恢复的备份,而不是一个「我复制了一张表」的口头承诺。修完之后,抽样比对修复前后的行数和关键字段,确认没有整行丢失。这一步的心态和 测试全绿但线上出问题 那类事故是相通的——语句执行成功不等于数据正确,得拿真实样本去看。
五、什么情况下别再折腾了
修数据是有成本的,而且拖得越久越贵。下面几个是明确的止损点。
问号已经落盘,且上游没有留存。 到这一步再花时间在数据库里试各种字符集转换是纯浪费。正确的动作是承认这批数据丢了,评估业务影响,把精力转到「怎么让它不再发生」和「怎么从别的地方补回来」。
一列里混着三种以上不同的坏法。 说明这张表历史上换过多次连接配置或迁移过多次。逐批修的边际收益会急剧下降,而每修一批都要停写一次。这时候换条路更划算:新建一列或新建一张表,从上游全量重灌,跑一段时间双写,验证无误后切换。
你已经改了三处以上配置,问题还在。 这通常意味着你在猜,不是在排查。回滚到最初状态(这也是为什么第一步要记下原始配置),重新从 HEX 开始走一遍链路。在乱码问题上,连续多次「改一下试试」会把现场搅浑,让原本能定位的问题变成不能定位。
修复窗口超出业务能承受的停写时长。 大表的字符集转换和列类型变更可能远超预期。与其硬扛,不如做在线迁移:建新表、双写、后台回填、校验、切换。慢,但每一步都可回滚。
问题只出现在某一个客户端工具里。 换个工具能正常读,那就是那个工具的编码设置问题,不值得为它去改生产库的表结构。
六、避坑清单
把库改成 utf8mb4 就以为万事大吉。 会踩是因为库级字符集只影响之后新建的表,对已有表和列没有任何追溯效力。避法是每次改完都查一遍 information_schema 里列级的实际字符集,以列为准,不以库为准。
依赖驱动的默认编码。 会踩是因为默认值取决于驱动版本、服务端配置、操作系统 locale 三方,任何一方变了行为就变,而且这种变化在测试环境不一定复现。避法是在连接串或连接参数里显式写死编码,让它不受环境影响。
用客户端显示是否正常来验收。 会踩是因为客户端读数据时自己也在做一次转换,两次错误可能互相抵消,看起来正常,实际存的是脏字节,换个工具就露馅。避法是验收只认 HEX,不认肉眼。
先执行字符集转换、后判断数据形态。 会踩是因为转换语句会按当前声明的字符集去解释现有字节,把原本可逆的双重编码固化成不可逆。避法是「先诊断、后动刀」,任何 ALTER 之前先跑一遍 HEX 抽样。
只修了主库连接,忘了旁路。 会踩是因为一套数据往往有多个写入方:主服务、定时任务、数据同步、运维脚本、临时导入。你只改了主服务的连接,脏数据仍会从别的入口进来,看起来像「修了又坏」。避法是把所有会写这张表的入口列出来逐个确认,包括那些平时没人管的脚本。
让 AI 生成的建表语句和连接配置直接上线。 会踩是因为生成的代码常常沿用某个流行的老写法,比如用三字节上限的旧 UTF-8 变体、或者连接串里根本不带编码参数——它在多数场景下能跑通,只有遇到 emoji 或特定生僻字才暴露。这类问题的性质和 AI 写的代码能不能上生产 里讲的一样:能跑通不等于配置正确。避法是把「列级字符集」和「连接编码显式声明」列进代码评审的固定检查项,让人过一遍眼。
把网上抄来的二进制中转当成通用解药。 会踩是因为二进制中转只解决「字节对、声明错」这一种,它的语义是保留字节换个解释,对双重编码这种字节本身已经变形的情况完全无效,而且它不报错,你会误以为修过了。避法是执行前后各跑一次 HEX 做对照,字节没变就说明这条路径不对,别继续往下推广。
在生产上直接试修复语句。 会踩是因为 ALTER 类语句多数不可撤销,试错的代价是整列数据。避法是复制一张结构相同的小表,把真实脏数据抽样导进去,在上面把整个修复流程走通再动正式表。
收个尾
编码问题让人烦躁的地方在于它「看起来是玄学」,但它其实是这一类故障里最有确定性的:字节是客观的,HEX 一查就知道发生过什么。真正拖时间的从来不是修复本身,是一上来就凭印象改配置,把现场搅乱。
留一份自检清单,下次照着走:
- 先看现象属于哪一类——问号、怪符号,还是方块。
- 查一次
HEX,和本地算出来的正确字节做对照;字节明明是对的,就顺手把列声明也查一遍。 - 四段链路逐段读编码:应用内存、连接声明、会话变量、列定义。
- 停写或划定边界线,再动手。
- 改动从连接层开始,一次只改一处,改完立刻用
HEX验证新写入。 - 存量数据先判断可逆性:不可逆的直接走重灌;可逆的分清是「声明错」还是「双重编码」,后者要多一步反向解码,都在小表上验证过再动正式表。
- 收尾时把所有写入方都过一遍,确认没有遗漏的旁路。
- 把连接编码和列字符集写进配置基线和评审清单,让这次的排查只做一次。