ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL千万级大表性能优化实战:从索引到架构全面梳理

MySQL千万级大表性能优化实战:从索引到架构全面梳理 做后端开发这些年最常听到的一句话就是“线上又卡了是不是MySQL扛不住了”尤其是当单表数据量从几百万涨到千万级别原本秒开的查询突然变成几秒甚至几十秒接口超时、CPU飙高、锁等待齐上阵。这篇文章想聊的就是MySQL大表优化这件事从最直接的索引、SQL、表结构到实例参数和架构扩展完整梳理一条可以把千万级数据性能瓶颈摁下去的实战路线。适合正在为慢查询挠头的后端开发、DBA也适合准备做性能压测或容量规划的同学。内容偏实战理论部分我会尽量用大白话讲清楚方便你直接拿去做参考。1. 先搞清楚瓶颈在哪千万级大表的性能分析框架1.1 为什么数据量一大就变慢——底层存储与查询路径很多同学一遇到大表就开始加索引、改配置但改了之后效果有限原因往往是没弄明白“慢”到底慢在哪个环节。MySQL InnoDB 的默认存储结构是 B 树数据行按主键聚簇存放二级索引只保存索引列和主键值。查询时如果走二级索引先到索引树拿到主键再回到聚簇索引查整行这个“回表”动作在小数据量下几乎无感但到了千万级随机 IO 的代价会被急剧放大。另一个隐蔽问题是缓冲池命中率。InnoDB 的内存缓冲区Buffer Pool通常只够存放热点数据当表数据量远超内存时每次查询都可能触发磁盘读。磁盘随机读的延迟是内存的几百倍这就解释了为什么数据量跨过某个临界点后查询耗时突然呈指数级上升。所以定位大表瓶颈第一个要看的就是“数据是否还能被内存覆盖”第二个才是索引和 SQL 本身。1.2 用一套组合拳定位慢查询慢日志、EXPLAIN、profile我习惯一上来先开慢查询日志给个阈值比如 1 秒把“罪魁祸首”抓出来。实操命令很简单-- 临时开启慢查询日志 SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; SET GLOBAL slow_query_log_file /var/log/mysql/slow.log;注意这两项是全局变量重启后会丢失如果确认要长期开启记得写进 my.cnf。拿到慢日志后不要急着改先用 EXPLAIN 看执行计划重点看 type 字段。我通常只关心几个关键值type 是不是 ALL全表扫描key 是否为空rows 是不是估算得很夸张Extra 里有没有 Using filesort 或 Using temporary。这些信息能直接告诉你查询是否走了索引、是否产生了临时表、排序是否让你意外。如果 EXPLAIN 还解释不了问题用 EXPLAIN ANALYZE 或 profile 看每个阶段的耗时。MySQL 8.0 里可以用 EXPLAIN ANALYZE 直接看到实际执行时间和行数比传统估算准得多。定位问题的原则很简单先确认瓶颈在 CPU 还是 IO再确认是单条 SQL 慢还是并发压垮了实例方向错了后面所有优化都是白费。2. 索引优化大表性能的第一根救命稻草2.1 索引失效的常见姿势与规避方法索引是优化大表查询最直接的手段但很多人建了一堆索引查询还是慢八成是索引根本没用上。常见失效原因有几种对索引列做了函数运算比如WHERE DATE(create_time) 2024-01-01隐式类型转换比如手机号字段是 varchar却用数字去查前导模糊匹配LIKE %keyword还有 OR 连接的条件里只有一个字段有索引。这些都容易让优化器放弃索引。解决思路是让查询条件保持索引列“原样”。日期范围可以改成create_time 2024-01-01 00:00:00 AND create_time 2024-01-02 00:00:00。模糊搜索建议用全文索引或者干脆交给搜索引擎不要跟 B 树较劲。至于 OR尽量改写成 UNION ALL或者保证 OR 两端都有索引。索引失效的坑属于“排查一小时修复一秒钟”的类型但修复之后收益往往立竿见影。2.2 联合索引设计原则与最左前缀大表上的联合索引设计得好能省掉大量回表。核心是最左前缀原则查询条件能命中联合索引的最左列索引才会被使用。比如建了(user_id, status, create_time)联合索引那么WHERE user_id ?和WHERE user_id ? AND status ?都能走索引但只查status ?就不行。设计联合索引时我一般遵循两个经验一是把区分度高的列放前面比如 user_id 通常比 status 更值得放左边二是把范围查询的列尽量往后放因为范围条件之后的列无法走索引排序。比如订单表经常按用户和时间查询(user_id, create_time)就比(create_time, user_id)更合适。一个常见的误区是“每个查询都建一个索引”结果索引太多写入变慢还占空间。大表上新增索引前一定先看看现有索引能不能覆盖新查询。2.3 覆盖索引与索引下推让回表次数归零覆盖索引是最让人舒服的优化方式查询的字段恰好都在索引里InnoDB 就不需要回表。比如订单表有一条高频统计 SQLSELECT COUNT(*) FROM orders WHERE user_id 123 AND status 1;如果存在联合索引(user_id, status)这个查询直接从索引里数行数即可完全不用碰数据页。同理像SELECT id, status FROM orders WHERE user_id ?这种如果索引包含 id 和 status也能实现覆盖。索引下推Index Condition Pushdown是 MySQL 5.6 引入的特性允许存储引擎在使用索引时直接过滤一部分不满足条件的记录减少回表次数。日常开发中把能用得上的过滤列尽量塞进联合索引就能顺手享受 ICP 带来的红利。不过覆盖索引也不是越多越好索引体积增大后内存压力和写入成本都会上升要拿高频查询去换。3. SQL与表结构层面的深度优化3.1 大字段拆出去垂直拆分的思路千万级大表里如果存了大字段比如 text、blob甚至长 json行记录会变得很“胖”InnoDB 一行能存的数据变少B 树层数可能增加查询时即使走了索引回表读一行也要读很多无用字节。常见的做法是垂直拆分把低频访问但体积大的字段挪到另一张“扩展表”里主表和扩展表通过主键一对一关联。例如用户表可以拆成user_baseid、name、phone和user_extid、bio、avatar_url核心列表页只查 base 表详情页再关联 ext 表。这样既减少了行宽度也提高了缓冲池的有效利用率。需要注意的是垂直拆分后不要轻易用SELECT *跨表关联查询否则收益会被连接代价抵消。3.2 水平拆分与分区什么时候真正需要水平拆分是把一张大表的数据按规则分散到多张表或多个库比如按 user_id 哈希或按时间范围分表。分表的优势是单表数据量可控索引体积小写入和查询都能横向扩展。但它也带来跨表聚合、分页、事务一致性的复杂度所以通常建议数据量到达几千万甚至上亿、且常规优化手段都用尽之后再考虑分表。相比分表MySQL 内置的分区表实现起来更轻。RANGE 分区适合时间序列数据比如按月分区CREATE TABLE orders ( id BIGINT NOT NULL, order_time DATETIME NOT NULL, amount DECIMAL(10,2), PRIMARY KEY (id, order_time) ) PARTITION BY RANGE (YEAR(order_time)) ( PARTITION p2023 VALUES LESS THAN (2024), PARTITION p2024 VALUES LESS THAN (2025) );注意分区键必须包含在主键里否则会报错。分区最大的收益是“分区裁剪”查询条件带上分区键后MySQL 只扫描对应分区相当于隐式地分表。但分区表也有坑如果查询没法裁剪分区反而会扫描更多分区分区数量太多也会影响打开表的开销。因此是否要分区得用真实查询条件做验证不要为了分区而分区。3.3 深分页优化延迟关联与游标翻页分页是千万级大表的头号杀手。很多后台列表喜欢这样写SELECT * FROM orders WHERE user_id 123 ORDER BY create_time DESC LIMIT 100000, 20;MySQL 需要先找到前 100020 行再丢弃前 100000 行扫描行数非常可观越往后越慢。几种常用解法我都在项目里试过一是延迟关联先只查主键再做连接SELECT o.* FROM orders o JOIN (SELECT id FROM orders WHERE user_id 123 ORDER BY create_time DESC LIMIT 100000, 20) tmp ON o.id tmp.id;二是基于主键或唯一键做“滚动翻页”也就是记住上一页最后一条记录的 id下一页用WHERE id 上一页最大id ORDER BY id LIMIT 20。这种方式适合列表顺序跟主键或索引顺序一致的业务性能能稳定在毫秒级。缺点是用户不能随意跳页但内容流、新闻列表完全够用。3.4 事务、锁与快照读别让并发拖垮性能大表最怕的不只是慢查询还有并发下的事务和锁竞争。MySQL InnoDB 默认使用可重复读隔离级别但它通过 MVCC 实现了快照读普通SELECT不会加锁所以读写并不会互相阻塞。真正容易出问题的是UPDATE、DELETE和SELECT ... FOR UPDATE它们要加行锁如果更新条件没走索引就会退化成锁表并发一高直接排队。避免锁竞争的关键是让更新和删除总是基于索引列尤其是唯一索引或主键。还要注意事务尽量短不要在事务里做远程调用、外部 API 请求、大量计算等耗时操作。长事务不仅会持有锁时间长还会让 undo log 不断膨胀导致历史版本堆积查询变慢。MySQL 锁的分类和加锁顺序也值得背一背尤其是多个事务同时更新多行时一定要让所有事务按相同顺序操作否则死锁概率会明显增加。4. MySQL实例配置与参数调优4.1 InnoDB Buffer Pool 该怎么设实例参数里影响最直接的就是 InnoDB Buffer Pool 大小它相当于 MySQL 的“热数据缓存”。如果机器内存是 64GBMySQL 独占这台机器我通常会把 Buffer Pool 设到物理内存的 60% 到 70% 左右也就是 40GB 上下。别贪心全给 MySQL操作系统本身和文件页缓存也需要一点空间。如果不知道当前命中率可以看SHOW GLOBAL STATUS LIKE InnoDB_buffer_pool_read%读磁盘次数除以总读取次数就是未命中率长期超过 5% 就要考虑加内存或优化数据访问模式。MySQL 8.0 支持在线调整innodb_buffer_pool_size但建议还是写进配置文件并设置innodb_buffer_pool_instances通常 8GB 以内设 1 个实例更大内存可以按每实例 1GB 到 2GB 拆分减少内部锁竞争。4.2 排序缓冲与临时表不常看但很关键大表上经常出现ORDER BY和GROUP BY如果排序数据量比较大MySQL 会用系统临时文件排序这个动作非常慢。参数sort_buffer_size是每个会话的排序缓冲区大小不是全局的所以不宜设太大否则高并发下内存会瞬间被吃满。我一般会设到 2MB 到 4MB配合max_length_for_sort_data让宽行排序尽早拆分成主键排序加回表。tmp_table_size和max_heap_table_size共同决定内存临时表上限超过之后会落到磁盘。如果发现慢日志里频繁出现Using temporary除了调大这两个参数更该检查 SQL 能不能少用临时表。例如GROUP BY和非严格模式的DISTINCT都会隐式创建临时表能改成索引分组是最好的。4.3 连接数与线程模型连接数配置也经常被忽略。默认max_connections是 151应用连接池开得大一点就可能把数据库打满。但直接把max_connections调到 2000 并不是好主意因为每个连接都要分配线程和内存连接数越多上下文切换越严重。更好的做法是让应用层的连接池控制在一个合理范围比如单实例数据库连接池 50 到 200 之间同时把max_connections留出 20% 余量。MySQL 8.0 的动态线程池在某些场景能降低线程切换开销但如果是经典的主从架构建议先观察Threads_running和Threads_connected两个状态值如果Threads_running经常大于 CPU 核数说明 SQL 并发执行效率不够这往往不是连接数问题而是慢 SQL 占据 CPU 导致的需要回第 2、3 节找根因。5. 从单机到架构读写分离与数据归档5.1 让主库喘口气读写分离的落地要点当单实例的读压力已经很大优化 SQL 也只是“延迟死刑”时加只读副本做读写分离通常是性价比最高的架构手段。业务上写流量走主库读流量走从库从库通过主从复制同步数据。大表上的聚合报表查询、后台导出、数据分析任务都可以甩给从库。落地时要注意主从延迟。对于千万级大表一个大事务或者 DDL 都可能让从库落后好几秒。因为 MySQL 复制是单线程的 SQL 线程应用日志5.7 之后增强了并行复制能力但还是不建议让业务强依赖读从库的实时一致性。常见的折中方案是“写后读”关键路径强制走主库能容忍秒级延迟的走从库。另外从库不要只配一台按业务线拆分比单纯堆同一份全量数据更实用。5.2 大表归档把历史包袱卸下来很多大表里真正高频访问的只是最近三个月甚至一个月的数据历史数据常年躺着占用磁盘和内存。我做过一个订单表的归档项目单表 1.2 亿行其中超过一年的数据占了大半把这些冷数据迁移到归档库后主表只剩 4000 万行Buffer Pool 命中率明显回升查询耗时降了一个数量级。归档不能直接DELETE FROM删掉几千万行那样会带来巨大的事务和锁开销甚至拖垮主库。稳妥的做法是按主键范围分批删除每批几百行或几千行循环执行并结合SLEEP()控制节奏。更优雅的是借助分区表直接DROP PARTITION清理整段历史数据秒级完成。如果你公司有大数据平台冷数据归档到 ClickHouse 之类的分析引擎也是常见选项这时候注意同步链路的数据一致性就格外重要。5.3 缓存先行挡住重复查询MySQL 再快也快不过本地内存或 Redis。大表性能优化的最后一层通常是把热点数据从数据库里搬出来。比如用户最近订单列表、商品详情、配置类数据这些读取频率高、更新频率低的场景非常适合加一层 Redis 缓存。缓存策略我是用 Cache Aside先读缓存读不到再查数据库然后回填缓存更新时先更新数据库再删除缓存避免并发下缓存和数据库数据不一致。缓存不是银弹它会把“数据库慢查询”变成“缓存穿透、缓存击穿、缓存雪崩”的新问题。千万级数据下缓存 key 一定要设计好粒度避免一个用户一个 key 导致内存膨胀热点 key 可以加随机过期时间查询不存在的数据要做好空值缓存防止恶意流量直接把数据库打死。6. 实战案例千万级订单表优化全过程6.1 现状诊断慢日志里看到的触目惊心前阵子帮一个电商项目做过优化订单表大约 3200 万行服务器 16 核 64GB 内存MySQL 8.0。业务反馈后台订单列表打开要 8 秒导出功能经常超时。我先抓慢日志发现清一色都是这类 SQLSELECT * FROM orders WHERE seller_id 1001 ORDER BY create_time DESC LIMIT 20000, 20;EXPLAIN 之后发现 type 为 ALLrows 估算了 300 多万。原因也简单表上只有一个主键索引没有任何二级索引。卖家查自己的订单本来数据量不算大但没有索引只能全表扫再加上深分页排序自然慢得离谱。6.2 三板斧改造索引、分页、归档三管齐下第一板斧是加联合索引。因为业务查询基本都带 seller_id 和 create_time我建了(seller_id, create_time)联合索引。这个索引既支持卖家维度过滤也能让 ORDER BY create_time 直接走索引排序避免 filesort。同时加了一个覆盖索引的变体把常用查询字段尽量塞进展开减少回表。第二板斧是改深分页。后台列表改成了“滚动翻页”模式前端不再传页码而是传最后一条订单的 create_time 和 id。SQL 变成SELECT * FROM orders WHERE seller_id 1001 AND (create_time, id) (2024-xx-xx 12:00:00, 12345) ORDER BY create_time DESC, id DESC LIMIT 20;第三板斧是归档。业务确认只关心最近一年的订单我跟团队一起把一年前的订单分批迁移到了历史库主表从 3200 万行降到 700 万行。迁移采用主键范围分批迁每批 500 行迁完验证再删期间对线上影响很小。6.3 优化效果与经验沉淀改造后同样条件下的订单列表查询从 8 秒降到 20 毫秒左右单接口耗时下降了 99% 以上导出任务也能在 1 分钟内完成。核心收益其实是三件事叠加出来的索引让扫描行数从百万级降到千级深分页改造让排序代价下降归档让 Buffer Pool 能覆盖更多热点数据。这次优化让我更加确认一点大表优化一定是从查询特征反推设计的先看业务到底怎么查再决定建什么索引、要不要分区分表而不是照着网上模板堆参数。7. 常见问题与避坑指南7.1 高频问题速查表现象大概率原因优先检查项单条查询慢rows 很大索引缺失或失效EXPLAIN 的 type、key数据量增加后越来越慢Buffer Pool 命中率低检查命中率考虑加内存或归档limit 翻页越往后越慢深分页扫描行数过多延迟关联或滚动翻页更新偶尔锁等待更新条件未走索引优化 update/delete 的条件索引并发一高 CPU 飙升慢 SQL 重复执行慢日志、Threads_running磁盘 IO 高排序/临时表落盘sort_buffer_size、临时表优化遇到问题首先不要盲目调参先看监控和慢日志。我见过太多人一上来就把innodb_buffer_pool_size调大一倍结果慢 SQL 还在只是数据库重启时间变长了。参数调整只是辅助SQL 和索引才是大头。7.2 我踩过的一些实战心得最后分享几个个人感悟。第一个是在大表上做 DDL 一定要克制尤其是 5.6 之前的老版本加索引会导致锁表千万级数据可能直接“卡死”业务。MySQL 5.6 之后支持 Online DDL但 5.7 和 8.0 的不同操作仍有各自的锁策略建议用ALTER TABLE ... ALGORITHMINPLACE, LOCKNONE的方式尽量降低影响并选择业务低峰期操作。如果不确定可以先用一个小表模拟验证。第二个是“优化完一条 SQL不代表万事大吉”。大表的数据分布会随着业务变化索引是否仍然有效、分区是否还合理都要定期复盘。我习惯每季度导出一次慢日志 TOP 20对照表结构变化做一次索引评审。很多隐藏的性能问题就是这么一点一点被提前解决的。第三个是敢于跟业务方确认需求。有时候一个接口慢是因为业务要求展示的数据太宽、筛选条件太复杂跟产品讨论后砍掉一两个低频筛选效果比任何技术优化都明显。把技术方案和业务诉求对齐往往能走一条更省力的路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进