InnoDB 与 MyISAM:存储引擎怎么选
MyISAM 查询常常更快,为什么现在几乎没人用它?因为存储引擎的选择不是比速度,而是比"出问题时能不能救回来"。这篇从事务、锁粒度、索引结构、崩溃恢复四个角度看两者的分野。
一、一个让人困惑的现象
在只读的基准测试里,MyISAM 的 SELECT 经常比 InnoDB 快。它的表更小(没有事务相关的额外开销)、全表扫描更直接,甚至连 SELECT COUNT(*) 都是 O(1)——因为 MyISAM 在表头维护了一个行数计数器。
那为什么从 MySQL 5.5 开始,InnoDB 就成了默认存储引擎,而 MyISAM 在几乎所有新项目里都不再被考虑?
因为这个选择从来不是"谁更快"的问题,而是"出问题时能不能救回来"的问题。下面从四个维度拆开。
二、维度一:事务支持
这是两者最根本的分野。
| InnoDB | MyISAM | |
|---|---|---|
| 事务 | 支持(ACID) | 不支持 |
| 提交 / 回滚 | 支持 | 无此概念 |
| 崩溃后一致性 | 保证 | 不保证 |
| 典型用途 | 业务表 | 日志、只读报表、临时数据 |
MyISAM 不支持事务,这一条会引发一串连锁反应。
一个隐蔽的坑:@Transactional 加了等于白加
在应用层,很多人习惯用声明式事务:
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) {
orderMapper.insert(dto); // 写订单
stockMapper.decrease(dto.getSkuId()); // 扣库存
// 假设下面抛异常,期望上面两步一起回滚
throw new BizException("下单失败");
}
如果 order 和 stock 两张表的引擎是 MyISAM:
- Spring 的事务切面照常执行,
begin/commit语句照发不误,日志里看不到任何异常 - 但 MyISAM 根本不认这套指令,两条
INSERT/UPDATE已经各自落盘 - 异常抛出后,没有任何东西被回滚
结果就是订单存在、库存没扣(或反过来),而且没有任何报错提示。这类问题往往要等到对账时才发现,排查成本极高。
所以有一条硬性检查:库里所有表必须是 InnoDB。 迁移历史库、从别人手里接过的库,第一件事就是查引擎。
-- 查出所有非 InnoDB 的表
SELECT table_schema, table_name, engine
FROM information_schema.tables
WHERE table_schema = 'your_db' AND engine <> 'InnoDB';
-- 单表转换(大表在生产环境要用在线工具,避免长时间锁表)
ALTER TABLE order_item ENGINE = InnoDB;
顺带说一个同样隐蔽的事务失效点:即使引擎正确,异常被 catch 住没有抛出,或者抛出的是检查异常,事务也会照常提交。稳妥的写法是显式声明 rollbackFor = Exception.class。
三、维度二:锁粒度
锁粒度直接决定了并发写入能力。
| InnoDB | MyISAM | |
|---|---|---|
| 锁粒度 | 行级锁(默认) | 表级锁 |
| 写并发 | 不同行可以并发写 | 任何写都会锁住整张表 |
| 读写关系 | 读写不互相阻塞(MVCC) | 读会阻塞写,写会阻塞读 |
| 死锁 | 可能发生,需处理 | 基本不发生 |
MyISAM 只支持表级锁:一次 UPDATE 会锁住整张表,其他会话的读写全部排队。
-- MyISAM 表上:
-- 会话 A:UPDATE users SET name = 'x' WHERE id = 1; -- 锁整张 users 表
-- 会话 B:UPDATE users SET name = 'y' WHERE id = 2; -- 必须等 A 完成
-- 会话 C:SELECT * FROM users WHERE id = 999; -- 也要等
只有两个用户时看不出差别,一旦并发写入上到几百,表锁就是吞吐量的天花板——所有的写都被强制串行化,哪怕它们操作的是完全不相干的行。
InnoDB 的行级锁则让"改第 1 行"和"改第 2 行"可以真正并行。这也是它更适合在线业务的核心原因之一。
不过要注意,行锁是加在索引上的。如果 WHERE 条件没走索引,InnoDB 会退化成锁住扫描过的所有行,极端情况下接近表锁:
-- id 有索引:只锁 id = 1 这一行
UPDATE users SET name = 'x' WHERE id = 1;
-- name 无索引:扫描全表,给扫过的每一行都加锁 → 实际效果接近表锁
UPDATE users SET name = 'x' WHERE name = 'lan';
行锁的前提是走索引。 这是索引不仅影响查询性能、也影响写并发的原因。
四、维度三:索引结构(聚簇 vs 非聚簇)
| InnoDB | MyISAM | |
|---|---|---|
| 索引类型 | 聚簇索引 | 非聚簇索引 |
| 主键索引叶子存什么 | 完整行数据 | 数据行的地址指针 |
| 二级索引叶子存什么 | 索引列 + 主键值 | 索引列 + 行地址 |
| 是否需要回表 | 需要(回聚簇索引) | 需要(按地址找行) |
| 主键要求 | 必须有(无显式主键会自动建隐藏主键) | 可以没有 |
| 索引与数据文件 | 同在 .ibd 文件 |
索引在 .MYI,数据在 .MYD,分离 |
最实质的差别在于主键索引的叶子节点:
- InnoDB:叶子节点就是数据行本身。按主键查 = 一次 B+ 树查找直接拿到整行。
- MyISAM:索引和数据文件是分开的。索引叶子存的是行在
.MYD文件中的偏移量,拿到偏移量后还要再去数据文件里读一次。
-- InnoDB:主键查询只有一次 B+ 树查找
SELECT * FROM users WHERE id = 1001; -- 直接命中行数据
-- MyISAM:索引找到偏移量 → 再按偏移量读数据文件
SELECT * FROM users WHERE id = 1001; -- 两步
这个差别带来一个重要的连带后果:InnoDB 的主键大小会放大所有二级索引。
因为 InnoDB 的二级索引叶子存的是主键值,主键越大,每个二级索引就越大。用 UUID(36 字节)当主键,等于让每一个二级索引都跟着膨胀,同样的页能放的索引条目更少,树更高,IO 更多。
-- ✅ 自增 BIGINT 主键:8 字节,二级索引只多存 8 字节
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY
-- ❌ UUID 主键:36 字节,每个二级索引都被放大;
-- 且随机插入导致 B+ 树频繁页分裂
-- id CHAR(36) PRIMARY KEY
而 MyISAM 的二级索引存的是固定长度的行地址,主键多大都不影响它——这是非聚簇结构的一个附带优势,但不足以抵消它在事务和锁上的劣势。
五、维度四:崩溃恢复
| InnoDB | MyISAM | |
|---|---|---|
| 日志 | redo log + undo log | 无 |
| 崩溃恢复 | 自动,重启后前滚 + 回滚 | 需手动 REPAIR TABLE |
| 数据丢失窗口 | 可配置到接近 0 | 可能丢失大量数据 |
| 表损坏风险 | 低 | 高(写入过程中断电易损坏) |
redo log:保证已提交的不丢
redo log 是物理日志,记录"在某个页上做了什么改动"。它采用循环写的固定大小文件。
写入流程是 WAL(Write-Ahead Logging):
1. 修改 Buffer Pool 中的页(内存操作)
2. 把改动写入 redo log(顺序 IO,快)
3. 事务提交时,redo log 刷盘 → 此时事务就算成功了
4. 后台线程慢慢把脏页刷回数据文件(随机 IO,慢)
关键在于第 3 步:事务提交只需要保证 redo log 落盘,不需要等数据页落盘。顺序写日志远快于随机写数据页,这就是 InnoDB 既能保证持久性、又不牺牲写入性能的原因。
重启恢复时:redo log 里已提交但数据页未刷盘的部分,前滚重做。所以已提交的事务不会丢。
undo log:保证未提交的回滚
undo log 是逻辑日志,记录"如何把数据改回去"——插入对应删除,更新对应反向更新。
它有两个作用:
- 事务回滚:执行 undo 里的反向操作。
- MVCC:一致性读时,如果当前行的版本比事务快照新,就顺着 undo 链回溯到可见的历史版本。
-- undo log 记录的是反向操作
-- 原操作:UPDATE users SET name = 'B' WHERE id = 1; (原值 'A')
-- undo :UPDATE users SET name = 'A' WHERE id = 1;
MyISAM 两者皆无。写入是直接改数据文件,没有 WAL,没有回滚段。如果写入过程中断电或进程崩溃,表结构可能损坏,此时唯一的办法是:
REPAIR TABLE myisam_table; -- 手动修复,可能失败,可能丢数据
对于一个承载业务数据的表,这种恢复手段显然不可接受。
MVCC 与读写不阻塞
undo log 支撑的 MVCC(多版本并发控制)还带来一个 MyISAM 完全没有的能力:读写不互相阻塞。
InnoDB:事务 A 正在更新某行(未提交)→ 事务 B 读这行 → 读到 undo 链上的历史版本
→ B 不用等 A 提交,A 也不用等 B 读完
MyISAM:会话 A 正在写 → 会话 B 要读 → 必须等 A 写完释放表锁
在"读多写少但写很频繁"的在线业务里,这个差别直接决定了系统能否支撑并发。
六、其他差异
| 特性 | InnoDB | MyISAM |
|---|---|---|
| 外键约束 | 支持 | 不支持(语法忽略) |
| 全文索引 | 5.6+ 支持 | 支持(较早) |
SELECT COUNT(*) |
需扫描(走最小二级索引) | O(1),读表头计数器 |
| 自增列 | 需索引,重启后不持久化历史最大值(8.0 前) | 独立维护 |
| 空间占用 | 较大 | 较小 |
| 压缩表 | 支持 | myisampack 只读压缩 |
几点说明:
- 外键:MyISAM 会解析但忽略
FOREIGN KEY语法——建表语句不报错,但约束根本不生效。这比报错更危险。不过在生产实践中,外键约束通常由应用层保证,因为外键在高并发下会带来额外的锁检查开销,也不利于分库分表。 COUNT(*):MyISAM 快是因为它存了计数器,但带WHERE的COUNT一样要扫描。而在有并发写的场景下,那个计数器本身也很难保证读到"精确的当前值"。所以这个优势的实际价值有限。- 全文索引:MyISAM 早年在这块领先,但对中文分词的支持一直很弱(需要
ngram之类的解析器)。真要做全文检索,应该交给专门的倒排索引引擎。
七、选型建议
| 场景 | 推荐 | 理由 |
|---|---|---|
| 在线业务表(订单、用户、库存) | InnoDB | 需要事务、行锁、崩溃恢复 |
| 高并发读写的核心表 | InnoDB | MVCC 让读写不阻塞 |
| 需要外键或强一致的场景 | InnoDB | MyISAM 直接忽略外键 |
| 纯只读的归档 / 报表表 | 可考虑 MyISAM | 无写入,事务和锁都用不上 |
| 日志类、可重放的中间数据 | 可考虑 MyISAM | 丢了能重新生成 |
| 临时表、ETL 中间结果 | 可考虑 MyISAM | 生命周期短,无并发写 |
判断标准可以归成一句话:
只要这张表的数据"丢了要命"或者"有人会同时写",就用 InnoDB。 MyISAM 只在"只读 + 可重建"这两个条件同时成立时才有立足之地。
在 MySQL 5.7 及之后,连只读报表场景也常常推荐 InnoDB——因为它的表空间压缩和只读事务优化已经足够好,一致性读还能避免报表读到"写了一半"的中间态。相比之下,MyISAM 省下的那点空间实在不值得为它承担风险。
八、小结
- 引擎选择不是比速度,而是比**"出问题时能不能救回来"**
- 事务:InnoDB 支持 ACID;MyISAM 不支持,
@Transactional加了等于白加,且不报错 - 锁粒度:InnoDB 是行级锁(前提是走索引);MyISAM 是表级锁,写操作阻塞全表
- 索引结构:InnoDB 是聚簇索引,主键叶子存整行,主键大小会放大所有二级索引;MyISAM 是非聚簇,索引和数据文件分离
- 崩溃恢复:InnoDB 靠 redo log 前滚 + undo log 回滚,配合 WAL 做到"提交即持久";MyISAM 无日志,崩了只能
REPAIR TABLE - MVCC:InnoDB 读写不互相阻塞;MyISAM 读会阻塞写、写会阻塞读
- 外键:InnoDB 支持;MyISAM 静默忽略,比报错更危险
- 选型原则:业务表一律 InnoDB;MyISAM 仅适用于"只读 + 可重建"的窄场景
接手一个老库时,先跑一遍 information_schema 查引擎。那一条 SQL 花不了十秒钟,但可能帮你避开一类最难排查的数据不一致。
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。