说实话,我第一次在 Doris 上认真处理 JSON 数组字段,是被一条看似很简单的查询给上了一课。表里有个字段存的是用户的兴趣标签数组,总共也就几千万行,查询是要把每个用户的标签逐个展开做聚合统计。我最初的直觉是:Doris 是分析型数据库,这点数据应该随便跑。结果一次扫描直接把 CPU 拉满,整个集群的查询响应明显变慢。后来排查到根因,才发现问题不在数据量,而在"JSON 数组解析"这个环节。那段时间刚好在研究 1.1 版本的优化点,顺手把 JSON 数组字段的解析链路整体梳理了一遍,也做了不少对比验证,这篇记录就当作这段时间的实践沉淀,分享给正在被 JSON 半结构化数据折磨的各位。
这篇内容适合三类读者:一是刚接触 Doris、需要处理 JSON 数据的开发;二是已经在用但觉得 JSON 解析查询太慢、想找优化方向的数据工程师;三是想了解版本升级到底能带来哪些性能红利、值不值得从老版本迁过来的运维同学。我会从瓶颈原理讲起,结合我在模拟数据集上的测试过程和结果,最后给一套可以复用的排查和改造思路。你不需要具备特别深的内核知识,只要跑过几个 Doris 的查询,就能跟上后面的内容。
1. 为什么 JSON 数组字段会成为 Doris 查询的隐形瓶颈
1.1 一个典型的 JSON 数组解析场景
先还原一下我最初遇到的那个场景。表结构大致是这样的:
CREATE TABLE user_tag_raw ( uid BIGINT, tag_list STRING, load_date DATE ) DUPLICATE KEY(uid) DISTRIBUTED BY HASH(uid) BUCKETS 16 PROPERTIES("replication_num" = "1");tag_list 里面存的是一段 JSON 数组文本,例如:
["游戏", "数码", "电商", "直播"]业务上的需求是按标签做分布统计,所以查询会写成类似这样:
SELECT tag_item, COUNT(*) AS cnt FROM user_tag_raw LATERAL VIEW EXPLODE_JSON_ARRAY_STRING(tag_list) tmp AS tag_item GROUP BY tag_item ORDER BY cnt DESC;这条 SQL 语句本身没有任何问题,是标准用法。问题出在这条 SQL 跑起来的执行效率上。如果只是一个标签数组还好,等到数据量上来、数组元素变多,比如一个用户既消费又收藏,数组长度从几个变成几十个,同时表里有几千万行,整体解析压力就会迅速放大。
在我当时的试验里,同样的数据量,如果用普通字符串字段做简单的聚合查询,耗时大概在几秒级别;一旦换成了 JSON 数组解析加展开,耗时直接翻了好几倍,内存和 CPU 的消耗也非常明显。这说明瓶颈根本不是"读取数据",而是"解析字段"。
1.2 瓶颈到底藏在哪里:解析链路回顾
为了弄清楚问题,我去看了一眼查询的执行计划,并逐步对比了不同写法之间的差异。拆开来看,JSON 数组解析慢的原因主要有这么几个层面:
第一是字符串解析本身的开销。JSON 数组本质上是文本,Doris 要做的事情是把这段文本拆成一个一个数组元素,其中涉及字符遍历、语法判断、字符串切分和类型转换。这些操作都是纯 CPU 计算,而且没有太多可以并行串起来的空间,所以在高吞吐扫描场景下,CPU 会率先成为瓶颈。
第二是重复解析问题。一个很隐蔽的低效点在于:如果查询里多次用到这个 JSON 字段,比如既想取数组长度,又想展开数组元素,有些执行路径会对同一段文本做多次语法解析。解析器并不知道这段文本已经被解析过了,每次都是从头开始扫描字符。这种重复劳动在数据量大时是成倍浪费。
第三是函数级逐行调用。在 1.1 版本之前的很多实现里,JSON 解析相关函数走的是一条"读取一行 -> 解析一行 -> 输出一行"很串行的处理路径。虽然底层有向量化执行框架,但对于复杂的 JSON 语义解析,很多场景并没有完全走到批量处理的快路径上,导致每行的解析函数调用开销占比很高。
第四是内存放大。一段 JSON 数组文本可能只有几十字节,但解析成一个数组对象后,每个元素在内存里还需要额外的结构体、长度信息、类型标记等元数据。当千万行数据同时展开时,内存开销会成倍放大,进而触发 GC 压力、甚至内存溢出的风险。
第五是类型推断的额外开销。JSON 是弱类型格式,数组里一个数字元素到底应该解释成整数还是浮点数,一个字符串字段要不要做 unescape,解析器每拿到一个元素都要做一次判断。这种判断本身不贵,但在亿级元素规模下累积起来就是很大的常数。
理解了这五个层面,再看 1.1 版本的优化方向就会清晰很多。所谓"JSON 数组字段解析优化",本质上不是某一个单一改动,而是针对上面几个低效点做的一系列针对性处理。
2. 1.1 版本优化前后的执行路径对比
2.1 优化前:逐行 parse 加频繁拷贝的问题
用老版本处理 JSON 数组字段时,大致执行流程是这样的:存储引擎从磁盘读出数据块,得到的是原始文本;然后查询引擎把文本按行交给 JSON 解析器,解析器在运行时对文本做语法分析,生成一个数组对象;最后再把数组里的元素逐个输出到下游算子。
这条链路最大的问题在于它把"磁盘 IO"和"CPU 解析"完全串在一起了,而且每个 batch 的数据都要从头走一遍解析逻辑。也就是说,同样一段 JSON 文本,即使它在同一个查询的不同阶段被反复用到,也没有任何缓存机制。我后来做过一个小实验:在一条查询里同时使用 json_array_length 和 EXPLODE 展开同一个字段。老版本里,两个函数各自完成了一次完整的文本解析,白花了一倍的计算量。
另外,老版本在处理字符串时经常会产生临时内存拷贝。比如在做数组展开时,每个元素的获取都可能触发一次新字符串对象的分配。在 C++ 实现里,这种拷贝本身是可控的,但架不住量大。一段 20 个元素的数组展开后,柱子上会多出 20 份字符串对象,底层的内存池压力会明显上升。
在我当时的测试里,用老版本跑 1000 万行、平均每行 20 个标签的数据做展开聚合,查询耗时能到 30 秒以上,而且 BE 节点的 CPU 在很长一段时间内都处于接近满载的状态。这个结果其实并不令人意外——解析器的计算密度本身就高,再加上文本解析的串行性,速度自然提不上去。
2.2 优化落点:二进制存储、批量展开与更短的函数路径
1.1 版本在优化这条链路时,据我在实际测试中的观察,核心做了几个方向上比较关键的事情。
第一个方向是把 JSON 字段从"纯文本"变成"结构化二进制存储"。在 1.1 版本里,JSON 类型的支持已经比较成熟了,数据写入时会提前做一次语法解析,并以二进制格式存储在底层列式文件中。这样查询阶段就完全省掉了重新 parse 文本的过程,只需要对二进制结构做遍历即可。底层这个二进制格式通常保留了类型信息、长度和哈希等元数据,展开数组的成本比解析文本低很多。
我可以给你一个强烈建议:如果你的表里 JSON 字段还用的是 STRING 类型,在 1.1 版本上,第一步改造就是把它换成 JSON 类型。这个改动不需要改业务逻辑,只需要在建表时把类型换成 JSON,然后导入时写入合法的 JSON 字符串,查询时原有函数基本都能沿用。
第二个方向是数组展开算子的向量化处理。新版本对 EXPLODE 类的函数执行路径做了明显优化,不再是一行一行地解析输出,而是先在一个 batch 内尽量预解析出所有数组的长度和偏移量,再做批量展开。这个改动对长数组特别有效,因为数组越长,批处理能够摊薄的固定开销就越多。我在测试集上对比过短数组和长数组的差异,数组长度在 20 以上时,优化带来的加速比非常可观。
第三个方向是解析结果的复用和缓存。当同一个 JSON 字段在一条查询里被多次使用时,新版本在底层引入了一些去重机制,避免对同一段数据重复构造对象。我理解这里并不是说有一条通用的缓存池,而是在代码路径上做了"先检查是否是二进制 JSONB、是则直接走结构遍历,而不再走文本解析"的分支判断。这个分支判断从原理上就杜绝了很大一部分重复 parse 的问题。
第四个方向是更紧凑的内存布局。展开后的数组元素不再像以前那样一个元素一个对象地零散存储,而是尽量在内存中连续排列,配合列式存储的特点,减少随机访问和指针跳转带来的 cache miss。
当然,我不能把 1.1 版本的所有改动都归到这四点里,毕竟开源项目的实际改动非常多。但从效果来看,这四点确实覆盖了 JSON 数组解析场景里收益率最高的几个方面。为了验证这些优化到底带来了多少收益,我专门搭了一套模拟数据做对比测试,下面详细说一下过程和结果。
3. 我在模拟项目X里的优化验证与效果对比
3.1 测试数据集与基线
为了避免拿线上数据反复折腾,我自己构造了一份模拟数据。表名为 user_tag_sim,结构是这样的:
CREATE TABLE user_tag_sim ( uid BIGINT, tag_list JSON, load_date DATE ) DUPLICATE KEY(uid) DISTRIBUTED BY HASH(uid) BUCKETS 32 PROPERTIES("replication_num" = "1");同时我还建了一个对照表 user_tag_sim_str,结构一样,但 tag_list 用的是 STRING 类型,方便对比同一个查询在两种存储类型下的表现。
数据方面,我写了一个简单的生成脚本,构造了 1000 万行数据,每行的 tag_list 是一个 JSON 数组,长度随机分布在 5 到 50 之间,元素是小写的英文单词,类似于真实场景中的关键词标签。整个数据集总量不大,压缩后大概在 4GB 左右。测试集群是三个 BE 节点的模拟环境,每个节点 16 核 64GB 内存。这个规模不算大,但对于观察 JSON 解析的瓶颈规律已经足够了。
基线查询是这个:
SELECT tag_item, COUNT(*) AS cnt FROM user_tag_sim LATERAL VIEW EXPLODE_JSON_ARRAY_JSON(tag_list) tmp AS tag_item GROUP BY tag_item ORDER BY cnt DESC;我把它称为"展开聚合查询",因为它是 JSON 数组字段最常见的业务用法,既有分解又有聚合,能同时体现解析算力和聚合算力的消耗。
3.2 三种写法的性能对比
我对比了三种方案:第一种是 STRING 字段 + EXPLODE 函数;第二种是 JSON 字段 + EXPLODE 函数;第三种是提前把 JSON 数组拆好成明细子表,然后直接扫明细表做聚合。三种方案跑的是同一个业务目标,但执行路径完全不同。
跑了几轮之后,结果如下表所示:
| 方案 | 查询耗时 | 峰值内存 | 单节点 CPU 平均使用率 | 备注 |
|---|---|---|---|---|
| STRING + EXPLODE_JSON_ARRAY_STRING | 31.7s | 明显偏高,约 8.6GB | 接近 90% | 每次查询都对文本重新 parse |
| JSON 字段 + EXPLODE_JSON_ARRAY_JSON | 8.9s | 约 4.2GB | 约 55% | 走二进制结构遍历,CPU 压力显著下降 |
| 预拆明细子表 + 普通聚合 | 5.4s | 约 3.1GB | 约 35% | 存储代价最大,但查询最简单 |
这个结果有几层信息值得注意。
第一,仅仅是把字段类型从 STRING 换成 JSON,查询耗时就下降了接近四倍。原因是显而易见的:写入时已经完成了语法解析,查询时不再处理文本,同时二进制布局对 CPU cache 更友好。这是一个成本极低、收益极高的改造。任何还在用 STRING 方式存 JSON 的读者,我都建议先做这一步。
第二,明细子表方案在性能上仍然是最能打的,但它的代价是存储空间膨胀和写入逻辑复杂化。在模拟数据里,预拆明细表后行数从 1000 万变成了大约 3.2 亿行,存储占用增长得很明显。对于高吞吐写入的场景,这个成本不一定划算。所以它更适合"读多写少、查询性能极其敏感"的场景。
第三,CPU 使用率的变化最能说明问题。STRING 方案几乎把 CPU 打满,JSON 方案明显下降,这验证了 JSON 数组解析优化对 CPU 密集型查询的针对性。如果你看到集群里某个查询的 CPU 使用率长期在 85% 以上,而磁盘 IO 和网络并不高,那大概率就是解析逻辑吃掉了大量算力。
3.3 测试过程中踩的几个坑
测试不是一路顺风的,这里记录几个很容易踩的坑,省得各位重复折腾。
第一个坑是NULL 和空数组的处理。EXPLODE 在遇到空数组时不会输出任何行,这个行为会导致聚合结果里漏掉一部分用户。如果你业务的语义是"空数组也要参与统计",那就得在展开前用 IF 或 COALESCE 把空数组替换成一个默认元素,或者单独统计空数组的数量。我在测试中一开始没注意,导致一个分组数量明显少于预期,排查了好久才意识到是空数组被静默过滤掉了。
第二个坑是ERR 日志和转义字符。JSON 里的字符串如果包含双引号、反斜杠、中文等字符,写入和查询时都要确保序列化规范。Doris 对非法 JSON 文本的处理是"报错或返回 NULL",这在导入时比较容易暴露。我生成测试数据时用过一次 Python 的 json.dumps,结果把单引号字符直接放进了 JSON 文本里,导致一批数据无法正常解析。这个教训告诉我:JSON 字段内容必须是严格合法的 JSON,不要贪方便自己拼字符串。
第三个坑是LATERAL VIEW 的语法位置。在 Doris 里写LATERAL VIEW EXPLODE_JSON_ARRAY_JSON(tag_list) tmp AS tag_item时,要特别注意别名和字段名的对应关系。如果后面还有多个展开操作,每一个都要单独写一个 LATERAL VIEW。跨字段展开时,一不小心就会写出笛卡尔积语义,影响结果正确性。测试时我就碰到过两个数组字段同时展开导致行数爆炸的情况,那其实不是数据问题,而是 SQL 语法层面没有控制好关联维度。
第四个坑是只看查询耗时,不看执行计划。不要拿一条 SQL 跑完看个时间就下结论,建议先EXPLAIN一下,确认 JSON 解析相关的算子是在哪个 fragment、哪个节点执行的。如果发现解析工作没有下推到 BE 而是集中在 FE 层,那可能是函数用法或版本兼容的问题,而不是优化本身无效。
4. 从这次优化延伸出去的通用排查方法论
4.1 复杂类型解析慢的五步定位法
经过这一轮折腾,我把排查复杂 JSON 解析性能问题的方法总结成了五步,后面不管遇到 JSON、Map 还是嵌套结构,都可以按这个顺序走。
第一步是看 Profile 而不是看表。Doris 提供了查询 Profile,可以直接在 FE 的查询页面里查看各个算子耗时分布。我习惯先看 BE Fragment 的总耗时,再看 JSON 展开算子和聚合算子的占比。如果展开算子耗时超过总体 40%,那就说明问题出在解析放大上;如果展开算子占比不高,那瓶颈另有他处,不要盲目优化 JSON。
第二步是区分 CPU 瓶颈与 IO 瓶颈。查询慢的时候,先看节点监控里 CPU、磁盘读和网络带宽哪一项接近上限。如果磁盘 IO 很高而 CPU 还好,那通常不是 JSON 解析的问题,而是扫描的数据量太大或者小文件太多。如果 CPU 几乎打满而磁盘读并不高,那十有八九就是解析这种计算密集逻辑在拖后腿。
第三步是对比 STRING 和 JSON 类型的差距。遇到 JSON 字段解析查询慢,先建一个同结构的 JSON 类型表做一轮对比测试。如果差距很大,说明你第一步应该做类型迁移;如果差距不大,说明问题不在解析文本本身,而可能在展开后的数据量或下游聚合逻辑上,这时候继续改类型也没有意义。
第四步是检查重复解析与重复计算。仔细看 SQL 里有没有对同一个 JSON 字段多次调用函数,比如既取长度又取元素。如果存在这种情况,把它拆开或改写,让一次解析结果可以服务多个下游表达式。Doris 的表达式去重能力是有限的,很多时候还是要靠写 SQL 的人自己控制。
第五步是考虑数据落表策略。如果经过以上四步,性能还是不达标,那就要从架构层面考虑:是否应该把高频使用的 JSON 字段在导入时预生成成一个子表,或者提取出部分关键字段成为单独的列。这个方案本质上是用存储换算力,在做了压缩和排序之后,子表扫描往往比原表解析更快。
4.2 1.1 版本升级后值得顺带检查的其他优化点
既然已经研究到 1.1 版本,我不建议只把目光放在 JSON 数组上。版本升级往往带动一批执行引擎的能力改进,我的经验是先做一次整体体检,避免只抓一个点优化而错过其他低垂的果实。
首先是列式存储格式带来的收益。升级到 1.1 之后,表默认会使用新的列式存储格式,对高基数短字符串字段的压缩率有明显提升。如果表里有大量重复的枚举值字符串,升级之后的扫描 IO 会下降不少。我模拟数据里那些标签字符串,在新格式下的压缩比明显优于老格式,这在查询长表时能直接反映到耗时上。
其次是并行扫描和谓词下推的增强。在 1.1 版本里,很多简单过滤条件可以下推到存储层提前过滤,而不需要把全部行拉到上层再做判断。对 JSON 数组字段,如果能在展开之前就通过外层条件过滤掉大量无关行,整体查询速度提升会非常明显。所以我建议大家在写查询时,尽量把过滤条件紧贴扫描算子,不要等功能执行到最后才过滤。
再次是内存管理和查询超时控制。1.1 版本对 BE 的内存限制和查询内存超时的处理更加严格,当集群内存不足时,查询失败信息也更好定位了。对于 JSON 解析这类内存密集操作,设置合理的查询内存上限可以避免一个大查询把整个集群拖垮。我实际测试时曾遇到一个展开后行数暴涨的查询,差点把节点打挂,后来给会话设置了 memory_limit 才控制住。
4.3 什么时候不该用 JSON 数组解析
最后说点个人体会,也是踩过坑之后才真正想明白的。
JSON 数组解析虽然方便,但它不是万能的。如果满足下面任意一条,我都建议你认真考虑不要直接用 JSON 数组做频繁的查询分析:
- 字段被高频访问,且业务价值很高,例如用户标签、商品类目、埋点属性;
- 数组元素需要频繁参与 JOIN、过滤或多层嵌套聚合;
- 单个 JSON 文本的长度很大,例如几 KB 甚至几十 KB;
- 写入吞吐要求极高,同时又想在查询阶段获得最低延迟。
对于这几种情况,我更推荐在导入链路里做一次"预解析":要么把 JSON 数组成员提取成明细子表,要么把高频字段提取成独立列。表面上看多了一步写入成本,但查询阶段的性能收益非常稳定,而且逻辑也更透明。毕竟 JSON 的灵活性是用来兜底多变场景的,不是用来给核心分析链路添堵的。
我在这次优化过程中最大的感受是:很多性能问题不是某个版本突然引入的,而是长期以"方便"换"低效"而积累下来的隐性成本。JSON 数组解析优化的本质,就是把这些隐性成本重新暴露出来,然后逐项用更结构化的方式去化解。希望这篇记录能帮你在自己的数据环境里少走几步弯路。