ARTICLE DETAIL

资讯详情

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

数据库性能优化实战:从慢查询定位到索引与架构设计

数据库性能优化实战:从慢查询定位到索引与架构设计 数据库慢这事几乎每个团队都遇到过。我见过太多这样的场景一张两千万行的订单表业务方说“就是查一个订单号怎么要三秒多”开发试着加了个索引没效果于是把问题抛给DBADBA看了一眼说“你这条SQL的写法把索引废了”开发不服说“就少写了个函数调用而已”。类似的扯皮我参与过不少后来我总结出一个规律——绝大多数数据库性能问题都不是单一原因而是从需求理解、SQL写法、索引设计、存储引擎特性、架构选型这条链路上某个环节出了问题。真正的性能优化是在这条链路里找出那一个或几个关键节点用最小成本把它修好。这篇内容围绕数据库性能优化实战展开核心目标是帮你建立一套从发现性能瓶颈到根治瓶颈的完整思路。适合谁看后端开发、DBA、架构师还有刚接触数据库优化、想建立系统方法论的读者。我会从定位方法、索引底层逻辑、失败现场、架构决策、新场景差异这几个维度展开所有原理都会配合案例讲透。1. 先学会看体检报告性能瓶颈的定位方法1.1 慢查询日志与基线指标优化数据库性能第一件事不是改SQL不是加索引而是搞清楚“哪里慢了、慢到什么程度、是不是一直慢”。这句话说起来容易做起来经常变形。我见过不少团队一接到线上告警就冲到服务器上加索引结果加了五六个索引业务反而更慢了——写入多了一条更新索引的开销查询还没命中。这种“瞎调优”的根子就是缺少度量。先说慢查询日志。MySQL里有一个常年被忽略的参数叫long_query_time默认值是10秒。这意味着一条SQL执行9.9秒都不会被记进慢日志。生产环境把10秒作为“慢”的标准几乎等于没有标准。你在业务侧已经感知到页面卡了数据库这边却连一条慢查询都没记录下来。我的建议是线上核心业务把long_query_time调整到1秒延迟敏感的业务甚至可以调到0.5秒。调整方式如下SET GLOBAL long_query_time 1; SET GLOBAL slow_query_log ON;这里提醒一句慢查询日志的开关和阈值建议持久化到MySQL配置文件里my.cnf或my.ini否则实例一重启配置就丢了。同时把log_queries_not_using_indexes打开记录那些“虽然是全表扫但因为表还不大所以不算慢”的查询。这类查询看起来人畜无害等数据量涨上来就会变成定时炸弹。基线指标同样重要。QPS、TPS、平均响应时间、buffer pool命中率、磁盘IOPS、慢查询次数这些指标不用每天盯着看但在做优化前必须记录至少一周的基线。没有基线你很难回答“优化之后到底快了多少”这个问题。我遇到过有团队把一个查询从2秒优化到1.5秒觉得很成功但后来翻基线数据发现这个查询高峰期本来就只该跑50ms之前的2秒是因为当时有个大事务把整个实例拖住了。这种误会就是不看基线导致的。1.2 执行计划EXPLAIN的每一列都在说什么定位到慢查询之后下一个动作一定是看执行计划。MySQL里是EXPLAINOracle里可以用DBMS_XPLAN.DISPLAYPostgreSQL是EXPLAIN (ANALYZE, BUFFERS)。语法不同核心逻辑一致看数据库到底是按你写的SQL去执行还是悄悄换了一条执行路径。很多初学者看EXPLAIN只看type字段是不是ALL是ALL就喊索引失效其实不够。type只是访问类型真正的效率还要结合rows、filtered、key_len和Extra一起看。我整理了一个常用访问类型对比表type含义说明const主键或唯一键常量等值查询最快最多返回一行eq_ref连接查询中被驱动表按主键访问常见于多表joinref非唯一索引等值访问正常范围扫描range索引范围扫描如between、in、等index全索引扫描不理想但好于ALLALL全表扫描慢查询重灾区看到ALL或index基本可以断定这一行访问不够好。但比type更值得关注的是rows乘以filtered。比如rows100000、filtered10%意味着从十万行里过滤出1万行说明索引选择性和预估都有问题。还有Extra列里的Using filesort和Using temporary这两兄弟出现任何一个都意味着结果排序或分组没有走索引做了临时表内存扛不住还得落盘。MySQL 8.0.18之后有了EXPLAIN ANALYZE可以输出每一步真实执行时间和行数。我自己排查慢查询时习惯先EXPLAIN看结构再用EXPLAIN ANALYZE看真实耗时分布基本能在两三分钟内锁定问题节点。命令很简单EXPLAIN ANALYZE SELECT o.order_id, u.nickname FROM orders o JOIN users u ON o.user_id u.id WHERE o.status 1 ORDER BY o.created_at DESC LIMIT 20;输出结果里会带每个算子的实际耗时和循环次数哪个join环节慢、哪个排序耗时高一目了然。这里必须强调一句执行计划里看到的rows是优化器估算值不是真实值。MySQL的统计信息在频繁增删改之后会失真所以EXPLAIN发现优化器选择的索引明显不合理时别忘了先跑一句ANALYZE TABLE刷新统计信息。这个小动作成本极低却经常能解决类似“索引明明建了却不走”的怪现象。2. 索引设计的底层逻辑为什么a and b不是建两个索引2.1 B树的高度决定了I/O次数回到最基础的东西。数据库索引最常见的结构是B树MySQL InnoDB默认16KB一个页。B树的每一个非叶子节点只存索引键和指向下一层的指针叶子节点才存真实数据或主键。这个设计决定了“树的高度”就是随机查找时逻辑上要访问的页数每次访问一个页在磁盘上就是一次随机I/O。做一个粗略估算假设一行数据1KB一个叶子页能存16行一个非叶子节点页假设索引键加指针平均占16字节能存大约1000个键。那么一棵三层的B树第一层1个根节点第二层最多1000个节点第三层最多100万1000×1000个叶子节点每个叶子页16行总共能支撑1600万行数据。也就是说在千万级表里按索引找一个值通常只需要两到三次I/O——根节点基本常驻内存实际落盘次数会更少。这就是索引的核心价值把随机查找从全表的几千次I/O压缩到两三次。联合索引看起来像“加长了键”本质还是一棵B树只不过节点的比较从单个字段变成了多个字段的字典序比较。为什么WHERE a? AND b?时建一个(a,b)联合索引常常比建a和b两个单列索引更优因为两个单列索引意味着数据库要先在两棵B树里各查一次再做一次索引合并和交集运算而一个联合索引直接从根节点一路往下定位到满足a和b两个条件的记录省掉了额外的一整轮查找和合并操作。用翻字典类比就很好懂。你想找一本工具书里“姓张且名字两个字是伟”的人两个单列索引好比你先翻到“张”姓那一页再翻到“伟”字那一页然后对比两份名单联合索引则像字典直接按“姓氏名字”排序你翻到“张伟”区间任务完成。2.2 联合索引的字段顺序区分度、等值优先、覆盖需求联合索引字段怎么排是面试里被问烂、实际项目里也最容易拍脑袋的问题。我建议直接按下面三条规则顺次判断等值条件优先。WHERE里既有等值又有范围时等值字段放前面。因为B树中范围条件之后的其他字段无法继续利用索引定位和排序。区分度高的放前面。区分度指字段不同值的比例。比如性别只有两个值区分度极低订单表里的订单号、用户ID这类字段区分度极高。高区分度字段放前面可以让索引树在更上层就快速砍掉大量分支。尽量把查询需要的列塞进索引形成覆盖索引。如果索引键已经包含了SELECT要的所有字段查询连回表都不需要直接读索引页返回Extra里会显示Using index。对应网上非常典型的问题where条件a and b该怎么建索引。假设有一张电商订单表CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL, amount DECIMAL(10,2), INDEX idx_user_status (user_id, status) );如果查询是“查某个用户某种状态的所有订单”那么idx_user_status(user_id, status)就是标准答案。user_id区分度高放前面status作为附加条件紧跟其后。但如果查询还会带created_at范围比如“查某个用户近30天的已支付订单”索引设计就要考虑(user_id, created_at, status)或(user_id, status, created_at)取决于等值条件和范围条件的优先级。没有放之四海而皆准的答案必须用执行计划里的rows估算去验证。还有一类容易漏掉的是order by和group by。很多人只关注WHERE条件里出现的字段忘了排序和分组也能利用索引。只要联合索引的字段顺序按照“WHERE等值字段→ORDER BY字段→GROUP BY字段”排列排序就可以直接从索引顺序中读取避免Using filesort。关于索引表空间这里顺带提一句Oracle里索引可以单独放在独立表空间方便把高频索引放到更快的存储卷上备份策略也能区分。MySQL InnoDB的索引和数据共用同一个表空间没有分开存放的概念所以做MySQL优化时不用纠结这一层更该关注的是索引个数和单个索引的大小。很多系统内存有限buffer pool塞不下所有索引页表现就是“索引明明存在却频繁淘汰、频繁读盘”这时候优先砍冗余索引、减小索引体积比换SSD更直接。3. 索引失效的典型现场与排查链路3.1 失效场景一函数包裹列先说最常遇到的第一个坑给索引列套了函数。典型写法SELECT * FROM coupons WHERE DATE(expire_at) 2024-06-30;expire_at字段建了索引但DATE()函数把列值处理过一遍之后索引的有序结构对函数结果完全没有帮助。优化器只能把全表扫描的结果逐行算一遍函数再比较。解决思路是把函数等值改写成范围查询SELECT * FROM coupons WHERE expire_at 2024-06-30 00:00:00 AND expire_at 2024-07-01 00:00:00;前后两个写法的业务语义完全一样但后者expire_at没有函数包裹可以直接走索引范围扫描。类似的问题还有LIKE前置通配符。SELECT ... WHERE name LIKE %数据库%百分号在最前面索引排序规则是“按字符串前缀排”前缀都不确定B树没法二分查找。只有把通配符放在右侧比如name LIKE 数据%才能利用索引。3.2 失效场景二隐式类型转换第二个高频坑是隐式类型转换。MySQL的规则是字符串列和数值字面量比较时如果类型不一致会尝试把字符串转成数值。问题在于当索引列是varchar条件里写的是数值时MySQL通常会对索引列做转换转换后就等于给列套了函数索引失效。经典例子手机号字段phone存的是varchar线上有个查询写成WHERE phone 13800138000。数字没加引号。执行计划一看typeALL几百万行全扫。改成WHERE phone 13800138000之后手机号字段可以直接按字符串走索引查询从几百毫秒降到个位数毫秒。这个坑在Oracle里同样存在只不过行为略有差异。更隐蔽的是join时两个表的关联字段类型不一致比如一个表是int另一个表是varchar连接条件发生隐式类型转换可能导致大表被反复全表扫。排查这类问题有个土办法把SQL里每个WHERE条件、join条件的字段类型和字面量类型逐个列出来对比凡是“索引列参与了运算、函数或类型转换”的全部标识出来。数据库在EXPLAIN输出里通常也会给出警告信息MySQL可以用SHOW WARNINGS看到优化器改写后的SQL对照一下很容易发现索引被动过手脚。3.3 一次慢查询的完整排查链路讲一个我实际处理过的订单慢查询完整走一遍排查链路你会对这些失效场景有更直观的感觉。背景电商订单表orders约800万行。线上告警某接口平均延迟突然从60ms涨到3.2秒。这个接口的SQL大致是SELECT id, order_no, user_id, amount, status FROM orders WHERE user_id 2019001 AND status 1 AND pay_time 2024-01-01 ORDER BY pay_time DESC LIMIT 20;orders表上当时有单列索引idx_user_id(user_id)和idx_status(status)。拿到慢查询日志后我做了四步第一步看全貌慢查询日志里这条SQL持续出现平均执行3秒左右但并不是每一条都慢主要集中在pay_time范围覆盖较大的场景。第二步抓执行计划EXPLAIN结果里的type是refkey是idx_user_idrows估算约25万Extra里出现Using where和Using filesort。看到这里初步判断order by pay_time没有利用索引25万行排序是主要耗时。第三步梳理索引现状idx_user_id只能精确定位到某个用户再用status和pay_time过滤、排序。问题是pay_time和status都没有进到同一个索引里排序自然成了瓶颈。第四步重建索引把索引改为(user_id, pay_time, status)。ALTER TABLE orders ADD KEY idx_user_pay_status (user_id, pay_time, status);修改后再次EXPLAINtype仍是ref但rows从25万降到了几百Extra里Using filesort消失接口延迟回到60ms左右。这个案例的价值在于索引不能只看“有没有命中”还要看“命中的索引用没有帮你做完排序和过滤”。idx_user_id确实命中了一个user_id等值条件但后续status过滤和pay_time排序它都帮不上忙等于只完成了三分之一的活。顺带回应热搜词里Oracle视图加索引的问题。Oracle里对普通视图不能像对表一样直接CREATE INDEX视图本质是保存的查询定义物化视图才真正存数据。如果业务需要在视图上加速常规做法是到视图对应的基表上建索引或者把高频且重的视图查询改造成物化视图再在物化视图上建索引。MySQL不支持物化视图一般通过手动建汇总表或定时刷新的中间表来实现类似效果。原则就一句话索引永远建在真实存储数据的对象上。4. 索引救不了你的时候架构层优化的决策路径索引优化做到位之后你迟早会撞上一个更残酷的现实明明执行计划已经漂亮得无可挑剔单机数据库的CPU、磁盘I/O还是扛不住了。这时候就要从架构层面找答案。架构优化不是一上来就分库分表而是有一个比较清晰的决策路径缓存优先其次读写分离最后才考虑分片。4.1 缓存优先把重复读挡在最前面读多写少的业务性能优化性价比最高的一招就是加缓存。把热点数据放到Redis或本地缓存数据库的查询压力能下降一个数量级。但缓存不是随便加坑都在细节里。第一个问题是缓存穿透查询一个根本不存在的key缓存里没有每次都打到数据库。解决方式有布隆过滤器前置过滤或者缓存空值但设置较短的过期时间。第二个问题是缓存击穿某个热点key在缓存过期的瞬间有大量请求同时打到数据库。解决方式是加互斥锁让请求在缓存重建期间排队或者把热点key的过期时间加随机数避免同一时刻集体失效。第三个问题是缓存雪崩大量key同时过期数据库在短时间内被击穿。解决方式是过期时间打散。以我的经验缓存层最重要的其实是先想清楚数据一致性要求。别把所有查询都塞进缓存只有允许一定延迟或可以容忍短暂不一致的业务数据才适合。金额、库存这类强一致数据要么不缓存要么做非常严格的缓存更新策略不然省下来的性能都要还给对账和修bug的时间。4.2 读写分离与主从延迟的权衡缓存解决的是“大量重复读”问题但如果业务是“大量不重复的读”比如报表、用户维度的千变万化查询缓存就不太适用了。更常用的架构手段是读写分离主库负责写入从库负责读多个从库分摊读压力。MySQL原生主从复制已经非常成熟选型上关注几个点主从延迟、半同步复制、从库只读配置。最简单的从库使用方式是配置一个负载均衡层读请求轮询到不同从库。麻烦的是主从延迟问题写入主库后立即去读从库如果从库还没同步到这条数据用户就会看到“刚才的操作没生效”。我处理过的业务里最稳妥的经验是“关键读走主库非关键读走从库”。比如支付成功页要显示最新订单状态这个查询走主库历史订单列表可以从从库读。这种路由规则在应用层判断代价低、效果直接。如果要求再高一点可以用半同步复制确保主库的binlog至少有一个从库接收成功最大程度降低数据丢失风险并收窄延迟窗口。但半同步复制会增加少许写入响应时间写入频繁的压测场景需要先做基准测试再决定是否启用。4.3 分库分表的代价没有银弹缓存和读写分离都挡不住的时候才轮到分库分表。我习惯把分库分表分成两个层次只分库不拆表解决“单一实例资源不够”分表解决“单表数据量太大导致索引深度、锁竞争、I/O放大”。后者通常出现在单表数据量超过千万级以后。分片键的选择是整个方案里最难反悔的决策。两个原则分片键必须覆盖绝大部分查询的等值条件分片键的分布要均匀。常见选择有用户ID、订单ID、租户ID。比如订单表按user_id分片“查某个用户最近的订单”这类查询直接路由到固定分片性能很好但如果业务里有“查某一天全平台所有订单”这种运营需求就必须在所有分片并行查询再汇总复杂度直线上升。分库分表会带来几个绕不开的问题全局主键不再依赖数据库自增常用雪花算法或号段模式跨分片的join和事务只能靠应用层或分布式事务中间件解决数据的扩容、迁移、备份复杂度成倍增加。所以我的态度是如果不是明确的数据量或QPS瓶颈别为了微服务架构的整齐美观提前分库分表。分库分表是治重症的手术不是预防感冒的疫苗。这个判断尤其要说给正在做微服务改造的团队听。微服务拆的是应用层数据库层是否分库分表应该独立评估。很多团队把服务拆了数据库还是一坨大库或者反过来服务没拆就把库拆了结果两边的复杂度叠在一起线上事故翻倍。架构演进的关键是每一步都只解决当前最痛的那一个问题。5. 特殊场景向量数据库与时序数据库的优化思路差异聊完了传统关系型数据库还有一个很容易被忽视的盲区非关系型数据库的性能优化思路和关系库差别很大但很多人还带着B树和索引的惯性思维去处理结果事倍功半。这里重点讲两个这两年很常见的场景向量数据库和时序数据库。5.1 向量检索的索引选择召回率与延迟的平衡向量数据库是RAG、语义检索、以图搜图等场景的底座。它的核心操作是“在N个高维向量里找出与查询向量最相似的K个”。传统B树索引在这个场景里几乎失灵因为你没法给一个向量做全序排列也没法用区间查询做到近似匹配。目前主流的索引结构是近似最近邻ANN索引常见的是HNSW和IVF。HNSW的核心思路是建立多层图结构从最粗的层快速粗筛逐层下钻到细粒度层在控制距离计算次数的同时逼近真实最近邻。IVF则先把向量空间聚类成若干个桶查询时只在少数几个候选桶里做精确计算。优化向量索引和优化B树的思路完全不同。你需要关注三个指标召回率、查询延迟、索引构建时间。调参重点通常集中在HNSW的M每个节点的最大连接数和efConstruction构建时动态列表大小。M越大、efConstruction越大召回率越高但内存占用和构建时间也越高。我个人的调优习惯是先用默认参数跑通再看业务对召回率的容忍度从低延迟优先开始调逐步提高召回率直到业务指标达标。因为向量索引非常吃内存盲目追求高召回率可能会把内存撑爆这比少召回几条向量更致命。5.2 时序数据库的写入性能一条一条insert和批量写入差距有多大时序场景设备监控、日志、金融行情的性能瓶颈通常不在查询而在写入。数据点持续产生写入速率必须跟得上。以TDengine为例它针对物联网场景做了大量设计优化C/C客户端提供了参数绑定接口类似预编译语句核心是taos_stmt_prepare、taos_stmt_bind_param、taos_stmt_add_batch、taos_stmt_execute这几个API。如果你用C写上报程序一个常见坏习惯是每来一条数据就单独绑定、单独执行一次insert。这样每条数据都有完整的网络往返、SQL解析、绑定处理吞吐量上不去。正确做法是用stmt接口攒一批数据比如每50条或100条提交一次。示意逻辑如下taos_stmt_prepare(stmt, INSERT INTO meters VALUES (?, ?, ?, ?), strlen(sql)); for (int i 0; i 100; i) { taos_stmt_bind_param(stmt, params[i], ...); taos_stmt_add_batch(stmt); } taos_stmt_execute(stmt);注意上面是接口使用示意具体函数签名要以当前版本SDK头文件为准。我做过一个上送程序的对比测试同样的网络环境单条绑定的场景吞吐量大约每线程每秒几百到一千次写入改成每批100条批量提交之后吞吐直接提升了一个数量级。原因不复杂批量提交减少了prepare和execute之间的重复往返一次提交处理100条数据底层时序模型按设备和时间线存储写同一时间线可以连续落盘顺序I/O的优势被充分发挥。这个经验放到很多时序数据库都通用像OpenTSDB、InfluxDB也都有类似的batch写入机制核心原则就一句话高频小写入一定要合成低频大批量写入能走一次网络往返解决的事情绝不要拆成一百次。6. 优化之后回归度量6.1 优化前后的执行计划与延迟分布对比最后聊一聊真正让我和团队受益的一环每一次优化的闭环不是“改完了、线上不报警了”就算结束而是要回到度量体系里验证效果。我的固定动作第一就是优化前后的执行计划对比。把优化前的EXPLAIN输出保留优化后重新跑一遍EXPLAIN对照type、rows、Extra三项的变化搞清楚这次优化究竟改变了哪些环节。如果rows没降下来、filesort还在只是巧合快了一点那迟早会有新的慢查询冒出来。第二是回看慢查询日志和延迟分布。不要只看平均值平均值很容易被少量高速查询拉低。要看P99甚至P95。一个系统P99延迟从3秒降到了80ms才是真实有效的优化如果平均值好看但P99纹丝不动说明瓶颈没被根治很可能只是把一部分查询变成了更快的路径剩下的依然卡在原地。6.2 冗余索引的清理与统计信息维护第三是清理和复核索引。优化过程中很容易产生临时索引、调试索引验证完就得删掉。冗余索引每多一个写入链路就多一次索引更新的代价buffer pool被无谓占用优化器还可能选错索引。我会定期查索引使用统计把一段时间内从未被使用的索引标记出来确认后统一删除。同时别忘了维护统计信息。前面提到优化器依赖统计信息做成本估算如果长时间不做统计更新索引选择会越来越歪。MySQL可以在低峰期执行ANALYZE TABLEOracle有自动统计信息任务关键是确保它在跑而不是建完索引就再也不管。这几年反复处理线上性能问题我最深的体会是数据库性能优化不是一个炫技操作而是一个不断逼近真因的过程。先度量、再定位、后改动改完再度量。这个闭环本身比任何一条索引技巧都值钱。见过太多团队长期停留在“遇到慢查询就加个索引”的阶段最后等到单机资源耗尽才被迫做架构改造那种被动局面下的每一步都格外疼。希望这篇内容能帮你把优化的节奏提前在瓶颈真正卡住业务之前就把该做的功课做扎实。
返回列表