ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL主键查询却全表扫描?字符集与排序规则隐式转换是元凶

MySQL主键查询却全表扫描?字符集与排序规则隐式转换是元凶 1. 从一次真实故障说起订单明细表为何全表扫描1.1 故障现象一条连表SQL拖垮了查询性能先说结论放在前面主键查询却走全表扫描很多时候不是SQL写得烂而是表的字符集和排序规则在暗中使坏。这个月我就被一个线上问题折腾了整整大半天最后发现问题出在两个表字段的字符集配置上排查过程的戏剧性程度让我觉得值得好好写一篇复盘。当时业务方反馈说订单详情接口变慢了平时几十毫秒的接口突然飙到三秒以上。我拉出慢查询日志看到一条关联查询SELECT o.order_no, o.user_id, o.amount, i.sku_id, i.quantity FROM orders o LEFT JOIN order_items i ON o.order_no i.order_no WHERE o.order_no 202501010001234;orders表按主键order_no定位一行理论上应该是秒出结果。order_items表也建了order_no的普通索引left join关联时走索引数据量也就几十万行怎么算都不该超过几十毫秒。可实际跑起来就是慢。我第一时间用EXPLAIN看了一眼执行计划差点没被吓到order_items表那行type是ALLkey是NULLpossible_keys里明明写着idx_order_no实际却没用上。而orders表倒是正常走了主键。整个执行耗时里99%都耗在order_items的全表扫描上。这意味着数据库要把order_items几十万行全部扫一遍再逐行和orders匹配。数据少的时候还能忍等表数据涨到千万级这个查询基本就废了。更诡异的是idx_order_no索引确实存在optimizer也不是傻子放着索引不用必然有它的理由。1.2 常规三连排查后我依然没找到答案面对这种索引失效的情况我的第一反应是走一套常规排查三板斧看统计信息、看数据分布、重建索引。先执行ANALYZE TABLE order_items刷新统计信息结果没变化。再检查表数据是不是有大量重复或NULL值发现order_no基本都非空重复率也很低索引区分度完全正常。最后甚至试过ALTER TABLE order_items DROP INDEX idx_order_no之后再重新建索引执行计划依然我行我素地走全表扫描。这时候我开始意识到问题没那么简单。EXPLAIN的结果里有一个细节被我忽略了orders表主键那行显示的是typeconstkeyPRIMARY这符合预期但order_items表那行Extra里居然出现了Using where而且possible_keys和key不一致。以往经验告诉我这种情况九成九是发生了隐式转换也就是MySQL在比较时偷偷调用了CONVERT之类的函数导致索引列没法正常二分查找。于是我把目光转向字段类型和字符集。orders表的order_no定义是varchar(32)order_items表的order_no定义也是varchar(32)看起来一致。但SHOW CREATE TABLE之后问题浮出水面orders表的order_no字符集是utf8mb4排序规则是utf8mb4_0900_ai_ci。 order_items表的order_no字符集也是utf8mb4但排序规则是utf8mb4_general_ci。字符集一样排序规则不一样。就因为这个小小的差异MySQL在两个varchar字段做等值比较时认定它们不能直接比较必须在底层做一次额外的转换而这次转换直接让order_items的索引列失去了B树有序性。真正的元凶是字符集和排序规则的隐式转换只是它藏得比普通类型转换更深。2. 字符集问题的底层原理MySQL到底什么时候会偷偷做转换2.1 隐式类型转换的规则和触发场景MySQL在做比较运算时有一套自己的类型优先级规则。如果参与比较的两个值类型不同它不会直接报错而是把其中一个转换成另一个的类型。问题就出在这个转换发生在哪一列上。举个例子主键是int类型的列你用where id 123这种字符串去查MySQL会把右边的字符串123转换成数字123再和左边int列比较。因为转换发生在参数这一侧左边列还能正常走索引所以这种写法虽然不太好但通常不会导致全表扫描。但如果反过来主键是varchar类型你却用where id 123这种数字去查MySQL会认为数字的优先级更高于是它会把varchar列强制转换成数值类型。这等价于在索引列上套了一层CONVERT(id AS DOUBLE)索引在函数面前完全失效只能全表扫描。字符集之间的转换也遵循类似规则。MySQL引入了coercibility这个概念可以理解成“强制转换优先级”。数字类型的优先级最高其次是字符集相关设置。当两个字符串比较且字符集不同时MySQL会把coercibility值较低、也就是优先级更高的字符集作为目标把另一个转换过来。比如utf8mb4和utf8mb3比较utf8mb4是MySQL 8.0的默认字符集优先级更高所以utf8mb3的列会被隐式转换为utf8mb4。如果utf8mb3的列恰好是索引列索引就废了。字符集的转换涉及整个列的编码重算B树里存储的字节序列不再满足比较时的顺序关系优化器只能选择全表扫描。2.2 排序规则不一致比字符集不同更容易忽视的坑字符集不同会触发转换这是个常见认知。但很多人不知道的是哪怕字符集完全一样排序规则collation不同同样会触发隐式转换原理和字符集转换完全一致。排序规则决定了字符串在比较时的大小规则。utf8mb4_0900_ai_ci和utf8mb4_general_ci都支持utf8mb4字符集但它们的排序方式、大小写敏感度、声调处理都不一样。MySQL把它们视为两种不同的“比较上下文”做等值判断时如果两边排序规则不一致就必须先做转换再比较。这就解释了我遇到的场景orders表用的是utf8mb4_0900_ai_ci这是MySQL 8.0以后新库的默认值order_items表是历史遗留一直在用utf8mb4_general_ci。两边字符集都是utf8mb4看起来一模一样排序规则却在背后捅了一刀。用生活化的类比来说两个人都会说中文但一个用简体中文一个用繁体中文虽然能沟通但见面时需要先花时间翻译统一一下说法。这个翻译动作如果发生在服务员的身上倒没什么影响如果发生在已经排好队的顾客身上整个队伍的秩序就乱了。索引就是那个排好队的队伍。2.3 为什么主键索引也会中招很多人的概念里主键是聚簇索引是数据库的“亲儿子”怎么可能全表扫描实际上聚簇索引也没有任何特殊豁免权。只要索引列参与了隐式转换哪怕它是主键一样会失效。原因在于B树的搜索依赖键值的可比较性。主键索引的叶子节点本来按顺序排列搜索时直接按值二分定位。一旦MySQL需要先对主键列做CONVERT操作原来的存储顺序和转换后的值顺序就不再一致二分查找的前提没了只能扫描。我见过更极端的案例有人把用户ID设计成varchar类型查询时用了数字字面量主键明明就在那里执行计划却显示全表扫描。这跟字符集问题本质上是同一个道理都是在索引列上套了一层看不见的转换。区别只是转换的类型不同一个是类型转换一个是字符集和排序规则转换。所以遇到主键查询全表扫描时脑子里要绷一根弦索引失效不等于索引不存在更不等于SQL语法写错而是要怀疑是不是有隐式的转换动作发生在索引列上。字符集、排序规则、类型不一致这三者是同一种病根的不同表现形式。3. 定位这类问题的三个实战方法3.1 别只盯EXPLAIN字段组合看possible_keys与key_len很多同学看EXPLAIN只关心type和key看到key是NULL就懵了要么去重建索引要么去分析统计信息方向完全跑偏。正确的做法是看组合特征。当possible_keys里有可用的索引但key是NULL同时type变成ALL基本可以断定是发生了隐式转换。这是第一层判断。第二层判断要看key_len。如果key_len异常偏大比如varchar(32)的索引正常应该显示129字节左右结果却变成更大或者无法匹配列长度也可能是发生了拼接或转换。还可以用EXPLAIN FORMATJSON看更详细的执行细节。JSON输出里会有execution_plan相关的信息有时候能看到类似refine的阶段和转换提示。虽然没有直接告诉你“此处发生了隐式类型转换”但可以通过对比实际表结构反推出来。我在排障时会打印出EXPLAIN之后再立刻执行SHOW CREATE TABLE查看字段定义。重点核对三样东西字段类型、字符集、排序规则。只要两边字段在这三项上任何一项不一致优先级高的那一侧会保持原样优先级低的那一侧被迫转换索引失效就是板上钉钉的事。3.2 用information_schema暴力搜索不一致字段当系统里表特别多一个一个看SHOW CREATE TABLE效率太低。可以直接从information_schema里拉出所有相关表的字符集信息。SELECT table_schema, table_name, column_name, character_set_name, collation_name FROM information_schema.columns WHERE table_schema your_database AND column_name IN (order_no) ORDER BY table_name, column_name;执行之后我把orders表和order_items表的order_no字段列在同一个结果集里对比差异一目了然。一个是utf8mb4_0900_ai_ci一个是utf8mb4_general_ci。这个查询几十毫秒就出结果比肉眼盯着DDL逐行比对快得多。合适的项目里甚至可以直接查全库所有varchar字段里哪些表和业务核心字段存在排序规则不一致提前排查隐患。比如把所有order_no相关列的collation都列出来发现几种不同的排序规则混用就该列一个整改清单了。3.3 optimizer_trace日志实战解读如果对执行计划还不够放心可以开优化器追踪看看优化器在做选择时到底收到了什么信号。SET optimizer_trace enabledon; SELECT ... 慢查询的完整SQL ...; SELECT * FROM information_schema.OPTIMIZER_TRACE\G优化器trace的输出内容很庞杂我一般重点关注join_optimization里的rows_estimation和considered_execution_plans部分。如果表上有索引而优化器最终选择的计划是ALL那么在trace里常常能看到它认为索引扫描成本更高或者因为比较条件需要转换导致无法直接利用索引。trace是确认问题时非常有用的手段但日常排障不一定每次都开它因为输出太长了读起来很累。通常先用EXPLAIN和表结构对比锁定隐式转换再用trace去验证效率更高。把这个工具留给最疑难、最说不清的案例才是它的正确打开方式。4. 彻底修复方案与方案取舍4.1 改表结构统一字符集正确但要注意锁表风险定位到根因之后最彻底的修复方法是把order_items表的order_no字段统一成和orders表一致的排序规则。ALTER TABLE order_items MODIFY COLUMN order_no VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;这条SQL执行完两个表的字段就完全一致了隐式转换不再发生索引自然恢复。但这里有个非常重要的实操点不要线上执行环境直接裸跑。ALTER TABLE MODIFY COLUMN在数据量大时会造成长时间锁表和重建表几十万行可能几秒几千万行可能几分钟甚至更久业务高峰期跑了直接就是事故。我习惯的做法是分三步走第一步先在预发环境执行同样的ALTER观察耗时和锁表情况。 第二步评估业务能够接受的停写窗口。 第三步如果表太大不用原生ALTER改用pt-online-schema-change这类工具在后台以触发器的形式增量同步数据最大限度降低对线上读写的影响。另外MODIFY COLUMN之后要记得检查所有引用该字段的索引、外键、生成列是否受到影响。列定义变更有可能导致索引重建这在主从复制环境下会放大延迟需要提前观察。4.2 不改表的应急手段SQL显式转换与BINARY如果业务紧急来不及改表结构可以在SQL层面显式做转换把转换动作主动放在不影响索引的一侧。SELECT o.order_no, o.user_id, i.sku_id, i.quantity FROM orders o LEFT JOIN order_items i ON o.order_no CONVERT(i.order_no USING utf8mb4) WHERE o.order_no 202501010001234;等等这条路要仔细想清楚。如果对i.order_no做CONVERT等于还是在索引列上套了函数order_items依然会全表扫描。显式转换的方向必须反过来让转换发生在非索引列上或者发生在参数上。最理想的方式其实是把两个排序规整统一化。比如让order_items侧保持原样在orders侧的关联字段上做转换LEFT JOIN order_items i ON CONVERT(o.order_no USING utf8mb4) i.order_no这样转换发生在orders表的主键列上orders表本身只有一行匹配损失微乎其微而order_items的索引能够得到正常利用。但这种方式治标不治本写错方向只会让情况更糟。还有一个更轻量的应急写法是用BINARY关键字强制二进制比较。在某些场景下可以避开排序规则的转换LEFT JOIN order_items i ON o.order_no BINARY i.order_noBINARY会让字符串按字节逐位比较但与B树的比较语境不一定完全一致使用前一定要先EXPLAIN验证执行计划是否走了索引。不要想当然认为这条一定有效实测为准。4.3 防患于未然建库规范与上线检查修复一个故障只是把欠的债还了真正的收益来自让新账不再产生。首先是建库规范。MySQL 8.0以后默认字符集就是utf8mb4默认排序规则是utf8mb4_0900_ai_ci这个组合非常稳定新项目一律不要改。拿到一个旧库时先跑一遍信息收集看看有没有历史遗留的表还在用utf8、latin1或者其他排序规则整理清单后纳入改造计划。其次是DDL审查。在所有涉及新表、新字段的评审里把character set和collation作为必查项。我见过太多团队建表时图省事不写字符集结果默认值五花八门有的表继承库默认有的表单独指定后续联表和JOIN时就炸了。建议在所有建表语句里显式声明CREATE TABLE order_items ( order_no VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, ... ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;再次是连接层设置。应用连数据库的时候连接字符集要统一为utf8mb4。如果连接层用的字符集和表字符集不一致查询条件里的字符串也可能在比较前被转换。设置SET NAMES utf8mb4是基本操作但很多老项目的连接池配置还停留在utf8这种隐性风险在表字段全部统一之后依然存在。最后是监控。核心查询的执行计划变化是可以被监控的至少应该对慢查询日志里的全表扫描SQL保持敏感。多花五分钟看执行计划就能少当一个小时的救火队长。5. 常见问题速查与避坑指南5.1 排障速查主键查询却全表扫描的10种常见原因把这次排障过程中梳理出来的索引失效原因整理成了一张速查表。以后遇到类似问题挨个对照排除基本能覆盖大多数情况。序号可能原因识别特征处理建议1隐式类型转换varchar列与数字比较修改SQL参数类型避免在索引列上转换2字符集不一致两个表同名列charset不同统一表字段字符集3排序规则不一致charset相同但collation不同统一排序规则重点查_0900_与general_4统计信息过期数据量变化大但未ANALYZE执行ANALYZE TABLE刷新5索引失效索引列使用了函数改写SQL或建函数索引6优化器选错小表驱动成本估算异常更新统计信息必要时FORCE INDEX7数据分布倾斜大量相同值导致索引区分度低考虑复合索引或改变查询条件8类型长度不匹配两边varchar长度一致但字符集优先级不同统一字段定义9表锁或元数据锁查询阻塞执行计划异常检查INFORMATION_SCHEMA锁等待10连接字符集问题应用连接层SET NAMES与表不一致统一连接层字符集这张表的重点提醒是排障时不要第一时间怀疑优化器是傻子它大多数时候是讲道理的。执行计划出现反直觉的选择背后一定有原因而原因往往藏在你看不见的地方。5.2 字符集的坑不止数据库一个跨领域的共同痛点字符集相关的坑远远不止MySQL。最近有朋友在Visual Studio 2022里碰到编译报错提示“出现无效字符。多字节字符集必须包含一前导字节且无结尾字节”查了半天才发现是源代码文件编码和编译器默认字符集不一致中文字符串在读取时被截断成了半个字符。这个报错里的“前导字节”“结尾字节”本质上是多字节字符集编码在解析时断裂的典型表现。编程语言领域Python的str和bytes的混乱也能让无数新手中招CSV文件用Excel打开乱码八成是编码问题Redis迁移数据出现乱码也是字符集没对齐。这类问题有一个共性平时安静得像不存在一旦触发就是跨系统、跨版本的深深坑。放到数据库场景里也是一样。字符集和排序规则这种放在建表语句末尾的配置项太容易被忽略但它的影响贯穿整个查询生命周期。做技术的人可以把这句话刻在脑子里字符集是那种不出问题你永远感受不到它存在的东西但要出问题就只会出大问题。5.3 我个人的排障经验沉淀这次故障处理完我把排障顺序固化成了一个习惯EXPLAIN之后先看key和possible_keys是否一致再看Extra有没有Using where以外的异常提示然后立刻查两个参与比较字段的字符集、排序规则和类型定义三分钟内就能锁定是否隐式转换导致的索引失效。还有一个平时不太容易注意的经验是同一个字段不同表之间如果collation不一致哪怕都叫utf8mb4执行计划也会选择全表扫描这是最坑人的。因为SHOW CREATE TABLE初看几乎一样只有看到COLLATE部分才会有差异排查时要细心。最后说一个小技巧如果发现线上库存在大量排序规则不一致的字段但又不能大批量修改可以先从最核心、查询频率最高的字段开始把那些参与JOIN和WHERE条件的字段列成一个清单逐个统一。统一一个就少一个隐患并不需要一次性全库整改。字符集问题之所以会让人栽跟头是因为它把“看不见的转换”和“看得见的索引失效”结合在了一起。理解了MySQL在比较时的那层隐式转换机制再看这类执行计划就不会再觉得诡异了。主键查询还是那个主键查询全表扫描还是那个全表扫描中间隔着的只是一个你没有注意到的COLLATE而已。
返回列表