关于数据库:TiKV-高性能追踪的实现解析

2次阅读

共计 8162 个字符,预计需要花费 21 分钟才能阅读完成。

本文为 PingCAP Observability 团队研发工程师钟镇炽在 Rust China Conf 2020 大会上所做演讲《高性能 Rust tracing 库设计》的具体文本,介绍了对性能要求十分刻薄的分布式 KV 数据库 TiKV 如何以不到 5% 的性能影响实现所有申请的耗时追踪。

背景

零碎的可观测性 (Observability) 通常由三个维度组成:日志 (Logging)、指标 (Metrics) 和追踪 (Tracing),它们之间的关系如下:

  • 日志:离散的错误信息和状态信息。
  • 指标:记录和出现可聚合的数据。
  • 追踪:单个申请的一系列事件。

TiKV 实现了齐备的日志和指标零碎,但缺失了追踪,导致在诊断 TiKV 和 TiDB 问题时会遇到以下艰难:

  • 观测数据之间的没有关联:只有相熟申请链路上每个操作对应什么监控指标的同学能力残缺追溯和诊断问题。
  • 申请抖动难以追溯:TiKV 节点往往同时解决不同模式的业务,零星申请的性能抖动无奈体现在 AVG / P99 / MAX 等监控指标中,从而无奈诊断抖动起因。

追踪能够无效解决上述场景中遇到的问题。以下具体介绍 TiKV 中高性能追踪的实现。追踪性能在 TiKV 中尚为实验性个性,须要特定代码分支开启,感兴趣的同学能够关注 GitHub issue Introduce tracing framework (#8981)。

基本概念

追踪(Trace)出现零碎中的一个申请的执行门路。例如追踪一个 SQL 语句从 TiDB 到 TiKV 的执行全过程后能够失去下图:

从图中能够直观看到 SQL 语句“INSERT INTO t VALUES (1), (2), (3);”乏味的信息:

  • TiDB 解决这个申请时顺次进行了 compile、plan、execute 三个步骤
  • TiDB 在 execute 阶段调用了 TiKV 的 Prewrite RPC 和 Commit RPC
  • 申请共耗时 5ms

图中每个方框代表一个事件,称之为 Span。每个 Span 蕴含:

  • 事件名称
  • 事件起始工夫戳和完结工夫戳

Span 之间有层级,能够形成父子关系或先后关系,如下图所示:

实现

本文所有性能测试后果,若特地阐明测试环境,均在以下平台实现:

  • CPU: Intel Core i7-8700
  • Linux distros: Ubuntu 20.04
  • Linux kernel: 5.4
  • Memory: 32G
  • Disk: NVMe SSD

TiKV 应用 Rust 编写。Rust 生态中有几个现成的追踪库,别离是 tokio-tracing, rustracing 和 open-telemetry,它们都兼容 OpenTracing 标准,但 性能不够现实,引入后会升高 TiKV 50% 以上性能。TiKV 目前的实现能将性能的影响管制在 5% 以内。这次要来自于单个 Span 追踪收集仅耗时 20ns

以下具体介绍 TiKV 如何在 20ns 内实现单个 Span 追踪和收集。

计时

计时在追踪中是高频操作,每个 Span 都须要取两次工夫戳,别离代表事件的起始和完结时刻,因而 计时的性能会很大水平上影响追踪的性能

追踪库采纳的计时形式通常须要能满足以下要求:

  • 获取的工夫戳枯燥递增
  • 高性能
  • 高精度

std::Instant

Rust 原生提供以下两种计时形式:

  • std::SystemTime::now()
  • std::Instant::now()

其中第一种形式获取的是以后零碎工夫,它可能受用户手动调整、NTP 服务修改等起因的影响,获取到的工夫戳并不提供枯燥递增的保障,因而不能采纳。

大多数 Rust 社区的追踪库采取了第二种形式,能够获得枯燥递增的、纳秒精度的工夫戳。但它的性能不够现实,取两次工夫须要 50ns,这是社区追踪库性能较低的起因之一。

Coarse Time

若仅从高性能的角度登程来寻找计时计划,可应用 Coarse Time,它 就义了肯定的精度换取高性能。在 Linux 环境下,以 CLOCK_MONOTONIC_COARSE 作为工夫源参数,通过 clock_gettime 零碎调用可获取 Coarse Time。Rust 社区也提供了库 coarsetime 获取 Coarse Time:

coarsetime::Instant::now()

Coarse Time 性能很高,在测试环境下实现两次调用仅须要 10ns。它的精度取决于 Linux 的 jiffies 配置,默认精度为 4ms。

低精度的计时对于短耗时申请的追踪会产生让人困惑的后果。如下图所示,从观测的角度来看曾经损失了相当一部分的细节信息:

当然 在少数状况下,Coarse Time 仍是疾速计时的首选。一方面是它在 Linux 零碎下开箱即用,获取不便。另一方面,4ms 精度对大部分利用来说是能够承受的。

尽管如此,作为追踪性能的开发者,咱们不心愿限度用户的场景,例如对于 KvGet 申请,4ms 在要求高的场景中已足够作为异样的抖动须要追溯了,因而 有必要反对微秒乃至纳秒级别精度的追踪。同时,性能作为外围出发点,也不能被就义掉。侥幸的是,这个问题是有解的,它便是接下来要介绍的 TSC。

TSC

TiKV 采纳 Time Stamp Counter (TSC) 寄存器进行高精度高性能计时。TSC 寄存器在古代 x86 架构的 CPU 中曾经存在很久了,最早能够追溯到 2003 年推出的奔流处理器。它记录了 CPU 供电重设后到以后时刻所通过的 CPU 时钟周期数。在 CPU 时钟周期速率雷同的条件下,通过测量和换算即可用于高精度计时。

TSC 能够同时满足枯燥递增、高精度和高性能的需要。在咱们的测试环境中取两次 TSC 仅需 15ns。在理论状况中,随着处理器的一直倒退,TSC 寄存器积攒了相当多历史遗留问题会对其正确性造成影响,须要修改。

TSC 速率

TSC 递增速率由 CPU 频率决定。现代化 CPU 可能会动静调节频率节俭能耗,导致 TSC 递增速率不稳固:

另外,一些 CPU 在休眠状态时不会递增 TSC

比拟古代的 x86 架构 CPU 提供了个性确保 TSC 递增速率的稳定性。在 Linux 下能够通过 /proc/cpuinfo 中的 CPU flag 来查看 TSC 速率是否稳固:

  • constant_tsc: TSC 将以固定的额外标称频率而非刹时频率递增
  • nonstop_tsc: TSC 在 CPU 休眠状态下仍继续递增

以上 TSC 速率的稳定性保障仅对单个 CPU 外围无效,在多核状况下还需解决 TSC 同步问题。

TSC 多核同步

x86 架构 CPU 没有提供 TSC 寄存器在所有外围上的一致性保障,这会导致计时存在问题。下图展现了某台 2020 年生产的搭载了过后最新 x64 CPU 的笔记本上 TSC 测量状况。能够看到,16 个外围中有一个外围 CPU 0 的 TSC 值存在偏差。

在追踪中,残缺的计时操作会读取两次工夫戳,别离代表事件的始末。因为操作系统的线程调度,这两个工夫戳的读取可能产生在不同的外围上。若咱们简略地以 TSC 值差值进行计时,会 在多核 TSC 不同步的状况下造成耗时计算的偏差

举个例子:

  1. t1 时刻,线程在 Core 1 上运行,读取了较大的 tsc1
  2. 操作系统将线程从 Core 1 调度至 Core 2
  3. t2 时刻,线程在 Core 2 上运行,读取了较小的 tsc2

此时计算的 TSC 差值甚至成为了正数,无奈换算为耗时。

为了解决这个问题,TiKV 会同步各个外围的原始 TSC 值,计算出 TSC 值在各个外围的偏移量,应用同步过后的 TSC 值用于计算耗时。具体算法为在各个外围上任取两次 TSC 和物理工夫,以物理工夫作为 x 轴、外围上的 TSC 作为 y 轴计算截距,差值即为各个外围的 TSC 偏移,如下图所示:

在计算初始 TSC 偏移时,须要确保取两次 TSC 的过程全都同一外围上执行。在 Linux 中能够通过零碎调用 sched_setaffinity 设置线程的亲核性,将线程固定到某个外围上运行:

fn set_affinity(cpuid: usize) -> Result<(), Error> {use libc::{cpu_set_t, sched_setaffinity, CPU_SET};
   use std::mem::{size_of, zeroed};
 
   let mut set = unsafe {zeroed::<cpu_set_t>() };
   unsafe {CPU_SET(cpuid, &mut set) };
 
   // Set the current thread's core affinity.
   if unsafe {
       sched_setaffinity(
           0, // Defaults to current thread
           size_of::<cpu_set_t>(),
           &set as *const _,
       )
   } != 0
   {Err(std::io::Error::last_os_error().into())
   } else {Ok(())
   }
}

有了各个外围的 TSC 偏移值后,在计时阶段只需获取以后执行线程所在的 CPU 及 TSC 值,即可计算出同步后的 TSC 值。须要留神的是,以后执行所在的 CPU 及以后的 TSC 值须要在一条指令中同时获取,防止其中插入操作系统的线程调度导致计算错误。这能够 通过 RDTSCP 指令实现。它能够帮忙咱们原子性地获取原始 TSC 值和 CPU ID。Rust 代码如下:

#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
fn tsc_with_cpuid() -> (u64, usize) {#[cfg(target_arch = "x86")]
   use core::arch::x86::__rdtscp;
   #[cfg(target_arch = "x86_64")]
   use core::arch::x86_64::__rdtscp;
 
   let mut aux = std::mem::MaybeUninit::<u32>::uninit();
   let tsc = unsafe {__rdtscp(aux.as_mut_ptr()) };
   let aux = unsafe {aux.assume_init() };
 
   // IA32_TSC_AUX are encoded by Linux kernel as follow format:
   //
   // 31       12 11      0
   // [node id][cpu id]
   (tsc, (aux & 0xfff) as usize)
}

上文形容的高精度计时的逻辑曾经提取成一个独立的 Rust 社区库 minstant,可供类似需要的其余我的项目间接应用。

Span 收集

Span 可能在各个线程上产生,最终要收集起来汇聚成一个追踪,因而须要跨线程的 Span 收集机制。Span 的收集也是追踪库的一个常见性能瓶颈点

个别有以下形式进行线程平安的 Span 收集:

  • Arc<Mutex<Vec<Span>>>
  • std::sync::mpsc::Receiver<Span>
  • crossbeam::channel::Receiver<Span>

这几种常见的收集形式中 crossbeam channel 是最优的,发送和收集一次 Span 的耗时约为 40ns。为了在晋升性能,TiKV 采纳了与上述不同的形式收集 Span:同一线程上 Span 仅 在线程本地无竞争地收集 、最终会集各个线程上曾经 收集好的一批 Span 到全局收集器

Local Span

TiKV 为每个线程保护一个线程本地构造 LocalSpanLine,负责 LocalSpan 的产生和存储。再由另外一个线程本地构造 LocalCollector,负责驱动 LocalSpanLine 和收集 LocalSpan。这三者之间的关系和各自的职责如下图。

因为 LocalSpan、LocalSpanLine 和 LocalCollector 均是线程本地的,它们之间的交互均 不须要线程间的同步和互斥,也不会毁坏内存缓存,因而性能极高。LocalSpan 的收集是简略的 Vec::push 操作,均匀消耗仅为 4ns。

另外,在结构 Span 依赖关系时,利用线程本地的个性能够很不便地实现 隐式上下文 的机制,用户无需批改函数签名来手动传递追踪上下文,大大降低了对现有代码的侵入性。

上面咱们来深刻理解对于 LocalSpan 产生和收集的实现细节。

首先,LocalSpanLine 保护了一个容器 SpanQueue,用于装载正在进行的或者曾经实现的 LocalSpan。“正在进行”意味着 LocalSpan 所批示的事件开始工夫已知,而完结工夫未知。这些 LocalSpan 均存储在 SpanQueue 外部的 Vec 构造。

除此之外,上文提到咱们利用隐式上下文来结构 LocalSpan 之间的父子依赖关系,这个过程实际上依赖于 SpanQueue 保护的一个变量 next_parent_id

接下来咱们将通过一些例子对整个过程进行更为具体的开展。

假如这样一个 foo 事件,于 09:00 产生,继续至 09:03:

09:00  foo +
09:01      |
09:02      |
09:03      +

初始状态下,SpanQueue 为空,next_parent_id 记为 root。那么在 foo 产生的时刻,即 09:00,SpanQueue 会去实现以下几个步骤:

  • 新增一条记录,填写事件名称 foo,起始工夫 09:00,留空完结工夫
  • next_parent_id 的值赋给 foo 的 parent
  • next_parent_id 更新为 foo
  • 向内部返回 index 的值 0,用以接管事件完结的告诉,进而实现后续完结工夫的回填

在 foo 完结的时刻,即 09:03,用户提交 index,向 SpanQueue 告诉 foo 事件完结,于是 SpanQueue 开始回填工作:

  • 通过 index 索引到 foo 事件所在记录
  • 将完结工夫回填为 09:03
  • next_parent_id 更新为该记录的 parent

以上的例子形容了单个事件的记录过程,很简略也很无效。而实际上多个事件的记录也仅仅只是上述过程的反复。比方上面的过程,foo 事件蕴含了两个子事件:bar 和 baz。

09:00  foo +
09:01      | bar +
09:02      |     |
09:03      |     +
09:04      |
09:05      | baz +
09:06      |     |
09:07      |     +
09:08      +

正如上文所述,SpanQueue 除了记录各个事件的起始和完结工夫,还须要记录各个事件之间的父子依赖关系。这个例子中,foo 产生时 SpanQueue 的存储内容和上文没有区别。而在 bar 产生时,SpanQueue 设置 bar 的 parent 为以后的 next_parent_id 值,即 foo,同时将 next_parent_id 更新为 bar:

在 bar 完结时,会依照下面提到的回填步骤,更新 bar 记录的完结工夫以及 next_parent_id 变量:

反复以上步骤,最终 SpanQueue 以一种高效的形式,残缺记录了这三个事件的信息:

将这些记录串连起来,最终造成如下的 Trace 树状构造:

Normal Span

尽管 LocalSpan 的记录比拟高效,然而因为其自身基于线程本地的实现形式,使得灵活性有余。比方在异步场景下,一些 Span 的产生和完结产生在不同的线程,线程本地的实现就不再能发挥作用。

针对上述问题,TiKV 保留了前文最开始所形容的线程平安的 Span 记录形式,即采纳 crossbeam channel 每次进行单个 Span 的收集,这样的 Span 下文称之为 NormalSpan。

从实现的角度看,NormalSpan 的信息不会记录在线程本地的容器当中,而是由相应的变量自行保护,以便于跨线程的挪动。同时,NormalSpan 之间的父子关系不再由线程本地隐式构建,而需由用户手动指定。

然而,NormalSpan 和 LocalSpan 并非齐全隔离,TiKV 通过以下的交互方式将这两者分割起来:从 LocalCollector 收集而来的一组 LocalSpan,能够挂载在 NormalSpan 上作为子树,如下图所示。同时,挂载的数量不受限制,通过容许进行多对多的挂载形式,TiKV 在肯定水平上反对了对 batch 场景的追踪,这是社区中大部分追踪库没有笼罩到的。

上述实现形式造成了 Span 收集的快慢两条门路。它们独特单干,实现对某个申请的执行门路信息的记录:

  • LocalSpan 不可逾越线程但记录高效,通过批量收集 LocalSpan 而后挂载至一般 Span 的形式,让追踪的开销变得非常低。
  • 一般 Span 的记录绝对较慢,不过它能够跨线程传递,应用起来比拟灵便。

应用办法

TiKV 中的高性能追踪的逻辑已提取成一个独立的库 minitrace-rust,可间接在各种我的项目中应用,步骤如下:

  1. 申请达到时,创立对应根 Span;
  2. 申请执行门路上,应用 minitrace-rust 提供的接口记录事件的产生;
  3. 申请实现时,收集执行门路上产生的所有 Span。

根 Span 的创立和收集

个别在一个申请开始的时候能够创立根 Span。在 minitrace-rust 中用法如下:

for req in listener.incoming() {let (root_span, collector) = Span::root("http request");
   let guard = root_span.enter();
   my_request_handler(req);
}

Span 基于 Guard 实现了主动在作用域完结后完结 Span,而无需手工标记 Span 的终止。除了返回根 Span 外,Span::root(event) 还返回了一个 CollectorCollector 与根 Span 一一对应。在申请实现时,可调用 Collectorcollect 办法,从而实现对执行门路上产生的所有 Span 的收集。如下所示。

let (root_span, collector) = Span::root("http request");
let guard = root_span.enter();
 
handle_http_request(req);
 
drop((guard, root_span));
let spans = collector.collect();

事件记录

比拟举荐应用 minitrace-rust 提供的 tracetrace_async 宏进行函数级别的事件记录。通过上述形式为单个函数记录的执行信息如下:

  1. 调用的产生时刻
  2. 调用的返回时刻
  3. 间接(或间接)调用者的援用
  4. 间接(或间接)调用的子函数的援用

例如,追踪两个同步函数 foobar,通过增加 trace(event) 作为这两个函数的 attribute,即可记录函数的执行信息。如下所示。

#[trace("foo")]
fn foo() -> u32 {bar();
   42
}
 
#[trace("bar")]
fn bar() {}

最终记录下来的信息,包含这两个函数各自的起始和实现时刻,以及函数调用关系:foo 调用了 bar

对于异步函数的记录,步骤略有不同。首先须将 trace 替换成 trace_async,如下所示。

#[trace_async("foo async")]
async fn foo_aysnc() -> u32 {bar_async().await;
   42
}
 
#[trace_async("bar async")]
async fn bar_async() {yield_now().await;
}

另外还须要要害的一步:将 Task 用 minitrace-rust 提供的 Future 适配器 in_span 进行包装,从而将该 Future 与某个 Span 绑定起来。

Task,在 Rust 异步语境中,特指被 spawn 至某个 executor 的 Future,也称根 Future。例如以下的 foo_async 就是一个 Task:

executor::spawn(foo_async()
);

假如要追踪 foo_async 这样一个 Task,并且与一个由 Span::from_local_parent(event) 创立的 Span 进行绑定,那么,相干的利用代码将如下所示。

executor::spawn(foo_async().in_span(Span::from_local_parent("Task: foo_async"))
);

下图为该 Task 追踪的后果:

结语

TiKV 作为底层 KV 数据库,对其减少观测性功能人造有着与一般业务程序齐全不一样的性能要求,十分具备挑战性。除了追踪以外,TiKV 及其下层 SQL 数据库 TiDB 也还有其余富裕挑战性的观测性需求。PingCAP 的 Observability 团队专一于这类观测难题的解决与性能实现,感兴趣的同学可投递简历到 hire@pingcap.com 退出咱们,或退出 Slack channel #sig-diagnosis 参加技术探讨。

正文完
 0