人人都会AI编程

8.4 写入操作的性能影响与注意事项

更新时间:2026-07-11

写入操作(INSERT、UPDATE、DELETE)不像查询那样可以单纯靠缓存扛住压力,每一次写入最终都要落到磁盘,并且涉及索引维护、锁管理、日志刷盘等一系列底层工作。理解写入的性能影响,能让你在业务高峰期避免踩坑。

8.4.1 写入操作的真实代价

一条写入语句,远不止“修改一行数据”那么简单。在 InnoDB 中,它至少会触发以下工作:

  • 更新数据页:找到目标行所在的数据页,在缓冲池中修改,标记为脏页。
  • 维护所有索引:表上每个索引(包括二级索引)都需要同步更新。一张表有 3 个索引,一条 INSERT 会同时写入 3 个索引的B+树。
  • 写入 Undo Log:记录反向操作,用于回滚和 MVCC。
  • 写入 Redo Log:记录数据页的物理变更,保证持久性。
  • 写入 Binlog:记录逻辑变更,用于主从复制和数据恢复。
  • 加锁与冲突检测:需要获取行锁或间隙锁,处理并发冲突。

这意味着写入的性能开销通常是读取的好几倍,而且随着索引数量增加,写入代价会线性增长。这是很多开发者直觉上容易忽略的。

8.4.2 批量写入优化

逐条 INSERT 是写入性能的头号杀手。每次 INSERT 都独立开启一个事务(如果 autocommit=1),意味着每条语句都要:

  • 开启事务 → 写日志 → 同步刷盘 → 提交事务 → 释放锁

这一套流程走一遍,一条语句可能只插入一行,日志刷盘开销却一点没少。1000 条逐条插入,至少产生 1000 次日志同步和事务开销。

方案一:批量 INSERT 单语句

将多条值合并到一个 INSERT 中:

-- 不推荐
INSERT INTO t (c1, c2) VALUES (1, 2);
INSERT INTO t (c1, c2) VALUES (3, 4);

-- 推荐
INSERT INTO t (c1, c2) VALUES (1, 2), (3, 4), (5, 6), ...;

一条 INSERT 可以携带数百甚至上千行数据,这样事务只开启一次,日志只写一次,性能提升可达几十倍。但要注意单条 SQL 长度不宜过大,避免超出 max_allowed_packet 限制(默认 64MB)。一般建议每批 500~2000 行,具体根据字段大小和压力测试调整。

方案二:手动控制事务

如果必须逐条执行(比如数据来自流式处理),至少要把多条操作放在一个事务里:

START TRANSACTION;
INSERT INTO t (c1, c2) VALUES (1, 2);
INSERT INTO t (c1, c2) VALUES (3, 4);
...
COMMIT;

提交前,日志不会强制刷盘(取决于 innodb_flush_log_at_trx_commit 设置),锁也会在提交时才释放,整体开销大幅降低。

方案三:使用 LOAD DATA 导入

对于海量数据初始化或文件导入,LOAD DATA INFILE 是写入速度最快的方式。它底层走批量加载逻辑,跳过了部分 SQL 解析和约束校验,速度通常是逐条 INSERT 的几十倍甚至上百倍。但要注意它默认会触发触发器,如有必要可先禁用。

8.4.3 索引对写入的影响

每多建一个索引,就多一棵 B+ 树需要维护。INSERT 时每个索引都要定位插入点、可能引发页分裂;UPDATE 若修改了索引列,等同于一次 DELETE 加一次 INSERT;DELETE 时每个索引都要标记删除或合并。

一个常见的错误是:为了“以防万一”给表上建了五六个单列索引,结果写入速度奇慢无比。索引要按需创建,而不是预留给所有可能的查询。 可以用 pt-duplicate-key-checker 等工具检查冗余索引,定期清理。

另外,如果你有一个大的数据批处理任务(比如月底结算大量写入),可以临时删除非关键索引,写入完成后再重建。重建索引是全量排序构建,比逐条维护快很多。

8.4.4 自增主键与写入热点

InnoDB 的表如果使用自增主键,所有 INSERT 都会集中在 B+ 树的最右侧页面(因为数据按主键顺序存放)。这在高并发写入时会导致插入热点,多个线程争抢同一数据页的锁,产生竞争。

  • MySQL 5.7 及之前,自增锁(AUTO-INC lock)在语句执行期间持有,可能阻塞其他插入。
  • MySQL 8.0 优化后,自增锁在分配 ID 后立即释放,并发度有所提升,但最右侧页面的锁竞争依然存在。

优化思路:

  • 使用 innodb_autoinc_lock_mode=2(交叉模式),进一步降低自增锁竞争。
  • 如果业务允许,考虑使用 UUID 或雪花算法生成离散主键,将写入分散到 B+ 树的各个位置。但 UUID 也有缺点(占用空间大、索引分裂多),需要权衡。
  • 对于极端写入场景,使用 INSERT ... ON DUPLICATE KEY UPDATE 可以减少一次查找和竞争。

8.4.5 大事务的危害

一个事务如果包含太多写入操作,会造成严重问题:

  • 锁持有时间长:事务未提交,持有的行锁不会释放,阻塞其他等待锁的事务,可能引发连锁超时甚至死锁。
  • Undo Log 膨胀:事务修改的所有行都会生成 Undo Log,长事务会导致 Undo Log 无法被清理,占用大量磁盘空间,影响查询性能。
  • 主从延迟:主库提交的事务,从库的回放需要同样的时间。一个执行了 1 小时的大事务,从库也可能要 1 小时才能跟上,导致主从延迟剧增。
  • 恢复缓慢:如果大事务执行过程中崩溃,重启时的回滚操作同样耗时长,可能导致数据库长时间不可用。

建议对事务保持“小而快”:一个事务里不要混入多个不相关的业务操作,不要包含大量数据的批量更新。如果是清理过期数据,可以分批次小事务执行:

-- 每次删除 1000 行,循环直到删完
DELETE FROM logs WHERE created_at < '2024-01-01' LIMIT 1000;

配合脚本循环执行,每次提交一个小事务,避免长事务锁表。

8.4.6 Redo Log 与 Binlog 的刷盘策略

两个日志的刷盘行为直接影响写入性能和可靠性。

Redo Log 刷盘(innodb_flush_log_at_trx_commit

  • =1(默认):每次事务提交都刷盘,最安全,性能最差。
  • =0:每秒刷一次,断电可能丢失 1 秒数据,写入性能最高。
  • =2:写入 OS 缓存后立即返回,每秒刷盘一次,折中选择。

大多数业务使用 =1 保证不丢数据。但如果你的业务允许丢失极少数据(如日志、临时统计),调整为 =2 会明显提升写入吞吐。

Binlog 刷盘(sync_binlog

  • =1(默认):每次事务提交都刷盘,最安全。
  • =N:每 N 次事务刷一次,可以减少磁盘压力,但断电可能丢失 Binlog,影响从库复制。

线上环境通常建议双 1 配置(innodb_flush_log_at_trx_commit=1sync_binlog=1),配合带缓存保护的 RAID 卡或 SSD,可以同时保证性能和安全。

8.4.7 更新与删除的注意事项

UPDATE 操作

  • 修改的列如果有索引,则该索引需要更新,可能导致页分裂。尽量只修改必要的列。
  • 修改主键值非常昂贵,相当于删除旧行再插入新行,且旧主键对应的二级索引全部需要调整。避免修改主键。
  • 大批量更新最好使用 LIMIT 分批执行,避免长时间锁住大量行。

DELETE 操作

  • DELETE 逐行删除,每行都记录 Undo Log,释放的空间不会立即交还给操作系统,而是可在表空间内复用。
  • 如果需要清空全表数据,TRUNCATE TABLEDELETE FROM 快得多,因为它直接删除并重建表,不逐行处理,日志量极少。但 TRUNCATE 无法回滚(属于 DDL),且不触发触发器等,使用时务必确认业务逻辑。
  • 删除大量数据后,表空间碎片增多,可能需要后续执行 OPTIMIZE TABLE 来收缩空间(会锁表,谨慎操作)。

8.4.8 写入并发的几点实用建议

  • 避免热点行:设计上尽量让写入分散到不同数据页,例如不用自增主键而用随机主键。但若必须更新同一行(如库存扣减),可以采用乐观锁重试或排队机制,减少数据库层锁等待。
  • 使用 INSERT ... ON DUPLICATE KEY UPDATE 替代“先查后改”:减少一次网络往返和竞争窗口期。
  • 监控写入性能指标:关注 Innodb_row_lock_waits(行锁等待)、Innodb_buffer_pool_pages_dirty(脏页比例)、Innodb_log_waits(日志等待)等状态变量,及时发现写入瓶颈。
  • 降低写入锁竞争:一般让事务先主键更新、再更新二级索引列,可以减少锁冲突;合理使用乐观锁。

总结一句话:写入操作的核心挑战是“减少锁竞争、降低日志开销、控制事务大小”。 好的表设计和索引策略,加上批量操作和事务控制,能让你的数据库在写入密集场景下依然流畅运行。