本博客基于结项时的项目报告,写于2021年9月中下旬,内容稍有落后。代码Merge前发布的英文博客中包含了更详细易懂的例子,如有需要请参考。
该项目来自于开源软件供应链点亮计划 - 暑期2021的Apache IoTDB - Apache SkyWalking适配器项目。插件的设计和开发工作都得到了IoTDB的黄向东老师(@jixuan1989)和SkyWalking的吴晟老师(@wu-sheng)的指导。感谢他们的指导和社区的帮助。目前项目已提交至SkyWalking社区(IoTDB storage plugin #7766),正在接受社区评审。
实现思路
项目目标是为SkyWalking开发一个使用IoTDB进行相关数据读写的适配器。可以参考SkyWalking目前已有的采用InfluxDB、H2和Elasticsearch的适配器。SkyWalking给出了存储拓展的开发指南,参考链接。此外,需要使用SkyWalking和InfluxDB的调试过程,以便充分了解SkyWalking的接口和使用方式。
IoTDB建议使用其提供的原生客户端封装: Session或Session Pool与IoTDB服务端进行交互。
开发平台:Win10,IoTDB版本:0.12.3
相关概念
SkyWalking的存储模型
SkyWalking 8.0+的存储模型大致分为4类:Record,Metrics,NoneStream,ManagementData。它们都实现了StorageData接口。StorageData接口必须实现id()方法,下面分别介绍4类存储模型:
-
Record:Record大部分是原始日志数据或任务记录。这些数据需要持久化,无需进一步分析。所有Record类的模型均具备time_bucket字段,用于记录当前Record所在的时间窗口。具体例子有:SegmentRecord,AlarmRecord,BrowserErrorLogs,LogRecord,ProfileTaskLogRecord, ProfileThreadSnapshotRecord,TopN。
- SegmentRecord:Trace Segment明细记录模型。由skywalking-trace-receiver-plugin插件接收并解析SkyWalking Agent发送来的链路数据后得到TraceSegment。
- AlarmRecord:报警明细记录模型。在指标触发报警规则时,会产生对应的报警明细数据模型。
- TopN:TopN是采样模型,具备statement字段(用于描述采样数据的关键信息),latency字段(用于记录采样数据的延迟),
trace_id字段(用于描述采样数据的关联分布式链路ID),service_id字段(用于记录服务ID)。目前采样模型默认只有TopNDatabaseStatement。- TopNDatabaseStatement:按照延迟排序的DB采样记录。
-
Metrics:Metrics表示统计数据,是通过OAL脚本或硬编码对源(Source)数据进行聚合分析后生成的存储模型。它的生命周期由TTL(生存时间)控制。所有Metrics类的模型均具备time_bucket和entity_id字段。例如:NetworkAddressAlias,Event,InstanceTraffic,EndpointTraffic, ServiceTraffic,EndpointRelationServerSideMetrics,ServiceInstanceRelationServerSideMetrics, ServiceInstanceRelationClientSideMetrics,ServiceRelationServerSideMetrics,ServiceRelationClientSideMetrics
-
NoneStream:NoneStream基于Record,支持time_bucket转换为TTL。例如:ProfileTaskRecord
-
ManagementData:UI模板管理相关的数据,默认只有一个UITemplate实现类
部分参考《Apache SkyWalking实战》第9章:SkyWalking OAP Server存储模型
IoTDB的数据模型
简单来说,可以用树结构来认识IoTDB的数据模型。如果按照层级划分,从高到低依次为:Storage Group
-> (LayerName
) -> Device
-> Measurement
。从最上层到其下某一层称为一条路径(Path),最上层是Storage Group
,倒数第二层是Device
,倒数第一层是Measurement
,中间可以有很多层,每一层姑且称之为LayerName
。更多信息请参考IoTDB v0.12.x的官方数据模型介绍。
值得注意的是,每个Storage Group需要一个线程,所以Storage Group过多会导致存储性能下降。此外,LayerName的值存储在内存中,而Measurement的值及其下的数据存储在硬盘中。
SkyWalking的IoTDB-adapter存储方案
概念划分
SkyWalking的每个存储模型可以认为是一个Model,Model中包含了多个Column,每个Column中具备ColumnName和ColumnType属性,分别表示Column的名字和类型,每个名为ColumnName的Column下存储多个数据类型为ColumnType的Value。从关系型数据库的角度来看的话,Model即是关系表,Column即是关系表中的字段。
方案一:类似关系型数据库的存储方案(无法实现)
将SkyWalking的所有存储模型都写入IoTDB的一个存储组中,例如root.skywalking存储组。Model对应Device,Column对应Measurement。即SkyWalking的Database -> Model -> Column
对应到IoTDB的Storage Group -> Device -> Measurement
。该方案的IoTDB存储路径只有4层:root.skywalking.ModelName.ColumnName
。该方案的优点是逻辑清晰,实现难度较低,但由于数据都存储在硬盘上,查询效率相对较差。
验证结果:该方案无法实现
原因:部分存储接口需要实现group by entity_id的查询功能,但IoTDB只支持group by time。需要采用方案二并通过group by level来实现。
方案二:引入索引的存储方案
由于IoTDB的每个LayerName
存储于内存中,可以将其认为是一种索引,可以充分利用LayerName
的这个特性提高IoTDB的查询性能。
依然将SkyWalking的所有存储模型都写入IoTDB的一个存储组中,例如root.skywalking
存储组,它会占据path的前两层。Model对应一个LayerName
,它会占据path的第三层,相当于root.skywalking.model_name
。需要索引的Column也对应于LayerName
,不过LayerName
并不存储ColumnName,而是存储对应的Value,相当于需要索引的一个Column的不同Value存储在同一分支下的同一层。不需要索引的Column依然对应Measurement
。最终来自同一个Model的数据被分散到多个Device
中。
SkyWalking有其自己的索引要求,但它们并不适用于IoTDB。考虑到查询频率和其它存储插件的实现,我们选择id
, entity_id
, node_type
, service_id
, service_group
, trace_id
作为索引,并固定它们的出现顺序。如果不这样做,我们就不能将它们的value映射到对应的column,因为该方案丢失了需要索引的ColumnName并且直接将它们的value存储在path中。
SkyWalking数据模型到IoTDB数据模型的映射关系如下:
SkyWalking | IoTDB |
---|---|
Database | Storage Group(1st and 2nd layer of the path) |
Model | LayerName(3rd layer of the path) |
Indexed Column | stored in memory through hard-code |
Indexed Column Value | LayerName(after 3rd layer of the path) |
Non-indexed Column | Measurement |
Non-indexed Value | the value of Measurement |
该方案的IoTDB存储路径长度是不定的,索引的Column越多,路径的长度越长。一个较一般的例子如下:
当前有model1(column11, column12),model2(column21, column22, column23),model3(column31),下划线说明该字段需要索引。
- 需要具备索引的Model:
root.skywalking.model1_name.column11_value.column12_name
root.skywalking.model2_name.column21_value.column22_value.column23_name
- 不需要具备索引的Model:
root.skywalking.model3_name.column31_Name
插入:
-- 插入model1
insert into root.skywalking.model1_name.column11_value values(timestamp, column12_value);
-- 插入model2
insert into root.skywalking.model2_name.column21_value.column22_value values(timestamp, column23_value);
-- 插入model3
insert into root.skywalking.model3_name value(timestamp, column31_value);
查询:
-- 查找model1的所有数据
select * from root.skywalking.model1_name align by device;
-- 查找model2中column22_value="test"的数据
select * from root.skywalking.model2_name.*.test align by device;
-- 按照column21分组统计model2中column23的和
select sum(column23) from root.skywalking.model2_name.*.* group by level = 3;
已确定采用索引的字段和它们的顺序
id
entity_id
node_type
service_group
service_id
trace_id
此外,可以将SkyWalking Model中的time_bucket转换为IoTDB的时间戳timestamp后进行存储,而无需另行存储time_bucket
该方案的优点是实现了索引功能,类似InfluxDB的tag,但逻辑较复杂,实现难度较大。另一方面还需进一步确定哪些Column需要作为索引列,这一点可以参考Elasticsearch(StorageEsInstaller),InfluxDB(TableMetaInfo),MySQL(MySQLTableInstaller)的实现,以及ModelColumn的isStorageOnly属性。
该方案将部分数据通过LayerName存储在内存中,在海量数据的情况下可能会导致内存开销较大。此外,由于同一个Model的数据被分散到了多个Device中,所以查询往往需要跨多个Device进行查询。但在这一方面,IoTDB对于跨Device的聚合查询、排序查询、分页查询的支持还不够完善,在一些情况下需要使用暴力法将所有符合条件的数据都查出来,然后再自行实现聚合、排序、分页的功能。具体描述可参考下文在IoTDB社区提交的Issue和Discussion。
- Issue:
- Discussion:
方案的性能测试
参考来自开源软件供应链点亮计划 - 暑期2021的兼容InfluxDB协议或客户端项目的设计文档,可以看到,使用索引的情况和不使用索引的情况在查询时间上有数倍的差距。
SkyWalking-IoTDB适配器的设置参数
- host,IoTDB主机IP,默认
127.0.0.1
- rpcPort,IoTDB监听端口,默认
6667
- username,用户名,默认
root
- password,密码,默认
root
- storageGroup,存储组名称,默认
root.skywalking
- sessionPoolSize,SessionPool大小,默认
16
- fetchTaskLogMaxSize,在一次请求中获取的TaskLog数量的最大值,默认
1000
方案的实施
首先,我的开发遵循SkyWalking官方的官方指南:Storage extension development guide.
当oap启动时,IoTDB存储插件将model的元信息通过IoTDBTableMetaInfo
保存在内存中,同时转换将它们的结构以满足我们的设计。对于每个model,用List<String> indexes
来记录它的需要索引的ColumnName,用Map<String, TSDataType> columnAndTypeMap
来记录其它的ColumnName以及它们的TSDataType。
当插入数据时,IoTDB存储插件在IoTDBMetricsDAO
, IoTDBRecordDAO
, IoTDBNoneStreamDAO
和IoTDBManagementDAO
中设置或者转换timestamp,然后在IoTDBBatchDAO
中执行写入。我们使用IoTDBInsertRequest
来封装所有的插入和更新请求。它会使用来自IoTDBTableMetaInfo
的信息构建index和indexValue的映射,以及measurement和measurementType,measurementValue的映射。同时它会转换那些非法值。此外,因为一些Record Model有可变化数量的tag,所以我们有时会在IoTDBRecordDAO
中修改IoTDBInsertRequest
。这些tag被处理为measurement
。
当查询请求到来时,IoTDB存储插件会在QueryDAO
或IoTDBMetricsDAO
执行它们。如上所述,因为我们的设计使得来自同一个model的数据分散在多个Device
中,并且IoTDB对于跨Device
的聚合、排序、分页查询支持不够好,所以有些查询不得不使用暴力法。所以查询效率可能不高。
遇到的问题及解决方案
问题1:SkyWalking部分存储接口要求
order by
查询,但IoTDB仅支持order by time
。这本来可以使用选择函数top_k
和bottom_k
来过滤数据。但在使用索引方案的情况下,由于数据点分散在多个device中,top_k
和bottom_k
函数无法过滤数据,也无法使用类似group by level
的合并device的查询方法,具体的描述可参考:Discussion #3888, Issue #3905
- 解决方案:目前除暴力法全部查询出来再排序以外暂无其他解决方案。
问题2:SkyWalking部分存储接口要求
group by entity_id
的分组聚合查询,但IoTDB仅支持group by time
和group by level
- 解决方案:采用方案二,并将entity_id作为LayerName存储,通过group by level实现分组查询。
问题3:在使用索引方案的情况下,由于数据点分散在多个device中,聚合函数的使用必须要通过
group by level
才能正确统计数据。但在此情况下,group by level
和where
同时使用得不到预期的结果。应该要加上align by device
语义才行,但加上以后就会引起IllegalPathException,推测是因为IoTDB不能同时支持group by level
和align by device
的语义。具体描述可以参考Discussion #3907
- 解决方案:运用
align by device
和where
过滤查询所有数据后自行实现聚合函数的功能。
问题4:SkyWalking的存储接口要求模糊查询,但IoTDB使用like的模糊查询并不支持跨device查询,不适用于方案二。此外,IoTDB的字符串函数string_contains只能返回true/false,并不会对数据进行过滤。具体描述可以参考:Issue #3945
问题5:SkyWalking的存储接口要求模糊查询和过滤查询。由于采用了索引方案,所以需要跨device查询,但使用
string_contains
函数的过滤查询对多个device之间采用了and的语义,无法正确过滤数据,如果是or的语义应该就可以正确过滤数据了。同时string_contains
也不支持使用align by device
。以上这种情况仅支持对time的过滤。
- 解决方案:最初使用
select *, string_contains from root.skywalking.xxx.* where time > ?
获得结果后自行对其他条件过滤。后来采用类似问题4的解决方案,直接使用类似MySQL的模糊查询和过滤查询即可。
总结
当前的存储方案还存在使用暴力法进行查询的情况,存储插件的性能可能存在问题,目前缺乏测试。暂时还没有好的方法可以解决,要等后续IoTDB针对这类场景增加不同语义的关键字或者完善现有的查询语义才行。