Dapper Dapper 感觉最早是 Google 的追踪系统。实际系统的链路会非常长,从上游发起一个搜索,到下游运行,下游可能又是个分布式请求。如果最终结果是上游出现满请求/问题,需要能够找到对应的选项。本身请求的监控是有用的,通过监控,开发者能够知道每个阶段大概的耗时。但对于偶现的查询,可能无法找到对应的耗时来源,需要具体的请求-阶段耗时。
这里还有个问题,就是定位到的结果也要分锅什么的,慢只是一个结果。可能某个系统多租户没做好,然后来个傻逼用户打一堆 AP 请求过来,把别的地方的请求搞慢了。这些地方也要根据 tracing 之类的来分锅。
竟然要分锅,这里暗含了几件事:
这个系统对性能的影响,应该要在预期之内,不能太大
可能可以全打,或者精细的按照一些配置来输出
可能可以 hack 进一些基本的工具,包括 rpc、runtime 等(作者说不需要手工编码的部分,强调了对应用的透明,但我感觉为了一些细粒度的排查,还是需要的吧)
数据模型 一个调用的链路如下文所示:
基本的思路是,这里需要有一些 context (大概可以用类似 snowflake 之类的方式生成):
跟踪标识符(message identifiers)
时间戳(timestamped events)。
这里是单个的跟踪关系,dapper 这里非常有意思,考虑了层次的/并发的跟踪关系:
如上,这里其实是有一个(多个) Span 对象的,Root 本身构建了一个 Span 对象,然后调用下层的时候也会起 Span。这里可以有一个很显著的事实:Span 是可以跨机器的。
还有个很奇怪的问题是时间戳的偏移,这里本身靠请求发出的时间 > 请求收到的时间保证。(TODO:不过感觉这个是个特别工程的问题了,可以看看这些系统是具体怎么实现的了)。
Dapper 在使用的时候,可能会被丢到 Thread Local Storage 系统里,或者和某个 Context 对象绑定,具体而言:
这个 context 开销尽量小,然后可能可以丢到 TLS 或者某个 context 参数里
在异步调用、起别的线程的时候,能够正确处理它们的所有权
这个时候还要注意到一点,这些大公司可能用的是一套 RPC,RPC 可能又会定义一些 runtime 之类的,trace 得很好的和他们做到一起。这里还可以加入一些 annotation,注入一些标记:
这个我感觉就依赖一些奇怪的内存申请了。论文里还提到可以存 key-value, counter,不过我觉得就是一个多模/语义的问题了。
数据收集
这里分为三个阶段:
log 到本地文件
Dapper 的 deamon 把它弄到某个 collector 里面
最后写到 BigTable 里面,对 模型为 trace_id/span/span_cnt
同时,这里 latency 从 15s - 2min,不算很延迟敏感。这里还有一些重要数据要走 OOB,我个人感觉这里暗示了有一些 tracing 可以是「重要的」。
上图也表明,tracing 大概包括:
客户端和对应的 Dapper daemon
Collectors
一个时序的库,用来存储这样的数据
Tracing 的开销和应对 毕竟要在线上系统跑,肯定要考虑性能问题,考虑 Root Span 需要分配 unique id ,论文描述:
根span的创建和销毁需要损耗平均204纳秒的时间,而同样的操作在其他span上需要消耗176纳秒
如果一个span没有被采样的话,那么这个额外的span下创建annotation的成本几乎可以忽略不计,他由在Dapper运行期对ThreadLocal查找操作构成,这平均只消耗9纳秒。如果这个span被计入采样的话,会用一个用字符串进行标注—在图4中有展现—平均需要消耗40纳秒。这些数据都是在2.2GHz的x86服务器上采集的。
在Dapper运行期写入到本地磁盘是最昂贵的操作,但是他们的可见损耗大大减少,因为写入日志文件和操作相对于被跟踪的应用系统来说都是异步的。
Dapper 将自己的进程设置为 Linux 调度最低的优先级,然后在实践中,占用资源一般非常非常低。
同时,采样率对性能会产生影响,内容如下:
Dapper 能够可变的、自适应的、aggressive 的采样。上层还支持了一些二级采样,继续 discard 一些数据。
百度大搜的一些方案 如果你真的查过一些问题,你就会知道,Dapper 很好,但是很多东西你会希望更多的日志、更高的自由度、必要的时候有一些定制的信息,这里可以参考百度大搜的方案:
https://mp.weixin.qq.com/s/BMbdk5RviLG1Ftlo-qRsDQ
https://mp.weixin.qq.com/s/IHVUnyhJr4fhiopMLOJqjA
这个方案相对来说可能和开放方案不是 100% 对齐。里面对数据的可追踪性做了考量,我认为,有意思的地方在于:
希望开启根据某些特征采样的时候,一定能开启
支持全量的采样
我觉得这两点还是比较重要的
采样的一些讨论 上述 baidu 的系统通过一些资源开销完成了完全的采样. 关于 Dapper 本身的采样,可以看 <Uncertainty in Aggregate Estimates from Sampled Distributed Traces>,Google 在这篇论文对采样和概率做了量化描述。
Trace: 工业界实践 工业界有着不少的实践,或许最值得瞅一眼的入门是 Opentracing。有的体系过于庞大,我们会一步一步介绍这个系统是怎么构建的。
brpc tracing: 客户端实现的一个简单例子 介绍这玩意一方面是它是 Dapper 描述的客户端的一个很好的实现,另一方面我上班用的是 brpc… 出于私心瞅一眼。
入口可以看到 Collector 和 Span:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 class Span : public bvar::Collected {friend class SpanDB ; struct Forbidden {}; public : Span (Forbidden) {} ~Span () {} private : uint64_t _trace_id; uint64_t _span_id; uint64_t _parent_span_id; uint64_t _log_id; bthread_id_t _base_cid; bthread_id_t _ending_cid; butil::EndPoint _remote_side; SpanType _type; bool _async; ProtocolType _protocol; int _error_code; std::string _info; Span* _local_parent; Span* _next_client; Span* _tls_next; };
我们首先关注一下这个类型的成员.
Channel::CallMethod 里面,描述了作为 client 发送请求的对应 Span 和行为,调用了 Span::CreateClientSpan。
这里接收一个请求的时候,会初始化它(以 HTTP Server 为例):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 Span* span = NULL ; const std::string& path = req_header.uri ().path ();const std::string* trace_id_str = req_header.GetHeader ("x-bd-trace-id" );if (IsTraceable (trace_id_str)) { uint64_t trace_id = 0 ; if (trace_id_str) { trace_id = strtoull (trace_id_str->c_str (), NULL , 10 ); } uint64_t span_id = 0 ; const std::string* span_id_str = req_header.GetHeader ("x-bd-span-id" ); if (span_id_str) { span_id = strtoull (span_id_str->c_str (), NULL , 10 ); } uint64_t parent_span_id = 0 ; const std::string* parent_span_id_str = req_header.GetHeader ("x-bd-parent-span-id" ); if (parent_span_id_str) { parent_span_id = strtoull (parent_span_id_str->c_str (), NULL , 10 ); } span = Span::CreateServerSpan ( path, trace_id, span_id, parent_span_id, msg->base_real_us ()); accessor.set_span (span); span->set_log_id (cntl->log_id ()); span->set_remote_side (user_addr); span->set_received_us (msg->received_us ()); span->set_start_parse_us (start_parse_us); span->set_protocol (is_http2 ? PROTOCOL_H2 : PROTOCOL_HTTP); span->set_request_size (imsg_guard->parsed_length ()); }
可以看到,这个地方,如果没有给 trace_id , log_id 的话,这两个会是 0,那我们细看一下对应的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 Span* Span::CreateServerSpan ( const std::string& full_method_name, uint64_t trace_id, uint64_t span_id, uint64_t parent_span_id, int64_t base_real_us) { Span* span = butil::get_object <Span>(Forbidden ()); if (__builtin_expect(span == NULL , 0 )) { return NULL ; } span->_trace_id = (trace_id ? trace_id : GenerateTraceId ()); span->_span_id = (span_id ? span_id : GenerateSpanId ()); span->_parent_span_id = parent_span_id; span->_log_id = 0 ; span->_base_cid = INVALID_BTHREAD_ID; span->_ending_cid = INVALID_BTHREAD_ID; span->_type = SPAN_TYPE_SERVER; span->_async = false ; span->_protocol = PROTOCOL_UNKNOWN; span->_error_code = 0 ; span->_request_size = 0 ; span->_response_size = 0 ; span->_base_real_us = base_real_us; span->_received_real_us = 0 ; span->_start_parse_real_us = 0 ; span->_start_callback_real_us = 0 ; span->_start_send_real_us = 0 ; span->_sent_real_us = 0 ; span->_next_client = NULL ; span->_tls_next = NULL ; span->_full_method_name = (!full_method_name.empty () ? full_method_name : unknown_span_name ()); span->_info.clear (); span->_local_parent = NULL ; return span; }
这里有生成 Span 的逻辑,同时注意到,这个逻辑绑定在了 Runtime 的 bthread 上:
1 2 3 4 5 6 7 8 9 10 void EndAsParent () { if (this == (Span*)bthread::tls_bls.rpcz_parent_span) { bthread::tls_bls.rpcz_parent_span = NULL ; } } void AsParent () { bthread::tls_bls.rpcz_parent_span = this ; }
这里,它把 trace 上下文挂在了 thread local 的地方。那怎么创建子 Span 呢?内容如下:
1 2 3 4 5 6 7 if (parent) { span->_trace_id = parent->trace_id (); span->_parent_span_id = parent->span_id (); span->_local_parent = parent; span->_next_client = parent->_next_client; parent->_next_client = span; }
这里挂了一个 Span 的单链表。
那 Annotation 呢?其实很简单:
1 2 3 4 5 6 void Span::Annotate (const char * fmt, va_list args) { const int64_t anno_time = butil::cpuwide_time_us () + _base_real_us; butil::string_appendf (&_info, BRPC_SPAN_INFO_SEP "%lld " , (long long )anno_time); butil::string_vappendf (&_info, fmt, args); }
这里会更新格式,然后丢到 _info 对象里。
我们再看看它是怎么异步析构的:
1 2 3 4 5 6 7 8 9 10 11 12 void Span::destroy () { EndAsParent (); Span* p = _next_client; while (p) { Span* p_next = p->_next_client; p->_info.clear (); butil::return_object (p); p = p_next; } _info.clear (); butil::return_object (this ); }
ok,此外,这里可能会造一个 LevelDB 对象,然后有一个 Collector 定期把这些数据收集到 LevelDB.
那采样的逻辑呢?看看这里:
1 2 3 4 5 6 7 8 inline bool IsTraceable (bool is_upstream_traced) { extern bvar::CollectorSpeedLimit g_span_sl; return is_upstream_traced || (FLAGS_enable_rpcz && bvar::is_collectable (&g_span_sl)); }
这里通过 CollectorSpeedLimit 这个限流器,完成了「低访问不采样,高访问采样」这样的逻辑。
tokio tracing Tokio Tracing 的文档见:https://tracing.rs/tracing/ . 其实它处理的也是一个 runtime 的问题。因为
The span in which a thread is currently executing is referred to as that thread’s current span.
同时它也处理了一些 field tag 什么的,原论文里面说自己能处理 kv pair 写到 BigTable 什么的,我觉得这里差不多。
tracing-core 实现了一些基础的语义,包括 Span 元信息 MetaData,和 event (记录消息,开 Span 退出 Span 在 tokio-tracing 里面都叫做 event),大致内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 #[derive(Debug)] pub struct Event <'a > { fields: &'a field::ValueSet<'a >, metadata: &'static Metadata<'static >, parent: Parent, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct Id (NonZeroU64);#[derive(Debug)] pub struct Current { inner: CurrentInner, } #[derive(Debug)] enum CurrentInner { Current { id: Id, metadata: &'static Metadata<'static >, }, None , Unknown, } #[derive(Debug)] pub (crate ) enum Parent { Root, Current, Explicit (Id), }
上面是需要的元数据,包括 Parent,Current . 还有一部分重要内容是 Dispatch,文档可见: https://tracing.rs/tracing_core/struct.dispatch
Every thread in a program using tracing has a default collector . When events occur, or spans are created, they are dispatched to the thread’s current collector.
dispatch 内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #[derive(Clone)] pub struct Dispatch { #[cfg(feature = "alloc" )] collector: Kind<Arc<dyn Collect + Send + Sync >>, #[cfg(not(feature = "alloc" ))] collector: &'static (dyn Collect + Send + Sync ), } #[cfg(feature = "alloc" )] #[derive(Clone)] enum Kind <T> { Global (&'static (dyn Collect + Send + Sync )), Scoped (T), } #[cfg(feature = "std" )] thread_local! { static CURRENT_STATE: State = State { default: RefCell::new (Dispatch::none ()), can_enter: Cell::new (true ), }; }
这部分有个 thread_local 的 State 内容,表示本线程对应的 tracing 上下文。
下面我们看看 Span 具体是怎么实现的,这部分在 tracing 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 #[derive(Clone)] pub struct Span { inner: Option <Inner>, meta: Option <&'static Metadata<'static >>, } #[derive(Debug)] pub (crate ) struct Inner { id: Id, collector: Dispatch, }
这里用 DefaultGuard 等内容,很 RAII 的做了一下它们生命周期的变化
让我们来看看 Span 的 Enter 和 Exit
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 #[inline] fn do_enter (&self ) { if let Some (inner) = self .inner.as_ref () { inner.collector.enter (&inner.id); } if_log_enabled! { crate::Level::TRACE, { if let Some (_meta) = self .meta { self .log (ACTIVITY_LOG_TARGET, log::Level::Trace, format_args! ("-> {}" , _meta.name ())); } }} } #[inline] fn do_exit (&self ) { if let Some (inner) = self .inner.as_ref () { inner.collector.exit (&inner.id); } if_log_enabled! { crate::Level::TRACE, { if let Some (_meta) = self .meta { self .log (ACTIVITY_LOG_TARGET, log::Level::Trace, format_args! ("<- {}" , _meta.name ())); } }} }
看,这里调用了 tracing-core 的内容。OK,那最后一个问题,这里都是 thread-local,那么 tokio 呢?这里可以看 span 的 enter 注释,instrument 和 Span::in_scope
minitrace https://github.com/tikv/minitrace-rust
这个项目不力图完善,而是力图快,用 tsc 寄存器等方式大幅度优化了 trace 的性能。比较值得再提的是 ministant: https://github.com/tikv/minstant
Jaeger & OpenTelemetry(OpenTracing) OpenTracing 通过提供了 API 标准,基本上算是做了很不错的事情:https://github.com/opentracing/specification/blob/master/specification.md
Jaeger 提供了下面 的架构(使用 Kakfa or Direct-to-storage):
这里的 DB 可能还需要一些时序/多模之类的需求: 本质上这个和存储日志差不多,半结构化的数据、最近的数据需要高效查询 etc…
参考 [1] https://bigbully.github.io/Dapper-translation/
[2] https://mp.weixin.qq.com/s/BMbdk5RviLG1Ftlo-qRsDQ
[3] https://mp.weixin.qq.com/s/IHVUnyhJr4fhiopMLOJqjA
[4] https://zhuanlan.zhihu.com/p/34318538
[5] https://www.jaegertracing.io/docs/1.31/architecture/
[6] https://tracing.rs/tracing/
[7] Dapper, a Large-Scale Distributed Systems Tracing Infrastructure