一条 INSERT INTO orders VALUES (1, 'test') 语句,从客户端发出到数据安全落在磁盘上,至少经历 7 个步骤:解析、并发控制、WAL 写入、内存结构更新、返回成功、后台刷盘、后台 Compaction。其中任何一步出问题,要么数据丢失,要么写入卡住。
WAL 什么时候写?数据什么时候从内存刷到磁盘?Group Commit 如何把多条写入合并成一次 I/O?这一章追踪写路径的每一步——从请求到持久化的完整旅程。
一、写路径全景
1.1 通用写路径
写路径的核心原则:
| 原则 | 说明 | 为什么 |
|---|---|---|
| WAL 先写 | WAL 必须先于内存结构落盘 | 崩溃后可通过 WAL 恢复 |
| 顺序写入 | WAL 追加写入 | 顺序 I/O 比随机 I/O 快数百倍 |
| 延迟刷盘 | 内存结构积累后批量刷盘 | 摊销 I/O 开销 |
| 后台合并 | Compaction 在后台执行 | 不阻塞前台写入 |
1.2 InnoDB vs RocksDB 写路径对比
| 步骤 | InnoDB | RocksDB |
|---|---|---|
| 1. 并发控制 | 行锁 + MVCC | 无锁(CoW) |
| 2. 日志 | Undo + Redo | WAL(可选) |
| 3. 内存更新 | 修改 Buffer Pool 页面 | 插入 MemTable |
| 4. 刷盘 | 脏页刷盘 + Doublewrite | Flush SSTable |
| 5. 后台 | Purge + Change Buffer | Compaction |
二、WAL 写入
2.1 WAL 的写入流程
// WAL 写入流程(简化)struct WALWriter { int fd; // WAL 文件描述符 uint64_t current_offset; // 当前写入偏移 buffer_t write_buffer; // 写入缓冲 mutex_t write_mutex; // 写入互斥锁 cond_t write_cond; // 写入条件变量};
void wal_append(WALWriter *wal, WriteBatch *batch) { // 1. 序列化 WriteBatch wal_record_t record; record.length = serialize_batch(batch, record.data); record.crc = crc32(record.data, record.length);
// 2. 追加到写入缓冲 mutex_lock(&wal->write_mutex); buffer_append(&wal->write_buffer, &record, sizeof(record));
// 3. Group Commit:等待一小段时间,合并多个写入 // 详见第三节
// 4. 刷盘(fdatasync) write(wal->fd, wal->write_buffer.data, wal->write_buffer.size); fdatasync(wal->fd); // 确保数据落盘
// 5. 清空缓冲 buffer_clear(&wal->write_buffer); mutex_unlock(&wal->write_mutex);}2.2 WAL 记录格式
// WAL 记录格式struct WALRecord { uint32_t crc; // CRC32 校验 uint32_t length; // 数据长度 uint64_t lsn; // 日志序列号 uint8_t type; // 记录类型 uint8_t data[]; // 变长数据};
// 记录类型enum WALRecordType { FULL_RECORD = 0, // 完整记录 FIRST_FRAGMENT = 1, // 分片:第一片 MIDDLE_FRAGMENT = 2, // 分片:中间片 LAST_FRAGMENT = 3, // 分片:最后一片 CHECKPOINT = 4, // 检查点};
// InnoDB Redo Log 记录格式struct RedoLogRecord { uint8_t type; // 操作类型(INSERT/UPDATE/DELETE...) uint32_t space_id; // 表空间 ID uint32_t page_no; // 页面编号 uint16_t offset; // 页内偏移 uint8_t data[]; // 变长数据};2.3 WAL 的持久化保证
| 配置 | 说明 | 持久性 | 性能 |
|---|---|---|---|
O_DIRECT + fdatasync | 绕过 Page Cache,直接刷盘 | 强 | 中 |
O_DIRECT + O_DSYNC | 绕过 Page Cache,每次写同步 | 强 | 低 |
O_DSYNC | 经过 Page Cache,同步写 | 较强 | 中 |
不刷盘 | 依赖操作系统回写 | 弱 | 高 |
数据库的 WAL 刷盘策略直接决定了持久性保证。InnoDB 的 innodb_flush_log_at_trx_commit=1 表示每次事务提交都 fsync WAL,这是最安全的配置。设为 0 或 2 可能丢失最近 1 秒的事务。
三、Group Commit
3.1 Group Commit 原理
Group Commit 是写路径最重要的优化之一:将多个事务的 WAL 记录合并为一次 fsync:
// Group Commit 实现(简化)struct GroupCommitQueue { mutex_t mutex; cond_t cond; WriteBatch *batches[MAX_BATCH]; int count;};
void group_commit_submit(GroupCommitQueue *queue, WriteBatch *batch) { mutex_lock(&queue->mutex); queue->batches[queue->count++] = batch;
if (queue->count == 1) { // 第一个事务:启动 Group Commit 窗口 // 等待更多事务加入(通常 100μs–1ms) cond_timedwait(&queue->cond, &queue->mutex, GROUP_COMMIT_TIMEOUT);
// 窗口关闭,执行 Group Commit do_group_commit(queue); } else { // 后续事务:等待 Leader 完成 fsync while (!batch->committed) { cond_wait(&queue->cond, &queue->mutex); } } mutex_unlock(&queue->mutex);}
void do_group_commit(GroupCommitQueue *queue) { // 1. 合并所有 WriteBatch buffer_t combined; for (int i = 0; i < queue->count; i++) { serialize_batch(&combined, queue->batches[i]); }
// 2. 一次写入 + fsync write(wal_fd, combined.data, combined.size); fdatasync(wal_fd);
// 3. 通知所有等待的事务 for (int i = 0; i < queue->count; i++) { queue->batches[i]->committed = true; } cond_broadcast(&queue->cond); queue->count = 0;}3.2 Group Commit 性能提升
| 并发事务数 | 无 Group Commit | 有 Group Commit | 提升 |
|---|---|---|---|
| 1 | 1,000 TPS | 1,000 TPS | 1x |
| 10 | 1,000 TPS | 8,000 TPS | 8x |
| 100 | 1,000 TPS | 50,000 TPS | 50x |
| 1000 | 1,000 TPS | 200,000 TPS | 200x |
四、InnoDB 写路径详解
4.1 InnoDB 写入流程
// InnoDB 写入流程(简化)void innodb_write_row(dict_index_t *index, dtuple_t *row, trx_t *trx) { // 1. 开启事务 trx_start(trx);
// 2. 加行锁 lock_table(row->table, LOCK_X);
// 3. 写 Undo Log(用于回滚和 MVCC) undo_log_write(trx, UNDO_INSERT, row);
// 4. 在 B+ 树中插入记录 // 4a. 查找目标页面 page_t *page = btree_search(index, row->key);
// 4b. 修改页面(在 Buffer Pool 中) page_insert(page, row); page_mark_dirty(page);
// 5. 写 Redo Log(WAL) // 记录页面的物理变更 redo_log_write(REDOPAGE_INSERT, page->id, row->offset, row->data);
// 6. 提交事务 // Group Commit:等待 fsync trx_commit(trx);
// 后台操作: // - 脏页刷盘(由 Page Cleaner 线程负责) // - Doublewrite(防止页面写撕裂) // - Purge(清理 Undo Log)}4.2 InnoDB 的 Doublewrite
Doublewrite 是 InnoDB 防止页面写撕裂(torn page)的机制:
| 步骤 | 操作 | 目的 |
|---|---|---|
| 1 | 脏页写入 Doublewrite Buffer | 保存完整页面副本 |
| 2 | Doublewrite Buffer 刷盘 | 确保副本持久化 |
| 3 | 脏页写入数据文件 | 实际写入 |
| 4 | 如果步骤 3 崩溃 | 从 Doublewrite 恢复 |
Doublewrite 增加了约 10–15% 的写入开销,因为每个脏页需要写两次。在支持原子写入的存储设备(如 Fusion-io、部分 NVMe SSD)上,可以关闭 Doublewrite:innodb_doublewrite=0。
五、RocksDB 写路径详解
5.1 RocksDB 写入流程
// RocksDB 写入流程(简化)void rocksdb_put(DB *db, const Slice &key, const Slice &value) { // 1. 构造 WriteBatch WriteBatch batch; batch.Put(key, value);
// 2. 获取写锁(所有写入串行化) mutex_lock(&db->write_mutex);
// 3. 写入 WAL(如果启用) if (db->options.write_options.sync) { wal_append(db->wal, batch); fdatasync(db->wal_fd); }
// 4. 写入 MemTable memtable_insert(db->memtable, key, value, db->next_seq++);
// 5. 检查是否需要 Flush if (memtable_size(db->memtable) >= db->options.write_buffer_size) { // 5a. 将当前 MemTable 切换为 Immutable db->immutable = db->memtable; db->memtable = memtable_create();
// 5b. 通知后台线程 Flush schedule_flush(db); }
// 6. 释放写锁 mutex_unlock(&db->write_mutex);}5.2 RocksDB 的写入优化
| 优化 | 说明 | 效果 |
|---|---|---|
| WriteBatch | 批量写入,一次 WAL + MemTable | 减少锁竞争 |
| Group Commit | 多个 WriteBatch 合并一次 fsync | 减少 fsync 次数 |
| Pipelined Write | WAL 和 MemTable 写入并行化 | 提升吞吐 |
| Write Thread | 专用写入线程,避免上下文切换 | 减少延迟 |
| Rate Limiter | 限制 Compaction 和 Flush 的 I/O 速率 | 避免影响前台写入 |
5.3 RocksDB 写入停顿
当 LSM 树的层级文件过多时,RocksDB 会主动减慢或停止写入:
# RocksDB 写入停顿机制def check_write_stall(db): l0_file_count = len(db.levels[0].sstables) l1_size = db.levels[1].total_size l1_max = db.options.max_bytes_for_level_base
# L0 文件数过多 if l0_file_count >= db.options.level0_stop_writes_trigger: return "STOP" # 完全停止写入
if l0_file_count >= db.options.level0_slowdown_writes_trigger: return "SLOW" # 写入前 sleep 1ms
# L1 大小超过限制 if l1_size >= l1_max * db.options.level1_size_limit: return "SLOW"
return "OK"
# 默认配置# level0_slowdown_writes_trigger = 20# level0_stop_writes_trigger = 36# 当 L0 有 20+ 个 SSTable 时,写入延迟增加# 当 L0 有 36+ 个 SSTable 时,写入完全停止六、刷盘策略
6.1 刷盘时机
| 触发条件 | InnoDB | RocksDB |
|---|---|---|
| 主动刷盘 | 事务提交时 fsync WAL | WriteOptions.sync=true 时 fsync WAL |
| MemTable 满 | — | Flush 为 SSTable |
| 脏页比例 | 脏页超过 75% 时加速刷盘 | — |
| 后台线程 | Page Cleaner 每秒刷脏页 | Flush 线程按需刷盘 |
| 检查点 | Sharp/Fuzzy Checkpoint | — |
6.2 刷盘与性能
# 刷盘策略对性能的影响def analyze_flush_strategy(): scenarios = { "每次提交 fsync": { "latency": "1–5ms(取决于磁盘)", "throughput": "~1000 TPS(单线程)", "durability": "最强", }, "每秒 fsync": { "latency": "0.1–0.5ms", "throughput": "~10000 TPS", "durability": "可能丢失 1 秒数据", }, "Group Commit fsync": { "latency": "0.5–2ms", "throughput": "~50000+ TPS", "durability": "强(所有提交的事务都持久化)", }, "不 fsync": { "latency": "0.01–0.1ms", "throughput": "~100000+ TPS", "durability": "弱(可能丢失数据)", }, } for name, info in scenarios.items(): print(f"{name:25s} | 延迟: {info['latency']:15s} | " f"吞吐: {info['throughput']:15s} | 持久性: {info['durability']}")
analyze_flush_strategy()七、写路径性能优化
7.1 写入优化清单
| 优化 | 层次 | 效果 | 代价 |
|---|---|---|---|
| Group Commit | WAL | 减少 fsync 次数 | 增加提交延迟 |
| 批量写入 | API | 摊销锁开销 | 需要应用层攒批 |
| 异步写入 | API | 不阻塞调用线程 | 需要处理回调 |
| WAL 压缩 | WAL | 减少 WAL 大小 | CPU 开销 |
| 延迟分配 | 文件系统 | 减少碎片 | 崩溃后可能丢失 |
| O_DIRECT | I/O | 绕过 Page Cache | 需要对齐 |
| 写入限速 | Compaction | 保护前台写入 | 后台延迟增加 |
7.2 InnoDB 写入调优
-- InnoDB 写入相关配置-- WAL 刷盘策略SET GLOBAL innodb_flush_log_at_trx_commit = 1; -- 最安全-- 1: 每次提交 fsync(默认,最安全)-- 2: 每次提交写入 OS 缓冲,每秒 fsync-- 0: 每秒写入并 fsync(最快,可能丢数据)
-- DoublewriteSET GLOBAL innodb_doublewrite = 1; -- 建议开启
-- 刷盘方法SET GLOBAL innodb_flush_method = O_DIRECT; -- 推荐
-- 脏页刷盘比例SET GLOBAL innodb_max_dirty_pages_pct = 75;SET GLOBAL innodb_max_dirty_pages_pct_lwm = 10;
-- 刷盘频率SET GLOBAL innodb_io_capacity = 2000; -- SSD 推荐SET GLOBAL innodb_io_capacity_max = 4000; -- SSD 推荐7.3 RocksDB 写入调优
# RocksDB 写入相关配置# WAL 模式# WAL_sync: 每次写入 fsync WAL(最安全)# WAL_async: 异步 fsync# WAL_disable: 不写 WAL(最快,崩溃丢数据)
# Write Buffer 大小write_buffer_size = 67108864 # 64MB
# 最大 Write Buffer 数量max_write_buffer_number = 3
# Write Buffer 总大小限制db_write_buffer_size = 268435456 # 256MB
# 后台 Flush 线程数max_background_flushes = 2
# 后台 Compaction 线程数max_background_compactions = 4
# 写入限速rate_limiter_bytes_per_sec = 104857600 # 100MB/s八、实战:观察写路径
8.1 InnoDB 写路径观察
-- 观察 WAL 写入量SHOW STATUS LIKE 'Innodb_os_log_written';
-- 观察脏页比例SHOW STATUS LIKE 'Innodb_buffer_pool_pages_dirty';SHOW STATUS LIKE 'Innodb_buffer_pool_pages_total';
-- 观察刷盘统计SHOW STATUS LIKE 'Innodb_data_writes';SHOW STATUS LIKE 'Innodb_data_written';SHOW STATUS LIKE 'Innodb_fsyncs';
-- 观察 Doublewrite 统计SHOW STATUS LIKE 'Innodb_dblwr%';8.2 RocksDB 写路径观察
# RocksDB 统计信息# 通过 db.GetProperty() 获取rocksdb.stats
# 关键指标:# wal.bytes_written: WAL 写入字节数# wal.syncs: WAL fsync 次数# memtable.insert: MemTable 插入次数# memtable.flush: Flush 次数# compaction.bytes_written: Compaction 写入字节数# stall.micros: 写入停顿时间8.3 写入延迟分析
# 使用 perf 分析写入延迟perf record -e 'block:block_rq_issue,block:block_rq_complete' \ -p $(pidof mysqld) sleep 10perf report
# 使用 bcc 分析 fsync 延迟/usr/share/bcc/tools/xfsslower 10# Tracing XFS operations slower than 10 ms
# 使用 strace 跟踪 fsync 调用strace -e fdatasync,fsync -p $(pidof mysqld) -c写路径中最容易被忽视的性能陷阱是 fsync 的频率。每次 fsync 都触发一次强制刷盘,如果每条事务都 fsync,TPS 会急剧下降。MySQL 的 innodb_flush_log_at_trx_commit=2 模式每秒 fsync 一次而非每次提交,性能提升 10 倍以上——但崩溃时可能丢失 1 秒数据。
九、总结
| 主题 | 核心要点 | 关键词 |
|---|---|---|
| WAL 先写 | 所有修改先写 WAL 再更新内存,保证崩溃可恢复 | Write-Ahead, 持久性 |
| Group Commit | 合并多个事务的 WAL 记录为一次 fsync,提升吞吐 10–200x | 批量提交, fsync |
| InnoDB 写路径 | Undo + Redo + Buffer Pool + Doublewrite,原地更新 | 原地更新, Doublewrite |
| RocksDB 写路径 | WAL + MemTable + Flush + Compaction,追加写入 | 追加写入, MemTable |
| 刷盘策略 | fsync 时机决定持久性与性能的权衡 | 刷盘时机, 持久性 |
| 写入停顿 | LSM 树层级文件过多时主动限速,保护读取性能 | 限速, 写停顿 |
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






