存储层
这一篇解决什么
指标算好了,怎么存。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.yml的storage.selector。BanyanDB 首选的原因就一句话:SkyWalking 的核心数据形态在它那里都是一等公民。
Measure(指标聚合,按 seriesID + timestamp 自动覆盖合并)、stream(原始事件)、trace、property、group(TTL 与降采样隔离)、TopNAggregation(服务端预聚合 TopN)、IndexRule(显式倒排)——这六样恰好一一对应 SkyWalking 的核心数据形态。ES 是通用文档库,没有原生 measure,只能"按时间分物理索引 + 文档 upsert + 查询期 aggregation"硬模拟;JDBC 用关系表 + 按天分表模拟。BanyanDB 把这些语义下沉到存储引擎,省掉了 ES/JDBC 的大量模拟开销。
存储抽象体系
StorageModule 定义的能力契约
StorageModule(oap-server/server-core/.../storage/StorageModule.java:60)的 services() 返回一组接口,分四类:

写入/安装类(StorageModule.java:71-113):StorageBuilderFactory、StorageDAO(DAO 工厂)、IBatchDAO(批量异步写入,被 PersistenceTimer 驱动)、IHistoryDeleteDAO(TTL 删除)、ModelInstaller(DDL 安装器,把 Model 创建成后端物理 schema)、RuntimeRuleManagementDAO(运行时规则持久化)。
查询类:ITopologyQueryDAO、IMetricsQueryDAO、ITraceQueryDAO、IMetadataQueryDAO、IAggregationQueryDAO、IAlarmQueryDAO、IRecordQueryDAO、ILogQueryDAO、IEventQueryDAO、IBrowserLogQueryDAO、IZipkinQueryDAO、各 profiling 查询 DAO、ITagAutoCompleteQueryDAO、IHierarchyQueryDAO——每个都是独立接口。
写入型 DAO vs 查询型 DAO
- 写入型(Metrics/Record/NoneStream/Management,被
PersistenceTimer驱动)关心怎么把对象转成后端写入请求。- 查询型(
storage/query/I*QueryDAO,被 GraphQL query 模块调用)关心怎么把后端查询翻译成 OAP 返回结构。
StorageDAO · DAO 工厂
StorageDAO(storage/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 接口
| 接口 | 职责 | 关键方法 |
|---|---|---|
IMetricsDAO(IMetricsDAO.java:34) |
指标,唯一支持"读改写" | multiGet 读旧值、prepareBatchInsert/prepareBatchUpdate、isExpiredCache |
IRecordDAO(IRecordDAO.java:29) |
原始记录只写不更新 | prepareBatchInsert(无 update) |
INoneStreamDAO(INoneStreamDAO.java:28) |
非流式配置类 | insert(同步) |
IManagementDAO(IManagementDAO.java:28) |
管理数据 | insert(同步) |
IHistoryDeleteDAO(IHistoryDeleteDAO.java:28) |
TTL 删除 | deleteHistory(model, timeBucketColumnName, ttl) |
IMetricsDAO 为什么有 multiGet
指标是增量聚合——新数据来了要先读旧值、叠加、写回。multiGet(IMetricsDAO.java:43)按指标 ID 批量读回已有值。prepareBatchInsert(:51)转成InsertRequest,prepareBatchUpdate(:59)转成UpdateRequest。isExpiredCache(: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 与后端的解耦点
StorageBuilderFactory(storage/StorageBuilderFactory.java:34)让存储端可覆盖默认 builder。OAL/MAL 在编译期为每个指标类生成一个*Builder,默认把字段塞进 HashMap。存储插件若需更原生格式(BanyanDB 要 tag/field 分离、ES 要 mapping 友好结构),就通过StorageBuilderFactory替换 builder。这是"DSL 生成代码"和"后端原生格式"的解耦点。
Model / ModelColumn · 后端中立的表定义
Model(storage/model/Model.java:36)是后端中立的"表定义",核心字段:name、columns(List<ModelColumn>)、scopeId、downsampling(降采样粒度)、superDataset(超大数据集)、streamClass(判断 isMetric/isRecord/isTimeSeries)、timeRelativeID、allowBootReshape(Model.java:51-53),以及三个后端专属扩展 sqlDBModelExtension/banyanDBModelExtension/elasticSearchModelExtension(:54-56)。
插件化的关键设计点
Model后端中立,但通过三个*ModelExtension给每个后端留专属配置挂载位。BanyanDBModelExtension(storage/model/BanyanDBModelExtension.java:33)挂timestampColumn(:42)、traceIdColumn(:51)、indexMode(:80)、streamGroup(:84)、traceGroup(:88);ElasticSearchModelExtension挂 ES 专属;SQLDatabaseModelExtension挂附加表、复合索引。
ModelColumn(storage/model/ModelColumn.java:29)描述一列:columnName、type(:31)、storageOnly(只存不查,:36)、indexOnly(只查不存,与 storageOnly 互斥,:42)、length(:46)。byte[] 和 DataTable(指标多值哈希)强制 storageOnly=true(:86-87)——这类字段永远不能做查询条件。
ModelInstaller · 四种安装策略
ModelInstaller(storage/model/ModelInstaller.java:45)订阅 ModelRegistry 的 whenCreating(:50)/whenRemoving(:172)事件——每注册一个 Model 触发后端建表。whenCreating 实现四种策略:

抽象方法 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:808 的 Kind 分支,:1027 定义 MEASURE, STREAM, PROPERTY, TRACE):

- measure(
Kind.MEASURE):指标聚合表,按seriesID(一组 tag)+ timestamp 唯一定位,同 seriesID 同 timestamp 的新写覆盖/合并旧值——这正是 SkyWalking 指标增量聚合所需的原生语义。 - stream(
Kind.STREAM):原始事件流(普通 Record、Log、浏览器错误日志),按 timestamp 顺序追加。 - trace(
Kind.TRACE):链路,带 span 父子关系与 traceId 索引,是 stream 的特化。 - property(
Kind.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,见.gitmodules的skywalking-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》提出的浮点数压缩。两个机制叠加:
- delta-of-delta 压缩时间戳。相邻点的时间戳通常等间隔(每分钟一个点),差值是常数。对差值再求差(delta-of-delta),等间隔序列就变成一串 0。Gorilla 用一个变长 bit 编码:0 存 1 个 bit,小扰动存 2-4 bit,大跳变才存更多。近乎 1 bit/点不是夸张,是等间隔序列的真实结果。
- 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.multiGet(BanyanDBMetricsDAO.java:98 读 ext.isSeriesID() 列)。
segment TTL——BanyanDB 把数据按时间切成 segment(一段时间的列存块),每个 group 的 IntervalRule 定义 segment 生命周期。过期整 segment 直接删文件,OAP 端什么都不用做。对比 ES 要 OAP 端定时扫描删整个物理索引、JDBC 要定时 drop 整张物理表——BanyanDB 把 TTL 变成存储原生能力。
TopN 预聚合——MetadataRegistry.parseTopNSpecs(MetadataRegistry.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.notifyAfterCompleted(BanyanDBStorageProvider.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.parseMetadataMetadataRegistry.java:808-930):指标按降采样粒度分METRICS_MINUTE/METRICS_HOUR/METRICS_DAY(:898,910,922),元数据归PROPERTYgroup(: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)。 - property:
registerPropertyModel(MetadataRegistry.java:255),无时间维度,纯 tag 键值表。
Model → schema 映射
BanyanDBStorageProvider(storage-banyandb-plugin/.../BanyanDBStorageProvider.java:111)在 prepare() 注册全部 DAO 实现(:141),start() 连接 client 并 modelInstaller.start()(:245),然后 ModelRegistry.addModelListener(modelInstaller)(:247)——每个 Model 注册都回调 installer。
BanyanDBIndexInstaller.isExists/createTable(BanyanDBIndexInstaller.java:155,367)按 Model 性质分流(:200-258):

具体映射(MetadataRegistry):
registerStreamModel(:146):解析 seriesID 列(:152,必填)、tag 元数据、tagFamily、IndexRule,构造Streamproto。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(栅栏等待)。

BanyanDBIndexInstaller.fenceOnRevision(BanyanDBIndexInstaller.java:289)调 client.getSchemaWatcher().awaitRevisionApplied(maxRev, timeout),阻塞直到所有存活数据节点的 notifiedModRevision 水位达到 maxRev。
对返回 mod_revision==0 的删除,改用 awaitSchemaDeleted(SchemaKey, timeout)(基于 key 消失等待)——因为 revision-based fence 看不见没留 tombstone 的删除(BanyanDBIndexInstaller.java:582-619,SchemaWatcher.java:109)。
SchemaWatcher(server-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
- indexMode(
BanyanDBModelExtension.isIndexMode(),BanyanDBModelExtension.java:80):10.3.0 起,installer 自动给 indexMode measure 建一个虚拟 String tagid作为 seriesID(BanyanDBMetricsDAO.java:90-91,113-118)。这种 measure 没有数值 field,把整条指标当倒排索引用——"按 ID 快速查到记录"的场景。- allowBootReshape(
Model.java:51-53):仅 BanyanDB,为 true 时允许 installer 启动时做纯加性的 schema 变更(加 tag/加 field);JDBC/ES 忽略此标记(append-only 本就接受加列)。
BanyanDB 批量写入
BanyanDBBatchDAO(storage-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 用三招模拟:

- 时间分物理索引(rolling):
TimeSeriesUtils(storage-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让多个逻辑日落到一个物理索引,减少索引数量。 - 文档 upsert(按 _id 覆盖):
MetricsEsDAO.prepareBatchInsert(base/MetricsEsDAO.java:101)把指标转成Map,用IndexController.generateDocId(IndexController.java:80)算出稳定 _id,写进对应时间物理索引。同 _id 的新写覆盖旧文档——模拟 measure 的"同 key 同时间合并"。但 ES 的 upsert 是 read-then-write,并发下要靠版本号或 retry,远不如 measure 的原生合并高效。 - 查询期 aggregation:指标查询用 ES 的
date_histogram+avg/sumaggregation 在物理索引上聚合,而非像 BanyanDB 那样存储端就预聚合好。TopN 也靠查询期termsaggregation,没有服务端预聚合。
为什么 ES 不适合时序,一句话
Lucene 为搜索而生,强在全文检索(日志 match query),但指标聚合要靠查询期扫全文档算、TTL 要靠 OAP 端定时删整个物理索引、TopN 要靠terms全扫。它没有列存、没有 Gorilla 编码、没有 measure 原生合并、没有 segment 级 TTL——这四样时序数据库的基本功它都没有,全靠 OAP 侧模拟补上。
index 模板、rolling、TTL 删除
- index 模板:
StorageEsInstaller(base/StorageEsInstaller.java:52)isExists(:86)对时序表检查isExistsTemplate(:118),对比 template 的 mappings 与 settings。createMapping/createSetting生成 ES mapping 与 index settings,ColumnTypeEsMapping(:55字段,:69构造)负责 Java 类型→ES 类型映射。 - rolling:
IndexController(base/IndexController.java)管理逻辑表名→物理表名映射。LogicIndicesRegister(:136)维护"逻辑表→物理表"目录,支持多个逻辑 metric 模型合并写��同一个物理索引——METRICS_LOGIC_TABLE_NAME = "metrics-all"(:62)。分钟/小时/天指标若配置合并,都进同一物理索引,靠metric_table列区分(appendTableColumn:118-120,列名常量METRIC_TABLE_NAME = "metric_table":150)。 - 批量写入:
BatchProcessEsDAO(base/BatchProcessEsDAO.java:34)用 ESBulkProcessor(:35),参数bulkActions/flushInterval/concurrentRequests/batchOfBytes(:36-39)。 - TTL 删除(按时间删 index):
HistoryDeleteEsDAO.deleteHistory(base/HistoryDeleteEsDAO.java:44)算 deadline(:57),只对Minute降采样或 record 跑一次(:48-55,因为同日所有粒度在同一索引),取出别名下所有物理索引,用isolateTimeFromIndexName(TimeSeriesUtils.java:139)解析索引名里的时间,早于 deadline 的deleteByIndexName(HistoryDeleteEsDAO.java:103-104)。删的是整个物理索引而非文档,契合 ES 模型且高效。
ES 的巧妙优化
- 多个降采样粒度合并写进同一物理索引,靠
metric_table列区分,减少索引数量。- TTL 删整个物理索引(而非删文档),契合 ES 分索引模型。
dayStep让多个逻辑日合并一个物理索引,低流量场景省索引。这些都是为了弥补"ES 没有原生时序聚合"的妥协。
分词器与 aggregation
- 分词器:
AnalyzerSetting(base/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 是默认演示/测试后端。
表结构生成
JDBCTableInstaller(common/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 生成与批量插入
JDBCMetricsDAO(common/dao/JDBCMetricsDAO.java:40):multiGet(:45)按 ID 批查;prepareBatchInsert(:56)→getInsertExecutor;prepareBatchUpdate(:61)→getUpdateExecutor。JDBCBatchDAO(common/dao/JDBCBatchDAO.java:41)用BatchQueueManager建异步批量队列(bufferSize=10000、maxIdleMs=20)。flush(:65)把请求按 SQL 分组,用BatchSQLExecutor按maxBatchSqlSize批量执行 JDBC batch。
JDBC TTL 删除(按天 drop table)
JDBCHistoryDeleteDAO.deleteHistory(common/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.java、storage/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.java、MetadataRegistry.java |
|
| BanyanDBBatchDAO / BanyanDBMetricsDAO | 同目录 BanyanDBBatchDAO.java、measure/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 侧仅以
DataTablefield 使用,服务端原生结构不在本仓库。- 列存/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)一致。