背景

Percona在今年8月引入了一个大patch “rb tree block allocation strategy”,使用变种的红黑树作为跟踪未使用block hole的数据结构。

Percona上对此介绍的连接(点击这里跳转到原文

这个改进是基于percona内部性能测试和外部用户反馈,发现当TokuDB引擎在长时间写入压力比较大的场景下,随着时间增长写入性能会急剧下降;当采用small node size配置时(这是对高速存储设备场景下性能调优的建议),这个问题会更加严重。现象是:TPS降低,客户端的response time增加。

写入性能下降的主要原因是:原有的block allocator使用线性数组来记录已使用的blocks和最大未分配的block。每次分配的时候,顺序遍历已使用的blocks数组,按照strategy(first fit,best fit等四种strategy)找到一个未使用的block hole用来分配。当频繁写入,文件碎片比较多时,顺序遍历的代价就会非常大。

文件内部碎片化问题

为什么TokuDB索引文件碎片化比较严重? TokuDB缺省支持数据压缩,每个数据节点的partition分别压缩后顺序存储,导致数据节点的大小是变化的。其实在压缩之前,数据节点大小也不是一个固定值。中间节点除了包含pivot key,还包含msg buffer;空的中间节点可能就占几个字节,一个满的数据节点(既可以是中间节点也可以是叶子节点)可能占4M(缺省配置)。这跟传统使用btree(及其变种树)作为索引存储的数据库很不一样。数据节点大小是变化的,频繁创建/删除节点,会使得索引文件内部碎片化严重。另一方面,TokuDB的checkpoint机制也加剧了碎片化问题。

TokuDB的checkpoint过程对碎片化影响

TokuDB是sharp checkpoint,每次checkpoint(缺省是60秒执行一次)会把内存buffer pool中所有的脏页写到磁盘上。写采用非覆盖写,每次写到一个新的位置上。BTT(Block Translation Table)是一个数组,记录了blocknum到文件offset的映射。BTT的索引是blocknum,数组按照blocknum递增的顺序排列,每个数组元素记录了blocknum对应的二元组。

这里的blocknum是数据节点的逻辑页号,在数据节点的生命周期里始终不变,创建节点时生成,删除节点时被释放。 前面两节中谈到的block hole和block分配,其实分别是指索引文件中未使用的地址空间和分配索引文件中一段空闲的地址空间

从磁盘读取节点时,需要查BTT确定blocknum在索引文件上的位置,然后seek到那个位置读数据;数据写回时,查找BTT找到满足节点大小的一个未使用的block hole,把节点数据写到那里。为了加速block hole的查找过程,TokuDB使用了线性数组_blocks_array来记录所有已经分配的blocks,数组按照offset递增顺序排列,每个数组元素仍然是的二元组。未使用的block hole是通过相邻的两个block之间的hole计算出来的:

细心的朋友会发现,_blocks_array没有跟blocknum有关的信息,因为这个数组仅用于查找block hole。

这个patch改进的部分是把记录未分配block hole的数据结构,由原来的线性数组改造成红黑树,最差和平均的查找时间复杂度从o(n)变成o(logn)。

FT磁盘文件layout

Percona的文章里面谈到了FT文件layout,这里也简单介绍一下。其实这个patch,并没有修改BTT和 FT layout部分,数据文件与原来兼容。

TokuDB索引文件对应磁盘文件系统上一个独立的文件。 每个索引文件由3部分组成:header,BTT和blocks。Header 和 BTT都包含了多个版本。

screenshot.png

Header有2个版本:最近checkpoint的版本和更早checkpoint的版本。 BTT也有2个版本:最近checkpoint的版本和更早checkpoint的版本。

这2个版本的header位于索引文件固定位置上:offset 0和offset 4096。这两个header是以round robin的方式存储的,假若head 0是最近checkpoint的版本,head1就是更早checkpoint的版本;反之,head 0是更早checkpoint的版本,head1就是最近checkpoint的版本。

每个header里面保存着相应BTT的起始位置。如果想找到最近checkpoint的版本中blocknum 5在索引文件的位置,只要访问最近checkpoint的header找到对应BTT的起始位置,然后读取BTT找到blocknum 5对应的二元组,再seek到那个offset进行读取就可以了。

内存中FT的header也有2个版本:

  • FT_CURRENT是当前的header
  • FT_CHECKPOINT_INPROGRESS是checkpoint开始时刻FT_CURRENT的克隆

索引文件里面保存的2个header都是FT_CHECKPOINT_INPROGRESS,在checkpoint结束时被释放。

内存中的BTT有3个版本:

  • TRANSLATION_CURRENT:当前的BTT
  • TRANSLATION_INPROGRESS:checkpoint开始时刻TRANSLATION_CURRENT的克隆
  • TRANSLATION_CHECKPOINTED:最近完成的checkpoint的BTT

Checkpoint结束时,释放TRANSLATION_CHECKPOINTED记录的映射关系,并把TRANSLATION_INPROGRESS拷贝给TRANSLATION_CHECKPOINTED。TRANSLATION_CHECKPOINTED记录了上一次完整checkpoint的数据,这些数据在新的checkpoint结束之前都是有效的,那些空间不允许被使用。

索引文件里面保存的2个BTT都是TRANSLATION_INPROGRESS,在checkpoint结束时被清空。

引入红黑树优化search time

Percona引入MHS (Max Hole Size)红黑树来解决这个问题。这是一个变种的红黑树,除了记录未使用的block hole,还存储了左右子树的max block size信息,在分配block hole的时候可以根据此信息进行减枝,避免不必要的子树遍历。

下面这个例子是percona给出的示例。

已分配blocks如下:

screenshot.png

block holes如下:

screenshot.png

红黑树如下: screenshot.png

这个例子中,红黑树的数据节点存储的是已分配的block信息和左右子树的mhs信息。 但是,patch的代码实现的是:数据节点存储的是未使用的block信息和左右子树的mhs信息。 例子跟代码实现稍有差别,但是不影响示例的作用。

如果想分配4个字节的hole,只需要遍历root的右子树,不需要访问节点<0,1>和节点<3,2>,这极大的优化了block hole的搜索过程,从原来的o(n)变成o(logn),而且可以根据mhs信息进行适当减枝。

这个算法的缺点是:总是尝试从第一个满足条件的hole中分配,而不去看整个地址空间的使用情况。频繁small size分配会把地址空间的前部切分的非常细碎,小碎片情况可能会更加严重。

rbtree allocator实现

到这里,我们一起看一下rbtree allocator的实现。红黑树算法的部分比较复杂,也比较长,不打算在这里详细介绍了。如果有朋友感兴趣,我们可以另开一篇介绍。

从BTT的BlockPair数组构建rbtree

前面提到过这个patch没有修改BTT和FT layout,那么,自然可以用这个patch打开已有的FT索引文件。

当打开一个已有的FT索引文件时,首先去读FT header的信息。上一小结有提到过FT header有两个版本,TokuDB需要分别去读两个版本的header,看看是否都是有效的,哪个是更新的版本。

通过最新且有效的FT header,能够找到其对应的BTT的起始地址,读取每个blocknum到文件offset的映射信息生成BlockPair数组来构建allocator,这里是rbtree。

下面的函数就是通过BlockPair构建allocator的过程,首先是把BlockPair数组按照offset排序。之后,把排好序的BlockPair数组加到rbtree里面。

函数的第一个参数是reserve_at_beginning表示reserved的地址空间上限,通过allocator分配的block hole一定要大于reserve_at_beginning。所以,从reserve_at_beginning到排好序的BlockPair数组第一个元素之间也可能存在一个有效的hole,那也需要把它加到rbtree里面。

  1. void BlockAllocator::CreateFromBlockPairs(uint64_t reserve_at_beginning,
  2. uint64_t alignment,
  3. struct BlockPair *translation_pairs,
  4. uint64_t n_blocks) {
  5. CreateInternal(reserve_at_beginning, alignment);
  6. _n_blocks = n_blocks;
  7. struct BlockPair *XMALLOC_N(n_blocks, pairs);
  8. memcpy(pairs, translation_pairs, n_blocks * sizeof(struct BlockPair));
  9. std::sort(pairs, pairs + n_blocks);
  10. if (pairs[0]._offset > reserve_at_beginning) {
  11. _tree->Insert(
  12. {reserve_at_beginning, pairs[0]._offset - reserve_at_beginning});
  13. }
  14. for (uint64_t i = 0; i < _n_blocks; i++) {
  15. // Allocator does not support size 0 blocks. See
  16. // block_allocator_free_block.
  17. invariant(pairs[i]._size > 0);
  18. invariant(pairs[i]._offset >= _reserve_at_beginning);
  19. invariant(pairs[i]._offset % _alignment == 0);
  20. _n_bytes_in_use += pairs[i]._size;
  21. MhsRbTree::OUUInt64 free_size(MAX_BYTE);
  22. MhsRbTree::OUUInt64 free_offset(pairs[i]._offset + pairs[i]._size);
  23. if (i < n_blocks - 1) {
  24. MhsRbTree::OUUInt64 next_offset(pairs[i + 1]._offset);
  25. invariant(next_offset >= free_offset);
  26. free_size = next_offset - free_offset;
  27. if (free_size == 0)
  28. continue;
  29. }
  30. _tree->Insert({free_offset, free_size});
  31. }
  32. toku_free(pairs);
  33. VALIDATE();
  34. }

从rbtree分配block hole

从rbtree里面分配一个block hole,就是找到满足requested size的节点,从那里分配空间。之后,如果那个节点的size变成0,需要释放节点。其实这个过程就是从rbtree删除requested size大小的hole。

  1. void BlockAllocator::AllocBlock(uint64_t size,
  2. uint64_t *offset) {
  3. // Allocator does not support size 0 blocks. See block_allocator_free_block.
  4. invariant(size > 0);
  5. _n_bytes_in_use += size;
  6. *offset = _tree->Remove(size);
  7. _n_blocks++;
  8. VALIDATE();
  9. }

Rbtree上删除size大小的hole是在函数Tree::Remove实现的。首先找到满足请求size的第一个hole,然后在hole上分配size,如果分配之后hole的size等于0,就要删除这个hole。

  1. uint64_t Tree::Remove(size_t size) {
  2. Node *node = SearchFirstFitBySize(size);
  3. return Remove(_root, node, size);
  4. }

如果root对应的hole不能满足请求size,并且左右子树mhs都比size小,表示root及其子树上都没有合适的空间,此时返回NULL表示在root上分配失败。

  1. Node *Tree::SearchFirstFitBySize(uint64_t size) {
  2. if (EffectiveSize(_root) < size && rbn_left_mhs(_root) < size &&
  3. rbn_right_mhs(_root) < size) {
  4. return nullptr;
  5. } else {
  6. return SearchFirstFitBySizeHelper(_root, size);
  7. }
  8. }

如果在root上有合适的空间分配,首先看一下root本身能否满足要求。

  1. 有种情况需要特殊处理,如果root本身可以满足分配,并且其左子树也可以满足分配。这种情况下优先在左子树上分配。

  2. 如果1失败,但是root本身能满足分配,此时在root上分配。

  3. 如果2失败,看看其左右子树是否满足分配;如果左右子树都能满足分配,优先在左子树上分配。

  1. Node *Tree::SearchFirstFitBySizeHelper(Node *x, uint64_t size) {
  2. if (EffectiveSize(x) >= size) {
  3. // only possible to go left
  4. if (rbn_left_mhs(x) >= size)
  5. return SearchFirstFitBySizeHelper(x->_left, size);
  6. else
  7. return x;
  8. }
  9. if (rbn_left_mhs(x) >= size)
  10. return SearchFirstFitBySizeHelper(x->_left, size);
  11. if (rbn_right_mhs(x) >= size)
  12. return SearchFirstFitBySizeHelper(x->_right, size);
  13. // this is an invalid state
  14. Dump();
  15. ValidateBalance();
  16. ValidateMhs();
  17. invariant(0);
  18. return NULL;
  19. }

回到函数Tree::Remove,找到满足请求size的第一个节点后,需要从那个节点分配size大小的hole,也就是从那个节点删除size大小的空间。

从rbtree分配的block hole,是用来存放FT索引的数据节点的,为了支持direct I/O,分配的block hole的offset和size都必须是512的整数倍。

下面函数中,节点的n_offset,n_size表示的是rbtree节点的block hole的起始位置和大小,不一定是512对齐。answer_offset表示的才是512对齐的起始地址。

  1. 如果answer_offset不等于offset,分配之后一定有剩余,不需要删除节点,也不需要调整红黑树的形态。

分配之后: I. 可能只剩余,相当于hole缩小; II. 可能剩余2个hole,512对齐前的一段和分配size之后的一段,相当于hole中间截断

在第二种情况,后面的hole是新产生的,需要加到红黑树里。

  1. 如果answer_offset等于offset,并且size等于n_size,分配之后需要把这个hole从红黑树里删除。
  1. uint64_t Tree::Remove(Node *&root, Node *node, size_t size) {
  2. OUUInt64 n_offset = rbn_offset(node);
  3. OUUInt64 n_size = rbn_size(node);
  4. OUUInt64 answer_offset(align(rbn_offset(node).ToInt(), _align));
  5. invariant((answer_offset + size) <= (n_offset + n_size));
  6. if (answer_offset == n_offset) {
  7. rbn_offset(node) += size;
  8. rbn_size(node) -= size;
  9. RecalculateMhs(node);
  10. if (rbn_size(node) == 0) {
  11. RawRemove(root, node);
  12. }
  13. } else {
  14. if (answer_offset + size == n_offset + n_size) {
  15. rbn_size(node) -= size;
  16. RecalculateMhs(node);
  17. } else {
  18. // well, cut in the middle...
  19. rbn_size(node) = answer_offset - n_offset;
  20. RecalculateMhs(node);
  21. Insert(_root,
  22. {(answer_offset + size),
  23. (n_offset + n_size) - (answer_offset + size)});
  24. }
  25. }
  26. return answer_offset.ToInt();
  27. }

红黑树里,不会直接删除一个中间节点。 如果要删除的节点,左右子树都存在,需要找到它的直接后继,就是它的右子树上最左的节点。用那个节点代替它的位置,然后删除它。 如果它的左右子树只有一个是存在的,另一个为空,就用那个非空的子节点代替它。 删除过程会改变节点的位置,需要对受影响的节点重新计算mhs。 如果替代的节点是黑色的,需要调整树形态,满足每条路径上黑色节点的数目是相同的。

  1. void Tree::RawRemove(Node *&root, Node *node) {
  2. Node *child, *parent;
  3. EColor color;
  4. if ((node->_left != NULL) && (node->_right != NULL)) {
  5. Node *replace = node;
  6. replace = replace->_right;
  7. while (replace->_left != NULL)
  8. replace = replace->_left;
  9. if (rbn_parent(node)) {
  10. if (rbn_parent(node)->_left == node)
  11. rbn_parent(node)->_left = replace;
  12. else
  13. rbn_parent(node)->_right = replace;
  14. } else {
  15. root = replace;
  16. }
  17. child = replace->_right;
  18. parent = rbn_parent(replace);
  19. color = rbn_color(replace);
  20. if (parent == node) {
  21. parent = replace;
  22. } else {
  23. if (child)
  24. rbn_parent(child) = parent;
  25. parent->_left = child;
  26. rbn_left_mhs(parent) = rbn_right_mhs(replace);
  27. RecalculateMhs(parent);
  28. replace->_right = node->_right;
  29. rbn_set_parent(node->_right, replace);
  30. rbn_right_mhs(replace) = rbn_right_mhs(node);
  31. }
  32. replace->_parent = node->_parent;
  33. replace->_color = node->_color;
  34. replace->_left = node->_left;
  35. rbn_left_mhs(replace) = rbn_left_mhs(node);
  36. node->_left->_parent = replace;
  37. RecalculateMhs(replace);
  38. if (color == EColor::BLACK)
  39. RawRemoveFixup(root, child, parent);
  40. delete node;
  41. return;
  42. }
  43. if (node->_left != NULL)
  44. child = node->_left;
  45. else
  46. child = node->_right;
  47. parent = node->_parent;
  48. color = node->_color;
  49. if (child)
  50. child->_parent = parent;
  51. if (parent) {
  52. if (parent->_left == node) {
  53. parent->_left = child;
  54. rbn_left_mhs(parent) = child ? mhs_of_subtree(child) : 0;
  55. } else {
  56. parent->_right = child;
  57. rbn_right_mhs(parent) = child ? mhs_of_subtree(child) : 0;
  58. }
  59. RecalculateMhs(parent);
  60. } else
  61. root = child;
  62. if (color == EColor::BLACK)
  63. RawRemoveFixup(root, child, parent);
  64. delete node;
  65. }

从rbtree释放block hole

当删除一个FT数据节点或者checkpoint结束时回收上次checkpoint占用的地址空间时,allocator需要记下这个block hole,把它加到红黑树里面。

  1. void BlockAllocator::FreeBlock(uint64_t offset, uint64_t size) {
  2. VALIDATE();
  3. _n_bytes_in_use -= size;
  4. _tree->Insert({offset, size});
  5. _n_blocks--;
  6. VALIDATE();
  7. }

Rbtree insert的时候,跟binary tree一样,通过binary search找到合适的位置。找到以后,需要考虑是否能和相邻的hole合并。

新的hole可能只和它左边或者右边的hole合并;也可能同时跟左边和右边合并形成一个更大的hole。

这里,左边的hole就是它的直接前驱,右边的hole是它的直接后继。

如果可以合并,只需要修改已有rbtree节点的信息,不会引入新的节点。

如果不能合并,需要新加一个节点,然后加入到rbtree里。新加入的节点一定是红色的,由于引入新的红色节点,可能会导致连续的红色节点出现,需要调整红黑树的形态。

无论是合并了,还是引入新节点都需要对受影响节点重新计算mhs。

  1. int Tree::Insert(Node *&root, Node::BlockPair pair) {
  2. Node *x = _root;
  3. Node *y = NULL;
  4. bool left_merge = false;
  5. bool right_merge = false;
  6. Node *node = NULL;
  7. while (x != NULL) {
  8. y = x;
  9. if (pair._offset < rbn_key(x))
  10. x = x->_left;
  11. else
  12. x = x->_right;
  13. }
  14. // we found where to insert, lets find out the pred and succ for
  15. // possible
  16. // merges.
  17. // node->parent = y;
  18. Node *pred, *succ;
  19. if (y != NULL) {
  20. if (pair._offset < rbn_key(y)) {
  21. // as the left child
  22. pred = PredecessorHelper(y->_parent, y);
  23. succ = y;
  24. IsNewNodeMergable(pred, succ, pair, &left_merge, &right_merge);
  25. if (left_merge || right_merge) {
  26. AbsorbNewNode(
  27. pred, succ, pair, left_merge, right_merge, false);
  28. } else {
  29. // construct the node
  30. Node::Pair mhsp = {._left = 0, ._right = 0};
  31. node =
  32. new Node(EColor::BLACK, pair, mhsp, nullptr, nullptr, nullptr);
  33. if (!node)
  34. return -1;
  35. y->_left = node;
  36. node->_parent = y;
  37. RecalculateMhs(node);
  38. }
  39. } else {
  40. // as the right child
  41. pred = y;
  42. succ = SuccessorHelper(y->_parent, y);
  43. IsNewNodeMergable(pred, succ, pair, &left_merge, &right_merge);
  44. if (left_merge || right_merge) {
  45. AbsorbNewNode(
  46. pred, succ, pair, left_merge, right_merge, true);
  47. } else {
  48. // construct the node
  49. Node::Pair mhsp = {._left = 0, ._right = 0};
  50. node =
  51. new Node(EColor::BLACK, pair, mhsp, nullptr, nullptr, nullptr);
  52. if (!node)
  53. return -1;
  54. y->_right = node;
  55. node->_parent = y;
  56. RecalculateMhs(node);
  57. }
  58. }
  59. } else {
  60. Node::Pair mhsp = {._left = 0, ._right = 0};
  61. node = new Node(EColor::BLACK, pair, mhsp, nullptr, nullptr, nullptr);
  62. if (!node)
  63. return -1;
  64. root = node;
  65. }
  66. if (!left_merge && !right_merge) {
  67. invariant_notnull(node);
  68. node->_color = EColor::RED;
  69. return InsertFixup(root, node);
  70. }
  71. return 0;
  72. }