MySQL 这名字,干开发的基本都绕不开。我当年啃这套数据库知识的时候,最烦的就是知识点太散:索引讲一遍,事务讲一遍,锁又讲一遍,真到排查问题的时候脑子里全是浆糊。后来我干脆跟着一套以开发应用为导向的专题课,从基础到原理系统过了一遍,边学边记,慢慢沉淀成一份自己的“MySQL 专题笔记”。这个过程中最大的体会就是:很多人不是看不懂 MySQL 文档,而是缺少一条能把索引、事务、日志、锁串起来的线索。这份笔记恰好就是干这个事的,它不讲高可用集群,不聊运维部署,聚焦的就是从 SQL 开发视角必须吃透的底层机制和实战技巧。
今天我把这份笔记里最核心的几条线整理出来,不是贴一遍官方文档,而是把我在学习过程中反复确认过的关键细节,以及笔记里对“为什么”的解释一并写进来。不管是准备面试、还是手上正维护着慢查询频发的业务库,这套思路应该都能给你一些启发。
1. 这份专题笔记的边界:不碰运维,但哪些内容必须吃透
1.1 为什么学习笔记要刻意划清边界
MySQL 这个生态实在太大了,从一条 SQL 怎么执行,到主从复制延迟怎么排查,到分库分表中间件选型,再到备份恢复策略,任意一块都能写一本厚书。如果不划清边界,学习的时候特别容易陷入焦虑——今天看索引,明天又跳到集群方案,最后哪块都不深。
我整理的这套笔记,边界非常明确:不含运维篇。所谓“不含运维”,不是指运维知识不重要,而是要先把开发侧的底子打牢。具体来说,笔记主要覆盖这几条主线:
- 存储引擎与索引结构,重点是 InnoDB
- 事务、隔离级别、MVCC 与锁机制
- SQL 优化与执行计划,慢查询的排查思路
- 日志系统(redo log、binlog、undo log)与崩溃恢复的逻辑
- 日常开发里高频踩坑的点,比如隐式转换、字符集问题、深分页
我见过不少同学,刚工作就在排查主从延迟、配置半同步复制,但问到底层一条 update 语句执行时 binlog 和 redo log 谁先写,反而答不上来。不是说他能力不行,而是学习顺序反了。开发侧的原理都没理清,直接冲进运维深水区,遇到问题只能靠试。
1.2 笔记里的核心假设:默认 InnoDB + 最新常用版本
另一个需要提前说清楚的,是这份笔记的默认前提。MySQL 的存储引擎有好几种,MyISAM、Memory、Archive 都还在用,但当下绝大多数业务场景用的都是 InnoDB。所以笔记里凡是涉及事务、锁、崩溃恢复的内容,默认都建立在 InnoDB 之上。版本方面以 5.7 和 8.0 的共性行为为主,个别差异(比如 8.0 移除了查询缓存)会单独标注。
这里我想多说一句:学习 MySQL 的时候,一定要先明确版本和引擎,否则网上搜到的结论经常互相打架。例如“select count(*) 在 MyISAM 下直接读计数,在 InnoDB 下要扫索引”,这个差异只要版本或引擎不统一,讨论就毫无意义。我的笔记在每一章开头都会写上“本章默认环境”,就是为了避免这种张冠李戴。
1.3 这份笔记适合谁,以及正确的打开方式
如果你是刚开始系统学 MySQL 的开发者,这份笔记的价值在于帮你建立主线:存储是基础,索引是核心,事务和锁是并发场景的钥匙,日志是理解可靠性的窗口。如果你是工作了两三年的后端开发,这份笔记更像一个“查漏补缺清单”,很多你平时写 SQL 时靠直觉判断的东西,这里都能找到原理层面的解释。
我不建议拿着笔记从头背到尾,更有效的做法是:带着问题去读。比如你最近被慢查询困扰,就先看执行计划和索引那几章;你在做订单系统,重点研究事务隔离级别和行锁、间隙锁的加锁范围。笔记的价值不是替代思考,而是把思考需要的零件都摆在桌上,用的时候能快速捡起来。
2. 从页到 B+ Tree:InnoDB 索引设计的底层逻辑
2.1 数据页是理解索引的第一块拼图
先忘掉索引,想想一个最基本的问题:MySQL 的表数据在磁盘上到底是什么形态?答案是页。InnoDB 默认把数据按照 16KB 一个页来组织,页里面再分记录,页和页之间通过双向链表连接,同一个页内的记录又按照主键顺序形成单向链表。平时我们说的“查找一条记录”,在存储引擎层面其实是“从某个页的某个槽位里定位一条记录”。
为什么要先讲页?因为很多索引相关的行为,离开页就解释不通。比如一次磁盘 I/O 的最小单位就是一个页;为什么 B+ Tree 一个节点可以放很多关键字?因为它一个节点就对应一个页,而一个 16KB 的页能存上千个关键字。理解了页,你就知道为什么 InnoDB 选择 B+ Tree 而不是二叉搜索树或红黑树——树的层数直接决定了查询要读取多少个页,层数越少,磁盘 I/O 次数就越少。
以三层 B+ Tree 为例,根节点在内存常驻,第二层可能几百个页,第三层能存放的叶子页数量非常可观。简单粗算一下:假设叶子页里每条记录 1KB,一个页放 16 条记录,三层树能支撑的记录量就是百万到千万级别。这正是 B+ Tree 在关系型数据库里几乎一统天下的原因:它用很低的树高,换来了非常可控的 I/O 次数。
2.2 聚簇索引和二级索引:两次检索到底是怎么回事
InnoDB 的主键索引是聚簇索引,意思很直接:数据行的物理存储顺序,就是主键索引叶子节点的逻辑顺序。换句话说,聚簇索引的叶子节点直接保存了整行数据。所以只要你根据主键查数据,走聚簇索引,一次树形检索就能把完整记录捞出来。
而二级索引(普通索引、联合索引)的叶子节点,保存的并不是整行数据,而是索引列的值加上主键值。查询的时候如果索引列不满足“覆盖索引”条件,就要拿着主键再去聚簇索引里查一次,这个过程俗称回表。回表不是必然发生的,但一旦发生,就多了一次 B+ Tree 检索。
很多人搞混一个概念:二级索引里存的是“主键值”而不是“行指针”。这个设计的妙处在于,即使触发了页分裂、行移动,二级索引的叶子也不需要跟着改指针,因为主键值一旦生成就不会变(自增主键场景下基本如此)。代价则是回表查询要多走一次索引。这也是为什么“尽量别用很大的 varchar 做主键”“主键长度越小,二级索引越瘦”这些建议,本质都是在控制回表成本。
2.3 联合索引的最左前缀原则,背结论不如懂结构
关于联合索引,几乎每一篇教程都会说“最左前缀”,但很多人只是把这个词当一个规则背下来,并不理解为什么。我在笔记里花了不少篇幅去还原这个为什么。
假设你在 (a, b, c) 三个列上建了一个联合索引。B+ Tree 的排序规则是先按 a 排序,a 相同的再按 b 排序,b 相同的再按 c 排序。这种复合排序结构导致:
- 走这个索引的前提是查询条件里能确定出 a 的比较范围
- 如果只查 b 或只查 c,那 b、c 列在索引里虽然是排好序的,但排序对于“单独查询”来说没有意义,因为它们的顺序是嵌套在 a 之下的
从数据结构的视角看,所谓“最左前缀”,就是你在 B+ Tree 上无法从中间层直接开始定位,必须先命中最左边的列。比如查询条件里是 a 和 c,而没带 b,那么 a 能用于索引定位,c 就无法继续利用索引有序性,只能作为 a 条件筛选结果后的回表后过滤。这个细节在数据库里被称为“索引失效的一部分”,其实不是索引完全失效,而是索引的利用不完整。
我自己写 SQL 时养成的习惯是:建联合索引前先盘点高频查询的条件组合,把等值查询的列放在最前面,把范围查询的列放后面,因为范围列之后的索引列很难派上用场。
2.4 覆盖索引、索引下推,两个容易被忽略的加速点
回表既然费成本,那就想办法不回表。如果查询需要的所有列都已经存在于二级索引的叶子节点上(包括索引列和主键),那么引擎在二级索引上就能把数据拿完,这就是覆盖索引。典型场景:select a, b from t where a = 1,只要 (a,b) 上有联合索引,这个查询完全可以不走聚簇索引。
再说索引下推。MySQL 5.6 引入的一个优化,叫 Index Condition Pushdown,简称 ICP。在没有 ICP 之前,二级索引查到的每条记录要先回表,拿到完整行数据后,再对索引之外的条件做过滤。有了 ICP 之后,存储引擎层会先利用索引列本身能判断的条件,把不满足的记录直接在索引层面过滤掉,减少回表次数。
举个例子,联合索引 (name, age),查询条件是 name like '张%' and age > 18。没有 ICP 时,引擎先按 name 的模糊匹配逻辑把一堆记录回表,再过滤 age;有 ICP 时,在索引遍历过程中,age > 18 这个判断在索引层面就能做,大量不达标的记录根本不需要回表。这是我做 SQL 优化时非常喜欢检查的一项,因为它是“零成本优化”,只需要确认版本支持、执行计划里出现 Using index condition 就可以了。
2.5 索引失效的高频场景:别让优化器想帮你都帮不了
笔记里专门列了一张表,记录我踩过或见过的索引失效场景。这里挑三个最常见的:
- 对索引列使用函数或计算,比如 where date(create_time) = '2024-01-01',create_time 上的索引会失效。正确写法是范围条件 create_time >= '2024-01-01 00:00:00' and create_time < '2024-01-02 00:00:00'
- 隐式类型转换,最常见的是 varchar 列和数字比较。比如 phone 列是 varchar 类型,where phone = 13800000000,MySQL 会把列值转换成数字进行比较,导致索引上发生全量转换,索引失效
- 前导通配符的 like,比如 like '%abc',因为 B+ Tree 是按前缀排序的,后模糊匹配无法定位起始位置
还有一种情况很误导人:索引没有失效,但因为返回行数占比太高,优化器觉得回表成本高于全表扫描,直接选了全表。这种情况故意用 force index 不一定是最优解,更合理的是先想清楚为什么选择性这么差,比如性别列只有两个值,建索引的实际收益确实有限。
3. 事务、隔离级别与 MVCC:并发读写背后的秩序规则
3.1 ACID 不是口号,每个特性都有自己的负责机制
讲事务,通常都会从 ACID 说起。但我在笔记里提醒自己:ACID 四个属性不是并列关系,它们是由不同机制分工保证的。原子性由 undo log 保证,事务执行到一半失败,可以借助 undo log 回滚到事务开始前的状态;持久性由 redo log 保证,即使宕机,已提交事务的重做日志也能在恢复阶段重放;隔离性由锁和 MVCC 共同保证;一致性则是应用层约束配合数据库机制合力达成的结果。
想明白这一层,再看事务就很清晰了:如果你怀疑某个事务的行为不对,先要判断它是原子性回滚的问题,还是隔离性没达到预期的问题,定位方向完全不一样。
3.2 四种隔离级别到底隔离了什么
SQL 标准定义了四种隔离级别,从低到高分别是读未提交、读已提交、可重复读、串行化。每一种级别都要回答同一个问题:事务期间,其他事务的修改对当前事务可见到什么程度。
读未提交的问题是脏读,事务 A 能读到事务 B 还没提交的数据,一旦 B 回滚,A 就读到了“不存在”的历史。读已提交解决了脏读,但带来不可重复读:同一个事务里,两次 select 同一条记录,结果可能不同,因为另一事务在这期间提交了新值。可重复读解决了不可重复读,保证事务内多次读取同一数据结果一致,但可能产生幻读——范围查询时,另一事务插入了一条满足条件的新记录,导致当前事务的两次范围查询结果行数不同。串行化最严格,通过强制事务串行执行避免所有并发问题,代价是并发性能大幅下降。
MySQL InnoDB 的默认隔离级别是可重复读,但它通过间隙锁和 MVCC,在很大程度上避免了幻读。注意这里说的是“很大程度上”,不是绝对消灭,后面讲锁的时候我会具体展开。
3.3 MVCC 的核心:undo log 版本链与 ReadView
MVCC 的全称是多版本并发控制,它的核心思想是:一行数据在物理上可能有多个版本,不同事务看到哪个版本,取决于该事务启动时生成的 ReadView。
当一个事务修改某行数据时,InnoDB 并不是直接覆盖旧值,而是先把旧值写入 undo log,然后生成一个新版本。行记录里有一个隐藏字段 DB_ROLL_PTR,指向 undo log 中的旧版本,多个旧版本通过这个指针串成一条版本链。新版本和旧版本在内存/磁盘里都真实存在,只是通过可见性判断决定暴露给哪个事务。
ReadView 就像一个“快照清单”,里面记录了生成时刻未提交事务的列表。判断一个版本可见的规则大致是:
- 版本的事务 ID 比 ReadView 中的最小未提交事务 ID 还小,说明该版本已提交,可见
- 版本的事务 ID 在 ReadView 的活跃事务列表里,说明还未提交,不可见
- 版本的事务 ID 大于 ReadView 的最大事务 ID,说明该版本在当前事务启动之后才生成,不可见
这里最反直觉的点是:可重复读级别下,ReadView 是事务内第一次执行快照读时生成的,之后整个事务都复用这个 ReadView;而读已提交级别下,每次快照读都会生成新的 ReadView。这就是两者在“可重复读”这一点上表现不同的根本原因。
3.4 快照读与当前读:select 的隐藏分支
为什么同样一条 select 语句,有时候像是拿到了历史快照,有时候又能立刻感知到其他事务的提交?因为在 MySQL 里,select 分两类:快照读和当前读。
普通的 select ...(不加锁)是快照读,走 MVCC,读的是版本链里对当前事务可见的版本。而 select ... for update、select ... lock in share mode、insert、update、delete 都属于当前读,读的是数据的最新已提交版本,并且会对相关记录加锁。当前读之所以“当前”,是因为加锁必须针对最新版本操作,不可能去锁一个历史版本。
update 语句的执行流程,本质就是“当前读 + 写入”。先按当前读拿到最新版本,再在内存中修改并生成新版本,同时把旧版本写进 undo log。理解了这层,你就能解释很多怪异现象:比如两条事务同时更新同一行,后到的那个会阻塞等待,因为该行上已经有排他锁;等前一个事务提交后,它读到的是最新版本,然后基于这个最新状态继续操作。
4. SQL 优化实战:从执行计划到慢查询的完整排查链路
4.1 EXPLAIN 核心字段,每个都值得多问一层
遇到慢查询,第一件事永远是 EXPLAIN。但执行计划里的字段实在太多了,我在笔记里挑出几个最关键的,分别记录它们到底想告诉我们什么。
type 字段是访问类型,从好到差大致是 system > const > eq_ref > ref > range > index > ALL。前几个大概率是命中主键或唯一索引,range 代表索引范围扫描,ALL 是全表扫描。看到 ALL 时先不要急着否定,行数少的表全表扫描不一定慢,真正的问题是行数多却扫描了全表。
key 字段表示实际用到的索引,possible_keys 只是候选。一个容易忽略的点是:优化器选择的索引不一定每个字段都吃满,需要结合 key_len 判断到底用到了联合索引的哪几列。key_len 的计算规则(列类型长度 + 是否允许 NULL + 字符集最大字节数)我在笔记里记过一个练习,后来排查问题足够用了。
rows 是优化器估算的需要扫描的行数,Extra 则常出现几个关键词:Using where 表示存储引擎返回后再过滤,Using index 表示覆盖索引,Using index condition 表示索引下推生效,Using filesort 表示排序没有走索引,必须额外做一次文件排序。
4.2 一条真实慢 SQL 的优化过程
笔记里记录了一个很有代表性的例子。某业务表 orders 有大约 500 万行,查询语句大概是:
select order_no, amount, status from orders where create_time between '2024-06-01' and '2024-06-30' and status = 'PAID' order by amount desc limit 20;首次 EXPLAIN 的结果是 type 为 all,rows 估算接近 400 万,Extra 里还有 Using filesort。整体耗时 2.3 秒。
第一步先看 where 条件里哪个列最有筛选价值。create_time 肯定是能区分出一个时间窗口的,于是先建一个 (create_time) 单列索引。再 EXPLAIN,type 变成 range,rows 估算降到 30 万,但因为排序字段 amount 不在索引里,仍然有 Using filesort,耗时降到 1.1 秒。
第二步优化排序。将索引调整为联合索引 (create_time, amount),这样查询在索引扫描范围内取数据时,amount 天然有序,filesort 消失。执行计划里的 Extra 不再有 Using filesort,耗时降到几十毫秒。这里有个细节值得注意:因为索引里包含了 amount,查询回表次数也减少了,因为 status 字段还需要回表确认。
第三步才是真正体现经验的:这个查询还会持续运行,而页面只显示前 20 条。继续看逻辑,如果将 where 条件换成固定业务维度,比如先取某个状态字段做等值过滤,效果会更好。最终方案是联合索引调整成 (status, create_time, amount),把等值条件 status 放最左,接着是范围列 create_time,最后是排序列 amount。这轮优化做完,EXPLAIN 里 type 为 range,key_len 恰好覆盖到 create_time,Extra 干净,查询耗时稳定在 20 毫秒以内。
这个案例给我的启发是:优化 SQL 不是一步到位,而是循环逼近的过程。每改一次索引,都要重新读一次 Explain,观察扫描行数和排序方式是怎么变化的。
4.3 深分页为什么慢,以及常见的三种解法
深分页是开发里最容易遇到的性能陷阱。写法就是 limit 1000000, 20。表面上看只是要 20 条,实际上 MySQL 要先扫描 100 万条记录,然后把前 100 万条全部丢掉,只留下最后 20 条。
我在笔记里整理了三种解法:
- 延迟关联。先通过覆盖索引查出目标页的主键 id 集合,再用 id 去原表关联取出完整行。sql 上用 join 或者子查询都能实现
- 基于游标的分页。where id > 上一页最大 id order by id limit 20,适用于按主键递增翻页的场景,复杂度接近 O(1)
- 如果业务允许,用日期或业务维度做分段查询,避免一个超大范围的统一翻页
游标分页是所有方案里效果最好的,但不是所有业务都适合,比如用户跳转到第 55 页的场景就没法用。这种时候延迟关联通常能兜底。
4.4 优化器选错索引,该怎么办
选错索引的发生频率比想象中高。最典型的情况是:表里有两个索引,一个选择性高但统计信息偏旧,另一个索引表面上更“合适”,实际执行起来却更慢。MySQL 的优化器多数时候靠谱,但关键时刻也可能给出一个让你目瞪口呆的执行计划。
常见应对手段有三个:
- 手动 ANALYZE TABLE 更新统计信息,这是最优先的尝试
- 改写 SQL,让优化器更容易判断出正确的过滤条件
- 必要时使用 FORCE INDEX 强制指定索引,或者在 8.0 里通过优化器提示(OPTIMIZER HINTS)来引导
我在工作中一直遵循一个原则:能不改 SQL 语义就不改,能用统计信息解决就不上 force index。因为 force index 写进代码后,如果未来索引结构调整了,这段 SQL 就会变成定时炸弹。
5. InnoDB 锁机制与死锁排查:行锁之外的隐藏成本
5.1 锁的粒度与三种行锁类型
MySQL 的锁从粒度上分有表锁、页锁、行锁。InnoDB 支持到行级锁,这也是它支持高并发事务的关键。行锁的类型有三种:
- 记录锁(Record Lock):锁定索引记录本身
- 间隙锁(Gap Lock):锁定一个范围,但不锁定记录本身,目的是阻止其他事务在间隙中插入新记录
- 临键锁(Next-Key Lock):记录锁和间隙锁的组合,锁定记录及其前面的间隙
很多人对间隙锁感到陌生,是因为它只在可重复读级别下生效。它解决的核心问题正是幻读:假设你在范围和记录上都没有间隙锁,另一个事务插入一条符合条件的新记录,你的范围查询就会多出一行。间隙锁就是把这个“可插入的缝隙”堵住。
5.2 一个经典死锁场景的完整拆解
死锁的本质是两个事务各自持有一把锁,又都在等对方持有的另一把锁。InnoDB 提供了死锁检测机制,检测到后会回滚其中一个事务,让另一个继续执行。但被回滚的事务通常只能靠应用层重试,所以被回滚的那一方其实是用户体验的牺牲品。
笔记里记录了一个常见的死锁用例。假设有一张商品库存表 product_stock,事务 A 先更新了 product_id = 1 的库存,事务 B 先更新了 product_id = 2 的库存,接着 A 又去更新 product_id = 2,而 B 同时去更新 product_id = 1。两个事务都持有对方下一步需要的锁,互相等待,死锁立刻形成。
解决这类死锁,最直接的方式是统一加锁顺序。比如约定所有事务都按 product_id 升序加锁,那么两个事务会先抢同一个最小 id 的锁,只有拿到后才能继续,死锁自然消失。
5.3 死锁日志该怎么看
MySQL 检测到死锁后,会在错误日志里输出死锁日志。我第一次看的时候一头雾水,后来总结了三个重点:
- 先看 LATEST DETECTED DEADLOCK 部分,里面会列出两个事务分别持有和等待哪一把锁
- 找到每个事务的等待锁描述,重点看索引名和锁模式(X 锁还是 S 锁)
- 根据事务的 SQL 语句,反推业务代码里的加锁顺序,找出为什么会出现循环等待
如果死锁频率很低,优先通过代码层加锁顺序修复;如果频率很高,要考虑是不是某个查询范围过大,持有太多间隙锁,导致碰撞概率剧增。间隙锁带来的并发下降,往往是“业务看着正常,压测一上去就死锁”的隐形元凶。
6. 日志三兄弟:redo log、binlog、undo log 的分工协作
6.1 redo log 与 WAL:先写日志,再写磁盘
InnoDB 的持久性不是靠每次提交都直接刷新数据页到磁盘实现的,而是靠 WAL(Write-Ahead Logging)机制:事务提交时,先把 redo log 刷入磁盘,数据页可以晚点再刷。为什么可以这样?
因为 redo log 是顺序写,磁盘顺序写比随机写快得多。如果每次事务提交都把数据页随机写盘,那一条 update 可能要涉及多个页的多次 I/O,性能完全扛不住。而 redo log 只需要追加写入,成本低很多。万一数据库崩溃,redo log 里记录了所有已提交但尚未落盘的数据变更,恢复时按日志重放即可,这样既保证了持久性,又避免了每次提交都做随机写盘。
redo log 还有一个特性是它的大小固定,采用循环写入。写满后,如果对应数据页还没有刷盘,就得等待,这也就是所谓的 checkpoint 机制。理解这个循环结构,有助于解释大事务为什么危险:大事务会产生大量日志,可能把 redo log 写满,从而触发大量刷盘动作,影响其他事务。
6.2 binlog 与主从复制:MySQL 层面的“记事本”
redo log 是 InnoDB 存储引擎层的日志,binlog 则是 MySQL Server 层的日志。两者定位完全不同。binlog 记录的是逻辑操作,比如“在某张表的某一行做了某个变更”,主要用于数据复制、数据恢复等场景。主从复制之所以能同步数据,核心就是主库把 binlog 传给从库,从库把 binlog 中记录的操作在本地重放一遍。
这里有个容易混淆的点:binlog 到底什么时候算写成功了?和 redo log 一样,binlog 也需要刷盘,但它有自己的刷盘策略,可以通过参数控制,比如每次事务提交都刷盘,还是交给操作系统决定。从数据安全的角度看,binlog 的刷盘策略直接决定了主库宕机时最多可能丢多少事务。
6.3 undo log 的一体两面:回滚与 MVCC 都要靠它
undo log 这个名字容易让人误以为它只用于回滚。实际上,结合前面讲的 MVCC,undo log 同时承担了两个任务:事务回滚时,按 undo log 逆向恢复旧值;快照读时,顺着 undo log 版本链找到当前事务可见的版本。
一条记录被更新多次,undo log 就会形成一条版本链,版本链越长,历史版本查询的成本越高。这也是长事务需要警惕的一个原因:长事务持有 ReadView 不释放,导致大量旧版本不能被清理,undo log 不断膨胀,最终可能把磁盘空间吃满。
6.4 两阶段提交:redo log 与 binlog 怎么保持一致
前面两块日志同时存在,就会引出一个经典问题:redo log 和 binlog 如果不同步,崩溃恢复时数据可能不一致。比如事务提交后,binlog 写了但 redo log 没写,或者反过来,从库同步的数据就和主库不一致了。
InnoDB 的解决方案是两阶段提交。事务提交时,先写 redo log,并标记为 prepare 状态;然后写 binlog;最后把 redo log 标记为 commit 状态。如果崩溃发生在 prepare 之后、binlog 写入之前,恢复时回滚该事务;如果崩溃发生在 binlog 写入之后,恢复时则补完 commit,保证两个日志最终一致。
这个机制我不止一次在面试里被问到,也在一次排查主从不一致的案例中真正派上用场。当时从库某行数据和主库对不上,排查半天最终定位到开发者在代码里手动提交事务的顺序有问题,扰乱了日志状态。后来我在笔记里写下一条经验:不要试图绕过 MySQL 的事务机制去手动控制日志流程,那是费很大劲给自己埋雷。
7. 笔记之外的实战细节:字符集、连接池和长事务
7.1 字符集与排序规则的隐性坑
字符集的问题隐蔽性很强,出问题时表现又很刁钻。比如两张表 join,一张表字段是 utf8mb4,另一张是 utf8,如果关联字段的字符集不一致,MySQL 可能会放弃索引,甚至出现“Illegal mix of collations”报错。
我在笔记里给自己定的规矩是:库、表、字段统一使用 utf8mb4,排序规则统一使用 utf8mb4_general_ci 或 utf8mb4_0900_ai_ci,具体看版本默认值。不要因为某张表是历史遗留就单独搞特殊,特殊字符集往往就是下一个事故现场。
另外,如果需要对 emoji 做存储,必须使用 utf8mb4。utf8 在 MySQL 里实际上是 utf8mb3,最多存 3 字节,存 emoji 会直接报错。这个问题在用户昵称、评论内容里极为常见。
7.2 隐式类型转换:索引失效里最狡猾的一种
前面提过一次隐式转换,这里展开说。隐式转换的规则是:当字符串列和数字比较时,MySQL 会把字符串转成数字再比较。比如 varchar 类型的 phone 列,where phone = 138... 这种写法,会让 phone 列上的每个值都经历一次类型转换,索引相当于失去了作用。
还有一种隐蔽情况是日期字符串的比较。如果你在代码里把时间参数拼成字符串传给 datetime 列,但格式又不符合 MySQL 期望,一样可能触发转换。规范做法是明确使用 STR_TO_DATE 或直接传 Date 类型参数,让数据库不需要做任何隐式转换。
判断是否发生了隐式转换,最简单的办法还是看 EXPLAIN:type 从 range 变成 all,或者 key 直接消失,就要怀疑类型问题。
7.3 连接池、事务边界与长事务
连接池是很多开发者的知识盲区。你以为拿到了一个连接就可以随便开启事务,实则是事务的边界远比想象的宽。如果在一个 Spring 事务方法里做了大量外部 RPC 调用、文件操作,事务就会长时间持有数据库连接和锁,连接池很快被耗尽,其他请求全部排队。
更严重的是长事务对 undo log 的影响:前面说过,长事务会让旧版本不能被清理,导致 undo log 膨胀。实际运维中,我曾经见过一个统计任务每 5 分钟跑一次,事务内做了全表聚合,半小时不提交,直接把磁盘打了大半。排查起来倒是简单,看到 information_schema.innodb_trx 里 trx_query 是那条统计 SQL,整个连接已经挂了一个多小时。
所以我在笔记里强调一个原则:事务里只放数据库相关操作,能放到事务外的都不要放进来。外部接口调用、消息发送、文件上传,这些统统应该在事务提交后再做。
7.4 我现在是怎么使用这份笔记的
笔记整理完成后,我养成了一个固定习惯:每次写表和写查询之前,先默背一遍它的索引设计;每次压测前,主动查看连接数和慢查询数。笔记不再是学习阶段的文档,而是变成了工作前的一个 checklist。
遇到线上问题,我也不会再从第一章重新看起,而是直接跳到相关章节查结论。比如某张表的查询突然变慢,我优先想到的是执行计划里 key 有没有变化、统计信息是不是过期了,再去看索引是否正确。这套“先看结构、再看日志、最后改代码”的排查顺序,帮我省下了大量时间。
最后分享一个小经验:建立自己的 MySQL 笔记,不要追求大而全,而是记录那些你亲手验证过或者踩过坑的内容。我这份专题笔记里的绝大多数结论,都不是靠背资料得来的,而是通过构造数据、执行计划、错误日志一步步验证出来的。MySQL 这个东西,表面看是 SQL 语法,实际拼的是对底层机制的掌握深度。你把索引、事务、日志这三条线串起来之后,再去看任何数据库问题,都会有一种“原来如此”的通透感。