在研究 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的持久化,另外索引数据的两个操作如下:
•refresh:生成新的 Lucene Segment,使数据可以被搜索,此时数据本身在page cache里面。
•flush:执行 Lucene commit,持久化索引数据到磁盘,并清理不再需要的 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。
这就形成了一个关系:
•修改数据页,会产生 redo log。
•修改 undo 页,也会产生 redo log。
•数据页和 undo 页都可能成为脏页。
•脏页刷盘前,都必须满足 WAL 要求。
我们再沿着前面的事务推演一次。
假设事务 T 将余额从 1000 修改为 900,但还没有提交。
此时:
1.数据页中记录了新值 900。
2.undo log 中保存了旧值 1000。
3.数据页和 undo 页都可能处于脏页状态。
4.对应的页面修改都产生了 redo log。
如果此时 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。
它实际上是操作系统提供的文件同步接口。
理解它,需要先区分三个位置:
1.应用程序自己的缓冲区。
2.操作系统的 Page Cache。
3.稳定存储设备如磁盘等。
以 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 返回的成功状态,很容易把这些保证混为一谈。
实际上,判断一条数据是否安全,可以继续追问几个问题:
•有多少个副本确认了?
•副本确认时,数据是在内存还是已经落盘?
•如果数据页提前写入磁盘,相关日志是否已经持久化?
•如果发生崩溃,系统能否通过日志恢复数据?