注意

本文档适用于 Ceph 的开发版本。

完整的OSD映射版本修剪

对于每个增量osdmap epoch,监视器都会在存储中保留一个完整的osdmap epoch。

虽然这对处理来自客户端的osdmap请求非常有用,允许我们无需从无数增量中重新计算完整的osdmap即可满足他们的请求,但一旦我们开始保留无限数量的osdmap,它也可能成为一种负担。

监视器将尝试在存储中保留有限数量的osdmap。这个数量由 mon_min_osdmap_epochs 定义(且可配置),默认为500个epoch。一般来说,一旦超过此限制,我们将删除较旧的osdmap epoch。

然而,删除osdmap有一些限制。这些限制都在 OSDMonitor::get_trim_to() 中定义。

如果这些条件之一未满足,我们可能会超出 mon_min_osdmap_epochs 定义的范围。如果集群在一段时间内(例如,unclean pgs)未满足修剪标准,监视器可能会开始保留大量osdmap。这可能会对底层键/值存储以及可用磁盘空间造成压力。

缓解此问题的一种方法是停止在磁盘上保留完整的osdmap epoch。我们将不得不按需重建osdmap,或者如果它们最近被服务过,则从缓存中获取它们。我们仍然必须至少保留一个osdmap,并将所有增量应用于存储中保留的最旧的映射epoch或从缓存中获取的更近期的映射之上。虽然这可行,但重建osdmap似乎会消耗大量的CPU(以及潜在的IO)。

此外,这将防止上述问题在未来发生,但对于目前处于不保留osdmap状态的存储来说,这将无济于事。

这就引出了完整的osdmap修剪。

与其不保留完整的osdmap epoch,不如在我们有太多时修剪掉其中一些。

判断我们是否有太多osdmap将由一个可配置选项 mon_osdmap_full_prune_min(默认值:10000)决定。一旦超过此阈值,修剪算法将启动。

我们不会删除所有 mon_osdmap_full_prune_min 个完整的osdmap epoch。相反,我们将在完整的映射序列中打一些洞。默认情况下,自从上次保留的映射以来,每10个映射我们会保留一个完整的osdmap;即,如果我们保留epoch 1,我们也会保留epoch 10,并删除完整的映射epoch 2到9。此间隔的大小可通过 mon_osdmap_full_prune_interval 进行配置。

本质上,我们建议保留大约10%的完整映射,但我们将始终遵守由 mon_min_osdmap_epochs 定义的最小osdmap epoch数量,这些不会用于计算要修剪的最小版本数量。例如,如果我们有磁盘上的版本 [1..50000],我们将允许修剪算法仅对osdmap epoch [1..49500) 进行操作;但是,如果我们有磁盘上的版本 [1..10200],我们将不会修剪,因为算法只会对版本 [1..9700) 进行操作,并且此间隔包含的版本少于 mon_osdmap_full_prune_min 所要求的最小值。

算法

假设我们在存储中有50,000个osdmap epoch,并且我们对所有可配置选项都使用默认值。

-----------------------------------------------------------
|1|2|..|10|11|..|100|..|1000|..|10000|10001|..|49999|50000|
-----------------------------------------------------------
 ^ first                                            last ^

当满足以下所有约束条件时,我们将进行修剪

  1. 版本数量大于 mon_min_osdmap_epochs

  2. firstprune_to 之间的版本数量大于(或等于)mon_osdmap_full_prune_min,其中 prune_to 等于 last 减去 mon_min_osdmap_epochs

如果这些条件中的任何一个失败,我们将修剪任何映射。

此外,如果已知我们一直在修剪,但此后我们不再满足上述约束中的至少一个,我们将不会继续修剪。本质上,只有当存储中的epoch数量需要时,我们才会修剪完整的osdmap。

由于修剪会在完整映射序列中创建间隙,我们需要跟踪缺失映射的间隔。我们通过保留一个固定映射的清单来实现这一点——即,一个通过固定而不能被修剪的映射列表。

虽然固定映射不会从存储中移除,但两个连续固定映射之间的映射会被移除;要移除的映射数量将由可配置选项 mon_osdmap_full_prune_interval 决定。算法会努力使固定映射之间的间隔与此选项定义的映射数量相同,但在极端情况下,它可能允许较小的间隔。此外,由于这是一个在每次修剪迭代发生时都会读取的可配置选项,如果用户更改了此配置选项,此间隔有可能会改变。

固定映射是懒惰执行的:我们将在移除映射时固定映射。这使我们在修剪发生时更改修剪间隔方面具有更大的灵活性,同时也大大简化了算法,以及我们需要在清单中保留的信息。下面我们展示一个简化版的算法:

manifest.pin(first)
last_to_prune = last - mon_min_osdmap_epochs

while manifest.get_last_pinned() + prune_interval < last_to_prune AND
      last_to_prune - first > mon_min_osdmap_epochs AND
      last_to_prune - first > mon_osdmap_full_prune_min AND
      num_pruned < mon_osdmap_full_prune_txsize:

  last_pinned = manifest.get_last_pinned()
  new_pinned = last_pinned + prune_interval
  manifest.pin(new_pinned)
  for e in (last_pinned .. new_pinned):
    store.erase(e)
    ++num_pruned

本质上,算法确保存储中的第一个版本始终是固定的。毕竟,我们在重建映射时需要一个起点,我们不能简单地移除我们拥有的最早映射;否则我们将无法重建第一个修剪间隔的映射。

一旦我们至少有一个固定映射,算法的每次迭代都可以简单地基于清单中最后一个固定映射(我们可以通过读取清单中固定映射列表尾部的元素来获取)。

接下来我们需要确定要移除的映射间隔:从 last_pinnednew_pinned 的所有映射,而 new_pinned 不外乎是 last_pinned 加上 mon_osdmap_full_prune_interval。我们知道这两个值 last_pinnednew_pinned 之间的所有映射都可以移除,前提是 new_pinned 已经被固定。

一旦两个初始先决条件中的一个未满足,或者我们不满足对算法正确性没有影响的两个附加条件,算法就会停止执行

  1. 如果我们无法创建一个与 mon_osdmap_full_prune_interval 正确对齐且小于 last_pruned 的新修剪间隔,我们将停止。除了允许我们保持间隔具有预期的尺寸,并防止最终会发生的小的、不规则的间隔(例如,修剪在几次迭代中继续,每次删除一两个或三个映射)之外,没有特定的技术原因来强制执行此要求。

  2. 一旦我们知道我们已经修剪了超过一定数量的映射,我们将停止。该值由 mon_osdmap_full_prune_txsize 定义,并确保我们不会花费无限数量的周期来修剪映射。我们不会严格遵守此值(删除成本不高),但我们会努力遵守它。

我们可以一次性完成移除,但我们不知道那需要多长时间。因此,我们将执行多次迭代,每次迭代最多移除 mon_osdmap_full_prune_txsize 个osdmap。

最后,我们的磁盘映射序列将类似于

------------------------------------------
|1|10|20|30|..|49500|49501|..|49999|50000|
------------------------------------------
 ^ first                           last ^

因为我们不是一次性修剪所有版本,所以我们需要保持关于修剪进度的状态。考虑到这一点,我们创建了一个数据结构 osdmap_manifest_t,它保存了固定映射集:

struct osdmap_manifest_t:
    set<version_t> pinned;

鉴于我们只在修剪时固定映射,我们不需要跟踪有关上次修剪版本的额外状态。我们知道,任何两个连续固定映射之间的所有中间映射都已被修剪。

然而,人们可能会问的问题是,如果监视器崩溃了,我们如何确保我们修剪了所有中间映射。为了确保我们能防止此类事件发生,我们始终在删除映射的同一事务中将osdmap清单写入磁盘。这样我们就保证了,如果监视器崩溃,我们将读取最新版本的清单:要么包含新固定的映射,这意味着我们也修剪了中间的映射;要么我们将找到先前版本的osdmap清单,它不包含我们在崩溃时固定的映射,因为我们将写入更新的osdmap清单的事务(以及映射移除)未被应用。

每次修剪时,osdmap清单都会写入存储,并更新固定映射列表。它是在实际修剪映射的事务中写入的,因此我们保证清单始终是最新的。作为此标准的结果,我们第一次写入osdmap清单是在我们第一次修剪时。如果osdmap清单不存在,我们可以确定我们没有保留修剪过的映射间隔。

我们将依赖清单来确定我们是否修剪了映射间隔。理论上,这将始终是磁盘上的osdmap清单,但我们确保每次从paxos更新时都读取磁盘上的osdmap清单;这样我们始终确保拥有最新的内存osdmap清单。

一旦我们完成修剪映射,我们将把清单保留在存储中,以便我们能够轻松找到哪些映射已被固定(而不是检查存储直到找到映射)。这还有一个额外的好处,就是允许我们快速找出我们需要修剪的下一个间隔(即,上次固定加上修剪间隔)。然而,这并不意味着我们将永远保留osdmap清单:一旦监视器修剪osdmap并且存储中最早的可用epoch大于我们上次修剪的映射,osdmap清单将不再需要。

OSDMonitor::get_trim_to() 相同的条件,强制监视器保留大量osdmap,从而要求我们进行修剪,最终可能会改变并允许监视器移除一些最旧的映射。

映射修剪

如果监视器修剪映射,我们必须调整osdmap清单以反映我们的修剪状态,或者如果保留它不再有意义,则完全删除清单。例如,采用前面的映射序列,但假设我们没有完成修剪所有映射。

-------------------------------------------------------------
|1|10|20|30|..|490|500|501|502|..|49500|49501|..|49999|50000|
-------------------------------------------------------------
 ^ first            ^ pinned.last()                   last ^

pinned = {1, 10, 20, ..., 490, 500}

现在假设监视器将修剪到epoch 501。这意味着删除早于epoch 501的所有映射,并将 first_committed 指针更新为 501。鉴于删除所有这些映射会使我们现有的修剪工作无效,我们可以认为修剪已完成,并丢弃osdmap清单。这样做也简化了新的修剪开始,如果我们在从存储刷新状态后满足所有起始条件。

然后我们将拥有以下映射序列

---------------------------------------
|501|502|..|49500|49501|..|49999|50000|
---------------------------------------
 ^ first                        last ^

然而,想象一个稍微复杂一点的场景:监视器将修剪到epoch 491。在这种情况下,epoch 491先前已从存储中修剪。

鉴于我们始终需要存储中最旧的已知映射,在我们修剪之前,我们将不得不检查该映射是否在修剪间隔中(即,如果该映射epoch属于 [ pinned.first()..pinned.last() ))。如果是这样,我们需要检查这是否是一个固定映射,在这种情况下,我们除了从清单的固定列表中删除较低的epoch之外,不需要担心太多。另一方面,如果修剪到的映射不是固定映射,我们需要重建该映射并将其固定,然后才能删除早于该映射epoch的固定映射。

在这种情况下,我们将得到以下序列:

-----------------------------------------------
|491|500|501|502|..|49500|49501|..|49999|50000|
-----------------------------------------------
 ^   ^- pinned.last()                   last ^
 `- first

还有一个我们应该提到的边缘情况。考虑我们要修剪到epoch 499,这是最后一个修剪的epoch。

就像上面的场景一样,我们将把osdmap epoch 499写入存储;但我们应该如何处理固定映射和修剪呢?

最简单的解决方案是丢弃osdmap清单。毕竟,鉴于我们修剪到了最后一个修剪的映射,并且我们正在重建此映射,我们可以保证所有大于e 499的映射都是连续的(因为我们没有修剪任何它们)。本质上,在这种情况下丢弃osdmap清单与我们修剪超过最后一个修剪的epoch本质上相同:如果满足所需条件,我们可以稍后再次修剪。

至此,我们已经全面探讨了完整的osdmap修剪。本文档后面可以找到有关整个算法(从修剪到修剪)的详细 REQUIREMENTS, CONDITIONS & INVARIANTS。此外,下一节将详细介绍几个额外的检查,以保证我们的配置选项的合理性。请享用。

配置选项合理性检查

我们在修剪之前执行额外的检查,以确保所有涉及的配置选项都是合理的

  1. 如果 mon_osdmap_full_prune_interval 为零,我们将不修剪;我们需要一个大于一的正数才能修剪映射。如果间隔为一,我们实际上不会修剪任何映射,因为固定映射之间的间隔本质上是一个epoch。这意味着固定映射之间将有零个映射,因此永远不会修剪任何映射。

  2. 如果 mon_osdmap_full_prune_min 为零,我们将不修剪;我们需要一个大于零的正值,以便我们知道应该修剪的阈值。我们不想猜测。

  3. 如果 mon_osdmap_full_prune_interval 大于 mon_osdmap_full_prune_min,我们将不修剪,因为无法确定合适的修剪间隔。

  4. 如果 mon_osdmap_full_prune_txsize 小于 mon_osdmap_full_prune_interval,我们将不修剪;我们需要一个 txsize 值至少等于 interval,并且(取决于后者值)理想情况下更高。

要求、条件与不变式

要求

  • 仲裁中的所有监视器都需要支持修剪。

  • 一旦启用修剪,不支持修剪的监视器将不允许加入仲裁,也不允许同步。

  • 删除osdmap清单会导致禁用修剪功能的仲裁要求。这意味着不支持修剪的监视器将被允许同步并加入仲裁,前提是它们支持所需的任何其他功能。

条件与不变式

  • 修剪从未发生过,或者我们已经修剪过了它之前的间隔:

    invariant: first_committed > 1
    
    condition: pinned.empty() AND !store.exists(manifest)
    
  • 修剪至少发生过一次:

    invariant: first_committed > 0
    invariant: !pinned.empty())
    invariant: pinned.first() == first_committed
    invariant: pinned.last() < last_committed
    
      precond: pinned.last() < prune_to AND
               pinned.last() + prune_interval < prune_to
    
     postcond: pinned.size() > old_pinned.size() AND
               (for each v in [pinned.first()..pinned.last()]:
                 if pinned.count(v) > 0: store.exists_full(v)
                 else: !store.exists_full(v)
               )
    
  • 修剪已完成:

    invariant: first_committed > 0
    invariant: !pinned.empty()
    invariant: pinned.first() == first_committed
    invariant: pinned.last() < last_committed
    
    condition: pinned.last() == prune_to OR
               pinned.last() + prune_interval < prune_to
    
  • 修剪间隔可以修剪:

    precond:   OSDMonitor::get_trim_to() > 0
    
    condition: !pinned.empty()
    
    invariant: pinned.first() == first_committed
    invariant: pinned.last() < last_committed
    invariant: pinned.first() <= OSDMonitor::get_trim_to()
    invariant: pinned.last() >= OSDMonitor::get_trim_to()
    
  • 修剪修剪过的间隔:

    invariant: !pinned.empty()
    invariant: pinned.first() == first_committed
    invariant: pinned.last() < last_committed
    invariant: pinned.first() <= OSDMonitor::get_trim_to()
    invariant: pinned.last() >= OSDMonitor::get_trim_to()
    
    postcond:  pinned.empty() OR
               (pinned.first() == OSDMonitor::get_trim_to() AND
                pinned.last() > pinned.first() AND
                (for each v in [0..pinned.first()]:
                  !store.exists(v) AND
                  !store.exists_full(v)
                ) AND
                (for each m in [pinned.first()..pinned.last()]:
                  if pinned.count(m) > 0: store.exists_full(m)
                  else: !store.exists_full(m) AND store.exists(m)
                )
               )
    postcond:  !pinned.empty() OR
               (!store.exists(manifest) AND
                (for each v in [pinned.first()..pinned.last()]:
                  !store.exists(v) AND
                  !store.exists_full(v)
                )
               )
    

由 Ceph 基金会为您呈现

Ceph 文档是由非营利性 Ceph 基金会 资助和托管的社区资源。如果您希望支持这项工作和我们的其他努力,请考虑 立即加入