本文面向正在或计划在生产环境大规模使用链路追踪系统的中高级工程师与架构师。我们将深入剖析 Zipkin 这类 APM 系统在数据存储层面临的普遍挑战,从操作系统和分布式系统原理出发,探讨以 Elasticsearch 和 Cassandra 为代表的两种主流存储方案的内部机制与技术权衡,并最终给出一套从单体到混合架构的、可落地的性能与成本优化演进路线。目标是帮助你构建一个在高吞吐、低延迟、成本可控的约束下稳定运行的观测性平台。
现象与问题背景
链路追踪系统(如 Zipkin、Jaeger)在微服务架构下已成为排查问题、性能分析、理解服务依赖的“标准基础设施”。项目初期,团队引入 Zipkin,通常会选择官方推荐的 Elasticsearch 作为后端存储。起初一切安好,Trace 查询丝滑流畅,系统瓶颈一目了然。但随着业务流量增长、微服务数量从几十个膨胀到几百上千个,这套观测性系统本身开始成为团队最大的运维负担:
- 存储成本失控: 链路数据(Spans)是典型的“高写低读”时序数据。一个用户请求在复杂系统中可能产生数十个 Span。日增数据量轻易达到 TB 级别,导致 Elasticsearch 集群的磁盘成本急剧攀升。
- 查询性能雪崩: 当索引数据量超过一定阈值(通常是数十 TB),即便投入大量硬件资源,根据服务名、注解(Tags)等条件查询 Trace 的延迟也会从毫秒级恶化到数十秒甚至超时。尤其在故障排查的紧急时刻,APM 系统的不可用是致命的。
- 写入瓶颈与集群稳定性: 高并发写入请求持续冲击 Elasticsearch 集群。频繁的 Segment Merging、GC a pause 以及 Shard 分配不均等问题,导致集群健康状态频繁在 yellow 和 red 之间波动。写入延迟飙升,甚至开始出现数据丢失,链路追踪的完整性受到破坏。
问题的根源在于,我们试图用一个“通用”的搜索引擎来解决一个“专用”的、具有极端读写特性的时序数据存储问题。这种架构上的“错配”,在高负载下必然会暴露其内在矛盾。
关键原理拆解
要理解为什么会产生上述问题,以及如何解决,我们必须回到存储引擎的底层原理。这里我们对比分析 Elasticsearch(基于 Lucene)和 Cassandra 的核心数据结构与工作机制。
Elasticsearch/Lucene 的核心:倒排索引(Inverted Index)
作为一名严谨的教授,我们必须明确,Elasticsearch 的强大之处在于其核心——倒排索引。其设计初衷是为了高效地回答“哪些文档包含某个词?”这类全文检索问题。
- 工作机制: 当一个 Span 文档写入时,Elasticsearch 会对其包含的各个字段(如 serviceName, name, tags)进行分词(Analysis),然后更新一个从“词(Term)”到“文档ID列表(Posting List)”的映射。例如,`serviceName: “order-service”` 会在倒排索引中更新 “order-service” 这个 Term 对应的文档列表,追加上当前 Span 的文档 ID。
- 优势: 这种结构使得任意字段的组合查询(AND/OR/NOT)变得极为高效。你可以在毫秒内找到所有“来自 order-service、http.status_code 为 500、且延迟大于 2 秒”的 Span。这是 Kibana 能够提供丰富、灵活的仪表盘和查询界面的基石。
- 代价: 这种灵活性的代价是巨大的写入放大。一个 Span 文档可能包含几十个 Tag,每个 Tag 都会触发倒排索引的更新。索引本身会占据比原始数据大几倍甚至几十倍的存储空间。在高基数(High Cardinality)场景下——比如将用户 ID、订单 ID 作为 Tag——索引会急剧膨胀,这就是所谓的“映射爆炸”(Mapping Explosion)。此外,Lucene 的段合并(Segment Merging)过程会消耗大量 I/O 和 CPU,在高写入吞吐下成为性能瓶颈。
Cassandra 的核心:日志结构合并树(LSM-Tree)
与 Elasticsearch 不同,Cassandra 这类 NoSQL 数据库专为大规模写入密集型场景设计,其核心数据结构是 LSM-Tree。LSM-Tree 的设计哲学与传统数据库的 B-Tree 截然相反。
- 工作机制:
- 写入内存: 所有写入操作首先进入内存中的数据结构 Memtable,并同时顺序写入 Commit Log 以保证持久化。内存写入速度极快。
- 刷写磁盘: 当 Memtable 达到阈值,其内容会被排序后作为一个整体(SSTable, Sorted String Table)顺序地刷写到磁盘上。这个过程是纯粹的顺序 I/O,效率远高于 B-Tree 的随机 I/O。
- 后台合并: 随着时间推移,磁盘上会产生大量 SSTable 文件。后台的 Compaction 进程会定期将这些 SSTable 合并,清除被覆盖或删除的数据,并生成新的、更大的 SSTable。
- 优势: LSM-Tree 将随机写转换为了顺序写,极大地提升了写入吞吐能力。这完美契合了链路追踪数据“海量写入”的特点。其数据结构本身也更紧凑,没有倒排索引带来的巨大空间开销。
- 代价: 读取操作相对复杂。它可能需要查询 Memtable 和磁盘上的多个 SSTable,然后合并结果。为了优化读取,查询模式必须在表结构设计时就严格定义。你无法像在 ES 中那样对任意字段进行即席查询。查询的效率高度依赖于分区键(Partition Key)和聚类键(Clustering Key)的设计。
结论: Elasticsearch 为查询灵活性而生,牺牲了写入性能和存储效率。Cassandra 为写入性能和扩展性而生,牺牲了查询的灵活性。链路追踪数据的存储需求,恰好落在这两种模型的矛盾交汇点上:我们需要通过 Trace ID 进行极快的点查,也希望对服务名、标签等进行多维度分析。
系统架构总览
基于上述原理分析,一个兼顾性能、成本和查询能力的理想架构,不应是单一存储选型,而是一个分层的、读写分离的混合架构。以下是我们推荐的演进式架构蓝图,它通过一个数据管道将两种存储的优势结合起来。
我们可以用文字来描述这幅架构图:
- 数据采集层: 应用程序通过 Zipkin-instrumented libraries (e.g., Brave) 产生 Span 数据。通过配置合理的采样率(Sampling),从源头控制数据量。
- 数据接收与缓冲层: 所有 Span 数据不再直接发送给 Zipkin Collector,而是先发送到高吞吐的消息队列,如 Apache Kafka。Kafka 作为持久化的缓冲层,可以削峰填谷,解耦采集端和处理端,并为后续的流式处理提供数据源。
- 数据处理与分发层: 一个流处理应用(如 Flink, Spark Streaming, 或者一个定制的 Kafka Consumer Group)消费 Kafka 中的 Span 数据。这是架构的核心。这个处理器执行以下逻辑:
- 将所有(或经过高比例采样的)Span 数据,按照为 Trace ID 查询优化的模式,写入主存储 Cassandra。这部分保证了任何 Trace 都能被快速、可靠地检索到。
- 对 Span 数据流进行过滤和预处理。例如,只选择那些标记为 `error=true` 的、延迟超过特定阈值的、或来自核心业务的 Span。
- 将经过筛选和转换后的索引数据子集,写入 Elasticsearch。这部分数据用于聚合分析、错误排查和仪表盘展示。
- 存储层:
- Cassandra 集群: 作为主存储(Source of Truth),保存全量或高采样率的 Trace 数据,通过 TTL(Time-To-Live)自动清理过期数据。主要负责根据 Trace ID 的精确查询。
- Elasticsearch 集群: 作为二级索引(Secondary Index),仅保存用于分析和搜索的数据子集。数据量大幅减少,集群规模和成本得到有效控制。同样设置较短的保留策略。
- 查询层: Zipkin Query Service 需要进行定制化改造,或者在其上层封装一个查询网关。当查询请求是根据 Trace ID 发起时,直接路由到 Cassandra;当请求是复杂的条件搜索时,路由到 Elasticsearch。
核心模块设计与实现
数据采样(Sampling)
这是最直接有效的优化手段,但也是一把双刃剑。作为一线工程师,我要告诉你,不要迷信“全量采集”。对于绝大多数常规请求,1% 到 10% 的采样率足以满足日常性能分析的需求。关键是要实现动态、智能的采样策略。
例如,在 Spring Cloud Sleuth 中,你可以提供一个自定义的 `Sampler` Bean,实现基于请求路径、错误状态的自适应采样。
@Bean
public Sampler customSampler() {
return new Sampler() {
@Override
public boolean isSampled(Span span) {
// 对健康检查接口永不采样
if (span.getName().contains("health")) {
return false;
}
// 对包含错误的 Trace 强制采样
if (span.tags().containsKey("error")) {
return true;
}
// 其他情况,采用 10% 的概率采样
return Math.random() < 0.1;
}
};
}
Cassandra 表结构设计
在 Cassandra 中,数据模型即命运。一个糟糕的 Schema 会让你的集群生不如死。针对 Zipkin 的数据,核心查询是“根据 Trace ID 查询其下所有的 Span”。因此,`trace_id` 必须是分区键的一部分,或者至少是主键的一部分。
一个经过优化的 `spans` 表结构如下:
CREATE TABLE zipkin.spans (
trace_id text,
ts_uuid timeuuid, -- 用于保证 Span 在分区内的唯一性和排序
id text,
parent_id text,
name text,
service_name text,
kind text,
duration bigint,
tags map<text, text>,
annotations list<frozen<annotation>>,
-- 还有其他字段...
PRIMARY KEY (trace_id, ts_uuid)
) WITH CLUSTERING ORDER BY (ts_uuid ASC)
AND default_time_to_live = 259200; -- 设置 3 天的 TTL
极客解读:
- `PRIMARY KEY (trace_id, ts_uuid)`:`trace_id` 作为分区键,意味着同一个 Trace 的所有 Span 都会落在同一个物理节点(及其副本)上,这使得根据 Trace ID 的查询是一次高效的单分区读取。`ts_uuid` 作为聚类键,保证了 Span 在分区内部按时间戳排序,并且解决了 Span ID 可能重复的问题。
- `default_time_to_live`:这是 Cassandra 的核武器。数据在写入时就自带了“死亡倒计时”,到期后会被 Compaction 过程自动回收,无需手动的删除任务,极大地简化了数据生命周期管理。
Elasticsearch 索引模板优化
即便我们只向 ES 写入了数据子集,精细化的索引模板(Index Template)依然是降低资源消耗的关键。
{
"index_patterns": ["zipkin:span-*"],
"settings": {
"number_of_shards": 6,
"number_of_replicas": 1,
"index.codec": "best_compression"
},
"mappings": {
"dynamic": "false",
"properties": {
"traceId": { "type": "keyword" },
"serviceName": { "type": "keyword" },
"name": { "type": "keyword" },
"duration": { "type": "long" },
"timestamp_millis": { "type": "date", "format": "epoch_millis" },
"tags": {
"type": "object",
"dynamic": "true",
"properties": {
"http.status_code": { "type": "keyword" },
"error": { "type": "keyword" }
// 只定义你知道且必须查询的 tag 字段
}
}
}
}
}
极客解读:
- `"dynamic": "false"`:禁止动态映射。这是一个非常激进但有效的策略。它强制你只索引你明确需要的字段,防止高基数的 Tag 污染你的映射,导致集群元数据膨胀。任何未在 `properties` 中定义的字段都将被忽略。
- `"type": "keyword"`:对于服务名、Trace ID、标签等不需要全文检索的字段,一律使用 `keyword` 类型。这避免了分词带来的开销,并能进行精确匹配和聚合。
- `"index.codec": "best_compression"`:对于写多读少的场景,启用最佳压缩(DEFLATE)可以在牺牲少量 CPU 的情况下,显著降低磁盘占用。
性能优化与高可用设计
写路径优化
在混合架构中,Kafka 是抵御流量洪峰的第一道防线。务必为 Span topic 设置足够的分区数,以提高并行处理能力。流处理应用自身也需要横向扩展,通过增加 Consumer 实例来匹配 Kafka 分区的并行度。此外,对 Cassandra 和 Elasticsearch 的写入都应该采用批量异步的方式,例如,在 Flink 中使用 `CassandraSink` 和 `ElasticsearchSink`,并调整好 `batch size` 和 `flush interval`,这是典型的吞吐量与延迟的权衡。
读路径优化
定制 Zipkin Query 服务是关键。你需要修改或扩展其 `SpanStore` 接口的实现。
- `getTrace(traceId)` 方法:直接查询 Cassandra。这是最高频的操作,必须保证最低延迟。
- `getTraces(queryRequest)` 方法:将请求转换为 Elasticsearch 的 DSL 查询。由于 ES 中只存储了数据子集,查询速度会快得多。需要向用户明确告知,这里的搜索结果可能是不完整的,但包含了最有价值的信息(如错误和慢请求)。
高可用
- 多可用区部署: Kafka、Cassandra 和 Elasticsearch 集群都应跨多个可用区(AZ)部署。Cassandra 的副本策略(Replication Factor)和机架感知(Snitch)配置是实现跨 AZ 高可用的核心。
- 数据管道的容错: 流处理应用(如 Flink)自身具备强大的状态管理和故障恢复能力。利用 Checkpoint 机制,即使处理节点宕机,也能从 Kafka 的上一个消费位点精确一次(Exactly-once)或至少一次(At-least-once)地恢复,保证数据不丢失、不重复。
架构演进与落地路径
一口气吃不成胖子。一个复杂的混合架构不应该是一蹴而就的,而应遵循业务发展分阶段演进。
- 阶段一:起步与规范化 (0-100 服务)
- 方案: 单一 Elasticsearch 集群 + Zipkin 原生 Collector。
- 重点: 此时性能压力不大,核心任务是建立规范。推广客户端接入,统一日志与 Trace 的上下文(如 Trace ID),并实施前文提到的 Elasticsearch 索引模板优化。从一开始就养成良好的数据治理习惯。
- 阶段二:性能瓶颈期 (100-500 服务)
- 方案: 引入 Kafka 作为缓冲层,但后端依然是单一的 Elasticsearch。
- 重点: 解耦采集和存储。通过 Kafka 削峰填谷,保护 ES 集群免受流量冲击。开始实施精细化的采样策略,从源头控制数据量。对 ES 集群进行深度调优(JVM、队列大小、分片策略)。
- 阶段三:架构分离期 (500+ 服务)
- 方案: 实施 Cassandra + Elasticsearch 的混合存储架构。
- 重点: 引入流处理引擎,实现数据的分层存储。将绝大部分 Trace ID 查询的压力从 ES 转移到 Cassandra。此时,ES 集群的规模可以大幅缩减,只专注于其擅长的搜索和分析任务。这是解决规模化问题的根本性变革。
- 阶段四:成本与智能驱动 (长期)
- 方案: 引入冷热数据分离和智能异常检测。
- 重点: 利用 ES 的 ILM(Index Lifecycle Management)或云厂商提供的存储分层服务,将旧的索引数据迁移到更廉价的存储介质上。同时,在流处理层之上构建更复杂的异常检测模型,不仅仅是存储数据,而是主动地从数据中发现问题,将观测性平台从被动查询演进为主动预警。
总结而言,优化 Zipkin 的存储没有银弹。它是一个典型的分布式系统工程问题,需要架构师深入理解业务场景、数据特性和底层技术原理,并在性能、成本、功能完备性之间做出明智的权衡。从一个经过精细优化的 Elasticsearch 单一存储开始,逐步演进到基于 Kafka 和流处理的读写分离混合架构,是一条被业界头部公司反复验证过的、行之有效的路径。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。