文章/why-innodb-doublewrite-did-not-recover-a-torn-page

#mysql#innodb#doublewrite#torn-page#crash-recovery7 分钟阅读

为什么 InnoDB doublewrite buffer 不能恢复 torn page

Doublewrite 保存了页面副本,但是否使用它,取决于 MySQL 有没有进入 crash recovery。

看到 torn page 时,很容易产生一个疑问:InnoDB 明明有 doublewrite buffer,为什么坏页还会留在数据文件里?

我遇到的坏页是 16 KiB,前 12 KiB 全零,最后 4 KiB 仍有数据。页首的 LSN 已经变成零,页末 trailer 里的 LSN 还在。innochecksum 可以稳定地识别出损坏。

从页面形态看,这正是 doublewrite 想解决的问题。可 MySQL 正常启动后没有修复它。顺着 MySQL 8.4.8 的启动代码看下去,代码给出的答案有些反直觉:doublewrite 不是一份在读取失败时随时可用的备用页。对于普通数据页,它只在 crash recovery 中参与恢复。

换句话说,保存了副本,不代表 MySQL 一定会使用它。

Doublewrite 的承诺比想象中窄

InnoDB 刷新 dirty page 时,不会直接把内存页覆盖到最终 tablespace。它先把完整页面写入 doublewrite buffer,确认这份副本持久化,再写最终位置:

dirty page
  -> doublewrite buffer
  -> 持久化完整副本
  -> tablespace 中的最终位置

假设服务器在最后一步中途崩溃,tablespace 里可能只写入半页。Doublewrite 中仍有一份完整页面。下次启动进入 crash recovery 后,InnoDB 可以把它写回去。这也是 MySQL 官方文档 对这个机制的描述。

这里有个容易忽略的限定词:crash recovery。

Doublewrite 解决的是“InnoDB 写最终页面时发生崩溃”,不是“以后任何时候读到坏页,都从 doublewrite 找一份好的回来”。.dblwr 文件也不是持久保存所有页面的镜像,它的 slot 会循环复用。

加载 doublewrite 不等于执行恢复

MySQL 8.4.8 启动时确实会读取 .dblwr 文件。SysTablespace::read_lsn_and_check_flags() 在判断是否需要 crash recovery 之前调用 doublewrite 的加载逻辑。

调用顺序是先加载普通和 reduced doublewrite 文件,再校验 system tablespace 的 page 0:

err = recv_sys->dblwr->load();
if (err != DB_SUCCESS) {
  return err;
}

err = recv_sys->dblwr->reduced_load();
if (err != DB_SUCCESS) {
  return err;
}

err = it->validate_first_page(space_id(), flushed_lsn, false);

这里也能看出 page 0 为什么特殊。紧接着的代码会在 page 0 校验失败时调用 restore_from_doublewrite(0),因为 MySQL 必须先读到 system tablespace 的启动状态。这个提前恢复只针对启动关键页,不会顺便扫描普通 .ibd 页面。

我一开始看到这里,以为普通启动也会顺便检查并修复坏页。继续往下看才发现,加载和恢复是两个独立动作。普通数据页的恢复发生在 recv_init_crash_recovery() 中:

static void recv_init_crash_recovery() {
  ut_a(!recv_needed_recovery);

  recv_needed_recovery = true;
  recv_sys->dblwr->recover();

  // Start the recovery writer when redo application is enabled.
  if (srv_force_recovery < SRV_FORCE_NO_LOG_REDO) {
    // ...
  }
}

recover() 不是启动流程无条件调用的函数,它被包在 recv_init_crash_recovery() 里。接下来要找的就是谁会调用这个初始化函数。

recv_recovery_from_checkpoint_start() 中,第一个门槛是 redo checkpoint LSN 与 system tablespace 中 flush LSN 的比较:

if (checkpoint_lsn != flush_lsn) {
  if (!recv_needed_recovery) {
    recv_init_crash_recovery();
  }
}

即使两个 LSN 相等,后续扫描如果在 checkpoint 后发现更多有效 redo,也会调用同一个函数。反过来,LSN 相等且没有额外 redo,recv_needed_recovery 就一直是 falsedblwr->recover() 也不会执行。

进入 crash recovery 后,recv::Pages::recover() 才会遍历候选页面,读取 tablespace 中的目标页并校验 checksum。如果目标页损坏,doublewrite 副本有效,InnoDB 会覆盖整个物理页。

dblwr_recover_page() 中与页面恢复直接相关的分支如下:

fil_io(request, true, page_id, page_size, 0, page_size.physical(),
       buffer.begin(), nullptr);

BlockReporter data_file_page(true, buffer.begin(), page_size,
                             fsp_is_checksum_disabled(space->id));

if (data_file_page.is_corrupted()) {
  dberr_t dblwr_err;
  const bool dblwr_corrupted =
      is_dblwr_page_corrupted(page, space, page_no, &dblwr_err);

  if (dblwr_corrupted) {
    ib::fatal(UT_LOCATION_HERE, ER_IB_MSG_DBLWR_1306);
  }
} else {
  const bool data_page_zeroes = buf_page_is_zeroes(buffer.begin(), page_size);
  const bool dblwr_zeroes = buf_page_is_zeroes(page, page_size);
  dberr_t dblwr_err;
  const bool dblwr_corrupted =
      is_dblwr_page_corrupted(page, space, page_no, &dblwr_err);

  if (!data_page_zeroes || dblwr_zeroes || dblwr_corrupted) {
    return false;
  }
}

fil_io(write_request, true, page_id, page_size, 0, page_size.physical(),
       page, nullptr);

前一个 fil_io() 读取数据文件中的实际页面,BlockReporter 判断它是否损坏。目标页全部为零、而 doublewrite 中有一份有效的非零页面时,也会进入恢复。候选 doublewrite 页通过校验后,最后一个 fil_io()page_size.physical() 字节完整写回目标位置。Doublewrite recovery 不是拿 redo 拼接坏页,而是直接用完整物理页覆盖它。

即使坏页的 header 已经归零,这个过程仍然可以工作。Space ID 和 page number 来自 doublewrite 副本,不依赖坏页自己的 header。真正的问题不是 MySQL 认不出 torn page,而是它没有进入调用 recover() 的路径。

正常关机关闭了恢复入口

InnoDB 正常关机时会刷新 dirty pages,并写入 clean shutdown 所需的全局状态。下次启动时,MySQL 根据 system tablespace 和 redo 判断上次关机是否完整。如果状态呈现为 clean,且没有 redo 需要应用,recv_init_crash_recovery() 就不会被调用。

这里的 clean shutdown 状态不是一个单独的布尔标记。srv_shutdown_log() 先停止 redo 后台线程、flush tablespace,再确认当前 LSN 已经等于最后一个 checkpoint LSN:

log_make_empty_and_stop_background_threads(*log_sys);

const lsn_t lsn = log_get_lsn(*log_sys);
fil_flush_file_spaces();

ut_a(lsn == log_sys->last_checkpoint_lsn.load());

auto err = fil_write_flushed_lsn(lsn);
ut_a(err == DB_SUCCESS);

fil_write_flushed_lsn() 随后把这个 LSN 写入 system tablespace page 0 的 FIL_PAGE_FILE_FLUSH_LSN 字段,并再次 flush:

mach_write_to_8(buf + FIL_PAGE_FILE_FLUSH_LSN, lsn);
fil_write(page_id, univ_page_size, 0, univ_page_size.physical(), buf);
fil_system->flush_file_spaces();

下次启动读取的 flush_lsn 就来自这里。它描述的是 InnoDB 关机流程推进到了哪个 LSN,不包含每个普通数据页的 checksum 清单。

这套判断回答的是“上次关机有没有完成”,并不回答“磁盘上每一个普通数据页现在是否完整”。MySQL 不会在启动时遍历所有 .ibd 页面、逐页计算 checksum,再决定要不要执行 doublewrite recovery。

正常情况下,这没有问题。既然 InnoDB 自己完成了关机,dirty pages 应该已经落盘。但如果页面在正常关机之后被破坏,或者数据文件中的某一页来自更早的磁盘状态,system tablespace 和 redo 仍然可以表现为 clean。

这样就会留下一个看起来矛盾、实际上完全可能的组合:

全局状态:clean shutdown
普通数据页:torn page
doublewrite:可能仍有有效副本
启动结果:不调用 recv_init_crash_recovery(),不执行页面恢复

这正是正常关机和 doublewrite recovery 之间的空隙。Clean shutdown 不是一次全盘 scrub,它会让 recv_needed_recovery 保持为 false,从而跳过 dblwr->recover()

等到读出坏页时,已经走了另一条路

启动时没有扫描普通数据页,坏页通常要等到业务第一次读取才暴露。此时 buf_page_io_complete() 会校验页面,记录 corruption,并移除失败的 buffer page。默认配置下,坏页处理最终可能终止 mysqld。

这里的实现再次使用 BlockReporter,但错误分支只交给 buffer pool 的读失败处理:

BlockReporter reporter(true, frame, bpage->size,
                       fsp_is_checksum_disabled(bpage->id.space()));
is_corrupted = reporter.is_corrupted();

if (compressed_page || is_corrupted) {
  if (srv_force_recovery < SRV_FORCE_IGNORE_CORRUPT) {
    buf_read_page_handle_error(bpage);
    return false;
  }
}

这段函数里没有 recv_sys->dblwr->find(),也没有 dblwr->recover()。相同的 checksum 检查在启动恢复和运行期读取中得到相同结论,但后续动作不同。

这条运行期读路径不会回头查询启动时加载的 doublewrite 页面,也不会因为 checksum 错误临时调用 dblwr->recover()。Doublewrite 在这里不是 read fallback。

这也解释了为什么现象会让人困惑:MySQL 能准确发现页面坏了,doublewrite 里甚至可能还有一份好页面,但发现坏页和使用 doublewrite 分属两个阶段,前一条路径不会转入后一条路径。

这里讨论的是普通 file-per-table 数据页。System tablespace page 0 等启动关键页面有专门的提前恢复逻辑,不能用它们的行为推断普通数据页。

回到最初的问题

Doublewrite 要成功恢复一个普通 torn page,至少要同时满足三个条件:

  1. MySQL 判定上次发生了 crash,并进入 crash recovery。
  2. 目标数据页校验失败。
  3. Doublewrite slot 中仍保留该页的有效副本。

正常关机后的坏页,卡在第一个条件上。MySQL 会加载 .dblwr,却不会执行普通数据页的 recover()。等到运行期读出坏页,页面校验路径也不会回查 doublewrite。

所以,“为什么 doublewrite buffer 不能恢复 torn page”的准确答案是:它并非不能识别或覆盖这页,而是恢复动作受 crash recovery 控制。只要全局状态仍然是 clean,普通数据页的损坏就不会触发 doublewrite recovery。

相关阅读

文章PolarFS: An Ultra-low Latency and Failure Resilient Distributed File System for Shared Storage Cloud DatabaseMemo修正 ZFS 主机的 Node Exporter 内存告警Memo两块 Gen3 x4 NVMe,速度差了一倍

讨论

邮箱用于身份识别和回复通知,并由 Waline 存储。