做后端开发和数据库维护这些年,我最怕听到的一句话是:“这张表就是慢,你帮我看下能不能换个存储引擎?”说这句话的人,往往默认“换引擎=性能变好”。但真实情况是:同样的表结构、同样的 SQL、同样的数据量,放到不同引擎上,结果可以天差地别——有的是快慢问题,有的是能不能跑的问题,有的甚至是数据会不会丢的问题。MyISAM 时代一条慢 UPDATE 能锁住整张表让所有读请求排队,InnoDB 的间隙锁会在你以为只改一行的时候悄悄锁住一片区间,Memory 表则可能在你重启实例的那一刻让你辛苦攒的数据瞬间蒸发。这篇文章不打算给你背文档,而是把我踩过的坑、排查过的锁等待、做过的迁移,结合 InnoDB、MyISAM、Memory 三个引擎的原理和选型,一次性讲透。文章会从引擎的分工讲起,逐步深入到事务、MVCC、聚簇索引、锁机制、索引失效这些实战话题,最后给出一份可以直接照做的选型决策清单。不管是刚入门、正在被慢查询折磨,还是准备做引擎迁移的同学,这篇应该都能给出你想要的答案。
1. 存储引擎到底在管什么:一张表背后的分工与文件真相
1.1 Server 层与引擎层的边界:谁在解析 SQL,谁在落地数据
MySQL 的逻辑架构可以粗暴地切成两层:上面是 Server 层,负责连接管理、SQL 词法语法解析、优化器生成执行计划、权限校验,以及 8.0 之前的内置查询缓存;下面是存储引擎层,真正负责数据怎么存、怎么读、怎么加锁、怎么保证事务不出错。
打个比方:Server 层像餐厅的前厅,负责接单、安排座位、解释菜单;存储引擎层是后厨,不同后厨有不同的做菜风格——有的厨师每道菜都记台账(事务日志),有的厨师一口大锅从头锁到尾(表锁),有的厨师压根不点火(数据全在内存)。你点的还是同一道菜(SQL),但后厨的风格决定了上菜速度、能不能加急、以及厨房万一停电要不要紧。
理解这个分层特别重要,因为很多“引擎换完之后 SQL 变慢了”的案例,根子其实在 Server 层——执行计划没变、优化器选错了路径、统计信息过期,跟引擎关系不大。换引擎之前,先确认问题到底出在哪一层,否则很容易白折腾。
1.2 引擎是表级属性:三条指令看清引擎的“开关”
存储引擎不是 MySQL 实例级别的概念,也不是库级别的概念,而是表级别的属性。你在建表语句里写ENGINE=InnoDB,或者直接省略(会走default_storage_engine参数,默认就是 InnoDB)。一张表想查当前引擎很简单:
SHOW CREATE TABLE user_info\G -- 或者 SELECT TABLE_NAME, ENGINE, ROW_FORMAT, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'your_db';改引擎一行 SQL 就行,但这一行 SQL 背后的代价一点都不小:
ALTER TABLE user_info ENGINE=InnoDB;这个操作的本质是新建一张目标引擎的表,把原表数据一行行拷进去,再重建索引,期间会长时间持有元数据锁。10 万行的小表无所谓,几千万行的表在业务高峰期执行,轻则 IO 打满,重则把后面所有 DML 和查询全堵住。我在生产环境做引擎迁移从来不直接ALTER TABLE,要么走 pt-online-schema-change,要么用 gh-ost,要么干脆在低峰期停机窗口操作。
1.3 从数据目录看引擎本质:一份文件清单
我判断一张表是什么引擎,习惯直接看数据目录。不同引擎在磁盘上的落盘形态完全不同:
| 引擎 | 对应文件 | 数据与索引关系 | 表结构存储 |
|---|---|---|---|
| InnoDB | 每张表一个 .ibd(独立表空间) | 数据、索引都在同一个表空间内,主键索引叶子节点直接存整行数据 | MySQL 8.0 之前是 .frm,8.0 之后进数据字典 |
| MyISAM | .MYD(数据)、.MYI(索引)两份 | 数据和索引分离,索引文件里存的是指向数据记录的指针 | 同上 |
| Memory | 没有磁盘文件 | 全部在内存,不落盘 | 仅表结构存在于数据字典 |
看到.MYI这种文件,基本可以断定是 MyISAM;如果你发现某张表在磁盘上连文件都没有,但SHOW TABLES里还在,那多半就是 Memory 引擎或者某次异常后只留下了空的表结构。
1.4 引擎变更的本质:先想清楚再动手
这里多说一句:ALTER TABLE ... ENGINE=...还有一个隐藏行为——即使你改成和原来一样的引擎,MySQL 也会认为表结构变了,照样执行全表拷贝和索引重建。很多人用它来“整理碎片”,效果跟OPTIMIZE TABLE差不多。但代价一样很大,操作前务必评估表大小和 IO 情况。碎片整理我一般推荐在低峰期做,并且先看information_schema.TABLES里的DATA_FREE字段,碎片小就别折腾。
2. InnoDB 凭什么当默认:事务、MVCC、聚簇索引与行锁
2.1 redo log 与 undo log:崩溃恢复和安全感的来源
InnoDB 从 MySQL 5.5 开始成为默认引擎,不是因为名字好听,而是因为它把“数据不丢”这件事做得最扎实。核心机制是 WAL(Write-Ahead Logging,预写日志):任何修改发生之前,先写 redo log,再改缓冲池里的数据页,最后异步把脏页刷到磁盘。事务提交时,只要 redo log 落盘成功,这个事务就算持久化了。
这就好比做饭前先在便签上写下完整菜谱,哪怕中途停电,第二天照着便签也能把菜做出来。redo log 保证的是“持久性”和“崩溃恢复”;undo log 负责的是“回滚”和“MVCC 读旧版本”。当你执行 UPDATE 或 DELETE,旧版本数据会留在 undo log 里,其他事务需要读修改前的快照时,就从这里捞。一个事务长时间不提交,undo log 只增不减,这就是我后面要提的“长事务导致磁盘暴涨”的根源。
2.2 隔离级别与 MVCC:快照读和当前读的博弈
InnoDB 默认的隔离级别是 REPEATABLE READ(可重复读),它能做到这一点,主要靠 MVCC(多版本并发控制)。普通SELECT走的是“快照读”:直接读取事务开始那一刻的已提交版本,不加锁,也不影响别人写;UPDATE、DELETE、SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE走的是“当前读”:必须读最新版本,并且要加锁。
这解释了一个新手的经典困惑:为什么一个事务里两次SELECT结果一样,但另一个事务明明改了数据?因为快照读用的是同一个历史版本。为什么UPDATE又明明能读到别的事务刚提交的数据?因为当前读要的是最新值。弄清楚这两种读的边界,很多“数据怎么对不上”的诡异问题就迎刃而解了。
四个隔离级别我再压成一张表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 读最新未提交版本 |
| READ COMMITTED | 不会 | 可能 | 可能 | 每条语句新建快照 |
| REPEATABLE READ(默认) | 不会 | 不会 | 基本不会(间隙锁兜底) | 事务开始建快照 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 所有读都变当前读加锁 |
线上我一般建议就保持默认的 REPEATABLE READ,别轻易调低到 READ COMMITTED 去“提高并发”。可重复读不是性能瓶颈,真正拖垮并发的是长事务和索引没走对的锁范围。
2.3 聚簇索引与二级索引:主键选择决定写入上限
InnoDB 的数据组织方式有一个和 MyISAM 本质不同的点:它是聚簇索引表。主键索引的 B+ 树叶子节点上直接挂着完整的数据行,也就是说“主键 = 数据的存放位置”。二级索引(普通索引、唯一索引)的叶子节点不存数据,只存主键值,查询时先通过二级索引找到主键,再回主键索引里捞整行,这个过程叫“回表”。
这个机制直接带来一个反直觉的结论:主键怎么设计,能决定你的写入能跑多快。自增主键是严格递增的,新行永远追加在 B+ 树最右侧,磁盘写入基本是顺序的,不太触发页分裂;而 UUID、随机业务号这类“无序主键”,每次插入都可能落在已有页的中间,触发页分裂、产生碎片、增加随机 IO,写入性能会肉眼可见地下降。
我踩过最大的一个坑:订单表用 UUID 做主键,单表 2000 万行之后,批量导入从每秒几千条掉到几百条。排查半天的结论很尴尬——不是 SQL 问题,不是机器问题,就是主键太随机导致页分裂频繁。改成自增主键、再用唯一索引去兜底业务唯一性之后,写入速度立刻回来。所以请记住:主键值好不好看无所谓,递增性优先。
2.4 主键索引和唯一索引的区别:别再混为一谈
这个话题在面试里出现频率很高,在实战中也容易踩。两者的区别有这么几条:
- 主键索引是聚簇索引,叶子节点存整行数据;唯一索引是二级索引,叶子节点存主键值,查询可能需要回表;
- 一张表只能有一个主键,但可以有多个唯一索引;
- 主键列不允许为 NULL,唯一索引列允许有多个 NULL。这一点很多人会记反:MySQL 里唯一索引允许多个 NULL,因为 NULL 和 NULL 互不相等,不违反唯一约束;
- 主键通常还是外键引用和复制(binlog 回放、从库应用)的基础,尽量选择一个稳定、简短、递增的值。
设计表的时候,业务上唯一但可能为空的字段(比如用户邮箱、订单号)就应该用唯一索引而不是主键;主键就老老实实选一个无意义的自增代理键,业务安全性由唯一索引保证。这套组合在绝大多数业务模型里都是最优解。
2.5 行锁、间隙锁与死锁:经典案例拆解
InnoDB 的行锁不是只有一种“锁住一行”那么简单。它分三类:
- 记录锁(Record Lock):锁住索引上的一条具体记录;
- 间隙锁(Gap Lock):锁住两个记录之间的“空隙”,防止别的事务往这个区间插入数据;
- 临键锁(Next-Key Lock):记录锁 + 间隙锁的组合,左开右闭,是 REPEATABLE READ 下防幻读的主要手段。
这就是为什么有时你明明只 UPDATE 了一行,却感觉把整个范围都堵了——因为范围扫描命中了别的事务持有的间隙锁。还有一个非常常见的坑:UPDATE 的 WHERE 条件没走索引,InnoDB 只能全表扫描定位记录,每一行都加锁,最终等于锁住了整张表。表面上你是行锁引擎,实际干出了表锁的活。
死锁是最有意思的部分。两个事务互相持有对方的下一把锁就会死锁,经典代码如下:
-- 事务 A START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT; -- 事务 B(并发执行) START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 2; UPDATE account SET balance = balance + 100 WHERE id = 1; COMMIT;事务 A 持有 id=1 的行锁、等待 id=2;事务 B 持有 id=2 的行锁、等待 id=1,两边都不撒手,死锁就形成了。MySQL 的innodb_deadlock_detect机制会立刻检测到,并回滚其中一方,让你的一条 SQL 报错“Deadlock found”。死锁不是 bug,是锁博弈的正常结果,解决办法是让所有事务按相同顺序访问资源——先把 id 从小到大更新完,再更新大的。高并发场景下如果死锁频繁,除了检查加锁顺序,还要看是不是范围条件锁了太多间隙,收窄扫描范围往往立竿见影。
3. MyISAM 的看家本事与时代局限
3.1 表级锁的并发天花板:一个 UPDATE 卡住全表读
MyISAM 的锁模型极其简单:读读共享、读写互斥、写写互斥,而且所有操作都是表级锁。一个会话对 MyISAM 表执行 UPDATE,其他会话想 SELECT 都会被堵在“Waiting for table level lock”;更麻烦的是 MyISAM 的写锁优先于读锁,一旦有写操作持续没结束,读请求可能被饿死。
早年论坛、博客、小流量网站大量用 MyISAM,因为典型的访问模型就是读多写少,跑起来确实轻松。但今天的业务系统,读写密集且要求秒级响应,MyISAM 这种“一个写拖垮全表读”的特性已经很难接受了。我处理过的线上事故里,有相当一部分慢查询现象其实是“表锁排队”,本身那条 SQL 并不慢,是前面有一张 MyISAM 表被卡住,后面所有请求层层堆积。遇到这种,先看SHOW PROCESSLIST里的 State,看到Waiting for table level lock,立刻能锁定元凶。
3.2 COUNT(*) 秒回与压缩表:仅存的“爽点”
MyISAM 的.MYI文件里保存了表的总行数,所以不带 WHERE 条件的COUNT(*)可以直接读这个数字,O(1) 时间返回。InnoDB 做不到这一点,只能走索引统计或扫描,这也是很多人舍不得换掉 MyISAM 的唯一理由。但要注意:只要COUNT(*)带了 WHERE,MyISAM 同样要全表扫描,优势瞬间归零。我的建议是:如果只是想要“大表总数秒回”,完全可以用 InnoDB 的覆盖索引来做到,代价是维护一张统计表或用 Redis 计数,没必要为此牺牲事务和崩溃安全。
MyISAM 还有一个实用功能是压缩表:用myisampack工具可以把只读表压缩,能省不少磁盘空间,查询时自动解压。缺点是一旦压缩,表就只读,修改前得先解压。归档历史数据、冷数据存储时这招挺好用,但只适合那种“写完就不碰”的表。
3.3 没有 redo log 的后果:crashed 表和 40 分钟的 REPAIR
MyISAM 最大的硬伤是没有任何事务日志和崩溃恢复机制。正常运行没问题,一旦 MySQL 非正常退出(断电、kill -9、磁盘异常),索引数据和文件数据可能对不上,表被标记为 crashed,报错长这样:
Table 'xxx' is marked as crashed and should be repaired恢复手段是CHECK TABLE检查 +REPAIR TABLE重建索引。小表几秒钟,大表可能就是几十分钟,期间这张表完全不可用。我在 MySQL 5.6 时代经历过一次:一张 5000 万行的 MyISAM 日志表,机房断电后 REPAIR 花了 40 分钟,期间整个报表系统全线超时,用户投诉电话不断。那次之后我立下规矩:任何核心表一律 InnoDB,MyISAM 只能在明确只读、丢了能重建、崩了能等的场景出现。
3.4 现在还适合用 MyISAM 的场景:只读归档与报表
所以 MyISAM 是不是完全没用了?也不是。我现在的使用场景就非常收敛:
- 历史归档表:数据写完就不再修改,只做查询;
- 数据仓库的维度表、统计分析表:数据来源是 ETL 任务,全量刷新,期间可以锁表;
- 极端的只读备份库:主库是 InnoDB,备库或分析库为了查 COUNT(*) 方便用 MyISAM。
还有一个老理由是全文索引:MySQL 5.6 之前全文索引只在 MyISAM 上可用。但 5.6 之后 InnoDB 原生支持全文索引,这个理由早就失效了。如果你还在用 MyISAM 只是因为“听人说全文索引快”,建议先确认一下自己用的 MySQL 版本——大概率可以直接换 InnoDB。
4. Memory 引擎:最快的读写和最贵的气泡
4.1 内存里的数据与 HASH 索引:等值查询秒回,范围查询抓瞎
Memory 引擎(老版本叫 HEAP)把所有数据放进内存,读写不碰磁盘,单看延迟是三个引擎里最低的。它的索引默认是 HASH 索引,适合=、IN这类等值查询,不支持范围查询走索引;如果你需要范围条件,得在建索引时显式指定USING BTREE。这里有个比较容易误导的点:Memory 表建了 BTREE 索引后,范围查询确实可以借索引定位,但数据本身依然是内存里的无序结构,性能跟 InnoDB 那种天然有序的 B+ 树还是有差距,别指望它能干亏大查询的活。
还有一个硬性限制:Memory 引擎不支持 TEXT/BLOB 字段。建表时一旦出现这些大字段,直接报错“The BLOB/TEXT column 'xxx' can't be used in key specification”或者引擎压根不支持。想用 Memory 放长文本,你得先把字段改成 VARCHAR 并限制长度。
4.2 临时表的隐形依赖:GROUP BY、ORDER BY、JOIN 背后的内存表
很多人以为 Memory 引擎离自己很远,其实它一直在后台默默干活。MySQL 执行GROUP BY、ORDER BY、多表 JOIN、UNION时,如果需要中间结果,会在内存里创建内部临时表——5.7 用的是 MEMORY 引擎,8.0 换成了专用的 TempTable 引擎,底层同样是内存实现。一旦中间结果集超过了tmp_table_size和max_heap_table_size两者中的较小值,MySQL 会把临时表转成磁盘临时表(5.7 默认 MyISAM,8.0 默认 InnoDB),性能断崖式下降。
排查这个问题的百试百灵方法:在 MySQL 里看状态变量。
SHOW GLOBAL STATUS LIKE 'Created_tmp_disk_tables'; SHOW GLOBAL STATUS LIKE 'Created_tmp_tables';如果Created_tmp_disk_tables / Created_tmp_tables的比例持续偏高,说明你的 SQL 经常在生成大临时表。这时候不要去调引擎,优先优化 SQL:让GROUP BY走索引、给 JOIN 字段加索引、避免SELECT *带大字段进临时表。调大 tmp 阈值只是治标,SQL 本身不走索引才是治本。
4.3 三个致命坑:重启丢数据、表级锁、内存上限
Memory 表看着快,坑比想象中的多,三个坑每一个都能让线上出事:
- 数据易失:MySQL 重启、异常退出,Memory 表数据全部清空,只剩一张空表壳。拿它当业务数据的临时承接点,等于把数据安全交给运气;
- 表级锁:数据在内存不代表并发就好,Memory 引擎依然是表锁,并发写一样排队,写多读多照样卡;
- 内存上限:每张 Memory 表受
max_heap_table_size限制,默认一般 16MB 或 32MB。超过限制,要么报“Table is full”,要么你手贱调大了上限,结果内存被吃光触发系统 swap 或 OOM,把整个 MySQL 实例拖垮。我见过一个同学把 max_heap_table_size 调到 2GB,然后往 Memory 表里灌了 1.5GB 数据,服务器直接卡死,连 SSH 都进不去。
4.4 正确用法:小字典表、会话级中间结果,而不是缓存替代品
Memory 引擎不是不能用,而是要严格限制使用场景。我的经验是:只放“一次性、可重建、量小”的数据,例如:
- 跑批脚本里的中间结果表,任务结束后立刻清理;
- 会话级的去重表、计数表;
- 启动时从 InnoDB 表预加载到内存的静态字典表,地区表、代码表、配置表都行,几千行数据放 Memory 里,等值查询确实快。
如果你是想解决“热点数据查询快”的问题,我更推荐用 Redis 或本地的 Caffeine 这类专业缓存组件,把缓存和数据源分开管理,至少不会因为 MySQL 重启就把缓存层的数据全搞没。专业的事交给专业的组件,Memory 引擎更像是应急工具箱里的存在,而不是常规武器。
5. 选型决策:从业务需求反推引擎
5.1 一张表看清三个引擎的差异与选型结论
选型不能靠“听说”,得靠业务需求反推。我把三个引擎的核心差异浓缩成一张表:
| 维度 | InnoDB | MyISAM | Memory |
|---|---|---|---|
| 事务支持 | 完整 ACID | 无 | 无 |
| 崩溃恢复 | redo log 重放,已提交数据基本不丢 | 无日志,崩溃后可能 crashed,需 REPAIR | 重启数据全丢 |
| 锁粒度 | 行锁 + 间隙锁 | 表锁 | 表锁 |
| 索引结构 | 聚簇索引 + 二级索引(B+ 树) | 非聚簇索引,数据与索引分离 | 默认 HASH,可建 BTREE |
| 外键 | 支持 | 不支持 | 不支持 |
| 全文索引 | 支持(5.6+) | 支持 | 不支持 |
| COUNT(*) 无 WHERE | 走索引扫描,较慢 | 秒回(保存精确行数) | 秒回(行数在内存) |
| 数据存储 | 磁盘 .ibd | 磁盘 .MYD + .MYI | 纯内存 |
| 推荐场景 | 一切核心业务、读写混合 | 只读归档、报表、冷数据 | 小字典表、会话中间结果 |
我的选型结论可以浓缩成一句话:默认 InnoDB,唯一合理的例外是“明确只读且能接受手动 REPAIR 的表”可以选 MyISAM,Memory 只做临时数据承接。这里要特别提醒一句:不要因为“查询快”选 MyISAM,也不要因为“快”选 Memory,这两个理由在今天的业务场景里基本都不成立。数据读写安全是第一位的,快慢问题完全可以通过索引、缓存和架构解决。
选型还要考虑运维。InnoDB 能用 Xtrabackup 做在线物理备份,备份期间业务基本无感;MyISAM 想拿一致性快照,通常得配合FLUSH TABLES WITH READ LOCK锁全库,流量稍大就根本没机会执行。我当年用 Xtrabackup 备份混合引擎库时,最痛苦的就是 MyISAM 表,每次都要小心翼翼地在锁窗口内完成,切换和恢复也比 InnoDB 麻烦得多。从这个角度说,InnoDB 对运维友好度是碾压级的。
5.2 多引擎混用:允许但不等于随便用
一个 MySQL 实例、甚至一个库里同时存在多种引擎的表,技术上完全支持,但有几个纪律要守住:
- 涉及事务的更新操作,必须只发生在 InnoDB 表上。MyISAM 和 Memory 表没有事务概念,一旦 SQL 里跨引擎更新,MyISAM 那部分操作失败也不会回滚,会出现“InnoDB 回滚了、MyISAM 更新成功了”这种数据不一致的灾难;
- 跨引擎 JOIN 没问题,但查询计划可能因为两张表的统计信息、索引结构差异而变得不可控,能避免就避免,尽量把同一业务域的表统一引擎;
- 改引擎必须评估大表影响。直接
ALTER TABLE ... ENGINE=...会拷贝全表数据并重建索引,请结合在线改表工具、在低峰期操作,并提前准备好回滚方案; - 混用场景下,监控要分别看:MyISAM 关心
Key_reads/Key_read_requests(键缓存命中),InnoDB 关心Buffer pool hit rate和锁等待,别用一套指标管所有表。
5.3 MyISAM 迁 InnoDB:一次完整迁移的操作清单
如果你已经决定把存量 MyISAM 表迁到 InnoDB,我建议照这个顺序来,我迁移过几十张表的流程基本是这样:
- 盘点全部表,确认哪些是核心读写表、哪些可以继续留在 MyISAM(纯归档只读表可以不动);
- 检查每张表的建表语句,确保都有合理主键。InnoDB 没有主键时会选用第一个非空唯一索引,否则用隐藏 ROW_ID,会导致回表和复制效率低下,务必提前补主键;
- 处理大字段:TEXT/BLOB 在 MyISAM 下可能埋下行溢出的坑,迁到 InnoDB 后建议把大字段拆到独立表,避免主键索引页膨胀;
- 评估表大小和迁移窗口,小表直接低峰期
ALTER TABLE,大表用 pt-online-schema-change 在线执行; - 迁移完成后跑一遍
ANALYZE TABLE,刷新优化器统计信息,避免迁移后执行计划突变; - 观察一段时间慢查询、锁等待、死锁日志,尤其关注长事务导致的 undo log 膨胀,以及原本依赖 MyISAM COUNT(*) 秒回的 SQL 是否需要在应用层改造。
整个迁移期间最有价值的技巧:先在测试环境用同数据量复现一遍,记录迁移前后的大 SQL 耗时,别上线当天才发现有一条COUNT(*)查询从 0.01 秒变成了 3 秒,那就尴尬了。
6. 选完引擎只是开始:锁分类、索引失效与高并发调优
6.1 MySQL 锁分类全景:从全局锁、MDL 到 InnoDB 行锁
引擎选完了,真正决定线上稳定性的,是锁和索引的使用细节。MySQL 的锁可以从粒度分成几层:
- 全局锁:
FLUSH TABLES WITH READ LOCK,让整个实例只读,主要用于全库备份的一致性快照。有了 Xtrabackup 以后我基本不手动用,除非要备份 MyISAM 表; - 表级锁:包括显式表锁(
LOCK TABLES ... READ/WRITE)、元数据锁(MDL,DDL 和 DML 之间互斥)、以及 MyISAM 自身的读写锁。MDL 是很多线上事故的隐形元凶——一个会话长时间未提交事务,握着 MDL 写锁,后续所有 DDL 和查询都会排队,表现就是“数据库突然一片超时”; - 行级锁:InnoDB 的记录锁、间隙锁、临键锁,之前已经展开过。还有意向锁(IS/IX)是 InnoDB 内部用来协调表级意向和行锁的,不需要我们手动管,但理解它能帮你读懂
SHOW ENGINE INNODB STATUS里的输出。
看锁等待最常用的命令:
-- 看死锁和锁等待详情 SHOW ENGINE INNODB STATUS\G -- 从 performance_schema 查锁的持有与等待关系 SELECT * FROM performance_schema.data_lock_waits;我的排查习惯:先SHOW PROCESSLIST看 State,凡是卡在Waiting for table metadata lock的先杀掉长事务或 DDL;凡是Waiting for table level lock的先找 MyISAM 写操作;凡是Lock wait timeout exceeded的再看 InnoDB 锁等待视图。把问题定位到“哪一类锁”再动手,比瞎调参数高效得多。
6.2 索引失效的经典场景:为什么引擎会让后果被放大
引擎再强,索引没用对也白搭。索引失效的经典场景我列一份清单,每一个都亲自踩过:
- 对索引列使用函数或表达式:
WHERE YEAR(create_time) = 2024,会让 create_time 索引失效,应改成create_time >= '2024-01-01' AND create_time < '2025-01-01'; - 隐式类型转换:手机号字段是 VARCHAR 却传入数字,MySQL 会把索引列转成数字再比较,索引失效。排查时看 EXPLAIN 的 type 从 const/ref 退化到 ALL,大概率就是这类问题;
- 左模糊:
LIKE '%abc'用不了普通 B+ 树索引,LIKE 'abc%'没问题。真需要后模糊搜索,考虑全文索引或 ES; - OR 条件:OR 连接的条件里只要有一个不走索引,优化器为了结果正确可能整条走全表扫描。拆成 UNION ALL 通常能救回来;
- 联合索引最左前缀:建了
(a,b,c)却直接查 b 或 c,索引无法使用; - 优化器“闹脾气”:统计信息失真或数据量太小,优化器可能放弃索引。这时候
ANALYZE TABLE刷新统计信息,比硬塞索引更管用。
这些都是通用 SQL 层面的问题,但和引擎绑在一起时后果差异很大:在 MyISAM 上,索引失效意味着整表扫描加表锁,所有并发请求遭殃;在 InnoDB 上,索引失效则意味着本来应该锁一行的 UPDATE 变成锁全表扫描路径上的所有记录,甚至触发间隙锁大范围冲突。所以我才说“选引擎是打地基,索引和锁的用法才是上层建筑”,地基选对了,上层偷懒照样塌。
6.3 高并发下 InnoDB 的核心参数调优
如果你的表已经全部是 InnoDB,高并发压测时建议优先盯这几个参数:
| 参数 | 建议值 | 说明 |
|---|---|---|
| innodb_buffer_pool_size | 物理内存的 50%~70%(专用实例) | 热点数据能不能留在内存,直接影响读性能 |
| innodb_flush_log_at_trx_commit | 1 最安全,2 性能好 | 1=每事务刷盘;2=每秒刷盘,崩溃最多丢 1 秒数据 |
| innodb_io_capacity | SSD 建议 1000~2000 | 告诉 InnoDB 刷脏页时有能力用多少 IO,别让它保守拖慢 |
| innodb_lock_wait_timeout | 5~10 秒 | 行锁等太久不如快速失败,业务侧加重试 |
| innodb_deadlock_detect | 默认开 | 死锁频繁时结合加锁顺序优化,别贸然关 |
针对高并发,我的经验排序是:先保证 buffer pool 命中率,再看刷盘策略能否接受“最多丢 1 秒”的窗口,然后解决慢查询和索引问题,最后才谈分库分表。很多团队一上来就拆库拆表,结果单表压测时发现 InnoDB 参数全是默认的,buffer pool 才 128M,那才是真正的浪费。
再说一遍刷盘参数:innodb_flush_log_at_trx_commit = 2时,事务提交不强制刷盘,由后台每秒统一刷,性能能提升一大截,代价是实例崩溃时最多丢最近 1 秒的已提交事务。金融、订单这类场景我劝你老实保持 1,普通 Web 应用如果业务能接受“秒级数据丢失窗口”,用 2 换取吞吐是非常划算的。这是典型的“正确认识取舍”。
文章写到这,原理和实操都覆盖得差不多了。最后用我自己的经历收尾吧。几年前我维护过一个后台报表系统,当时为了查询快和 COUNT() 秒回,把几十张统计表全部设成了 MyISAM,跑了半年确实挺爽。直到一次磁盘迁移时操作失误导致实例非正常关闭,开机后三张大表全部 crashed,REPAIR 期间报表系统全线瘫痪。后来我把所有表迁到 InnoDB,把大表的 COUNT() 改成走覆盖索引的写法,性能没有明显下降,可靠性却完全是两个档次。那次之后我给自己定了一条铁规矩:默认只用 InnoDB,除非能明确说出“这张表不需要事务、不需要崩溃安全、能接受表锁加 REPAIR”三个条件,才考虑 MyISAM;Memory 只在会话级临时数据里短暂出现。你也别嫌这个标准保守——数据库这行,活得久比跑得快重要得多。