科大讯飞: 写的多、查的快、易使用,ES Loki 到 Apache Doris 可观测性存储底座升级

用户案例
2024/10/22
科大讯飞软件研发工程师 曲庆伟

导读:科大讯飞星际日志中心经历了从 Elasticsearch 到 Loki,再到 Apache Doris 的可观测性存储分析底座升级,支持可观测三大支柱 Log Trace Metrics 的存储与分析,有效解决 Elasticsearch 成本高、Loki 查询慢的问题。Doris 能够在降低成本的同时提高查询效率,实现了查询性能提升 10 倍、存储空间缩减至 Elasticsearch 1/6。此外,Doris 提供的半结构化数据类型 VARIANT 能高效存储可扩展的 JSON 数据,具备很高的灵活性,且其性能媲美普通宽表。

指标、日志、链路是服务可观测性的三大支柱。 在保障服务稳定性方面,指标主要用于发现故障和问题,而日志和链路分析则侧重于定位和分析问题。其中,日志实际上在这三大维度中充当了重要桥梁。

讯飞星迹作为科大讯飞推出的一款全领域场景的可观测产品,旨在满足基础设施、应用及业务上的监测需求,提供一体化的解决方案。以实际场景为例,当服务上线后,需要对中间件和操作系统的 CPU 、内存等资源进行监控,这些指标能够实时反映系统的健康状态。当问题出现后,可以通过日志数据分析其来源,再利用日志链路分析迅速定位问题的位置

为更好的处理及管理日志数据,讯飞星迹推出了星迹日志中心,经过多个版本迭代后, 现已基于 Apache Doris 升级为可观测存储分析底座,实现查询性能提升 10 倍,存储空间缩减至原来 1/6。目前,在集团内多个 BG BU 的项目上稳定运行,帮助业务降本增效,同时通过业务指标分析和用户画像与行为分析助力业务增长。

科大讯飞可观测性存储底座.png

星迹可观测日志中心建设目标

科大讯飞对星迹可观测日志中心建设目标可以总结为三点:写的多、查的快、易于使用。

写的多:

  • 高吞吐、低延迟:日志数据增长迅猛,每天可产生 10 ~ 100 TB 数据、日志条数可达几十亿~几百亿条。同时,日志写入并发量也在持续提升,可达到 50 万 TPS,对日志平台的写入吞吐要求极高。

  • 低成本存储:日志数据总量庞大且保存时间较长,需实现高压缩率的存储方案,支持冷热存储和数据归档,避免占用过多存储资源。

查的快:

  • 秒级查询响应:即使面对海量数据,也需要提供稳定高性能的查询能力,以应对故障排查等对响应速度有较高要求的使用场景,分钟级的数据延迟往往无法满足业务要求。

易于使用:

  • 运维简单:要求架构极简易用,支持快速部署、扩容及运维,并能够与其他子系统串联打通,支持多云场景。

  • 使用简单:支持工程师熟悉的接口和语言比如 SQL,以及可视化的、便捷的管理界面。

可观测性存储底座架构演进

01 ELK

早期讯飞基于 Elasticsearch 搭建了如图所示的日志处理及分析架构。具体来说,日志采集器将数据上报到 Kafka,接着通过 Logstash 进行 ETL 处理,处理后的数据存储到 Elasticsearch 中,由 Elasticsearch 提供数据的查询及分析服务。

可观测性存储底座架构演进.png

存在的问题:

  • 资源占用高:无论是写入还是查询,CPU 占用率普遍较高。这主要是由于高吞吐量写入时,分词操作和 Segment 合并会导致显著的 CPU 消耗。

  • 存储成本高:受限于 Elasticsearch 的压缩率,海量日志数据存储成本相对较高。

  • 稳定性差:在进行跨天查询或处理大数据量时,系统经常出现 OOM(内存溢出)问题,且故障恢复时间较长。此外,index 加载耗时较长,可能导致写入被拒绝。

02 Loki + Cassandra

第二代架构采用了基于 Grafana Loki 的轻量化架构,通过在各应用物理机上部署采集器来实现日志数据的收集。日志数据通过 Kafka 进入 Logstash 进行 ETL 处理,最终存储到 Loki 中。Loki 的后端存储选用 Cassandra ,其采用 ZSTD 算法压缩,相较于 Elasticsearch,存储成本节约了 5 倍。具体而言,单条日志 300KB,每分钟处理 1.8 万次,经过压缩后,1 天的日志数据仅需 1.4 TB 的存储空间。

Loki + Cassandra .png

存在的问题:

  • CPU 使用率高、查询分析效率低:Loki 的架构与 Prometheus 原理相似。当以标签进行数据查询时,系统先根据 index 定位到 chunk,然后加载 chunk 中的数据,解压后逐条暴力搜索。这种处理方式显然会导致较高的 CPU 使用率,并且经常出现 OOM(内存溢出)问题,查询和分析的效率也并不理想。

  • 创建标签的数量受限: 当标签基数较少时,查询速度尚可。而当标签数量过多或搜索条件复杂时,无法对基数较大的值进行查询加速,而日志场景中的字段基数通常较高。例如,在查询每个链路的 trace ID 时,每条 trace ID 数据几乎都是唯一的,这种高基数的数据不适合使用标签来处理。

03 基于 Apache Doris 的可观测性存储底座

进一步的,为解决上述架构存储的问题,讯飞引入了 Apache Doris。使用 Apache Doris 替换了 Loki 服务。采集后的数据依旧流转至 Kafka 中,消费到日志服务加工处理,按时间和数据大小攒批,设置攒批时长为 3 分钟、数据大小为 200M,满足任一条件则触发数据发送,处理完的数据通过 Stream Load 写入到 Doris 集群中。

基于 Apache Doris 的可观测性存储底座.png

Apache Doris 引入后,可以满足文章最初提出的 3 个建设目标:

  • 写的多: 可支撑日均 600 亿条、10TB 的写入流量。与 Elasticsearch 相比,存储成本不及其 1/6,相同的数据 Elasticsearch 存储需要 1T,而依赖于 Doris 的列式存储和 ZSTD 压缩,仅需要 170 G。

  • 查的快: 查询效率提升至少 10 倍,特别是在聚合分析、短语模糊匹配及 TOPN 命中前缀索引等场景下,性能提升效果显著。Doris 即使在没有命中索引的情况下,也可以在分钟内返回结果。与 Elasticsearch 频繁出现的 OOM 相比,效率提升尤为突出。

  • 易于使用: Doris manager 可便捷管理所有 Doris 集群。科大讯飞在交付私有化项目时,通常需要管理至少数十套集群,而 Doris Manager 使这一过程变得便捷轻松。此外,系统还提供 Grafana 和自研的 Web 查询界面,方便用户进行日志检索和分析。

新架构的应用及优化经验

01 Log Trace Metric 选择合适的数据模型

Log Trace Metric 选择合适的数据模型.png

Doris 数据模型分为主键模型、明细模型和聚合模型,这三个模型在可观测日志中心都有不同程度的应用。

主键模型: 主要应用于调用链数据 Trace、配置数据的处理。主键模型能够保证 Key(主键)的唯一性,当用户更新一条数据时,新写入的数据会覆盖具有相同 Key(主键)的旧数据。

CREATE TABLE config (
  app_code varchar(50) NOT NULL COMMENT '业务唯一编码',
  app_name varchar(50) NOT NULL ,
  table_name varchar(200) NOT NULL COMMENT '数据表名',
  create_time datetime NOT NULL COMMENT '创建时间',
  update_time datetime NOT NULL COMMENT '更新时间'
) 
ENGINE = OLAP 
UNIQUE KEY(app_code) 
DISTRIBUTED BY HASH(app_code) BUCKETS AUTO
PROPERTIES (
  "enable_unique_key_merge_on_write" = "true",
  "light_schema_change" = "true"
);

**明细模型:**主要用于日志数据 Log 的处理。数据将按照导入文件中的内容进行存储,且不进行任何聚合,即使 Key 列完全相同的数据也会被保留。在建表语句中指定的 Duplicate Key 仅用于指明数据存储时按哪些列进行排序,以适应业务不断产生的数据。一旦数据产生,就不会再发生变化。

CREATE TABLE log_record
(
    `log_date` DATETIMEV2(3) COMMENT "日志打印时间",
    `source` VARCHAR(100) COMMENT "日志来源",
    `level` VARCHAR(10) COMMENT "日志级别",
    `host_name` VARCHAR(100) COMMENT "主机名",
    `msg` STRING COMMENT "日志内容",
    INDEX idx_msg (`msg`) USING INVERTED
)
ENGINE = OLAP
DUPLICATE KEY(`log_date`)
PARTITION BY RANGE (`log_date`)
(
)
DISTRIBUTED BY RANDOM BUCKETS 50
PROPERTIES (
    "compaction_policy" = "time_series",
    "dynamic_partition.enable" = "true",
    "dynamic_partition.time_unit" = "DAY",
    "dynamic_partition.create_history_partition" = "true",
    "dynamic_partition.start" = "-30",
    "dynamic_partition.end" = "7",
    "dynamic_partition.prefix" = "p",
    "compression"="zstd"
);

聚合模型: 主要用于告警指标 Metrics 的聚合。根据 Key 列聚合数据,通过提前聚合大幅提升性能,极大降低聚合查询时所需扫描的数据量和查询的计算量,适合有固定模式的报表类查询场景。

CREATE TABLE alarm_agg
(
    `id` LARGEINT,
    `first_trigger_time` DATETIME MIN COMMENT "第一次触发时间",
    `level` TINYINT MAX COMMENT "告警级别",
    `msg` STRING REPALCE COMMENT "告警内容",
    `num` INT SUM COMMENT "总数量"
)
AGGREGATE KEY(`id`)
DISTRIBUTED BY HASH(`id`) BUCKETS 10;

当相同的 ID 进入时,将根据聚合函数进行处理。例如,对于触发时间,将提取最小值作为第一次触发时间。此外,还会对某些级别和内容进行替换,并对数量进行求和。这一过程可以提前对原始数据进行聚合,从而方便后续查询。通过这种方式,后续查询时能够扫描更少的数据。

  • 数据聚合发生的时段分为三个:

    • 每一批次数据导入的 ETL 阶段。每一批次导入的数据内部进行聚合,聚合后写入 Doris 集群。
    • BE 进行数据 Compaction 阶段。BE 会对已导入的不同批次的数据进行进一步的聚合。
    • 数据查询阶段。对于查询涉及到的数据,进行对应的聚合。
  • 聚合模型的局限性

    • count(*) 查询不友好, Doris 必须扫描所有的 AGGREGATE KEY 列,并且聚合后,才能得到语意正确的结果。 当聚合列非常多时,count(*) 查询需要扫描大量的数据。

02 可扩展 JSON 数据使用半结构化数据类型 VARIANT

{
  "source":"PC",
  "time":1722562556534,
  "userId":"d3079d82",
  "properties": {
    "duration":8979,
    "mark":"标识",
    "title":"标题",
    "url":"/statistic/analysis"
  }
}

上述示例展示了一个典型的行为分析日志,其中 properties 是 JSON 格式的可扩展字段,存储了运行时长、标题和 URL 等子字段。然而,不同业务线在 properties 字段中的具体数据可能有所差异。例如,某业务线可能仅依据其标识来记录数据,如果该业务线涉及启动参数或启动状态,字段数量可能会减少,仅包含用户信息,如用户 ID。根据不同的事件类型,整个 properties 字段的内容也会有所不同。因此,在日志场景中,经常需要存储一些动态字段,如 Kubernetes 下的标签以及采集指标数据的标签等。

2-1 基于导入手动指定 json_path 静态 Schema 方案:

对于 properties 这种可扩展的 JSON 字段,最初在使用 Doris 导入时,手动配置 json_path 参数以映射到表的字段。为提供用户体验,我们还在页面上添加了可视化配置映射功能。该方案的优缺点都很明显。

  • 优点:能够确保存储和查询的效率,所有字段都映射到 Doris 表中的普通列。相较于 JSON 文本数据,列式存储在存储和查询方面的优势非常明显。
  • 缺点:不够灵活,无法动态扩展字段。每次修改都需手动调整表结构和映射关系,上下游系统需协同处理,这一过程繁琐且低效,容易出错。

2-2 基于 Doris Variant 动态 Schema 方案:

后来,我们将 Apache Doris 升级到了 2.1 版本,该版本提供了 VARIANT 数据类型,支持嵌套的不固定 Schema。VARIANT 数据类型可以存储任何合法的 JSON,可自动从 JSON 中抽取字段并推断其类型,并将这些字段存储为 VARIANT 列的子列。

使用 VARIANT 类型的表结构和查询语句如下所示:

CREATE TABLE log_variant (
    time BIGINT,
    source STRING,
    userId STRING,
    properties VARIANT
)
SELECT * FROM log_variant
WHERE time BETWEEN t1 AND t2
  AND source = 'PC'
  AND properties['duration'] > 100000;

拆分成子列并采用列式存储的方式,使得 VARIANT 具备良好的存储和分析性能。在进行聚合/过滤/排序等查询时,只需读取 VARIANT 子列数据(比如 properties['duration']) 即可,不会产生额外的数据读取和解析的开销(比如marktitleurl 等字段)。这种方案的性能与静态列相当,而相较于 JSON 字符串,性能提升存在数量级的差异。

在使用 VARIANT 类型的过程中,也遇到一些局限性并已找到解决方法。

  • 早期版本 VARIANT 不能应用在聚合模型中, 升级到 2.1.3 以上的版本可以解决。

  • 当 VARIANT 与 Group Commit 一起使用时,会出现过早反压影响写入性能的问题,升级到 2.1.5 以上的版本可以解决。

  • 在 VARIANT 列上创建索引时,会对所有子列同时创建索引。如果子列数量过多,可能导致索引数量过多,从而影响写入性能。此外,同一 VARIANT 列的分词属性是一致的。例如,如果某列包含十个字段,并使用中文分词,那么这十个字段都将应用相同的中文分词规则。

  • 日期、Decimal 等非标准 JSON 类型,会被默认推断成字符串类型,应尽可能从 VARIANT 中提取出来,用静态类型性能更好。

综合来看,建议需要 VARIANT 的用户使用最新的 2.1.x 版本,目前最新版本为 2.1.6。针对问题 3 和 4,社区已经开发了 VARIANT 内部 Schema 允许用户自定义的功能,预计将在后续版本中正式发布。

03 建表分区分桶优化

Doris 支持以表进行逻辑分区,一个 Table 下面有多个 Partition,每个 Partition 下有多个 Tablet。

建表分区分桶优化

如上图所示,Partition 是按照时间进行分区的,假设今天是 8 月 15 日,那么今天写入的数据将写入到右边分区中。该分区的意义在于,将总体数据量进一步拆分,查询时只查符合条件的分区,以此来提升查询的效率。

Tablet 分桶一般有 Hash 和 Random 这两种方式:

Hash: 数据写入时将指定 Key 进行 Hash 计算,以确定该分配到哪个 Tablet。这种方法的优势在于,当根据 ID 进行 Hash 时,查询时可以精准地定位到相应的 Tablet。具体来说,先根据时间进行过滤,筛选到特定的数据位置,由于数据是基于 Hash 进行分桶的,这样可以轻松地命中相应的 Tablet。

Random: 在数据写入时,系统会随机选择一个 Tablet,并结合 Single Tablet Load 实现将整个 Batch 数据写入同一个 Tablet 的单个文件。这种方式不仅增加了 IO 批处理的规模、提高了写入的性能,还使得时间相近的数据能够存储在一起,从而优化了存储效率。

分桶数一般设置为磁盘数量的三倍,以确保每个 Tablet 的大小保持在 1-10GB 范围内。

CREATE TABLE log_record (
  log_date DATETIMEV2(3),  
  level VARCHAR(10),
  host VARCHAR(100),
  message string,
  INDEX idx_host (host) USING INVERTED),
  INDEX idx_message (message) USING INVERTED PROPERTIES("parser" = "chinese", "support_phrase" = "true")
)
ENGINE = OLAP
DUPLICATE KEY(log_date)
PARTITION BY RANGE (log_date)()
DISTRIBUTED BY RANDOM BUCKETS 50
PROPERTIES (
 "compaction_policy" = "time_series",
 "compression"="zstd“,
 "dynamic_partition.enable" = "true", 
 "dynamic_partition.time_unit" = "DAY", 
 "dynamic_partition.create_history_partition" = "true", 
 "dynamic_partition.start" = "-30", 
 "dynamic_partition.end" = “3", 
 "dynamic_partition.prefix" = "p"
);

分区分桶优化前后的效果对比非常明显。 例如,下面的根据时间查询前 20 条数据。

SELECT * FROM log_record
WHERE log_date >= '2024-08-10 00:00:00.000' AND log_date <= '2024-08-11 00:00:00.000' 
ORDER BY log_date DESC LIMIT 0, 20

优化前(左)虽然成功返回了 20 条数据,但其扫描的行数和数据量(ScanRowsRead, ScanBytesRead)远大于优化后(右)。优化后的查询以时间为分区,并以时间作为前缀索引,该方式适用于分页场景下,能够更好的提高查询的性能,如红色圈注处,只需在每个节点扫描前 20 行数据,即可完成查询。

优化前后 Profile 对比.png

04 写入优化

  • FE 参数优化:尽量让每个节点的 Tablet 均匀

    • enable_round_robin_create_tablet = true
    • tablet_rebalancer_type = partition
  • BE 参数优化:增加写入 Buffer Size 的大小,write_buffer_size = 1073741824

  • Stream Load 参数优化:设置单 Tablet 写入,load_to_single_tablet=true

我们会在写入前攒批,压力分摊到写入模块:

写入优化.png

写入前攒批与 Group Commit 原理相似(为什么不使用 Group Commit?最初在使用时, Group Commit 还未发布),将 Kafka 消费的数据进行本地存储,也就是说相当于在写入 Doris 之前,数据已经被处理完成。通过这样的方式将写入压力分摊至写入模块,可有效减轻 BE 的压力。

此外,Doris 提供了两种 compaction 策略:size_basedtime_series,其中 time_series 适用于时序场景。

时序数据指每批导入的数据具有时间上的顺序性。由于数据写入到 Tablet 中是有序的,因此在合并时,我们可以直接对 Segment 文件进行合并。然而,在实际应用场景需要注意,可能会出现新的数据插入到原本有序的数据中。比如,第一次写入 10-12 点数据,第二次写入 13-14 点数据,第三次又写入 8-9 点数据。在这种情况下,我们通常会默认采用基于 size 的 Compaction 策略。

优化完成后,在三节点集群测试下,每分钟可写入 600 万条数据,数据流量约 4.5G。整个过程中,磁盘 IO 均值不超过 9%,BE 内存占用均值为 4G,CPU 占用均值 9%。相对于其他系统,Doris 在写入优化方面是非常优秀的,写入吞吐高延迟低,而且资源占用率低。

收益总结

可观测性存储分析底座从 ELK 到 Loki 再到 Apache Doris 的架构升级,带来了多个方面的提升,总结起来还是最初提到的 3 个点:写的多,查的快,易于使用,具体表现在:

  • 写的多: 可支撑日均 10TB,600 亿条日志的写入流量。与 Elasticsearch 相比,存储成本不到其 1/6。

  • 查的快: 查询效率提升至少 10 倍,特别是在聚合分析、短语模糊匹配及 TOPN 命中前缀索引等场景下,性能提升效果显著。

  • 易于使用: Doris Manager 使得管理多个集群变得简单,同时提供 Grafana 和自研的 Web 查询界面,方便用户进行日志检索和分析。

未来展望

未来,还将在 Doris 的基础上进行以下规划:

  • 基于讯飞星火大模型的 AIOps:持续探索智能运维的最佳实践,包括日志异常监测、故障预测和故障诊断等。

  • 用户行为分析:目前采用手动创建物化视图,未来探索如何利用 Doris 的物化视图能力实现自动物化。

  • 存算分离:计划基于 3.0 版本的存算分离,实现中心化的读写分离以及租户的物理隔离。