存储层

这一篇解决什么
指标算好了,怎么存。OAP 用一套 Model + StorageBuilder + DAO 抽象把存储后端隔离开,同一份逻辑跑在自研 BanyanDB、Elasticsearch、关系库上。这篇讲存储抽象、BanyanDB 原生数据模型(为什么它是首选)、ES 怎么用文档库模拟指标聚合、JDBC 怎么用关系表模拟时序,最后对比三套实现。读完应该能回答:BanyanDB 凭什么更适合,ES 和 JDBC 各自怎么妥协。


存储层解决什么问题

OAP 存储层把流式分析产出的指标、原始记录(Trace/Log/Event)、管理数据、非流式配置数据持久化到外部数据库,查询时按时间范围、维度、TopN 读回来。它要同时伺候两类截然相反的负载:写密集的时序聚合(每分钟/小时/天滚动写入大量指标点),读密集的关联查询(按 traceId、service、endpoint 倒排检索原始 trace)。

为什么插件化
OAP 核心只定义"要存什么、要查什么"——StorageModule.services() 列出 30+ 个接口(oap-server/server-core/.../storage/StorageModule.java:69),具体"怎么存"交给各 ModuleProvider 实现。核心和存储引擎彻底解耦,同一份分析逻辑跑在 BanyanDB/ES/MySQL 上,运维只改 application.ymlstorage.selector

BanyanDB 首选的原因就一句话:SkyWalking 的核心数据形态在它那里都是一等公民。Measure(指标聚合,按 seriesID + timestamp 自动覆盖合并)、stream(原始事件)、tracepropertygroup(TTL 与降采样隔离)、TopNAggregation(服务端预聚合 TopN)、IndexRule(显式倒排)——这六样恰好一一对应 SkyWalking 的核心数据形态。ES 是通用文档库,没有原生 measure,只能"按时间分物理索引 + 文档 upsert + 查询期 aggregation"硬模拟;JDBC 用关系表 + 按天分表模拟。BanyanDB 把这些语义下沉到存储引擎,省掉了 ES/JDBC 的大量模拟开销。


存储抽象体系

StorageModule 定义的能力契约

StorageModuleoap-server/server-core/.../storage/StorageModule.java:60)的 services() 返回一组接口,分四类:

图1

写入/安装类(StorageModule.java:71-113):StorageBuilderFactoryStorageDAO(DAO 工厂)、IBatchDAO(批量异步写入,被 PersistenceTimer 驱动)、IHistoryDeleteDAO(TTL 删除)、ModelInstaller(DDL 安装器,把 Model 创建成后端物理 schema)、RuntimeRuleManagementDAO(运行时规则持久化)。

查询类:ITopologyQueryDAOIMetricsQueryDAOITraceQueryDAOIMetadataQueryDAOIAggregationQueryDAOIAlarmQueryDAOIRecordQueryDAOILogQueryDAOIEventQueryDAOIBrowserLogQueryDAOIZipkinQueryDAO、各 profiling 查询 DAO、ITagAutoCompleteQueryDAOIHierarchyQueryDAO——每个都是独立接口。

写入型 DAO vs 查询型 DAO

  • 写入型(Metrics/Record/NoneStream/Management,被 PersistenceTimer 驱动)关心怎么把对象转成后端写入请求。
  • 查询型(storage/query/I*QueryDAO,被 GraphQL query 模块调用)关心怎么把后端查询翻译成 OAP 返回结构。

StorageDAO · DAO 工厂

StorageDAOstorage/StorageDAO.java:27)是 DAO 工厂接口,四个方法:

IMetricsDAO  newMetricsDao(StorageBuilder)
IRecordDAO   newRecordDao(StorageBuilder)
INoneStreamDAO newNoneStreamDao(StorageBuilder)
IManagementDAO newManagementDao(StorageBuilder)

OAP 核心按数据类型向存储要 DAO(storageDAO.newMetricsDao(builder)),存储端拿对应 StorageBuilder 返回自己的实现。各后端实现:BanyanDB(BanyanDBStorageDAO.java:39)、ES(StorageEsDAO)、JDBC(JDBCStorageDAO)。

五大核心 DAO 接口

接口 职责 关键方法
IMetricsDAOIMetricsDAO.java:34 指标,唯一支持"读改写" multiGet 读旧值、prepareBatchInsert/prepareBatchUpdateisExpiredCache
IRecordDAOIRecordDAO.java:29 原始记录只写不更新 prepareBatchInsert(无 update)
INoneStreamDAOINoneStreamDAO.java:28 非流式配置类 insert(同步)
IManagementDAOIManagementDAO.java:28 管理数据 insert(同步)
IHistoryDeleteDAOIHistoryDeleteDAO.java:28 TTL 删除 deleteHistory(model, timeBucketColumnName, ttl)

IMetricsDAO 为什么有 multiGet
指标是增量聚合——新数据来了要先读旧值、叠加、写回。multiGetIMetricsDAO.java:43)按指标 ID 批量读回已有值。prepareBatchInsert:51)转成 InsertRequestprepareBatchUpdate:59)转成 UpdateRequestisExpiredCache:70,default 方法)判断进程内缓存的指标是否超 TTL 该淘汰。

StorageBuilder · 实体↔存储互转

StorageBuilder<T>storage/type/StorageBuilder.java:28)定义双向转换:

  • storage2Entity(Convert2Entity converter):35):从存储返回结构还原成 OAP 实体。
  • entity2Storage(T entity, Convert2Storage converter):44):把 OAP 实体塞进 converter,再 obtain() 出后端需要的结构。

Convert2Entity/Convert2Storage 是转换器接口,不同后端不同实现:ES 用 Map<String,Object>,BanyanDB 用 MeasureWrite/DataPoint,JDBC 用 HashMap

StorageBuilderFactory · DSL 与后端的解耦点
StorageBuilderFactorystorage/StorageBuilderFactory.java:34)让存储端可覆盖默认 builder。OAL/MAL 在编译期为每个指标类生成一个 *Builder,默认把字段塞进 HashMap。存储插件若需更原生格式(BanyanDB 要 tag/field 分离、ES 要 mapping 友好结构),就通过 StorageBuilderFactory 替换 builder。这是"DSL 生成代码"和"后端原生格式"的解耦点。

Model / ModelColumn · 后端中立的表定义

Modelstorage/model/Model.java:36)是后端中立的"表定义",核心字段:namecolumnsList<ModelColumn>)、scopeIddownsampling(降采样粒度)、superDataset(超大数据集)、streamClass(判断 isMetric/isRecord/isTimeSeries)、timeRelativeIDallowBootReshapeModel.java:51-53),以及三个后端专属扩展 sqlDBModelExtension/banyanDBModelExtension/elasticSearchModelExtension:54-56)。

插件化的关键设计点
Model 后端中立,但通过三个 *ModelExtension 给每个后端留专属配置挂载位。BanyanDBModelExtensionstorage/model/BanyanDBModelExtension.java:33)挂 timestampColumn:42)、traceIdColumn:51)、indexMode:80)、streamGroup:84)、traceGroup:88);ElasticSearchModelExtension 挂 ES 专属;SQLDatabaseModelExtension 挂附加表、复合索引。

ModelColumnstorage/model/ModelColumn.java:29)描述一列:columnNametype:31)、storageOnly(只存不查,:36)、indexOnly(只查不存,与 storageOnly 互斥,:42)、length:46)。byte[]DataTable(指标多值哈希)强制 storageOnly=true:86-87)——这类字段永远不能做查询条件。

ModelInstaller · 四种安装策略

ModelInstallerstorage/model/ModelInstaller.java:45)订阅 ModelRegistrywhenCreating:50)/whenRemoving:172)事件——每注册一个 Model 触发后端建表。whenCreating 实现四种策略:

图2

抽象方法 isExists/createTable/dropTable 由各后端实现。dropTable 默认空实现(ModelInstaller.java:273,282-283)——只有 BanyanDB(每逻辑模型一张 measure/stream)需要真删,JDBC/ES 是 append-only,模型移除时底层表/索引保留。


BanyanDB 存储(首选)

BanyanDB 是什么

BanyanDB 是 SkyWalking 社区自研的时序 + 链路存储引擎,通过 gRPC 提供五类顶层资源(MetadataRegistry.parseMetadata MetadataRegistry.java:808Kind 分支,:1027 定义 MEASURE, STREAM, PROPERTY, TRACE):

图3

  • measureKind.MEASURE):指标聚合表,按 seriesID(一组 tag)+ timestamp 唯一定位,同 seriesID 同 timestamp 的新写覆盖/合并旧值——这正是 SkyWalking 指标增量聚合所需的原生语义。
  • streamKind.STREAM):原始事件流(普通 Record、Log、浏览器错误日志),按 timestamp 顺序追加。
  • traceKind.TRACE):链路,带 span 父子关系与 traceId 索引,是 stream 的特化。
  • propertyKind.PROPERTY):元数据键值(网络别名、服务标签),无时间维度。
  • TopNAggregation:挂在 measure 上的服务端预聚合规则,按指定 field 排序取 Top-N。

资源按 group 组织——group 是物理与配置隔离单位,每个 group 有独立 IntervalRule(生命周期 TTL)和 catalog。

measure vs stream · 对新手最关键的区分

财务类比
measure 像"每日汇总账"——每天一行,第二天重算就覆盖。stream 像"流水单"——每笔交易一行,只增不改。BanyanDB 给这两种负载分别造了原生结构,ES 只有一种"文档",两种都得用文档模拟,效率自然差。

measure(指标) stream(原始记录)
对应 OAP 数据 Metrics 子类(ServiceAvgRespTime Record 子类(trace、log)
主键 seriesID(维度标签)+ timestamp timestamp 顺序
更新语义 同 key 同时间覆盖合并 只追加不更新
查询方式 tag 列做 seriesID/过滤 倒排 IndexRule + 时间扫描
TopN 支持 服务端 TopNAggregation 预聚合 不支持服务端 TopN

measure 对应 SkyWalking Metrics 子类。每分钟一个数据点,由 (维度标签 = seriesID, 时间戳) 唯一标识。同一分钟同一服务又有新数据,OAP 先 multiGet 读旧值、叠加、再 update(BanyanDBMetricsDAO.multiGet measure/BanyanDBMetricsDAO.java:87)。measure 用 tag 列做 seriesID 与过滤,用 field 列存数值(MetadataRegistry.registerMeasureModel MetadataRegistry.java:189,seriesID 列在 :195,indexMode 在 :198 强制 seriesID 含 ID)。field 不能做查询条件,isIndexMode 时甚至禁止有 field(:234-239)。

stream 对应 Record 子类。每个事件一条记录,只追加不更新。查询靠倒排 IndexRule + 时间范围扫描。不支持服务端 TopN 预聚合——BanyanDBIndexInstaller.java:232 注释写得很直白:“Stream not support server side TopN pre-aggregation”。

BanyanDB 的物理存储:列存、Gorilla 编码、SeriesID、segment TTL

这部分讲的是 BanyanDB 服务端引擎的物理结构
BanyanDB 服务端不在 SkyWalking 仓库里(OAP 只带 client proto,见 .gitmodulesskywalking-banyandb-client-proto 子模块)。下面这些是 BanyanDB 公开设计文档描述的物理层,OAP 插件层看不到也不需要关心——但它解释了"为什么 BanyanDB 比 ES 更适合时序"。能对照到的 OAP 侧证据我会标出来。

列存(columnar storage)——行存把一条记录的所有字段连续存在一起,读整行快但读单列要拖出整条;列存把同一列的值连续存在一起。时序负载的查询绝大多数只读少数几列(“给我某服务最近 1 小时的 avg response time”,只要 service + value 两列),列存让读盘只碰需要的列,IO 放大极小。ES 的底层 Lucene segment 本质是行式文档 + 倒排,读单列也要解整条 _source(除非用 doc_values 冷冻),列存优势拿不到。

Gorilla 编码 / delta-of-delta——Facebook 2015 年论文《Gorilla: A Fast, Scalable, In-Memory Time Series Database》提出的浮点数压缩。两个机制叠加:

  1. delta-of-delta 压缩时间戳。相邻点的时间戳通常等间隔(每分钟一个点),差值是常数。对差值再求差(delta-of-delta),等间隔序列就变成一串 0。Gorilla 用一个变长 bit 编码:0 存 1 个 bit,小扰动存 2-4 bit,大跳变才存更多。近乎 1 bit/点不是夸张,是等间隔序列的真实结果。
  2. XOR 编码浮点值。相邻采样点的浮点值通常很接近(响应时间不会突变),用相邻值的按位异或(XOR)结果存——XOR 后大多高位是 0,只存有效位。叠加后一个点平均 1.37 byte(论文实测),远小于一个 double 8 byte。

BanyanDB 在 measure 的 field 列上用这类编码。ES 存指标要进 _source JSON(每个数值都是完整 ASCII 文本 + 字段名重复),再加倒排索引和 doc_values,膨胀一个数量级很正常。这是 BanyanDB 写入和存储成本碾压 ES 的根本原因。

SeriesID——一组 tag 的组合(如 service=order-svc, endpoint=/api/pay)算出一个唯一 ID,作为 measure 的行键。同 seriesID 同 timestamp 自动覆盖合并,正是 SkyWalking 指标增量聚合所需。OAP 侧的对应逻辑在 MetadataRegistry.parseEntityNames:652-658 收集 seriesID 列)和 BanyanDBMetricsDAO.multiGetBanyanDBMetricsDAO.java:98ext.isSeriesID() 列)。

segment TTL——BanyanDB 把数据按时间切成 segment(一段时间的列存块),每个 group 的 IntervalRule 定义 segment 生命周期。过期整 segment 直接删文件,OAP 端什么都不用做。对比 ES 要 OAP 端定时扫描删整个物理索引、JDBC 要定时 drop 整张物理表——BanyanDB 把 TTL 变成存储原生能力。

TopN 预聚合——MetadataRegistry.parseTopNSpecsMetadataRegistry.java:330)读 bydb-topn.yml 配置,对单值指标(ValueDataType.COMMON_VALUE:340)创建 TopNAggregation proto,指定 sourceMeasure/fieldName/fieldValueSort/countersNumber/group_by_tag_names:348-364)。TopN 查询由 BanyanDB 服务端预聚合返回,OAP 不必全扫 measure。BanyanDBStorageProvider.notifyAfterCompletedBanyanDBStorageProvider.java:259)还会 cleanupUnusedTopNRules:277)清理多余规则。ES 没这个能力,TopN 全靠查询期 terms aggregation 全扫。

group / index / property / tag 列模型

倒排索引通俗解释
正排是"给我 ID 为 X 的记录"(按主键找内容);倒排是"给我所有 service=order-service 的 trace"——先在"service → ID 列表"的索引里查到候选 ID,再回表取内容。BanyanDB 的 IndexRule 就是显式声明"我要在哪个 tag 上建倒排",没声明则该 tag 只能顺序扫描,查询慢。

  • group:物理隔离 + TTL 单位。group 名按数据语义固定(MetadataRegistry.parseMetadata MetadataRegistry.java:808-930):指标按降采样粒度分 METRICS_MINUTE/METRICS_HOUR/METRICS_DAY:898,910,922),元数据归 PROPERTY group(:813),Trace 按 TraceGroup.TRACE/ZIPKIN_TRACE:828,837),Stream 按 StreamGroup.RECORDS/RECORDS_LOG/RECORDS_BROWSER_ERROR_LOG:871,853,862)。
  • tag:可被索引的列,组成 seriesID 或充当 IndexRule 索引列。
  • field:仅 measure 有,存数值,不可查询。
  • IndexRule + IndexRuleBinding:显式倒排索引——先定义 IndexRule(某 tag 上的索引类型,如 INVERTED/BLOOM),再用 IndexRuleBinding 绑到���体 measure/stream(BanyanDBIndexInstaller.java:212-214,227-229,242-246)。defineIndexRule/defineIndexRuleBinding 在 createTable 时调用(:387-391,413-416,442-446)。
  • propertyregisterPropertyModelMetadataRegistry.java:255),无时间维度,纯 tag 键值表。

Model → schema 映射

BanyanDBStorageProviderstorage-banyandb-plugin/.../BanyanDBStorageProvider.java:111)在 prepare() 注册全部 DAO 实现(:141),start() 连接 client 并 modelInstaller.start():245),然后 ModelRegistry.addModelListener(modelInstaller):247)——每个 Model 注册都回调 installer。

BanyanDBIndexInstaller.isExists/createTableBanyanDBIndexInstaller.java:155,367)按 Model 性质分流(:200-258):

图4

具体映射(MetadataRegistry):

  • registerStreamModel:146):解析 seriesID 列(:152,必填)、tag 元数据、tagFamily、IndexRule,构造 Stream proto。
  • registerMeasureModel:189):解析 seriesID,若 isIndexMode 强制 seriesID 含 ID:198-201);解析 tag + field;设 interval = 降采样粒度(:228 附近);indexMode 时 setIndexMode(true) 且禁止 field(:234-239)。
  • registerTraceModel:269)、registerPropertyModel:255)。
  • parseMetadata:808)决定 group 归属 + Kind + TTL 配置——这是上面路由的源头。

TopN / Histogram 原生能力

  • TopNAggregation:见上文 parseTopNSpecs。TopN 查询由服务端预聚合返回,OAP 不必全扫 measure。
  • IndexRule / IndexRuleBinding:由 BanyanDBExtension.shouldIndex() 决定哪些 tag 建索引。
  • Histogram:SkyWalking 的 DataTable 类型字段(P99/百分比指标)在 BanyanDB 以 measure field 存储,按 bucket 维度表达。具体 Histogram 原生结构在 BanyanDB 服务端,OAP 插件层通过 measure field + value 列表达。

SchemaWatcher · mod_revision fence 机制

这是 BanyanDB 与 ES/JDBC 最大的架构差异。

为什么需要 schema fence
BanyanDB 的 schema 存在它自己的 _schema 存储,异步传播到每个数据节点的内存缓存。DDL(Create/Update/Delete)只发给 schema-server 节点并立即返回一个 mod_revision(现为 time.Now().UnixNano() 时间戳,删除未记录 tombstone 时为 0),不等数据节点

若数据写入时 schema 还没传播到该数据节点,会被丢弃(cannot find measure definition,日志记录后跳过)。

所以 OAP 每次 DDL 后必须 fence(栅栏等待)。

图5

BanyanDBIndexInstaller.fenceOnRevisionBanyanDBIndexInstaller.java:289)调 client.getSchemaWatcher().awaitRevisionApplied(maxRev, timeout),阻塞直到所有存活数据节点的 notifiedModRevision 水位达到 maxRev

对返回 mod_revision==0 的删除,改用 awaitSchemaDeleted(SchemaKey, timeout)(基于 key 消失等待)——因为 revision-based fence 看不见没留 tombstone 的删除(BanyanDBIndexInstaller.java:582-619SchemaWatcher.java:109)。

SchemaWatcherserver-library/library-banyandb-client/.../client/SchemaWatcher.java:60)封装三个 gRPC(SchemaBarrierService):awaitRevisionApplied:74)、awaitSchemaApplied:92,按 key 列表)、awaitSchemaDeleted:109)。

fence 尽力而为:单次预算 2s(FENCE_TIMEOUT:268-275),超时只记 WARN 列出 laggards 并继续(:332-334),非硬保证。批量运行时规则 apply 时 fence 延迟一次性——不在每个资源 fence,而是注册 flush 闭包,apply 结束后用累计 maxModRevision 一次 fence(:292-301 的 deferFence 闭包,实际执行在 doDeferredFence :322-343,用 opt 配置的更长 timeout)。

indexMode 与 allowBootReshape

  • indexModeBanyanDBModelExtension.isIndexMode()BanyanDBModelExtension.java:80):10.3.0 起,installer 自动给 indexMode measure 建一个虚拟 String tag id 作为 seriesID(BanyanDBMetricsDAO.java:90-91,113-118)。这种 measure 没有数值 field,把整条指标当倒排索引用——"按 ID 快速查到记录"的场景。
  • allowBootReshapeModel.java:51-53):仅 BanyanDB,为 true 时允许 installer 启动时做纯加性的 schema 变更(加 tag/加 field);JDBC/ES 忽略此标记(append-only 本就接受加列)。

BanyanDB 批量写入

BanyanDBBatchDAOstorage-banyandb-plugin/.../BanyanDBBatchDAO.java:37)按请求类型分流到三个 BulkWriteProcessor:

请求类型 Processor
BanyanDBStreamInsertRequest StreamBulkWriteProcessor
BanyanDBMeasureInsertRequest MeasureBulkWriteProcessor(insert 回调 onInsertCompleted:82
BanyanDBMeasureUpdateRequest MeasureBulkWriteProcessor
BanyanDBTraceInsertRequest TraceBulkWriteProcessor

三个 processor 懒初始化、各自加锁(:97-128),参数 maxBulkSize/flushInterval/concurrency 来自配置(:47-51)。这是 PersistenceTimer.flush 的真正执行端。


Elasticsearch 存储

ES 怎么模拟指标聚合

ES 是通用文档库,没有 measure 概念。OAP 用三招模拟:

图6

  1. 时间分物理索引(rolling)TimeSeriesUtilsstorage-elasticsearch-plugin/.../base/TimeSeriesUtils.java)把逻辑表名 + 时间桶后缀拼成物理索引名。writeIndexName(model, timeBucket):114)按降采样粒度截取时间桶——Minute 取 timeBucket/10000:125)、Hour 取 /100:123)、Day 取 timeBucket 原值(:127)。注意:OAP 的 timeBucket 是 yyyyMMddHHmm 形态的 long, Minute 粒度要除 10000 丢掉时分、Hour 除 100 丢掉分。DAY_STEP:46)控制几天合并一个物理索引,低流量场景设 dayStep>1 让多个逻辑日落到一个物理索引,减少索引数量。
  2. 文档 upsert(按 _id 覆盖)MetricsEsDAO.prepareBatchInsertbase/MetricsEsDAO.java:101)把指标转成 Map,用 IndexController.generateDocIdIndexController.java:80)算出稳定 _id,写进对应时间物理索引。同 _id 的新写覆盖旧文档——模拟 measure 的"同 key 同时间合并"。但 ES 的 upsert 是 read-then-write,并发下要靠版本号或 retry,远不如 measure 的原生合并高效。
  3. 查询期 aggregation:指标查询用 ES 的 date_histogram + avg/sum aggregation 在物理索引上聚合,而非像 BanyanDB 那样存储端就预聚合好。TopN 也靠查询期 terms aggregation,没有服务端预聚合。

为什么 ES 不适合时序,一句话
Lucene 为搜索而生,强在全文检索(日志 match query),但指标聚合要靠查询期扫全文档算、TTL 要靠 OAP 端定时删整个物理索引、TopN 要靠 terms 全扫。它没有列存、没有 Gorilla 编码、没有 measure 原生合并、没有 segment 级 TTL——这四样时序数据库的基本功它都没有,全靠 OAP 侧模拟补上。

index 模板、rolling、TTL 删除

  • index 模板StorageEsInstallerbase/StorageEsInstaller.java:52isExists:86)对时序表检查 isExistsTemplate:118),对比 template 的 mappings 与 settings。createMapping/createSetting 生成 ES mapping 与 index settings,ColumnTypeEsMapping:55 字段,:69 构造)负责 Java 类型→ES 类型映射。
  • rollingIndexControllerbase/IndexController.java)管理逻辑表名→物理表名映射。LogicIndicesRegister:136)维护"逻辑表→物理表"目录,支持多个逻辑 metric 模型合并写��同一个物理索引——METRICS_LOGIC_TABLE_NAME = "metrics-all":62)。分钟/小时/天指标若配置合并,都进同一物理索引,靠 metric_table 列区分(appendTableColumn :118-120,列名常量 METRIC_TABLE_NAME = "metric_table" :150)。
  • 批量写入BatchProcessEsDAObase/BatchProcessEsDAO.java:34)用 ES BulkProcessor:35),参数 bulkActions/flushInterval/concurrentRequests/batchOfBytes:36-39)。
  • TTL 删除(按时间删 index)HistoryDeleteEsDAO.deleteHistorybase/HistoryDeleteEsDAO.java:44)算 deadline(:57),只对 Minute 降采样或 record 跑一次(:48-55,因为同日所有粒度在同一索引),取出别名下所有物理索引,用 isolateTimeFromIndexNameTimeSeriesUtils.java:139)解析索引名里的时间,早于 deadline 的 deleteByIndexNameHistoryDeleteEsDAO.java:103-104)。删的是整个物理索引而非文档,契合 ES 模型且高效。

ES 的巧妙优化

  • 多个降采样粒度合并写进同一物理索引,靠 metric_table 列区分,减少索引数量。
  • TTL 删整个物理索引(而非删文档),契合 ES 分索引模型。
  • dayStep 让多个逻辑日合并一个物理索引,低流量场景省索引。

这些都是为了弥补"ES 没有原生时序聚合"的妥协。

分词器与 aggregation

  • 分词器AnalyzerSettingbase/AnalyzerSetting.java)支持自定义 analyzer/tokenizer/filter/charFilter,通过 ElasticSearchExtension 在 ModelColumn 上声明(needMatchQuery),用于日志全文检索。
  • aggregation:所有指标/TopN 聚合在查询期用 ES aggregation 完成(见上文第 3 点)。

JDBC 存储(H2/MySQL/PostgreSQL)

通用 JDBC 适配

storage-jdbc-hikaricp-plugin 用 HikariCP 连接池,一套 common 包适配多数据库,再在 mysql/postgresql 子包做方言特化(如 PostgreSQLMetricsQueryDAO 用 PG 特有 JSON/数组能力)。H2 是默认演示/测试后端。

表结构生成

JDBCTableInstallercommon/JDBCTableInstaller.java:61)是 JDBC 的 ModelInstaller

  • createTable(model):97)算当天 dayTimeBucket,调 createTable(model, timeBucket):103)→ createOrUpdateTable(table, columns, false) + createOrUpdateTableIndexes
  • createOrUpdateTable:237):先查已有列,只加缺失列——因为某些 SQL 方言没有 alter table add column if not exists:238 注释),用"查后过滤"兼容多数据库。
  • 按天分表TableHelper.getTableName + timeBucket 拼 表名_yyyyMMdd,每天一张表。additionalTables:228-232)支持附加表(如大数据列拆出去),同样按天分。
  • createOrUpdateTableIndexes:138):对非 storageOnly 列建 CREATE INDEX idx_<hash>:145,159,182),支持复合索引(:201-212)。

SQL 生成与批量插入

  • JDBCMetricsDAOcommon/dao/JDBCMetricsDAO.java:40):multiGet:45)按 ID 批查;prepareBatchInsert:56)→ getInsertExecutorprepareBatchUpdate:61)→ getUpdateExecutor
  • JDBCBatchDAOcommon/dao/JDBCBatchDAO.java:41)用 BatchQueueManager 建异步批量队列(bufferSize=10000maxIdleMs=20)。flush:65)把请求按 SQL 分组,用 BatchSQLExecutormaxBatchSqlSize 批量执行 JDBC batch。

JDBC TTL 删除(按天 drop table)

JDBCHistoryDeleteDAO.deleteHistorycommon/dao/JDBCHistoryDeleteDAO.java:52):算时间范围,取出应保留的表,用 conn.getMetaData().getTables 查所有 表名% 物理表,过滤只匹配 表名_\\\\d{8} 的,对剩余 drop table if exists:89-92)。额外表也按其 timeBucket 删。为下一天预建表(:104-106)。


三后端对比

维度 BanyanDB Elasticsearch JDBC
指标聚合模型 原生 measure(覆盖合并) 文档 upsert by _id 模拟 关系表行 + upsert
原始记录模型 原生 stream/trace 文档索引 关系表行
元数据模型 原生 property 文档索引(非时序) 关系表
时序分片 group(按降采样/TTL 分组) 按天物理索引(dayStep 可调) 按天物理表 表名_yyyyMMdd
TopN 服务端 TopNAggregation 预聚合 查询期 ES terms aggregation 查询期 SQL ORDER BY ... LIMIT
倒排索引 显式 IndexRule+IndexRuleBinding ES 原生倒排 + 自定义 analyzer 普通 CREATE INDEX(B-Tree)
TTL 删除 服务端 group IntervalRule 原生生命周期,OAP 端空实现 OAP 端按 deadline 删整个物理索引 OAP 端按 deadline drop 整张物理表 + 预建下一天
DDL 同步性 异步传播,需 SchemaWatcher fence on mod_revision 协调节点同步生效,无需 fence 协调节点同步生效,无需 fence
Schema 变更 fence 等所有数据节点;shape mismatch 记 SKIPPED_SHAPE_MISMATCH 对比 template mapping/settings,diff 报 WARN 查已有列,仅 ADD 缺失列
批量写入 三种 BulkWriteProcessor ES BulkProcessor BatchQueue + JDBC batch
是否首选 否(通用文档库,模拟 measure 有开销) 否(演示/测试/小规模)

为什么 BanyanDB 更适合
SkyWalking 核心数据形态在 BanyanDB 都是一等公民:

  • measure 原生合并省掉 ES 的 upsert 模拟;
  • TopN 服务端预聚合省掉查询期全扫;
  • group + IntervalRule 让 TTL 由存储原生处理,省掉 OAP 端删索引/删表的定时扫描;
  • 列存 + Gorilla 编码让写入和存储成本比 ES 低一个数量级;
  • 显式 IndexRule 让索引精确可控。

ES 为通用搜索而生,强在全文检索(日志 match query),但指标聚合、TTL、TopN 都得 OAP 侧模拟;JDBC 为关系数据而生,更不适合海量时序写入。三者映射同一套 Model/ModelColumn/StorageBuilder 抽象,运维只改 application.yml 即可切换,这正是存储层插件化的价值。


关键文件速查

子系统 关键类 路径
核心抽象 StorageModule oap-server/server-core/.../storage/StorageModule.java
StorageDAO / IBatchDAO / IMetricsDAO / IRecordDAO / IHistoryDeleteDAO oap-server/server-core/.../storage/*.java
PersistenceTimer oap-server/server-core/.../storage/PersistenceTimer.java
StorageBuilder / StorageBuilderFactory oap-server/server-core/.../storage/type/StorageBuilder.javastorage/StorageBuilderFactory.java
Model / ModelColumn / ModelInstaller / BanyanDBModelExtension oap-server/server-core/.../storage/model/*.java
BanyanDB BanyanDBStorageProvider oap-server/server-storage-plugin/storage-banyandb-plugin/.../BanyanDBStorageProvider.java
BanyanDBIndexInstaller / MetadataRegistry 同目录 BanyanDBIndexInstaller.javaMetadataRegistry.java
BanyanDBBatchDAO / BanyanDBMetricsDAO 同目录 BanyanDBBatchDAO.javameasure/BanyanDBMetricsDAO.java
SchemaWatcher oap-server/server-library/library-banyandb-client/.../client/SchemaWatcher.java
ES StorageModuleElasticsearchProvider oap-server/server-storage-plugin/storage-elasticsearch-plugin/.../StorageModuleElasticsearchProvider.java
StorageEsInstaller / MetricsEsDAO / HistoryDeleteEsDAO / TimeSeriesUtils .../elasticsearch/base/*.java
JDBC JDBCTableInstaller oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/.../common/JDBCTableInstaller.java
JDBCMetricsDAO / JDBCBatchDAO / JDBCHistoryDeleteDAO .../common/dao/*.java

未确认项

  • BanyanDB 是否有区别于 measure field 的独立 Histogram 原生类型——OAP 侧仅以 DataTable field 使用,服务端原生结构不在本仓库。
  • 列存/Gorilla 编码/SeriesID/segment TTL 是 BanyanDB 服务端引擎的物理结构,OAP 仓库只带 client proto(skywalking-banyandb-client-proto 子模块),服务端 Go 实现不在本仓库,上述物理层描述基于 BanyanDB 公开设计文档,OAP 侧可对照的证据已标注。

接下来

数据存好了,怎么查出来给 UI?告警又怎么基于这些数据触发?详见 06-查询层与告警。

心智模型
把存储层想成三套仓库设计图:BanyanDB 是为 SkyWalking 量身定制的专用仓库(measure 货架自动合并、TopN 区有预分拣、TTL 自动清货、列存 + Gorilla 压缩省空间),ES 是租来的通用文档库(要把指标塞进文档、靠 _id 模拟合并、靠删整库做 TTL、TopN 现扫现算),JDBC 是用关系数据库当仓库(按天分表、靠 SQL upsert 模拟合并、靠 drop table 做 TTL)。同一份"货物清单"(Model),三套仓库各有各的摆法,但对外接口(DAO)一致。