版本

  • MySQL 8.0.25

背景

InnoDB 使用 B+ 树作为它的索引数据结构,B+ 树作为一种经典的数据结构具备高效的读写查询,本文主要分析 InnoDB 中 B+ 树对于 Record 的增删改如何实现,理解 Record 在 InnoDB 中的 B+ 树如何增删改,可以更直观的帮助我们理解 InnoDB 的索引组织方式。

B+ tree 的插入操作

在MySQL中,一条 Insert 语句就是一个 Record 的插入操作,我们以插入一条聚簇索引(非压缩)为例,略过连接建立过程和 SQL parse 阶段,经过 InnoDB 的 handler::ha_write_row() 调用:

 ------------
|    ...     |
 ------------
   |
   |    /* Cluster Index 聚簇索引的插入 */
   |    ---------------------------------
   --> | row_ins_clust_index_entry_low() |
        ---------------------------------
          |
          |
          |      /* 针对 delete-marked 的记录, 并且该记录的唯一字段和待插入的 Record 一致,则 Inplace Modify. */
          |    ---------------------------------------
          --> | row_ins_clust_index_entry_by_modify() |
          |    ---------------------------------------
          |
          |
          |           /* 乐观插入 */
          |    -----------------------------
          --> | btr_cur_optimistic_insert() |
          |    -----------------------------
          |      |
          |      |
          |      |       /* 加锁并记录 Undo Log */
          |      |    -----------------------------
          |      --> | btr_cur_ins_lock_and_undo() |
          |      |    -----------------------------
          |      |
          |      |        /* 插入b+树 */
          |      |    -------------------------
          |      --> | page_cur_tuple_insert() |
          |           -------------------------
          |
          |
          |            /* 悲观插入 */
          |    ------------------------------
          --> | btr_cur_pessimistic_insert() |
               ------------------------------

上述的调用过程说明了插入一条 Record 的过程,具体的分析如下:

  • row_ins_clust_index_entry_low() 函数的参数包括我们需要插入的 Record 和当前的 dict_index_t 索引。首先我们需要通过 pcur 游标来定位我们需要插入的位置:

    /* 调用 btr_pcur_open() 定位待插入的位置.
     * 参数 entry 是待插入的 Record, search mode 是 PAGE_CUR_LE, 即定位到一个小于等于待插入记录的 Record.
     * 举例假如当前存在 Record 是[1, 2, 3], 我们插入5, 即 cursor 会定位到3. */
    btr_pcur_open(index, entry, PAGE_CUR_LE, mode, &pcur, &mtr);
  • 针对 cursor 返回的 Record 检查主键重复的问题。

  • 调用 btr_cur_optimistic_insert() 乐观插入:

    • 通过 cursor 定位 leaf page,计算 Record 的物理长度。

    • 假如 Record 的大小超过了 Page 的剩余空间,则乐观插入失败,需要调用悲观插入。

      • 对于乐观插入成功的情况下,调用 btr_cur_ins_lock_and_undo() 记录 Undo Log.

      • 调用 page_cur_insert_rec_low() 完成 Page 的插入并记录类型为 MLOG_REC_INSERT 的 Redo Log.

B+ tree 的悲观插入

对于需要分裂的 Page 需要调用 btr_cur_pessimistic_insert(),悲观插入会对 B+tree 进行分裂操作:

  1. 如果插入点在 Page 的最后,则尝试分裂 btr_insert_into_right_sibling().
  2. 如果插入点为顺序插入,即待插入的位置位于 PAGE_LAST_INSERT 之后,则直接在当前插入点向后(FSP_UP)分裂 btr_page_get_split_rec_to_right().
  3. 如果插入点为递减插入,即待插入的位置位于 PAGE_LAST_INSERT 之前,则直接在当前插入点向前(FSP_DOWN)分裂 btr_page_get_split_rec_to_left().
  4. 如果插入位置都不位于 PAGE_LAST_INSERT 左右,则直接进行中间点分裂 page_get_middle_rec(),以一个中间 record 进行分裂。

B+ tree 的分裂

B+ tree 的加锁流程

  • 乐观插入

乐观插入使用的 mode 为 BTR_MODIFY_LEAF,加锁顺序:

  1. 先对 dict_index_t 加 S 锁。
  2. 查找过程中针对所有的 non-leaf page 加 S 锁,因为需要目标 leaf page 进行修改,所以对 leaf page 加 X 锁。
  3. 到达 leaf level 层后(height == 0),开始释放 lock:释放 dict_index_t 的 s 锁;释放 page 的 s 锁,释放顺序是从上至下。
row_ins_clust_index_entry_low(flags, BTR_MODIFY_LEAF, index, n_uniq,
                              entry, thr, dup_chk_only);
  • 悲观插入

悲观插入使用的 mode 为 BTR_MODIFY_TREE,加锁顺序:

  1. 先对 dict_index_t 加 SX 锁。
  2. 在 search 过程中不会针对 page 加任何锁(RW_NO_LATCH),但会保留整个 branch 涉及的 page.
  3. 对于路径上不会发生 SMO 的 page 全部都释放。
  4. 最后针对路径可能涉及 SMO 的所有 page 加 X 锁。
  5. SMO 过程中保留dict_index_t 的 sx 锁和可能涉及 SMO 的 page 的 x 锁。
row_ins_clust_index_entry_low(flags, BTR_MODIFY_TREE, index, n_uniq,
                              entry, thr, dup_chk_only);

BTR_MODIFY_TREE 涉及 B+ tree 的 SMO 操作,因为 dict_index_t 的 SX 锁的互斥关系,所以在一个索引 B+tree 上,同时只能有一个 SMO 操作。

Record 的删除操作

  /* Record 删除操作. */
 -------------------------
| ha_innobase::delete_row |
 -------------------------
   |
   |
   |    /* Record 的更新删除操作都经过 row_update_for_mysql() 入口. */
   |    ------------------------
   --> | row_update_for_mysql() |
        ------------------------
          |
          |
          |    ----------------------------------------
          --> | row_update_for_mysql_using_upd_graph() |
               ----------------------------------------
                 |
                 |
                 |
                 |    -----------
                 --> | row_upd() |
                 |   ------------
                       |
                       |    ----------------
                       --> | row_upd_step() |
                            ----------------
                             |
                             |
                             |       /* 加锁并记录 Undo Log */
                             |    ------------------------------
                             --> | row_upd_del_mark_clust_rec() |
                                  ------------------------------
                                    |
                                    |
                                    |    /* 保存 Record 至 node-> row 用来删除二级索引. */
                                    |    ---------------------
                                    --> | row_upd_store_row() |
                                    |    ---------------------
                                    |
                                    |
                                    |     /* 上锁, 记录 Undo Log, 设置 Record 的标志位为 REC_INFO_DELETED_FLAG. */
                                    |    ----------------------------------
                                    --> | btr_cur_del_mark_set_clust_rec() |
                                         ----------------------------------

通过源码分析我们可以发现 Record 的删除操作对于聚簇索引并不是真的物理删除,仅仅是标记为 REC_INFO_DELETED_FLAG。而对于其他的二级索引,依然采用设置标记的方法 (btr_cur_del_mark_set_sec_rec()).

B+ tree 的悲观删除

InnoDB 中 B+ tree record 的删除操作由 purge 线程来处理,用户的删除标记为 delete mark 以后,由 purge 线程来后台清理 record 所占用的空间,purge 的流程为先使用乐观删除,即待清理 record 的空间回收以后不会发生 SMO 操作,但是以下条件会判断是否需要进行悲观删除:

ibool btr_cur_can_delete_without_compress(
    btr_cur_t *cursor, /*!< in: btr cursor */
    ulint rec_size,    /*!< in: rec_get_size(btr_cur_get_rec(cursor))*/
    mtr_t *mtr)        /*!< in: mtr */
{
  page_t *page;

  ut_ad(mtr_memo_contains(mtr, btr_cur_get_block(cursor), MTR_MEMO_PAGE_X_FIX));

  page = btr_cur_get_page(cursor);

  /* 是否需要 SMO(merge) 操作
   * 1. 删除这条 record 后该 Page 的剩余空间小于 50%(DICT_INDEX_MERGE_THRESHOLD_DEFAULT).
   * 2. 或者 Page 的前后节点均为 FIL_NULL. (代表这一层只有一个 Page, 没有保留的必要了)
   * 3. 或者 Page 的 user records 数量小于 2. */
  if ((page_get_data_size(page) - rec_size <
       BTR_CUR_PAGE_COMPRESS_LIMIT(cursor->index)) ||
      ((btr_page_get_next(page, mtr) == FIL_NULL) &&
       (btr_page_get_prev(page, mtr) == FIL_NULL)) ||
      (page_get_n_recs(page) < 2)) {

	/* 判断是否为 root page. */
    return (dict_index_get_page(cursor->index) == page_get_page_no(page));
  }

  return (TRUE);
}

悲观删除 btr_cur_pessimistic_delete():

  1. 对于只剩下一个 Record 且不是 Root Page 的情况,需要直接删除 Page(btr_discard_page()).

  2. 在悲观删除中如果删除的 Record 是 Page 上的第一个,需要更新父节点的 node ptr.

    • 在父节点层更新 node ptr,也通过悲观删除(btr_cur_pessimistic_delete()) 递归删除。

    • 使用 next_rec 重新构建一个 node ptr 插入父节点。

  3. 其余的情况直接调用 btr_cur_compress_if_useful() 尝试合并 Page.

    • 针对不存在左右节点的 Page,即代表该层只有这一个 Page,将 record 合并到父节点。

    • 存在左右 Page 的情况下,首先尝试与左节点 merge,如果无法与左 Page 合并,则尝试与右 Page 合并。

    • 合并的流程均为拷贝需要释放的 Page 上的 record 至 merge Page 上,更新左右 Page 指针,删除 Page 所指向的 node pointer,更新删除的 Page 上原有的 lock (table lock, record lock) 至 merge Page 上。

    • 调用 btr_page_free() 将 Page 放回 segment 的 free list.

    • 被删除的 Page 的 node pointer 在删除时可能会导致父节点也发生 merge 操作,所以可能出现递归 merge 的情况,在获取 node pointer 时使用的 BTR MODE 是 BTR_CONT_MODIFY_TREE.

  4. merge 的过程中可能造成 non-leaf level 的 node ptr 被删除,如果向左 merge,则保留左边 page 的 node ptr;如果向右 merge,则保留当前 page 的 node ptr,删除右边 page 的 node ptr.

  5. 所以 InnoDB B+ tree 中的 node ptr 都是小于等于下层 page 的最小的 user record (小于的情况是因为可能存在下层的最小的 user record 被删除的情况,在标记为 DELETE MARK 的场景里并不会直接更新 node ptr).

Record 的修改操作

对于 Record 的修改操作,使用了和删除操作一样的接口 row_upd_clust_step() 。对于修改存在多种不同的处理方法:

  1. 对于只修改聚簇索引,而无需修改二级索引的 Update 操作,调用 row_upd_clust_rec(),对于仅修改聚簇也存在两种情况:是否存在 Record 长度的变化。
  • 对于 Update 后长度不变的 Record,调用 btr_cur_update_in_place() 原地修改。

  • 对于 Update 后引起 Record 长度变化的操作,依然会根据当前 Page 的剩余空间调用乐观更新(btr_cur_optimistic_update())和悲观更新(btr_cur_pessimistic_update())。引起 Record 长度变化的 Update 操作都是 append 写入方式,对于旧的 Record 需要更新其标志位,插入 Page 的 PAGE_FREE 链表。

  1. 对于会影响排序的字段,调用 row_upd_clust_rec_by_insert() 更新。

  2. 对于需要同时修改聚簇索引和二级索引的 Update 操作,依然调用 row_upd_clust_rec() 完成。与 一样会在使用 row_upd_store_row() 记录旧的 Record 至 row->node,以供二级索引更新使用。

  3. 对于二级索引的修改操作,全部采用标记删除后重新插入的方式。

总结

我们通过源码分析了 InnoDB 中关于索引部分的增删改步骤,需要注意的是 B+ tree 中的增删改流程全部处于同一个 trx 的保护中,因此对于聚簇索引和二级索引的修改都保证了原子性,这里也涉及 InnoDB 的 Undo Log 模块和事务锁系统模块。