上周有个朋友在群里发了个线上问题:一个订单查询接口从最初300毫秒涨到3秒多,数据库CPU直接飙到90%以上。我让他把慢查询日志打开,抓出来一看,一条语句在500万行的订单表上做了DATE_FORMAT(create_time, '%Y-%m-%d')的条件过滤,字段被函数包了一层,索引完全失效,整表扫描加文件排序,不慢才怪。
这种问题在MySQL性能优化里太典型了——真正难的往往不是优化动作本身,而是搞清楚瓶颈到底卡在哪个环节。市面上讲MySQL优化的文章很多,但要么停留在"加索引、调参数"的口号层面,要么一上来就丢一堆命令让人照抄。这篇东西我打算换个思路:从InnoDB的存储结构讲起,把索引为什么快、为什么失效、锁为什么堵、参数为什么不能乱调讲透,再落到具体的排查手段和优化手法上。适合正在被慢查询、锁等待、连接数打满折磨的开发、DBA和运维同学,也适合准备MySQL面试想建立系统知识框架的人。
1. 别急着调参:先把性能问题的"度量标准"建立起来
很多人的第一反应是"调优=改配置"。但在我处理过的大量案例里,真正需要动my.cnf的场景少之又少,绝大多数性能问题靠SQL改写和索引设计就能解决。如果连问题在哪都不知道就调参,那不叫优化,叫碰运气。所以第一步永远是建立观测手段,让问题自己"说话"。
1.1 慢查询日志:第一手证据怎么拿
慢查询日志是排查MySQL性能问题最直接的入口。它记录了执行时间超过阈值的SQL语句,也是定位"到底哪条语句在拖垮数据库"的第一手证据。开启方式很简单,在MySQL里执行:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 'ON';
long_query_time是阈值,单位秒,线上环境建议先设成1。注意这个参数对已有连接不生效,需要新连接才管用,或者直接写进配置文件并重启。log_queries_not_using_indexes会把没有走索引的查询也记进来,这个开关非常有用,因为很多慢查询的SQL本身执行时间可能不到1秒,但全表扫描的风险已经埋下了。
日志文件默认在数据目录下,文件名类似主机名-slow.log。拿到文件后直接用自带工具分析:
bash复制mysqldumpslow -s at -t 20 /var/lib/mysql/机器名-slow.log
-s at表示按平均查询时间排序,-t 20取前20条。这个输出会按SQL模板聚合,把参数替换成抽象的N,很方便看出哪些SQL模式是真正的"大户"。如果环境允许,装一个Percona Toolkit里的pt-query-digest更强大,它会按总执行时间、平均执行时间、扫描行数等维度自动打分排序,直接给出最值得优化的SQL清单。
1.2 用一组指标给数据库做"体检"
慢查询日志解决的是"找出问题SQL",但数据库整体的健康状况还需要一组指标来判断。我每次接手一个性能问题,都会查这几个关键状态值:
| 指标 | 命令/来源 | 经验参考 |
|---|---|---|
| 运行线程数 | SHOW GLOBAL STATUS LIKE 'Threads_running'; |
持续高于10说明有并发堆积 |
| 连接数 | SHOW GLOBAL STATUS LIKE 'Threads_connected'; |
接近max_connections就是危险信号 |
| 缓冲池命中率 | SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%'; |
1 - reads/read_requests,应高于99% |
| 慢查询数量 | SHOW GLOBAL STATUS LIKE 'Slow_queries'; |
暴涨说明SQL出了问题 |
| 临时表数量 | SHOW GLOBAL STATUS LIKE 'Created_tmp_disk_tables'; |
磁盘临时表多说明查询排序/分组有问题 |
其中缓冲池命中率是最容易被忽视的指标。InnoDB的数据都缓存在buffer pool里,逻辑读远快于物理读。如果命中率持续低于95%,说明内存缓冲池配得太小,或者有大量全表扫描在污染缓存。
performance_schema和sys库也是排查利器。MySQL 5.7以上自带sys库,很多巡检可以直接查视图。比如sys.session能看到当前每个会话的执行状态、连接来源、正在执行的SQL;sys.innodb_lock_waits能直接列出锁等待关系。这些信息在出现"数据库卡死"类的故障时,比反复去SHOW PROCESSLIST高效得多。
1.3 一套可复用的排查顺序
踩过的坑多了以后,我总结出一个固定排查顺序,新接手任何MySQL性能问题都按这个来,基本不会跑偏:
- 先看慢查询日志,锁定占用资源最多的SQL模式。
- 对慢SQL跑
EXPLAIN,看执行计划,是否全表扫描、是否文件排序、估计扫描行数多大。 - 检查锁等待和长事务:查
information_schema.innodb_trx、sys.innodb_lock_waits。 - 看连接数和运行线程数,确认是"慢"还是"堵"——慢是SQL执行慢,堵是并发资源耗尽。
- 最后才看系统层面的CPU、IO、内存指标,排除硬件瓶颈。
这个顺序的本质是"从最可能、最便宜的方案开始排除"。SQL和索引问题是查起来最容易、修起来最便宜的,如果一上来就怀疑硬件、怀疑配置,很容易陷入被动。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. InnoDB的存储骨架:B+树、聚簇索引与回表代价
要真正理解索引设计,必须回到InnoDB最底层的存储结构。很多优化方案看起来"差不多",但执行计划天差地别,根源就在引擎层的实现细节上。
2.1 从B+树结构算一笔账
InnoDB的数据页默认大小是16KB,数据以B+树的形式组织。B+树的非叶子节点只存索引键值和指向子节点的指针,不存数据,所以它的分支因子非常大。
算一笔账就懂了:假设主键是BIGINT类型,占8字节,子节点指针占6字节,一个索引项约14字节。那么一个16KB的页能存放约16384 / 14 ≈ 1170个索引项。也就是说,一棵高度为2的B+树,根节点能分出1170个子节点;如果每行数据平均1KB,每个叶子页能存16行,那么高度为2的树能存1170 × 16 ≈ 1.8万行;高度为3时,能存1170 × 1170 × 16 ≈ 2190万行。
这意味着什么?一张2000万行的表,通过主键查一条记录,只需要3次IO就能从根节点走到叶子页。这就是索引"快"的本质——B+树的层数极少,查询代价基本恒定。所以做优化时,只要能让查询走对索引,几千万行和几万行的体验差别并不大。
2.2 聚簇索引与二级索引:回表是怎么发生的
InnoDB是聚簇索引组织表。表本身就是一棵以主键为键值的B+树,叶子节点直接存放整行数据。二级索引(普通索引)的叶子节点不存数据,只存主键值。
这就带来一个关键概念:回表。当你通过二级索引查询时,先在二级索引的B+树里找到主键值,再拿着主键到聚簇索引里找完整行。这个过程多了一次索引查找。
举个例子,表t有主键id,二级索引idx_name(name),执行:
sql复制SELECT * FROM t WHERE name = '张三';
这条查询先在idx_name中找到主键id,再回表查完整行。如果命中5行,就要回表5次。回表本身不是大问题,但如果有几百上千行满足条件,代价就上来了。更深的问题是:如果查询条件是WHERE name LIKE '%张三%'这种非前缀模糊匹配,二级索引根本用不上,直接全表扫描。
2.3 覆盖索引、索引下推与最左前缀
理解了回表,就知道覆盖索引的意义了:如果二级索引的叶子节点已经包含查询需要的所有字段,就不需要回表。比如:
sql复制SELECT id, name FROM t WHERE name = '张三';
idx_name(name)的叶子节点存了id和name两个值,查询结果全都在索引里,EXPLAIN的Extra列会显示Using index,不需要回表。这就是为什么很多查询会建议把select的字段"塞进"联合索引里,形成覆盖索引,虽然会增加索引体积,但能省掉大量随机IO。
索引下推(Index Condition Pushdown,ICP)是在MySQL 5.6引入的优化。假设联合索引(name, age),查询是WHERE name LIKE '张%' AND age > 20。传统流程是:存储引擎根据name LIKE '张%'找到一批主键值,然后回表,再由Server层过滤age > 20。开启ICP后,存储引擎会在索引遍历过程中直接判断age > 20,提前过滤掉不满足条件的记录,减少回表次数。EXPLAIN里Extra列出现Using index condition就是触发了ICP。
最左前缀原则也在这里体现得淋漓尽致。联合索引(a, b, c)等价于建立了a、(a,b)、(a,b,c)三个索引。但如果你直接按b或c查询,这个索引完全帮不上忙,因为B+树先按a排序,a相同才按b排序。这个原则是设计联合索引时的"宪法",我见过太多开发随手建了个(b, a)的索引,导致明明有索引却全表扫描的案例。
2.4 一个容易被忽视的索引失效场景:字段上的表达式
回到开头烫伤我的那个案例。WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-06-01'这类写法,问题是索引里存的是create_time的原始值,不是格式化后的字符串。MySQL必须对每一行的create_time先执行函数计算,才能比较条件,这个过程索引完全无法参与,只能全表扫描。
同理,WHERE id + 5 = 10看似只做了个简单的算术,但它也是对id字段做了表达式运算,索引失效。正确写法是WHERE id = 5。这个坑在开发里非常常见,尤其是写习惯了ORM或者从其他数据库转过来的同学,很容易写出"看起来没问题"但实际让索引废掉的SQL。
经验之谈:判断一条SQL能不能用上索引,标准很简单——索引列是否被原样出现在比较表达式中。 只要索引列外面套了任何函数、计算、类型转换,索引基本就废了。需要日期范围查询,就老老实实用create_time >= '2024-06-01 00:00:00' AND create_time < '2024-06-02 00:00:00'这种区间写法,既走了索引,又表达了相同语义。
3. 让索引真正生效:EXPLAIN、深分页与三类索引杀手
慢查询日志会告诉你"哪些SQL慢",但不会告诉你"为什么慢"。这一步要交给EXPLAIN。它能把MySQL优化器选择的执行方案摊开给你看:用了哪个索引、扫描了多少行、有没有做文件排序、有没有用临时表。
3.1 EXPLAIN里真正需要盯住的字段
EXPLAIN输出有很多列,但日常优化我重点盯四个:type、key、rows、Extra。
type表示访问类型,从好到坏大致是:system(系统表,几乎见不到)> const(主键/唯一索引等值查询)> eq_ref(联表时被驱动表用主键或唯一索引查找)> ref(普通索引等值查询)> range(索引范围扫描)> index(全索引扫描)> ALL(全表扫描)。优化目标是至少到range级别,ALL和index要尽量避免。
key列显示实际使用的索引。如果为NULL,说明这条SQL没有用索引,需要检查原因。rows是优化器估算的需要扫描的行数,这个数字和实际差距一般不会太大,如果rows上百万而查询只是等值条件,说明索引设计有问题。
Extra列信息量很大,常见几个值必须认识:
| Extra值 | 含义 | 处理建议 |
|---|---|---|
| Using index | 覆盖索引,无需回表 | 理想状态 |
| Using where | 存储引擎返回后Server层再过滤 | 检查是否有更合适的索引 |
| Using filesort | 文件排序,性能较差 | 优化排序字段建立索引 |
| Using temporary | 使用了临时表 | 优化GROUP BY / DISTINCT |
| Using index condition | 索引下推已生效 | 正常 |
看到Using filesort尤其需要警惕。MySQL的排序如果没法通过索引顺序直接获得,就会在sort buffer里排序,数据量大还要落盘,非常消耗性能。比如ORDER BY create_time,如果create_time不在任何索引的第一列或未被索引覆盖,基本逃不掉文件排序。
3.2 深分页优化:limit千万偏移量为什么慢
分页查询是慢查询的重灾区。典型的翻页SQL:
sql复制SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 20;
这条SQL慢在哪?MySQL要按create_time排序后,扫描出前1000020行,然后丢弃前1000000行,只返回最后20行。这不仅意味着要扫描百万行,还要走文件排序。更麻烦的是,如果create_time上有索引,排序可以走索引,但前1000000个主键的值还是需要一个个回表拿完整行,大量随机IO集中在LIMIT后面这段。
两种常见优化方式:
第一种是延迟关联。先只查询主键,再与原表关联取完整数据:
sql复制SELECT t1.* FROM orders t1
INNER JOIN (
SELECT id FROM orders ORDER BY create_time DESC LIMIT 1000000, 20
) t2 ON t1.id = t2.id;
核心思路是让内层子查询走覆盖索引,只取id,避免回表百万次。这个改动在数据量大时效果极其明显,可能从几秒降到几百毫秒。
第二种是游标式分页,适合"上一页/下一页"场景,不适合跳页:
sql复制SELECT * FROM orders
WHERE create_time < '2024-05-20 10:00:00' -- 上一页最后一条的时间
ORDER BY create_time DESC LIMIT 20;
游标分页的本质是"无偏移",每页查询都从上一页的边界开始,扫描行数恒定。这也是为什么很多ToB系统、后台管理系统都在用。
3.3 OR、隐式类型转换、函数包裹:三类"索引杀手"
把高频踩坑的索引失效场景归个类,这三类最典型:
第一类是函数包裹。刚才说过了,索引列上套函数直接失效。包括DATE_FORMAT、YEAR、MONTH、LENGTH、SUBSTR等。优化建议:把函数挪到常量的那一边,或者改成等价的区间查询。
第二类是隐式类型转换。最常见的是字符串字段和数字比较。假设phone是VARCHAR类型,执行:
sql复制SELECT * FROM users WHERE phone = 13800138000;
MySQL在比较时会把字符串列隐式转换成数字再比较,相当于对phone列做了CAST(phone AS SIGNED),索引失效。正确写法是WHERE phone = '13800138000',保持类型一致。反过来,如果字段是数字类型,用字符串查询通常不会失效,但也不建议依赖这个行为。
第三类是OR条件。WHERE a = 'x' OR b = 'y'这类写法有个隐藏问题:如果a和b上都有索引,MySQL 5.0以上可能走Index Merge合并两个索引,最终结果还有可能去重;但更常见的情况是优化器直接放弃索引,选择全表扫描,因为它评估合并索引的代价更高。
关于热搜词里那个"mysql的or能去重吗"——严格说,OR本身不参与去重逻辑,去重与否取决于优化器选择的执行计划。如果走了Index Merge,两个结果集合并时需要去重;如果是全表扫描,本来就不会有重复行返回。但问题不在于去不去重,而在于OR很难稳定命中索引。更可靠的改法是拆成两个查询用UNION ALL合并(UNION会去重但代价更高),或者改写为IN条件:
sql复制SELECT * FROM users WHERE a = 'x'
UNION ALL
SELECT * FROM users WHERE b = 'y';
如果业务上不需要去重,UNION ALL比UNION快很多,因为省掉了去重排序的环节。
3.4 排序与联接的优化思路
ORDER BY排序优化有个基本原则:排序字段尽量匹配索引顺序。如果查询是WHERE a = ? ORDER BY b,那么联合索引(a, b)最理想——先用a等值过滤,b的有序性直接满足排序,避免filesort。但如果索引是(b, a)或只有a的索引,排序就必须额外进行。
联表查询的优化核心是驱动顺序。小表驱动大表,被驱动表的关联字段必须有索引。EXPLAIN结果第一行就是驱动表,当两张表关联字段都无索引时,会出现经典的全表扫描嵌套循环,灾难级性能。优化的第一步永远是检查ON条件的字段有没有索引,优先在被驱动表上建。
COUNT(*)这个统计操作也要注意。MyISAM有独立的计数缓存,InnoDB没有,因为MVCC机制下不同事务看到的不同版本行数不同。所以InnoDB的COUNT(*)只能逐行数,根本没捷径。要高频计数,只能用独立的计数表或Redis等外部方案维护。
4. 锁与事务:并发上不去的隐形天花板怎么查
SQL本身不慢,但系统吞吐量就是上不去,数据库到处是"堵"的症状,这一般就是锁和事务的问题了。MySQL锁相关的知识比较抽象,但排查手段是可以具体化的。
4.1 锁表到底是怎么发生的:MDL、行锁、间隙锁
InnoDB默认是行级锁,按理说并发度很高。但线上最常见的锁问题根本不是行锁,而是元数据锁(MDL)。每次执行DDL操作(ALTER TABLE、DROP TABLE)前,MySQL需要拿到表的MDL写锁;执行期间所有读写请求都会被阻塞,表现为"表被锁死"。
另一种常见的锁表是SELECT ... FOR UPDATE和UPDATE操作没有走索引,导致行锁升级为全表记录级锁。注意,InnoDB的行锁是通过索引项实现的——如果更新条件没走索引,存储引擎会扫描所有聚簇索引记录并逐一加锁,效果上跟表锁差不多。所以一条本该通过索引更新几行的UPDATE,因为索引失效,把所有行都锁了。排查时先看EXPLAIN,如果更新语句是ALL全表扫描,锁升级几乎是必然的。
间隙锁(Gap Lock)和Next-Key Lock是InnoDB在可重复读(RR)隔离级别下用来防幻读的机制。它会锁定一个范围而不是具体的记录。这在并发插入时容易造成"插入意向锁"互相等待,尤其是在范围条件比较宽的时候。比如UPDATE orders SET status = 1 WHERE amount > 1000,在没有索引的amount列上执行,它的间隙锁覆盖的范围极大,后续的插入全部被阻塞。这也是为什么InnoDB在RC隔离级别下没有间隙锁,很多大型互联网公司会特意把隔离级别从默认的RR降到RC,换取更高的并发度。
4.2 死锁的排查路径:从锁等待到死锁日志
死锁的典型特征是:数据库报错"Deadlock found when trying to get lock",然后其中一个事务被自动回滚。死锁本身不一定可怕,可怕的是你搞不清楚为什么死锁、怎么预防。
排查死锁的路径很固定。出现死锁时,先看死锁日志:
sql复制SHOW ENGINE INNODB STATUS;
日志的LATEST DETECTED DEADLOCK部分会列出两个事务各自持有和等待的锁,以及正在执行的SQL。最常见的死锁模式是两个事务以不同顺序更新多张表或同一张表的不同行。
比如事务A先更新id=1再更新id=2,事务B先更新id=2再更新id=1,两个事务互相持有对方要的资源,就形成了环形等待。预防办法有两个层面:一是业务层面让所有事务按照相同顺序访问资源;二是尽量缩短事务时间,减少锁的持有时间,让冲突窗口变小。
平时没有死锁但系统变慢时,查锁等待关系很重要。MySQL 5.7以上直接查sys库:
sql复制SELECT * FROM sys.innodb_lock_waits\G
这个视图会列出谁在等谁的锁、等了多久、涉及哪个表哪个SQL。再用information_schema.innodb_trx看事务是否长时间未提交:
sql复制SELECT * FROM information_schema.innodb_trx\G
重点看trx_started和trx_rows_locked。执行时间超过几秒甚至几分钟的写事务就是"长事务",它们持续持有锁,是阻塞的源头。发现了就直接KILL对应掉连接,先解除阻塞再分析原因。
4.3 事务隔离级别与长事务的性能代价
隔离级别对性能的影响经常被低估。MySQL默认的RR级别提供了可重复读和防幻读,但代价是间隙锁。如果业务能接受读已提交(RC)级别下的不可重复读(即同一事务内两次相同查询结果可能不一致),切换到RC能显著减少锁冲突。
切换隔离级别很简单:
sql复制SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
但要确认应用层代码不依赖可重复读。比如事务内先查再根据结果更新,在RC下可能查到不同的值,导致业务逻辑出错。这个要全量评估,不是DBA拍脑袋就能改的。
长事务的另一个隐患是undo log堆积。InnoDB的MVCC需要保留旧版本数据,供长事务回滚和一致性读使用。长事务不结束,undo log就无法清理,最终导致undo表空间膨胀、purge线程跟不上,整个数据库性能下降。有些人会发现"数据库越跑越慢,重启就好了",往往就是这个原因——重启把所有长事务干掉了。
所以监控长事务非常有必要。我习惯写个定时巡检,每10分钟执行一次SELECT * FROM information_schema.innodb_trx WHERE trx_started < NOW() - INTERVAL 30 SECOND,发现长时间未提交的事务就告警,这样能把很多锁问题消灭在萌芽阶段。
5. 参数调优的最佳实践:哪些能改、哪些别乱动
到了参数调优这一步,就把前面所有问题都排除之后了。给MySQL调参,核心原则是:改之前先回答一个问题——这个参数改动了,它会带来什么副作用? 如果答不上来,就先别改。
5.1 innodb_buffer_pool_size:到底给多大
innodb_buffer_pool_size是MySQL最重要的性能参数,它决定InnoDB缓存数据和索引的内存大小。合理值一般建议是机器可用内存的60%到75%。比如一台32G内存的机器,如果MySQL是主要服务,可以设20G到24G;如果是混部的机器,就得按实际内存占用往下压,总内存不要超过物理内存导致swap。
这个参数的合理性怎么验证?看命中率:
sql复制SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read_%';
命中率 = 1 - Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests。注意Innodb_buffer_pool_read_requests是逻辑读,Innodb_buffer_pool_reads是真正落到磁盘的物理读。如果命中率长期低于95%,优先加大buffer pool;如果已经99%以上,继续加大内存收益不大。
buffer pool内存很大时,还建议配置innodb_buffer_pool_instances,把缓冲池拆成多个实例,不同线程可以并发访问不同实例,减少内部锁竞争。8G以上内存建议设8个实例。
5.2 日志刷盘策略:性能与安全的取舍
innodb_flush_log_at_trx_commit这个参数直接决定事务提交时redo log的刷盘方式,取值有0、1、2三种:
| 取值 | 行为 | 数据安全 | 性能 |
|---|---|---|---|
| 0 | 每秒刷盘一次 | 最多丢1秒事务 | 最快 |
| 1 | 每次事务提交都刷盘 | 不丢已提交事务 | 最慢 |
| 2 | 每次提交写入操作系统缓存,每秒刷盘 | 断电可能丢事务 | 接近0 |
默认值是1,保证任何已提交事务都不丢失。这也是金融、支付类业务的底线配置。但如果你做的是日志、文章、评论这类允许丢失少量数据的业务,改成2甚至0,写入性能可以提升数倍。这个参数每次遇到双十一大促前优化都是重点调整项之一。
另一个容易被忽略但很关键的是innodb_io_capacity,它决定了InnoDB后台刷脏页的IO上限。如果设太低,脏页刷得慢,会堆积在内存里,影响性能;设太高,后台刷盘可能会抢业务IO。机械盘建议200-400,SSD建议2000-10000。配置前先用fio这类工具了解你磁盘的真实随机读写能力。
5.3 线程、连接与排序缓冲区:别被"越大越好"误导
max_connections也许是改动最频繁但最无效的参数。很多开发一看到"连接数过多"就往上调,结果越调越卡。
为什么?MySQL每个连接都要占用线程和内存,max_connections设得过大,大量并发连接会引发线程频繁上下文切换,CPU消耗在调度上而不是真正执行SQL上。更合理的做法是:观察Threads_connected的历史峰值,然后在那个基础上留30%余量。比如峰值是300,设400就够了。如果业务确实需要更多并发,优先考虑引入连接池(应用侧)或数据库中间件,而不是无脑调高上限。
sort_buffer_size、join_buffer_size、read_buffer_size这类参数有个共性:它们都是每连接独享的。设得越大,单个查询可能越快,但并发一旦上来,内存总量呈线性增长。比如sort_buffer_size设成64MB,同时有100个连接在排序,那就是6.4G内存瞬间没了。所以这类参数宁小勿大,一般保持默认8MB-16MB即可,让SQL本身尽量避免排序才是正道。
有一个思路值得所有做参数调优的人借鉴:改参数前,先记录改动前的性能基线(QPS、TPS、CPU、慢查询数),改完后对比基线。如果没有提升甚至下降了,果断回滚。不要同时改多个参数,否则出了问题根本定位不了是哪个改动引起的。
6. 从单机到集群:主从、读写分离与分库分表的性能边界
当单机性能已经压榨到极限,架构层面的优化就该提上日程了。但架构方案是个系统工程,每个方案都有它要支付的"税",不能只听宣传就上。
6.1 主从复制的性能与可靠性的平衡
MySQL主从复制的原理不算复杂:主库把变更写入binlog,从库拉取binlog并重放到自己的数据上。这里的性能平衡点在于binlog格式和同步方式。
binlog_format主流是ROW和STATEMENT两种。STATEMENT格式只记录SQL语句,binlog体积小,复制效率高,但遇到UUID()、NOW()这类非确定性函数,主从执行结果可能不一致。ROW格式记录的是每行数据的变更,安全性高,但binlog体积会暴涨,尤其在大批量UPDATE/DELETE时,一条SQL可能产生海量binlog。现在生产环境基本都用ROW,因为它配合binlog_row_image=FULL能保证数据一致性,排查问题也更方便。
同步方式上,默认的异步复制无法保证主库提交后从库一定收到日志。半同步复制(Semisynchronous Replication)加了确认机制:主库提交事务后必须等至少一个从库确认收到binlog,才向客户端返回成功。这减少了数据丢失风险,但会把主库提交延迟拉高,性能取决于网络往返时间。选型时要在"丢数据风险"和"主库延迟"之间做权衡,没有银弹。
6.2 读写分离落地与主从延迟
读写分离是提升读多写少场景吞吐量的常见手段。应用层把写操作发到主库,读操作分发到从库;中间件层面可以用Proxy实现,或者应用内用多数据源路由。
读写分离最怕的是主从延迟。MySQL默认的复制是单线程重放,遇到大量写操作时从库可能延迟几秒甚至几十秒。用户在写入后立刻读取,可能读到旧数据。缓解手段有:
- 关键读请求强制走主库,比如"买家下单后立即查订单"这个请求必须主库读。
- 开启并行复制:MySQL 8.0用
replica_parallel_workers参数配置多线程并行应用binlog,通常能显著降低延迟。 - 业务上接受"近实时一致",从库只承担对一致性要求不高的统计查询。
还要注意从库的硬件配置不能太差。有些团队给从库配的机器比主库低一个档次,读写分离后从库扛不住读流量,反而是新的瓶颈。
6.3 分库分表:什么时候该上、什么时候不该上
分库分表是性能优化的"最后手段",但很多人把它当"第一手段"。我见过不少项目,单表几百万行就嚷嚷着要分库分表,实际上把索引建好、SQL优化好就能扛住几千万行。
分库分表真正要解决的痛点是:单表数据量过大导致索引深度增加、写热点集中、以及单库连接数/QPS达到物理极限。一般来说,单行平均1KB的数据,单表超过5000万行到1亿行时,才需要考虑水平拆分。
分片方案要考虑分片键的选择。最常见的坑是选了不合适的分布列(比如订单表用订单状态做分片键),导致数据倾斜,80%的流量打到同一个分片上,分片了个寂寞。订单表用user_id(买家id)分片通常是最稳的选择,可以保单个用户的所有订单在同一个分片中,便于查询和事务。
更现实的问题是,分库分表后,跨分片的JOIN、COUNT(*)、ORDER BY ... LIMIT都变得非常麻烦。以前一条SQL能搞定的事,现在要并发查多个分片再在应用层做聚合,复杂度直线上升。ShardingSphere、MyCat这类中间件能屏蔽部分复杂度,但代价是分布式事务、全局主键、全局唯一约束这类新问题接踵而至。
所以我的建议是:先做尽单机优化、缓存优化、归档历史数据、读写分离,最后再动分库分表这个"手术"。 如果决定要分,早期就定好分片键和扩容策略,别等数据量铺开了再迁移,那时候代价是以"月"为单位计的。
顺便提一句线上大表的表结构变更。在分库分表之前,很多人会卡在ALTER TABLE锁表问题上。直接执行ALTER TABLE在大表上会长时间持有MDL锁,阻塞业务读写。方案是使用pt-online-schema-change或gh-ost这类在线DDL工具,通过创建临时表、拷贝数据、切换的流程完成变更,把对业务的影响降到最低。
我自己在实际项目中还有一个体会:架构层面的优化方案,一定要有回滚预案和容量预估。比如主从切换前,先验证从库的复制延迟是0,并且数据一致性校验通过;分库分表上线前,先做全量压测,确认新方案的吞吐量和延迟优于现状。优化是给系统减压,不是给团队添堵。
回头看开头那个订单查询的案例,最终解决也就两步:把DATE_FORMAT(create_time) = '2024-06-01'改成create_time >= '2024-06-01 00:00:00' AND create_time < '2024-06-02 00:00:00',再给create_time加了个组合索引,查询从3秒多降回200毫秒。全程没有改任何系统参数——这就是绝大多数MySQL性能优化工作的真实缩影:低垂的果实永远在SQL和索引这边,架构和参数是果实摘完之后才需要考虑的事。
