PG peering,recovery 和 backfill

一直以来对 PG 部分的代码了解不多,最近在研究 Crimson,刚好对 PG 部分的代码进行一次深入梳理。

PG 和 PG log

Ceph 中每个 object 都隶属于一个 PG,数据寻址是以 PG 为单位。因为 Ceph 是通过计算的方式确定数据位置,每次集群拓扑变化时要把所有数据位置重新计算一遍,如果按照单个 object 来管理,则计算量随着数据量增多会越来越大,而以固定数量的 PG 为单位则可以避免这个问题。当然,PG 这种大粒度管理带来的缺点就是数据不够均衡,无法做到 object 粒度的管理。不过从经验上看,通过 upmap 至少可以做到 5%以内的容量偏差。

Ceph 是主从模式写入,每次写入都会产生一个 PG log entry,primary 再把 entry 发送给 replica,不同 PG 有各自独立的 log 和状态机,互不影响。

为什么要有 pg log?

pg log在内存中是一个由pg_log_entry_t 构成的 list,每个 entry 包含版本号和 obj id,但不包括要改动的数据,所以是一个很小的元数据信息。

在多副本情况下可能出现一个副本写入成功,另一个副本失败的情况,这时要想让集群恢复到数据一致的状态,必须让每个副本对哪些请求已完成,哪些请求要丢弃/回滚达成一致。如果每个副本都维护一个自己的最新版本信息(info.last_update),那么就能通过这个版本信息确定哪个副本上数据最新,以最新的副本为基础,就可以把其他副本都恢复。但如果只有 pg info 没有 pg log,恢复的时候就不知道具体哪些 object 受影响,只能全量遍历,pg log 此时的作用就是维护一个窗口,将要恢复的 object 限定在窗口范围内,避免全量遍历。当副本的 pg log 比 auth 的 pg log 落后太多时(窗口无法连接)就退回到了全量遍历的情况,即进行 Backfill 而不是 recovery。

因为 pg log 不包请求的数据部分,因此和单机层面的 transaction 无关,只是为了优化数据恢复。引入 pg log 的缺点是每个修改请求都引入了额外 kv(pg log 在底层都是以 kv 形式存在)信息,消耗 IO 性能。

Peering

peering 是指集群拓扑变化后对每个 pg 选出新的可用于提供服务的 osd 组的过程。每个节点都可以根据 crush 计算得出 pg 的 up set,但 up set 中可能有不包含数据的新加入的 osd,因此还要引入 acting set,表示包含该 pg 数据的能提供服务的 osd 组。比如初始状态 pg.1 的 up 和 acting set 均为[0,1],新加入了 osd.2,根据 crush 规则pg.1现在的 up set 为[0,2],但 backfill 完成前 acting set 为[0,1]。为了选出 acting set,需要知道该 pg 历史 osd 有哪些(即 past interval),再根据这些 osd 的 pg log 和 pg info 得出一个 acting set。另外还有一个 acting_recovery_backfill 集合,是 up 和 want_acting(依次从 up、acting、past intervals 中选出来的有足数据的副本个数的 osd 集合) 的和,primary 处理 client 请求时会尝试发给 acting_recovery_backfill 所有成员。

PG 状态机主要是由 OSDMap 变化驱动,peering 过程中也会因收到 peer 消息或自身逻辑产生状态变化。
OSD 在收到monitor 发来的 osdmap 时会先将 osdmap 写入磁盘,然后才是 pg 层处理,pg 层处理主要有三个步骤(每个 pg 都要进行):

//OSD::committed_osd_map()
pg->handle_advance_map() // PeeringState::advance_map()
pg->handle_activate_map() // PeeringState::activate_map()
pg->complete_rctx() //发送 pg_temp,up_thru 信息给 monitor,发送积累的消息,重跑队列中的请求

如果新的 osdmap 对 pg 没有影响则 pg 状态不需要变化,如果有影响,比如 out 了一个 osd,则被 out 的 osd 上的 pg 都会重新进行 peering。peering 的目的是选出新的副本并拉齐各个副本间的信息(pg log 和 info),这个过程简要描述如下:

  1. 得到 probe list:根据 osdmap 和 paste_interval 可以得到所有待探测的节点 probe list
  2. 得到 peer info:探测 probe list 得到全部 info,根据 info 可以计算出谁是 auth log
  3. 产生完整 log:primary 读取 auth 和其他 replica 的 log 合并到自己,再发给 replica
  4. active:有 missing obj 则进入 recovery 状态,有 backfill 则进入 backfill 状态,否则进入 clean 状态,此时 pg 已经可以提供服务

Recovery 和 Backfill

时机

PG 的数据恢复都是由 primary 主导,replica 只负责回应 primary 的请求。Primary 在 Activating 状态会判断是否需要进行数据恢复,如果有 missing obj 则进入 Recovery,比如一个副本所在 osd 重启后,收到 primary 发来的 log 会发现 primary log 比自己更新,这部分新内容对应的 obj 需要标记为 missing。如果有空副本或者副本上数据落后太多的情况则进入 Backfill,比如 up 集合中有新加入的 shard,此时该 shard 上没有任何该 pg 的数据,则需要进行 backfill。

通过PG::on_active_complete() 可以看到 pg recovery 会优先于 backfill。这样做的原因是 backfill 依赖 list object,如果参与 backfill 的副本中有 missing 则 list 会漏掉相应 object,导致 backfill 过程不会推送该 object,而 recovery 过程则会解决所有 missing object。

数据恢复没有银弹,当 object 正在恢复时来自 client 的 IO 是被阻塞的,但通过精心设计可以尽量减小阻塞时间和机会。

Recovery

通过状态机进入的 recovery 是 PglogBasedRecovery,还有另外一种 recovery 是 UrgentRecovery。当 primary 收到一个客户端请求,但对应的 obj 处于 missing 状态则会发起一个UrgentRecovery,马上对 obj 进行恢复。

PglogBasedRecovery 过程

peering 过程已经计算出了每个副本缺少哪些 object 以及这些 object 可以从哪里获得,recovery 过程主要是 2 步:

  1. 遍历 primary 上的 missing 集合,逐个进行恢复。恢复过程先从 peer pull 对应 obj(如果 obj 在primary 上 missing),然后 push 到同样 missing 该 obj 的 peer。
  2. 遍历 peer_missing[] 集合,里面记录了每个 peer 缺少 的 obj,primary 会将这些 obj push 到 peer。

关键点

  • 如何处理客户端写请求?primary 在IO 路径上(ClientRequest::process_op())会先检查 obj 在 acting_recovery_backfill set 中是否处于 missing,如果 missing 则阻塞客户端请求,恢复后再继续。
  • 如何处理客户端读请求?如果请求发到 primary,当 primary 上 obj missing 时需要阻塞等待恢复,否则能正常读取。如果发到 replica,则在ClientRequest::with_pg_process_interruptible()中会判断对应 obj 是否 missing,如果 missing 则直接返回错误。
  • N 版开始加入了 async_recovery 优化,原理是只要有 min_size 大小的副本集合没 missing obj,则可以提供服务。具体实现是当副本 missing 数量超过osd_async_recovery_min_cost 则将副本从 acting set 移动到 async_recovery_targets,除非移动后 acting set 数量小于 pool min_size,这样当process_op()检查 missing 时可以略过 async_recovery_targets 中的副本,就能减少 IO 被阻塞的情况,当给副本发repop 时如果 peer 上 obj missing 则不会给该 peer 发repop,只有通过 recovery 完整恢复 obj 后该 peer 才能承接 IO。需要注意的是 crimson 目前没有移植 async_recovery 功能。

Backfill

过程

  1. resource reserve。包括 local 和 remote,用于控制 backfill 速度,相关参数 osd_max_backfillsosd_min_recovery_priority
  2. list objcet。在 primary 和要恢复的 replica 上 list object,得到要 push 到 replica上的 object 名称。
  3. recover object。从 primary 逐个 push object 到 replica,replica 收到后写入本地。
  4. 结束。

关键点

  • object 都是按名字排序,每次 list 只有一部分,由 osd_backfill_scan_max 参数控制。
  • 如何处理客户端写请求?写请求都是由 primary 处理,处理前会先检查obj是否处于 backfilling,是则阻塞,否则继续处理,不过在 send repop 给 replica 前还要再确认peer 上有完整数据,没有则不给 peer 发送 repop。
  • 如何处理客户端读请求?客户端选择 target osd 时从 acting set 里选择,而 backfill 的 osd 不在 acting set 中,所以请求不会发到 backfill 的副本上。

后记

没想到这么多年没(啃)去(不)啃(动)的代码现在终于找到点头绪,而且描述起来竟然出乎意料的显而易见。这里再补充些学习过程中的笔记。

PG 状态图

很早以前见过一张包括所有状态以及转换关系的 PG 状态图,那张图对于初学者真是天书一般。这里重新整理了状态关系,但没有加上事件转换关系,对阅读代码有一定帮助。需要注意的几点:

  1. 有些状态有子状态,会自动进入到默认子状态。
  2. 每个状态的构造、析构函数也有可能包括转换逻辑,不能略过。
PG 状态

peering

更细的代码层面的流程如下:

  1. 根据新的 osdmap 可以计算出新的 up 集合(通过 crush+upmap),此时 acting 集合无法准确获得(比如 osd.0 被 out 但没有 down,上面仍然有数据,应该作为 acting 的一员),只能暂时设置成和 up 一样。
  2. start_new_interval:新的 up 和 acting 作为新的 interval,旧的记录到 past_interval 中
  3. getinfo 阶段:对 past_interval 中记录的 osd 发送 query,等待对端返回 notify,用于初始化 peer_info 数组。(此阶段也会尝试更新 up_thru)
  4. getlog 阶段:找到 auth_log(log 信息最多的副本);choose_acting() 选出副本个数个有有效 log 的 osd(先从 up、acting 中找,不足则继续从步骤 3 中得到的 peer_info 中找);如果当前 primary 不是 auth 则先从 auth 拉取 log,合并到自己的 log 再继续。
  5. getmissing 阶段:对于 acting_recovery_backfill(包括 up,有有效日志的 acting 和其他 peer)中和自己日志有差异的副本发送 query,等待收到 peer 返回的 log 合并到本地
  6. activating 阶段:此时 primary 已经有了全部日志(通过合并 auth和其他 replica)和全部 peer info,由于合并日志会产生 missing obj,这时需要找到哪些 osd 上有这些 obj,另外其他副本可能和 primary 日志不一样所以还需要给其他副本发送primary 的日志。
  7. activated 阶段:等 primary 和 replica 的 log、info 信息落盘后,如果没有 missing obj 则进入 clean 状态,否则进入 recovery 或 backfill 状态

其中有几个细节地方如下:

  1. up_thru:表示这个 osd 存活的最大 interval,是一个优化措施,在 2 副本下,如果 osd.0 和 osd.1 先后 down,随后 osd.0启动,如果没有 up_thru 记录则无法知道 osd.1 在 osd.0 down 后且重新启动之前是否有更新,osd.0 启动之后只能等待 osd.1 启动才能继续。有 up_thru 后每个 pg 在能提供服务前必须更新 osd 的 up_thru 值(通过向 monitor 发送 MOSDAlive 消息)到当前 interval,这样只要 osd 成功更新了 up_thru,即使无法启动 ,其他节点也能通过 osdmap 知道对应 osd 是否有提供过服务(有可能刚更新完 up_thru 就宕机,实际也没有产生更新,但这种情况无法识别,只能等待)。
  2. missing obj:合并日志时只知道一个 obj 发生了变化,但对应的 obj 数据还没有同步,因此这些 obj 都作为 missing obj。
  3. pg_temp:表示一个 pg 当前实际需要的拥有该 pg 数据的副本个数的 osd 集合。产生时机是 choose_acting() 根据日志选出的副本个数的 osd 集合(want_acting)和通过 crush 选出的 up 集合不一致时。例如两节点时 pg.1 健康状态下副本是[0,1],out osd.1 后根据 crush 计算出来的 up 集合是[0],但 choose_acting()选出来的 want_acting 集合是[0,1],这时 pg.1 的 pg_temp 就是[0,1],当有 pg_temp 时 acting 的就是用 pg_temp 而不是 up 集合。
  4. 至少经历 2 轮 interval 才会进入 activating,第二轮会产生 pg_temp
  5. 只有 primary 合并 auth log 时会extent head,其他情况合并 log 时如果 replica 的 log 更长,都是较长部分作为 divergent 处理
两节点下out 一个 osd 后状态机变化流程

backfill

backfill 单独实现了一个状态机,不过 resource reserve 阶段仍是使用 peer_state 状态机。


backfill 时的 PG 状态机
backfill 时的 Backfill 状态机

和其他实现对比

假设有这样一个分布式存储系统,使用中心化查表方式记录每个 object 的磁盘位置,每个磁盘通过一个存储引擎来管理(类似 osd),写入采用多副本星型写,每个 object 有一条元数据记录 version 信息,每次写入 version 加 1,出现磁盘故障时客户端 IO 阻塞至 object 重新恢复成 3 副本。

出现故障节点时服务恢复流程

和 ceph 节点故障时对比,两者第一步类似,都是做一个多节点写入,第二步 ceph 是做 peering,相比假设存储要做对象级别的数据传输,peering 产生的数据量小很多,耗时理论上会更小,第 2 步结束后两种存储都可以对外提供服务,不同的是 ceph 是在提供降级写入。如果假设的中心化存储也能提供降级写,那么就能避免阻塞 IO 的情况。要提供降级写至少有类似 past interval 的信息,这个可以存放在中心节点,而且不需要类似 pg log 的结构,因为管理粒度是 object,不存在全量遍历的问题。嗯,这样看来好像也没什么问题,只要中心节点扛得住。对比 ceph,管理粒度分散到 osd,每个 osd 管理数百个 pg,而中心节点的设计需要元数据节点管理千亿级别的 object,但从 HDFS 的经验来看也不是什么问题。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容