ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MySQL IN查询大数据量优化:5种可落地方案与排障指南

MySQL IN查询大数据量优化:5种可落地方案与排障指南 MySQL的IN查询一遇到大数据量就容易让人头大这个困境我太熟悉了。业务方丢过来一个任务这有一份名单几千个ID帮我查一下这些ID对应的订单都是什么状态。你不能跟业务说你少给点你也不能假装没看见慢SQL告警。没法避免就只能优化。这篇文章我想把自己在真实业务里验证过的优化思路、SQL改写手法、参数调整和排障经验完整梳理一遍给同样被这类问题困住的后端、DBA和数据开发同学做个参考。内容不挑版本MySQL 5.7和8.x基本都能用重点放在思路和可落地的操作上。我知道很多同学一看到IN查询优化第一反应是改EXISTS、改JOIN。这么说不能说错但实际业务里如果只是机械替换SQL你会发现有的场景改了更好有的场景改了反而更慢。所以这篇文章不打算只给一个标准答案而是把所有能在IN查询大数据量无法避免这个前提下真正起作用的方案都列出来并且说清楚每个方案背后的逻辑、适用边界和踩坑点你拿到手可以对号入座。如果你只是来抄一个万能优化语句这篇文章可能让你失望。但如果你想弄明白为什么你的IN查询慢、怎么判断慢在哪、以及不同体量下分别该用什么姿势去优化那这篇文章应该能帮你少走不少弯路。1. 先把问题定性IN查询大数据量为什么慢1.1 什么样的场景算无法避免的大数据量IN查询先说清楚边界。我见过很多同事把IN查询优化当成一个单纯的技术题来解上来就问是不是得换JOIN。但实际业务里IN查询的大数据量分两种一种是查询主表本来就很大另一种是IN列表本身很大。这两者的优化方向差别很大需要分开看。第一种场景比如订单表有5000万行你要查其中100个订单的状态这时候的瓶颈在于主表的扫描范围和索引命中率。只要索引设计合理100个ID走主键索引其实压力不大问题往往是很多表压根没有能让IN用好索引的结构。第二种场景更棘手运营同学给你一份10万个SKU的Excel名单让你去库存表里把这些SKU的库存都捞出来。这时候不是主表的问题是你把一个几十MB的列表塞进了SQL里MySQL的优化器在面对这么大的IN列表时很容易算不清楚账。从业务动因上看无法避免通常来自三块批量筛选与批量操作比如后台管理系统的多选过滤、批量审核、批量发货权限与名单匹配比如用户可见的资源ID集合你没法提前建表只能动态传入数据校验与对账比如拿外部系统的ID清单来业务库核对差异。这三种场景的共同点是ID清单是业务的输入条件不是数据库里可以JOIN出来的结果所以你不能跟产品说这个需求没法做只能硬生生把性能扛起来。我印象很深的一个真实案例某运营系统的商品筛选接口前端支持从第三方平台拖拽一份商品ID列表进来做库存和价格回显。列表小的时候一次拖二三十个ID完全没有问题后来运营玩出花样一次拖了5万个ID。接口直接超时数据库CPU被打到100%慢SQL日志里全是那条带超长IN的查询。这就是典型的绕不开的大数据量IN查询。1.2 性能瓶颈到底出在数据库的哪个环节很多同学一遇到慢查询就怪索引但IN查询大数据量的时候瓶颈往往不止索引一层。我一般把它拆成四个环节来看第一是优化器解析IN列表的环节。MySQL的优化器拿到一个带IN列表的SQL会把列表里的每个值都当作一个等值区间来处理。正常情况下这没问题但列表一旦变长优化器需要内存去保存这些潜在range这个内存上限由参数range_optimizer_max_mem_size控制。当IN列表大到超过这个值时优化器会直接放弃对更多索引范围的精细估算更倾向于采用全表扫描或者走一个比较差的执行计划。第二是索引扫描与回表环节。即使IN查询走了索引假设你在二级索引上命中了5000个值MySQL需要先根据索引定位到5000个二级索引条目再逐条回聚簇索引取完整数据行。这5000次回表如果落在不同的数据页上会产生大量随机IO。你可以把它想象成在一本书里翻5000个折了角的页面虽然每页都能翻到但要不停跳来跳去时间全浪费在翻页上了。第三是排序、分组、临时表环节。如果查询里还带着ORDER BY、GROUP BY、DISTINCT或者由于SQL结构触发了半连接物化优化器可能需要在内存或磁盘里建临时表。临时表不仅要拷贝数据还会带来额外的排序开销。很多人的IN查询慢不是慢在IN本身而是慢在Extra那栏里明晃晃的Using temporary和Using filesort。第四是数据传输与结果集环节。这是最容易被忽略的。如果IN列表命中了一大批数据查询结果集本身就很大比如一次返回几十万行。这时候你在慢日志里看到SQL执行了5秒其实里面有一半时间是在往客户端传输数据和查本身的关系不大。这个不搞清楚后面怎么优化都会跑偏。把这四个环节理清楚之后你手上的慢SQL才不会变成无头苍蝇。接下来我推荐的优化步骤也都是围绕这四类瓶颈展开的。2. 优化前的三个关键自查别急着改SQL2.1 先看EXPLAIN判断慢在哪一层不管网上说的方案多花哨落到你自己业务里第一步永远是跑执行计划。我见过太多人上来就按网上的建议把IN改成EXISTS结果不仅没快反而更慢就是因为跳过了这一步。你的操作很简单在慢SQL前面加EXPLAIN关键字然后重点看四列type、key、rows、Extra。type表示访问类型从好到差大概是system、const、eq_ref、ref、range、index到ALL如果你看到ALL说明做了全表扫描这就是最大的性能隐患。key指的是实际用到的索引如果为NULL说明没走索引。rows是优化器预估要扫描的行数这是一个估算值但能反映执行代价的量级。Extra里面如果出现Using temporary、Using filesort那说明还存在临时表和文件排序的开销这块是IN查询附带过来的常见问题。我建议你把这个习惯做成条件反射任何SQL改动前后都要把EXPLAIN结果保存下来做对比。拿它说事拿它验收而不是靠感觉判断好像变快了。2.2 检查索引设计IN查询到底走没走索引排查完执行计划后第二步检查索引设计。这一步最隐蔽的坑是索引明明存在优化器却不想用。为什么会这样常见原因有这么几类一是隐式类型转换。表里ID是VARCHAR类型但IN列表里传入的是INT数字MySQL会在比对时把字段转成数字再匹配函数作用在索引列上索引自然失效。二是字符集不一致。关联字段或者IN字段本身如果在不同字符集下做隐式转换也容易让索引失效。三是联合索引的最左前缀原则。如果表上有联合索引(a,b)但你只用b做IN查询根据最左前缀原则这个索引无法被有效利用。这时的IN查询就算列表只有几十个值也可能老老实实走全表扫描。另外还有一层细节容易被忽略就算IN查询走了索引是否走了覆盖索引也会影响巨大。举个例子你有张订单表索引是(user_id, status)联合索引。查询SELECT user_id, status FROM orders WHERE user_id IN (...)时索引字段本身就能覆盖所有要返回的列不需要回表这叫覆盖索引扫描。但如果同样的WHERE条件你SELECT了amount字段amount不在索引里每次命中都要回表一次。列表越大回表次数越多性能差距就越明显。2.3 分清查询慢和传输慢结果集大小先评估这是我觉得最具实战价值的一个自查点。有一次我被叫去救火说有一条IN查询行数巨大、耗时严重。我先跑了一下SELECT COUNT(*)验证命中行数发现居然有30万行同时接口里又用了List来接收全部结果。这时候我判断真正的瓶颈根本不是SQL而是把30万行数据传输到应用服务器再序列化成JSON响应给前端的那一整套流程。SQL本身哪怕优化到极致30万行的传输开销也省不掉。所以动手优化前先估算结果集到底有多大。你可以用SELECT COUNT(*)在没有ORDER BY的情况下去掉分页限制验证一下。返回行数如果超过几千几万就要先考虑业务是否真的需要一次全部返回。很多时候你遇到的问题是接口设计和大批量数据传输造成的三分天灾、七分人祸这时候光改SQL是治标不治本。3. 分场景实测5种可落地的IN查询优化方案3.1 场景一能用JOIN替代IN子查询但要先想清楚语义当IN后面跟的不是硬编码的ID列表而是一个子查询时你可以尝试把它改写成JOIN。这是最常见、也最容易验证的优化手段。举个例子-- 原始写法 SELECT o.* FROM orders o WHERE o.user_id IN (SELECT id FROM users WHERE vip_level 3); -- JOIN改写 SELECT o.* FROM orders o JOIN users u ON o.user_id u.id WHERE u.vip_level 3;改写的核心逻辑是把先查出VIP用户ID集合再在订单表里匹配变成两个表直接通过索引做连接。这时候MySQL可以选择驱动表和被驱动表配合users表上的主键索引执行效率会好很多。但有两个坑必须提醒。第一个坑是去重如果users表与orders表是一对多关系JOIN的结果集会比IN子查询多出重复行必须加DISTINCT或者确保关联字段唯一否则业务数据就错了。第二个坑是子查询带聚合的情况比如WHERE id IN (SELECT user_id FROM orders GROUP BY user_id HAVING COUNT(*) 10)你很难用普通JOIN保持同样的语义。这种场景下如果你非要改写通常会改造中间表或者临时表。另外需要说明一点MySQL 5.6之后引入了半连接优化某些IN子查询和EXISTS子查询本身就会被优化器自动转为JOIN执行。所以别迷信转JOIN一定更快一切用EXPLAIN说话。我实测下来JOIN改写主要对优化器选择不稳定的场景效果明显如果你当前执行计划的type已经直接走了ref或eq_ref那改动意义不大。3.2 场景二超长IN列表就分批查最简单也最稳妥所谓分批查就是把几万个ID的IN列表拆成几十个几百个ID的小清单循环执行多次查询再由应用层把结果合并。这个方法优点是逻辑简单、改动量小、数据库压力均匀缺点是会产生多次请求、总耗时可能变长。那每批多少合适我的经验是500到1000个区间具体需要压测。举个例子一次用户画像查询IN列表里有2万个设备ID最初一次性执行执行了6秒多。改成分批1000个ID一次查询循环20次以后单批查询耗时稳定在200毫秒以内虽然总耗时变到了4秒左右但数据库不再被一条大SQL锁死其他业务的抖动也消失了。对于后台异步任务来说这个代价完全可以接受。分批时的应用层逻辑我建议写成这样的思路从内存里拿到完整ID列表后用固定步长切分为子列表然后串行或限制并发地执行单批查询最后把结果按ID映射关系合并。为了防止某一批失败导致整个任务重跑最好把批号和查询状态记录下来支持断点续跑。这里必须强调一个血泪教训分批查询如果业务上要求结果集一致性快照比如查询过程中对方同时在做状态变更那分批拿到的数据可能是不同时间点的混合状态。解决思路有两种要么在事务内用可重复读隔离级别跑完整批查询要么接受最终一致按业务容忍度来。对于实时性要求高的地方你甚至可以考虑直接上临时表方案见下一个场景。3.3 场景三把IN列表塞进临时表关联查询取代长列表如果你遇到的IN列表不是几千而是几万、几十万分批循环也会显得吃力。这时候我推荐换一种思路不要试图用一串ID去做条件而是把这些ID先搞成一张临时表再和业务表做JOIN。完整套路是这样的。第一步创建一张临时表结构很简单只有一个ID主键字段。第二步用程序把完整的ID列表批量INSERT到这张临时表里这里可以用分批插入单次几百上千条的VALUES语法即可。第三步把原来的IN查询改成JOIN临时表条件变成临时表的ID等于业务表的ID。最后用完临时表后DROP掉。-- 创建临时表 CREATE TEMPORARY TABLE tmp_ids (id BIGINT PRIMARY KEY) ENGINEInnoDB; -- 批量插入ID INSERT INTO tmp_ids (id) VALUES (1001), (1002), (1003); -- 原来的IN查询改写成JOIN SELECT o.* FROM orders o JOIN tmp_ids t ON o.user_id t.id;这个方案的优势非常明显临时表上的主键索引可以被JOIN充分利用而且临时表数据量即便有几十万行从InnoDB层面看也只是一张可以高效关联的索引表优化器不再需要去解析一个超长的IN列表也就绕开了range优化器内存不足导致的执行计划退化问题。要注意的是临时表也有内存临时表和磁盘临时表的区别。默认情况下MySQL会优先使用内存临时表但会受到tmp_table_size和max_heap_table_size两个参数影响。如果临时表数据量很大超过阈值会被自动转成磁盘临时表一旦转换成磁盘上的MyISAM或InnoDB临时表I/O开销会明显上升。所以用这个方案时一方面把临时表的字段压到最精简另一方面也要监控临时表是否落盘。我踩过的一次坑就是临时表插入了10万行之后没注意它已经转成磁盘临时表JOIN性能直接崩了。3.4 场景四EXISTS在特定场景下的优势别机械替换EXISTS和IN是很多文章喜欢对比的老话题。必须承认在现代MySQL版本中优化器很多时候会把IN子查询自动改写成半连接EXISTS不再拥有绝对优势。但在一些特殊场景下手写EXISTS仍然有正面意义。什么场景值得试主要看子查询和主查询的数据量对比。如果外层表行数不多但子查询要扫描的表非常巨大EXISTS可以借助外层表的每一行去驱动子查询的索引查找做到边查边停。而IN子查询的逻辑是先把子查询的结果集全部物化出来再和外层表匹配如果子查询结果集本身就大这一步的成本就高。-- 原始写法 SELECT * FROM orders o WHERE o.user_id IN (SELECT user_id FROM user_login_log WHERE login_date 2024-01-01); -- EXISTS改写 SELECT * FROM orders o WHERE EXISTS ( SELECT 1 FROM user_login_log l WHERE l.user_id o.user_id AND l.login_date 2024-01-01 );改写后MySQL会针对orders表里每一行去user_login_log表中通过user_id索引查找存在性。如果orders表很小、user_login_log表很大这种方式的资源优势就会比较明显。但如果你外层表本身也是千万级EXISTS的逐行驱动可能未必占便宜最后还是得过EXPLAIN这一关。还有一个细节容易被忽略IN条件里如果子查询结果包含NULL值IN的匹配结果会和直觉不同返回的结果会排除匹配不上NULL的情况而EXISTS的NULL处理逻辑没有这个坑。所以当子查询来源字段可能为NULL时改EXISTS并加上对应的NULL过滤条件也是规避逻辑坑的一种方式。3.5 场景五从应用层设计层面釜底抽薪如果你的接口已经被大数据量IN查询折磨很久而且查询频率还很高那就不能只在SQL层面打补丁了。很多情况下最有效的优化是在应用层改变数据获取方式把实时算变成离线算或缓存算。第一个思路是结果缓存。对于业务上允许短暂延迟的数据比如商品价格区间、库存状态可以把IN查询的结果按ID做缓存过期时间视业务容忍度而定。后续请求里如果大部分ID都在缓存里命中真正打到数据库的IN列表长度就大幅缩小。这个方案在重复查询同一批ID的高频场景下收益特别大。第二个思路是预聚合与中间表。如果你能提前知道名单的大概范围或者可以通过定时任务提前把结果计算好比如按用户ID-最新订单状态建一张汇总表那么实时IN查询直接查汇总表就行数据量小了好几个数量级。这在报表和数据核对场景里非常好用本质上是用空间换时间。第三个思路是给前端接口加批量ID上限约束。听起来有点粗暴但真的有效。比如一次最多传500个ID超过就要求分批调用或者下载文件。很多无法避免的大数据量IN查询其实是产品设计偷懒把数据库当内存用给前端一个强约束往往能倒逼更合理的交互流程。4. 让IN查询跑得更快的关键细节索引、参数与执行计划4.1 联合索引与覆盖索引的实战配置这部分我想拿出一个具体的表结构来做示例帮助你把前面的理论落到实际建表里。假设订单表场景如下CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, status TINYINT NOT NULL, amount DECIMAL(12,2) NOT NULL, created_at DATETIME NOT NULL, KEY idx_user_status (user_id, status) ) ENGINEInnoDB;如果业务经常需要按user_id列表查status和amount那么索引idx_user_status只能完全覆盖USER_ID和STATUS两个字段。当你执行SELECT user_id, status FROM orders WHERE user_id IN (...)时MySQL可以通过idx_user_status建立覆盖索引扫描不需要回表效率最高。但如果你把amount也放进SELECTamount不在索引里那就必然回表每条命中都会多一次随机IO。这里我给你的建议是优先确认你的SELECT列表能不能被索引完全覆盖。如果业务只需要返回ID和状态就坚决把那些大字段从查询里摘出去。如果业务确实需要返回很多字段那你也可以尝试把高频字段补进联合索引中比如KEY idx_user_status_amount(user_id, status, amount)让查询走覆盖索引。当然加索引不是免费的写入会变慢磁盘占用会变大这个需要权衡。另外MySQL的索引下推Index Condition Pushdown在IN查询里也是一个隐藏加分项。简单理解就是MySQL把WHERE里的部分条件尽可能地下推到存储引擎层用二级索引做初步过滤减少回表次数。这听着很美好但不是万能的需要你的索引列能覆盖这些条件下推到引擎层。所以再次印证不要上来就套方案先看清现有索引和查询的关系。4.2 三个跟IN查询直接相关的参数调优除了SQL和索引MySQL还有三个参数在IN查询大数据量时值得调整。它们都是改参数就能生效的但都别在生产环境直接动先测试。第一个是range_optimizer_max_mem_size。正如开头提到的IN列表太长时优化器会放弃精细的range估算这个阈值就由它决定。MySQL 8.0默认是4194304字节也就是8MB但5.7某些版本默认只要128KB所以会发生同一个IN查询在不同版本表现差异巨大的情况。如果你遇到IN列表几千个ID却走了全表扫描且索引没问题可以尝试调大这个值比如调到16MB让优化器重新启用range访问方式。SHOW VARIABLES LIKE range_optimizer_max_mem_size; SET SESSION range_optimizer_max_mem_size 16777216;注意这个参数是会话级的可以在测试库先验证效果确认有效再考虑写入配置文件全局调整。第二个是max_allowed_packet。这个参数决定了客户端能发送给服务端的最大SQL包大小。当IN列表特别长、SQL文本特别大时你可能会收到Got a packet bigger than max_allowed_packet bytes的报错。默认值在4MB或64MB根据版本不同而不同。解决办法确实可以调大这个值但我要劝你先想想是不是又往SQL里硬塞了一堆本可以不塞的数据。第三个是optimizer_switch里的semijoin相关开关。MySQL的IN子查询会触发半连接优化转向去重和物化策略。大多数情况下保持默认就好但如果你怀疑优化器对某个子查询的生成了不合理的物化决策可以通过临时关闭某个半连接策略来验证SET SESSION optimizer_switch semijoinoff;这属于调试手段确认问题后再决定是否要长期调整千万不要一上来就关闭优化器功能。4.3 用EXPLAIN对比优化前后效果为了让上面的优化不变成玄学我把一次真实优化前后的执行计划放在一起帮你理解怎么验收。当时有一条按1万个ID查订单明细的SQL优化方式是临时表JOIN。优化前核心执行计划大致是type等于ALLkey为NULLrows显示扫描了一大片全表行Extra里出现Using where。这说明MySQL根本没有用上索引全表过滤所有行慢是必然的。优化后换成临时表JOIN执行计划里type变成了eq_ref或refkey显示用上了主键或临时表主键索引rows缩减到每个值只扫描极少的行Extra里的Using temporary和Using filesort也消失了。EXPLAIN SELECT o.* FROM orders o JOIN tmp_ids t ON o.user_id t.id\G你看到的变化本质上就是扫描行数从全表行数降到了命中个数访问代价自然就降下来了。但是一定要记住执行计划只是预估最终还是要看真实响应时间。我在测试时就会把优化前后的SQL各跑十遍取中位数和P95用统计说话而不是凭感觉拍板。5. 常见问题与排查技巧实录5.1 IN列表只有几百个ID为什么还是走了全表扫描这个问题我一个月能遇到好几次每次排查路径都很像。第一查字段类型确认IN列表里的值和表字段类型一致尤其注意字符串数字和数字类型混用。第二查字符集两张表或者字段的字符集如果不一致隐式转换也会导致索引失效可以通过SHOW CREATE TABLE比对。第三查优化器内存用上文提到的range_optimizer_max_mem_size在会话级调大如果执行计划从ALL变成range那问题就出在这个参数。第四查统计信息如果InnoDB的统计信息过旧优化器对行数的估算可能严重失真可以执行ANALYZE TABLE强制更新统计信息。我遇到过最刁钻的一个案例表总共就1万行但优化器就是走全表扫描怎么调整索引都没用。查了很久才发现是IN列表里混了一个NULL值导致优化器对NULL的处理逻辑产生了奇怪的估算扩展出的range路径反而被认为代价更高。把NULL过滤掉后索引立刻走起来了。5.2 分批IN查询时怎么处理数据不一致和事务边界分批查询最大的问题是数据一致性。如果查询过程中其他会话在修改数据你第一批查到的是旧数据第二批查到的是新数据最后合并的结果集就是一个时间混合体。这个问题有两个常用解法。如果业务对一致性要求严就把整个分批查询放到一个事务里用可重复读隔离级别。在InnoDB的REPEATABLE READ隔离级别下事务内所有普通查询看到的是同一快照因此多次分批查询的视角是统一的。代价是长事务可能导致Undo Log膨胀所以批次不能太慢。如果业务能容忍最终一致那就别开长事务但要注意查询结果有重复或遗漏的风险。如果你是基于主键ID做范围分批比如WHERE id 10000 AND id 20000这种而不是IN列表做分批那在高并发写入下依然可能出现数据移动到其他范围的情况这点必须提前和业务沟通好。5.3 结果集巨大从SQL优化到数据导出有次线上调优我把IN查询从6秒优化到了1秒但接口总耗时还是8秒一查发现那1秒是查询时间其余全耗在把30万行数据组织成JSON上了。所以当结果集本身很大时要考虑的不只是SQL而是数据出口。这时有几种方向如果只是导出用SELECT INTO OUTFILE把数据在数据库侧直接落盘应用层只做文件搬运。如果必须走API建议改成游标式流式读取在Java里用Statement的fetchSize设置合理值在Python里用SSDictCursor等方式分批从服务端拿数据应用层再按流处理。如果业务只是要汇总结果那也别傻傻查明细尽量在SQL层面用聚合函数把结果压缩掉。5.4 独家避坑经验几个让我印象深刻的教训第一个教训是DELETE/UPDATE与IN组合时要格外小心。我有一次执行UPDATE orders SET status2 WHERE id IN (几万个ID)结果因为IN列表巨大优化器选择了不走索引的路径直接把几个核心表全锁了。后来我养成的习惯是对大列表的UPDATE和DELETE先JOIN临时表并且在事务里逐批执行控制影响行数避免一把锁太大。第二个教训是临时表JOIN不加索引等于自杀。最早用临时表方案时我建了临时表却没加主键结果JOIN的时候临时表被全表扫描了一次整体比原来更慢。所以临时表一定要根据JOIN字段加索引这步不能省。第三个教训是监控慢SQL要带上下文。我们在慢查询日志里定位到一条超大IN查询但参数值被日志截断了根本不知道具体是哪些ID。后来我给应用层加了IN列表长度和SHA值的日志配合全链路ID再排查这种问题就快了很多。所以说优化不只是改SQL治理机制也要跟上。我个人在实际操作中的体会是MySQL的IN查询大数据量优化没有银弹。你需要先把慢的原因定位准确再选择对应的方案JOIN替代适合子查询场景分批和临时表适合硬编码长列表EXISTS调整适合特定数据分布应用层改造适合高频重复查询。最后再分享一个很朴素的技巧日常把慢查询日志配上执行计划和锁等待分析一旦线上出现类似问题你至少有据可查、有方向可试而不是全凭感觉赌一把。
返回列表