所谓全链路可观测性,即每次业务请求中都有唯一的能够标记这次业务完整的调用链路,我们称这个ID为RequestId。而每次链路上的调用关系,类似于树形结构,我们将每个树节点上用唯一的RpcId标记。
如图,在入口应用App1上会新建一个随机RequestId(一个类似UUID的32位字符串,再加上生成时的时间戳)。因它为根节点,故RpcId为“1”。在后续的RPC调用中,RequestId通过SOA框架的Context传递到下一节点中,且下一节点的层级加1,变为形如“1.1”、“1.2”。如此反复,同一个RequestId的调用链就通过RpcId还原成一个调用树。
也可以看到,“全链路可观测性的实现”不仅依赖与ETrace系统自身的实现,更依托与公司整体中间件层面的支持。如在请求入口的Gateway层,能对每个请求生成“自动”新的RequestId(或根据请求中特定的header信息,复用RequestId与RpcId);RPC框架、Http框架、Dal层、Queue层等都要支持在Context中传递RequestId与RpcId。
ETrace Api示例在Java或Python中提供链路埋点的API:
/*
记录一个调用链路
/
transaction trasaction = Trace.newTransaction(String type, String name);
// business codes
transaction.complete();
/*
记录调用中的一个事件
/
Trace.logEvent(String type, String name, Map<String,String> tags, String status, String data)
/*
记录调用中的一个异常
/
Trace.logError(String msg, Exception e)
Consumer的设计细节
Consumer组件的核心任务就是将链路数据写入存储。主要思路是以RequestId RpcId作为主键,对应的Data数据写入存储的Payload。再考虑到可观测性场景是写多读少,并且多为文本类型的Data数据可批量压缩打包存储,因此我们设计了基于HDFS HBase的两层索引机制。
如图,Consumer将Collector已压缩好的Trace数据先写入HDFS,并记录写入的文件Path与写入的Offset,第二步将这些“索引信息”再写入HBase。特别的,构建HBase的Rowkey时,基于ReqeustId的Hashcode和HBase Table的Region数量配置,来生成两个Byte长度的ShardId字段作为Rowkey前缀,避免了某些固定RequestId格式可能造成的写入热点问题。(因RequestId在各调用源头生成,如应用自身、Nginx、饿了么网关层等。可能某应用错误设置成以其AppId为前缀RequestId,若没有ShardId来打散,则它所有RequestId都将落到同一个HBase Region Server上。)
在查询时,根据RequestId RpcId作为查询条件,依次去HBase、HDFS查询原始数据,便能找到某次具体的调用链路数据。但有的需求场景是,只知道源头的RequestId需要查看整条链路的信息,希望只排查链路中状态异常的或某些指定RPC调用的数据。因此,我们在HBbase的Column Value上还额外写了RPCInfo的信息,来记录单次调用的简要信息。如:调用状态、耗时、上下游应用名等。
此外,饿了么的场景下,研发团队多以订单号、运单号作为排障的输入,因此我们和业务相关团队约定特殊的埋点规则--在Transaction上记录一个特殊的"orderId={实际订单号}"的Tag--便会在HBase中新写一条“订单表”的记录。该表的设计也不复杂,Rowkey由ShardId与订单号组成,Columne Value部分由对应的RequestId RpcId及订单基本信息(类似上文的RPCInfo)三部分组成。
如此,从业务链路到全链路信息到详细单个链路,形成了一个完整的全链路排查体系。
Consumer组件的另一个任务则是将链路数据计算成指标。实现方式是在写入链路数据的同时,在内存中将Transaction、Event等数据按照既定的计算逻辑,计算成SOA、DAL、Queue等中间件的指标,内存稍加聚合后再写入时序数据库LinDB。
指标存储:LinDB 1.0应用指标的存储是一个典型的时间序列数据库的使用场景。根据我们以前的经验,市面上主流的时间序列数据库-- OpenTSDB、InfluxDB、Graphite--在扩展能力、集群化、读写效率等方面各有缺憾,所以我们选型使用RocksDB作为底层存储引擎,借鉴Kafka的集群模式,开发了饿了么的时间序列数据库--LinDB。
指标采用类似Prometheus的“指标名 键值对的Tags”的数据模型,每个指标只有一个支持Long或Double的Field。某个典型的指标如:
COUNTER: eleme_makeorder{city="shanghai",channel="app",status="success"} 45
我们主要做了一些设计实现:
- 指标写入时根据“指标名 Tags”进行Hash写入到LinDB的Leader上,由Leader负责同步给他的Follower。
- 借鉴OpenTSDB的存储设计,将“指标名”、TagKey、TagValue都转化为Integer,放入映射表中以节省存储资源。
- RocksDB的存储设计为:以"指标名 TagKeyId TagValueId 时间(小时粒度)“作为Key,以该小时时间线内的指标数值作为Value。
- 为实现Counter、Timer类型数据聚合逻辑,开发了C 版本RocksDB插件。
这套存储方案在初期很好的支持了ETrace的指标存储需求,为ETrace大规模接入与可观测性数据的时效性提供了坚固的保障。有了ETrace,饿了么的技术人终于能从全链路的角度去排查问题、治理服务,为之后的技术升级、架构演进,提供了可观测性层面的支持。
其中架构的几点说明1. 是否保证所有可观测性数据的可靠性?
不,我们承诺的是“尽可能不丢”,不保证100%的可靠性。基于这个前提,为我们设计架构时提供了诸多便利。如,Agent与Collector若连接失败,若干次重试后便丢弃数据,直到Collector恢复可用;Kafka上下游的生产和消费也不必Ack,避免影响处理效率。
2. 为什么在SDK中的Agent将数据发给Collector,而不是直接发送到Kafka?
- 避免Agent与Kafka版本强绑定,并避免引入Kafka Client的依赖。
- 在Collector层可以做数据的分流、过滤等操作,增加了数据处理的灵活性。并且Collector会将数据压缩后再发送到Kafka,有效减少Kafka的带宽压力。
- Collector机器会有大量TCP连接,可针对性使用高性能机器。
3. SDK中的Agent如何控制对业务应用的影响?
- 纯异步的API,内部采用队列处理,队列满了就丢弃。
- Agent不会写本地日志,避免占用磁盘IO、磁盘存储而影响业务应用。
- Agent会定时从Collector拉取配置信息,以获取后端Collector具体IP,并可实时配置来开关是否执行埋点。
4. 为什么选择侵入性的Agent?
选择寄生在业务应用中的SDK模式,在当时看来更利于ETrace的普及与升级。而从现在的眼光看来,非侵入式的Agent对用户的集成更加便利,并且可以通过Kubernates中SideCar的方式对用户透明部署与升级。
5. 如何实现“尽量不丢数据”?
- Agent中根据获得的Collector IP周期性数据发送,若失败则重试3次。并定期(5分钟)获取Collector集群的IP列表,随机选取可用的IP发送数据。
- Collector中实现了基于本地磁盘的Queue,在后端的Kafka不可用时,会将可观测性数据写入到本地磁盘中。待Kafak恢复后,又会将磁盘上的数据,继续写入Kafka。
6. 可观测性数据如何实现多语言支持?
Agent与Collector之间选择Thrift RPC框架,并定制整个序列化方式。Java/Python/Go/PHP的Agent依数据规范开发即可。
2.0:异地多活,大势初成2016年底,饿了么为了迎接业务快速增长带来的调整,开始推进“异地多活”项目。新的多数据中心架构对既有的可观测性架构也带来了调整,ETrace亦经过了一年的开发演进,升级到多数据中心的新架构、拆分出实时计算模块、增加报警功能等,进入ETrace2.0时代。