☰
SQL Server中文乱码排查:排序规则、nvarchar与代码页实战
2026/11/3 4:53:27 网站建设 项目流程

1. 中文乱码这条链路上,最容易背锅和最该背锅的环节

Navicat 连上 SQL Server,打开一张表,本来应该是"上海市浦东新区"的字段显示成了一串问号,或者更诡异的"涓婃捣甯";再或者列里明明是中文,导出成 CSV 用 Excel 打开又花掉了。这类问题我在排查时遇到过太多次,几乎每隔一段时间就会有人拿着截图来问。绝大多数人的第一反应是"Navicat 编码设置错了",于是去连接属性里翻编码选项,翻半天发现 SQL Server 的连接里压根没有 MySQL 那种字符集下拉框,然后就卡住了。

先把结论摆出来:SQL Server 的中文乱码,九成以上跟 Navicat 没关系,真正的问题在存储链路上。SQL Server 和 MySQL 的字符集模型完全不是一回事。MySQL 有服务器级、库级、表级、列级四层字符集,还能连接时协商SET NAMES;SQL Server 只有一层东西叫排序规则(Collation),它同时决定了两件事——字符串怎么比较排序,以及varchar类型的列用哪个代码页解释字节。nvarchar则绕开了代码页这一层,直接按 UTF-16 存放。

这个差异决定了排查思路完全不同。MySQL 乱码要往上追连接字符集,SQL Server 乱码要往下追列的排序规则和数据类型。你要是拿 MySQL 的经验套 SQL Server,会一直在客户端配置里打转,永远找不到根因。

这篇内容适合三类人看:一是刚接手一套从别处迁移过来的 SQL Server 数据库,发现中文全是问号的;二是自己建库时随手选了默认排序规则,后面越用越别扭的;三是需要在存量系统里批量整改字段类型,但怕改出问题的。下面我会把整条链路拆开,给出可以直接跑的诊断 SQL、可以直接抄的整改脚本,以及几个只有真改过库的人才会遇到的问题。

1.1 从键盘敲下第一个汉字到磁盘落盘,要过五道编码转换

想搞清楚乱码,得先知道一个汉字要经过多少道手。以最常见的场景为例:一个前端页面提交表单,后端用 Java 或 .NET 处理,最后写进 SQL Server。

第一道,浏览器到 HTTP 请求体。浏览器按页面声明的编码(通常是 UTF-8)把"上海"编码成字节E4 B8 8A E6 B5 B7,塞进请求体。

第二道,Web 框架解析请求体。框架要知道请求体是 UTF-8,才能把这两组三字节还原成两个 Unicode 码位 U+4E0A 和 U+6D77。这一步配错,框架会把 UTF-8 字节按 ISO-8859-1 解,得到"上海"这种拉丁字母加符号的怪东西。

第三道,应用层到数据库驱动。驱动把 Java 的 String 或 .NET 的 string 转成 TDS 协议要传的格式。SQL Server 的 TDS 协议对 Unicode 字符串用 UTF-16LE 传输,这一步通常是安全的。

第四道,服务端接收并写入列。这里是分水岭。如果目标列是nvarchar,UTF-16 字节原样落盘,结束。如果目标列是varchar,SQL Server 会按该列排序规则对应的代码页做一次转换。比如列排序规则是Chinese_PRC_CI_AS,代码页 936(GBK),"上海"会被转成 GBK 的C9 CF BA A3四个字节存下去。如果列排序规则是SQL_Latin1_General_CP1_CI_AS,代码页 1252,GBK 里根本没有对应字符,转换失败,全部变成问号?。

第五道,读取出来展示。nvarchar列读出来还是 UTF-16,Navicat 渲染成中文,没问题。varchar列读出来是一串字节加一个代码页标记,客户端按代码页解码,解对了就是中文,解错了就是乱码。

看出来了吧,第四道是唯一一处会真正丢数据的地方。前三道错了还能靠重新解析救回来,第五道错了只是显示问题,数据本身是好的。唯独第四道,一旦字符在目标代码页里不存在,转换就是有损且不可逆的,存进去是问号,读出来就是问号,神仙难救。

1.2 三种典型乱码长相,各自指向完全不同的病灶

乱码不是一种东西,长得不一样,病因也不一样。我在排查时基本靠"看长相"就能缩小一半范围。

第一种,成片的问号,比如"?????"或者中文混着问号"上海??"。这是代码页转换时字符无法表示,被替换成了替换字符。指向的病因非常明确:数据写进了一个代码页不支持的varchar列。这种情况数据已经损坏,改列类型也救不回来,只能从源头重新导入。

第二种,"锟斤拷"。这三个字在中文技术圈几乎是梗了,但它有严格的成因:UTF-8 编码里的替换字符 U+FFFD,其 UTF-8 字节是EF BF BD,两个这样的字符连在一起就是EF BF BD EF BF BD,被 GBK 按双字节切分后正好解出"锟斤拷"三个字。看到这个,基本可以断定是UTF-8 字节被当成 GBK 解码了。这属于显示层或传输层的编码错配,原始字节大概率还在,有机会救。

第三种,"上海"这类拉丁字母加符号的组合。这是 UTF-8 字节被按 ISO-8859-1 或 CP1252 解码的典型结果。同样是显示问题,数据本身没坏,换个正确的解码方式就能还原。

还有一种容易被误判的:"烫烫烫"或"屯屯屯"。这不是编码问题,是读到了未初始化的内存,调试模式下常见。跟数据库没关系,别往编码上想。

我把这几类的对应关系整理成一张表,排查时对着看会快很多:

乱码长相成因数据是否已损坏处理方向
?????目标代码页无对应字符,被替换是,不可逆从源头重新导入
锟斤拷UTF-8 的 U+FFFD 被 GBK 解码否,字节还在修正解码链路
上海UTF-8 被按 Latin-1 解码否修正解码链路
涓婃捣UTF-8 被按 GBK 解码否修正解码链路
烫烫烫未初始化内存无关排查程序逻辑
中文显示成方块字体缺字形否换字体,与数据库无关

注意:判断"数据是否已损坏"是整条排查链路的分水岭。在没确认这一点之前,任何修改表结构的操作都可能是白费功夫,甚至有风险。

1.3 一个反直觉的结论:多数"乱码"其实数据没坏

我处理过的案例里,统计下来大约七成属于"显示层错配",数据本身是完整的。为什么会这样?因为 SQL Server 用nvarchar存中文是非常稳的,只要应用层把字符串正确地交给驱动,落盘的就是完整的 UTF-16,中间不会有任何有损转换。

那这七成是怎么产生的?集中在这几个地方:

  • 从数据库导数据到 CSV,导出时选了 GBK,再用 Excel 打开时 Excel 按 UTF-8 猜,猜错了。
  • 用sqlcmd或命令行工具查询,Windows 控制台代码页是 936,但输出流被当成 UTF-8 处理。
  • 应用读出来之后写日志,日志文件的编码和写入方式不匹配。
  • 从 MySQL 或 PostgreSQL 迁移过来,中间经过了一次 CSV 中转,中转时编码选错。

这几种情况的共同点是:数据库里是好的,坏在了数据库之外。所以我给别人排查时,第一步永远是让他先确认库里的数据到底是不是好的,而不是上来就改配置。下一节就讲怎么用几条 SQL 把这个事钉死。

2. 三组查询把故障点钉死:字节级排查法

排查乱码最忌讳的就是"猜"。猜是客户端问题就去改客户端,猜是驱动问题就去换驱动,改了一圈发现还是乱码,时间全浪费了。正确的做法是在数据库内部做一次验尸,看清楚这个字段里到底存的是什么字节,然后一切就都清楚了。

这一节给的三组查询,我在实际排查中几乎每次都会跑一遍,加起来不到一分钟,但能把故障范围从"整条链路"压缩到"某一个具体的点"。

2.1 DATALENGTH 和 LEN 的比值,直接暴露存储宽度

DATALENGTH返回字节数,LEN返回字符数(注意LEN会忽略尾部空格)。这两个函数的比值,对nvarchar列来说应该恒等于 2(因为 UTF-16 每个字符占 2 字节,除非遇到代理对,中文常用字基本不会)。

准备一张测试表:

CREATE TABLE dbo.EncTest ( Id INT IDENTITY(1,1) PRIMARY KEY, ColN NVARCHAR(100) NULL, ColV VARCHAR(100) NULL ); GO INSERT INTO dbo.EncTest (ColN, ColV) VALUES (N'上海市浦东新区', N'上海市浦东新区'); GO

然后跑这条诊断:

SELECT Id, ColN, DATALENGTH(ColN) AS ColN_Bytes, LEN(ColN) AS ColN_Chars, DATALENGTH(ColN) / NULLIF(LEN(ColN), 0) AS ColN_Ratio, ColV, DATALENGTH(ColV) AS ColV_Bytes, LEN(ColV) AS ColV_Chars, DATALENGTH(ColV) / NULLIF(LEN(ColV), 0) AS ColV_Ratio FROM dbo.EncTest;

跑出来的结果,ColN_Ratio应该是 2,ColV_Ratio取决于列排序规则的代码页——代码页 936 下,一个汉字占 2 字节,所以比值也是 2;但如果列排序规则是SQL_Latin1_General_CP1_CI_AS,中文根本存不进去,ColV会存成 7 个问号,DATALENGTH是 7,比值是 1。

这里有个非常实用的判据:如果你怀疑某个varchar列里存的数据是坏的,看比值。比值是 1 而内容是"中文",那不用怀疑了,它一定是坏的,因为单字节代码页存不下中文,能存下来的只可能是被替换后的问号。

反过来,如果一张老表里列是varchar,但比值是 2、内容也确实是正常中文,说明这个列的排序规则本身就是中文代码页(936),是能正常工作的,只是存在代理对字符可能出问题的隐患。

2.2 UNICODE 函数摸出第一个字符的真实码位

DATALENGTH只看宽度,看不出内容对不对。想看内容,用UNICODE函数取第一个字符的码位,再用NCHAR反查:

SELECT ColN, UNICODE(SUBSTRING(ColN, 1, 1)) AS FirstCharCode, NCHAR(UNICODE(SUBSTRING(ColN, 1, 1))) AS FirstCharBack, CONVERT(VARBINARY(64), ColN) AS RawBytes FROM dbo.EncTest;

正常的"上"字,UNICODE返回 19978(0x4E0A)。如果返回的是 63,那就是问号,说明数据在落盘时已经被替换了。如果返回的是 65533(0xFFFD),那是 Unicode 替换字符,说明上游有一步转换失败过。

我还会顺手用CONVERT(VARBINARY, ...)把原始字节打出来。这一步看起来原始,但极其有用。比如同一段中文,nvarchar存的是0A4E 77B5 ...(小端 UTF-16),varchar在代码页 936 下存的是C9CF BAA3 ...,在 UTF-8 排序规则下存的是E4B8 8AE6 B5B7 ...。三种字节长得完全不同,一眼就能看出来这个列到底用什么方式存的。

这里有个排查技巧很值得记住:如果你不确定一个varchar列的排序规则到底是什么,拿一条已知的中文写进去,看CONVERT(VARBINARY, ...)的结果就够了。E4B8 8A打头的是 UTF-8,C9CF打头的是 GBK,3F打头的是问号。

2.3 实例、库、列三级排序规则一次性对齐

SQL Server 的排序规则是分层的,三层里任何一层不一致都可能出问题。很多人只知道查库级,但真正决定varchar用哪个代码页的,是列级。

三层一起查:

-- 实例级 SELECT SERVERPROPERTY('Collation') AS ServerCollation; -- 库级 SELECT name, collation_name FROM sys.databases WHERE name NOT IN ('master','model','msdb','tempdb'); -- 列级(以某张表为例) SELECT t.name AS TableName, c.name AS ColumnName, ty.name AS DataType, c.max_length, c.collation_name 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 t.name = 'EncTest' ORDER BY c.column_id;

跑出来的结果,重点看三件事:

第一,实例级排序规则。默认安装如果是英文环境,很可能就是SQL_Latin1_General_CP1_CI_AS。这个值决定了master、model、tempdb的排序规则,也决定了新建数据库时如果不显式指定,会继承什么。它还决定了临时表和表变量的默认排序规则,这一点后面会专门讲,是个高频坑。

第二,库级排序规则。如果库是从别处还原或附加过来的,库级排序规则跟着原来的服务器走,可能和你当前实例不一致。这种情况在迁移场景里非常常见。

第三,列级排序规则。nvarchar列的collation_name也有值,但它只影响比较排序,不影响存储;varchar列的collation_name才是决定代码页的关键。

三层里最容易出问题的是第三层。因为ALTER DATABASE ... COLLATE只改库的默认值,不会动已有列。很多人以为改了库排序规则就万事大吉,结果老表的列还是旧排序规则,中文照样乱。这是我在实际整改中遇到最多的误解,一定要记住:改库不改列,等于没改。

3. 新建库表的防乱码基线配置

排查完存量问题,更值得投入精力的是把新建的库表建对。乱码这种事,防的成本远远低于治的成本——治的时候数据可能已经丢了,而防只是在建表时多打几个字符。

这一节讲的是我在新项目里固定会用的一套配置基线,不复杂,但每一条都有明确的理由。

3.1 nvarchar 还是 varchar:先想清楚代码页在哪一端

这是最根本的一个选型。网上常见的说法是"中文用 nvarchar,英文用 varchar 省空间",这个说法对,但不够精确,容易让人在边界情况上翻车。

准确的判断逻辑是这样的:varchar的字节含义依赖列的排序规则,而排序规则是数据库内部的约定,一旦数据要出库——导出、跨库传输、给第三方系统——这个约定就可能丢失。而nvarchar用 UTF-16 存储,虽然它也不是自描述的,但至少在所有 Windows 平台和主流客户端上,UTF-16 是通用约定,出库后不容易被误解。

所以我的原则是:

  • 任何可能存中文、日文、韩文,或者需要支持多语言的字符串列,一律nvarchar。别纠结那点空间,一个nvarchar(50)比varchar(50)多占的字节,在现在的存储成本下几乎可以忽略。
  • 纯 ASCII 的字段可以用varchar,比如编码、状态位、URL slug、Base64 哈希值。但前提是你能保证它永远不会出现非 ASCII 字符,这个保证往往靠不住——我见过"备注"字段被设成varchar(200),结果用户输入了个 emoji 直接报错的。
  • 主键和索引列要额外考虑。nvarchar的索引键长度是varchar的两倍,如果要做大量 JOIN,宽主键会拖累性能。这种场景我会用varchar存编码类主键,同时在应用层做强校验。

有一个细节值得单独说:varchar在 SQL Server 2019 之后有了新选项,就是带_UTF8后缀的排序规则。用了它,varchar也能存 UTF-8 编码的中文,一个汉字占 3 字节。这个特性看起来很香,但我在生产环境里用得比较谨慎,原因后面 3.3 会详细讲。

3.2 N 前缀这件事,为什么总有人漏

N'中文'前面那个N,是 National Character 的缩写,作用是告诉 SQL Server"这个字面量是 Unicode 的"。不带N的字面量会被当成varchar,按当前数据库的默认排序规则解释。

这个差异在很多情况下看不出来,因为varchar字面量赋给nvarchar列时,SQL Server 会自动做一次隐式转换。但转换发生在解析阶段,且用的是数据库默认排序规则对应的代码页。如果数据库默认排序规则是SQL_Latin1_General_CP1_CI_AS,'上海'这个字面量在解析时就已经被转成问号了,再赋给nvarchar列,存进去的还是问号。

我做过一个最小的复现实验:

-- 假设当前库排序规则是 SQL_Latin1_General_CP1_CI_AS DECLARE @a NVARCHAR(50) = '上海'; -- 没有 N 前缀 DECLARE @b NVARCHAR(50) = N'上海'; -- 有 N 前缀 SELECT @a AS WithoutN, @b AS WithN, UNICODE(SUBSTRING(@a,1,1)) AS CodeA, UNICODE(SUBSTRING(@b,1,1)) AS CodeB;

结果是WithoutN显示??,CodeA是 63;WithN显示"上海",CodeB是 19978。同一句 SQL,只差一个字母,结果天壤之别。

这个坑在生产环境里最常出现在三个地方:存储过程里的字符串常量、动态 SQL 拼接、以及INSERT ... VALUES的批量脚本。批量脚本最要命,因为它是从别的地方导出来的一堆 INSERT 语句,几百条里漏了几个N,跑完之后只有部分数据是坏的,定位起来极其费劲。

我现在的做法是:写任何含中文的 SQL 脚本,先全局搜索一遍单引号,确认每一个前面都有N。这个习惯是被坑出来的,比事后排查便宜太多。

3.3 排序规则的版本号与 _UTF8 后缀到底要不要尝鲜

排序规则的完整名字看着长,但结构很规整,拆开看就明白了。以Chinese_PRC_90_CI_AS为例:

  • Chinese_PRC是语言区域,中文简体。
  • 90是版本号,对应 SQL Server 2005 的排序规则版本。不写版本号的Chinese_PRC_CI_AS是最老的那一版,等价于Chinese_PRC_80。
  • CI是 Case Insensitive,大小写不敏感。
  • AS是 Accent Sensitive,重音敏感。

常见的版本号有 90、100、140。数字越大,遵循的 Unicode 排序标准越新,对新增字符的支持越好。新项目我一律用 100 或 140,理由是老的 80 版本对某些生僻字和新增 Unicode 字符的排序行为不符合直觉,而且 2008 之后的默认安装基本都是 100。

然后是_UTF8后缀。SQL Server 2019 开始支持,写法类似Chinese_PRC_100_CI_AS_SC_UTF8。它让varchar按 UTF-8 存储,好处是省空间(英文 1 字节,中文 3 字节,比nvarchar的中文 2 字节多,但英文少一半)并且能和外部系统的 UTF-8 数据直接对接。

但我实际用下来,有几个必须知道的问题:

第一,它改变的是存储编码,不是数据类型语义。varchar还是varchar,LEN和DATALENGTH的关系变了,很多原来按"一个字符一字节"写的代码会算错长度。

第二,老客户端和驱动可能不认。一些旧版本的 ODBC 驱动、一些第三方的报表工具,拿到_UTF8排序规则的列会按代码页 936 去解,结果就是乱码。我在一个项目里遇到过,数据库升级后某个老报表工具导出的中文全花了,最后是给那个工具单独建了视图,把列CAST成nvarchar才解决。

第三,排序规则转换不能反向。从_UTF8换成普通排序规则,如果列里存了非 ASCII 字符,转换会失败或丢数据。

所以我的建议是:新项目、全链路可控、客户端都是新版本的,可以上_UTF8;有历史包袱或者对接方不明的情况下,老老实实用nvarchar。nvarchar是 SQL Server 里最稳的中文存储方案,二十多年没变过,不会踩坑。

3.4 应用连接层需要设置的三个地方

数据库这边建对了,应用这边还有三处要确认。虽然 SQL Server 的驱动层对 Unicode 处理得比较好,但配置不对一样出问题。

第一处是 JDBC 连接串。SQL Server 的 JDBC 驱动有一个参数sendStringParametersAsUnicode,默认是true,意思是把 Java 的 String 按 Unicode 发送,这是正确的。如果有人为了兼容老驱动把它改成false,中文就会按单字节发送,遇到varchar列乱码、遇到nvarchar列可能报截断。除非有明确理由,这个参数不要动,保持默认。

另外characterEncoding和useUnicode这两个参数,在 MySQL 驱动里是必须的,在 SQL Server 驱动里作用有限,写了也没坏处,但不要指望靠它们解决乱码。

第二处是 .NET 的SqlConnection。.NET 的System.Data.SqlClient和Microsoft.Data.SqlClient都默认按 UTF-16 传输,配置层面基本不会有编码问题。真正容易出错的是SqlBulkCopy——如果源数据的DataTable列类型是string,会按nvarchar批量导入;如果是从一个IDataReader流式读取,而那个 reader 返回的是varchar,就可能出问题。这个场景我在做数据迁移时踩过一次,几十万行数据导进去一半是问号,最后是靠显式指定列映射的类型解决的。

第三处是 ORM 的映射配置。用 Entity Framework 或 MyBatis 这类 ORM 时,要确认实体字段映射到的数据库列类型是nvarchar而不是varchar。有些代码生成工具会按数据库里现有的类型生成,如果原表是varchar,生成出来的映射也是varchar,你在代码里怎么改都没用。这种情况得先改表结构,再重新生成。

这三处都确认完之后,新写入的数据基本就不会有乱码了。剩下的就是存量数据的处理,这是下一节的内容。

4. 存量数据已经坏了:补救的完整执行顺序

改一个已经上线、有数据的库,和新建一个库完全是两码事。新建的时候可以随便折腾,改存量的时候每一步都要考虑:会不会锁表、会不会丢数据、出问题能不能回滚、改完之后业务代码会不会挂。

我在实际整改中总结出一个执行顺序,看起来有点啰嗦,但每一步都有必要,跳过任何一步都可能在后续翻车。

4.1 为什么必须"先备份、再改类型、最后改排序规则"

这个顺序不是拍脑袋定的,是由三个事实推导出来的。

事实一:改列类型是不可逆的。varchar改成nvarchar,如果原列里存的是问号,改完之后还是问号,不会变回中文。而且nvarchar改回varchar时,如果数据里有代码页不支持的字符,会直接报错或者被截断。所以改类型前必须有完整备份,出问题能退回来。

事实二:改列类型会重建数据页。ALTER TABLE ... ALTER COLUMN表面上只是改元数据,实际上 SQL Server 需要把每一行的这一列重新写入,对于大表来说这是一次全表扫描加重写,会产生大量日志,可能锁表很久。我处理过一个两千万行的表,改一个varchar(50)到nvarchar(50),跑了一分四十秒,期间表锁死。这个操作必须放在业务低峰期。

事实三:改库排序规则会重置所有对象的依赖关系。ALTER DATABASE ... COLLATE是一个相当重的操作,它要求数据库处于独占状态,期间任何连接都会被拒绝。而且它只改库的默认值,不动已有列——所以它其实应该放在改列类型之后,作为"以后新建的列默认用新排序规则"的收尾动作。

所以完整的顺序是:

  1. 完整备份(含日志备份)。
  2. 记录当前三级的排序规则快照,存成文本备查。
  3. 逐个改列的排序规则和数据类型。
  4. 改库级排序规则。
  5. 改实例级排序规则(如确有必要,这一步风险最高,通常不动)。
  6. 回归验证。

第5步我要特别说明:实例级排序规则几乎不要动。它影响master、model、tempdb,改它需要重建系统库,过程复杂,而且改完之后所有数据库的临时对象行为都会变。我的做法是实例级保持原样,全部在库级解决。

4.2 用系统视图批量生成 ALTER COLUMN 脚本

手工写ALTER TABLE是最容易出错的环节,尤其是列多、表多的时候。正确做法是让系统视图帮你生成脚本,然后人工审一遍再执行。

下面这段 SQL 会扫出当前库里所有varchar列,生成把它们改成nvarchar的语句:

SELECT 'ALTER TABLE [' + s.name + '].[' + t.name + '] ' + 'ALTER COLUMN [' + c.name + '] NVARCHAR(' + CASE WHEN c.max_length = -1 THEN 'MAX' ELSE CAST(c.max_length AS VARCHAR(10)) END + ') ' + CASE WHEN c.is_nullable = 1 THEN 'NULL' ELSE 'NOT NULL' END + ';' AS AlterScript FROM sys.columns c JOIN sys.tables t ON c.object_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id JOIN sys.types ty ON c.user_type_id = ty.user_type_id WHERE ty.name = 'varchar' AND t.is_ms_shipped = 0 ORDER BY s.name, t.name, c.column_id;

有几个地方要注意:

varchar列的max_length本身就以字节计,转成nvarchar时长度参数应该是字符数,所以这里直接取max_length当字符数用是安全的——因为原来的字节数一定不小于需要的字符数,宁大勿小。但如果原列是varchar(max),max_length是 -1,要特殊处理成MAX,上面的CASE已经处理了。

默认值和约束会丢。ALTER COLUMN会保留列的默认值约束(如果它绑定在列上),但如果列上有CHECK约束或计算列依赖它,操作会失败。所以生成脚本之后,先跑一遍这个查询看依赖:

SELECT OBJECT_NAME(d.referencing_id) AS ReferencingObject, d.referenced_entity_name AS ReferencedTable, d.referenced_minor_name AS ReferencedColumn FROM sys.sql_expression_dependencies d WHERE d.referenced_id = OBJECT_ID('dbo.YourTable');

一定要先在一个小表上试跑。挑一张数据量小、依赖少的表,跑一遍生成的脚本,然后立刻验证数据是否完好,再推到全库。我见过有人直接全库执行,中途某个表因为依赖失败,结果一半表改了另一半没改,状态非常尴尬。

4.3 修改库级排序规则时被连接卡住的处理

ALTER DATABASE ... COLLATE要求数据库单用户状态,否则会报"无法获得数据库独占锁"。而生产库里永远有连接,包括连接池里的空闲连接、Navicat 开着的查询窗口、SQL Server Agent 的作业连接。

标准的做法是这样:

-- 第一步:切单用户,踢掉所有现有连接 USE master; GO ALTER DATABASE [YourDb] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; GO -- 第二步:改排序规则 ALTER DATABASE [YourDb] COLLATE Chinese_PRC_100_CI_AS; GO -- 第三步:立刻切回多用户,否则业务全挂 ALTER DATABASE [YourDb] SET MULTI_USER; GO

这三句要放在同一个执行批次里连续跑,中间不要停。ROLLBACK IMMEDIATE会立刻回滚未提交事务并断开连接,如果业务正在跑重要事务,会被强制中断,所以务必在低峰期。

还有一个坑:第三句如果没跑,库就一直是单用户状态,业务全部连不上,而且此时只有你一个人能连进去,看起来像"数据库挂了"。我见过有人改完之后忘了切回来,然后被叫去"紧急处理数据库故障",现场一看是自己造成的。

更稳妥的做法是用TRY...CATCH包起来,确保无论成功失败都会切回多用户:

BEGIN TRY ALTER DATABASE [YourDb] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; ALTER DATABASE [YourDb] COLLATE Chinese_PRC_100_CI_AS; END TRY BEGIN CATCH PRINT ERROR_MESSAGE(); END CATCH BEGIN ALTER DATABASE [YourDb] SET MULTI_USER; END

提示:执行前确认没有正在运行的备份作业或索引重建作业,ROLLBACK IMMEDIATE会中断它们,可能导致备份文件损坏。

4.4 排序规则冲突报错的三类触发点与统一写法

改完列排序规则之后,最常见的报错就是这一句:

Cannot resolve the collation conflict between "SQL_Latin1_General_CP1_CI_AS" and "Chinese_PRC_100_CI_AS" in the equal to operation.

意思是两个字符串在一个比较操作里,但它们的排序规则不一样,SQL Server 不知道该按谁的规则比。这在改库改到一半的时候特别容易出现,因为老列和新列排序规则不同,一 JOIN 就炸。

三类触发点:

第一类是表和表 JOIN。一张老表的列还是Latin1,新表是Chinese_PRC,a.code = b.code立刻报错。

第二类是临时表和正式表 JOIN。临时表在tempdb里,默认继承实例级排序规则,如果实例是Latin1而业务表是Chinese_PRC,一 JOIN 就冲突。这个坑后面 6.1 会详细说。

第三类是表变量和正式表 JOIN。表变量同样继承tempdb的排序规则,问题一模一样。

统一的解决写法是在比较的表达式上显式加COLLATE DATABASE_DEFAULT:

SELECT a.code, a.name, b.amount FROM dbo.TableA a JOIN dbo.TableB b ON a.code COLLATE DATABASE_DEFAULT = b.code COLLATE DATABASE_DEFAULT;

DATABASE_DEFAULT是一个占位符,表示"用当前数据库的默认排序规则",这样写的好处是不写死具体的排序规则名字,库换了排序规则之后代码不用改。

但如果两边排序规则不同是长期状态,COLLATE会让索引失效。这时候应该改表结构,让两边统一,而不是长期靠COLLATE兜着。COLLATE是应急手段,不是长治久安之计。

4.5 已经变成问号的数据,能不能救回来

直接说结论:变成问号的数据救不回来。

原因是问号?是一个替换字符,原来的字符信息在转换时就已经彻底丢失了。?和?之间没有任何区别,你无法从一堆问号反推出原来是什么字。这就好比你把一张彩色照片转成了黑白,再想还原颜色是不可能的。

但有几条可以做的:

第一,判断损坏范围。用这个查询统计一下有多少行是坏的:

SELECT COUNT(*) AS BrokenRows FROM dbo.YourTable WHERE YourColumn LIKE '%?%' AND YourColumn NOT LIKE '%[%]%'; -- 排除正常的问号场景

第二,确认坏的行的特征。比如是不是集中在某个时间段、某个来源系统、某个用户操作路径。这些特征能帮你定位是哪个环节的问题。

第三,从源头重新导入。如果原始数据还在CSV、Excel、或者源系统里,这是唯一可靠的办法。导入前务必把目标列的排序规则和类型都改好,避免二次损坏。

第四,如果源头也没有了,只能靠业务方人工补录,或者接受这部分数据缺失。这种情况要提前跟业务沟通清楚,别自己扛着。

有一个例外情况值得提:如果乱码是显示层的问题(锟斤拷那类),原始字节还在,是有救的。判断方法是用CONVERT(VARBINARY, ...)看原始字节,如果能看出是 UTF-8 的模式(E4-E9打头的三字节序列),就可以用程序按 UTF-8 解码再按 GBK 重新解读,还原出正确的字符串。这个操作我在一次迁移事故里做过,用一段脚本把二十多万行数据救了回来,但前提是字节确实是完整的,没有丢。

5. 导入导出这个高发场景,细节全在编码选项上

数据库内部的编码问题一旦修好,剩下的高发区就是数据进出库的环节。这部分的坑不在于技术复杂,而在于选项多、默认值不一定对、而且出错之后往往以为是数据库的问题。

我统计了一下自己处理过的编码问题,导入导出环节占了一半以上。这一节把常见的几个场景拆开讲。

5.1 CSV 与 Excel 导入时那个容易被忽略的编码下拉框

Navicat 的导入向导里,选完文件之后有一个"编码"下拉框,默认值通常是Auto或者未指定。这个默认值就是万恶之源。

如果你导的是一个 UTF-8 的 CSV,而 Navicat 按 GBK 去读,中文就会变成"涓婃捣";反过来,如果文件是 GBK,按 UTF-8 读,就会出现替换字符。两种情况都不会报错,导入"成功"了,但数据是坏的,等业务方发现的时候,可能已经过了一天。

正确的做法是:导入前先确认文件编码,然后手动在下拉框里选对应的值。UTF-8 选65001,GBK/GB18030 选936。不要相信Auto。

怎么确认文件编码?几个办法:

  • 看 BOM。用十六进制编辑器打开文件头,EF BB BF开头是 UTF-8 with BOM,FF FE是 UTF-16LE,FE FF是 UTF-16BE,什么都没有就是无 BOM(可能是 UTF-8 无 BOM,也可能是 GBK,需要进一步判断)。
  • 用文本编辑器确认。主流的编辑器都能显示当前文件编码,打开看一眼比什么都快。
  • 用程序判断。Python 里可以用chardet库检测,虽然不保证百分百准,但作为参考够了。

还有一种情况:文件是 UTF-8 无 BOM,但用 Excel 打开变成乱码。这是 Excel 的老毛病——Excel 打开 CSV 时,无 BOM 的文件会按系统区域设置(简体中文系统是 GBK)去解,UTF-8 中文就花了。解决办法有两个:一是重新用"数据 - 从文本/CSV"导入并指定编码,二是把文件另存为 UTF-8 with BOM。

注意:给非技术人员分发的 CSV,尽量存成 UTF-8 with BOM。多三个字节,能省掉无数沟通成本。

5.2 SQL 脚本文件的 BOM 决定它被怎么读

用INSERT语句写成的 SQL 脚本文件,同样有编码问题。而且这个问题的隐蔽性更强,因为脚本执行成功、行数也对,只是数据是坏的。

关键在于执行工具怎么读这个文件。SSMS 打开.sql文件时,会根据 BOM 判断编码;没有 BOM 时按系统区域设置判断。所以:

  • 文件是 UTF-8 有 BOM:SSMS 正确识别为 UTF-8,含中文的N'中文'字面量正常。
  • 文件是 UTF-8 无 BOM:SSMS 可能按 GBK 读,UTF-8 字节被误解,N'中文'里的中文字节已经变了,执行进去就是乱码。
  • 文件是 GBK:SSMS 按 GBK 读正常,但把文件传到 Linux 服务器用sqlcmd跑就可能出问题。

我的做法是:所有含中文的 SQL 脚本,一律存成 UTF-8 with BOM。这个格式在 Windows 全家桶里兼容性最好,SSMS、VS Code、Notepad++ 都能正确识别。

还有一点,如果用sqlcmd在命令行执行脚本,要加-f 65001参数显式指定输入文件编码:

sqlcmd -S localhost -d YourDb -U sa -P yourpassword -f 65001 -i import_script.sql

不加这个参数,sqlcmd会按系统代码页读文件,UTF-8 的中文就废了。

5.3 跨库跨实例搬运时先建骨架再灌数据

从 A 实例搬到 B 实例,最省事的办法是备份还原,但如果两个实例的排序规则不同,还原之后库的排序规则会跟着源库走,列排序规则也跟源库走。这时候如果 B 实例的应用代码里有些依赖当前库排序规则的写法,就可能出问题。

更麻烦的是跨库跨版本迁移,比如从一棵老树迁到新建的库。这种情况下我推荐的流程是:

第一步,在目标库建表结构,用目标库的排序规则。手工建或者用脚本建都行,关键是建的时候就指定好nvarchar和正确的排序规则,不要复制源库的 DDL。

第二步,用 SQL Server 自带的导入导出向导或bcp灌数据。这两个工具默认走 Unicode 模式,中文不会丢。

第三步,灌完之后立刻做一次校验。校验方法是抽若干行,比对源库和目标库的DATALENGTH和UNICODE结果是否一致。不一致就说明中间有转换。

这次的校验很重要,因为一旦灌进去的时候坏了,后面所有的分析都是在错误的数据上做的。

bcp的 Unicode 模式写法是这样的:

# 导出(-w 表示使用 Unicode 字符数据类型) bcp YourDb.dbo.YourTable out data.dat -w -S source_server -U sa -P pwd # 导入 bcp YourDb.dbo.YourTable in data.dat -w -S target_server -U sa -P pwd

-w这个参数是关键,不加它就用字符模式(-c),中文在转换时会出问题。用-w导出的文件是 UTF-16 格式,体积比-c大一倍,但中文绝对安全。

6. 几个反复踩到的小坑

前面讲的都是主干,这一节列几个边角但高频的坑。这些坑单独拿出来看都不复杂,但它们有个共同特点:出错的时候不会报错,只会静默地产生错误数据,所以特别难发现。

6.1 临时表和表变量偷偷继承了 tempdb 的排序规则

这是我最想强调的一条。#temp临时表和@tableVar表变量,它们的字符串列排序规则继承的是tempdb的排序规则,而tempdb继承的是实例级排序规则。不是当前数据库的排序规则。

所以会出现这么一种情况:业务库排序规则是Chinese_PRC_100_CI_AS,实例级是SQL_Latin1_General_CP1_CI_AS,然后你写:

SELECT * INTO #Temp FROM dbo.Orders; SELECT * FROM #Temp t JOIN dbo.Orders o ON t.OrderNo = o.OrderNo;

#Temp里的OrderNo是Latin1排序规则,dbo.Orders里的是Chinese_PRC,JOIN 的时候报排序规则冲突。或者更隐蔽的情况:不报错,但比较结果不对。

解决办法是在建临时表时显式指定排序规则:

CREATE TABLE #Temp ( OrderNo VARCHAR(50) COLLATE DATABASE_DEFAULT, Remark NVARCHAR(100) COLLATE DATABASE_DEFAULT );

DATABASE_DEFAULT在这里表示"用当前数据库的默认排序规则",正好解决这个问题。

表变量同理:

DECLARE @Temp TABLE ( OrderNo VARCHAR(50) COLLATE DATABASE_DEFAULT );

这个写法有点啰嗦,但能省掉很多莫名其妙的排查。我现在写含字符串列的临时表,条件反射地会加上这个后缀。

6.2 存储过程参数与变量声明同样要带 nvarchar

存储过程的参数类型,同样是一个高频遗漏点。看这段代码:

CREATE PROCEDURE dbo.GetOrders @CustomerName VARCHAR(100) AS BEGIN SELECT * FROM dbo.Orders WHERE CustomerName = @CustomerName; END

调用的时候传中文进去,如果列是nvarchar,参数是varchar,会在参数传入时先按库默认排序规则转一次,可能在转换时就丢了。正确的写法是参数也用nvarchar:

CREATE PROCEDURE dbo.GetOrders @CustomerName NVARCHAR(100) AS BEGIN SELECT * FROM dbo.Orders WHERE CustomerName = @CustomerName; END

还有变量声明也一样:

DECLARE @name NVARCHAR(100) = N'上海';

DECLARE后面的变量也要带N前缀赋值,原因和 3.2 讲的一样。

这两个地方之所以容易漏,是因为编译不会报错,运行时也不会报错,只是数据不对。等发现的时候,可能已经在生产上跑了好几个月。

6.3 跨实例恢复备份后表还在但列变了

从一台服务器备份,还原到另一台服务器,如果两台的实例级排序规则不同,表结构和数据都会跟着源库走——这本身没问题。但有一个坑:如果你之后在这台服务器上新建了一张表,用的是目标库的默认排序规则,和还原进来的老表排序规则不一致,两者一 JOIN 就冲突。

这种情况在做数据迁移或合并的时候特别常见。解决方案有两个:一是在还原之后统一改一遍排序规则,二是所有跨表比较都加COLLATE DATABASE_DEFAULT。

我倾向于第一个方案,因为长期的COLLATE会拖累查询性能。改完之后在sys.columns里再查一遍,确认所有相关列的collation_name都是同一个值。

还有一个细节:还原之后要重置数据库的所属用户。用ALTER AUTHORIZATION或者sp_change_users_login处理孤立用户,否则应用可能连不上。这个问题和乱码无关,但经常和乱码问题一起出现,因为都是迁移场景。

6.4 Windows 命令行工具的代码页

最后说一个和数据库无关但经常被误会成数据库问题的:Windows 控制台的代码页。

如果你用sqlcmd或其它命令行工具查询中文,输出的中文变成乱码,先别怀疑数据库。chcp命令看一下当前代码页,如果是 936 而工具输出的是 UTF-8,或者反过来,那就只是控制台显示的问题。

处理方法:

:: 查看当前代码页 chcp :: 切换到 UTF-8 chcp 65001

切到 65001 之后,很多命令行工具的中文输出就正常了。但要注意,切换代码页可能影响后续所有命令的行为,包括文件路径的显示。用完最好切回去,或者干脆在单独的命令窗口里跑。

还有一个更彻底的办法:把输出重定向到文件,然后用支持指定编码的编辑器打开。这样绕开了控制台的编码限制,结果最可靠:

sqlcmd -S localhost -d YourDb -U sa -P pwd -Q "SELECT * FROM dbo.Orders" -o output.txt -f 65001

-f 65001指定输出文件的编码是 UTF-8,生成的文件用任何编辑器打开都不会乱。

我个人在实际操作中的体会是:乱码问题之所以让人头疼,不是因为技术难,而是因为同一个现象背后可能有好几种完全不同的原因,而每一种的修法都不一样。看到乱码先别急着改配置,按"查字节、定范围、再动手"的顺序走一遍,把故障点压缩到具体某一环,剩下的就是照着改。最怕的是凭直觉一路改下去,改到最后自己都不知道改了什么,问题还在。

另一个很深的体会是,防的成本永远低于治的成本。新建库的时候多花十分钟把排序规则、数据类型、N前缀都弄对,能省掉未来几个月甚至几年的排查。我见过一个系统,因为建库时用了英文排序规则,后来每次跨表查询都要加COLLATE,这个技术债背了好几年,直到重构才还清。这类问题一旦扩散到几十张表、几百处代码,清理的代价是指数级上升的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询