Linux 下用 jq 高效处理 JSON:从查询筛选到数据转换的完整指南
说实话我第一次接触jq的时候是拒绝的。作为一个常在终端里跟 JSON 打交道的开发者我之前的日常是grep查字段、sed切行、awk按列抓碰上嵌套结构干脆复制粘贴到在线工具里看。直到有一次要解析一份数万行的 JSON 日志老办法从下午折腾到晚上还被转义符和嵌套层级坑了好几次我才老老实实把jq捡起来。结果发现它不是简单的JSON 格式化工具而是一门藏在命令行里的小型查询和转换语言。这篇内容围绕 Linux 下的jq命令展开覆盖安装、基础过滤器、筛选排序、数据转换、编程式写法、真实项目复盘和常见坑。适合后端开发、运维、SRE、数据分析师以及所有需要在终端里跟 JSON 打交道的人。如果你之前只会用jq .来美化输出那这篇文章正好能帮你把它变成日常利器。1. 为什么说 jq 是处理 JSON 的标配工具1.1 传统文本工具面对 JSON 时的无力感JSON 是结构化数据而grep、sed、awk本质上是为行和列设计的。你可以在日志里 grep 出一个status:error但很难回答status 为 error 的那条记录里用户信息中的 email 是什么这类跨层级问题。更别说 JSON 里还可能有数组、嵌套对象、转义字符、Unicode 编码纯文本工具处理起来极其痛苦。举个实际例子。之前我拿到这么一条接口返回{code:0,data:{users:[{id:1,name:张三,profile:{city:北京}},{id:2,name:李四,profile:{city:上海}}]}}用grep可以把所有city:北京揪出来但没法按顺序对应到用户的id和name。用awk -F,去切字段遇到嵌套多层就乱了而且一旦 JSON 字段顺序变化整个脚本立刻失效。这就是传统文本工具的边界它们只看得到长得像文本的字符串看不到键值对之间的父子关系。jq不一样。它把 JSON 解析成树状结构再提供一套表达式语言对树进行查询、过滤和重建。你关心的是数据结构本身而不是字符串的排列方式。这也是为什么在很多服务器镜像上jq几乎成了和curl一样默认安装的基础工具。1.2 jq 的定位不只是格式化工具很多人都只知道jq .可以用来给 JSON 换行缩进但实际上这只是它最微不足道的功能。我把 jq 的能力分成几个层级理解这个分层很重要能力说明典型用法格式化输出带缩进、带颜色的 JSONjq . file.json提取按 key、索引、路径取字段jq .data.items[0].name筛选按条件过滤多条记录jq .[] | select(.age 18)转换字段重命名、拼接、构造新对象jq {id: .user.id, name: .name}统计求和、分组、排序、去重jq group_by(.city) | map(length)编程定义函数、reduce、条件循环jq reduce .[] as $x (0; . $x)把这六个层级串起来jq完全可以承担一个命令行版的小型 ETL 工具角色。很多场景下你不需要写 Python 脚本一条 jq 命令就能从 API 响应清洗到生成 CSV 报表。1.3 哪些人最应该掌握它如果你符合以下任一场景jq 值得你花一晚上系统过一遍后端开发调试接口返回、在 CI 脚本里断言某个字段的值、从响应中提取 token 或 id。运维/SRE处理应用日志、分析 Nginx 或网关的 JSON 日志、从云平台 API 响应中拉取实例列表。数据分析师快速清洗 JSON 数据、转 CSV 表格、做分组统计不用启动数据分析软件。日常用户偶尔要解析命令行工具输出的 JSON 结果比如包管理器的 metadata、系统信息采集等。jq 的核心优势是快和无依赖单个二进制文件不依赖运行时环境几乎可以嵌入任何 shell 脚本。你只要记住凡是 JSON 数据第一反应应该想到 jq而不是 grep。2. 安装与基础上手从最简单的过滤器开始2.1 各平台安装方式不同系统安装 jq 的方式差别不大我列一下常见的# Debian / Ubuntu 等 sudo apt install jq # Red Hat / CentOS / Fedora 系 sudo dnf install jq # ArchLinux 系 sudo pacman -S jq # macOS先装 Homebrew 这类包管理器 brew install jq # Windows建议用包管理器 winget install jq装完之后验证一下jq --version输出类似jq-1.7.1就算装好了。提示如果遇到没有 root 权限的服务器可以下载官方编译好的静态二进制放到~/bin或者任意目录然后把这个目录加进PATH。jq 没有运行时依赖拷贝到哪个机器都能直接跑。2.2 入门三件套点、索引与管道先准备一个测试文件demo.jsoncat demo.json EOF { name: 接口服务, version: 2.0, users: [ {id: 1, name: 张三, age: 25}, {id: 2, name: 李四, age: 17} ] } EOF第一条命令拿到name字段jq .name demo.json.name是 jq 最基本的过滤器含义是取当前对象的 name 字段。输出接口服务注意它还带 JSON 双引号因为字符串本身是 JSON 值。第二条命令拿数组里的第一个元素jq .users[0] demo.json输出{id: 1, name: 张三, age: 25}第三条命令是很多人最开始容易搞混的管道。我在这里说清楚jq 里的|和 shell 的管道作用类似都是把前面的输出传给后面继续处理只不过这里传输的不是文本流而是 JSON 值流。举个例子jq .users | .[0] | .name demo.json上面的意思是先把.users取出来然后在这个数组上取.[0]再取.name。但更快的写法是jq .users[0].name。管道更多的价值在于把一条表达式拆成多段方便组合和阅读。2.3 为什么用 jq . 而不是其他格式化工具如果你把一整行压缩过的 JSON 丢进来jq . demo.jsonjq 会自动做缩进和语法高亮。这个功能本身并不稀奇Python 也可以做到python -m json.tool demo.json但 jq 的优势是它是为管道而生。你可以在拿到接口响应的同时直接格式化curl -s https://example-api.invalid/v1/health | jq .并且可以继续往下接比如格式化完接着提取某个字段curl -s https://example-api.invalid/v1/health | jq .status另外如果输入的不是合法 JSONjq 会直接报错并给出大致位置这在排查问题的时候非常有用。python -m json.tool也能报错但输出信息对终端用户不太友好。写脚本时更常用的是-c参数输出紧凑模式把每个 JSON 对象压成一行方便逐行处理jq -c .users[] demo.json输出{id:1,name:张三,age:25} {id:2,name:李四,age:17}3. 进阶查询语法筛选、排序、去重一次学明白3.1 数组遍历 .[] 与 map 的本质区别当你要对数组里的每个元素做处理时.[]是核心操作。它会把一个数组展开成一系列独立的 JSON 值。比如jq .users[].name demo.json输出张三 李四注意这里输出的是两个字符串值而不是一个数组。这种值流stream of values是 jq 的底层设计后续的select、管道、add都依赖它。输出结果如果想重新收集成数组就要用map。map(.name)等价于[ .[] | .name ]jq .users | map(.name) demo.json输出[张三,李四]我建议你记住两条规则.[]是拆开map是拆开再组装。什么时候用哪个取决于你下一步想处理的是每一条还是整体列表。3.2 select 筛选让数据自己跳出来select是 jq 里最常用的判断过滤器。它接收一个条件条件为真就把值保留条件为假就丢弃。比如筛选成年用户jq .users[] | select(.age 18) demo.json输出{id: 1, name: 张三, age: 25}因为.users[]拆成了两条记录select对每条记录做判断李四 17 岁不满足条件就被过滤掉了。条件还可以用逻辑组合and、or、notjq .users[] | select(.age 18 and .name 张三) demo.json这里有个常见误解有人以为select必须放在管道最后。不是的它可以出现在任何阶段比如你先筛选再取字段、先取字段再筛选都可以。但实际项目中我更推荐先筛选再取字段因为这样每一步的数据结构更清晰排查问题也方便。3.3 排序、去重、取前几条sort_by和unique在处理一整个数组时非常好用。比如jq .users | sort_by(.age) demo.json按年龄升序排列。如果要降序后面再接一个reversejq .users | sort_by(.age) | reverse demo.json取年龄最大的前 1 个jq .users | sort_by(.age) | reverse | .[0] demo.json去重的两个常用写法是unique和unique_by。unique针对的是值本身去重unique_by(.field)是按某个字段去重。假设一个订单列表里同一个商品可能出现多次你想拿到不重复的商品 IDjq [.orders[].product_id] | unique data.json如果你想按product_id去重同时保留第一条完整记录jq unique_by(.product_id) data.json注意unique和sort_by都要求输入是数组因此如果前面是.[]拆出来的值流需要先用[ ... ]包回数组或者用sort_by之前的管道把整体交给它。3.4 脏数据兜底// 默认值运算符与 has 判断接口返回的数据经常有缺失字段比如李四没有填email。这时候直接取.email会得到null如果你希望缺失时给一个默认值用 jq 的//运算符jq .users[] | {name: .name, email: (.email // 未填写)} demo.json但这个运算符有个隐藏坑左侧是false时也会被替换成右侧。换句话说如果某个用户把email字段设成了false虽然不常见但确实会遇到上面这条命令会输出未填写而不是false。如果你只想针对null做兜底请明确写条件jq .users[] | {name: .name, email: (if .email null then 未填写 else .email end)} demo.json判断字段是否存在可以用has(email)jq .users[] | select(has(email)) demo.jsonhas只检查键是否存在并不关心值是不是null。这一点和(.email ! null)不同实际选用时要看你想过滤的是没写这个字段还是写了但值是空。4. 用 jq 做数据转换API 响应到表格输出4.1 字段重命名与字符串拼接调用接口时拿到的是后端定义的字段名比如user_name、user_info而你希望输出成name、info。jq 构造新对象非常顺手jq .users[] | {name: .name, city: .profile.city} demo.json输出{name:张三,city:北京} {name:李四,city:上海}字段拼接也一样比如把姓和名拼成完整显示名jq {full_name: (.first_name .last_name)} data.json这里括号不能省因为.first_name .last_name是一个整体表达式如果不用括号包住jq 会把它解析成对象里两个字段的并列定义就会报错。4.2 用 as 给中间结果命名如果想在表达式里多次引用同一个对象as变量可以帮你避免重复计算。比如jq .users[] | . as $u | {id: $u.id, name: ($u.name | ascii_upcase)} demo.jsonas在复杂转换里是救命的。之前我写过一个统计脚本要从嵌套三层的数据里提取多个字段每个字段都要重访整个路径表达式长到根本没法读。后来改成as $item每次取字段都从$item出发可读性直接上了一个台阶。4.3 输出 CSV、TSVcsv 与 tsv把 JSON 转成表格是我日常用得最多的场景。假设你要把用户列表导出成 CSVjq -r .users[] | [.id, .name, .age] | csv demo.json输出1,张三,25 2,李四,17需要注意几点一定要加-r也就是原始输出。如果不加结果会被包成 JSON 字符串你只能看到一行转义的双引号。csv接收的是一个数组数组里的每个元素对应 CSV 的一列。tsv同理只是分隔符换成了 Tab适合直接拿给column -t对齐。想带列名的话可以把表头和记录放在同一个数组里再交给csvjq -r ( [id, name, age], (.users[] | [.id, .name, .age]) ) | csv demo.json这里(..., ...)会生成两批数组第一批是表头第二批是每条用户记录。管道把它们一起交给csv后就能一次性生成带表头的 CSV。4.4 与 xargs、curl 联动串起一整条命令链jq 在管道链里最常见的配合对象是xargs。比如接口返回一批待处理的 URL你想逐个调用jq -r .items[].url list.json | xargs -I {} curl -s {}或者配合column做终端表格对齐jq -r .users[] | [.name, .age] | tsv demo.json | column -t -s $\t输出张三 25 李四 17关键技巧是能用-r就尽量用-r把 jq 的输出当成纯文本交给下游命令而不是把带引号的 JSON 字符串传过去否则你会浪费大量时间在引号转义上。5. jq 编程结构条件分支、reduce 与自定义函数5.1 if-then-else 与 empty让表达式有逻辑很多初学者看到 jq 的if会懵其实它和普通语言的if很像只是形式不同jq .users[] | if .age 18 then 成年人 else 未成年人 end demo.json输出成年人 未成年人注意这里有个容易踩的坑jq 里赋值用而判断相等用。我见过不少人写if .age 18结果 jq 直接报错。如果你要在某个条件下不输出这条数据可以用emptyjq .users[] | if .age 18 then . else empty end demo.json这样未成年用户会被直接丢弃而不是输出null。这个模式在日志清洗里非常有用比如只保留level ERROR的日志行。5.2 reduce 与 group_by做聚合统计的核心武器先看最简单的手写求和echo [1, 2, 3, 4] | jq reduce .[] as $n (0; . $n)输出10。reduce的结构大致是reduce 输入 as 变量 (初始值; 累加逻辑)。它把输入流里的每个值依次绑定到变量每次通过累加逻辑更新当前值最终返回最后一个结果。实际业务中更有用的是group_by。假设有一组订单数据你想统计每个城市有多少订单jq group_by(.city) | map({city: .[0].city, count: length}) orders.jsongroup_by(.city)会把数组按城市分组得到的结果是一个二维数组每个子数组包含同一城市的订单。然后map遍历每个分组用.[0].city取城市名用length取该组订单数量。我经常用这样一个组合拳先select过滤有效订单再group_by分组最后map构造统计对象。这一步三个动作分开写比写一大段 Python 脚本直观得多。5.3 自定义函数把重复逻辑封装起来如果同一个转换逻辑在表达式里出现多次就该考虑自定义函数了。jq 的函数定义用def没有参数的话就像变量宏jq def double: . * 2; [1, 2, 3] | map(double)输出[2, 4, 6]。带参数也很自然jq def add(x): . x; 10 | add(5)输出15。更贴近实际的例子比如你有个字段可能是1.5这样的字符串也可能是数字1.5想统一转成浮点数并乘以 100 存成整数。可以这样jq def to_cents: if type string then tonumber * 100 | round else . * 100 | round end; .orders[].amount | to_cents data.json函数定义还可以放在独立文件里用include引入。这是团队协作时的大杀器把一套 jq 公共函数放仓库里所有人都能复用。不过要注意jq 函数没有返回值关键字最后一个表达式的值就是返回值所以不要写return。5.4 大文件处理理解 --stream 的边界jq 默认会把输入完整解析进内存。如果是一个几百 MB 甚至上 GB 的 JSON 文件内存可能吃紧。这时候有两种思路如果文件是 JSON Lines 格式也就是每行一个独立的 JSON 对象直接用jq -c select(.level ERROR) app.log.jsonjq 会逐个处理每个顶层 JSON 实体内存占用基本可控。如果文件是一个超大数组比如[ {...}, {...}, ... ]可以考虑用--stream流式解析jq --stream select(length 2 and .[0] | index(id)) | .[1] huge.json但--stream的语法对新手不太友好它会把路径和值拆成很多小片段。我有一个更朴素的建议大 JSON 文件如果常见建议在生成端就改成每行一个对象既是 JSON Lines 格式对日志系统友好jq 处理起来又快又省内存。这不是 jq 的问题这是数据结构设计的问题。6. 真实项目复盘从接口响应清洗到统计报表6.1 模拟一个订单清洗场景某内部系统给了我一批订单数据orders.json结构大概是{ orders: [ { id: 10001, status: paid, total: 199.9, region: 华东, user: {name: 王五, level: vip}, items: [{name: 键盘, price: 199.9}] }, { id: 10002, status: refunded, total: 99.9, region: 华北, user: {name: 赵六, level: normal}, items: [{name: 鼠标, price: 99.9}] } ] }目标是筛选出已支付订单、提取用户姓名和区域、按区域统计订单数量和总金额最后生成 CSV 报表。6.2 清洗命令逐段拆解先看订单总量和有效订单量jq .orders | length orders.json jq [.orders[] | select(.status paid)] | length orders.json第一条输出2第二条输出1。第二行先拆数组再筛选最后用[ ... ]包回数组才能length。这一步是后面所有统计的基础因为如果筛选条件本身有问题后面统计全错。提取要输出的字段转成 TSVjq -r .orders[] | select(.status paid) | [.id, .user.name, .region, .total] | tsv orders.json输出10001 王五 华东 199.9如果这一步能肉眼看到预期数据再往下做分组统计就比较稳。6.3 分组统计报表生成按区域统计订单数量和总金额jq -r [.orders[] | select(.status paid)] | group_by(.region) | map([.[0].region, (map(.total) | add), length]) | ([region, total_amount, order_count], .[]) | csv orders.json输出region,total_amount,order_count 华东,199.9,1如果区域不止一个这里会按字母或编码顺序排好每一行代表一个地区的汇总。注意map(.total) | add这里是对分组内部的所有订单金额求和length是订单数量。6.4 结果验证多问自己一句对吗命令写出来后第一件事不是发给别人而是验证结果。我会做三个检查计数对账分组后的order_count加起来是不是等于前面筛选出的有效订单总数。金额对账分组后的total_amount加起来是不是等于全部有效订单total的加总。边界数据有没有total为null的订单被add自动忽略有没有订单区域字段为空被分到了一个空字符串组前两个问题可以直接问 jqjq [.orders[] | select(.status paid) | .total] | add orders.json如果这个数字和分组统计表的总和一致那基本可以放心。如果不一致优先怀疑筛选条件前后不一致比如一处写了status paid另一处写成了status PAID之类这类问题肉眼很难发现。7. 常见陷阱与避坑经验我花了几个晚上才想明白的事7.1 引号与转义shell 和 jq 的双层地狱jq 指令写错十有八九是引号问题。一个经典错误是jq .items[].name data.json这条在大多数时候能跑但如果表达式里有$、有!、有空格双引号会被 shell 优先解释。比如你想按某个变量去取字段KEYname jq .items[].$KEY data.jsonshell 会先把$KEY展开成name这条命令也许能碰巧跑通但表达式里一旦有特殊字符行为就不可控了。更安全的写法是单引号包住整个 jq 表达式再用--arg把 shell 变量传进去jq --arg key $KEY .items[][$key] data.json--arg会把变量变成 jq 内部的字符串值避免所有 shell 层面的引号冲突。这算是我最想告诉新手的一件事jq 表达式永远用单引号shell 变量永远走--arg不要靠字符串拼接。7.2 null 与 false 的判断陷阱jq 里只有false和null是假值其他都算真值包括0、空字符串、空数组[]。这意味着jq .count // 999如果.count是0结果还是0不会变成999如果.count是false结果会变成999。很多人写代码之前没意识到这一点导致默认值逻辑和预期不符。推荐做法是明确判断jq if .count null then 999 else .count end同样select(.field)会把field为false的条目也过滤掉如果你只想排除缺失字段要写select(.field ! null)。这个细微差别经常在统计报表里造成数万个数据的差异。7.3 金额计算的浮点精度问题jq 内部使用双精度浮点数来处理数字。直接对带两位小数的金额做add可能在底层出现类似0.1 0.2 0.30000000000000004的精度误差。虽然 jq 显示时可能会帮你舍入到常用精度但如果你把结果再传给别的系统做对账就容易出现差一分钱的尴尬。我处理金额的做法是先把元转成分也就是乘以 100用round取整再做整数运算最后如果需要在报表里展示再除以 100。比如jq [.orders[].total * 100 | round] | add / 100 orders.json这个技巧在涉及金融、订单、计费数据时尤其重要。整数运算在大多数语言里都比浮点数可靠jq 也一样。7.4 性能问题别在一个超大数组上跑全局排序最后说说性能。sort_by、group_by、unique全都是先把整个数组加载到内存再处理。如果数组有几百万条命令会非常慢甚至把机器卡死。我在一次处理网关日志时直接在原始数组上group_by(.user_id)结果内存飙升最后只能杀进程重来。正确的做法是先用select尽量缩小数据集。能拆成多行 JSON Lines 的就让 jq 逐个处理不要试图一次性塞进表达式。必须做全局聚合时先用-c和值流一条条筛出需要的字段再二次处理。比如jq -c .records[] | select(.event purchase) huge.json | head -n 1000这样输出就会在得到 1000 条后停止适合快速摸清数据格式。等你确认格式没问题再跑完整统计。我个人在写过几百条 jq 命令之后最大的体会是不要试图一次性把表达式写到完美而是像写 shell 脚本一样先取一小块数据试跑、再逐步加条件、加管道。jq 最大的学习门槛不是语法难而是你还没习惯用一堆小过滤器去组合成最终结果的思考方式。等你跨过这道坎很多原本要开 Python 才能解决的问题在终端里几秒钟就搞定了。