Logfire 通过 JSON 列 shredding 将查询速度提升最高 1000 倍
Shredding JSON columns gave Logfire up to 1000x faster queries
Logfire 通过动态 shredding 优化半结构化 attributes 数据的查询,部分查询提速超过 1000 倍,此前 30 秒超时的查询现在不到 1 秒完成。该功能将于 2026 年 2 月起向所有客户逐步推出。
前言
在 Logfire 中查询数据即将变得快得多。在某些情况下,速度可提升超过 1,000 倍。以前 30 秒后超时的查询,现在不到一秒就能完成。
“看到 查询时间快多了!感觉几乎是瞬间完成”,一位早期访问客户说道。
这篇技术博客文章详细介绍了我们如何通过动态分片(dynamic shredding)实现这一目标,这是一种针对半结构化属性数据查询的优化。我们将于 2026 年 2 月开始向所有客户逐步推出此功能。
Logfire 数据模型
OpenTelemetry 数据(即 Logfire 摄取的数据)是半结构化数据。 它混合了:
- 固定模式字段(时间戳、span 名称等)
- 半结构化属性(HTTP 状态码、用户 ID、区域等)
半结构化数据通过 attributes(每个 span 的键值对)或资源属性(Logfire 中的 otel_resource_attributes;这些是每个资源的键值对,其中资源是服务/k8s pod 等,本质上是你的应用程序或服务的一个实例)发送。
当你编写这样的代码时:
app = FastAPI()
@app.get('/user/{user_id}')
async def handle_request(user_id: str) -> dict[str, str]:
with logfire.span(f'handle_request for user {user_id}', user_id=user_id):
return {"user_id": user_id}
这会生成类似这样的导出:
{
"resource": {
"attributes": {
"service.name": "my-web-service",
"region": "us-west-2"
}
},
"spans": [
// Manual span created by user code
{
"span_name": "handle_request for user user123",
"start_timestamp": "2024-01-01T12:00:00Z",
"end_timestamp": "2024-01-01T12:00:01Z",
"message": "handle_request for user user123",
"trace_id": "0194c7b1e5f4a3b2c1d0e6f7a8b9c0d1",
"span_id": "abcdef1234567890",
"parent_span_id": "1234567890abcdef",
"attributes": {
"user_id": "user123",
"http.status_code": 200,
},
},
// Automatic span created by OpenTelemetry FastAPI instrumentation
{
"span_name": "GET /user/{user_id}",
"start_timestamp": "2024-01-01T12:00:00Z",
"end_timestamp": "2024-01-01T12:00:01Z",
"message": "GET /user/user123",
"trace_id": "0194c7b1e5f4a3b2c1d0e6f7a8b9c0d1",
"span_id": "1234567890abcdef",
"parent_span_id": null,
"attributes": {
"http.method": "GET",
"http.response": 200,
},
}
]
}
我在这里使用 JSON 进行说明,尽管我们支持多种传输格式(OTLP protobuf、JSON 等);大多数数据通过 gRPC 以 protobuf 形式发送。 还有一些细节,比如时间戳表示为整数(epoch 纳秒)而不是 ISO 字符串,以及来自 scope 的额外嵌套层级,为了清晰起见我省略了。
这里需要注意几点:
- 一些字段如
start_timestamp、end_timestamp、span_name和message是固定模式字段,每个 span 都存在 并且是强类型的。 - 属性是半结构化的,不同 span 之间可能差异很大。
一些属性如
http.status_code和http.method很常见,在 OpenTelemetry 规范中具有语义含义, 而其他如user_id则是应用程序特定的,在某些地方可能是整数,在其他地方可能是字符串。
我们最终将这些数据以 Parquet 文件形式存储在对象存储中,列式模式大致如下:
start_timestamp: Timestamp(Microsecond, "+00:00")
end_timestamp: Timestamp(Microsecond, "+00:00")
span_name: Utf8View
message: Utf8View
trace_id: FixedSizeBinary(16)
span_id: FixedSizeBinary(8)
parent_span_id: FixedSizeBinary(8)
attributes: Utf8View (JSON)
otel_resource_attributes: Utf8View (JSON)
了解一些关于列式存储、Parquet 和 zone map 剪枝的背景知识有助于理解本文的其余部分。我推荐阅读 MotherDuck 关于该主题的精彩概述。
我们的起点:快速 JSON 解析
Pydantic 最初是一个用于高效验证和解析 JSON 数据的库。
当我们构建 Logfire 时,最初将所有属性作为 JSON blob 存储在单个列中,并依赖 Jiter(任何语言中最快的 JSON 解析器之一)尽可能快地处理 JSON 数据。
这就是上面详述的布局(attributes: Utf8View (JSON))。
这种方法很简单,但犯了一个根本性错误:我们优化的是更快地完成工作,但我们实际上无法避免做这些工作。 压缩效果也不理想,因为单个变化的属性会使 Parquet 级别的字典压缩失效,甚至 zstd 也难以处理高度可变的数据、所有 JSON 语法开销等。
最大的问题实际上是额外的 IO:由于我们的大部分数据存储在对象存储上,下载大型 JSON blob 带来的额外 IO,尤其是启动这些下载的延迟,最终对查询运行时间的影响远大于任何 CPU 工作。
分片列以节省 IO 和计算
为了让查询更快,我们需要避免不必要的工作。 对于普通列,DataFusion / Parquet 通过列式存储(允许只读取你需要的列) 以及 zone maps / min-max 统计信息(允许跳过整个不匹配过滤谓词的行组或页)来避免不必要的工作。
所以当你编写这样的查询时:
SELECT count(*) FROM records WHERE duration < 0;
系统可以高效地只读取 duration 列,并根据存储的统计信息跳过 max duration 为 < 0 的行组。
在这种情况下,这是一个有效的查询,duration 是 f64,所以理论上它可能有 <0 的值。
但实际上 duration 总是 >= 0,所以这个查询会非常快:根据统计信息我们知道没有行匹配,可以立即返回 0。
那么,我们如何为像以 JSON 存储的 attributes 这样的半结构化数据实现这一点呢?
我们最初的解决方案是静态拆分:我们手动挑选一小组常用查询的属性
(如 http.status_code、http.method 等),并在摄取期间将它们提取到各自强类型的列中。
所以当我们收到:
| attributes |
|---|
| {'http.response.status_code': 200, 'url.full': 'https://example.com?foo=bar'} |
| {'my_llm_response': 'large text'} |
| {'user_id': '123'} |
假设我们硬编码的拆分配置提取 http.response.status_code、url.full 和 db.query.text,我们会存储:
| _lf_attributes | _lf_attributes_http.response.status_code | _lf_attributes_url.full | _lf_attributes_db.query.text |
|---|---|---|---|
| null | 200 | https://example.com?foo=bar | null |
| {'my_llm_response': 'large text'} | null | null | null |
| {'user_id': '123'} | null | null | null |
现在我们可以运行这样的查询:
SELECT count(*) FROM records WHERE _lf_attributes_http.response.status_code = 500;
我们可以使用统计信息来裁剪不匹配的文件 / 行组 / 页,
然后只扫描 _lf_attributes_http.response.status_code 列,该列是强类型的(int64)并且压缩效果很好。
当然,作为用户,你不必直接使用 _lf_attributes_http.response.status_code 列,你可以编写:
SELECT count(*) FROM records WHERE attributes->'http.response.status_code' = 500;
我们会在底层将其重写为使用拆分后的列。
我觉得值得提一下 _lf_attributes 前缀:当前静态拆分方法的一个挑战
是这些列对用户可见,例如如果你运行 select * from records limit 10,你会看到这些列。
由于这些是内部实现细节,我们宁愿不将它们泄露给你。
我们希望从你的视角保持 attributes 的逻辑布局为单个 JSON 列,
同时在底层优化物理布局。
缺点:灵活性有限、未拆分属性成本高且存在开销
这个方案有几个问题:
- 灵活性有限:只有一小组硬编码的属性被拆分。如果用户想查询其他属性(例如
user_id、session_id、region),他们需要承担 JSON 存储和解析的全部成本。在数据量达到数百 GB 的项目中,对这些属性的查询可能会变慢。我们无法在不修改代码和部署的情况下轻松添加新的拆分属性。这在索引方面尤其痛苦;我们只在硬编码的拆分属性集上有索引。 - 未拆分属性成本高:对未拆分属性(例如
attributes->>'user_id' = 'user123')进行过滤的查询需要扫描整个 JSON 列,导致高 I/O、压缩效果差和昂贵的 JSON 解析。这会导致查询缓慢和资源使用率高。由于数据以块的形式存储,即使单个大型未拆分属性也可能使整个列膨胀,使得所有查询都变得昂贵,即使它们并未触及该属性。 - 用户可见的列:这些被拆分的列对用户可见,导致查询结果混乱和杂乱。用户可能不理解这些内部列的用途,它们会使查询编写和结果解读变得复杂。
- 开销:如你所见,有几列可能全是
null(例如,如果应用程序不进行数据库查询,_lf_attributes_db.query.text列始终为 null)。尽管 Parquet / Arrow / DataFusion 在处理 null 方面相当高效,但这些未使用的列在存储和处理上仍有一些开销。
每个涉及 attributes 的查询,只要不是基于一小部分硬编码的拆分属性进行过滤,都要承担这些成本。
例如,考虑以下查询:
SELECT * FROM events WHERE attributes->>'my_llm_response' like '%important%';
此查询需要扫描整个 attributes JSON 列,因为 my_llm_response 未被拆分。
即使 my_llm_response 是一个大属性,它也可能是一个罕见属性,仅出现在一小部分行中。
这样想:即使在代理应用程序中,不涉及 LLM 调用的 span 也可能比涉及的多得多。
┌─────────────────────────────────────────┐
│ Cost 1: Download Entire _lf_attributes │
│ (could be GBs of data) │
└─────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ Cost 2: Poor Compression │
│ Variable structure → zstd 10% efficiency│
└──────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Cost 3: JSON Parsing │
│ Jiter does lazy parsing, worst case │
│ must parse entire row. │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Cost 4: In-Memory Operations │
│ Create copies of extracted values │
│ Do comparisons in memory │
└─────────────────────────────────────────┘
结果:此查询必须解析和扫描可能高达 GB 的 JSON 数据,其中大部分与查询无关,只是为了获取包含 my_llm_response 的少数行。
或者考虑以下查询:
SELECT * FROM events WHERE attributes->>'user_id' = 'user123';
此查询现在有相反的问题:即使 1/3 的行包含 user_id 属性,我们也必须扫描整个 JSON 列来找到它们,
这包括那些使列大小膨胀的大 my_llm_response 属性。
解决方案:动态拆分
我们不是将所有属性存储在单个 JSON 列中,而是自动将最常访问的属性提取到单独的强类型列中。 这在数据摄取和压缩期间自动发生——无需配置。
让我们比较动态拆分前后的数据物理布局。
之前(当前):
| _lf_attributes | _lf_attributes_http.response.status_code | _lf_attributes_url.full | _lf_attributes_db.query.text |
|---|---|---|---|
| null | 200 | https://example.com?foo=bar | null |
| {'my_llm_response': 'large text'} | null | null | null |
| {'user_id': '123'} | null | null | null |
之后(使用动态拆分):
| _lf_attributes | _lf_attributes_http.response.status_code | _lf_attributes_url.full | _lf_attributes_my_llm_response | _lf_attributes_user_id |
|---|---|---|---|---|
| null | 200 | https://example.com?foo=bar | null | null |
| null | null | null | large text | null |
| null | null | null | null | 123 |
此布局是根据我们看到的实际数据,为我们写出的每个 Parquet 文件动态生成的。 我们不必包含所有 null 列,并且可以优先拆分最常出现或最大的属性。
现在当上述查询运行时:
SELECT * FROM events WHERE attributes->>'my_llm_response' like '%important%';
它可以高效地仅扫描 _lf_attributes_my_llm_response 列:
┌─────────────────────────────────────────┐
│ Benefit 1: Index pruning │
│ Zone maps and full text search indexes │
│ let us skip entire files / row groups │
└─────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ Benefit 2: Download Only Relevant Column│
│ (_lf_attributes_my_llm_response) │
└──────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Benefit 3: Better Compression │
│ English text string data │
│ via zstd + dictionary encoding │
│ Maybe FastLanes in future? │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Benefit 4: Zero JSON Parsing │
│ Data already extracted & typed │
│ No deserialization, zero copy into mem │
└─────────────────────────────────────────┘
此查询现在受益于渐进式缩小/修剪和列式存储的全部效率。
它就像 my_llm_response 是模式中的一等列一样快。
动态拆分的艰难之路
我们开发这个功能已经将近 6 个月了。 它需要在 DataFusion 中进行大量上游改动,以支持 JSON 访问器的表达式下推和谓词下推。 这就是与上游开源项目合作的好处与坏处:我们可以建立在坚实的基础之上,但也必须等待上游改动落地并发布。 我们开发这个功能的方式不仅对我们自己有益,也让所有使用 DataFusion 处理半结构化或嵌套数据的用户受益,包括 Parquet/Arrow 结构体列以及新引入的 Json Variant 类型。
我们不得不重构 DataFusion 执行引擎的核心部分,将表达式下推到 Parquet 读取器中,以便它能够利用物理文件布局来优化表达式求值(在我们的场景中,就是判断是需要从 _lf_attributes 剩余列读取,还是可以使用某个已分片列)。
这项工作已经完成,并已在 DataFusion 52.1.0(2026 年 1 月)中完全落地。
剩余的工作涉及将这些表达式真正传入扫描算子,例如以下情况:
SELECT attributes->>'http.response.status_code', count(*)
FROM records
GROUP BY attributes->>'http.response.status_code';
从 GROUP BY 子句中提取 attributes->>'http.response.status_code' 表达式并将其下推到 Parquet 扫描中,以便直接使用已分片列,这有点棘手,涉及复杂的表达式重写和优化过程,但我们预计这项工作将在 2026 年 2 月前在 DataFusion 中落地。
这样做的结果是,我们能够为 DataFusion 贡献重大改进,惠及广大用户社区。
尽量减少对用户的破坏
我们计划在尽量减少错误和对用户破坏的前提下落地这项功能:首先重写我们的查询路径(非破坏性,大部分已完成),使其使用动态分片,同时在写入路径中保留静态分片。 伴随这一变更,我们将从代码库中消除并隔离所有硬编码分片列的概念,将其放入写入路径中一个自包含的部分。 本质上,我们动态分片的初始启发式策略将模仿当前的静态分片配置。 这让我们能够测试绝大多数这些变更,而无需做出任何不可逆的更改(例如写出新的物理布局)。
一旦我们确信动态分片查询路径是可靠的,我们就可以在写入路径中开启开关,使用发现算法启用动态分片。
我们如何决定分片哪些内容?
对我们生产数据的分析表明,现有 Parquet 文件中 99% 的 attributes JSON 列的唯一顶层键少于 128 个。
中位数更接近 32 个唯一键。
因此,我们选择默认在摄取/压缩期间从每个 JSON 列中提取最大的 128 个键(按所有行中的总大小计算)。
这种自动发现方法平衡了以下两者之间的权衡:
- 提取足够多的属性以覆盖常见查询模式并最大化性能收益
- 避免因分片过多属性而导致列过度增殖和开销
例如,如果你不小心开始发送像 logfire.span('foo', **{uuid.uuid4(): 'some value' for _ in range(1000)}) 这样的日志,我们不希望分片所有 1000 个动态生成的键。
该算法即使面对这种病态情况也很稳健,因为它会分片数据中占主导的非唯一键,同时将其他“意外”动态生成的键放入缩减后的 JSON 列中。
针对这些罕见键的查询仍然可以工作,只是会有通常的 JSON 解析开销。
针对其余数据的查询将保持快速。
对拆分列数量设置硬性上限,可以让我们在 Parquet 列数方面拥有可控的开销,从而避免 schema 膨胀,并避免在解析 Parquet 元数据时产生过高的开销。 我们的实际测量表明,约 1000 列对 Parquet / DataFusion 来说不是问题,但超过这个数量,你就会开始看到明显的性能开销。
数据类型是如何确定的?
当我们发现要拆分哪些属性时,还需要确定每个拆分列的数据类型。
我们通过扫描数据时跟踪每个候选属性当前计划输出的数据类型,并按需拓宽类型来实现这一点。
基本规则是 Int -> Float -> JSON、Bool -> JSON 以及 String -> JSON。
String 和 JSON 之间有一个重要区别。
考虑值 [{"user_id": "123"}, {"user_id": 456}]。
在这种情况下,我们会将数据存储为 JSON,实际上就是一个包含 JSON 字符串的 Utf8View 列:["123", 456]。
这意味着从该列读取时仍然需要 JSON 解析,但我们仍然受益于列式存储、压缩以及一定程度的裁剪。
另一方面,如果我们有 ["123", "456"],就可以直接将其存储为 Utf8View 列,而无需 JSON 解析:[123, 456]。
未来工作
使用 Parquet Variant 类型
我们的拆分数据布局早于 Parquet Variant 类型和 ClickHouse 的 JSON 支持, 但与这些方法非常相似。
我们的长期计划是采用 Parquet Variant 类型来存储剩余未拆分的属性,这将为我们提供基本相同的架构,但使用一种其他查询引擎也能读写的行业标准数据布局,并将复杂性从我们的代码转移到 DataFusion / Parquet 本身。
DataFusion 社区(包括我们)仍在研究 Parquet Variant 类型的一些细节。 也就是说,我们需要将 variant UDF 集成到 DataFusion 中,并需要添加对嵌套结构类型统计信息的支持以实现裁剪。
动态索引
我们最初将支持对拆分列进行基本的 zone map 裁剪,但未来我们希望添加更多索引类型,例如:
- 用于文本列的反向索引,以加速
LIKE和全文搜索查询,例如让你可以快速按关键词过滤 LLM 响应。 - 用于高基数列的布隆过滤器,以加速等值过滤。
基于使用情况的拆分
我们最初的方法是根据数据中属性的大小/频率进行拆分。
这让我们可以将启发式规则自包含在每个 Parquet 文件的写入路径中。
未来我们可能希望将查询使用模式纳入拆分决策。
例如,如果我们发现 user_id 经常被查询,但它并不是一个大属性,我们可能仍然希望拆分它。
这将需要随时间跟踪查询模式,并将其反馈到摄取/压缩流水线中。
我们还会使用这种方法来决定对每个拆分列采用什么索引策略。
结论
在接下来的几个月里,你可能会看到你的 Logfire 查询速度提升几个数量级。 你无需做任何事情,只需像现在一样继续使用 Logfire 即可!
来源:Hacker News · pydantic.dev