从中间件的副本确认机制到 fsync:一条数据到底什么时候才算真正安全?
在研究 Kafka、Elasticsearch 和 MySQL 的可靠性机制时,有一个问题很容易被忽略:
一条数据写入成功,到底意味着什么?
比如,Kafka 返回了发送成功,ES 返回了写入成功,MySQL 的事务也提交成功了。这些系统都告诉我们操作已经完成,但它们对数据可靠性的保证并不完全相同。
有的系统只需要确认 Leader 收到了数据,有的需要等待多个副本完成写入,还有的会进一步等待日志刷入磁盘。
如果继续往下研究,还会遇到 MySQL 的 redo log、undo log、WAL、STEAL,以及操作系统的 write、fsync。
这些机制看起来分散在不同的系统和层次中,实际上都在解决一个问题:数据在什么情况下能够被保留下来,发生故障后又能否恢复?
一、先看副本确认:系统究竟在等待什么?
先从最容易接触到的配置说起。
Kafka、Elasticsearch 和 MySQL 都提供了不同程度的写入确认机制,但它们等待的对象并不一样。
| 确认方式 | 主要含义 | |
|---|---|---|
| Kafka | acks=0 |
不等待 Broker 确认 |
| Kafka | acks=1 |
Leader 确认写入 |
| Kafka | acks=all |
当前 ISR 中的副本全部确认 |
| Elasticsearch | wait_for_active_shards=1 |
写入前至少有一个活跃分片副本 |
| Elasticsearch | wait_for_active_shards=n |
写入前要求指定数量的活跃分片副本 |
| Elasticsearch | wait_for_active_shards=all |
写入前要求所有分配的副本活跃 |
| MySQL | 异步复制 | 主库提交不等待从库 |
| MySQL | 半同步复制 | 等待至少一个从库确认接收日志 |
| MySQL | Group Replication | 通过组通信和事务认证等机制协调事务 |
这里有几个容易混淆的地方。
1. Kafka 的 acks=all 并不意味着等待所有配置的副本。
它等待的是当前 ISR(In-Sync Replicas)中的副本。
如果某个 Follower 落后太多,就可能被移出 ISR。此时即使配置了 acks=all,也不会等待这个已经被移出的副本。
所以生产环境通常还需要配合 min.insync.replicas 使用。当 ISR 数量低于这个阈值时,Broker 会拒绝写入,避免 ISR 只剩 Leader 时,acks=all 实际上退化成只等待 Leader。
2. Elasticsearch 的 wait_for_active_shards 也容易被误解。
它控制的是写入前的活跃分片门槛,不是“等待所有副本写完”的开关。
ES 写入时,主分片会将写入操作同步到副本分片,默认需要等待所有 in-sync copies 副本完成写入后,才向客户端返回成功。而 wait_for_active_shards控制的是写入开始前,至少需要有多少个分片副本处于活跃状态。
3. MySQL 半同步复制等待的也不是从库执行事务完成。
MySQL 半同步确认的是事务事件已经写入从库 relay log 并刷盘,不要求从库已经执行完事务。主库提交成功时,从库上的查询仍可能读到旧值。半同步等待超时后还会退回异步复制,可靠性也会随运行状态变化。
这三套系统的确认机制虽然不同,但有一个共同点:
它们首先解决的是副本之间的协调问题。接下来要看,这些确认发生时,数据已经写到了哪里。
二、数据被副本确认之后,真的已经落盘了吗?
继续往下看,差异就比较明显了。
1. Kafka:副本确认不等于磁盘刷盘
Kafka 的消息采用日志追加的方式写入磁盘文件。
Broker 接收到消息后,会将数据写入对应的日志文件。通常情况下,数据先进入操作系统的 Page Cache,后续再由操作系统异步回写到磁盘。
当配置 acks=all 时,Leader 需要等待当前 ISR 中的所有副本完成相应的日志追加操作,然后才能向生产者返回成功。
这里需要注意:
Kafka 的 acks=all 并不要求每个副本都执行 fsync。
也就是说,Leader 和 Follower 都可能已经完成写入,但数据实际上还停留在各自操作系统的 Page Cache 中。
如果此时发生操作系统崩溃或突然断电,尚未刷盘的数据仍然可能丢失。
因此,Kafka 的多副本确认主要解决的是副本层面的可靠性,并不等价于每个副本都完成了物理持久化。
2. Elasticsearch:默认会等待 translog 刷盘
Elasticsearch 的情况有所不同。
ES 写入数据时,会先将数据写入 Lucene 的 Index Buffer,之后将写入操作追加到 translog(有先后顺序的)。Index Buffer 中的数据经过 refresh 后生成 Lucene Segment,供查询使用;translog 则负责故障恢复,避免数据在完成 Lucene commit (flush)之前丢失。
默认配置下:
index.translog.durability=reques
在 request 模式下,每次写入请求完成前,ES 都会对 translog 执行 fsync,确保日志持久化后再向客户端返回成功。
如果改成:
index.translog.durability=async
则由后台线程按照 index.translog.sync_interval 定期刷盘,默认间隔为 5 秒。这样可以减少同步刷盘的开销,但发生故障时,最近尚未刷盘的数据可能丢失。
上述讲的是translog的持久化,另外索引数据的两个操作如下:
3. MySQL:提交成功是否持久化,取决于刷盘配置
MySQL InnoDB 的事务提交主要涉及 redo log 和 binlog。(redo log 是 InnoDB 引擎私有的“崩溃恢复”日志,而 binlog 是 MySQL Server 层公有的“归档与复制”日志。)
这里有两个非常重要的参数:
innodb_flush_log_at_trx_commit
sync_binlog
第一个控制 InnoDB redo log 的刷盘策略,第二个控制 binlog 的刷盘策略。
innodb_flush_log_at_trx_commit 有三个常见配置:
| 配置 | 行为 | 故障风险 |
|---|---|---|
| 0 | 定期将 redo log 从 log buffer 写出并刷盘 | MySQL 进程崩溃也可能丢失最近事务 |
| 1 | 每次事务提交时写出并 fsync | 已提交事务具备较强的持久性保证 |
| 2 | 每次提交写入 OS Page Cache,定期 fsync | MySQL 进程崩溃通常不丢,但 OS 崩溃可能丢失最近事务 |
这里的定期刷盘通常约为 1 秒,但并不是严格的时间上限。
sync_binlog 则控制 binlog:
| 配置 | 行为 |
|---|---|
| 0 | 由操作系统决定何时刷盘 |
| 1 | 每次事务提交时同步 binlog |
| N | 累积 N 个事务后同步一次 |
生产环境中经常提到的双 1 配置,就是:
innodb_flush_log_at_trx_commit=1
sync_binlog=1
两个参数分别保证 InnoDB redo log 和 binlog 的持久性。
为什么需要同时关注这两个日志?
因为它们承担的职责不同。redo log 主要服务于 InnoDB 的崩溃恢复,binlog 则承担逻辑日志、复制和时间点恢复等职责。
三、深入 InnoDB:为什么 redo log 不只在事务提交时刷盘?
前面提到,innodb_flush_log_at_trx_commit=1 表示每次事务提交时同步刷 redo log。
但如果进一步研究 InnoDB 的刷盘机制,会发现一个问题:
redo log 并不是只有事务提交时才会刷盘。
要理解这一点,需要先认识两个概念:WAL 和 STEAL。
WAL(Write-Ahead Logging)是预写日志机制,核心要求是:
在数据页写入磁盘之前,必须先保证能够恢复该数据页修改的日志已经持久化。
而 STEAL 是数据库 Buffer Pool 管理中的一种策略。
它允许数据库将包含未提交事务修改的脏页提前写入磁盘,以便回收内存空间。
这两个机制放在一起,就会产生一个很有意思的问题。
假设有一个事务:
BEGIN;
UPDATE account
SET balance = balance - 100
WHERE id = 1;
-- 尚未提交
事务执行后,InnoDB 修改了 Buffer Pool 中的数据页,同时产生对应的 redo log 和 undo log。
此时事务还没有提交,但如果 Buffer Pool 空间不足,InnoDB 可能需要将这个脏页提前刷入磁盘。
这就是 STEAL。
问题来了:
如果数据页已经写入磁盘,而对应的 redo log 还没有持久化,那么一旦发生崩溃,数据库可能只留下了数据修改,却无法通过日志恢复这些修改。
这显然是不允许的。
因此,InnoDB 必须遵循 WAL 规则:
任何脏页刷盘之前,都必须确保对应的 redo log 已经持久化。
这与事务是否提交没有直接关系。
所以,InnoDB 的 redo log 至少存在两类刷盘驱动:
| 场景 | 触发原因 | 目的 |
|---|---|---|
| 事务提交 | 受 innodb_flush_log_at_trx_commit 控制 |
保证已提交事务的持久性 |
| 脏页刷盘 | WAL 强制要求 | 保证磁盘上的数据页能够通过日志恢复 |
两者解决的是不同的问题。
前者关注事务提交后的持久性,后者关注数据页写入磁盘时的一致性和可恢复性。
这也是为什么不能简单地把 innodb_flush_log_at_trx_commit=1 理解成 redo log 只在事务提交时刷盘。
四、undo log 为什么也不会丢?
理解了 redo log 的刷盘规则,再看 undo log 就容易多了。
undo log 主要用于事务回滚和 MVCC。
例如:
UPDATE account
SET balance = balance - 100
WHERE id = 1;
假设修改之前余额是 1000,修改之后变成 900。
为了支持事务回滚,InnoDB 需要保留修改前的数据,也就是旧值 1000。
这些信息会被记录到 undo log 中。
但 undo log 并不是一份独立于 InnoDB 存储体系的特殊日志文件,它本身存储在 InnoDB 的 undo tablespace 或相关存储结构中,其页面也由 Buffer Pool 管理。
undo 页的修改同样会产生 redo log。
这就形成了一个关系:
我们再沿着前面的事务推演一次。
假设事务 T 将余额从 1000 修改为 900,但还没有提交。
此时:
如果此时 InnoDB 决定将数据页刷入磁盘,那么在数据页写入之前,对应的 redo log 必须已经持久化。
由于 undo 页的修改也有对应的 redo log,因此这些日志同样受到 WAL 的保护。
假设此时发生崩溃。
InnoDB 启动恢复时,会先根据 redo log 重做相应的页面修改。
这样,即使之前的事务没有提交,数据页和 undo 页也能够恢复到崩溃前相应的状态。
随后,InnoDB 根据事务状态识别出 T 是未提交事务,再利用 undo log 将余额从 900 回滚到 1000。
整个过程可以概括为:
事务修改数据页
|
v
生成 undo log
|
v
数据页、undo 页产生 redo log
|
v
WAL 保证 redo 先于脏页落盘
|
v
发生数据库崩溃
|
v
重放 redo log
|
v
恢复数据页和 undo 页
|
v
识别未提交事务
|
v
利用 undo log 回滚事务
所以,undo log 并不需要单独配置一个类似 innodb_flush_log_at_trx_commit 的刷盘参数。
它的持久化依赖 InnoDB 自身的页面管理和 redo log 机制。
这里也能看出 WAL 和 STEAL 为什么可以同时存在:
STEAL 允许未提交的数据页提前落盘,WAL 则保证这些页面在发生崩溃时仍然可以被恢复和撤销。
两者并不矛盾,反而是数据库能够兼顾内存管理效率和事务原子性的重要基础。
五、最后一层:操作系统的 write 和 fsync 到底有什么区别?
前面无论是 Kafka、ES 还是 MySQL,都反复出现了一个词:fsync。
它实际上是操作系统提供的文件同步接口。
理解它,需要先区分三个位置:
以 MySQL 写 redo log 为例:
MySQL Log Buffer
|
| write
v
OS Page Cache
|
| fsync
v
磁盘稳定存储
write 的作用是将应用程序提供的数据写入内核管理的文件缓存。
调用成功,并不意味着数据已经真正写入磁盘。
此时即使 MySQL 进程退出,数据通常仍然存在于操作系统内核中。
但是,如果操作系统崩溃或者突然断电,Page Cache 中尚未持久化的数据就可能丢失。
fsync 则不同。
它要求操作系统将指定文件的脏数据同步到存储设备,并等待同步操作完成。
在底层存储设备正确履行持久化语义的前提下,fsync 成功返回,才能认为相关数据已经达到稳定存储。
当然,这里还有一个容易被忽略的细节:如果涉及新建文件、重命名等操作,仅对文件执行 fsync 并不一定足够,还可能需要同步父目录,以保证目录项变更也持久化。
回到 MySQL 的三个配置:
| 配置 | 主要写入位置 | 对应风险 |
|---|---|---|
innodb_flush_log_at_trx_commit=0 |
定期从 Log Buffer 写出并刷盘 | MySQL 进程崩溃可能丢失日志 |
innodb_flush_log_at_trx_commit=2 |
提交时写入 OS Page Cache | OS 崩溃可能丢失日志 |
innodb_flush_log_at_trx_commit=1 |
提交时同步刷盘 | 获得更强的持久性保证 |
这里还需要强调:fsync 保证的是操作系统和存储设备约定的持久化语义,并不意味着能够抵御磁盘损坏、控制器故障或者所有硬件故障。
六、回过头看三套系统,差异就比较清楚了
前面讨论了很多机制,现在再把 Kafka、ES 和 MySQL 放在一起看:
| 对比维度 | Kafka | Elasticsearch | MySQL InnoDB |
|---|---|---|---|
| 副本确认 | ISR | In-sync copies | 半同步 / Group Replication 等 |
| 核心持久化日志 | 分区日志 | translog | redo log / binlog |
| 默认同步刷盘策略 | 不要求每次写入 fsync | request 模式同步 fsync translog | redo 每次提交 fsync,binlog 取决于配置 |
| 是否依赖 Page Cache | 是 | 是 | 是 |
| 是否有 WAL 机制 | 日志追加模型 | translog 与 Lucene 协同 | redo log 预写日志 |
这里最值得注意的是:
Kafka 的 acks=all、ES 的默认写入确认,以及 MySQL 的事务提交成功,虽然都可以被理解为写入成功,但背后的持久化边界并不相同。
Kafka 通过 ISR(in-sync replicas)副本机制保证可靠性,在 acks=all 时写入需所有同步副本确认才算提交;ES 默认在主分片和所有副本分片都完成 translog 的 fsync 后才向客户端确认;MySQL 则依靠 redo log、binlog,并通过两阶段提交协调两者,来维护事务的持久性与一致性。
如果只看 API 返回的成功状态,很容易把这些保证混为一谈。
实际上,判断一条数据是否安全,可以继续追问几个问题:

浙公网安备 33010602011771号