1、重构概述
在Xline 0.7.0中,我们完成了对Xline代码库中进行了一次较大的重构。这次重构在某些性能测试中甚至使得Xline获得了近20倍的性能提升。在本文中我会讲解Xline中重构后命令执行流程的新设计,以及我们是如何优化Xline的性能的。
2、etcd的性能分析
由于Xline的实现和etcd类似,在etcd中的性能瓶颈在Xline中同样存在。因此在开始对Xline进行分析之前,让我们首先分析一下etcd的性能。
etcd命令执行流程
我们首先需要梳理一下etcd命令的执行流程,这有助于我们后面进行的性能分析。etcd使用的是Raft共识算法,命令的执行流程很简单,简要叙述如下:
- 节点接收client发送的命令
- 节点将命令写入自身的log中
- 节点将这条log复制到多数follower节点上
- 节点在自身状态机上执行这条命令,持久化到后端存储
- 节点返回执行结果到client
主要性能开销
影响一个etcd节点性能的因素有很多,要分析性能首要的问题是分析关键路径上的各类操作,这包括CPU时间和各类IO操作。接下来我会对这些操作进行逐条分析。由于在etcd集群中leader的压力是最大的,以下的性能分析中的节点都是指leader节点。
gRPC请求
在etcd中主要存在两种gRPC通信,一种是节点处理client发送的命令,另外一种是节点向其他follower节点复制log。对于etcd实现来说,第一种显然是在关键路径上的,而由于etcd需要提前复制到大多数节点上才会返回结果给client, 所以第二种同样位于关键路径上。
在go gRPC的性能测试中单核CPU通常能够每秒处理数十k的请求, 而在etcd在设计上也通常需要处理每秒数十k的请求,这说明对于etcd gRCP server的压力是非常大的。因此如果在有限的环境下,gRPC可能会导致性能瓶颈。
存储IO
对于存储设备上的IO主要有两种:
- 对于每个命令,我们需要持久化到WAL中
- 执行命令时,我们需要持久化到后端存储中
我们需要这两种操作都执行完成后才会返回给client,因此它们都是位于关键路径上
由于Raft安全性的要求,持久化到WAL是需要同步地落盘才能进行后续操作,因此性能瓶颈主要落在了fsync的性能上,因为即使在SSD上一次fsync也需要数百微妙。
而持久化到后端存储则没有很高的安全性要求,仅需要保持原子性即可,不需要每次调用都使用fsync,因此这些操作大部分都可以在内存中完成,一般情况下都是non-blocking的,不会造成明显性能瓶颈。
为什么etcd难以在多核cpu上扩展性能
etcd保证了strict serializable,所有操作必须按照一个全局的顺序来完成,这样就造成了我们的命令处理逻辑无法并发地完成。例如在处理Raft log时,我们首先需要拿到一把全局的锁,然后才能进行后续操作,同样对于后端的命令执行也需要按顺序逐个进行,无法并行执行。因此,etcd的吞吐量极大地受限于单线程的性能。
3、Xline重构概述
下面我会从整体角度介绍Xline本次重构中对性能有较大影响的部分。这其中主要涉及到Xline对于command的执行机制的修改。
Xline与etcd相似之处
Xline使用的是CURP共识算法,它和Raft最主要的区别就是分为前端commit和后端commit,后端commit和Raft相同,都需要leader复制log到大多数节点上。而前端commit是通过witness这个机制来实现的,witness的机制是client直接记录到witness上来完成快速commit。因此,要分析Xline的性能,我们需要从前端的witness性能和后端Raft的性能两方面进行分析。
CURP的冲突检测性能
在CURP的中,为了保证witness上的commands的commutativity, 我们需要对所有commands进行冲突检测。
一个直接的想法就是把所有commands放在一个列表里,然后当检查一个command是否冲突时就遍历这个列表进行检查。这就是Xline中旧实现的思路,这样导致关键路径上每次冲突检查的复杂度为 O(n) ,效率很低。同时,这个列表外层还需要加一把锁,造成严重的锁护送(lock convoy)的现象,这一现象我会稍后详细讲解。
在重写后的冲突检测机制中,我们使用了区间树来优化KV命令冲突检测的复杂度,使得冲突检测的时间复杂度降至了`O(log(n))`,使得冲突检测效率大大提升,即使是在关键路径上对性能影响也会比较小。区间树实现可以参见往期文章,这里不再赘述。
WAL取代RocksDB
Xline最初使用RocksDB作为CURP的Log存储。由于CURP的log实际上是顺序追加的,RocksDB并不是最合适的选择,因为RocksDB需要先写入到它自己的WAL中,然后再写入到MemTable,最后再写入到SST文件中,这对于我们的简单用例来说显得多此一举了。于是在新的实现中,我们实现了我们自己的WAL来作为CURP的log存储。这个WAL的实现非常简单,整个存储使用多个WAL文件,log的追加的实现就是单个文件的追加,这样所有的log追加操作都是文件的顺序写入,效率很高,并且不存在写放大的现象。
Cmd worker/Conflict Checked MPMC
Xline最初实现了一个称为conflict checked mpmc的结构,它的目的是用于命令的并发执行。它能够动态维护命令间的冲突关系,并且将没有冲突的command发送到cmd workers中进行执行。
但是由于需要动态维护冲突的关系,按照命令发送到这个mpmc中的顺序,所有节点的冲突关系形成了一个DAG,那么单次插入的时间复杂度为`O(n)`,虽然实现上是使用channel进行通信不存在锁护送问题,但在面临高吞吐的时候依然效率较低。在最新的设计中,这一结构已经被移除。
另外一个问题是接收conflict checked mpmc发出的command的cmd workers。Xline首先会spawn一定数量的workers,然后这些workers通过channel和conflict checked mpmc进行通信。但实际上这些worker是没有必要的,因为Xline已经构建在tokio的runtime上,runtime本身就使用了workers范式。而我们再在runtime的workers上再实现一个执行command的workers是没有必要的,并且可能会带来消息传递的overhead。所以在需要执行一个命令时,直接调用 tokio::spawn 即可。
Client propose
在CURP中,client的propose分为fast path和slow path,其中对于没有冲突的命令可以走fast path,只需要1RTT就能够完成,而如果发现有冲突,那么client就必须要求leader将这条命令同步到大多数节点后再返回结果。
在Xline之前的实现中,一个client propose可以分为两个gRPC unary的请求,一个是向leader发送 Propose 请求,这个请求包含实际的命令,返回值是fast path的结果。而另外一个是 WaitSynced 请求,它的作用是如果fast path失败就等待同步完成后再返回。
这样的设计看似简单,但其实存在性能上的问题。我们可以和etcd进行对比,etcd由于所有命令都是同步完成后才返回给client,因此etcd的client只需要发送一个propose的unary请求即可收到最终的结果。而对于Xline的client来说需要向leader发送两个请求。这样就大大增加了gRPC server的负载。在gRPC实现性能接近的情况下,相同的CPU时间Xline大约只能处理相当于etcd一半的请求。
为了解决这个问题,我们改用了使用gRPC流来实现client propose。这样client和server会建立一个双向的流来进行通信,client只需要向流中发送一条消息,并且根据情况选择接收一条或两条回复。这样leader需要处理的消息就从原先的两条变为一条,而且在fast path的情况下只需要返回一条消息,这样就大大提高了gRPC命令处理的效率。
4、实现中的问题
并发执行问题
在Xline初始设计中,我们希望能够发挥出CURP的优势,尽可能地使用并发执行的策略。
在CURP中,对于相互之间不冲突的命令,理论上是可以并发进行执行的。我们最开始设计了conflict checked mpmc这一数据结构来实现并发执行,但是事与愿违,这样做反而会对性能产生负面影响。下面我会解释这一现象的原因。
并发开销
1. 执行过程很短,并发成本昂贵对于一个put的命令,在speculative execute的过程中,我们并不需要真正地写入到DB中,我们只需要保证命令成功持久化到witness上即可返回结果给client。因此,对于这类不需要进行DB操作的命令来说,并发执行是没有必要的。
2. 而为了实现并发,需要使用channel在不同的tokio task间进行通信,这样就会产生不可忽略的开销:
- 线程通信开销,需要复制数据
- 无法利用CPU缓存,造成大量内存访问
3. 并发执行时对RocksDB IO产生负面影响 并发执行时每条命令完成时都会单独向RocksDB写入,相当于使用多个线程向RocksDB进行写入。这样做实际上是非常低效率的,一方面会造成更多的IO操作,另一方面会有严重的写放大现象。即使RocksDB底层实现上会对操作进行batching,但依然比我们手动管理batch更为低效。
并发的替代方案
因此,在重构后的版本中,我们删去了并发操作,所有线程的IO操作都会由channel发送到单独的专用线程中,在这个专用线程中经过batching之后再提交到存储当中,这样大大提高了IO的效率,系统整体性能也得到提升。
那么读者可能会有疑问,batching是否会对latency产生负面影响呢?我们的解决方法是这样的:用于提交到储存的这个线程从channel接受到一个操作后,它会在一个循环中busy-waiting channel中是否还会接收更多的操作。由于Xline设计上需要每秒处理数十k的请求,因此可以循环几百到数千次,这样在高负载下不会一次提交太多,低负载下也不会浪费CPU时间,最终对于latency不会有显著影响。对比之下如果使用定时器,例如 std::thread::sleep 或者 tokio::time::sleep 就会低效很多,因为前者需要使用系统调用,不仅效率低而且还会block住tokio的worker,而后者如果yeild到runtime之后我们无法准确知道休眠的时间(例如runtime处于高负载),因此几乎无法使用。
锁护送(lock convoy)
在最初调试Xline时,我们发现Xline的CPU占用率非常低,即使在最大throughput情况下在多核CPU上CPU time百分比只有数十。可想而知,我们最大的throughput也非常低,大约只能每秒处理数千的请求。这是为什么呢?
这个问题的原因就是锁护送(lock convoy)现象,按照我之前所讲解的冲突检测中的例子,我们用一把锁来保护一个数据结构,当我们并发执行的时候,多个线程会尝试获取锁来更新这个数据结构,而这个数据结构更新所需要的CPU时间又非常长,这样看似是使用多个线程进行并发执行,实际上线程大部分时间都在等待拿到锁。
更致命的是,tokio的async模型使用的是一个小的固定线程池作为workers,由于Xline中使用的是同步锁,一旦一个worker线程持有这个锁的时间过长,那么就会导致其余的worker线程都进入休眠状态,这些worker线程不仅无法更新这个数据结构,它们也无法执行其他任务,形成了一个锁车队。这样其实就解释了Xline的CPU占用很低的现象,因为大部分时间都只有一个线程处于运行的状态,无法发挥出异步的优势。
缓解Xline中锁护送问题的方法有很多,第一个就是我之前介绍的,优化数据结构的时间复杂度,减少更新操作花费的CPU时间。第二个就是使用背压(backpressure)机制,主动减少锁的负载(例如当数据结构中存储数据的规模达到一定限度时,阻止更多线程来继续更新数据结构),这样能使锁护送现象尽快消散。第三个就是使用单个专用线程来更新数据结构,其他线程使用channel将更新操作发送到这个线程,这样虽然会有线程间通信的开销,但不会出现锁护送问题。
Async中IO阻塞操作真的有害吗?
下一个就是IO阻塞问题, 我们往往会在async代码中处理IO操作时倾向于使用各类结构的async 版本,而不使用它们的同步版本,但是在async代码中同步版本真的应该被避免吗?
WAL的IO
最初我们使用了async的tokio::fs来实现WAL,但是当对它进行集成性能测试时,我们发现WAL的写入效率非常低,一次log append操作就需要花费数毫秒到几十毫秒,即使我们对log采用了batching,这么高的延迟也是不可接受的。
究其原因,我们发现延迟较高的现象是由于tokio runtime调度所导致的。如果我们使用async的文件,如果当前runtime处于高负载的情况下(例如有大量gRPC请求),当文件操作的future yeild到runtime以后,runtime可能会忙于处理其他的任务,再次运行这个future的时间就没法确定了。这样就导致文件写入的高延迟现象。
再者,tokio::fs中的文件操作实际上并不都是真正的异步,例如对于 fsync/fdatasync 操作,tokio使用了 tokio::task::spawn_blocking 将syscall的操作移动到tokio的blocking线程上。我们之前提到,在现代SSD上一次fsync操作只需要数百微妙,所以通常根本不能算是blocking操作,而我们将它移动到其他线程上再发送回结果就显得非常昂贵了。
对于WAL使用同步fs操作的还有一个理由,就是WAL写入是一个高优先级操作,如果WAL写入没有完成,那么节点上的其他工作也是无法取得进展的,因此每次WAL同步地写入反而是更好的策略,即使极少数情况下blocking的时间过长,也只会阻塞一个线程,这在多核CPU环境下对系统性能并不会有显著影响。
tokio::fs 的延迟其实一直以来都是一个问题,有关的性能测试可见tokio repo中的issue: https://github.com/tokio-rs/tokio/issues/3664。而最终我们使用 std::fs 替换了 tokio::fs ,在新的性能测试中平均单次log append操作的延迟下降到了不到一毫秒。
RocksDB的IO
重构之初,由于RocksDB不支持异步写入,我们使用了 tokio::task::spawn_blocking 将RocksDB操作发送到其他线程中执行,但是这样并没有带来性能提升。这主要有两种原因:
- 与etcd使用的BoltDB类似,在没有fsync的情况下,RocksDB的IO操作大部分是在缓存中进行的。所以大部分时候RocksDB的操作都能很快完成,并不能算是blocking操作,发送到其他线程就多此一举了。
- RocksDB有自己管理的线程池,RocksDB实际的处理逻辑和IO操作都是由这些线程来完成的,因此这些操作不会阻塞tokio的worker线程。
总而言之,RocksDB的操作是异步友好的,我们可以直接在异步代码中调用这些操作而不需要担心阻塞。
内部可变性
接下来我会谈谈Xline中使用内部可变性的问题。Xline中因为最初为了能够并发执行,在API设计上使用了很多的内部可变性范式。而在多线程代码中,我们最常见到的内部可变性使用方法就是使用 Mutex 或者是 RwLock 这两种锁 。
一个简单的API的例子:
trait KvOperations<K, V> {
fn insert(&self, key: K, value: V);
fn query(&self, key: &K);
}
impl KvOperations<K,V> for KvObject {
fn insert(&self, key: K, value: V) {
self.inner.write().insert(key, value);
}
...
}
fn thread0<T: KvOperations>(a: T, key: K, value: V) {
a.insert(key, value);
}
fn thread1<T: KvOperations>(a: T, key: K) {
a.query(key);
}
对于 insert 这样会实际上会修改数据结构的操作,在API定义上只要求一个不可变引用。这样的API设计看似方便,但在重构中我们发现这些不可变引用往往屏蔽了底层数据结构实现对于锁的使用,这样使用者往往不会考虑多线程下锁竞争的关系, 例如在上面的 thread0 和 thread1 中,调用者并不清楚底层具体使用了什么锁,它和其他的thread有没有竞争关系,这样就会造成API的滥用,最终就可能导致多线程下激烈的锁竞争。
所以,在构建API时,更好的方法是使用Rust的所有权模型,将锁交给调用者处理,修改后的例子如下:
trait KvOperations<K, V> {
fn insert(&mut self, key: K, value: V);
fn query(&self, key: &K);
}
impl KvOperations<K,V> for KvObject {
fn insert(&mut self, key: K, value: V) {
self.inner.insert(key, value);
}
...
}
fn thread0<T: KvOperations>(a: Arc<RwLock<T>>) {
a.write().insert(key, value);
}
fn thread1<T: KvOperations>(a: Arc<RwLock<T>>) {
a.read().query(key, value);
}
这样做就能够迫使调用者考虑锁竞争的关系,例如我们如果在 thread1 中获取了读锁,就会知道这次操作会阻塞其他线程中的写锁。在代码实现上还会更加灵活,例如持有锁以后可以进行一系列操作,而不是使用多个内部可变重复上锁。
隐藏的内存分配
隐藏的内存分配往往也会对性能产生很大影响。在重构中,我们尝试切换Xline的内存分配器从glibc到jemalloc,结果是带来了显著的性能提升,这是为什么呢?
我们知道jemalloc能够有效避免内存碎片,所以对于很多小分配来说,jemalloc通常会更快。分析Xline的代码,我们发现Xline中的确存在非常多的隐藏的堆分配,而且这些堆分配大部分都是用于命令的处理,这包括例如`Arc::new`,`Vec::clone`还有各类序列化操作。在benchmark用例中,一个命令只有几百个bytes,所以这些分配都可以视作为小分配。Xline的问题主要有两个,第一个是小分配太多导致内存碎片化,另外一个是在持有锁的情况下也会进行堆分配,导致持有锁的时间显著增加。因此,在代码中需要谨慎使用`Arc::new`这样的隐藏的内存分配,多利用Rust的生命周期机制,避免不必要的堆分配。另一个就是尽量减少在关键路径上,或者是持有锁的情况下进行堆分配。
无锁数据结构
最后一点就是有关无锁数据结构的使用。在Xline性能分析中我们发现,一些无锁数据结构并不像它们宣称的那样是其他需要锁定的数据结构的直接替代,这些无锁数据结构的使用反而堆系统性能造成负面影响。
第一个例子就是 DashMap ,它是 HashMap 的一个并发实现。但是在实际测试中,我们发现 DashMap 遍历效率很低,甚至比 HashMap 要慢了一个数量级,原因是 DashMap 内部使用的 Arc 导致解引用的效率会变低很多。
第二个例子就是对于 crossbeam中 SkipMap 的使用, SkipMap 虽然支持无锁并发操作,但是与 RwLock 相比,它的单线程插入和删除性能还是要慢了一倍以上,因为即使均摊时间复杂度相同, BTreeMap 树高度更低和对于缓存更加友好的特性使它的性能会更好。
5、总结
本次Xline的重构中,我们主要针对命令执行流程进行了重大改进,包括冲突检测优化,存储优化,以及client请求处理上的优化等等。同时,我们也对于一些影响性能的潜在问题进行了分析和改进。从重构中我们也学到了不少性能优化方面的经验和技巧,这对于Xline未来的持续开发和性能提升会有很大的帮助。
往期推荐
1. Xline中区间树实现小结
2. Xline command 去重机制(二)—— RIFL 实现
Xline于2023年6月加入CNCF 沙箱计划,是一个用于元数据管理的分布式KV存储。Xline项目以Rust语言写就。感谢每一位参与的社区伙伴对Xline的帮助和支持,也欢迎更多使用者和开发者参与体验和使用Xline。
GitHub链接:
https://github.com/xline-kv/Xline
Xline官网:www.xline.cloud
Xline Discord:
https://discord.gg/mJdTjzfD