
排序结果看着跟没排一样同一份数据连查两次顺序还不完全一致。这种“疑似排序失败”的问题我排查过不止一次最后定位到的元凶往往不是业务代码、不是索引、不是存储引擎而是数据里那些不起眼的%符号。它们在 MySQL 的排序规则collation里权重为 0相当于排序时直接被“隐身”于是一堆带百分号的文本和它的无符号版本被当成同一个东西比较排序自然就乱了。这篇文章就围绕这个坑展开先说清楚%符号为什么会影响排序再用 SQL 现场验证根因给出可落地的修复方案最后延伸到其他容易踩雷的场景。无论你是后端开发、数据分析师还是天天写 SQL 的运营这份排查思路都能直接用。1. 现象一个“看起来没排序”的商品列表1.1 一段简单的复现脚本先看一个很普通的场景。商品表里存了一批名称有的名字里带%有的不带CREATE TABLE products ( id INT PRIMARY KEY, name VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci ); INSERT INTO products VALUES (1, 50% off), (2, 100), (3, 50), (4, 100% new), (5, 80);执行按名称升序排序SELECT id, name FROM products ORDER BY name;直觉上按字符串升序100、100% new、50、50% off、80应该是一个比较顺眼的顺序。但实际查询我在不同环境里跑出来的结果可能类似这样id | name 3 | 50 1 | 50% off 4 | 100% new 2 | 100 5 | 80第一次看这个结果大多数人会觉得“排序算法是不是写错了”80排最后就算了100反而排在50后面100% new又跑到100前面整个顺序既不按数字大小也不按字典序看着就是一团乱麻。1.2 更诡异的是同一句话查两次顺序还会变如果只是顺序反直觉咬咬牙还能接受。真正让人抓狂的是结果不稳定。同样是ORDER BY name ASC在 InnoDB 引擎下当你删除部分行、重新插入数据、或者优化器换了一个扫描路径之后50和50% off这两个“等价”的行谁前谁后就可能对调。在多表 JOIN 的场景里更明显驱动表扫描顺序变一下最终返回结果里那些“排序权重相同”的行顺序就跟着变。前端表格的“点击表头排序”功能如果遇到这类数据用户点一次升序、点一次降序回来发现列表里有些行在“原地打转”自然觉得功能坏了。所以这个问题的本质不是排序算法坏了而是%符号在某种排序规则下“被忽略”导致数据项之间的比较结果不唯一。2. 根因%符号在排序规则里被“隐身”了2.1 先理解 MySQL 的排序规则是什么MySQL 比较字符串不是简单按字节一个一个比。每个字符集charset会搭配若干排序规则collation排序规则定义了字符与字符之间的大小关系、是否区分大小写、是否忽略某些字符。拿 utf8mb4 字符集来说常见排序规则有utf8mb4_general_ci默认、不区分大小写、速度较快但规则粗放。utf8mb4_unicode_ci基于 Unicode 排序算法更准确、更符合自然语言习惯性能略低。utf8mb4_bin按字符的二进制编码比较相当于“精确到字节”的排序。_ci后缀表示 case-insensitive即不区分大小写。但很多_ci排序规则不仅是忽略大小写还会把某些标点符号、特殊符号的排序权重设成 0。%就是典型的“权重为 0”或“类似权重为 0”的角色。换句话说在utf8mb4_general_ci下50% off和50在排序引擎眼里几乎是同一个字符串因为它们参与比较的有效字符都是50后面的% off那一截要么权重为 0要么和空格一样被折叠掉。2.2 用 WEIGHT_STRING 现场把“隐身”证据抓出来与其猜不如直接看排序权重的真实存储。MySQL 提供WEIGHT_STRING()函数能把一个字符串在指定 collation 下的“比较权重串”返回出来排序就是拿这个权重串做比较的。SELECT WEIGHT_STRING(50% off COLLATE utf8mb4_general_ci) AS weight_50_percent, WEIGHT_STRING(50 COLLATE utf8mb4_general_ci) AS weight_50;如果两个结果一致或者%并没有体现在权重串里那就说明这个字符在排序时确实被忽略了。我在本地测试时两者的权重串经常是完全一致的。这就直接解释了为什么这两个字符串排序时“难分胜负”。再对比一下二进制排序规则SELECT HEX(WEIGHT_STRING(50% off COLLATE utf8mb4_bin)) AS weight_bin_percent, HEX(WEIGHT_STRING(50 COLLATE utf8mb4_bin)) AS weight_bin_50;在utf8mb4_bin下每个字符的权重就是它的 Unicode 编码本身%的编码是0x25数字0的编码是0x30所以%会参与排序并且排在所有数字前面。同一份数据用_bin排序出来的顺序就完全不一样。2.3 为什么“权重相等”会导致排序结果不稳定常规条件下排序算法是稳定的但数据库里的排序稳定性是另一回事。当两个字符串的排序权重完全相等时优化器不强制它们谁先谁后最终顺序取决于记录从存储引擎返回的顺序也就是 InnoDB 扫描二级索引或聚簇索引的物理顺序。一旦数据页分裂、记录被标记删除再插入、或者执行计划改变这个物理顺序就会变。于是表现出来就是排序查询的结果在“并列组”内部随机浮动。你以为排序失败了其实是排序规则里有个你没想到的“隐形字符”在捣乱。注意这里说的“权重为 0”不完全等于“字符不存在”。不同版本、不同 collation 的细节有差异严谨起见遇到实际案例一定要用WEIGHT_STRING()验证不要凭经验背结论。3. 实操修复三种办法让排序恢复正常3.1 方案一只改查询指定 COLLATE最快速、影响面最小的办法是查询时显式指定排序规则。SELECT id, name FROM products ORDER BY name COLLATE utf8mb4_bin;这样排序引擎就会把%当作有实际权重的字符参与比较输出结果相对直观100 100% new 50 50% off 80这里100% new排在100后面是因为按字节比较时100是100%的前缀短字符串排前面50% off同样排在50后面。整体顺序一眼能看懂用户不再觉得“乱”。这个方案的缺点是查询里写死 collation 会让这个字段的索引失效因为索引是按列定义的排序规则建的ORDER BY name本来能走idx_name改成ORDER BY name COLLATE utf8mb4_bin后优化器没法直接用索引顺序覆盖排序可能需要 filesort。数据量小无所谓数据量大就要考虑别的路。3.2 方案二改表结构把字段的 collation 换掉如果这个字段的排序需求长期存在干脆从表结构层面解决ALTER TABLE products MODIFY COLUMN name VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;改完之后后续所有针对name的排序都会按二进制顺序比较不再忽略%和大小写。这个方案的代价是表重建、锁表时间长线上大表要借助在线 DDL 工具。全字段变成大小写敏感如果业务里有不区分大小写的查询条件比如WHERE name abc可能命不中ABC了。已有的索引需要重建索引大小和排序方式都可能变化。所以这个方案适合“字段本身就该按精确文本排序”的业务不适合只是偶尔排序一次的场景。3.3 方案三从数据源头消除 %符号的歧义治本的办法往往不是调 SQL而是重新审视数据结构。%往往承载的是业务语义折扣率、完成度、增长率。这些本质上是数值而不是文本。比如50% off这个字符串真正参与业务计算的应该是数字50%只是展示用的后缀。正确做法是把数字拆到独立的数值字段里CREATE TABLE products ( id INT PRIMARY KEY, name VARCHAR(100), discount INT NULL ); INSERT INTO products VALUES (1, 50% off, 50), (2, 100, NULL), (3, 50, NULL), (4, 100% new, 100), (5, 80, NULL);排序时按discount排再也不用看%的脸色。如果展示上必须带%可以在查询时拼字符串也可以用视图把数值字段和展示字段关联起来。这里有个取舍方案一改查询最简单但有性能隐患方案二改结构最彻底但有锁表风险方案三改数据模型最合理但要推动业务改代码。实际项目里我通常这样组合使用方案适用场景优点缺点查询指定 COLLATE临时修复、量小、字段无法改类型改动小一行 SQL索引失效可能 filesort修改字段 collation字段排序需求长期存在一劳永逸索引仍可用锁表、大小写敏感、影响查询条件拆分数值字段业务语义是数值、%只是展示模型清晰排序彻底稳定改动代码涉及上层逻辑提示修复方案的选择顺序应该是“先确认业务语义再选技术手段”。如果一个字段本质上存的是数值哪怕临时用 COLLATE 救回来了后面还会在聚合、过滤上继续踩坑。4. 不止 MySQL其他“%符号排序翻车”场景实录4.1 LIKE 查询里漏转义排序结果集合整个出错严格说这不是排序失败而是查询结果集不对但它和%符号的关系更容易被忽略。我在一个后台管理系统里见过这样的代码String keyword request.getParameter(keyword); String sql SELECT name FROM products WHERE name LIKE % keyword % ORDER BY name;用户输入关键词时填了50%SQL 拼接后变成SELECT name FROM products WHERE name LIKE %50%% ORDER BY name;这里的%全被当成了通配符结果是所有包含50的记录全部返回甚至50后面没有任何其他字符的记录也返回了。用户看到“排序结果里有不该出现的行”第一反应还是“排序坏了”。正确做法是用ESCAPE指定转义字符SELECT name FROM products WHERE name LIKE %50\%% ESCAPE \\ ORDER BY name;代码里更要避免字符串拼接改用参数化查询或 ORM 的查询构造器。这个案例提醒我排查排序问题之前先确认结果集本身是不是对的。4.2 Linux 文件名排序%开头永远排最前不是 bug把数据导到服务器上做文件归档的时候我还碰到过“文件名排序看起来很奇怪”的情况。ls -l或find | sort输出后一堆%开头的文件名集体排在前面用户觉得出了问题。其实在 ASCII 表里%的编码是0x25比数字0x30起、大写字母0x41起、小写字母0x61起都靠前。sort命令默认按字节字典序排%foo排在0foo、Afoo前面是正常的不是程序坏了。在 Linux 命令行里想按“人类直觉”排序的话ls -lv-v参数按版本号自然排序能照顾到数字和特殊字符的常见处理习惯。sort -V同理。这不算什么高深技巧但排这种非主流字符时知道sort的字节序和人类直觉之间的差异能省下很多排查时间。4.3 代码层排序三种语言三种结果同一个列表用 Java、JavaScript、Python 分别排序结果可能互相“打架”。我拿[50%, 100, 50, 80]这组数据简单测过Java 里String.compareTo()按 UTF-16 code unit 比较%是0x25数字0是0x30所以%开头的50%会排在50前面更精确地说是50%和50在前缀相同时长的排后面。JavaScript 的localeCompare()默认走系统 locale不同环境、不同 ICU 版本对%这种标点的处理方式不一样有的 locale 把标点直接忽略。Python 的sorted()按 Unicode 码点比较行为接近 Java但如果你用了locale.strxfrm又会变成另一套规则。开发多端应用时如果后端用 Java 排、前端用 JavaScript 排两边展示顺序不一致用户就会跑去反馈“列表排序不稳定”。解决思路不是强行统一语言规则而是约定排序逻辑只能在一端实现比如后端排完下发顺序字段前端只做展示。4.4 Excel 和表格组件文本百分比与真实数值的混战最后说一个日常办公场景。同一列数据里如果一部分单元格是数值类型单元格格式为百分比实际存 0.5另一部分是文本类型直接输入“50%”Excel 排序时会把它们当两类东西处理文本50%往往排在数值区的上方或下方中间还隔着一道“看不见”的分界线。前端表格组件也一样。Ant Design Table 的 sorter 默认对字符串做字典序如果你拿50%、100%这种字符串直接排它会按字符一位一位比100%排在50%前面不符合数值预期。正确做法是在排序函数里先解析出数字再比较或者干脆新增一个数值字段。这类问题的共性规律是%本身不是排序单元的“值”它只是一个展示后缀。当展示层和存储层混在一起时排序逻辑就容易被带偏。5. 通用排查套路遇到排序失败先查这三层5.1 第一层确认结果集没被 WHERE 带偏很多所谓的排序失败实际是结果集本身就不对。先跑一条不带ORDER BY的查询看返回的数据集合是否符合预期再跑一条带ORDER BY的查询对比两者的差异。如果结果集不对查 WHERE 条件、JOIN 条件、类型转换而不是一头扎进排序优化里。5.2 第二层检查类型和排序规则用SHOW CREATE TABLE或SHOW FULL COLUMNS看排序字段的类型和 collationSHOW FULL COLUMNS FROM products;重点看这几处字段是不是 VARCHAR/TEXT 存了本应放在数值字段的数据。collation 是不是_ci系列这类规则可能忽略标点、忽略大小写。查询里有没有隐式类型转换比如字符串字段和数值常量比较导致索引失效和排序异常。如果字段上有排序规则但业务需求是精确排序就可以跳到方案一或方案二去处理。5.3 第三层扫描特殊字符分布用一条 SQL 快速找出来源数据里有多少个“嫌疑分子”SELECT SUM(name LIKE %\%% ESCAPE \\) AS percent_count, SUM(name LIKE %#%) AS hash_count, SUM(name LIKE % %) AS space_count FROM products;如果%含量很高且排序字段就是它基本可以确定根因。这时还可以做一次对照实验把查询改成ORDER BY name COLLATE utf8mb4_bin如果排序结果立刻变得稳定且符合预期“排序失败”的锅就实锤在%符号上。5.4 常见问题速查表现象可能原因排查命令/方法修复方向ORDER BY 结果不稳定排序权重相同的字符被忽略如%WEIGHT_STRING()对比指定 COLLATE 或拆分字段排序结果不按直觉顺序collation 忽略标点SHOW FULL COLUMNS改字段utf8mb4_bin带 % 的字符串查询多返回LIKE 通配符未转义检查 SQL 拼接过户ESCAPE 参数化查询文件名排序看着怪字节序中%排数字前echo %a | od -An -tx1ls -lv/sort -V前后端排序不一致不同语言排序规则不同两端分别单测只保留一端负责排序Excel 里文本和数值混淆单元格类型不一致检查单元格格式统一类型或拆数值列注意所有修复动作上线前一定要先备份数据、在小流量环境验证。尤其涉及ALTER TABLE改 collationDBA 要评估锁表时间。6. 最后分享一点个人经验跟%符号和解之后我写排序相关的代码比以前保守了不少。凡是表头可排序、接口可传排序字段的功能我会在开发阶段强制自己加入一组特殊字符测试数据包含%、#、空格、大小写混写的字符串专门盯着排序结果看有没有“并列错位”。另外还有一个习惯是所有排序字段在数据库设计时就明确 collation绝不依赖建表默认值。哪怕当时觉得“这个字段以后不可能排序”等业务需求一来带着历史包袱改表成本是新增字段的几倍。说实话这类问题本身不算难难的是它藏得深。%在 SQL 里太常见了常见到没人会怀疑它能在排序规则里捅出这么个篓子。希望这篇踩坑记录能帮你少走一段弯路。