【华为云MySQL技术专栏】告别阻塞:RDS for MySQL大表删除优化之路
1、背景:为什么需要更快的表删除?
在MySQL运维中,tablespace被truncate或者drop是常见操作,比如有一个连接创建了临时表, 连接断开以后, 需要对临时表做tablespace truncate操作。社区MySQL版本(8.0.23前)执行这些操作时存在显著性能瓶颈:
1.Buffer Pool清理风暴
InnoDB使用全局唯一的space id标识表空间,而对于临时表空间、undo表空间以及MySQL5.7执行truncate table,存在表空间复用的场景,当新表创建可能复用已释放的space id,此时若未清理旧表页面,新表可能访问脏数据。因此社区在早期的设计中,当tablespace被truncate或者drop时,InnoDB会将该tablespace对应的所有的page从Buffer Pool中全部删除。为了将这些page删除, 那么就需要遍历LRU/FLUSH List。当Buffer Pool特别大的时候, 清理过程将耗时较长,会长时间独占单个Buffer Pool的Mutex,阻碍其他事务正常读写页面,进而引起实例性能劣化。
AHI清理阶段,InnoDB需要遍历Buffer Pool清理相关AHI记录,此过程需持有全局锁dict_sys->mutex,耗时较长。
本文将从设计原理及源码实现角度介绍华为云RDS for MySQL对于以上问题的解决方案。
2、原理深度解构:大表删除优化背后的设计哲学
2.1 优化思路
传统大表删除的关键瓶颈点主要集中在内存页面同步清理以及文件删除时的磁盘IO抢占,为彻底解决此问题,RDS for MySQL的优化采用了Buffer Pool惰性清理与异步删除文件的核心思想。
 图1  大表删除优化流程对比
图1  大表删除优化流程对比
大表删除优化带来了数据的延迟清理,包括内存中的页面清理以及磁盘上的文件清理,如何保证优化背后的数据正确性以及数据的有效清理是必须解决的问题:
2.2 关键技术实现
2.2.1 Buffer Pool惰性清理
核心变革:不再立即清理BP中的页面,而是通过两阶段标记机制延迟处理。
 图2  Buffer pool惰性清理机制
图2  Buffer pool惰性清理机制
1.添加space删除标记,延迟清理space
drop/truncate tablespace触发space_delete操作时,仅仅将space从fil_shard的m_spaces中移除,并将其放入m_deleted_spaces中,用于标记space被删除。最终的删除操作在master thread中定期检测执行, 当检测到m_deleted_spaces中的space在内存中已经没有页面引用,将space最终删除。
2.添加页面版本标记,拦截过期页面访问
Buffer Pool中的page添加一个version, 代表该page当时被访问时对应的space的版本。每个space也添加一个version,当tablespace被drop/truncate的时候, 对space的version+1, 那么此后再访问Buffer Pool中的page时, 就会因为page的version小于space的version而变得无效,此时会触发淘汰失效页面,并从磁盘读取新页面,从而避免访问脏数据发生。此外,对于过期页面,会被后台淘汰线程逐渐淘汰,避免反复访问失效页面。
通过两阶段标记机制,原先由drop/truncate tablespace 触发的space_delete 操作就变的非常的轻量。后台线程定期的将过期页面淘汰,当标记删除的space在Buffer Pool中的page均被淘汰后,master线程清理内存中的space对象就完成了表空间的最终删除。采用延迟删除机制,每次页面访问均需要进行页面版本判定,这也带来了额外的性能开销。
2.2.2 磁盘文件异步删除
对于大文件删除,导致磁盘IO抢占问题,RDS for MySQL自研purge large file特性,此特性通过启动一个后台线程来异步清理数据文件。当执行DROP TABLE或者TRUNCATE TABLE时,对应的数据文件会被重命名为临时文件,然后通过文件清理线程异步、平缓地清理。
 图3  large file purge异步删除机制
图3  large file purge异步删除机制
large file purge处理逻辑:
4.post_ddl函数继续回放当前THD对应的所有DDL Log记录。当处理到新插入的 DELETE_SPACE_LOG日志时,由于new_filepath非空,表明该日志属于异步清理大文件过程中新写入的记录。系统随后将new_filepath所指向的临时文件加入后台异步清理线程的工作队列中,由该线程实际执行文件删除操作。当前THD相关的所有 DDL Log回放完成后,通过delete清理对应的DDL Log。
3、源码解析:深入数据库内核实现
前面介绍了RDS for MySQL大表删除优化的设计原理,下面我们从源码角度了解Buffer Pool惰性清理以及磁盘文件异步删除的实现。
3.1 Buffer Pool惰性清理源码实现
3.1.1 两阶段标记机制
社区MySQL8.0.23版本通过InnoDB faster drop/truncate特性实现Buffer Pool的惰性清理。特性为buf_page_t 增加 m_space用于指向页面引用的space,增加m_version用于判定页面是否过期,每次从Buffer Pool中读取页面时,会比较page的m_version和m_space的m_version,只有当版本号相同时,页面才是有效的,以此避免访问脏数据。
Additions to buf_page_t {
 ...
 // 指向页面归属的fil_space_t
 fil_space_t *m_space{};
 // 用于检查页面是否过期的版本号,在读页面初始化的时候设置成m_space->m_version
 uint32_t m_version{};
};
dberr_t Buf_fetch_normal::get(buf_block_t *&block) {
  for (;;) {
    // 查找Buffer Pool中是否有对应的page
    block = lookup();
    if (block != nullptr) {
      // 检查页面是否过期,这里通过比较m_version是否等于m_space.m_version判断
      if (block->page.was_stale()) {
        // 尝试淘汰过期页面
        if (!buf_page_free_stale(m_buf_pool, &block->page, m_hash_lock)) {
          os_thread_sleep(100);
        }
        continue;
      }
      // 页面没有过期,直接返回页面
      break;
    }
    // Buffer Pool中无对应page,从存储读取页面
    read_page();
  }
  return DB_SUCCESS;
}fil_space_t增加m_version用于标记space的当前版本号,每次space delete/truncate就会递增1;增加m_n_ref_count用于标记space的page在Buffer Pool中的个数,Buffer Pool每增加一个属于该space的page ,m_n_ref_count递增1,当m_n_ref_count为0的时候,表示该space在内存中没有页面, 此时该space才能被彻底删除,否则Buffer Pool中的页面会指向空space,导致访问异常。
Additions to  fil_space_t {
 ...
 // space当前版本号,每次space delete/truncate时递增
 lsn_t m_version{};
 // space在Buffer Pool中页面个数,m_n_ref_count不为0时,不能删除space
 std::atomic_int m_n_ref_count{};
};
bool Fil_shard::space_truncate(space_id_t space_id, page_no_t size_in_pages) {
  fil_space_t *space{};
  /* Step-1:truncate的prepare阶段,包括停止该表空间上的所有新操作和I/O,页面将被丢弃 */
  if (space_prepare_for_truncate(space_id, space) != DB_SUCCESS) {
    return false;
  }
  mutex_acquire();
  /* Step-2:通过增加表空间版本号,将缓冲池中的表空间页面标记为过期。这些过期的页面将被忽略,并在稍后进行惰性释放
  这包括AHI,其条目将在buf_page_free_stale*() -> buf_LRU_free_page ->  btr_search_drop_page_hash_index()过程中被移除。*/
  space->bump_version();
  /* Step-3:truncate表空间,更新访问句柄 */
  bool success = os_file_truncate(file.name, file.handle, 0);
  mutex_release();
  return success;
}3.1.2 延迟清理机制
InnoDB的临时表空间space id是可复用的,在MySQL 8.0.23版本前,当会话连接断开时,会执行ibt::Tablespace::truncate->Filshard::space_truncate->buf_LRU_flush_or_remove_pages来清理表空间页面,该函数传入的清理策略参数为BUF_REMOVE_ALL_NO_WRITE,表示需要将Buffer Pool中该space 对应的页面都清理才可以完成操作。在社区MySQL 8.0.23 版本中,新增加 buf_remove_t类型: BUF_REMOVE_NONE,表示不需要移除该tablespace 的页面。
/** 清理Buffer Pool中page的方式. */
enum buf_remove_t {
  /** 不清理页面,仅将tablespace标记删除,通过两阶段标记机制识别页面过期,页面延迟清理 */
  BUF_REMOVE_NONE,
  /** 将BP中对应的页面全部删除,不需要刷脏,早期对于临时表truncate就是这个方式,space id存在复用场景,需要强制清理space的page */
  BUF_REMOVE_ALL_NO_WRITE,
  /** 从flush list删除页面,不需要刷盘,早期drop table执行这个操作,对于LRU list上面删除的操作放到了后台做*/
  BUF_REMOVE_FLUSH_NO_WRITE,
  /** 从flush list上删除, 并且刷脏,比如import tablespace */
  BUF_REMOVE_FLUSH_WRITE
};由于Buffer Pool中的页面延迟清理,因此对应的space也需要延迟删除,当执行space delete时,内存中的fil_space_t仅标记删除,当Buffer Pool中该space的页面清理完成,master线程最终删除内存中的space对象。
void Fil_shard::purge() {
  for (auto it = m_deleted_spaces.begin(); it != m_deleted_spaces.end();) {
    auto space = it->second;
    // has_no_references为true,表明bp中该space的页面已经清理完成
    if (space->has_no_references()) {
      space_free_low(space);
      it = m_deleted_spaces.erase(it);
    }}
}dberr_t Fil_shard::space_delete(space_id_t space_id, buf_remove_t buf_remove,
                                const char *new_filepath) {
  fil_space_t *space = nullptr;
  // 等待pending IO完成
  dberr_t err = wait_for_pending_operations(space_id, space, &path);
  // 根据传入的清理策略清理bp中的页面
  if (buf_remove != BUF_REMOVE_NONE) {
    buf_LRU_flush_or_remove_pages(space_id, buf_remove, nullptr);
  }
  if (const fil_space_t *s = get_space_by_id(space_id)) {
    // 通过bump_version来增加space的版本号标记space删除,同时标记m_deleted为true
    space->set_deleted();
    auto &file = space->files.front();
    /* 再次检查,等待pending IO完成. */
    while (file.n_pending_ios > 0 || file.n_pending_flushes > 0 ||
           file.is_being_extended) {
      std::this_thread::yield();
    }
    // 将space添加到m_deleted_spaces,master线程会周期性从m_deleted_spaces清理过期space
    m_deleted_spaces.push_back({space->id, space});
    // 关闭文件句柄
    space_detach(space);
    // 将space从内存中的m_spaces中删除
    space_remove_from_lookup_maps(space_id);
  }
  return err;
}3.1.3 过期页面的清理时机
通过上文可以知道,只有当space在Buffer Pool中的页面完全清理完成,该space的内存对象才可以最终删除。那么过期页面的清理时机有哪些?
总共有如下场景:
1.BP读页面场景:从Buffer Pool中读取page的时候,如果发现读取到的page是过期的,那么通过执行buf_page_free_stale将该page从Buffer Pool中淘汰。
bool buf_page_free_stale(buf_pool_t *buf_pool, buf_page_t *bpage) {
  auto *block_mutex = buf_page_get_mutex(bpage);
  mutex_enter(block_mutex);
  const auto io_type = buf_page_get_io_fix(bpage);
  bool success = false;
  if (io_type == BUF_IO_NONE) {
    if (bpage->is_dirty()) {
      buf_flush_remove(bpage);
    }
    // 淘汰页面
    success = buf_LRU_free_page(bpage, true);
  }
  return success;
}3.2 Purge large file特性
purge large file特性复用DELETE_SPACE_LOG日志类型,在回放drop table的日志时,通过设置m_new_file_path,为后台清理线程添加异步清理任务。
通过file_purge_event信号唤醒后台线程srv_file_purge_thread,执行异步清理任务。
void srv_file_purge_thread(void) {
loop:
  // 配置srv_data_file_purge_max_size控制每次清理文件大小
  max_size = srv_data_file_purge_max_size * 1024 * 1024;
  if (max_size <= truncated_size) {
    std::this_thread::sleep_for(
          std::chrono::milliseconds(srv_data_file_purge_interval));
      truncated_size = 0;
  }
  // 根据srv_data_file_purge_max_size配置,通过os_file_truncate分批清理
  // 当最后一批清理完成时,调用os_file_delete删除文件
  std::pair<int, uint64_t> result =
      file_purge_sys->purge_file(max_size - truncated_size,
                                 srv_data_file_purge_immediate);
  truncated = result.first;
  truncated_size += result.second;
  // 清理完成,等待信号唤醒
  if (truncated <= 0) {
    sig_count = os_event_reset(file_purge_event);
    os_event_wait_time_low(file_purge_event, std::chrono::seconds{5},
                           sig_count);
    truncated_size = 0;
  } else if (truncated > 0) {
    if (truncated_size >= max_size) {
      // 根据配置的srv_data_file_purge_interval控制异步清理间隔
      std::this_thread::sleep_for(
          std::chrono::milliseconds(srv_data_file_purge_interval));
      truncated_size = 0;
    }
  }
exit_func:
  if (srv_fast_shutdown < 2 && srv_data_file_purge_all_at_shutdown) {
    // 关机时清理所有的临时数据文件
    file_purge_sys->purge_all(srv_data_file_purge_max_size * 1024 * 1024, true);
  }
}4、优化效果对比
设置实例的Buffer Pool为32G,常稳执行sysbench oltp_read_write业务,混合每秒执行5000个临时表短连接业务测试,可以发现优化后,sysbench测试业务性能基本稳定,而优化前的版本业务卡顿,TPS接近为0。
 5、总结
5、总结
本文从原理以及源码角度解读了华为云RDS for MySQL在大表删除场景下的优化方案,呈现了优化后的效果,其方案价值不仅在于性能提升,更体现了数据库内核设计从"急迫清除"到"优雅失效"的转变,这种基于标记的惰性处理机制也可以应用到其他业务场景中。当然,这种惰性清理机制降低了内存的有效使用,未来华为云也将提供快速清理失效页面能力,解决该方案下内存管理的潜在问题。
- 点赞
- 收藏
- 关注作者
 
             
           
评论(0)