ARTICLE DETAIL

资讯详情

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

数据库慢查询排查指南:从SQL写法到索引与锁的完整解析

数据库慢查询排查指南:从SQL写法到索引与锁的完整解析 我做了十来年数据库相关工作被问得最多的一个问题不是“数据库怎么设计”而是“这条SQL明明加了索引为什么还是这么慢”说实话数据库查询速度的影响因素太多了小到一条SQL的写法大到服务器磁盘类型任何一个环节出问题都会让你精心设计的系统在压测那一刻原形毕露。下面我会从查询的完整执行链路讲起把SQL写法、索引、统计信息、锁与并发、配置与硬件这些真正影响查询速度的因素逐个拆开附上我平时排查慢查询的具体步骤和命令希望能给正在被慢查询折磨的开发、DBA和运维朋友一条清晰的排查路线。无论你是刚接触数据库的后端新人还是已经在生产环境摸爬滚打多年的老手这篇文章都值得从头到尾看一眼。新人能借此建立完整的排查框架不用再眉毛胡子一把抓老手则可以对照检查自己的SOP有没有漏掉盲区。毕竟查询性能问题从来不是单一原因它是一门需要全局视角的“组合题”。1. 查询的完整执行链路先搞懂一条SQL到底经历了什么1.1 一条SQL从客户端到存储引擎的完整旅程很多人排查慢查询喜欢直接盯着索引看这本身没错但如果连一条查询在数据库内部怎么流转都不清楚很容易被表象带偏。以MySQL为例一条SQL从客户端发出到最终返回结果集至少要经过连接器、分析器、优化器、执行器这四关最后才触达存储引擎读取数据。连接器负责建立会话校验账号密码并获取权限如果你用的是连接池这一步会被池化复用这是后话。分析器做词法和语法解析把SQL拆分成可识别的语法树任何语法错误在这一步就会暴露。真正决定查询速度的关键在优化器优化器会根据SQL的结构、表的统计信息、索引分布情况生成一个它认为成本最低的执行计划。执行器拿到计划后逐行调用存储引擎接口去取数据。理解了这条链路你就会明白一个非常重要的结论查询速度的“判决”在优化器而不是你写SQL时的直觉。同一句话换个写法优化器生成的执行计划可能完全不同性能差异可能是几十倍。这个认知是整个排查体系的地基后面所有章节都在围绕它展开。1.2 执行计划是查询速度的真正“体检报告”判断一条SQL到底慢在哪第一步永远是看执行计划而不是猜。MySQL里用EXPLAINPostgreSQL用EXPLAIN (ANALYZE, BUFFERS)Oracle用DBMS_XPLAN。这些命令的输出就是优化器给出的“最终决策”包括它选择用哪张表驱动、走哪个索引、预估处理多少行数据。看懂执行计划的核心就几个字段我把MySQL的常用项整理成了表格字段含义需要警惕的值type访问类型出现ALL全表扫描要高度警惕key实际使用的索引NULL表示没用上索引rows预估扫描行数与真实行数偏差过大说明统计信息有问题filtered过滤比例数值越低说明筛选效率越差Extra额外信息出现Using filesort或Using temporary往往暗示排序或临时表开销举个真实例子一条订单查询用户最近订单的SQL如果type是ALL、rows显示几十万行说明优化器放弃了所有索引选择全表扫描。这时候哪怕你明明建了索引也要回头查是不是条件写法导致索引失效是不是统计信息过期让优化器误判EXPLAIN不会直接告诉你“为什么慢”但它会精准告诉你“慢在哪一环”这是所有后续排查动作的起点。2. SQL写法大多数慢查询的死因不在索引而在写法2.1 隐式类型转换与函数处理索引失效的隐形杀手很多开发朋友找我排查慢查询第一句话就是“我加了索引啊”。结果我一看SQL十有八九问题出在写法上索引根本没被用上。最常见的坑是隐式类型转换。比如用户表里的mobile是varchar类型查询条件写成WHERE mobile 13812345678传入的是一个数字。MySQL在比较时会把字符串类型的列转换成数字去比对这一转换导致索引列被函数处理索引随即失效走全表扫描。正确的写法是WHERE mobile 13812345678保持类型一致。类似的坑还有在索引列上做函数运算比如WHERE DATE(create_time) 2024-01-01这会让create_time上的索引完全失效。我的建议是改成范围查询WHERE create_time 2024-01-01 AND create_time 2024-01-02。范围条件能走索引函数条件不能这两者性能差距在生产环境往往是灾难级的。还有一类容易被忽略的隐性问题是字符集不一致。早期系统里utf8和utf8mb4混用的情况很常见两个表join时如果关联字段的字符集不同MySQL又没法直接比较就会在关联字段上做隐式转换索引失效。排查这类问题看执行计划里的Extra列往往会提示Using where但不会明说“转换”需要你比对两张表的字符集设置。2.2 连接查询与子查询姿势不对性能翻倍下降连表查询是慢SQL的重灾区尤其是那种七八张表join在一起的大查询。优化器处理join时最核心的原则是“小表驱动大表”也就是用小结果集作为驱动表再去大表中匹配。如果写反了大表被全量扫描后又拿去小表匹配代价是成倍的。更麻烦的是join字段如果没走索引每一条驱动表记录都要去被驱动表里做全表扫描这种场景一旦数据量上来基本就是死局。所以我有几个坚持了很多年的习惯join的字段强制要求类型一致、字符集一致能用inner join就别用outer join外连接会让优化器可选的执行路径变少条件能下推就下推尽量在join前缩小每个表的数据集别把过滤逻辑全压在where最后。碰上NOT IN子查询我通常建议改成LEFT JOIN加IS NULL或者NOT EXISTS因为子查询在某些版本下会产生临时表拖慢整体速度。另外特别提醒一句SELECT *能不用就不用。虽然它不是查询变慢的根因但当你只是需要其中三列时SELECT *会带来大量无效的IO和网络传输尤其是有text、blob这类大字段的表格伤害非常明显。生产环境的ORM模型也建议只映射需要的字段宁可多写几列也别贪省事。2.3 深分页为什么越翻越慢分页也是一种非常典型的SQL性能陷阱。前端要查第10000页每页10条很多人的第一反应是写LIMIT 100000, 10。这句SQL看着简单但数据库需要先扫描并丢弃前100000行然后才取那10条返回。扫描的行数随着页码越深呈线性增长查询自然越来越慢。我常用的优化方案是“延迟关联”先只查主键拿到本页需要的主键集合后再用主键去关联原表取完整行。假设原表有50万行延迟关联写法大概是先SELECT id FROM orders WHERE ... ORDER BY id LIMIT 100000, 10然后基于这10个id再去原表关联查询。因为第一步只查索引列和主键扫描成本很低第二步走主键查找速度极快整体耗时可以从秒级降到毫秒级。还有一个更彻底的思路是游标分页也就是把LIMIT偏移改成基于上次查询结果的位置条件比如WHERE id 100000 ORDER BY id LIMIT 10。这种方式非常适合数据不断增长的场景翻页再深也不会出现性能退化只是前端交互逻辑需要微调。3. 索引设计不是越多越好也不是有了就能快3.1 联合索引与最左前缀原则索引设计的核心不是“建了就行”而是“建得对”。联合索引是最常见的误用点明明建了(a, b, c)三列联合索引查询却是WHERE b 1 AND c 2绕过了最左前缀的a索引自然用不上。这不是索引失效而是你的查询条件没有按联合索引的列序去命中这是两回事要分辨清楚。联合索引的列顺序怎么定我的经验排序是等值条件的列放前面范围条件的列放后面区分度高的列优先高频使用的查询条件优先。比如订单表经常按user_id和create_time查联合索引应该建( user_id, create_time )。为什么create_time放后面因为范围查询一旦出现后续的列很难继续用上索引。如果你把create_time放前面user_id等值过滤的效果就会被范围条件打断扫描范围明显变大。还要特别注意“冗余索引”问题。有些同事每看到一个查询需求就加一个索引久而久之一个表上十几个索引写入压力大增不说优化器选索引时也可能犯迷糊。我通常建议用pt-duplicate-key-checker这类工具定期扫描冗余索引联合索引(a, b)已经存在的情况下单独的(a)索引往往就是多余的可以安全删除。3.2 覆盖索引让回表消失回表是另一个高频性能瓶颈。innodb的二级索引叶子节点存的是主键值如果你查的列不在索引里数据库就得拿着主键再回聚簇索引查一次完整行。每回一次表就是一次随机IO行数一多性能立刻崩坏。而覆盖索引的意思是查询所需的所有列都包含在索引中数据库只扫索引就能拿到结果完全不需要回表。举个例子订单表有索引(user_id, status, create_time)你的查询是SELECT status, create_time FROM orders WHERE user_id 1024这里status和create_time都在索引里执行计划会显示Using index查询开销极小。如果你的查询里多了一个order_amount字段而它不在索引里那就得回表了性能明显打折。所以设计索引时不仅要想where条件还要想select了哪些列把高频查询的列塞进索引是个事半功倍的优化手段。3.3 统计信息过期索引没问题问题在“数据库的判断”这类问题最隐蔽我遇到好几次SQL写法没错索引也建了但执行计划就是选了全表扫描。原因通常是统计信息过期了优化器拿着旧数据估算以为全表扫描成本低于走索引。MySQL里表数据变化超过一定比例时统计信息会异步更新但大量删除或批量导入后更新可能没跟上。解决方法是执行ANALYZE TABLEPostgreSQL对应的是VACUUM ANALYZE。跑完之后再看执行计划往往立刻就正常了。MySQL 8.0还引入了直方图统计用于非索引列的分布估算。如果某列数据分布严重不均比如一个值占了90%的行而优化器不知道这个分布就可能选错索引。给这类列手动添加直方图统计能让优化器的判断更贴合实际。注意直方图不是索引它不会直接加速查询但能让优化器选出正确的执行路径间接价值非常大。3.4 视图到底能不能加快查询速度先说结论搜索“视图能不能加快查询速度”的人非常多这确实是个经典误区。普通视图本质上只是一条“命名的SQL”建视图时数据库不会帮你去执行或者缓存任何数据每次查询视图时数据库都会重新展开视图定义的SQL去执行一遍。所以普通视图既不会让查询变快也不会让查询变慢它只是简化了SQL书写提供了一层逻辑封装。真正能加速的是物化视图它会把查询结果实际落盘存储查询时直接读物化后的数据。PostgreSQL原生支持MATERIALIZED VIEWOracle也有成熟机制MySQL原生不支持物化视图8.0版本依然没有社区通常用汇总表或者外部缓存代替。如果你的报表场景是对几百万行做聚合而聚合结果变化不频繁物化视图能把这个聚合查询的耗时从秒级降到毫秒级代价是数据新鲜度打折需要定期刷新。所以在排查慢查询时如果看到有人用视图包了一层慢SQL别指望它自己变快核心还是去优化视图底层那条SQL。视图是“整理代码”的工具不是“优化性能”的工具这个认知一定要建立起来。4. 并发与锁单条SQL很快一压测就卡死4.1 锁等待慢查询日志里看不见的凶手有一种情况非常考验排查经验单条SQL手动执行毫秒级返回但一到压测或者高并发场景就大量超时。这时候慢查询日志往往查不出问题因为它记录的只是SQL真正执行的时间而锁等待时间在某些配置下是不计入SQL执行耗时的。换句话说你看到的“慢”背后其实是“等锁等到天荒地老”。MySQL InnoDB默认的行锁包括共享锁和排他锁写操作之间互斥读写之间也互斥。两个事务同时更新同一行后到的那个就得排队等待。如果前面的长事务迟迟不提交后面排队的会话越积越多整个系统的吞吐会瞬间崩溃。更麻烦的是间隙锁和next-key lock它们为了解决幻读问题会在索引间隙加锁等值查询一个不存在的记录时可能锁住一小段范围两个事务互相覆盖对方的间隙就形成死锁。排查锁等待我最常用的手段是先看information_schema.innodb_trx定位当前有哪些事务处于运行状态、跑了多久、持有哪些锁。如果发现某个事务已经打开数分钟还没提交顺手把它的完整SQL拿出来基本就是元凶。这类问题在OLTP系统里非常常见根因往往就是代码里事务开启后执行了外部接口调用或复杂计算锁一直攥着不放。我的建议是事务尽量短小精悍远程RPC调用坚决不能放在事务内。4.2 死锁经典案例与现场还原死锁本质上是两个或多个事务在互相等待对方持有的锁谁都不让谁。最经典的场景就是两个事务以相反顺序更新同一批数据。事务A先UPDATE t SET ... WHERE id1再UPDATE t SET ... WHERE id2事务B反着来先更新id2再更新id1。当A拿到id1的锁B拿到id2的锁两边都想继续要对方的锁就僵住了。InnoDB的死锁检测机制默认开启检测到死锁后会回滚其中一个事务让另一个继续执行。但这不是说你可以不管死锁因为被回滚的那一方会收到异常如果应用层没有正确处理重试机制用户就会看到一个报错甚至失败。排查死锁时MySQL里执行SHOW ENGINE INNODB STATUS会打印出最近一次死锁的详细信息包括涉及的事务、持有的锁、等待的锁、死锁发生的SQL语句。那一段日志我建议每个DBA都熟读几遍读懂了基本就能定位到具体代码逻辑。预防死锁最有效的办法就是让所有事务都按同一顺序访问资源从代码层面统一约定更新顺序。另一个辅助手段是减小事务粒度把持锁时间压到最短。生产环境还可以考虑适当调大innodb_lock_wait_timeout避免大量会话同时陷入等待后瞬间爆炸但别把超时调得太大否则会让用户长时间“挂起”体验更糟。4.3 连接池连接数不是越大越好连接数是数据库并发场景里另一根隐形铁丝。很多团队一遇到压测性能差第一反应是调大连接池上限从50调到500结果数据库直接被拖垮。创建一条MySQL连接本身就需要完成TCP握手、权限校验、上下文初始化过程很重。这也是为什么一定要用连接池的原因——复用连接能省掉这些重复开销。但连接池不是越大越好。每个空闲连接都会占用数据库的服务端内存和资源连接数过多反而容易造成上下文切换频繁CPU大量消耗在线程调度上真正的SQL执行能力反而下降。HikariCP这类连接池厂商给出的经验值是核心数乘以2再加有效磁盘数比如一台4核机器配合普通SSD默认12左右是比较合理的起点。如果你的系统里大量SQL是批量操作或复杂聚合连接数还要再往低调让每个连接能干更重的活。我记得有一次线上事故应用侧连接池配了200数据库max_connections配了500压测刚到50并发就出现大量连接超时。后来把连接池降到40单连接处理能力反而上去了整体吞吐提升了一倍。调连接池一定要结合压测数据看曲线而不是蛮调数字。5. 环境配置与硬件别让数据库“小马拉大车”5.1 内存缓冲池最重要的一个参数当SQL本身没问题、索引也对但查询响应总是不稳定这时候该把目光从数据库内部移到外围环境了。MySQL里最重要的性能参数就是innodb_buffer_pool_size它决定InnoDB能把多少热数据缓存在内存中。如果缓冲池太小每次查询都要从磁盘读页哪怕索引命中磁盘随机IO的延迟也会让查询速度很难看。经验值上一台专跑MySQL的服务器缓冲池可以设置到物理内存的70%左右剩下的留给操作系统页缓存和日常开销。PG对应的参数是shared_buffers建议值略有差异一般不超过内存的25%因为PG还有大量page cache依赖操作系统管理两者机制不同不要照搬MySQL的经验。怎么判断缓冲池够不够MySQL里看SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_read%计算读磁盘次数占总读次数的比例。如果这个命中率长期低于95%说明内存吃紧要么扩容要么优化查询减少扫描数据量。我见过太多案例一条毫秒级SQL在内存充足时毫无压力换到低配环境就变成秒级根因就是缓冲池覆盖不了热数据每一个查询都往下打磁盘。5.2 磁盘类型与刷盘策略随机IO是查询延迟的隐形杀手磁盘对查询速度的影响最直观也最容易被低估。机械硬盘的随机IOPS通常在100到200之间普通SSD能到几万NVMe更是几十万。数据库的查询大量依赖随机读尤其是回表和索引跳转HDD在这种场景下几乎无解。我常说的一句话是如果预算只够做一项硬件升级优先把机械盘换成SSD收益比加内存还明显。除了磁盘本身刷盘策略也直接决定写入性能而写入性能又会反噬读取体验。MySQL里innodb_flush_log_at_trx_commit和sync_binlog这两个参数一个控制日志缓冲刷新频率一个控制binlog同步策略。都设为1最安全但每次提交都要落盘写入性能会受限设为2或者0性能上去了崩溃时可能丢数据。这是典型的CAP取舍OLTP高并发系统要结合业务允许的数据丢失窗口去选不能无脑追求性能。另外提一下文件系统层面的优化比如MySQL数据目录所在磁盘不要和操作系统日志、swap放在同一块盘避免IO争抢。生产环境我用fio测试磁盘的随机读写性能可以快速判断瓶颈是不是在存储层这个方法比凭感觉判断靠谱得多。5.3 场景化数据库选型不同数据模型别用一个套路查询速度也跟数据库选型强相关很多时候不是你的SQL写得不好是选错了引擎。传统关系型数据库MySQL、PostgreSQL、Oracle在通用OLTP事务场景下都是可靠选择国产数据库如人大金仓、达梦也兼容主流协议迁移成本可控。但如果你的业务是时序数据比如工业设备传感器数据、车联网轨迹数据用关系型数据库硬扛海量写入和海量范围查询往往事倍功半。时序场景我比较推荐TDengine这类专用时序数据库它针对时间戳维度做了极强的存储和查询优化批量写入通过参数化绑定接口可以极大提升写入效率。普通关系库处理千万级时间序列点的聚合可能要数秒时序库毫秒级就能返回。选型时不要纠结“哪个数据库更快”先想清楚你的数据模型是行式、列式、文档、KV还是时序每一种模型都有它最擅长的查询模式选对了查询速度问题就解决了一半。6. 慢查询监控与诊断建立自己的排查SOP6.1 慢查询日志与全量SQL分析排查体系里最重要的一环是常态化开启慢查询日志。很多团队是出了问题才临时去开其实慢查询日志应该默认开启并且配置合理的阈值。MySQL的设置我通常这样配slow_query_log 1 slow_query_log_file /var/log/mysql/slow-query.log long_query_time 1 log_queries_not_using_indexes 1long_query_time设为1秒意味着超过1秒的SQL都会被记录下来配合log_queries_not_using_indexes还能捕捉到那些没走索引的“潜在慢SQL”。日志文件会越来越大我习惯用Percona Toolkit里的pt-query-digest做离线分析把慢日志里的SQL归类聚合按总耗时排序一眼就能看出哪些SQL是真正的“流量杀手”。PostgreSQL里我一般用pg_stat_statements扩展它可以统计每条SQL的总执行时间、调用次数、平均耗时配合视图查询找出TOP 10最耗时的SQL比翻日志高效得多。开启方式是在postgresql.conf里配置shared_preload_libraries pg_stat_statements然后创建扩展。这个扩展对生产系统的性能影响非常小强烈建议开启。6.2 动态性能视图与统一监控入口慢日志只能定位到“哪些SQL慢”要定位“为什么慢”还需要结合数据库的运行时状态。MySQL的performance_schema提供了大量性能指标比如锁等待统计、IO等待统计、语句执行明细关键时刻能救命。Oracle环境我习惯用EMCCOracle Enterprise Manager Cloud Control统一监控多个实例把报警、SQL分析、硬件监控集中到一个入口DBA值班时不用每个环境都开个终端去敲命令。监控的价值在于趋势。单看某一刻的性能指标意义有限但如果你能拉出过去一周的慢查询数量变化曲线、锁等待时长的趋势、磁盘IO延迟的波动就能在故障发生前嗅到隐患。我有一个自己常用的次优方案每周自动跑一次慢日志分析生成TOP SQL列表人工review一次把新出现的可疑SQL扼杀在灰度阶段。这套流程并不复杂但对线上稳定性的提升是立竿见影的。6.3 常见问题速查表最后把多年排查经验浓缩成一张速查表遇到问题可以直接对照定位现象常见原因优先排查位置解决思路SQL单跑快并发上不去锁等待、连接池过大innodb_trx、连接池配置缩短事务、调小连接池明明有索引却全表扫描隐式转换、函数处理、统计信息过期EXPLAIN、字符集改写SQL、ANALYZE TABLE翻页越深越慢LIMIT深偏移执行计划中的扫描行数延迟关联、游标分页查询结果集大且不稳定缓冲池命中率低Innodb_buffer_pool_read%调大缓冲池、优化扫描量两个事务互相等待回滚死锁SHOW ENGINE INNODB STATUS统一资源访问顺序慢日志有记录但偶发磁盘IO抖动、刷盘策略fio测试、iostat升级SSD、调整刷盘参数大表聚合查询要跑很久不适合的索引或统计缺失执行计划、直方图建覆盖索引、物化视图排查慢查询没有银弹本质是在SQL、索引、统计信息、并发控制、硬件配置五个层面之间不断做排查和验证。我个人调试的顺序基本固定先看执行计划确认优化器选择再查统计信息和索引命中情况然后看锁等待和连接池状态最后才怀疑硬件。按这个顺序一步步来90%的慢查询问题都能定位到根因剩下的无非是执行层面的优化和取舍罢了。
返回列表