迁移或者新建环境时,把 SQL Server 部署到 Linux 上已经是很常见的做法,但中文数据一落地就容易翻车:原本在旧环境里好好的中文姓名、地址、商品标题,到了 Linux 上变成一串问号,或者变成"涓枃"这种像乱码又不像乱码的东西。这个问题的麻烦之处在于,它往往不是单一原因造成的,而是排序规则(Collation)、代码页、数据类型、客户端驱动、终端编码这五个环节里任意一环没对齐就会发作。我见过最离谱的一次,同一个系统里有三张表的中文全部正常,唯独一张日志表全是问号,排查了两个晚上才发现是当年建表时手滑写成了VARCHAR。这篇内容会把中文乱码这件事从"现象"一层层剥到"根因",给出可直接照抄的建库语句、改列脚本、连接串配置和命令行工具参数,也会把那些文档里不会写、只有踩过才知道的坑摆出来。适合正在做数据库迁移的开发、运维,也适合刚接手一套 Linux 上 SQL Server 实例、被中文数据折磨的同学,从零基础到有经验的都能找到对得上的那一段。
1. 先把"乱码"这个词拆开:三类故障必须先分清
很多人一出问题就直奔"改排序规则",结果改完发现没用,白折腾一轮。原因很简单:中文乱码至少有三种完全不同的成因,它们的外在表现相似,处置手段却南辕北辙。分不清属于哪一类就动手,等于闭着眼睛换零件。我在实际项目里习惯先做一次分类判定,这一步花不了十分钟,但能省掉后面几小时的无用功。
1.1 存储层乱码:字符在写入的那一刻就已经死了
存储层乱码的典型特征是:数据在数据库里本身就是错的,不管用什么客户端查、用什么工具导出,看到的都是问号或者截断的字符。这种情况多半发生在写入环节,最经典的组合是VARCHAR列配上SQL_Latin1_General_CP1_CI_AS这类西方语言的排序规则。
VARCHAR的存储逻辑是"按当前列的代码页编码"。如果列的排序规则指向代码页 1252(西欧),那么一个汉字根本没有对应的编码位,SQL Server 不会报错,而是很干脆地塞进去一个?。注意,这个过程是不可逆的,?就是?,原始字节已经丢了,任何后处理都救不回来。
判断这类问题有个很干脆的办法:看长度。写进去两个汉字,本来就是两个字符,用DATALENGTH()一量,正常应该是 4(NVARCHAR)或 4(GBK 编码的VARCHAR),如果只有 2,说明两个字节分别是两个问号的 ASCII 码0x3F,字符已经在入口处阵亡了。
1.2 传输层乱码:数据库里是对的,路上被改了
传输层乱码的特征是:数据库里查出来的字节是对的,但应用拿到手就变形了。这种情况在 Linux 环境里出现频率很高,因为 Linux 的编码环境比 Windows 复杂得多,LANG、LC_ALL、终端编码、驱动配置、连接串参数,每一处都可能插手。
举个实际场景:用命令行工具在 Linux 上跑查询,终端LANG设成了POSIX或者C,那么即使数据库里存的是完好的 UTF-8 中文,终端也会把它渲染成问号或者方块。这时候你去改数据库排序规则,纯属南辕北辙——数据一点问题没有,是显示环节没配好。
再比如某些老版本驱动默认按本地代码页做字符串转换,从服务器取回NVARCHAR之后转成本地编码,遇到无法映射的字符就替换成?。这种问题在服务端怎么查都是正常的,只有应用日志里能看到坏数据。
1.3 显示层乱码:看起来乱,实际没问题
还有一类最好笑的情况:数据完全正确,只是"看起来像乱码"。典型表现是中文变成了"测试"这种西欧重音字符,这是 UTF-8 字节被当成 Latin-1 解读的经典结果,属于纯粹的显示端问题。它和真正的数据损坏完全不同——字节一个都没少,只是解码方式选错了。
区分方法和前两类一致:直接看原始字节。用CONVERT(VARBINARY(64), col)把值转成二进制,UTF-8 的"中文"是0xE4B8AD 0xE69687,GBK 的"中文"是0xD6D0 0xCEC4,只要字节对,那就是显示层的事,别去碰数据。
1.4 三分钟定位法:一条 SQL 定生死
我一般在接到"中文乱码"的反馈后,第一件事就是跑下面这条语句,把三类故障一次性区分开:
SELECT col AS 原始值, LEN(col) AS 字符数, DATALENGTH(col) AS 字节数, CONVERT(VARBINARY(64), col) AS 原始字节, SQL_VARIANT_PROPERTY(col, 'Collation') AS 列的排序规则 FROM dbo.你的表 WHERE id = 1;判读规则我用一张表固化下来,每次照着看就行:
| 现象 | 字节数特征 | 原始字节 | 结论 |
|---|---|---|---|
两个汉字显示为?? | 字节数 = 2 | 0x3F3F | 存储层损坏,不可逆 |
两个汉字显示为?? | 字节数 = 4 | 0x3F00 3F00 | NVARCHAR写入了字面量?,源头数据就错 |
显示为测试 | 字节数 = 6 | 0xE6B58BE8AF95 | 显示层解码错误,数据完好 |
| 显示正常但应用里是问号 | 字节数正常 | 正常 | 传输层/驱动层问题 |
| 正常汉字但只剩一个 | 字节数被截断 | 前缀正确 | 列长度不足,被截断 |
这里有个容易忽略的细节:NVARCHAR列的DATALENGTH是字符数的两倍,因为它是 UTF-16 存储;而VARCHAR列在 UTF-8 排序规则下,一个汉字占 3 字节。同样是"两个汉字",NVARCHAR给 4,GBK 的VARCHAR给 4,UTF-8 的VARCHAR给 6,这三个数字能帮你直接反推出列的存储方式,比查元数据还快。
注意:
LEN()函数会自动忽略尾随空格,而DATALENGTH()不会。做比对时如果值可能带空格,建议用DATALENGTH为准,或者两边都用LEN再加DATALENGTH交叉验证。
2. 根子上的东西:排序规则、代码页和数据类型到底谁管谁
分类做完,就该挖根了。中文乱码这件事之所以反复出现,是因为大部分人只记住了"要设成中文排序规则"这一句口诀,却没搞明白排序规则到底管什么、不管什么。把这几层关系理清,后面所有操作都有据可依,不用再靠试。
2.1 排序规则不只是"排序",它是编码的身份证
名字叫 Collation,中文翻成"排序规则",这个翻译误导了不少人。它实际承担三件事:排序权重(决定ORDER BY的顺序)、比较规则(决定=和LIKE怎么比)、代码页归属(决定非 Unicode 字符怎么编码存储)。第三件事才是中文乱码的关键。
一个排序规则的名字可以拆成几段来看,比如Chinese_PRC_100_CI_AS_SC_UTF8:
Chinese_PRC:语言区域,对应简体中文的排序权重100:版本号,100 对应 SQL Server 2008 引入的权重体系,比不带版本号的老规则更符合现代 Unicode 排序CI:Case Insensitive,大小写不敏感AS:Accent Sensitive,重音敏感SC:Supplementary Characters,支持补充字符(四字节 UTF-16,即代理对)UTF8:VARCHAR使用 UTF-8 编码存储
其中SC和UTF8是中文场景最容易漏掉的两个后缀,后面会单独说。而Chinese_PRC这一段决定了代码页是 936(GBK),这是它能正确存下 GBK 编码汉字的根本原因——换个说法,VARCHAR列能不能存中文,取决于它的排序规则指向的代码页里有没有这个字的编码位。
这也解释了一个常见困惑:为什么同样的VARCHAR(50),在Chinese_PRC_CI_AS下能存中文,在SQL_Latin1_General_CP1_CI_AS下就变问号。列的类型没变,变的是它背后的代码页。
2.2 VARCHAR 与 NVARCHAR 的分工,以及最常见的误用
这一组类型的区别,用一句话概括:NVARCHAR存的是字符,VARCHAR存的是字节。NVARCHAR(n)的n是字符数,一个汉字算一个,固定占 2 字节(_SC补充字符占 4 字节,但n仍按"字符单元"计);VARCHAR(n)的n是字节数,能存多少汉字完全取决于编码。
这个差别在长度规划上会直接咬人。假设建了一个VARCHAR(50)的地址列,在 GBK 编码下每个汉字 2 字节,最多存 25 个汉字,够用;但如果后来把列改成 UTF-8 排序规则,每个汉字变 3 字节,VARCHAR(50)就只能存 16 个汉字了(50 ÷ 3 = 16.67,向下取整 16),超出的部分直接被截断,而且默认情况下 SQL Server 不会报错,只会静默截断。这就是为什么有些系统在切换 UTF-8 之后,地址字段开始出现"广东省深"这种半截数据。
对应的换算我整理成了表,建表时对着看:
| 列定义 | 排序规则编码 | 单汉字字节 | 可存汉字数 |
|---|---|---|---|
VARCHAR(50) | GBK(代码页 936) | 2 | 25 |
VARCHAR(50) | UTF-8(_UTF8后缀) | 3 | 16 |
NVARCHAR(50) | 任意 | 2(UTF-16) | 50 |
所以我的建议很明确:新表一律用NVARCHAR,不要图省空间用VARCHAR。省下来的那点存储,在如今的环境里几乎可以忽略,但换来的是"任何排序规则下都能存中文"的确定性。真要用VARCHAR存 UTF-8,前提是你非常清楚自己在干什么,而且能接受长度按字节计的这套心智负担。
提示:如果存量系统里大量使用
VARCHAR,短期内改类型风险太大,可以走另一条路——把列或数据库的排序规则切到带_UTF8后缀的规则,让VARCHAR直接以 UTF-8 存储。这条路改动小,但同样有长度缩水的副作用,必须提前评估字段长度。
2.3 Linux 版默认排序规则带来的迁移陷阱
这是 Linux 环境下最容易被忽视的一个点。SQL Server 在 Linux 上初始化实例时,如果安装过程中没有显式指定,实例级排序规则会落在SQL_Latin1_General_CP1_CI_AS。而在很多中文 Windows 环境里,实例排序规则是Chinese_PRC_CI_AS。
这意味着什么?在同一套建表脚本下,Windows 上建出来的VARCHAR列能存中文,Linux 上建出来的就存不了。脚本没改一个字,行为却完全不同,这种"环境差异"最难排查,因为开发者的第一反应永远是"脚本没问题啊"。
更隐蔽的是还原场景。从旧环境还原一个备份到 Linux 实例上,数据库的排序规则会跟着备份一起过来,看起来一切正常;但只要在这个库里新建表,新表继承的是数据库的排序规则(这个是对的),可如果有人手动指定了实例默认值,或者用了tempdb做中间表,麻烦就来了——tempdb的排序规则跟随实例,跨库 JOIN 时如果排序规则不一致,会直接抛出排序规则冲突错误,报错信息大意是"无法解决 equal to 操作的排序规则冲突"。
处置办法分两步。先确认现状:
-- 实例级 SELECT SERVERPROPERTY('Collation') AS instance_collation; -- 数据库级 SELECT name, collation_name FROM sys.databases WHERE database_id > 4;如果实例级确实不是中文规则,在 Linux 上可以用配置工具调整(需要重启实例,且只影响此后新建的数据库):
# 查看当前配置 /opt/mssql/bin/mssql-conf get-collation # 设置排序规则(示例,按需选择) /opt/mssql/bin/mssql-conf set-collation # 交互式输入目标排序规则,例如 Chinese_PRC_100_CI_AS_SC已经存在的数据库不受影响,需要单独用ALTER DATABASE ... COLLATE ...处理,而且这个语句只改变数据库的默认排序规则,不会改动已有列,这点必须记牢,否则会以为执行完就万事大吉。
2.4 _SC 补充字符:生僻字和四字节字符的隐形坑
这个坑我踩过一次,印象深刻。当时所有中文都正常,唯独几个客户姓名显示成问号,查了半天数据类型是NVARCHAR,排序规则也是中文的,怎么看都该没问题。最后发现原因是那几个人名里含生僻字,属于 Unicode 基本平面之外的字符,在 UTF-16 里要用代理对(两个 16 位码元,共 4 字节)表示。
不带SC的排序规则不认识代理对,遇到这类字符会直接替换成?或者干脆报错。修复方式就是把列或数据库的排序规则改成带SC的版本,比如Chinese_PRC_100_CI_AS_SC。SQL Server 2012 起提供这类规则,只要版本够就能用。
顺带说一个连带问题:代理对在NVARCHAR(n)里占两个字符单元的位置。也就是说NVARCHAR(10)最多存 5 个这样的生僻字,如果n设得太紧,写入时会被截断,而且截断可能发生在代理对中间,产生非法字符。这是长度规划时容易忽略的边界,姓名类字段建议留足余量,NVARCHAR(50)起步比较稳妥。
注意:
_SC排序规则在比较时对补充字符的处理更严格,如果业务里有大量老数据依赖模糊匹配,切换前最好在测试库上跑一遍完整的查询回归,避免LIKE结果集发生变化。
3. 实施路径:从建库到写代码的四层加固
原理说清楚,接下来就是动手。我把中文编码这件事拆成四层:实例与数据库层、表与列层、应用层、工具层。四层里任何一层松了,中文都可能出问题,所以要逐层加固,而不是指望改一个地方包治百病。
3.1 实例级与数据库级:安装时定调,建库时兜底
最省事的做法是在部署阶段就把基调定对。Linux 上初始化实例时,如果有交互式安装流程,会提示选择排序规则,这时候直接给中文规则;如果是静默部署或容器化部署,则通过环境变量或配置文件指定。容器场景下常见的做法是设置MSSQL_COLLATION环境变量,值填目标排序规则名,这样实例首次初始化就会采用该规则。
已经跑起来的实例改起来麻烦些,需要重启,而且只影响此后新建的库。所以更实际的做法是在建库时显式指定:
CREATE DATABASE [biz_db] ON PRIMARY ( NAME = N'biz_db', FILENAME = N'/var/opt/mssql/data/biz_db.mdf', SIZE = 512MB, FILEGROWTH = 256MB ) LOG ON ( NAME = N'biz_db_log', FILENAME = N'/var/opt/mssql/data/biz_db_log.ldf', SIZE = 256MB, FILEGROWTH = 128MB ) COLLATE Chinese_PRC_100_CI_AS_SC;建库语句里带上COLLATE,这条库之后新建的所有表、所有字符列都会继承这个规则,等于给整个库上了一份保险。我现在的习惯是:任何建库脚本都必须显式写COLLATE,绝不依赖实例默认值,因为实例是谁装的、装的时候选了什么,往往是历史遗留谜团,不能赌。
至于选Chinese_PRC_CI_AS还是Chinese_PRC_100_CI_AS_SC,我的判断标准是:新项目一律选带100和SC的版本,排序更符合现代习惯,生僻字也能兜住;老系统迁移如果不希望排序结果变化引起业务异常,保守一点沿用原来的Chinese_PRC_CI_AS,但至少要评估一下生僻字字段的风险。如果决定用VARCHAR存 UTF-8,那就选Chinese_PRC_100_CI_AS_SC_UTF8。
3.2 表与列级:改列排序规则的正确姿势与代价
存量表已经有数据的,改列是绕不开的。语法不复杂:
ALTER TABLE dbo.customer ALTER COLUMN customer_name NVARCHAR(100) COLLATE Chinese_PRC_100_CI_AS_SC;但这一步有几个必须提前知道的限制,否则执行到一半报错会很难看。
第一,如果列上有索引、主键、唯一约束、计算列引用、统计信息或者全文索引,ALTER COLUMN会直接失败。必须先找出依赖并处理掉。查依赖可以用系统视图:
SELECT i.name AS index_name, i.type_desc FROM sys.indexes i JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id WHERE i.object_id = OBJECT_ID('dbo.customer') AND ic.column_id = COLUMNPROPERTY(OBJECT_ID('dbo.customer'), 'customer_name', 'ColumnId');第二,改类型是有代价的。VARCHAR改NVARCHAR意味着数据要从单字节编码转成 UTF-16,整张表的数据页都要重写。一张千万级的表,这个操作可能锁表几十分钟甚至更久。生产环境务必在维护窗口做,或者走"加新列 → 双写 → 回填 → 切读 → 删旧列"的渐进路线。
第三,也是最关键的一点:改列只能防止"未来写入变问号",救不回已经丢掉的字符。如果列里现在存的是?,改完排序规则它还是?。存量数据的修复要单独处理,后面第 4 节会详细讲。
批量生成改列脚本倒是有个偷懒办法,直接查元数据拼 SQL:
SELECT 'ALTER TABLE ' + QUOTENAME(SCHEMA_NAME(t.schema_id)) + '.' + QUOTENAME(t.name) + ' ALTER COLUMN ' + QUOTENAME(c.name) + ' ' + ty.name + '(' + CASE WHEN ty.name IN ('nvarchar','nchar') THEN CAST(c.max_length / 2 AS VARCHAR(10)) ELSE CAST(c.max_length AS VARCHAR(10)) END + ') COLLATE Chinese_PRC_100_CI_AS_SC;' AS ddl FROM sys.columns c JOIN sys.tables t ON c.object_id = t.object_id JOIN sys.types ty ON c.user_type_id = ty.user_type_id WHERE ty.name IN ('varchar','char') AND c.max_length <> -1 AND c.collation_name NOT LIKE '%UTF8%';跑出来的脚本一定要人工过一遍再执行,尤其是max_length = -1的MAX类型要单独处理,别直接照跑。
3.3 应用层:连接串、驱动与参数化写法
服务端配好了,应用侧还有一半工程。Linux 上的应用技术栈五花八门,我这里挑几个常见的说,重点是让驱动明确知道字符串是 Unicode,不要让它自作主张做编码转换。
Java(JDBC):驱动本身走 TDS 协议传NVARCHAR,中文一般不会丢,但连接串里加上明确的编码声明更保险:
jdbc:sqlserver://127.0.0.1:1433;databaseName=biz_db; sendStringParametersAsUnicode=true; characterEncoding=UTF-8; encrypt=false;trustServerCertificate=true;sendStringParametersAsUnicode=true是默认值,但老版本驱动在某些配置下会变成false,导致参数按单字节发送,一旦列是VARCHAR直接变问号。显式写死不吃亏。
Python(pyodbc):连接时把charset明确出来,同时把驱动版本写清楚,新版 ODBC 驱动对 Unicode 支持更完整:
import pyodbc conn = pyodbc.connect( "DRIVER={ODBC Driver 18 for SQL Server};" "SERVER=127.0.0.1,1433;DATABASE=biz_db;" "UID=app_user;PWD=******;" "TrustServerCertificate=yes;", charset="utf8", )用pymssql的话,charset="utf8"同样要显式给,不写就容易走默认的cp1252,中文直接歇菜。
Go:连接串里带charset=utf8,并在读取时用sql.NullString或直接string接收,驱动会自动处理 UTF-16 到 UTF-8 的转换。
除了驱动配置,还有一个比配置更容易出错的地方:拼接 SQL。任何用字符串拼出来的INSERT,中文都可能在中途被某层转码破坏,而且这种问题极难复现。参数化查询不仅防注入,还顺手把编码问题一起解决了,这是我一直坚持的底线写法。
还有一个细节:写NVARCHAR字段时,字面量前面要加N前缀,比如N'张三'。不加的话,SQL Server 会先把它当VARCHAR按当前数据库的排序规则解释,再转成NVARCHAR——如果数据库排序规则不认这个汉字,转换过程中就已经变成问号了,等到写入时早就晚了。
-- 正确 INSERT INTO dbo.customer (customer_name) VALUES (N'张三'); -- 危险:在某些排序规则下 '张三' 会先被当成 VARCHAR 处理 INSERT INTO dbo.customer (customer_name) VALUES ('张三');3.4 工具层:命令行工具的编码参数怎么给
Linux 上做运维离不开命令行工具,而命令行工具的编码行为恰好是最容易被忽略的一环。这里说三个高频场景。
sqlcmd 执行含中文的脚本文件。默认情况下,工具会按系统 locale 推断文件编码,如果脚本是 UTF-8 而系统 locale 不是 UTF-8,读进来的中文就废了。正确做法是显式指定代码页:
sqlcmd -S 127.0.0.1 -U sa -P '******' -d biz_db -f 65001 -i ./init_data.sql-f 65001表示输入输出都按 UTF-8 处理。如果要分开指定,可以用-f i:65001,o:936这种写法。另外别忘了LANG环境变量,跑之前export LANG=en_US.UTF-8或者对应的 UTF-8 locale,终端才可能正确渲染输出。
bcp 批量导入导出。这是中文丢失的重灾区。bcp的-c参数表示字符模式,-w表示 Unicode 模式。用-c而不指定代码页,工具会按默认代码页处理,中文极可能变形。两种稳妥写法:
# 方案一:Unicode 模式,文件是 UTF-16 bcp biz_db.dbo.customer in ./customer.dat \ -S 127.0.0.1 -U sa -P '******' -w -t "|" -r "\n" # 方案二:字符模式 + 显式 UTF-8 代码页 bcp biz_db.dbo.customer in ./customer.dat \ -S 127.0.0.1 -U sa -P '******' -c -C 65001 -t "|" -r "\n"我更倾向方案一,-w走 Unicode,不依赖代码页推断,跨环境一致性最好,代价是文件体积大约翻倍。
源文件本身是 GBK 的。这种情况在接手老系统时特别常见——导出的 CSV 是 GBK 编码,直接导进 UTF-8 的库,全是乱码。别急着改数据库,先用file命令确认编码,再用iconv转一道:
file -i ./customer.csv # customer.csv: text/plain; charset=iso-8859-1 iconv -f GBK -t UTF-8 ./customer.csv -o ./customer_utf8.csv转完再导,问题就消失了大半。这一步看着土,但比在数据库里折腾排序规则高效得多。
4. 存量数据修复实录:一次从 Windows 到 Linux 的迁移现场
原理和方案讲完,接下来是我觉得最有价值的部分——真实场景下的排查和修复过程。下面这套流程是我在一次跨平台迁移里踩出来的,当时涉及几百张表、上亿行数据,中间走了不少弯路,整理成可复用的步骤。
4.1 复现与取证:先别急着改,把现场固定下来
接到反馈时,症状是"客户名称显示成问号"。第一步绝对不是改配置,而是取证。我做的第一件事是挑一条出问题的记录,把它的各个维度信息全查出来:
SELECT TOP 5 id, customer_name, LEN(customer_name) AS char_len, DATALENGTH(customer_name) AS byte_len, CONVERT(VARBINARY(64), customer_name) AS raw_bytes FROM dbo.customer WHERE customer_name LIKE '%?%' OR customer_name LIKE '%[?]%';结果很清晰:byte_len = 2,raw_bytes是0x3F3F。两个问号,两个字节,实锤存储层损坏。接着查列定义:
SELECT c.name, ty.name AS type_name, c.max_length, c.collation_name FROM sys.columns c JOIN sys.types ty ON c.user_type_id = ty.user_type_id WHERE c.object_id = OBJECT_ID('dbo.customer') AND c.name = 'customer_name';返回varchar / 50 / SQL_Latin1_General_CP1_CI_AS。到这里根因就明确了:这张表是从旧环境带过来的,建表时用了VARCHAR,而 Linux 实例的默认排序规则是西方语言规则,写入时汉字没有编码位,被替换成了问号。
为了确认影响范围,我把所有字符列扫了一遍,按"类型 + 排序规则"分组统计:
SELECT ty.name AS 数据类型, c.collation_name AS 排序规则, COUNT(*) AS 列数量 FROM sys.columns c JOIN sys.tables t ON c.object_id = t.object_id JOIN sys.types ty ON c.user_type_id = ty.user_type_id WHERE ty.name IN ('varchar','char','text') GROUP BY ty.name, c.collation_name ORDER BY 列数量 DESC;结果发现受影响的不止一张表,一共 37 个VARCHAR列落在了非中文规则上。这个统计很重要,它决定了后续工作量,也避免了"修一个报一个"的拉锯。
4.2 区分真乱码与看似乱码,再决定动不动手
取证阶段还有一步不能省:确认哪些是真损坏,哪些只是显示问题。我把 37 个列全部跑了一遍字节检查,结果分成三类:
- 第一类,字节数明显偏小且全是
0x3F,真损坏,数据救不回来,只能从上游补 - 第二类,字节数正常,
raw_bytes是合法的 GBK 或 UTF-8 序列,数据其实完好,只是查询工具显示不对 - 第三类,混合情况,部分行正常部分行损坏
第一类最麻烦。?是不可逆的,数据库里没有任何信息能还原原始汉字。唯一的办法是回到数据源头——业务系统的日志、上游导出文件、或者备份里的旧版本数据。当时我们的做法是从旧环境的备份里把这几张表单独抽出来,比对主键后做定向更新,而不是整表覆盖,避免把迁移后新增的数据冲掉。
第二类的处理就简单多了,改一下查询工具的编码设置或者连接串就行,数据一个字都不用动。这里特别提醒:不要因为看到乱码就急着跑UPDATE,我见过有人一看到问号就用脚本批量"修复",结果把本来完好的 UTF-8 数据也覆盖成了问号,损失翻倍。
第三类要按行处理,先筛出坏行,再逐批补。判断坏行的条件可以写成:
SELECT id FROM dbo.customer WHERE DATALENGTH(customer_name) > 0 AND DATALENGTH(customer_name) = LEN(customer_name) AND customer_name LIKE '%?%';逻辑是:字节数等于字符数,说明每个字符只占一个字节,那必然是 ASCII 范围内的字符,如果还含问号,基本可以判定为损坏。
4.3 修复脚本与灰度验证:小步走,随时能回退
修复动作分三步走,顺序不能乱。
第一步,先把列结构改对。对于有索引的列,先记录索引定义,删掉索引,改列,再重建:
-- 记录原索引定义(用于重建) EXEC sp_helpindex 'dbo.customer'; -- 改列 ALTER TABLE dbo.customer ALTER COLUMN customer_name NVARCHAR(100) COLLATE Chinese_PRC_100_CI_AS_SC; -- 重建索引 CREATE NONCLUSTERED INDEX IX_customer_name ON dbo.customer (customer_name);第二步,验证新写入是否正常。这一步千万别跳过,直接在新结构上写一条测试数据,读回来比对字节:
INSERT INTO dbo.customer (customer_name) VALUES (N'测试中文'); SELECT customer_name, DATALENGTH(customer_name) AS byte_len FROM dbo.customer WHERE customer_name = N'测试中文';NVARCHAR下两个汉字应该给出byte_len = 4,字节是0x6D4B 0x8BD5 0x4E2D 0x6587。如果这一步就是问号,那说明问题不在列,而在连接层或者写入方式,得回头查。
第三步,回填损坏数据。回填的原则是按主键精准更新,绝不整表覆盖,并且每次只处理一批,批间做校验:
-- 从临时修复表按主键回填 UPDATE c SET c.customer_name = f.fixed_name FROM dbo.customer c JOIN dbo.customer_fix f ON c.id = f.id WHERE c.customer_name LIKE '%?%' AND f.fixed_name IS NOT NULL;执行前后各跑一次统计,确认影响行数和预期一致。我习惯在事务里先SELECT出待更新行数,跟预期比对,对得上再COMMIT,对不上直接ROLLBACK查原因。
4.4 导入导出链路的编码控制:把闸门设在入口
修复完之后,更重要的是防止再次发生。当时我们做了三件事。
第一,把所有数据导入脚本的编码统一到 UTF-8,并在sqlcmd调用处固定加上-f 65001。这条改动写进了部署脚本模板,不靠人记。
第二,所有批量导入改用bcp -w的 Unicode 模式,彻底绕开代码页推断。同时给导入脚本加了一道前置校验:导入前先file -i检查源文件编码,不匹配就直接中止,不让脏数据进库。
第三,把"新表必须用NVARCHAR、必须有COLLATE"写进了建表规范,并在 CI 里加了一条元数据检查——每次发版前扫一遍库里的VARCHAR列,凡是排序规则不含中文或UTF8的直接报错阻断。这条检查后来拦下过好几次手滑,比代码评审管用。
5. 常见问题速查与避坑清单
前面讲的都是主干流程,实际动手时还会撞上一堆边角问题。这一节把我这些年遇到的、别人也经常问的情况整理成速查表,配几条只有踩过才懂的经验。
5.1 高频问题速查表
| 现象 | 最可能的原因 | 处置动作 |
|---|---|---|
写入后变??,字节0x3F3F | 列是VARCHAR且排序规则不含中文代码页 | 改列类型为NVARCHAR并指定中文排序规则 |
显示成测试这类西欧字符 | UTF-8 字节被按 Latin-1 解码 | 改客户端/终端编码为 UTF-8,不动数据 |
| 数据库里正常,应用里是问号 | 驱动按本地代码页转换,或连接串未声明 Unicode | 检查charset/sendStringParametersAsUnicode |
| 跨库 JOIN 报排序规则冲突 | 两库排序规则不一致 | 在比较处加COLLATE DATABASE_DEFAULT |
生僻字显示为? | 排序规则不带SC,不支持代理对 | 改用带_SC的排序规则 |
| 中文被截断成半个字 | VARCHAR(n)按字节计,长度不够 | 扩长度或改NVARCHAR |
| 命令行输出全是问号但库里正常 | 终端LANG不是 UTF-8 | export LANG=en_US.UTF-8或对应 locale |
| 脚本执行后中文变形 | 脚本文件编码与-f参数不匹配 | 用iconv转码或加-f 65001 |
tempdb里临时表中文异常 | tempdb排序规则跟随实例 | 临时表列显式加COLLATE DATABASE_DEFAULT |
跨库冲突那条值得展开说一句。解决办法是在所有字符串比较和连接条件上显式指定排序规则:
SELECT a.order_no, b.customer_name FROM db_a.dbo.orders a JOIN db_b.dbo.customer b ON a.customer_code = b.customer_code COLLATE DATABASE_DEFAULT WHERE b.customer_name = N'张三' COLLATE DATABASE_DEFAULT;DATABASE_DEFAULT的含义是"用当前数据库的排序规则",这样写的好处是不硬编码具体的规则名,换库换环境都不用改 SQL。临时表和表变量同理,在定义列时加上这个后缀,能避免一大类偶发的冲突报错。
5.2 六条踩过坑才懂的经验
第一条,问题定位永远从字节入手,不要从"看起来对不对"入手。中文乱码最大的误导就是视觉判断,同一个?可能来自完全不同层级的故障。养成先跑DATALENGTH和CONVERT(VARBINARY)的习惯,五分钟能省一晚上。
第二条,N前缀不是可选项。只要目标是NVARCHAR列,字面量就加N。这个约定看着啰嗦,但它把"什么时候发生编码转换"这件事变得完全可控。团队里我要求所有涉及中文的 SQL 一律带N,代码评审时也盯着这一条。
第三条,改排序规则和生产数据修复要分两次做。先改结构,验证新写入正常,再处理存量。合在一起做,一旦出问题你分不清是结构变更导致的还是数据回填导致的,排查成本翻倍。
第四条,VARCHAR转NVARCHAR前必须算清楚长度。NVARCHAR(n)的n是字符数,原VARCHAR(n)的n是字节数,直接照抄n看起来安全,但实际上会把原本紧凑的字段放大一倍存储。更稳妥的做法是按业务语义重新定长度,比如姓名NVARCHAR(50)、地址NVARCHAR(200),别机械平移。
第五条,UTF-8 排序规则不是银弹。它解决的是"VARCHAR也能存 UTF-8"这个问题,但代价是长度按字节计、老版本不支持、部分工具链兼容性没验证过。如果团队里没人愿意长期维护这套心智模型,老老实实用NVARCHAR更省心。
第六条,把所有编码相关的约定写进自动化检查。人一定会忘,尤其是半年后接手的新同学。我在项目里加过一条最土但最有效的检查:每天定时扫一遍实例元数据,发现非中文排序规则的VARCHAR列就发告警。上线三个月,同类问题再没复发过。
最后分享一个我自己的小习惯:每次新建数据库,我都会先跑一个"中文冒烟测试",插一条含常见汉字、生僻字、全角标点的记录,读回来逐字节比对。这套动作一分钟能跑完,但能在项目最开始就把编码基线钉死,比出了问题再回头查划算太多。