存储查询 DAO 实现

这一篇解决什么
05-存储层 讲写入侧。这一篇讲查询侧:同一个"读指标"接口,BanyanDB / ES / JDBC 三个后端各用什么机制实现?BanyanDB 用 measure 倒排 + TopN 预聚合 + 原生 trace span 树;ES 用 multi-get 点查 + terms/avg 查询期聚合 + routing;JDBC 用主键 IN + SQL GROUP BY + tag 表 join。读完应该能回答:为什么 BanyanDB 查询最快、ES 次之、JDBC 最慢——以及这三套差别背后各自在押注什么。


为什么三种存储的查询实现差别这么大

先把结论摆出来,再逐个看实现。

三个后端不是"同一种查询的三种写法",而是三种存储哲学在三套查询语义上的投影。差别根源有三层:

  1. 数据是否原生支持时序预聚合。指标的本质是"每个时间桶、每个实体一个值"。BanyanDB 把这做成了一等公民(measure 模型,写入即按 (time_bucket, entity_id) 聚合),查询只是按 entity_id 倒排查一段时间的点。ES 和 JDBC 没有时序原生模型,靠把 (time_bucket, entity_id) 当作文档 ID / 主键来"伪装"成点查——查询时算出期望的几个桶 ID,直接按 ID 取回,不走聚合。这是三者指标查询性能接近、且都不在查询期聚合的原因。

  2. TopN 这类"现算"查询,谁来算、在哪算。BanyanDB 写入时就按 bydb-topn.yaml 规则把"最忙的 N 个实体"算好存起来,查询读预聚合结果;没有匹配规则时再退化到查询期 meanBy + topN。ES 没有预聚合,靠 terms + avg 聚合引擎现算。JDBC 也没有,还要按天分表,每张表各自取 TopN 再在 Java 端合并求平均——既慢又不精确。这一层拉开三档差距。

  3. trace 这种"树形关联"数据,存成什么样。一条 trace 由多个 span 组成,span 间有父子关系。BanyanDB 把 trace 当一等公民,span 嵌套存在 trace 记录里,一次查询出整棵树。ES 和 JDBC 没有 trace 原生模型,每个 segment(一段)是独立文档 / 行,靠 trace_id 字段关联,查一条 trace 要先捞所有 segment 再在应用层拼成树。这决定了 trace 查询的快慢和是否需要二次组装。

一句话:BanyanDB 是为 SkyWalking 量身造的时序+链路库,押注"贴近监控语义";ES 是租来的通用搜索引擎,押注"倒排成熟、能全文搜";JDBC 是拿关系库硬扛,押注"通用、好部署、不引新组件"。 性能差距就是这三个押注的后果。


几个术语,先说清楚

后面反复用到,新人不熟的话先看这里。

  • 倒排索引(inverted index):正常查询是"给一个文档,看它有哪些词";倒排是"给一个词,看哪些文档含它"。ES 和 BanyanDB 都给 service_idtrace_idtags 这类可过滤字段建倒排表,值→文档列表。term 查询就是查这张表,复杂度 O(匹配数),比扫全表快几个数量级。没有倒排的 JDBC 只能靠 B-tree 索引 + WHERE + join 模拟。

  • multi-get(多点查):一次请求按一组文档 ID / 主键取回多个文档。ES 的 getClient().ids(...)、JDBC 的 where id in (?, ?, ...) 都是 multi-get。适合"我已经知道要哪几个 ID,直接拿"的场景,不走聚合,代价 O(ID 数)。

  • GROUP BY 聚合:SQL 的 GROUP BY entity_id 把同 entity 的多行折叠成一组,配合 avg(value) 求每组均值、ORDER BY result DESC LIMIT N 取 TopN。这是关系库的标准聚合手段,代价是扫表 + 排序。

  • 预聚合 vs 查询期聚合:预聚合是写入时就按规则算好结果存起来,查询直接读;要预先配规则,查询极快但不灵活。查询期聚合是查询来了才扫数据算,灵活但慢,数据量大时贵。

  • routing(ES):ES 写文档时可用一个 routing key 决定文档落哪个分片。SkyWalking 写 trace 用 traceId 作 routing,查询时也传 traceId,ES 就只查一个分片而不是全部分片广播——对按 traceId 点查,省掉 N-1 个分片的无效扫描。

图1


查询层 DAO 抽象解决什么问题

查询层 DAO 抽象(server-core 里的 I*QueryDAO 接口)把"读监控数据"从具体存储引擎里剥出来。上游 GraphQL 查询服务(QueryModule)只认接口——“给我某指标在某时间段、按某实体的时序值”。至于这个值是靠 multi-get、ES terms 聚合、SQL GROUP BY 还是 BanyanDB measure 预聚合算出来,由各存储插件自己决定。这让 SkyWalking 不改查询业务逻辑,就能把同一套 UI 查询落到 ES / BanyanDB / 关系库上。

前置说明(修正任务清单两处与源码不符的表述)

  • 接口名是 IRecordsQueryDAO(复数),源码中无 IRecordQueryDAOIMetricsQueryDAO没有 readLabeledValues,实际是 readLabeledMetricsValues / readLabeledMetricsValuesWithoutEntity
  • ES 查询 DAO 没有date_histogram 聚合。指标时序靠"文档 ID 多点查"实现,terms/avg 只用于 TopN 和拓扑。
  • PostgreSQL 特化 DAO 没有用 PG 的 jsonb/数组类型(仓库内全局 grep jsonb/array_agg/->>/unnest 零命中),只是 SQL 别名/子查询包装的方言适配。

查询型 DAO 接口总览(server-core 定义)

接口目录:oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/query/。这些接口都继承 DAO(空标记接口)或 Service。每个接口带一组 ...Debuggable 默认方法,包一层 DebuggingTraceContext 调试 span——查询层只关心它们最终委派的"真"方法。

接口清单与方法签名

IMetricsQueryDAO — 指标查询(最能体现三后端差异)

IMetricsQueryDAO.java:59。查询侧最核心、也最能体现三后端差异的接口。

方法 行号 语义
MetricsValues readMetricsValues(MetricsCondition, String valueColumnName, Duration) :101 读单实体单指标的时序值(每个时间桶一个值)
List<MetricsValues> readLabeledMetricsValues(condition, valueColumnName, List<KeyValue> labels, Duration) :105 读带标签的指标(一个指标多个 label 维度,值存在 DataTable 字符串列里)
List<MetricsValues> readLabeledMetricsValuesWithoutEntity(metricName, valueColumnName, labels, duration) :113 不指定实体,用来取 label 元数据(限 10 条,常量 METRICS_VALUES_WITHOUT_ENTITY_LIMIT=10 :60
List<String> listEntityIdsInRange(metricName, valueColumnName, valueType, duration, limit) :163 10.5.0 新增,列出某指标在时间区间内有过值的去重 entity_id,供 admin 的 /inspect/entities 用,按最近时间优先
HeatMap readHeatMap(condition, valueColumnName, duration) :169 读热力图(直方图指标,值是 HistogramMetrics.DATASET

内嵌 Util 类(:171)做 label 拼装/排序/补默认值,是跨后端共享的纯计算逻辑——三个后端都调它,避免重复实现。

IAggregationQueryDAO — TopN 排序查询

IAggregationQueryDAO.java:36。问"哪些实体最慢/最忙"。

方法 行号 ��义
List<SelectedRecord> sortMetrics(TopNCondition, String valueColumnName, Duration, List<KeyValue> additionalConditions) :56 按 value 列对实体排序取 TopN。接口注释明确:“基于存储侧聚合,大部分存储支持 groupby/aggregation”

ITraceQueryDAO / ITraceQueryV2DAO — 链路追踪查询

ITraceQueryDAO.java:35 / ITraceQueryV2DAO.java:31ITraceQueryV2DAO extends ITraceQueryDAO(V2 是 BanyanDB 用的新版,V1 是 ES/JDBC 用的旧版)。

方法 行号 语义
TraceBrief queryBasicTraces(duration, minDuration, maxDuration, serviceId, serviceInstanceId, endpointId, traceId, limit, from, TraceState, QueryOrder, List<Tag> tags) ITraceQueryDAO.java:114 按 trace 列表查询
List<SegmentRecord> queryByTraceId(String traceId, @Nullable Duration) :130 按 traceId 取该 trace 下所有 segment 记录。duration 注释明确"nullable unless for BanyanDB query from cold stage"
List<SegmentRecord> queryBySegmentIdList(List<String>, Duration) :135
List<SegmentRecord> queryByTraceIdWithInstanceId(List<String>, List<String>, Duration) :140
List<Span> doFlexibleTraceQuery(String traceId) :145 给"没有 segment 概念的第三方 trace"用
V2:List<SpanWrapper> queryByTraceIdV2(traceId, duration) ITraceQueryV2DAO.java:54
V2:TracesQueryResult queryTraces(TraceQueryCondition) :56 条件对象化、返回带 RetrievedTimeRange

ITopologyQueryDAO — 拓扑/调用关系查询

ITopologyQueryDAO.java:33。服务、实例、端点、进程四层关系,分 client/server 两端检测。

方法 行号 语义
loadServiceRelationsDetectedAtServerSide(Duration, List<String> serviceIds) / ...ClientSide(...) :190/:196
全局版(不带 serviceIds) :202/:207
loadInstanceRelationDetectedAtServerSide(clientServiceId, serverServiceId, Duration) / ...ClientSide(...) :213/:221
loadEndpointRelation(Duration, destEndpointId) :228
loadProcessRelationDetectedAtClientSide(serviceInstanceId, Duration) / ...ServerSide(...) :235/:242

ILogQueryDAO — 日志查询

ILogQueryDAO.java:40

方法 行号 语义
boolean supportQueryLogsByKeywords() :42 默认 false,只有 ES 翻成 trueLogQueryEsDAO.java:59),表示该后端支持按日志正文关键词检索
Logs queryLogs(serviceId, serviceInstanceId, endpointId, TraceScopeCondition relatedTrace, Order, from, limit, Duration, List<Tag> tags, List<String> keywordsOfContent, List<String> excludingKeywordsOfContent) :87 日志可关联 trace、按 tag 过滤、按正文关键词(含/排除)检索
默认方法 parserDataBinary(...) :102/:106 把 base64 的 LogTags protobuf 反序列化成 KeyValue 列表——跨后端共享

其他查询接口速览

接口 行号 语义
IAlarmQueryDAO IAlarmQueryDAO.java:49 告警查询,getAlarm(旧)+ queryAlarms(11.0.0 新,支持 entity/layer/ruleName 过滤)
IBrowserLogQueryDAO IBrowserLogQueryDAO.java:30 浏览器错误日志,queryBrowserErrorLogs
IEventQueryDAO IEventQueryDAO.java:27 事件查询,queryEventsMAX_SIZE = 100
ITagAutoCompleteQueryDAO ITagAutoCompleteQueryDAO.java:27 标签自动补全,queryTagAutocompleteKeys/Values
IHierarchyQueryDAO IHierarchyQueryDAO.java:26 层级关系查询,readAllServiceHierarchyRelations/readInstanceHierarchyRelations
IRecordsQueryDAO(复数) IRecordsQueryDAO.java:37 慢查询记录/TopN 采样记录,readRecords
IZipkinQueryDAO IZipkinQueryDAO.java:33 Zipkin 兼容查询,getServiceNames/getTrace/getTraces

接口层的共性设计

三个共性

  1. ...Debuggable 默认方法模式:每个查询方法都有一个同名 ...Debuggable 默认方法,先 DebuggingTraceContext.TRACE_CONTEXT.get(),开了调试就 createSpan 并把入参拼进 msg,再委派给真方法。调试埋点和业务实现解耦——子类只实现真方法,调试能力由接口默认方法统一注入。
  2. Duration 贯穿所有接口Duration 封装起止时间戳 + 步长(DownSampling:minute/hour/day)+ 冷数据标记(isColdStage())。所有时间范围查询走 Duration,不直接传 long 时间戳,便于三后端统一处理冷热分层。
  3. @Nullable Duration 的约定:trace 相关方法对 duration 标了 @Nullable,注释反复出现"nullable unless for BanyanDB query from cold stage"——只有 BanyanDB 冷阶段查询需要显式 duration,其余后端用默认最近 24h。

BanyanDB 查询实现

BanyanDB 是 SkyWalking 自研的时序数据库,查询 DAO 散落在 storage-banyandb-plugin 下按数据模型分包:measure/(指标类)、stream/(记录类:log/alarm/事件/profile)、trace/(原生 trace 模型,span 嵌套存储)、顶层 BanyanDBAggregationQueryDAO/BanyanDBRecordsQueryDAO

查询基础设施 AbstractBanyanDBDAO

oap-server/server-storage-plugin/storage-banyandb-plugin/.../stream/AbstractBanyanDBDAO.java:60。所有 BanyanDB 查询 DAO 的父类,封装四种查询类型:

方法 查询类型 用途 行号
query(...) / queryDebuggable(...)(StreamQuery) StreamQuery stream 模型查询(log/alarm/record) :73/:111
query(...) / queryDebuggable(...)(MeasureQuery) MeasureQuery measure 模型查询(指标) :271/:232
topNQuery(...) / topNQueryDebuggable(...) TopNQuery 服务端预聚合 TopN :196/:150
queryTrace(...) / queryTraceDebuggable(...) TraceQuery 原生 trace 模型查询 :349/:310

内嵌 QueryBuilder<T> 抽象类(:399)提供条件构造器:eq/ne/gt/gte/lte/in/notIn/match/having,以及 and(List)/or(List) 组合。其中 having(String name, List<String> value):406,底层 StringArrayQueryCondition.having)是 BanyanDB 专门用于"在标签数组里包含某 key=value"的原生算子——这是 BanyanDB 倒排索引查 tag 的方式,区别于 ES 的 termtags 字段。

getTimestampRange(Duration):478):duration==null 默认取最近 24 小时。

冷数据支持:每个查询类型都有 if (isColdStage) query.setStages(Set.of("cold"))(如 :99:298:359),把查询路由到 BanyanDB 冷阶段存储。

BanyanDBMetricsQueryDAO(measure 查询)

oap-server/server-storage-plugin/storage-banyandb-plugin/.../measure/BanyanDBMetricsQueryDAO.java:54。实现 IMetricsQueryDAO。所有方法都用 MeasureQuery

  • readMetricsValues:60):
    1. MetadataRegistry.INSTANCE.findMetricMetadata(modelName, step) 拿到 measure schema(:65,注释提到对 foreign 指标会合成只读 schema)。
    2. queryByEntityID(schema, valueColumnName, duration, entityID):261)发起 MeasureQuery,条件是 query.and(eq(Metrics.ENTITY_ID, entityID)):267)——按 entity_id 倒排过滤,project 出 valueColumnName 字段。
    3. 把返回的 DataPointtimeBucket 映射,对 duration.assembleDurationPoints() 期望的每个时间桶补默认值(:78-90)。

measure 查询不聚合
这里没有聚合。measure 在写入时已经按 (time_bucket, entity_id) 预聚合好,查询就是按 entity_id 倒排查一个时间段的点。BanyanDB 的 measure 本身就是"时序指标"语义,倒排索引让"按 entity 过滤"是 O(匹配数) 而非 O(全表)。

  • readLabeledMetricsValues:108):同样 queryByEntityID,但取出 DataTable 字符串列(:122),再交给跨后端共享的 Util.composeLabelValue/Util.sortValues 做 label 拆装。
  • listEntityIdsInRange:173):MeasureQuery project 出 ENTITY_ID tag + valueColumnNameOrderBy DESC:206)+ limit,返回后用 LinkedHashSet 去重保序(:217)。foreign 指标走 synthesizeForeignMetricSchema:183)。
  • readHeatMap:225):取 HistogramMetrics.DATASET 字段,heatMap.buildColumn 解析。

别混了 BanyanDBMetricsDAO 与 BanyanDBMetricsQueryDAO
BanyanDBMetricsDAOmeasure/BanyanDBMetricsDAO.java:58)实现的是 IMetricsDAO写入/读缓存侧multiGet/prepareBatchInsert),不是查询侧的 IMetricsQueryDAO。两者名字相似但职责不同——一个给 OAL 流处理 worker 读缓存写指标用,一个给 GraphQL 查询读指标展示用。新手最容易混这点。

BanyanDBAggregationQueryDAO(TopN:预聚合 + 查询期聚合双路径)

oap-server/server-storage-plugin/storage-banyandb-plugin/.../BanyanDBAggregationQueryDAO.java:51。实现 IAggregationQueryDAOsortMetrics:59)有两条路径

图2

路径 A:服务端预聚合 TopN(serverSideTopN :122
schema 里定义了匹配当前查询 tag 组合的 TopNAggregation:91-92,从 bydb-topn.yaml 配置,由 MetadataRegistry.parseTopNSpecs 解析,见 MetadataRegistry.java:330)时,走 topNQueryDebuggable:125)。这是 BanyanDB 服务端预先聚合好的 TopN,查询只读结果,极快。TopNQueryAggregation.Type.MEANAbstractBanyanDBDAO.java:208)。

路径 B:查询期聚合 TopN(directMetricsTopN :143
没有匹配的预聚合规则时,发 MeasureQuery

query.meanBy(valueColumnName, ImmutableSet.of(Metrics.ENTITY_ID));  // :154 按entity分组求均值
query.topN(condition.getTopN(), valueColumnName);  // 或 bottomN,:156/158

查询期在 measure 上做 group-by + mean + topN,由 BanyanDB 引擎现算。

TopN 预聚合 vs 查询期聚合

  • 预聚合 TopN:写入时数据库就按规则把"最忙的 N 个实体"算好存起来,查询时直接读结果,适合高频固定看板,但要预先在 bydb-topn.yaml 配规则。改规则不影响历史数据(历史按旧规则算好的已落盘)。
  • 查询期聚合:查询来了才扫数据算 group-by + 排序 + 取 N,灵活但慢,数据量大时贵。

BanyanDB 两者都支持,先试预聚合、不行再查询期——这是它比 ES / JDBC 优越的核心点。代价是要维护 bydb-topn.yaml,规则没配就退化到查询期。

BanyanDBTopologyQueryDAO(measure group by tag)

oap-server/server-storage-plugin/storage-banyandb-plugin/.../measure/BanyanDBTopologyQueryDAO.java:55。实现 ITopologyQueryDAO。关系指标(service/instance/endpoint/process relation)也是 measure。

  • loadServiceRelationsDetectedAtServerSide:62)→ queryServiceRelation:106):MeasureQuery project 出 ENTITY_ID + COMPONENT_IDS,条件 query.or(in(SOURCE_SERVICE_ID, serviceIds)).or(in(DEST_SERVICE_ID, serviceIds)):99-100)+ query.groupBy({ENTITY_ID, COMPONENT_IDS}):101)。
  • 实例关系(buildInstanceRelationsQuery :155):用 and/or 组合双向条件(source=client AND dest=server)OR(dest=client AND source=server),query.criteria(or(...)):175)。
  • endpoint/进程关系类似,都靠 groupBy tag 去重。

group by tag 去重
BanyanDB 的 groupBy(tag set) 是原生 measure 操作,按 tag 维度折叠数据点。拓扑查询本质就是"在这段时间内出现过哪些关系实体",group by entity_id 天然去重——同一关系实体出现多少次都被折成一行。这比 ES 的 terms 聚合去重更直接,因为 group by 是 measure 的原生命令。

BanyanDBTraceQueryDAO(原生 trace 模型——span 嵌套存储)

oap-server/server-storage-plugin/storage-banyandb-plugin/.../trace/BanyanDBTraceQueryDAO.java:59。三后端里 trace 查询差异最大的一处。该类实现 ITraceQueryV2DAO(V2),但通过 BanyanDBStorageProvider 注册为 ITraceQueryDAO.classBanyanDBStorageProvider.java:173,因为 V2 extends V1)。

  • 旧 V1 方法直接抛 UnsupportedOperationExceptionqueryBasicTraces:71,提示"BanyanDB Trace Model changed, please use queryTraces")、queryByTraceId:76,提示"please use queryByTraceIdV2")。BanyanDB 升级了 trace 数据模型,不再支持按 segment 列表查。

  • queryTraces(TraceQueryCondition):148)—— V2 主查询:用 TraceQuery(BanyanDB 原生 trace 查询 API)。条件用 eq/gte/lte 拼装(traceId、latency 区间、serviceId、instanceId、endpointId、isError),排序 OrderBy(START_TIME/LATENCY, DESC)tag 查询用 having(SegmentRecord.TAGS, tagsConditions):211)。返回 TraceQueryResponse,结构是 resp.getTraces() 返回 trace 列表,每个 trace 的 getSpansList() 直接给出该 trace 下所有 span:222:141)。

  • queryByTraceIdV2:123):query.and(eq(TRACE_ID, traceId)),返回单个 trace 的所有 SpanWrapper:140-145,从 resp.getTraces().get(0).getSpansList() 解析)。返回超过 1 个 trace 抛异常(:137)。

trace 的 span 关系查询
一条 trace 由多个 span 组成,span 之间有父子关系。三后端存法不同:

  • BanyanDB:trace 是一等公民,span 嵌套存在 trace 记录里。TraceQuery 一次查出来就是"trace→spans 列表"的树状结构,不用二次拼装。这也是旧 V1(按 segment 列表查)被废弃的原因——segment 概念在 BanyanDB trace 模型里被 span 嵌套取代。
  • ES / JDBC:trace 没有原生模型,每个 segment 是独立文档/行,靠 trace_id 字段关联。查一条 trace 要先按 trace_id 捞出所有 segment 文档,再在应用层拼成 span 树。
  • buildRecords:235):把 trace 查询结果转成 SegmentRecord(给 profile 等老接口用),从 SpanWrapper 解析 SegmentObject protobuf,重新算 serviceId/instanceId/endpointId/latency。注释(:232)说明"only build the fields needed by ProfiledTraceSegments"。

其他 BanyanDB 查询 DAO

DAO 实现 数据模型 tag 查询
BanyanDBLogQueryDAO ILogQueryDAO stream having(LogRecord.TAGS, tagsConditions)supportQueryLogsByKeywords 默认 false(不支持正文关键词检索)
BanyanDBAlarmQueryDAO IAlarmQueryDAO stream StreamQuery
BanyanDBRecordsQueryDAO IRecordsQueryDAO stream StreamQuery + eq(ENTITY_ID) + OrderBy(valueColumnName) + limit
BanyanDBTagAutocompleteQueryDAO ITagAutoCompleteQueryDAO measure query.groupBy({TAG_KEY})/groupBy({TAG_VALUE}) + eq(TAG_TYPE) 取去重 key/value
BanyanDBBrowserLogQueryDAO / BanyanDBEventQueryDAO / BanyanDBZipkinQueryDAO / BanyanDBHierarchyQueryDAO / BanyanDBMetadataQueryDAO 各自接口 按 stream/measure 选 同上

BanyanDB 查询特点小结

  1. 善用原生能力:measure 查询(预聚合时序点)、TopNAggregation(服务端预聚合 TopN)、groupBy tag(去重/折叠)、having(标签数组倒排查)、TraceQuery(原生 trace span 树)。
  2. 冷热分层原生支持:所有查询带 isColdStagesetStages("cold")
  3. V2 trace 模型:废弃 segment 列表查法,span 嵌套存储,一次查询出整棵 span 树。
  4. schema 驱动:所有查询先 MetadataRegistry.findMetricMetadata/findRecordMetadata 拿 schema,再按 schema 的 tag/field 构建 query——schema 不存在直接抛异常。foreign 指标有 synthesizeForeignMetricSchema 合成只读 schema 的能力。

Elasticsearch 查询实现

查询 DAO 集中在 oap-server/server-storage-plugin/storage-elasticsearch-plugin/.../query/,都继承 EsDAO

MetricsQueryEsDAO(按文档 ID 多点查——不做聚合)

oap-server/server-storage-plugin/storage-elasticsearch-plugin/.../query/MetricsQueryEsDAO.java:59。实现 IMetricsQueryDAO。理解 ES 指标查询的关键:ES 指标查询不用 ES 聚合,而是按文档 ID 多点查(multi-get)。

图3

  • readMetricsValues:66):
    1. duration.assembleDurationPoints() 算出期望的时间桶列表(:82)。
    2. 对每个时间桶,用 pointOfTime.id(entityId) 拼出文档 ID:86)。开了 merged table(多指标合并索引)时,用 IndexController.generateDocId(metricName, id) 给 ID 加指标名前缀(:88)。
    3. TimeSeriesUtils.queryIndexName 把文档 ID 分组到对应时间分片索引(:90-92),得到 indexIdsGroup: Map<indexName, List<docId>>
    4. idsDebuggable(indexIdsGroup):97)——调用 ES 的 multi-get API(getClient().ids(...))按文档 ID 批量取文档。
    5. 取到的文档按 ID 取 realValueColumn 字段值,缺失补默认值,再 Util.sortValues 按时间桶顺序排好。

为什么 ES 不用 date_histogram
SkyWalking 的指标在写入时已经按 (time_bucket, entity_id) 预聚合好,且把这个组合作为文档 ID。查询时只要算出期望的几个时间桶 ID,直接 multi-get 取回——这比 date_histogram 聚合便宜得多(O(桶数) 的点查 vs O(扫描全量) 的聚合)。terms/avg 聚合只用在 TopN 和拓扑。换句话说,ES 在指标查询上"假装"自己是 KV 存储,绕开了聚合引擎。

  • readLabeledMetricsValues:127):同样 multi-get,取回 DataTable 字符串列。
  • listEntityIdsInRange:219):这里Search + range(TIME_BUCKET) + sort(TIME_BUCKET, DESC) + size(limit):246-260),扫最近 N 行再客户端 LinkedHashSet 去重 entity_id(:267)。foreign 指标走合并索引 METRICS_LOGIC_TABLE_NAME + term(METRIC_TABLE_NAME, metricName) 判别(:240-258)。
  • readHeatMap:278):同样 multi-get 取 dataset 字段。
  • buildQuery:322,protected 辅助):构造 range(TIME_BUCKET) + terms(ENTITY_ID) + 可选 term(METRIC_TABLE_NAME) 的 bool query,但 size(0):361)——只用于计数场景,不取文档。

merged table 概念
ES 默认把多个指标类型合并到一个逻辑索引(METRICS_LOGIC_TABLE_NAME),用 metric_table 字段区分;也支持 logicSharding(按指标 stream 类分索引)。IndexController.LogicIndicesRegister.getPhysicalTableName/isMergedTable/getPhysicalColumnName 处理这层映射。foreign 指标在 logicSharding 下无法解析物理索引(索引名来自本节点没有的 stream 类),所以 :73/:135/:234 直接拒绝。

TraceQueryEsDAO(倒排 + 时间范围 + routing)

oap-server/server-storage-plugin/storage-elasticsearch-plugin/.../query/TraceQueryEsDAO.java:58。实现 ITraceQueryDAO(V1,非 V2——ES 没有 V2 trace 模型)。注册为 ITraceQueryDAO.classStorageModuleElasticsearchProvider.java:236)。

  • queryBasicTraces:68):构造 BoolQueryBuilder

    • term(RECORD_TABLE_NAME, INDEX_NAME) 若 merged(:88
    • range(TIME_BUCKET).gte().lte() 时间范围(:92
    • range(LATENCY).gte().lte() 耗时区间(:96-103
    • term(SERVICE_ID)/term(SERVICE_INSTANCE_ID)/term(ENDPOINT_ID)/term(TRACE_ID):106-116)——全靠倒排索引 term 查询
    • match(IS_ERROR, ...) 状态(:119-123
    • source(...) 只取需要的字段(:129-134,减少回表)
    • sort(START_TIME/LATENCY, DESC):138-143
    • tag 查询:Query.term(SegmentRecord.TAGS, tag.toString()):146)——TAGS 字段在 ES 里建了倒排,term 精确匹配 key=value 字符串
    • size(limit).from(from) 分页(:149
    • TimeRangeIndexNameGenerator 按时间范围选索引分片(:152-156
  • queryByTraceId:182):term(TRACE_ID, traceId) + size(segmentQueryMaxSize)RoutingUtils.addRoutingValueToSearchParam(searchParams, traceId):192)——把 traceId 作为 ES routing 值,让查询只落到存该 trace 的那个分片,避免广播查。

倒排索引查询 + ES routing

  • 倒排索引:ES 对 service_id/trace_id/tags 这类字段建倒排表(值→文档列表)。term 查询就是查倒排表,O(匹配数),比扫全表快几个数量级。SkyWalking 把所有可过滤字段都靠 term/range 走倒排。
  • routing:ES 写入时可用一个 routing key 决定文档落哪个分片。SkyWalking 写 trace 时用 traceId 作 routing,查询时同样传 traceId 作 routing,ES 只查一个分片而不是全部分片——对按 traceId 查这种点查,性能提升巨大。代价是文档分布不均(热门 traceId 的分片会偏大),SkyWalking 接受这个权衡。
  • queryBySegmentIdList:200):terms(SEGMENT_ID, list)
  • queryByTraceIdWithInstanceId:216):bool().must(terms(TRACE_ID, list)).must(terms(SERVICE_INSTANCE_ID, list))
  • segment 在 ES 里是独立文档,靠 trace_id 关联,应用层 buildRecords:236)逐文档还原 SegmentRecord

AggregationQueryEsDAO(查询期 terms + avg 聚合)

oap-server/server-storage-plugin/storage-elasticsearch-plugin/.../query/AggregationQueryEsDAO.java:49。实现 IAggregationQueryDAOsortMetrics:56):

  1. range(TIME_BUCKET) + 可选 term(METRIC_TABLE_NAME) + 可选 term(attr)/mustNot(terms(attr)) + terms(additionalCondition) 过滤(:61-107)。
  2. 核心聚合:109-117):
    Aggregation.terms(ENTITY_ID).field(ENTITY_ID)
               .order(BucketOrder.aggregation(realValueColumn, asc))
               .size(condition.getTopN())
               .subAggregation(Aggregation.avg(realValueColumn).field(realValueColumn))
               .executionHint(MAP).collectMode(BREADTH_FIRST)
    
    terms 按 entity_id 分桶 + 子聚合 avg 求均值 + 按 avg 值排序 + 取 TopN。这是查询期 ES 聚合,与 BanyanDB 的预聚合 TopN 形成对比——没有预聚合可走,只能现算。
  3. response.getAggregations() 解析 buckets(:125-137)。

executionHint(MAP) / collectMode(BREADTH_FIRST)
ES terms 聚合的调优参数。MAP 用内存哈希表收集(适合基数不高),BREADTH_FIRST 广度优先(适合桶多、深子聚合)。SkyWalking 对拓扑/TopN 都用这个组合,是经验值。基数很高(百万级 entity)时 MAP 会吃内存,这是 ES TopN 在大集群下的隐患。

TopologyQueryEsDAO(terms 聚合去重关系实体)

oap-server/server-storage-plugin/storage-elasticsearch-plugin/.../query/TopologyQueryEsDAO.java:51。实现 ITopologyQueryDAO。和 Aggregation 一样用 terms 聚合,但这里是为了去重拿到这段时间出现过哪些关系实体

  • loadServiceRelationsDetectedAtServerSide:58)→ buildServiceRelation:286):terms(ENTITY_ID).size(1000) + 子聚合 terms(COMPONENT_IDS):289-299),BREADTH_FIRST+MAP。从 buckets 还原 Call.CallDetail
  • 实例关系(:121):单层 terms(ENTITY_ID)
  • 进程关系(:221):terms(ENTITY_ID) + 子 terms(COMPONENT_ID)
  • 条件用 term(SOURCE_SERVICE_ID)/term(DEST_SERVICE_ID) + should 组合双向(:174-175)。
  • size(0):63)——不取文档原文,只要聚合结果。

LogQueryEsDAO(倒排 + matchPhrase 关键词)

oap-server/server-storage-plugin/storage-elasticsearch-plugin/.../query/LogQueryEsDAO.java:53。实现 ILogQueryDAO,且 supportQueryLogsByKeywords() 返回 true:59)——三后端里唯一支持正文关键词检索的。

  • queryLogs:64):BoolQueryBuilder + term/range 一套倒排过滤(service/instance/endpoint/traceId/segmentId/spanId/tags)。
  • tag 查询:Query.term(AbstractLogRecord.TAGS, tag.toString()):111)——和 trace 一样靠 TAGS 倒排。
  • 关键词检索:Query.matchPhrase(MatchCNameBuilder.build(CONTENT), content):118)——matchPhrase 短语匹配,走 ES 全文倒排。排除关键词用 mustNot(matchPhrase(...)):129-130)。MatchCNameBuilder 处理列名映射(合并索引下正文列名带前缀)。
  • sort(TIMESTAMP, DESC/ASC):140)+ size(limit).from(from) 分页(:145)。

ES 查询特点小结

  1. 指标 multi-get,不用聚合:靠文档 ID(time_bucket+entity_id)点查,避免 date_histogram
  2. TopN/拓扑用 terms+avg 查询期聚合:无预聚合,靠 ES 聚合引擎现算,调优 MAP+BREADTH_FIRST
  3. trace/log 全倒排term/range/matchPhrase 走倒排索引,tags 字段也建倒排。
  4. routing 优化点查:按 traceId 查带 routing,单分片命中。
  5. merged table 判别:合并索引下用 metric_table/record_table_name 字段 term 过滤区分指标/记录类型。
  6. 无冷热分层原生支持(不像 BanyanDB 的 setStages),靠 TimeRangeIndexNameGenerator 按时间选索引分片。

JDBC 查询实现

查询 DAO 在 oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/.../common/dao/(通用,H2/MySQL/PG 共用),PostgreSQL 特化在 postgresql/dao/

JDBCMetricsQueryDAO(按主键 IN 查——不做聚合)

oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/.../common/dao/JDBCMetricsQueryDAO.java:45。实现 IMetricsQueryDAO。和 ES 思路一样——指标按主键 ID 点查,不聚合

  • readMetricsValues:56):

    1. tableHelper.getTablesForRead(metricName, startBucket, endBucket) 拿到时间范围内要查的物理表(JDBC 按天分表,:64)。
    2. duration.assembleDurationPoints() 算期望时间桶,TableHelper.generateId(metricName, pointOfTime.id(entityId)) 拼出主键 ID:72)。
    3. 对每个表发 SQL:select id, valueColumnName from table where id in (?, ?, ...):76-82)——主键 IN 查询,等价于 ES 的 multi-get,走主键 B-tree 索引。
    4. 结果按 Util.sortValues 排序补默认值。
    • foreign 指标:try-catch 跳过不含该 value 列的表(:97-103)。
  • listEntityIdsInRange:217):这里用 SQL 聚合select entity_id, max(time_bucket) as latest_time_bucket ... where table_name=? and time_bucket>=? and time_bucket<=? group by entity_id order by latest_time_bucket desc limit ?:250-259)。注释(:241-247)解释为什么用 GROUP BY+MAX 而非 SELECT DISTINCTPostgreSQL 拒绝 ORDER BY 一个不在 DISTINCT 列表里的列,所以用 GROUP BY 形式可移植。跨表结果在 Java 端按 latest 合并(:270)再全局排序取 limit。

  • readHeatMap:285):同样 id in (...) 点查取 dataset 列。

JDBCTraceQueryDAO(SQL WHERE + tag 表 JOIN)

oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/.../common/dao/JDBCTraceQueryDAO.java:62。实现 ITraceQueryDAO(V1)。纯 SQL 字符串拼接,参数化防注入。

  • queryBasicTraces:81):
    1. 先校验 tag 是否在 SearchableTracesTags:97)——只有声明为可搜索的 tag 才能查,否则直接返回空 TraceBrief(避免全表扫)。
    2. getTablesForRead / getTablesWithinTTL 遍历时间分表(:113-115)。
    3. 拼 SQL:where table_name=? and time_bucket>=? and time_bucket<=? and latency>=? and latency<=? and service_id=? and ... and is_error=1:138-185),order by start_time desc / order by latency desc:188/:191),LIMIT ? OFFSET ?:195buildLimit :229)。
    4. tag 查询靠 inner join 单独的 tag 表:132:171-177):inner join segment_tag t0 on main.id=t0.id and t0.tags=?。每个 tag 一次 join。

JDBC 的 tag 存储模型
SegmentRecord@SQLDatabase.AdditionalEntity(additionalTables = {ADDITIONAL_TAG_TABLE})SegmentRecord.java:88ADDITIONAL_TAG_TABLE = "segment_tag" :64)把 tags 拆到独立表 segment_tag(id, tags)。主表不存 tags,查 tag 必须 join tag 表。这和 ES(tags 字段建倒排)、BanyanDB(tags 数组 + having)完全不同——JDBC 没有倒排索引,靠额外表 + B-tree 索引 + join 实现 tag 过滤。代价是每个 tag 一次 join,tag 多了 join 数线性增长。

  • queryByTraceId:236):遍历 getTablesWithinTTL 所有 TTL 内的表:237),每表发 where table_name=? and trace_id=?。注释里 queryBasicTracesTODO: sort:226)说明跨表结果排序未在 SQL 层做。没有 ES 那样的 routing——JDBC 无法按 traceId 路由到单表,只能全 TTL 表扫,这是 JDBC trace 查询最慢的根源。

  • queryBySegmentIdList:257):segment_id in (...)

  • queryByTraceIdWithInstanceId:282):trace_id in (...) and service_instance_id in (...)

  • doFlexibleTraceQuery:309)返回空。

JDBCAggregationQueryDAO(SQL 子查询 GROUP BY + Java 端合并)

oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/.../common/dao/JDBCAggregationQueryDAO.java:44。实现 IAggregationQueryDAOsortMetrics:50):

  1. 遍历时间分表(:55)。
  2. buildSQL:94)构造子查询:
    select result, entity_id from (
      select avg(valueColumnName) as result, entity_id
      from table where table_name=? and time_bucket>=? and time_bucket<=? [and attr=?]...
      group by entity_id
    ) as T order by result desc limit topN
    
    avg + GROUP BY entity_id + ORDER BY + LIMIT——SQL 查询期聚合,单表内取 TopN。
  3. 跨表合并:73-91):因为按天分表,每个表各自取了 TopN,Java 端要 groupingBy(SelectedRecord::getId) 按实体合并(:79),对同一实体的多个表结果求平均:84),再按 ASC/DESC 排序、limit(topN):90)。

JDBC 分表是性能劣势根源
对比:ES 是单索引一次 terms 聚合跨所有桶;JDBC 是分表各自聚合再 Java 端合并——分表是 JDBC 性能劣势的根源,跨表 TopN 不可能精确(每表各自的 TopN 合并不等于全局 TopN,除非每表都取足够多)。跨多天查询时,要么每表取的 N 够大(吞吐变差),要么接受近似结果。这是关系库做时序 TopN 的结构性短板。

JDBCTopologyQueryDAO(SQL GROUP BY 去重)

oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/.../common/dao/JDBCTopologyQueryDAO.java:47。实现 ITopologyQueryDAOloadServiceCalls:140):

select entity_id, component_ids from table
where table_name=? and time_bucket>=? and time_bucket<=? [and (source=? or dest=?)]
group by entity_id, component_ids

:172-176)——GROUP BY 去重拿到这段时间出现过的关系实体。实例/端点/进程关系同理(:223-227:258-263:295-301),条件用 source=? and dest=? 双向 OR。

PostgreSQL 特化(仅 SQL 方言适配,无 JSON/数组)

oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/.../postgresql/dao/

  • PostgreSQLMetricsQueryDAO:26)继承 JDBCMetricsQueryDAO,只重写 buildMetricsValueSql:33):给聚合列加 as result 别名(PG 对 ORDER BY 别名要求���格)。
  • PostgreSQLAggregationQueryDAO:26)继承 JDBCAggregationQueryDAO,重写 buildMetricsValueSql:32):用 select * from (select avg(...) as result, entity_id from ...) 子查询包装。

PG 没用 JSON/数组
对整个 JDBC 插件源码 grep jsonb/array_agg/->>/unnest/@> 零命中PostgreSQLStorageProvider.prepare()PostgreSQLStorageProvider.java:45重注册 IMetricsQueryDAO:50)和 IAggregationQueryDAO:53)两个服务,其余 query DAO 全部继承通用 JDBCStorageProvider 实现。PG 特化纯粹是 SQL 语法适配(别名/子查询包装),没有用 PG 的 JSON/数组特性。哪怕 PG 有 jsonb 能存数组 tag,SkyWalking 也没用——保持和 H2/MySQL 同一套 segment_tag 表 + join 的实现。

JDBC 查询特点小结

  1. 指标按主键 IN 点查(同 ES multi-get 思路),不聚合。
  2. TopN/拓扑用 SQL GROUP BY+avg+ORDER BY 查询期聚合,跨天分表在 Java 端合并(不精确)。
  3. trace/log 全靠 SQL WHERE,没有倒排索引——tag 查询靠独立 tag 表 + inner join + B-tree。
  4. 按 traceId 查要扫全 TTL 表(无 routing 概念)。
  5. 可搜索 tag 受配置限制SearchableTracesTags 白名单外的 tag 直接拒绝查询。
  6. PG 特化仅 SQL 方言,无 JSON/数组。

三后端查询实现对比

同一个 readMetricsValues 的三种实现

维度 BanyanDB Elasticsearch JDBC
入口类 BanyanDBMetricsQueryDAO:60 MetricsQueryEsDAO:66 JDBCMetricsQueryDAO:56
查询机制 MeasureQuery + eq(ENTITY_ID) 倒排过滤 + 时间范围 ES multi-getgetClient().ids())按文档 ID 批量取 select ... where id in (...) 主键 IN 查询
是否聚合 否(measure 写入已预聚合) 否(multi-get 点查) 否(主键点查)
时间桶对齐 客户端按 assembleDurationPoints 补默认值 同左 同左(Util.sortValues
分片/分表 按 group/stage(冷热) TimeSeriesUtils.queryIndexName 按时间分索引 tableHelper.getTablesForRead 按天分表

三者都不在查询期聚合指标
因为指标在写入时已按 (time_bucket, entity_id) 预聚合,且该组合就是主键/文档 ID。查询本质都是"算出期望的时间桶 ID,按 ID 点查回填"。差异只在存储引擎的 API:BanyanDB 用 measure 查询语言、ES 用 multi-get、JDBC 用 WHERE id IN。这一层三者性能接近,差距在 TopN 和 trace 才拉开。

谁用原生聚合、谁用查询期计算(TopN)

维度 BanyanDB Elasticsearch JDBC
入口类 BanyanDBAggregationQueryDAO:59 AggregationQueryEsDAO:56 JDBCAggregationQueryDAO:50
预聚合 TopN TopNAggregationbydb-topn.yaml,服务端预聚合,:99 ❌ 无 ❌ 无
查询期聚合 directMetricsTopNmeanBy+topN:154-158 terms+avg+BucketOrder:110-114 avg+GROUP BY+ORDER BY+LIMIT:104-137
跨分片/分表 单 measure 内原生跨桶 单索引内 ES 聚合引擎 分表各自聚合 + Java 端合并求平均:79-90),不精确

性能差异根源:BanyanDB 有预聚合 TopN 路径,高频看板可走预聚合(O(预聚合结果大小));ES 和 JDBC 只能查询期现算。JDBC 还因为按天分表,跨表 TopN 要 Java 端合并,数据量大或跨多天时既慢又不精确。ES 单索引聚合引擎相对最优(无分表),但仍比 BanyanDB 预聚合慢。一句话:BanyanDB 用空间换时间(预存结果),ES 用算力换灵活(现算),JDBC 受限于分表结构两头不沾

trace 查询:谁用倒排、谁用 SQL where

维度 BanyanDB Elasticsearch JDBC
入口类 BanyanDBTraceQueryDAO:59V2 TraceQueryEsDAO:58(V1) JDBCTraceQueryDAO:62(V1)
查询机制 原生 TraceQuery(trace→spans 嵌套,一次出 span 树) BoolQueryBuilder + term/range/match 倒排 + sort + 分页 SQL WHERE 字符串拼接 + ORDER BY + LIMIT/OFFSET
trace 列表查询 queryTraces(TraceQueryCondition):148),返回 TracesQueryResult 含 span 树 queryBasicTraces:68),返回 TraceBrief(segment 列表) queryBasicTraces:81),返回 TraceBrief
按 traceId 取详情 queryByTraceIdV2:123),一次取整棵 span 树 queryByTraceId:182),term(TRACE_ID) + routing 优化 queryByTraceId:236),扫全 TTL 表 where trace_id=?
tag 查询 having(TAGS, ["k=v"]):211,原生数组倒排查) term(TAGS, "k=v"):146,倒排字段精确匹配) inner join segment_tag on id=id and tags=?:132,独立表 + B-tree)
关键词/正文 不支持 matchPhrase(CONTENT)LogQueryEsDAO:118 不支持
冷数据 setStages("cold") ❌(靠时间索引分片) ❌(靠 TTL 分表)
span 关系组装 无需组装(span 嵌套存储) 应用层从 segment 文档拼 应用层从 segment 行拼

性能差异根源

  • BanyanDB trace 是原生模型,span 嵌套,一次查询出整棵树,且 having 走原生倒排——最快。
  • ES 靠倒排 + routing,按 traceId 查能单分片命中,很快;但 span 树要应用层拼。
  • JDBC 没有倒排也没有 routing,按 traceId 查要扫所有 TTL 表(最慢);tag 查询要 join tag 表;且 tag 必须在 SearchableTracesTags 白名单内。

性能差异根源总结

三后端查询机制对照

  1. 指标查询:三者都靠"预聚合 + 主键点查",性能接近。差异主要在冷热分层(BanyanDB 原生支持)和分片策略。
  2. TopN 聚合:BanyanDB 有预聚合 TopN(最快);ES 查询期聚合(次之,单索引);JDBC 查询期聚合 + 分表 Java 合并(最慢且不精确)。
  3. trace 查询:BanyanDB 原生 span 树(最快,无需二次组装);ES 倒排 + routing(快,需组装);JDBC 全表扫 + join(最慢,需组装)。
  4. tag 查询:BanyanDB having(原生数组倒排);ES term(倒排字段);JDBC join tag 表(无倒排,B-tree)。
  5. 关键词检索:仅 ES matchPhrase 支持正文检索;BanyanDB/JDBC 不支持。
  6. 冷数据:仅 BanyanDB 原生 setStages 支持;ES/JDBC 靠时间分片/分表间接实现。

关键文件速查

子系统 关键类 路径
查询接口 I*QueryDAO oap-server/server-core/.../storage/query/*.java
BanyanDB 查询 AbstractBanyanDBDAO oap-server/server-storage-plugin/storage-banyandb-plugin/.../stream/AbstractBanyanDBDAO.java
BanyanDBMetricsQueryDAO 同目录 measure/BanyanDBMetricsQueryDAO.java
BanyanDBAggregationQueryDAO .../banyandb/BanyanDBAggregationQueryDAO.java
BanyanDBTraceQueryDAO .../banyandb/trace/BanyanDBTraceQueryDAO.java
ES 查询 MetricsQueryEsDAO oap-server/server-storage-plugin/storage-elasticsearch-plugin/.../query/MetricsQueryEsDAO.java
TraceQueryEsDAO 同目录 TraceQueryEsDAO.java
AggregationQueryEsDAO 同目录 AggregationQueryEsDAO.java
LogQueryEsDAO 同目录 LogQueryEsDAO.java
JDBC 查询 JDBCMetricsQueryDAO oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/.../common/dao/JDBCMetricsQueryDAO.java
JDBCTraceQueryDAO 同目录 JDBCTraceQueryDAO.java
JDBCAggregationQueryDAO 同目录 JDBCAggregationQueryDAO.java
PostgreSQL 特化 .../postgresql/dao/PostgreSQLMetricsQueryDAO.javaPostgreSQLAggregationQueryDAO.java

未确认事项

  • PostgreSQL 的 PostgreSQLTableInstaller DDL 是否用 PG 特殊类型(jsonb/数组)建表,本次未读 DDL 源码,标注未确认。但查询 DAO 层确定没用 PG JSON/数组。
  • ES 是否在其它查询 DAO(如 MetadataQueryEsDAO、profiling 相关)用 date_histogram,本次未全量检索;但本篇重点的 5 个 DAO(Metrics/Trace/Aggregation/Log/Topology)确认未用 date_histogram
  • BanyanDB TraceQuery 服务端具体如何存 span 嵌套(protobuf 结构细节),本次只看到客户端 TraceQueryResponse.getTraces().getSpansList(),服务端存储格式未深查。

全系列回顾

到这里,SkyWalking OAP 的核心子系统、数据模型、协议层、Profiling、查询 DAO 都拆解完了。回到 README 看整体地图,或回看任意一篇:

  • 00-整体架构与端到端数据流 / 01-模块系统与启动流程 / 02-数据采集层-Receiver
  • 03-分析引擎与四大DSL / 04-流处理引擎-Worker链 / 05-存储层
  • 06-查询层与告警 / 07-集群协调与动态配置
  • 08-数据模型与Scope体系 / 09-协议层与Agent采集
  • 10-Profiling体系

心智模型
把查询 DAO 想成"展厅的三种导览系统":BanyanDB 是为 SkyWalking 量身定制的智能导览——measure 货架按 ID 直取、TopN 区有预分拣榜单、trace 区有原生族谱树、标签有数组倒排;ES 是租来的通用搜索引擎——指标靠 ID 点查、TopN 靠现场聚合、trace 靠倒排+routing、还能全文搜日志;JDBC 是拿关系库当仓库——指标靠主键 IN、TopN 靠 GROUP BY+跨表合并、trace 靠全表扫+join tag 表。同一份"查询菜单"(接口),三种仓库各有各的取法,性能差距源自谁更贴近时序+链路的原生语义。