InfoQ 推荐 ( ) • 2022-08-09 16:18

简介和背景

随着分布式业务从单数据中心向多数据中心发展,多地多活部署的需求也越来越普遍。这带来最大的挑战就是跨数据中心跨地域的metadata管理,metadata对数据的稳定性和强一致性有极高要求。在单数据中心场景下,metadata的管理已经有很多成熟的解决方案,etcd就是其中的佼佼者,但是在多数据中心场景下,etcd的性能受Raft共识协议的限制,它的性能和稳定性都大打折扣。DatenLord作为高性能跨云跨数据中心的存储,对metadata管理有了跨云跨数据中心的要求。DatenLord目前使用etcd作为metadata的管理引擎,但是考虑到etcd无法完全满足DatenLord的跨云跨数据中心的场景,我们决定实现自己的metadata管理引擎。Xline应运而生,Xline是一个分布式的KV存储,用来管理少量的关键性数据,并在跨云跨数据中心的场景下仍然保证高性能和数据强一致性。考虑到兼容性问题,Xline会兼容etcd接口,让用户使用和迁移更加流畅。

Xline的架构

Xline的架构主要分为RPC server,KV server,其他server,CURP共识协议模块和Storage模块

RPC server:主要负责接受用户的请求并转发到相应的模块进行处理,并回复用户请求。KV Server和其他server:主要业务逻辑模块,如处理KV相关请求的KV server,处理watch请求的watch server等。CURP共识协议模块: 采用CURP共识协议,负责对用户的请求进行仲裁,保证数据强一致性。Storage:存储模块,存储了key value的相关信息。

一次写请求的操作流程如下:

RPC server接收到用户写请求,确定是KV操作,将请求转发到KV server。KV server做基本请求做验证,然后将请求封装为一个proposal提交给CURP模块。CURP模块执行CURP共识协议,当达成共识后,CURP模块会调用Storage模块提供的callback将写操作持久化到Storage中。最后通知KV server写请求已经commit。KV server得知请求已经被commit,就会封装请求回复,并通过RPC server返回给用户。

Xline的核心: CURP共识协议

CURP共识协议的细节介绍请参考 DatenLord|Curp 共识协议的重新思考"。CURP协议的优势是将非冲突的proposal达成共识所需要的RTT从2个降为1,对于冲突的proposal仍然需要两个RTT,而etcd等主流分布式系统采用的Raft协议在任何情况下都需要两个RTT。从两个RTT降为一个RTT所带来的性能提升在单数据中心场景下体现的并不明显,但是在多数据中心或者跨云场景下,RTT一般在几十到几百ms的数量级上,这时一个RTT的性能提升则相当明显。

Storage和Revision

Xline作为一个兼容etcd接口的分布式KV存储,etcd重要的revision特性需要完全兼容。简单介绍一下etcd的revision特性,etcd维护了一个全局单调递增的64bit的revision,每当etcd存储的内容发生改变,revision就会加一,也就是说每一次修改操作就会对应一个新的revision,旧的revision不会立马删除,会按需延时回收。一个简单的例子,两个写操作A -> 1,A -> 2,假设最初的revision是1,etcd会为 A = 1 生成revision 2,为 A = 2 生成revision 3。revision的设计使etcd对外提供了更加丰富的功能,如支持历史revision的查找,如查询revision是2的时候A的值,通过比较revision可以得到修改的先后顺序等。以下是etcd对一个KeyValue的proto定义

message KeyValue { bytes key = 1; int64 create_revision = 2; int64 mod_revision = 3; int64 version = 4; bytes value = 5; int64 lease = 6; }

一个KeyValue关联了三个版本号,

create_revision: 该key被创建时的revisionmod_revision:该key最后一次被修改时候的revisionversion:该key在最近一次被创建后经历了多少个版本,每一次修改version会加一

因为需要支持revision特性,Xline的Storage模块参考了etcd的设计,分为Index和DB两个子模块。Index模块存储的是一个key到其对应的所有revision数组的mapping,因为需要支持范围查找,Index采用了BTreeMap,并会放在内存中。DB模块存储的是从revision到真实KeyValue的mapping,因为有持久化和存储大量的历史revision的数据的需求,DB模块会将数据存到磁盘(目前prototype阶段DB仍然存在内存当中,在未来会对接持久化的DB)。那么一次查找流程是先从Index中找到对应的key,然后找到需要的revision,再用revision作为key到DB中查找KeyValue从而拿到完整数据。这样的设计可以支持历史revision的存取,分离Index和DB可以将Index放在内存当中加速存取速度,并且可以利用revision的存储特性即每一次修改都会产生一个新的revision不会修改旧的revision,可以方便DB实现高并发读写。

CURP共识协议带来的挑战

CURP协议的全称是Consistent Unordered Replication Protocal。从名字可以看出CURP协议是不保证顺序的,什么意思呢?比如两条不冲突的proposal,A -> 1,B-> 2,在CURP协议中,因为这两条proposal是不冲突的,所以它们可以并发乱序执行,核心思想是执行的顺序并不会影响各个replica状态机的最终状态,不会影响一致性。这也是CURP协议用一个RTT就可以达成共识的关键。但是对于冲突的proposal,如 A -> 1, A -> 2,CURP协议就需要一个额外的RTT来确定这两条proposal的执行顺序,否则在各个replica上A最终的值会不一样,一致性被打破。

因为Xline需要兼容etcd的revision特性也一定需要兼容。Revision特性要求每一次修改都有一个全局唯一递增的revision,但是CURP协议恰恰是无法保证不冲突proposal的顺序,它会允许不冲突的proposal乱序执行,比如前面的例子A -> 1,B -> 2,如果修改前存储的revision是1,那么哪一个修改的revision是2哪一个是3呢?如果需要确定顺序那么就需要一个额外的RTT,那么CURP协议仅需一个RTT就可以达成共识的优势将荡然无存,退化成和Raft一样的两个RTT。

解决方案

解决这个问题的思路是将达成共识和确定顺序即revision分成两个阶段,即通过一个RTT来达成共识,这时候就可以返回用户请求已经commit,然后再通过一个异步的RTT来确定请求的revision。这样既可以保证一个RTT就可以达成共识并返回给用户,又可以保证为每一个修改请求生成全局统一的revision。确定revision用异步batching的方式来实现,这一个额外的RTT会平摊到一段时间内的所有请求上并不会影响系统的性能。

Storage模块会实现如下两个callback接口供CURP模块调用,execute()会在共识达成后调用,通知proposal可以执行了,after_sync()会在proposal的顺序确定下来后再调用,以通知proposal的顺序,after_sync()接口会按照确定好的proposal顺序依次调用。

/// Command executor which actually executes the command. /// It usually defined by the protocol user. #[async_trait] pub trait CommandExecutor: Sync + Send + Clone + std::fmt::Debug where C: Command, { /// Execute the command async fn execute(&self, cmd: &C) -> Result; /// Execute the after_sync callback async fn after_sync(&self, cmd: &C, index: LogIndex) -> Result; }

为了配合CURP模块的两阶段操作,Storage模块的设计如下:

/// KV store inner #[derive(Debug)] struct KvStoreInner { /// Key Index index: Index, /// DB to store key value db: DB, /// Revision revision: Mutex, /// Speculative execution pool. Mapping from propose id to request sp_exec_pool: Mutex>>, }

当execute()回调被调用时,修改Request会被预执行并存到sp_exec_pool中,它存储了ProposeId到具体Request的mapping,这个时候该操作的revision并没有确定,但是可以通知用户操作已经commit,此时只需一个RTT。当操作顺序被确定后,after_sync()会被调用,Storage模块会从sp_exec_pool找到对应的Request并将它持久化,并把全局revision加1作为该操作的revision。

接下来我们用一次写请求 A -> 1 和一次读请求 Read A 来讲解整个流程。假设当前的revision是1,当KV server请求收到写请求,它会生成一个proposal发给CURP模块,CURP模块通过一个RTT达成共识后会调用execute() callback接口,Storage模块会将该请求放到sp_exec_pool中,这时候CURP模块会通知KV server请求已经commit,KV server就会返回给用户说操作已完成。同时CURP会异步的用一个额外的RTT来确定该写请求的顺序后调用after_sync() callback接口,Storage会把全局revision加1,然后从sp_exec_pool中讲写请求读出来并绑定revision 2,然后更新Index并持久化到DB当中,这时候DB存储的内容是 revision 2:{key: A, value:1, create_revision: 2, mod_revision: 2, version: 1}。当读请求到达时,就可以从Storage模块中读到 A = 1,并且create_revision = 2,mod_revision = 2。

总结

本文主要介绍了Geo-distributed KV Storage Xline的架构设计,以及为了兼容etcd的revision特性,我们对CURP共识协议和Storage模块做的设计,从而实现了在跨数据中心跨地域场景下的高性能分布式KV存储。详细代码请参考datenlord/Xline",欢迎大家来讨论。