1. 背景
在支撑一些延迟敏感的在线应用过程中,我们发现 Ceph 的尾延迟较差,当应用并发负载较高时,Ceph 很容易出现延迟的毛刺,对延迟敏感的应用造成超时甚至崩溃。我们对 Ceph 的尾延迟问题进行了深入细致的分析和优化。造成尾延迟的一个重要原因就是代码中锁的使用问题,下面根据锁问题的类型分别介绍我们的优化工作。本文假设读者已熟悉 Ceph 的基本读写代码流程,代码的版本为 Luminous。
2. 持锁时间过长
2.1 异步读优化
Ceph 的 osd 处理客户端请求的线程池为 osd_op_tp,在处理操作请求的时候,线程会先锁住操作对应 pg 的 lock。其中,处理对象读请求的代码如下图所示,在锁住对象所属 pg 的 lock 后,对于最常用的多副本存储方式,线程会同步进行读操作,直到给客户端发送返回的数据后,才会释放 pg lock。
在进行读操作时,如果数据没有命中 page cache 而需要从磁盘读,是一个耗时的操作,并且 pg lock 是一个相对粗粒度的锁,在 pg lock 持有期间,其它同属一个 pg 的对象的读写操作都会在加锁上等待,增大了读写延迟,降低了吞吐率。同步读的另一个缺点是读操作没有参与流量控制。
我们对线上集群日志的分析也验证了上述问题,例如,一个日志片段如下图所示,图中列举了两个 op 的详细耗时信息,这两个 op 均为同一个 osd 的线程所执行,且操作的是同一个 pg 的对象。根据时间顺序,第一个 op 为 read,总耗时为 56ms。第二个 op 为 write,总耗时为 69ms。图中信息显示,第二个 op 处理的一个中间过程,即副本写的完成消息在处理之前,在 osd 请求队列中等待了 36ms。结合上图的代码可以知道,这 36ms 都是耗在等待 pg lock 上,因为前一个 read 操作持有 pg lock,而两个对象属于相同 pg。
我们的优化如下图所示,我们创建了独立的读线程,负责处理读请求,osd_op_tp 线程只需将读请求提交到读线程的队列即可返回解锁,大大减少了 pg lock 的持有时间。读线程完成磁盘读之后,将结果放到 finisher 线程的队列,finisher 线程重新申请 pg lock 后负责后续处理,这样将耗时的磁盘访问放在了不持有 pg lock 的流程中,结合我们在流量控制所做的优化,读写操作可以在统一的框架下进行流量控制,从而精准控制磁盘的利用率,以免磁盘访问拥塞造成尾延迟。
我们用 fio 进行了异步读优化效果的测试,测试方法:对同一个 pool 的两个 rbd,一个做随机读,另一个同时做随机写操作,将 pg number 配置为 1,这样所有对象读写会落到同一个 osd 的同一个 pg。异步读优化后,随机写平均延迟下降了 53% 。下图为某业务的 filestore 集群异步读上线前后读吞吐率的数据,箭头所指为上线时间,可见上线之后,集群承载的读操作的吞吐率增加了 120% 。
上述优化在使用 filestore 存储后端时取得了明显的效果,但在使用 bluestore 存储后端时,bluestore 代码中还存在持有 pg 粒度锁同步读的问题,具体见 BlueStore::read 的代码。我们对 bluestore 的读也进行了异步的优化,这里就不详细介绍了。
3. 锁粒度过粗
3.1 object cache lock 优化
Ceph 在客户端实现了一个基于内存的 object cache,供 rbd 和 cephfs 使用。但 cache 只有一把大的互斥锁,任何 cache 中对象的读写都需要先获得这把锁。在使用写回模式时,cache flusher 线程在写回脏数据之前,也会锁住这个锁。这时对 cache 中缓存对象的读写都会因为获取锁而卡住,使读写延迟增加,限制了吞吐率。
我们实现了细粒度的对象粒度的锁,在进行对象的读写操作时,只需获取对应的对象锁,无需获取全局锁。只有访问全局数据结构时,才需要获取全局锁,大大增加了对象间操作的并行。并且对象锁采用读写锁,增加了同一对象上读的并行。测试表明,高并发下 rbd 的吞吐率增加了超过 20% 。
4. 不必要的锁竞争
4.1 减少 pg lock 竞争
Ceph 的 osd 对客户端请求的处理流程为,messenger 线程收到请求后,将请求放入 osd_op_tp 线程池的缓存队列。osd_op_tp 线程池的线程从请求缓存队列中出队一个请求,然后根据该请求操作的对象对应的 pg 将请求放入一个与 pg 一一对应的 pg slot 队列的尾部。然后获取该 pg 的 pg lock,从 pg slot 队列首部出队一个元素处理。
可见,如果 osd_op_tp 线程池的请求缓存队列中连续两个请求操作的对象属于相同的 pg,则一个 osd_op_tp 线程出队前一个请求加入 pg slot 队列后,获取 pg lock,从 pg slot 队列首部出队一个请求开始处理。另一个 osd_op_tp 线程从请求缓存队列出队第二个请求,因为两个请求是对应相同的 pg,则它会加入相同的 pg slot 队列,然后,第二个线程在获取 pg lock 时会阻塞。这降低了 osd_op_tp 线程池的吞吐率,增加了请求的延迟。
我们的优化方式是保证任意时刻每个 pg slot 队列只有一个线程处理。因为在处理 pg slot 队列中的请求之前需要获取 pg lock,因此同一个 pg slot 队列的请求是无法并行处理的。我们在每个 pg slot 队列增加一个标记,记录当前正在处理该 pg slot 的请求的线程。当有线程正在处理一个 pg slot 的请求时,别的线程会跳过处理该 pg slot,继续从 osd_op_tp 线程池的请求缓存队列出队请求。
4.2 log lock 优化
Ceph 的日志系统实现是有一个全局的日志缓存队列,由一个全局锁保护,由专门的日志线程从日志缓存队列中取日志打印。工作线程提交日志时,需要获取全局锁。日志线程在获取日志打印之前,也需要获取全局锁,然后做一个交换将队列中的日志交换到一个临时队列。另外,当日志缓存队列长度超过阈值时,提交日志的工作线程需要睡眠等待日志线程打印一些日志后,再提交。锁的争抢和等待都增加了工作线程的延迟。
我们为每个日志提交线程引入一个线程局部日志缓存队列,该队列为经典的单生产者单消费者无锁队列。线程提交日志直接提交到自己的局部日志缓存队列,该过程是无锁的。只有队列中的日志数超过阈值后,才会通知日志线程。日志线程也会定期轮询各个日志提交线程的局部日志缓存队列,打印一些日志,该过程也是无锁的。通过上述优化,基本避免了日志提交过程中因为锁竞争造成的等待,降低了日志的提交延迟。测试在高并发日志提交时,日志的提交延迟可降低接近 90% 。
4.3 filestore apply lock 优化
对于 Ceph filestore 存储引擎,同一个 pg 的 op 需要串行 apply。每个 pg 有一个 OpSequencer(简称 osr),用于控制 apply 顺序,每个 osr 有一个 apply lock 以及一个 op 队列。对于每个待 apply 的 op,首先加入对应 pg 的 osr 的队列,然后把 osr 加到 filestore 的负责 apply 的线程池 op_tp 的队列,简称为 apply 队列。op_tp 线程从 apply 队列中取出一个 osr,加上它的 apply lock,再从 osr 的队列里取出一个 op apply,逻辑代码如下图左所示。可见,每个 op 都会把其对应的 osr 加入到 apply 队列一次。如果多个 op 是针对同一个 pg 的对象,则这个 pg 的 osr 可能多次加入到 apply 队列。如果 apply 队列中连续两个 osr 是同一个 pg 的,也就是同一个 osr,则前一个 op 被一个线程进行 apply 时,osr 的 apply lock 已经加锁,另一个线程会在该 osr 的 apply lock 上阻塞等待,降低了并发度。
这个问题也体现在日志中。一个线上集群日志片段如下图,有两个 op_tp 线程 6700 和 5700,apply 队列里三个对象依次来自 pg: 1.1833, 1.1833. 1.5f2。线程 6700 先拿到第一个对象进行 apply, 线程 5700 拿第二个对象进行 apply 时卡在 apply lock 上,因为两个对象都来自 pg 1.1833,直到 6700 做完才开始 apply。而 6700 拿到第三个对象,即 1.5f2 的对象进行 apply 即写 page cache 只用了不到 1ms,但实际 apply 延迟 234ms,可见第三个对象在队列里等待了 233ms。如果 5700 不用等待 apply lock,则第二和第三个对象的 apply 延迟可以大大缩短。
我们优化后的逻辑代码如上图右所示,同一个 osr 只加入 apply 队列一次,取消 apply lock,利用原子操作实现无锁算法。上面的算法可以进一步优化,在将一个 osr 出队之后,可以一次从它的队列中取 m(m>1)个 op 进行 apply,在 op apply 完成阶段,改为如果 atomic::fetch_sub(osr->queue_length, m) > m,则将 osr 重新入队以提高吞吐率。
我们用 fio 进行了 apply lock 优化效果测试,方法为建两个 pool,每个 pool 的 pg number 为 1,每个 pool 一个 rbd, 对两个 rbd 同时进行随机写的操作,一个 pool 写入数据的量为 31k*10k,另一个 pool 写入数据的量为 4k*100k, 衡量所有请求 apply 的总耗时。优化前总耗时 434ks, 优化后总耗时 45ks,减少 89.6% 。
作者介绍:
汪黎,滴滴首席工程师
负责滴滴在线非结构化存储研发,曾任国防科技大学计算机学院副研究员,教研室主任,天河云存储负责人
本文转载自公众号滴滴技术(ID:didi_tech)。
原文链接:
评论