Ceph peering流程14问




本文基于H版本代码编写(对比L版本看了下,大致流程没啥变化)。

问题1:peering是如何启动的?

有两个场景会触发peering流程:
1. 在pg创建时
2. 在OSD启动、停止导致OSDMap变化进而导致pg的acting set发生变化时

场景1对应的代码流程是:

Initialize事件会使状态机(RecoveryMachine)进入Reset状态:

Reset状态定义了ActMap事件的反应函数:

Started状态的初始子状态是Start状态:

这里只关注MakePrimary,Start状态里定义了MakePrimary事件的反应函数:

由此进入了peering流程。

场景2对应的代码流程是:

OSD::_send_boot()发给monitor的MOSDBoot消息内容,monitor收到之后进行Paxos决议,之后就认为osd已经up了,并且会给osd发送osdmap信息(版本号是osd down之前的epoch+1),osd收到osdmap应该就触发了peering流程。

OSD::ms_dispatch(Message *m) -> OSD::_dispatch(Message *m) -> handle_osd_map(static_cast(m)) -> consume_map() -> pg->queue_null(osdmap->get_epoch(), osdmap->get_epoch()):

osd启动过程中OSD::init会调用load_pgs函数,进而调用handle_loaded:

之后OSD::process_peering_events函数(peering线程的实际处理函数)会调用OSD::advance_pg,再调用PG::handle_activate_map,发送ActMap事件给状态机(此时状态机处于Reset状态),状态机就进入了Started状态,之后就是MakePrimary/MakeStray了。

问题2:peering各个阶段是如何转换的?

peering主要分为GetInfo、GetLog、GetMissing、WaitUpThru这4个阶段(状态),4个阶段一般来说是串行的,他们的转换过程涉及到boost状态机的基础知识(参考问题7),总体来说状态转换图可以参考官方文档:
pg整体状态变化

这个图虽然看起来更清晰,但缺少了部分状态转换流程:peering状态变化

如在GetLog阶收到AdvMap事件后进入Reset状态的流程。

就代码层面来说,相关的转换过程举例如下:

进入peering初始状态

Peering状态的初始状态就是GetInfo(boost状态机基础知识,参考问题7):

GetInfo阶段经过以下几个主要步骤后进入下一个阶段(本阶段具体做的事情参考问题10):
1. pg->generate_past_intervals()
2. pg->build_prior(prior_set)
3. PG::RecoveryState::GetInfo::get_infos() // 如果prior_set为空,不需要从其他OSD获取信息,则直接进入GetLog阶段(post_event(GotInfo()))
4. PG::RecoveryState::GetInfo::react(const MNotifyRec& infoevt) // 获取其他OSD返回的pg info(接收到MNotifyRec事件)后进入GetLog阶段(post_event(GotInfo()))

另外一个问题:MNotifyRec(Message Notify Receive)事件是如何投递过来的?
首先我们要理解一点,根据peering线程模型(参考问题9)可知,所有peering相关操作都是在2个peering线程中执行的,因此事件投递要么直接通过这两个线程进行(无法异步),要么通过队列,然后由peering线程异步的从队列中获取事件并处理,Ceph中几乎所有耗时操作都是异步的,因此肯定是通过队列来投递事件,具体流程是:

PG::peering_queue队列是也是在peering线程池处理函数OSD::process_peering_events里出队的:

进入GetLog状态

上面已经提到进入GetLog状态的两个途径,1是如果prior_set为空,不需要从其他OSD获取信息,则直接进入GetLog阶段;2是收到MNotifyRec事件并处理完之后。这里就不多说明。

进入GetMissing状态

GetLog阶段会给拥有权威日志的OSD发送pg_query_t::LOG获取pg log请求,对方回复的日志消息事件是MLogRec(Message Log Receive),GetLog状态的pg收到这个事件后就进入了GetMissing阶段。

MLogRec事件的分发流程与MNotifyRec类似,这里也不多说明。

GetMissing阶段会检查pg log是否完整,如不完整则需要继续从actingbackfill的OSD集合里拉取日志,生成缺失的对象列表,并在后续的recovery/backfill阶段根据这个列表进行数据恢复。

进入WaitUpThru状态

WaitUpThru状态不是所有pg都会进入的,条件是两种情况:
1. pg已经在GetLog阶段获取了所有日志,没有缺失对象,GetMissing阶段就可以在第一阶段退出(不必给actingbackfill的OSD发请求),这时如果pg不需要通知monitor更新up_thru值,则不进入WaitUpThru状态,否则进入
2. pg有缺失对象,在收到OSD回复的pg日志后(MLogRec事件),还会再次做条件1的判断

进入WaitUpThru状态都是通过post_event(NeedUpThru())进行的。

如果不需要进入WaitUpThru状态,则pg直接进入Active状态(通过post_event(Activate(pg->get_osdmap()->get_epoch()))):
Peering状态里有定义事件转换:boost::statechart::transition< Activate, Active >

进入Active状态

要进入Active状态,必须是up_thru已经更新(或者原本就不需要更新)的情况,第一种很简单,进入WaitUpThru或者之前的GetMissing阶段就直接进入Active了。

这里讨论第二种up_thru需要更新的情况,在peering线程池处理函数OSD::process_peering_events中会轮询每个peering_queue中的pg,并调用OSD::advance_pg批量检查收到的增量osdmap(一般都是monitor发过来的),对每个增量版本都调用pg->handle_advance_map来更新到pg,并发送AdvMap(Advance Map)给状态机,该事件由PG::RecoveryState::Peering::react(const AdvMap& advmap) 反应函数处理,里面继续调用pg->adjust_need_up_thru(advmap.osdmap),这里会根据接收到的osdmap里的up_thru值,来检查是否已经更新到期望值,如果已更新,则把pg的need_up_thru值置为false,表示已更新,就可以OSD::advance_pg函数里pg->handle_advance_map之后通过调用pg->handle_activate_map(rctx)来发送ActMap(Active Map)事件,最终退出WaitUpThru阶段进入Active阶段。

问题3:peering为啥会阻塞客户端IO?

就原理层面简单来说是为了保证用户数据的完整性和一致性,试想一下如果pg的部分数据还没完全恢复或者各个副本之间还没达成一致,就接受客户端IO读写,这时将会发生读错误或者因写入新数据导致用户数据混乱不一致。

就代码层面来说,阻塞客户端IO的代码流程是在:

上面的dispatch_op_fast返回false之后,并不会真正的返回错误给客户端,而是会把这个op放入一个等待队列,等条件满足后会再次处理,也即在此过程中客户端IO是阻塞的。

在OSD::consume_map()函数中会调用OSD::dispatch_session_waiting,从而在OSDMap更新后继续处理执行阻塞的客户端IO,OSD::handle_pg_peering_evt(peering事件投递函数)中也会通过调用wake_pg_waiters(pg, pgid)来间接调用到OSD::dispatch_session_waiting。

问题4:peering怎么挑选OSD的?

PG::RecoveryState::GetInfo::GetInfo会调用PG::build_prior生成prior_set,prior_set里保存的就是peering阶段获取pg info要用到的OSD列表(可能包含当前OSD)。

挑选过程可以简单的理解为pg当前acting和up set,以及历史的acting set(需要OSD当前处于up状态)。在我们的使用场景下,一般一个OSD down了之后,不会out(坏盘场景除外),并且会很快的处理掉这种故障,在OSD down期间,他对应的副本OSD down的概率极低(我们一般不会容忍2个副本OSD同时down的场景),因此历史的acting set一般都用不到(一个OSD down期间可以认为他上面的pg的acting和up set不会变化),当前的acting和up set就可以获取peering所需的全部pg info信息。

问题5:为啥要有peering流程?

ceph社区官方解释:http://docs.ceph.com/docs/master/dev/peering/

简单来说就是,peering是为了让pg知道他应该关联到哪些OSD?哪个是主?谁保存了哪些用户数据、元数据?怎么汇集这些数据以便在各种OSD故障场景下,保证用户数据的完整性和一致性?所有额外的那些概念(PG或OSD类的成员)都是为了达到这个目的而添加/设置的。

为了保证数据的完整性和一致性,需要在peering未成功完成之前阻止客户端IO。因此如果peering无法正常完成,pg将处于incomplete状态,无法提供IO能力。无法完成peering的原因根据peering的功能可以推测出来,比如pg知道了他关联的OSD,但这些OSD都是down的;或者这些OSD保存的元数据汇集起来仍然无法还原故障期间用户所做的IO操作,也就是无法保证用户数据的完整性和一致性。

设想一下如果没有peering流程,3副本情况最简单的故障场景下:
1. A B C — up
2. A B — up, C — down // 我们期望AB继续对客户端提供IO能力,如果没有peering,C是主的情况下,AB就无法对客户端提供IO读写能力
3. A B C — up // C启动之后,它保存的数据可能就是不完整的,甚至是与AB不一致的

当然更复杂的故障场景下,需要添加更多的持久化变量(例如up_set、acting_set、up_thru等)来记录相关的信息,以便帮助OSD进行故障恢复的决策,也有一些临时性的变量在故障恢复过程中发挥作用(如prior_set等)。

问题6:peering相关的可调参数有哪些?分别有啥用处?

  1. peering线程池中worker线程数:osd_op_threads,默认值2,即有几个线程并发处理peering pg,线程多了相对会加快peering处理速度,但可能对monitor节点或者OSD本地盘造成压力,如果一个OSD节点的pg数量较多,并且peering pg有堆积现象,可以考虑增加线程数。
  2. 线程处理pg时批量获取pg的数量:osd_peering_wq_batch_size,默认值20,即每个peering worker线程每次从peering_queue队列中获取多少pg来处理,每批pg都是串行处理的,如果有一个pg比较耗时,则可能影响同一批的其他pg
  3. pg检查OSDMap增量版本时每次检查的最大版本数量:osd_map_max_advance,默认值200,每个pg每次被peering线程处理时,都会检查最多这么多个OSDMap增量版本,多了单次处理相对会更耗时,少了就要多被peering线程调度几次才能处理完
  4. monitor Paxos决议间隔时间:paxos_propose_interval,默认值1.0,monitor的多个主从节点之间进行Paxos决议的间隔,这个间隔是为了防止OSDMap的版本号变化太快导致overflow,同时缓解monitor节点压力,这个间隔时间段内的所有osd状态变化可以一次性处理掉

问题7:peering使用的boost状态机是怎么回事?

boost状态机的参考文档:http://sns.hwcrazy.com/boost_1_41_0/libs/statechart/doc/tutorial.html

下面将结合peering相关代码来说明状态机的常用操作。

常用操作:

  1. 定义一个状态机
    状态机都是继承自boost::statechart::state_machine:

  1. 定义一个状态
    状态继承自boost::statechart::state(也可以继承自boost::statechart::simple_state):

  1. 定义一个子状态
    子状态和状态定义上没有区别,只是在定义的时候可以为一个状态指定子状态,表示其从属关系,子状态也可以有自己的子状态,也可以没有。

  1. 定义状态机事件
    事件是用来驱动状态机在状态间进行转换的,其继承自boost::statechart::event:

  1. 定义事件反应
    事件反应是指状态机在某个状态下,接收到指定事件后,会把状态机转换到另外一个状态,或者执行指定的操作:

  1. 实现事件反应函数
    定制化的事件反应需要实现对应的反应函数,如上面定义的QueryState事件和AdvMap事件的custom_reaction反应,都需要实现具体的反应函数才行:

  1. 丢弃事件

  1. 投递事件
    投递事件有两种方式,一种是调用状态机的process_event()函数,一种是在自定义的事件反应函数里调用post_event(),两种方式都是向状态机投递相应的事件:

投递事件之后,状态机会根据当前所处的状态以及当前状态下定义的事件反应函数对事件做出反应(也即调用对应的事件反应函数)。

  1. 转发事件

  1. 强制状态转换

需要注意的是,强制状态转换时必须搭配return关键词使用,否则可能导致未定义行为。原因是transit之后会调用当前状态的析构函数释放相关资源,如果在transit之后还有要执行的代码,则会导致未知行为。

  1. 从外部获取状态机内部信息
    可以通过定义事件反应函数来通过特定事件获取状态机内部信息:

通过ceph daemon osd.0 perf dump recoverystate_perf命令可以从OSD的UNIX domain socket接口查询状态机内部信息:

perf dump相关数据记录过程(以GetInfo阶段为例):

问题8:peering有哪些阶段可能比较耗时?

准备阶段

之所以要讨论这个阶段,是因为在线下复现过程中,发现准备阶段也会影响peering耗时,其影响的peering阶段主要是GetInfo,原因如下:

每个pg执行到pg->handle_peering_event()之后就进入了peering的各个阶段(根据事件不同状态机转换到不同状态也即peering的不同阶段),而peering线程池线程数量就2个(也即2个线程并发执行OSD::process_peering_events函数),如果两个线程都阻塞到OSD::process_peering_events函数的某一步(例如advance_pg或dispatch_context_transaction),则就会导致之前已经进入peering流程的pg耗时变长,磁盘限速复现场景下,可以通过日志看到dispatch_context_transaction是耗时较长的流程(在FileStore::op_queue_reserve_throttle函数里有限流操作,队列中op数量超出50个则需要等待),其影响的peering阶段主要是GetInfo。

GetInfo

GetInfo阶段本身可能耗时的地方在于从其他OSD(同属一个pg或者曾经同属一个,up+acting)获取pg_query_t::INFO信息过程,与每个OSD的交互相当于有两次的网络传输,和一次OSD读取数据。在收到OSD回复的MNotifyRec消息后会退出GetInfo阶段进入GetLog阶段。

其他主要流程如pg->generate_past_intervals()、pg->build_prior()、pg->proc_replica_info等都是OSD进程内的内存操作,正常情况下都是非常快速的。

GetLog

GetLog阶段本身耗时的地方也是与其他OSD(同属一个pg或者曾经同属一个,up+acting)交互流程,获取pg_query_t::LOG信息,在收到其他OSD回复的MLogRec消息后会退出GetLog流程,进入GetMissing阶段。其他流程如pg->proc_master_log也是OSD内部的内存操作。

GetMissing

GetMissing阶段本身耗时的地方也是与其他OSD(同属一个pg或者曾经同属一个,actingbackfill)交互流程,获取pg_query_t::LOG或pg_query_t::FULLLOG信息,在收到其他OSD回复的MLogRec消息后会退出GetMissing流程,根据是否需要更新up_thru决定进入WaitUpThru阶段或者Activate阶段。其他流程如pg->proc_replica_log也是OSD内部的内存操作。

WaitUpThru

通常情况下这个阶段都是要走的,因为OSD启动或者停止后都会进入peering流程(本身OSD或者其他OSD),流程结束后,都要更新OSD的up_thru值,以记录OSD上次peering正常结束的epoch版本。

该阶段主要是发送MOSDAlive消息给monitor(包含期望更新到的up_thru版本号),并等待monitor回复更新后的OSDMap,并没有其他额外的流程。

OSD::process_peering_events函数在遍历完所有pg后,会根据是否需要更新up_thru来进入queue_want_up_thru函数,这个函数会发送MOSDAlive消息给monitor。之后由monitor通过Paxos协议决议出新的OSDMap(包含OSD期望更新到的up_thru版本号),然后通过回调回复给OSD。这个决议过程需要写入磁盘进行持久化,并且还需要最长等待1s来防止OSDMap变更过于频繁导致epoch值变化太快(可能发生越界)、OSDMap数量太多占用更多的存储空间。

如果monitor存储元数据所用的磁盘IO能力较低(尤其是IOPS能力),就会导致磁盘写入等待时间过长,无法及时进行决议的持久化,从而影响OSD的waitupthru耗时。

问题9:peering相关的线程模型和数据结构是什么样的?

数据结构:

线程模型:

peering使用的也是ceph的通用线程池模型(PeeringWQ绑定了ThreadPool),实现了自己的队列出队、入队,及线程处理函数(struct PeeringWQ::_process),线程池初始化是在OSD类的构造函数里初始化的:

整个线程池调用的最关键的处理函数就是osd->process_peering_events(pgs, handle)。

问题10:GetInfo阶段到底get的是哪些info?info用来干啥的?

GetInfo阶段主要做了4件事:
1. 计算past_interval
2. 构造prior_set
3. 给prior_set里的OSD发送查询pg info消息
4. 处理OSD返回的pg info

past_interval

pg->generate_past_intervals()生成past_interval的列表,保存在map<epoch_t,pg_interval_t> past_intervals中。

简单来说,past_interval就是pg的acting set和up set保持不变的一个时间段,这个时间段的开始和结束时间点都是用epoch来表示的(如果把epoch理解为时间戳则更容易理解),一旦acting和up set发生了变化,则就会生成一个新的interval,所谓past,顾名思义就是过去的,有过去的就有现在或者说当前的,当前的interval就是pg当前acting和up set一致的时间段,对应的名称是current_interval。

past_interval也不是全部的历史记录,也没有必要全部,实际上只关注OSD down掉的那部分时间段的即可,(为了各种异常会比较OSD的superblock里持久化了之前已经保存好的最老osdmap的epoch),在我们场景下,osd down了之后,pg会在degraded模式下运行(acting和up set中有2个osd),因此其past_intervals一般就一个interval,从osd down的那个epoch开始到osd up之前的那个epoch结束(从up那个epoch开始到当前最新的epoch就是current_interval)。

prior_set

由于我们的场景下past_intervals很少,因此prior_set也比较简单,基本可以认为是osd down掉之后另外两个osd就是prior_set,也即找这两个osd就可以恢复全部元数据和数据(这也不难理解,另外两个正常的osd确实保存了全部用户数据和pg元数据信息)。

pg info

pg info结构体定义如下:

这些信息主要是记录pg的元数据的,并没有用户数据,通过对从prior_set里其他OSD获取的pg info和本地OSD的pg info进行比对,就能知道谁的pg保存了哪些(时间段或者说epoch版本序列)用户数据(pg log),从而在后续阶段利用对应的OSD进行恢复。

问题11:GetLog阶段到底get的是哪些log?log用来干啥的?

choose_acting

因为新的osd up了,pg的分布就可能发生变化,所以要生成新的acting set,但是acting set里面可能有需要backfill的osd,所以这个set就叫acting_backfill。
在根据pool的类型来调用PG::calc_replicated_acting(副本)或calc_ec_acting(ec)计算acting_backfill之前,需要先找出具有权威日志的osd(可能包含多个osd,会进行排序),在我们关注的场景下(启动、停止osd,并且noout),降级状态的2个副本其中之一为主,因此根据选择规则基本上选择的权威日志OSD就是当前的主OSD。

这个过程中还涉及到pg_temp(临时主)的选择过程,简单理解就是crush算法选择的新主不具备全部用户数据时,就需要先通过临时主对新主进行数据恢复,恢复完毕之后再切换到真正的新主。

get olog

如果选择出来的权威日志OSD就是当前OSD,则直接进入GetMissing阶段,也即不需要从权威日志OSD节点上获取pg log用于恢复数据(自己本身就有这些日志了)。

如果权威日志OSD不是自己,就需要给他发送pg_query_t::LOG查询消息,而对方回复的消息里包含的数据是:

收到上述信息之后,会调用pg->proc_master_log()对其进行处理,这个函数主要是把收到的权威pg log与本地pg log进行合并,从而让当前pg所在的OSD变成主OSD(可能还需要经历pg_temp临时主阶段)。

问题12:GetMissing阶段?

GetMissing的正常流程是会从actingbackfill里的OSD上获取pg log,然后将其与权威日志比较,从而计算出各个OSD上缺少的对象,在后面的recovery/backfill阶段进行恢复。但在我们关注的场景下(启动、停止osd,并且noout),只有启动OSD的时候可能在当前OSD有缺少的对象,另外两个OSD一般来说都是包含全部对象的,因此只需要在当前OSD上进行数据恢复即可(也就是说不需要从其他OSD获取任何日志了)。

问题13:为啥要有WaitUpThru阶段?到底wait的是啥?

首先要理解up_thru引入的场景,简单来说就是up_thru是用来确认OSD上次正常完成peering流程的epoch版本,完成了peering流程就意味着OSD可能已经开始对外提供IO服务,就有可能有数据写入,有数据写入就意味着要检查pg上数据的完整性和一致性。如果没有这个字段,那么在下面的场景下(size=3,min_size=1),我们就无法判断OSD B上是否有写入过用户数据(如果直接认定B未写入过,就可能导致用户数据丢失):

epoch OSD up/down
101 A B C all up
102 B C A down
103 B C down
104 A C B down, AC up

上述假设场景下,epoch 103这个阶段,B可能是真的活着,也可能down了但还没报给monitor,如果B真的活着,那就有可能有数据写入,则在epoch 104的时候,AC虽然up了,也不能完成peering并接收客户端IO请求,否则在103到104这个时间段内用户数据可能丢失。

而如果引入up_thru这个概念,就能解决这个场景的困扰,只需要在每次peering完成时上报一次pg的up_thru版本号即可,如果103时B上报了up_thru为103,则表示他自己在单副本场景下运行过,可能有数据写入,否则up_thru就是102,表示B从来没自己单独对外提供IO服务过,因此在104阶段就AC可以完成peering并对外提供服务(C有最新的数据)。

有了上述理论基础,WaitUpThru阶段所做的工作和必要性就容易理解了。这个阶段就是更新pg的up_thru版本号并交给monitor进行决议并持久化保存,收到monitor回复的更新完毕的确认消息后,才可以表示peering真正的完成了。对应的代码流程是:

monitor端收到MOSDAlive消息的处理过程:

WaitUpThru状态收到monitor回复的消息后就会进入Active状态。

问题14:日志合并是怎么做的?

简单来说,要先看本地和权威日志是否有重叠,没有重叠就没办法合并,只能通过backfill全量恢复本地数据。
如果有重叠,就类似两个保存k-v对的有序链表的合并及去重操作(复杂的地方在于同一个对象两份日志可能有冲突,即k
ey相同value不同),大致流程应该是:
对最老的日志,权威日志的更老,就以权威日志为准,把最老的那段时间补上(尾巴接上);
对最新的日志,权威日志如果更新,说明他保存的数据更全更新,因此以他为准,把本地OSD缺少的对象记录下来,后续的流程进行恢复(recovery);
对冲突的日志,仍然以权威日志为准,算出本地需要更新的对象,后续流程进行恢复(recovery)。