mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1358 字
4 分钟
写路径
2025-06-24

一条 INSERT INTO orders VALUES (1, 'test') 语句,从客户端发出到数据安全落在磁盘上,至少经历 7 个步骤:解析、并发控制、WAL 写入、内存结构更新、返回成功、后台刷盘、后台 Compaction。其中任何一步出问题,要么数据丢失,要么写入卡住。

WAL 什么时候写?数据什么时候从内存刷到磁盘?Group Commit 如何把多条写入合并成一次 I/O?这一章追踪写路径的每一步——从请求到持久化的完整旅程。

一、写路径全景#

1.1 通用写路径#

graph TB subgraph 写路径["存储引擎写路径"] REQ["写入请求<br/>Put(key, value)"] --> LOCK["并发控制<br/>加锁/MVCC"] LOCK --> WAL["写入 WAL<br/>顺序追加"] WAL --> MEM["更新内存结构<br/>MemTable/B+ 树页面"] MEM --> ACK["返回成功"] MEM -->|"后台"| FLUSH["刷盘<br/>内存→磁盘"] FLUSH --> COMPACT["Compaction<br/>合并与清理"] end style REQ fill:#e3f2fd,stroke:#1565c0 style WAL fill:#fff9c4,stroke:#f9a825 style MEM fill:#c8e6c9,stroke:#2e7d32 style FLUSH fill:#ffe0b2,stroke:#e65100

写路径的核心原则:

原则说明为什么
WAL 先写WAL 必须先于内存结构落盘崩溃后可通过 WAL 恢复
顺序写入WAL 追加写入顺序 I/O 比随机 I/O 快数百倍
延迟刷盘内存结构积累后批量刷盘摊销 I/O 开销
后台合并Compaction 在后台执行不阻塞前台写入

1.2 InnoDB vs RocksDB 写路径对比#

graph TB subgraph InnoDB写路径["InnoDB 写路径"] I_REQ["写入请求"] --> I_LOCK["加行锁"] I_LOCK --> I_UNDO["写 Undo Log<br/>(回滚段)"] I_UNDO --> I_REDO["写 Redo Log<br/>(WAL)"] I_REDO --> I_PAGE["修改 Buffer Pool 页面<br/>(脏页)"] I_PAGE --> I_ACK["返回成功"] I_PAGE -->|"后台线程"| I_FLUSH["刷脏页<br/>(Doublewrite)"] end subgraph RocksDB写路径["RocksDB 写路径"] R_REQ["写入请求"] --> R_WAL["写 WAL<br/>(可选)"] R_WAL --> R_MEM["写入 MemTable<br/>(跳表插入)"] R_MEM --> R_ACK["返回成功"] R_MEM -->|"MemTable 满"| R_IMMU["切换为 Immutable"] R_IMMU -->|"后台线程"| R_FLUSH["Flush 为 SSTable"] R_FLUSH --> R_COMPACT["后台 Compaction"] end style I_REDO fill:#fff9c4,stroke:#f9a825 style R_WAL fill:#fff9c4,stroke:#f9a825 style I_PAGE fill:#c8e6c9,stroke:#2e7d32 style R_MEM fill:#c8e6c9,stroke:#2e7d32
步骤InnoDBRocksDB
1. 并发控制行锁 + MVCC无锁(CoW)
2. 日志Undo + RedoWAL(可选)
3. 内存更新修改 Buffer Pool 页面插入 MemTable
4. 刷盘脏页刷盘 + DoublewriteFlush SSTable
5. 后台Purge + Change BufferCompaction

二、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,同步写较强
不刷盘依赖操作系统回写
Important

数据库的 WAL 刷盘策略直接决定了持久性保证。InnoDB 的 innodb_flush_log_at_trx_commit=1 表示每次事务提交都 fsync WAL,这是最安全的配置。设为 0 或 2 可能丢失最近 1 秒的事务。

三、Group Commit#

3.1 Group Commit 原理#

Group Commit 是写路径最重要的优化之一:将多个事务的 WAL 记录合并为一次 fsync:

sequenceDiagram participant T1 as 事务 1 participant T2 as 事务 2 participant T3 as 事务 3 participant WAL as WAL Writer participant DISK as 磁盘 T1->>WAL: 写入 WAL 记录 T2->>WAL: 写入 WAL 记录 T3->>WAL: 写入 WAL 记录 Note over WAL: 等待 Group Commit 窗口 WAL->>DISK: 一次 fsync 写入所有记录 DISK-->>WAL: 完成 WAL-->>T1: 提交成功 WAL-->>T2: 提交成功 WAL-->>T3: 提交成功
// 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提升
11,000 TPS1,000 TPS1x
101,000 TPS8,000 TPS8x
1001,000 TPS50,000 TPS50x
10001,000 TPS200,000 TPS200x

四、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)的机制:

graph LR subgraph Doublewrite["Doublewrite 机制"] DIRTY["脏页"] --> DWS["Doublewrite Buffer<br/>2MB 连续空间"] DWS -->|"1. 先写 Doublewrite"| DW_DISK["Doublewrite 区域<br/>(共享表空间)"] DWS -->|"2. 再写数据文件"| DATA_DISK["数据文件<br/>(.ibd 文件)"] end style DWS fill:#fff9c4,stroke:#f9a825 style DW_DISK fill:#e3f2fd,stroke:#1565c0 style DATA_DISK fill:#c8e6c9,stroke:#2e7d32
步骤操作目的
1脏页写入 Doublewrite Buffer保存完整页面副本
2Doublewrite Buffer 刷盘确保副本持久化
3脏页写入数据文件实际写入
4如果步骤 3 崩溃从 Doublewrite 恢复
Note

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 WriteWAL 和 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 刷盘时机#

触发条件InnoDBRocksDB
主动刷盘事务提交时 fsync WALWriteOptions.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 CommitWAL减少 fsync 次数增加提交延迟
批量写入API摊销锁开销需要应用层攒批
异步写入API不阻塞调用线程需要处理回调
WAL 压缩WAL减少 WAL 大小CPU 开销
延迟分配文件系统减少碎片崩溃后可能丢失
O_DIRECTI/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(最快,可能丢数据)
-- Doublewrite
SET 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 10
perf 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
Warning

写路径中最容易被忽视的性能陷阱是 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 树层级文件过多时主动限速,保护读取性能限速, 写停顿

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

写路径
https://blog.souloss.com/posts/storage/storage-write-path/
作者
Souloss
发布于
2025-06-24
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时