先说明一句:数据库调优从来不是单一动作,而是一整套围绕“时间去哪了、资源被谁吃了、瓶颈卡在哪层”的排查过程。很多人一上来就改innodb_buffer_pool_size,或者看到慢SQL就盲加索引,运气好时确实有效,但更多时候是治标不治本。我做了这么多年的数据库运维和架构设计,陆陆续续整理出10个调优角度,基本覆盖了从SQL、索引、表结构,到连接池、配置参数、锁和事务、读写分离以及监控闭环的完整链路。这篇文章把这10个角度一次讲透,每个角度都配上我在实际项目中验证过的做法,适合刚刚接触调优的后端开发者,也适合正被慢查询和连接堆积搞得焦头烂额的DBA或技术负责人。
1. 第1个角度:索引设计,成本最低见效最快的入口
1.1 索引不是越多越好,先看选择性
很多人一接到慢查询反馈,第一反应就是把所有涉及到的字段都建上索引,结果写入变慢、索引膨胀、优化器反而选了错误的执行计划。我在实际项目里判断一个字段要不要建索引,通常就看三个指标:字段基数、查询频率、写入频率。
字段基数是指该列不重复值的数量。比如性别字段只有“男/女”两种值,基数极低,就算建了索引,优化器扫一遍索引拿到的还是接近一半的行,还不如全表扫描来得快。我曾经处理过一个用户表查询,status字段只有3种状态,却被人建了单列索引,优化器实际跑起来就是不使用它,因为使用索引的开销远高于直接扫表。在这种情况下,正确的做法是把低基数字段放到联合索引的靠后位置,或者干脆不单列为它建索引。
判断标准可以这样粗略地用经验值来记:选择性 = 去重后的记录数 / 总记录数,选择性越接近1,索引价值越高;低于0.2的字段单独建索引基本是浪费空间,考虑和其他字段组合成联合索引。
1.2 联合索引顺序不对,等于白建
联合索引里字段顺序的选择是我见过最常被忽略的点。核心规则是“最左前缀原则”:查询条件里只有从联合索引最左侧字段开始连续匹配,索引才派得上用场。
比如我建了一个idx_user_status(user_id, status, create_time),那么查询条件里只有status而没有user_id,这个索引就走不上;只有user_id时能走;user_id + status能走;user_id + create_time也能走,因为create_time是索引的第三段,但中间跳过了status等于断了一截,只用到user_id部分。
实际设计联合索引时,我习惯把等值查询的字段放在前面,范围查询放在后面,排序字段尽量包含进索引里。例如订单查询经常是WHERE user_id = ? AND status = ? ORDER BY create_time DESC,就可以考虑建(user_id, status, create_time),这样排序直接走索引的有序性,避免产生 filesort。对于高频查询来说,去掉一次文件排序带来的性能提升往往比加一个索引还明显。
1.3 覆盖索引是隐藏的加速器
覆盖索引的意思是:查询需要的所有列都能在索引结构里直接取到,不需要回表。这个技巧我在报表类查询里用得最多。
比如有个订单流水表,核心字段是id, user_id, order_no, amount, create_time。现在统计某用户最近30天的订单金额总和,SQL是:
SELECT SUM(amount) FROM orders WHERE user_id = 123 AND create_time >= '2025-01-01';如果我建的索引是(user_id, create_time),那么找到满足条件的每行后,还需要回到主表取amount列,产生大量回表IO。但如果把索引设计成(user_id, create_time, amount),amount就变成了覆盖列,整个查询只扫索引页,不回表。在大数据量下,这个优化经常让查询时间从几百毫秒降到几十毫秒。
提示:覆盖索引虽然好用,但不要盲目把所有查询列都塞进索引。索引本质是平衡树结构,列越多意味着每个索引页能容纳的键值越少,索引体积越大。一般是针对业务最核心的高频查询做精确覆盖设计,而不是什么查询都覆盖。
2. 第2个角度:SQL改写,一条慢SQL从源头拆解
2.1 避免在索引列上做函数运算
这是一个非常经典又容易犯的错。看这个例子:
SELECT * FROM orders WHERE DATE(create_time) = '2025-03-01';create_time上明明建了索引,却因为外层套了DATE()函数,导致优化器无法使用索引,只能逐行计算后过滤,等于强制全表扫描。正确的写法是:
SELECT * FROM orders WHERE create_time >= '2025-03-01 00:00:00' AND create_time < '2025-03-02 00:00:00';改写成范围扫描后,索引就能正常发挥作用。这个规律不仅对MySQL有效,对大多数数据库都适用:不要在索引列上套函数、不要做隐式类型转换、不要对索引列做算术运算。
2.2 大表分页的深翻页问题
LIMIT 100000, 20这种写法在数据量小的时候没什么感觉,一旦数据量上了百万级,越往后翻越慢。原因很简单:数据库必须先把前面10万行全部扫出来丢掉,再取目标20行。我常用的方案是延迟关联:
SELECT t.* FROM orders t INNER JOIN ( SELECT id FROM orders WHERE user_id = 123 ORDER BY create_time DESC LIMIT 100000, 20 ) tmp ON t.id = tmp.id;子查询先在索引上完成分页查找,只取到20个主键,再回表查出完整数据。由于索引覆盖了id和排序字段,前10万行扫描走的是索引页,比全文行扫描要快得多。如果业务上允许,还可以改成基于游标的分页:用WHERE id > 上一页最大id ORDER BY id LIMIT 20代替OFFSET,这种方案在网络社交类的“下拉加载”场景里非常普遍。
2.3 关联查询和子查询的使用分寸
IN和EXISTS的选择在MySQL优化器版本更新后已经不那么绝对了,但依然有一条实践准则:小表驱动大表。更直白地说,外层数据集小的时候用IN通常没问题;当外层表数据量很大,而内层子查询结果集又很小时,EXISTS往往更合适。
我自己更常做的其实是拆开查询。比如一个“查订单顺便带出用户信息”的需求,如果两个表数据量都很大,与其写一个复杂的JOIN,不如先查订单,再用user_id IN (...)批量查用户,在应用层做一次内存映射。这样每个SQL都简单清晰,索引命中率高,还避免了多表JOIN带来的临时表和结果集膨胀问题。毕竟让应用服务器多算一点,比让数据库多承受一次大JOIN要划算得多。
3. 第3个角度:表结构与字段类型,地基不牢等于白调
3.1 字段类型的长度选择
数据库调优如果只盯SQL和下盘,很容易忽略表结构层面的问题。我现在看任何新表,第一遍先看字段类型,第二遍再看索引。
常见的问题是字符串字段全部用varchar(255),整型全用bigint,时间字段一律用datetime。比如状态值status本来只有几位数字,用tinyint就够了,占用1字节;如果用了varchar(10),不仅浪费存储,索引体积也会变大,查询扫描的页数更多。更重要的是,类型宽度直接影响了内存中的临时表和排序操作,类型越宽,内存消耗越大,慢SQL的概率就越高。
还有个我自己踩过不少坑的点:不要轻易用TEXT或很长的VARCHAR去存业务数据。MySQL的InnoDB对变长字段处理起来会引入额外的行溢出逻辑,大字段会放到溢出页,查询大字段时会增加额外IO。如果是描述、备注类内容,建议拆到单独的子表,或者直接放对象存储,数据库里只存引用路径。
3.2 范式与反范式的平衡
我一直秉持的原则是:核心交易链路保持一定的冗余设计,但绝不做无节制的反范式。反范式指的是通过冗余字段来减少JOIN,比如订单表里冗余一份用户昵称,这样查询订单列表时不需要再关联用户表。
坏处显而易见的,冗余字段一旦源表更新,所有被冗余的地方都要跟着更新,一致性维护成本会成倍上升。我的取舍标准是:如果这个字段是低频更新、高频读取,而且业务上接受略微延迟一致,那就可以冗余;如果字段频繁变化,比如用户等级、余额之类,坚决不要冗余。
另外,遇到统计类报表需求,我的建议是不要直接在业务表上写各种GROUP BY聚合。可以把明细数据定时汇总到一张统计表,或者在业务库之外建一个分析库,避免分析查询拖垮在线交易。这个思路不算高级,但非常实用。
3.3 时间字段类型的选择
时间字段datetime和timestamp的选择,看起来是小事,实际会影响精度和存储。timestamp占用4字节,范围到2038年;datetime占用8字节,范围大很多。如果是存储需要长期保留的创建时间,用datetime更稳妥,也避免2038年问题。
有些业务需要毫秒级精度,比如记录用户操作流水,我一般会用bigint存毫秒时间戳,或者用datetime(3)。用bigint的好处是排序和范围比较非常直接,而且避免时区转换的麻烦;缺点是人工排查数据时不如时间字符串直观。取舍看团队习惯,但一旦定下来就要统一规范。
4. 第4个角度:分区与分库分表,量级增长后的必答题
4.1 什么时候该考虑分区
很多团队在小数据量时没有分区意识,等单表数据达到千万级别,删除历史数据成了噩梦。MySQL的分区表按范围把数据物理拆分到不同分区,对业务透明,查询时如果能命中分区裁剪,也能减少扫描量。
我个人对分区的态度是:用场景比较克制。分区最大的价值我反而觉得是便于数据生命周期管理——比如按月分区,删除一个月的数据只需要ALTER TABLE DROP PARTITION,秒级完成,比DELETE FROM WHERE create_time < ?一次删千万行要舒服太多。
分区键的选择很重要。必须是主键或唯一键的一部分,而且查询条件里尽量带上分区键,否则优化器没法做分区裁剪,性能反而可能下降。最常见的分区方式是RANGE COLUMNS,按时间字段分区。
4.2 分库分表之前的几个冷静问题
我经常跟想上分库分表的团队说:分库分表是最后的手段,不是第一选择。因为一旦引入分片中间件,关联查询、事务、全局ID、跨分片排序全都变得复杂。
如果真的到了非分不可的地步,我会先确认几个问题:单表数据量是否真的超过千万甚至上亿?查询是否带上了分片键?业务能否接受跨分片聚合的复杂度?
分片键的选择通常是用户ID或者订单ID。订单表按用户ID哈希分片,绝大多数查询都是查某个用户的订单,分片键命中后,实际上只查一个分片,效率很高。最怕的是分片键是订单号,而业务查询经常按商家维度来做,那就会导致查询广播到所有分片,性能反而比单表更差。
4.3 扩容的提前规划
分库分表之后最痛苦的就是扩容。假设最初分了8个库,数据涨起来要扩到16个库,如果用的是user_id % 8的哈希规则,重新分片时要迁移大量数据。我建议一开始就使用一致性哈希,或者按范围分片,这样扩容时只需要迁移部分分片的数据,而不是全量洗牌。
另外一个实用方案是“双层映射”:先对分片键算出一个逻辑库编号,再把逻辑库映射到物理库。这样将来调整物理库个数时,逻辑层不用变。虽然架构上多了一层,但换来了扩容的灵活性,对于长期演进的项目来说非常值得。
5. 第5个角度:数据库参数配置,默认值不一定适合你
5.1 先看内存类参数
数据库参数配置是调优里最容易被神化又最容易被误解的部分。网上铺天盖地的教程让你改一堆参数,实际上不同版本、不同硬件、不同业务模型,参数组合天差地别。我会按优先级顺序来调参,第一步永远是对内存下手。
以MySQL InnoDB为例,核心参数innodb_buffer_pool_size决定了数据页和索引页在内存中的缓存量。如果实例是纯数据库专用机器,内存为64GB,我通常会把 buffer pool 设置为物理内存的70%左右,也就是45GB左右。为什么不是全部?因为操作系统本身要留内存,数据库内部还有各种会话级排序、临时表等内存消耗,如果把机器内存全部吃干榨净,很容易触发OOM。
调整前要观察InnoDB Buffer Pool Hit Rate,如果命中率低于95%,说明缓存不够用;如果接近100%,说明缓存完全够但查询可能仍有问题,这时候加内存没意义,问题在SQL端。
5.2 连接数和线程相关配置
max_connections不是越大越好。每个连接都会占用线程栈内存和内核资源,连接数从默认151调到2000看似能抗更多并发,实际可能让CPU在上下文切换上浪费大量时间。我见过不少案例,表现为数据库CPU跑满,但吞吐量却上不去,最后发现是连接数配置过高导致线程频繁切换。
合理的思路是控制应用层的并发连接数,而不是无限调大数据库上限。配合连接池使用,数据库端max_connections设置成应用连接池最大连接数之和的1.5倍到2倍,给突发留出空间就可以。
5.3 日志相关的写入策略
innodb_flush_log_at_trx_commit这个参数控制redo log的落盘策略,默认值是1,表示每次事务提交都要将日志刷到磁盘,安全性最高,但写入性能最差。如果业务可以接受最近1秒内的事务日志在极端情况下丢失,可以设置为2,获得明显的写入性能提升。
sync_binlog也是同样的道理,默认1表示每写一次binlog就同步一次磁盘,设置为0或N(比如每次N次事务同步一次)能提升写入吞吐,但会带来数据丢失风险。这个参数怎么设,本质上是个“数据安全和性能”的取舍题,没有标准答案。金融类业务我建议保持默认;日志、点赞、浏览类业务可以激进一点。
注意:调参最忌讳一次性改一堆。每次只改一到两个参数,压测对比后再动下一组,否则出了性能波动你根本不知道是哪项改出来的问题。
6. 第6个角度:连接管理与连接池,别让小连接拖垮大库
6.1 连接池大小的正确姿势
连接池是应用和数据库之间的缓冲层,也是我对所有项目都会反复强调的地方。很多人以为连接池越大越好,这是最常见的误解。连接数过多时,数据库端要维护的会话上下文变多,锁竞争加剧,整体吞吐反而下降。
业界有个经验公式:连接数 = (CPU核心数 × 2) + 固态硬盘数量。比如一台8核的数据库服务器,连接池目标大小可以定在16到24之间。这个数字看起来不大,但配合高并发的短查询,已经足够撑起很高的QPS。对于计算复杂、单查询耗时较长的场景,连接数可以适当调低,因为每个连接都在长时间占用CPU。
我处理过一个线上事故:应用侧连接池设置成300,数据库端恰好也放开了限制,结果高峰期CPU被打满,应用响应时间从50ms飙升到5秒。后来把连接池压到40,数据库CPU立刻降下来,吞吐反而提升了近一倍。这个案例我一直拿来教育团队:并发不等于连接数,排队等待也是并发的一部分。
6.2 慢查询会占住连接
连接池调好了,还要防止某些慢查询长期占用连接。慢查询会把连接池的可用连接耗尽,后续请求全部排队,造成“假死”现象。我一般会做两件事:一是监控慢查询日志并设置告警阈值,超过比如2秒的SQL立即告警;二是在查询语句层面设定超时时间,让应用端不无限等待。
数据库端的wait_timeout和interactive_timeout也要注意。如果业务长连接较多,连接空闲时间太长,会在数据库端积累大量Sleep线程。把这些空闲连接主动断开,能释放内存和文件描述符。但注意不要把超时设得太短,否则会导致连接频繁断开重建,反而增加握手开销。
7. 第7个角度:存储引擎与存储层选型
7.1 InnoDB是默认答案吗
对于绝大多数MySQL业务,InnoDB就是默认答案。它支持事务、支持行级锁、支持MVCC,崩溃恢复能力也比较强。如果还在用MyISAM的表,我建议尽早迁移到InnoDB。MyISAM的表级锁在并发写入时会让其他请求全部阻塞,这在当今这个并发场景下基本没法接受。
选择存储引擎时还要注意,有些数据库中间件或云数据库服务已经屏蔽了存储引擎的选择,这时候要关注的是底层存储介质。比如云厂商的高性能云盘和本地NVMe SSD,IO延迟差异非常大,在IO敏感型业务的调优参数上也会完全不同。
7.2 大对象和文件不要进数据库
把图片、附件、大文本直接塞进数据库是我最反对的一种设计。虽然技术上能存,BLOB类型也能用,但大字段会破坏InnoDB的页结构,导致行溢出,查询性能明显下降。正确的做法是把文件放到对象存储或者分布式文件系统,数据库里只保存访问路径和元数据。
这一条在高并发读取场景下尤其重要。有一次我接手一个资讯类项目,文章正文直接存在表里的longtext字段,列表页每次查都要把几KB的正文一起读出来,导致查询计划看着没问题,实际IO压力巨大。后来把正文拆出去,列表查询列表字段,详情页再按ID取正文,数据库压力立刻下降了一截。
7.3 监控IO延迟和IOPS
存储层的调优很多时候被隐藏在“数据库变慢”的表象之下。如果发现写入性能骤降,先不要急着调innodb_flush_log_at_trx_commit,先看磁盘延迟:正常SSD的读写延迟应该在毫秒以内,如果延迟波动明显,说明存储层已经是瓶颈了。
我用工具监控IO时,重点看iowait、await和util这三项。util接近100%并不绝对代表磁盘满负荷,应该结合延迟一起看。如果await升高但util不高,可能是并发队列问题;如果每次IO延迟都很高,那大概率是硬件层面的问题,调数据库参数也是杯水车薪。
8. 第8个角度:事务与锁,并发高时最要冷静的地方
8.1 事务范围能缩多短就缩多短
8.1 事务时间能缩多短就缩多短
长事务带来的问题比大部分人想象的要严重。它不只是占用连接,更关键的是会让 InnoDB 的 undo log 不断膨胀,导致Purge进程跟不上,最终形成“历史版本堆积”,读操作需要访问的版本链越来越长,查询性能直线下跌。
我处理过一个典型的长事务问题:有个接口在事务里做了三次外部HTTP请求,每次耗时都在几百毫秒,整个事务跨度超过2秒。高峰期并发上来后,数据库CPU不高,但查询延迟猛增。排查下来就是长事务导致的undo堆积和锁等待。优化方案很简单:把外部调用挪到事务外面,事务内部只保留数据库写操作,事务跨度从2秒降到几十毫秒。问题立刻缓解。
所以检查代码时我会特意看事务的边界,尤其注意事务内不能有RPC调用、远程请求、批处理大循环。如果确实要处理大批量数据,分段提交也比一个长事务更容易控制。
8.2 行锁、间隙锁和死锁的排查
并发更新同一批数据时,很容易出现行锁等待。我一般先查information_schema.innodb_trx和innodb_lock_waits,看是哪个事务持有锁、哪个事务在等待。死锁一旦发生,数据库会自动回滚其中一个事务,表现为应用收到死锁异常。应用侧的解法是增加重试机制;数据库侧的解法是尽量让事务按固定顺序访问资源,减少交叉锁。
间隙锁是 InnoDB 在可重复读隔离级别下的产物,范围查询时会锁住一个区间,即使这个区间里没有记录。很多“莫名其妙被锁住”的案例都和间隙锁有关。如果业务对隔离级别要求不高,可以考虑把全局隔离级别改成读已提交,能明显降低间隙锁导致的锁等待。当然这个改动需要业务方配合确认,不能拍脑袋就改。
8.3 隔离级别的取舍
隔离级别越高,一致性越强,并发能力越弱。MySQL默认的可重复读(RR)有间隙锁的问题;读已提交(RC)下只有行锁,并发度更高,但对同一事务两次查询可能得到不同结果。
在实际业务里,大量订单、支付类系统用的是RC级别,配合应用层的幂等和约束来保证一致性。如果业务确实需要保证同一事务内多次读结果一致,再考虑RR或加锁读。我的习惯是先跟业务对齐需求再决定隔离级别,而不是由DBA单方面修改。
9. 第9个角度:读写分离与缓存,扛住读流量的两条腿
9.1 什么时候上读写分离
数据库读多写少是最常见的场景。一台主库挂了读流量,CPU率先扛不住。读写分离的典型做法是:主库负责写,从库负责读,应用层根据SQL类型路由。
但读写分离有个绕不开的坑:主从延迟。如果你的业务要求写入后立刻能读到,而主从复制延迟几百毫秒,用户就会看到数据“消失”又出现。对于强一致性的数据,读请求必须走主库;对于弱一致性的内容列表、统计信息,才适合走从库。
我习惯在应用层做显式路由:比如订单创建后立即跳转到详情页,这一步直接走主库查询;而用户浏览历史订单列表这种允许延迟的场景,才走从库。不要图省事把所有读都发给从库,延迟敏感业务迟早会出问题。
9.2 缓存层拦截重复读
在数据库前面加一层Redis,是扛读流量最有效的手段之一。我给团队定的标准是:一个查询如果QPS高、响应要求快、数据允许短时间不一致,就考虑加缓存。
缓存最怕三种情况:穿透、击穿、雪崩。穿透是查一个不存在的key,每次都要打到数据库,解决方案是缓存空值或布隆过滤器;击穿是某个热点key失效瞬间大量请求打到数据库,解决方式是加互斥锁或让缓存永不过期;雪崩是大量key在同一时间失效,解决方式是给过期时间加随机值。
缓存虽然不在数据库调优范围内,但它是保护数据库调优成果的重要配套。没有缓存时数据库调得再好,扛不住读流量的指数级增长。
10. 第10个角度:监控与调优闭环,别靠感觉做优化
10.1 建立性能基线和指标大盘
我接触的团队里,有很大一部分连基本的监控都没有,出了问题只能临时查慢查询日志。这样调优就像蒙着眼开车,改了对不对全靠玄学。
我先做的基础工作是建立指标大盘,至少包含这几类:数据库层(QPS、TPS、连接数、慢查询数、锁等待、InnoDB缓冲池命中率)、系统层(CPU、内存、磁盘IO、网络带宽)、应用层(接口RT、错误率、数据库调用次数)。有了这些指标,再配合定期压测,就能得出一个业务低峰期的基线值。之后每次调优都跟基线对比,快速验证效果。
10.2 慢查询日志的自动化分析
慢查询日志是排查SQL性能问题的第一手材料。我会用pt-query-digest这类工具做日志分析,它能自动聚合出Top SQL,按总耗时、平均耗时、出现次数排序。拿到Top SQL后,逐一执行EXPLAIN看执行计划,重点确认:是否会用到索引、是否产生文件排序、是否发生临时表操作。
分析慢SQL的工作最好做成定时任务,每天自动跑一遍,把我从被动接故障中解放出来。很多慢SQL其实是长期潜伏的,只是量级没上来之前感觉不到,等量变引发质变时再处理就晚了。
10.3 复盘和文档化
数据库调优非常讲究经验沉淀。每次排查完一个性能问题,我都要写一个简短的复盘文档,包含问题现象、排查过程、根因分析、解决方案、优化前后的对比数据。久而久之,这些文档就成了团队内部的“故障手册”,很多相似问题都能直接对照处理。
调优这件事没有终点,业务在变,数据量在变,SQL在变。今天的最优解,三个月后可能又成了瓶颈。所以构建一个“发现问题 → 定位根因 → 实施优化 → 验证效果 → 归档复盘的闭环”才是最重要的,比掌握任何一个单项技巧都值钱。
我自己做项目时还有一个习惯:每次优化完至少观察一周的生产数据,确认没有反弹再定论。着急下结论,容易被突发流量或偶发性抖动误导。数据库调优更像是持续的工程实践,而不是一次性的极限冲刺,稳中求进才是长期可复制的方法论。