数据库

InnoDB 与 MyISAM:存储引擎怎么选

MyISAM 查询常常更快,为什么现在几乎没人用它?因为存储引擎的选择不是比速度,而是比"出问题时能不能救回来"。这篇从事务、锁粒度、索引结构、崩溃恢复四个角度看两者的分野。

2026-03-12 约 13 分钟 5378 字 MySQL InnoDB 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 是逻辑日志,记录"如何把数据改回去"——插入对应删除,更新对应反向更新。

它有两个作用:

  1. 事务回滚:执行 undo 里的反向操作。
  2. 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_* 四个变量后自动开启。