排查接口问题的时候,很多人的第一步是从浏览器开发者工具里把请求"复制为 curl",然后丢进终端反复改参数。这一步之所以成立,是因为 curl 把 HTTP 请求拆成了几个可以独立拨动的开关——URL、方法、头部、请求体,每一个都能在命令行里单独控制,改一次跑一次,结果立刻看得见。但也正因为开关太多,不少人用了几年还停在"复制粘贴改个 URL"的阶段,遇到 401、413、超时、证书报错就只能干瞪眼。下面这些内容,是我这些年把 curl 当成日常主力工具攒下来的用法和坑,从一条最普通的命令讲起,一直讲到脚本化调用和排错链路,适合刚接触命令行请求的新手,也适合想把手上的零散命令整理成体系的老手。
1. curl 在真实工作流里的位置:它替你做了哪些事
1.1 一条最普通的命令,背后跑了一整套流程
先从这条命令开始:
curl http://127.0.0.1:8080/api/users/1看着 simple,实际执行时它依次完成了这些动作:解析 URL,取出主机和端口(没写端口就按协议补 80 或 443),做名称解析拿到 IP,建立 TCP 连接,如果是 https 还要完成 TLS 握手,然后拼出一份符合 HTTP/1.1 规范的请求报文,发给服务端,读回响应,最后把响应体打印到标准输出,响应头直接丢掉。
这里有两个细节新人经常忽略。第一,响应头默认是不可见的,你看到的只是 body。第二,curl 不判断业务对错,服务端返回 500 加一段错误 JSON,curl 照样把那段 JSON 打到屏幕上,退出码还是 0。这就导致很多脚本里curl ... && echo ok明明接口挂了还打印 ok。后面第 3 节会专门讲怎么让 curl 变成一个"会报错"的命令。
1.2 不写就等于这样的默认行为清单
把默认行为搞清楚,能省掉一大半的"为什么和我预期不一样"。下面这张表是我自己整理过的,值得存一份:
| 行为项 | curl 的默认表现 | 容易踩的后果 |
|---|---|---|
| 请求方法 | GET | 想发 POST 却忘了加参数,服务端按查询接口处理 |
| 跟随重定向 | 不跟随 | 拿到 301/302 却看不到最终内容 |
| Cookie | 不带、不存 | 登录态接口永远返回未授权 |
| 响应压缩 | 不解压(除非加 --compressed) | 屏幕上出现一堆乱码字节 |
| 超时 | 无限制 | 卡死的请求会一直挂着,脚本整体堵住 |
| 重试 | 不重试 | 偶发失败直接判定为失败 |
| 错误判定 | 4xx/5xx 也退出码 0 | 自动化流程误判成功 |
| 请求头 | 只带 Host、User-Agent、Accept | 服务端做客户端识别时行为不同 |
默认的User-Agent是curl/版本号,Accept是*/*,除此之外几乎什么都不带。有些服务端会根据 UA 返回不同的内容结构,你在浏览器里调试正常、换成 curl 就字段缺失,八成就是这个原因。我一般的做法是调试阶段先补一个-H "User-Agent: 自己的标识",把变量先固定住,避免把 UA 差异误判成接口 bug。
1.3 什么场景该用 curl,什么场景别硬上
curl 最强的地方是可复制、可脚本化、可版本化。一个接口调不通,我把完整命令贴给同事,他那边跑一遍就能复现,不依赖任何界面状态。写进部署脚本、健康检查、定时任务里也很自然。
但它不适合这些场景:需要肉眼观察复杂响应结构的探索式调试(图形化工具更顺手)、需要并发压测看吞吐曲线的场合(curl 是串行工具,-Z只是浅层并行,得不到有意义的性能数据)、需要浏览器环境才能跑的请求(带前端签名、依赖 WebSocket 长连接)。认清边界比死磕工具重要,我见过有人非要用 curl 手搓一个带前端加密参数的请求,耗了半天最后还是回到浏览器里抓。
提示:把调试成功的 curl 命令整理进一个文本文件,注明每个参数改动的原因,比记在脑子里靠谱得多。半年后回头改那个接口,你会感谢当时的自己。
2. 请求四要素的组装:URL、方法、头部、请求体
2.1 URL 里的转义问题,比想象中更容易翻车
URL 里出现空格、中文、&、#、+这些字符时,curl 的行为分几种情况。空格通常直接报 URL 格式错误;中文在某些版本会被原样发出去,服务端拿到的是乱码;&如果写在查询串里没转义,会被 shell 当成命令分隔符,直接把命令截断。
最省事的做法是让 curl 自己编码。查参数拼接有两种写法:
# 老版本通用写法:-G 把 -d 的内容转成查询串,--data-urlencode 负责编码 curl -G http://127.0.0.1:8080/api/search \ --data-urlencode "keyword=数据 分析" \ --data-urlencode "page=1" # 8.3 以后可以用 --url-query,语义更清楚 curl --url-query "keyword=数据 分析" --url-query "page=1" \ http://127.0.0.1:8080/api/search-G的意思是"把数据放到 URL 上而不是请求体里",配合--data-urlencode就变成了一个安全的查询串构造器。这里有个反直觉的点:-d默认是往 body 里塞的,加了-G才变成 GET 查询串。我第一次用的时候以为-d只能 POST,绕了不少弯路。
路径里带中文或空格的情况更麻烦,因为--data-urlencode管不到 path 部分。这时候要么自己手写百分号编码(空格%20,中文按 UTF-8 逐字节编码),要么在脚本里用编码函数处理一遍。我一般会在 shell 里备一个小函数,专门干这个事,避免每次手算。
2.2 方法选择的三个常见误区
第一个误区:以为 POST 必须写-X POST。实际上只要用了-d、-F、-T这类携带请求体的参数,curl 会自动把方法切成 POST。多写一个-X POST不算错,但和-L一起用的时候会出问题——跟随重定向后,curl 会用你指定的方法去请求重定向目标,而标准行为是 301/302 之后切换成 GET。这就是"加了 -L 之后接口报 405"的经典来源。
第二个误区:想发带 body 的 GET,于是写-X GET -d "a=1"。这样发出去的确实是 GET 带 body,但很多服务端和中间层会直接丢弃或拒绝这种请求。正确姿势是-G加-d,让参数落到查询串上。
第三个误区:拿-I当"看响应头"的通用手段。-I发的是 HEAD 请求,不是 GET。有些接口不支持 HEAD,会返回 405 或者干脆给一个空响应,让你误以为接口坏了。只想看响应头又想发 GET,应该用-i(小写),它会把响应头和 body 一起输出。
curl -I http://127.0.0.1:8080/api/users/1 # HEAD 请求 curl -i http://127.0.0.1:8080/api/users/1 # GET,同时显示响应头 curl -s -D - -o /dev/null http://127.0.0.1:8080/api/users/1 # 只要头,body 丢弃第三种写法是我最常用的,-D -表示把响应头写到标准输出,-o /dev/null把 body 扔掉,输出干净得可以直接 grep。
2.3 -H 的覆盖规则和"删掉一个头"的技巧
用-H指定同名头会覆盖 curl 的默认值,不同名则是追加。多个-H可以写很多次,顺序不影响结果。这套规则大部分时候好用,但有两个例外值得记住。
一是Content-Type。-d会默认带上application/x-www-form-urlencoded,你要发 JSON 就必须显式覆盖:
curl -X POST http://127.0.0.1:8080/api/users \ -H "Content-Type: application/json" \ -d '{"name":"A同学","age":18}'二是删除某个默认头。写法是给一个空值:-H "Expect:"。这个技巧在上传大文件时特别有用,curl 对超过一定大小的-F上传会自动加Expect: 100-continue,某些服务端处理不好这个协商过程,表现为请求卡住几秒才发出去,或者直接返回 417。加上这个空值头把 Expect 去掉,问题往往就消失了。这个坑我踩过一次,查了半天以为是网络慢,最后发现是等 100-continue 的响应超时。
2.4 请求体的四种构造方式对照
请求体是最容易混淆的部分,因为 curl 提供了好几个长得差不多的参数。整理成表格看会清楚很多:
| 参数 | 默认 Content-Type | 适用场景 | 关键差异 |
|---|---|---|---|
-d | application/x-www-form-urlencoded | 普通表单、短 JSON | 会去掉换行;@file读文件内容 |
--data-urlencode | 同上 | 需要编码的键值对 | 会对值做百分号编码,中文安全 |
--data-binary | 无(需自己指定) | 二进制、保留原样的大 JSON | 不做任何处理,换行保留 |
-F | multipart/form-data | 文件上传、混合表单 | 自动生成 boundary,可指定文件类型 |
-T | 无 | 整文件 PUT 上传 | 直接把文件当 body,不构造表单 |
--json | application/json | 纯 JSON 请求(7.82+) | 同时设置 Content-Type 和 Accept |
几个实操要点。-d @payload.json会读取文件内容,但它会去掉换行,如果你的 JSON 里有字符串包含换行,内容会被破坏,这种场景应该用--data-binary @payload.json。-F上传文件时可以附带类型:-F "file=@report.pdf;type=application/pdf",也可以从文件读一个字段值:-F "token=<token.txt"(注意是<不是@,@表示作为文件上传,<表示读取文件内容作为字段值)。这个区别很多人搞混,结果传上去的是一个叫 token 的文件而不是字段。
3. 响应解析与观测:让 curl 自己把问题说清楚
3.1 -v 输出怎么读:三段结构
-v是排查问题的第一把钥匙,它的输出严格分成三类前缀:
* Trying 127.0.0.1:8080... * Connected to 127.0.0.1 (127.0.0.1) port 8080 > GET /api/users/1 HTTP/1.1 > Host: 127.0.0.1:8080 > User-Agent: curl/8.5.0 > Accept: */* > < HTTP/1.1 200 OK < Content-Type: application/json < Content-Length: 42 <*开头是连接层信息,包括名称解析结果、TCP 连接、TLS 握手细节、连接复用情况;>开头是实际发出去的请求行和请求头;<开头是收到的状态行和响应头。中间那个单独的空行>表示请求头结束,body 从这里开始。
读这段输出有个顺序:先看*段有没有报错(连不上、证书问题都在这里),再看>段的方法和路径是否正确(路径拼错、参数没带上,一眼就能看出来),最后看<段的状状码和关键头(Content-Type、Content-Length、Location都在这里)。
注意:
-v会把Authorization、Cookie这些头原样打印出来。在共享终端、录屏、贴日志到公开渠道之前,务必先确认没有敏感串。我习惯在需要贴日志时加一个sed把 token 部分替换掉再发。
-v还不够细的时候,可以上--trace或--trace-ascii,这两个会把收发的原始字节都打出来,包括 TLS 层的记录。日常用不上,排查"服务端说没收到某个头"这类争议时很有说服力。
3.2 -w 模板:把耗时和状态码变成一行结构化数据
-w(--write-out)是我认为 curl 里最被低估的功能。它让你在请求结束后输出一段自定义文本,里面可以嵌入几十个变量。最常用的组合长这样:
curl -s -o /dev/null -w "code=%{http_code} total=%{time_total}s connect=%{time_connect}s ttfb=%{time_starttransfer}s size=%{size_download}\n" \ http://127.0.0.1:8080/api/users/1输出一行code=200 total=0.043s connect=0.002s ttfb=0.038s size=42,干净利落,可以直接喂给监控脚本或者写进日志。几个变量的含义值得记牢:
| 变量 | 含义 | 用法场景 |
|---|---|---|
%{http_code} | 最终状态码 | 判断成功失败 |
%{time_total} | 整个请求总耗时 | 接口耗时统计 |
%{time_connect} | TCP 建连耗时 | 判断是否是网络层慢 |
%{time_starttransfer} | 首字节时间(TTFB) | 判断是服务端处理慢还是传输慢 |
%{size_download} | 下载字节数 | 校验是否拿到完整响应 |
%{num_redirects} | 重定向次数 | 排查重定向环 |
%{url_effective} | 最终生效的 URL | 配合 -L 看落到了哪里 |
%{remote_ip} | 实际连接的 IP | 排查多实例负载不均 |
%{ssl_verify_result} | 证书校验结果码 | 0 表示通过 |
把time_connect和time_starttransfer放一起看,能快速定位瓶颈:两个都大说明网络链路问题,time_connect小但time_starttransfer大说明服务端处理慢。这个判断我自己用了很多次,比看日志猜有效得多。
3.3 让 curl 在出错时真的报错
默认情况下 4xx/5xx 也算"请求成功完成",退出码是 0。要改变这个行为,用--fail:
# 状态码 >= 400 时退出码变 22,且不输出 body curl --fail -s http://127.0.0.1:8080/api/users/999 # 想同时保留 body 用于看错误详情(7.76+) curl --fail-with-body -s http://127.0.0.1:8080/api/users/999--fail有个副作用:它会把响应体丢掉,你想看服务端返回的错误 JSON 就看不到了。所以调试阶段用--fail-with-body,脚本里只关心成败就用--fail。另外-s和-S建议成对使用:-s静默掉进度条和错误提示,-S在出错时把错误信息打回来。只写-s会导致报错时一片沉默,非常难查。
4. 会话类场景:Cookie、Token 与证书
4.1 cookie jar 的读写机制
curl 有两组 Cookie 参数,容易混:-b(--cookie)用于发送,-c(--cookie-jar)用于保存。两者可以同时出现:
# 第一次:登录并把服务端下发的 Cookie 写入文件 curl -c cookies.txt -d "user=demo&pass=demo123" http://127.0.0.1:8080/login # 后续:带上文件里的 Cookie 继续请求 curl -b cookies.txt http://127.0.0.1:8080/api/profile-b的参数有两种形态:如果值是name=value形式,就当 Cookie 字符串直接发;如果值是一个存在的文件路径,就从文件读。这个"看值判断"的规则偶尔会出问题——比如你恰好有个文件叫session=abc,那就争不清楚了。规避方法是文件路径带上./前缀或者绝对路径。
jar 文件的格式是 Netscape 那套纯文本,可以用编辑器直接看。这里有个必须知道的坑:没有过期时间的会话 Cookie 默认不写入 jar。很多登录接口下发的就是会话 Cookie,你-c保存完打开文件一看是空的,以为登录失败了。解决办法是用-b和-c指向同一个文件,让 curl 在同一进程内维持会话,或者在需要落盘时接受"只能保存带过期时间的 Cookie"这个限制。
4.2 认证头的几种写法与泄漏风险
基础认证用-u,curl 会自动做 base64 并加上Authorization: Basic ...:
curl -u demo:demo123 http://127.0.0.1:8080/api/private注意这只是编码不是加密,明文可还原。所以这套东西只能跑在 https 上,而且密码写在命令行里会被 shell 历史记录、ps输出捕获。更隐蔽的写法是用-u demo只给用户名,curl 会交互式提示输入密码;或者用-K从配置文件读取参数:
# auth.cfg 内容: # user = "demo:demo123" curl -K auth.cfg http://127.0.0.1:8080/api/private配置文件记得改权限为 600。Bearer Token 就是普通头,-H "Authorization: Bearer <token>",同理不要把真实 token 写进会提交到版本库的脚本里,从环境变量取更合适:
curl -H "Authorization: Bearer ${API_TOKEN}" http://127.0.0.1:8080/api/private--digest是另一种认证方式,会先收到 401 再带摘要重发,多一个来回。现在新接口基本不用了,遇到老的内部系统可能会碰见。
4.3 TLS 相关开关的取舍
-k(--insecure)跳过证书校验,是很多人遇到证书报错后的第一反应。它能解决问题,但代价是把中间人攻击的防护也一起关掉了。我的原则是:只在本地自签证书的测试环境用,并且用完就删掉这条命令,绝不带进任何会跑在真实环境的脚本里。
正规做法是带上自签 CA:--cacert ./ca.pem。客户端双向认证则是--cert client.pem --key client.key,如果证书和私钥在同一个文件里,只给--cert就够了。协议版本可以锁--tlsv1.2,排查"某台机器握手失败"时这个开关很有用。
想验证某台机器的证书链是否完整,用--resolve把域名强行指到目标 IP,比改 hosts 文件干净:
curl --resolve api.example.com:443:127.0.0.1 https://api.example.com/health -v这样既走了完整的 TLS 校验流程,又不用污染系统配置。证书过期、证书链不全、域名不匹配这三类问题,-v输出里都会明确写出来,配合退出码 60 就能定位。
5. 文件传输:下载、续传、上传的实操
5.1 输出文件名的三种策略
下载文件时,-O、-o、-J三个参数的行为差别很大:
| 参数 | 文件名来源 | 风险 |
|---|---|---|
-O | URL 路径最后一段 | 大量下载容易撞名覆盖,路径带查询串时名字会很怪 |
-o name | 自己指定 | 批量场景要自己拼名字 |
-O -J | 响应头Content-Disposition | 文件名由服务端决定,可能含路径穿越字符 |
-J必须和-O一起用,单独写不生效。-J的风险值得强调一下:如果服务端返回的Content-Disposition里带../,老版本 curl 会老老实实按那个路径写文件。所以对不可信来源,宁可自己用-o指定名字。
批量下载时我一般这么写,把序号拼进文件名:
for i in 1 2 3 4 5; do curl -sS -o "page_${i}.json" "http://127.0.0.1:8080/api/items?page=${i}" done--output-dir可以统一指定输出目录(7.73+),省得在循环里拼长路径。
5.2 断点续传、限速与条件下载
-C -让 curl 自动从已下载文件的末尾继续,前提是服务端支持 Range 请求,返回 206。如果服务端不支持,你会拿到一个 200 加完整内容,文件被从头覆盖,这时候-C反而是坑。判断方法很简单:先用-I看响应里有没有Accept-Ranges: bytes。
curl -C - -O http://127.0.0.1:8080/files/big.tar.gz curl --limit-rate 200k -O http://127.0.0.1:8080/files/big.tar.gz curl -z "last-modified.txt" -O http://127.0.0.1:8080/files/data.csv--limit-rate在共享带宽的环境里很有用,不加限制的大文件下载能把整条链路占满。-z是条件下载,只在远端文件比指定时间新时才下载,写定时同步脚本时特别省事——我有个每天跑的数据拉取任务,加上-z之后大部分时间都是空跑,几乎没有流量。
5.3 上传:-F 和 -T 的本质区别
-F走的是 multipart 表单,适合"一个请求里既有文件又有普通字段"的场景,服务端按表单解析。-T(--upload-file)是把文件内容整个当成请求体,适合对象存储这类按 PUT 语义接收的接口。选错了会得到 400 或 415。
# 表单上传,带额外字段和文件类型 curl -F "file=@report.pdf;type=application/pdf" \ -F "bucket=docs" \ http://127.0.0.1:8080/api/upload # 整文件 PUT curl -T report.pdf http://127.0.0.1:8080/api/objects/report.pdf大文件用-F时记得加上前面提到的-H "Expect:",能省掉那段莫名的等待。另外上传前用-w "%{size_upload}"确认一下实际发出的字节数,很多时候"上传成功但文件损坏"的根因是 shell 通配符展开错了文件,或者-d @把内容吃了换行。
5.4 批量任务里的重试和并行
--retry系列参数在批量场景里价值很高:
curl --retry 3 --retry-delay 2 --retry-connrefused \ --max-time 30 -sS -o out.json http://127.0.0.1:8080/api/data--retry默认只对"瞬时错误"(如超时、连接重置)重试,连接被拒绝这类不算,需要显式加--retry-connrefused。所有错误都重试则是--retry-all-errors,慎用——4xx 重试三次纯属浪费。
并行方面,-Z(--parallel)可以让一个 curl 进程同时跑多个 URL,--parallel-max控制并发数。但它更适合几十个 URL 的小批量,上百个任务我还是更愿意用xargs -P加上单个 curl,因为失败重试和日志分离都更好控制。
配置文件-K是另一个提升可维护性的手段。把公共的 header、超时、重试参数写进一个文件,各个脚本-K common.cfg引用,改一处全生效。参数多了之后,命令行会变得完全不可读,早一点迁到配置文件能省很多事。
6. 排错链路:从现象到根因的排查顺序
6.1 分五层看,不要一上来就猜业务
请求失败时,我固定按这个顺序走,基本不会绕远路:
- 看退出码。退出码 0 但结果不对,说明是业务层问题;非 0 说明是传输层问题,直接跳到对应层级。
- 看名称解析。
-v里如果卡在Trying之前,或者根本没打印出 IP,说明名称解析就没成功。 - 看连接。有 IP 但连不上,是端口、防火墙或者服务没起。
- 看 TLS。https 场景下握手失败会明确写出来,证书问题通常带退出码 60。
- 看请求和响应。前面都通了,才轮到检查方法、路径、头、body、状态码。
这个顺序的价值在于避免在错误的层级上浪费时间。我见过有人为了一个"超时"报错反复改请求体,实际上连接根本没建立起来。
6.2 常见现象对照表
| 现象 | 大概率原因 | 第一步动作 |
|---|---|---|
Could not resolve host | 域名拼错、DNS 不可达 | 用 IP 直连验证 |
Connection refused | 服务未启动、端口不对 | curl -v确认端口 |
Operation timed out | 链路不通、服务假死 | 加--connect-timeout 5区分建连还是读超时 |
SSL certificate problem | 证书过期、自签未被信任、缺中间证书 | -v看具体原因,自签用--cacert |
Empty reply from server | 服务端主动断连、协议不匹配 | 检查是否 http 打了 https 端口 |
| 415 Unsupported Media Type | Content-Type缺失或不对 | 显式补-H "Content-Type: ..." |
| 411 Length Required | 少Content-Length | 用--data-binary或-T |
| 413 请求体过大 | 超过服务端或中间层限制 | 分片上传或改-T流式上传 |
| 一直卡住无输出 | 等Expect: 100-continue | 加-H "Expect:" |
| 中文参数变乱码 | 未编码 | 换--data-urlencode |
6.3 两个让我印象深刻的排查过程
第一个是"本地通、线上不通"。同一份 curl 命令,在本地机器上秒回,部署到服务器上就超时。-v显示它一直在尝试某个地址,用%{remote_ip}打出来一看,连的是 IPv6 地址。问题出在那台服务器的 IPv6 出口不通,而本地是通的。加一个-4强制走 IPv4,立刻正常。这类问题非常隐蔽,因为错误信息里不会写"IPv6 有问题",只会给你一个超时。
第二个是"上传大文件偶尔失败"。小于 10MB 的文件都正常,超过就时不时返回 413,重试又能成功。一开始怀疑是服务端限制,翻了配置发现限制是 100MB。后来用--trace-ascii抓了一次失败的记录,发现请求头里有Expect: 100-continue,失败的那几次是服务端在 1 秒内没回 100,curl 等了超时之后继续发,中间某个环节把这次连接掐了。加上-H "Expect:"之后问题彻底消失。这个坑的教训是:偶发问题的排查一定先拿完整收发记录,靠猜永远猜不到。
还有一个不算坑但值得提的经验:给 curl 加一个统一的包装脚本。我自己的习惯是把-sS --connect-timeout 5 --max-time 60 --retry 2这几项固定下来,脚本里只传业务相关的参数。默认值设好之后,绝大多数"忘记设超时导致任务卡死"的低级问题就不会再出现。命令行工具的价值不在于参数多,而在于你把哪些参数固化成肌肉记忆——curl 的开关有几百个,真正常用的就那么二十来个,把这二十来个用熟、用出条件反射,比背下整本手册管用。