
访问某个测试接口的时候程序突然抛出一行日志http://www.xxx.com/ skipped. Content of size 67099 was truncated to 59363。乍一看像服务器在给你脸色其实这是客户端在自说自话它原本收到了 67099 字节的响应体却只取了 59363 字节剩下的被主动丢掉了。这个行为叫“截断”是 HTTP 客户端为了防内存爆炸、防日志刷屏、防被恶意响应拖死而设置的保护性操作不是连接断开的报错也不是服务器拒绝访问。类似的日志在爬虫框架、安全扫描器、HTML 解析工具和各种网络库里非常常见只要目标响应体超过某个阈值就会触发。这篇文章就把这行日志拆开揉碎讲清楚它背后的 HTTP 响应大小限制、主动截断机制、排查思路和解决办法。无论你是在写爬虫、调后端接口还是用现成工具做站点分析应该都能对得上号。我还会给出几个可以直接抄的流式下载代码和一份常见问题速查表帮你尽快定位到底是“数据真的丢了”还是“程序主动不想读”。1. 拆解这行日志一个“主动放弃”的信号1.1 日志里每个字段在说什么先看这行日志的结构http://www.xxx.com/ skipped. Content of size 67099 was truncated to 59363简单拆解一下http://www.xxx.com/请求的目标 URL一般不带查询参数方便日志去重。skipped程序决定不处理这个响应。注意这里不是“failed”也不是“error”而是“跳过”说明这是业务逻辑或框架策略上的主动放弃不是网络层的意外。Content of size 67099原始响应体的总字节数是 67099。这个数字有可能是从 HTTP 响应头的Content-Length里读到的也有可能是程序一边读一边统计出来的。truncated to 59363实际读取、保存或展示的字节数是 59363。两个数字一减差了 7736 字节。这意味着程序明明知道后面还有内容却不愿意继续读。为什么会这样答案就藏在“truncated”这个词里它把响应体截断了。需要说明的是HTTP 协议本身并没有规定一个响应体最多只能有多少字节。服务器可以发很大很大的内容只要客户端愿意收。但客户端程序出于现实考虑通常会在内存里设置一个“安全水位线”超过这个水位线就停止读取剩余部分。这行日志就是这种机制被触发时留下的记录。1.2 一个典型的产生场景我自己第一次见到类似日志是在用爬虫框架批量抓页面的时候。当时我设置了一个DOWNLOAD_MAXSIZE参数目的是防止单个响应过大撑爆内存。结果某个页面因为包含了一段超长的内联脚本实际响应体达到了几十 KB不同框架默认阈值不同比设置的上限大了一点点于是框架输出一行警告告诉我它把内容截断了。后来我发现很多 Python/Java 系的开源爬虫、扫描器、代理调试工具都有这个机制爬虫框架通常有“最大响应体”和“警告阈值”两个参数。超过警告阈值只打印日志超过最大响应体就直接丢弃整个响应。安全扫描器为了不让自己被恶意大包堵死会限制每个响应体的读取上限。浏览器开发者工具和抓包代理在展示响应体时为了界面流畅默认只渲染前面一部分其余折叠或截断但底层的原始流量其实是完整的。所以要明白一个前提你看到这行日志时真正的网络通信可能早就完成了甚至整个响应体都已经到达了本机网卡只是应用层没有把它全部读完就放弃了。这就引出下一个问题HTTP 协议到底允许多大的响应1.3 HTTP 对响应大小真的没有限制吗在 HTTP/1.1 的世界里响应体的大小通常由两种方式告知客户端Content-Length响应头直接告诉客户端后面还有多少个字节。Transfer-Encoding: chunked把响应体切成一个个分块每块自带长度客户端不需要预先知道总大小边收边拼。无论哪种方式协议层面都没有对“总大小”做硬性限制。理论上服务器可以一直往连接里写数据写到天荒地老。但实际工程里几乎没有程序会无限制地接收数据因为内存和磁盘都是有限的。打个比方HTTP 客户端像一根水管服务器是水龙头协议只规定了水怎么流没规定一盆水最多不能超过多少升。但如果你用一个小杯子去接水漫出来的时候你就得自己决定是继续加水把整个杯子撑爆还是停下来倒掉一部分。truncated to 59363就是“倒了多余的水只留下 59363 字节”的意思。搞清楚这一点之后就不难理解为什么会有截断机制了。下一节我们来看看这三个最常见的触发原因。2. 为什么客户端要主动截断2.1 保护进程内存防止 OOM最直接的原因是内存保护。很多简单的 HTTP 客户端代码喜欢一次性把整个响应读进内存比如 Python 里的requests.get(url).textJava 里的EntityUtils.toString(), JavaScript 里的response.text()。如果响应体只有几十 KB这么读没问题但要是几百 MB进程很可能会直接 OOM。尤其写爬虫的时候程序往往开着几十上百个并发请求。就算单个响应只有 1 MB100 个并发同时缓存就是 100 MB内存开销很快就失控。框架设一个上限超过上限就跳过是一种粗粒度但非常有效的自我保护策略。我看到有些同学在遇到这类日志后第一反应是把限制调成一倍两倍甚至无限大这其实很危险。一个公开的网页地址完全可以被构造出无限数据流恶意服务端甚至可以在Content-Length里写一个虚假的大数字让客户端飞蛾扑火。合理的做法是保留限制改用流式处理或分块下载后面第 4 节会说具体方案。2.2 降低日志噪音避免敏感信息刷屏第二个原因是日志层面的截断。有些库在调试模式或 verbose 模式下会把响应体内容打印出来方便开发者看接口返回。但如果响应体很大直接打印会把控制台刷爆还会拖慢写入性能所以这类库会默认只打印前 N KB然后在日志里追加一行Content of size ... was truncated to ...。这种截断一般不影响业务代码因为打印出来的内容只是给人看的真正的响应体对象可能还是完整的。你只需要注意如果日志提示被截断而你又在日志里抓 JSON 字段解析肯定会对不上正确做法是不要把日志当作数据源。另外从安全角度讲响应体里可能包含用户隐私、数据库字段、内部配置。开发阶段在日志里打出全量响应一旦日志系统被读取就是一处数据泄露风险。所以很多企业框架会刻意截断响应日志只保留前 512 字节或更少。skipped这种措辞也经常出现在安全组件里含义是“这个内容因为体积原因咱别记录也别展示”。2.3 安全与反制防止被恶意大包拖死第三个原因可以理解成安全机制。爬虫或扫描器在抓取网页时可能会遇到目标服务器故意返回超大响应体的情况目的就是消耗你的带宽、拖住你的线程甚至让程序崩溃。比如一个 10 GB 的垃圾数据如果没有限制客户端会持续下载很久连接会被占住内存也可能被打爆。为了防止这种攻击几乎所有健壮的下载器和扫描器都会设置一个最大响应体限制。一旦发现响应超过限制程序会立刻断开或跳过并把这条记录标记为skipped。这个逻辑和服务器端的“请求体大小限制”是对称的服务器不想收太大的请求客户端也不想收太大的响应。另一方面有些工具在下载超大附件时其实只是想解析标题或元数据并不需要完整内容。比如搜索引擎蜘蛛抓取一个 500 MB 的 PDF它根本不会把 PDF 完全读进内存而是只看前几百个字节里的元信息剩下的直接跳过。这时skipped不是错误而是效率策略。3. 怎么判断是“数据真的丢了”还是“主动不要了”3.1 先用 curl 拿到全量数据做基准收到截断日志时先别急着改代码。第一步是确认如果绕过当前客户端能不能完整拿到整个响应最简单的方法是用curl下载一遍观察实际字节数。curl -i -L http://www.xxx.com/ -o full_response.bin -s -w code%{http_code} size_download%{size_download} content_length%{size_download}\ncurl通常会尽力把数据下完除非服务器主动断开或你加了--max-filesize限制。如果curl下载到的字节数和响应头里的Content-Length一致说明网络和服务端都正常问题完全出在原客户端的限制配置上。如果curl也下不到那么大那要考虑网络、代理或服务端稳定性问题。需要注意-w里的size_download是实际下载到的字节数而content_length字段其实并不存在于 curl 的输出变量里比较接近的是通过读取响应头拿到的Content-Length。如果你想看响应头里的声明值用-i输出后自己数或者更简单先curl -sI URL单独看头。3.2 观察传输编码和超时设置第二步是看响应头里有没有Content-Length有没有Transfer-Encoding: chunked。有Content-Length而且数值是 67099那么程序可以在没读正文前就知道会超过限制日志里显示“Content of size 67099”就很合理后面truncated to 59363说明它只允许读到 59363。如果是Transfer-Encoding: chunked没有完整长度程序必须边读边判断读满 59363 字节后才发现还要继续读于是就地截断。另外注意看有没有Content-Encoding: gzip。如果启用了压缩67099 可能是压缩后的字节数也可能是解压后的字节数取决于日志记录在哪个阶段。我在实践中见过因为压缩和解压状态混乱导致的“假截断”客户端只解压了前 59363 字节就以为整个响应结束了。这类问题需要结合代码逻辑排查。3.3 在代码里记录实际接收长度并二次校验在业务代码里我习惯给每个下载任务加一个“长度校验”记录响应头的Content-Length。边读边累计实际写入文件的字节数。收尾时比较两者是否相等。如果不等说明要么网络中断要么被截断至少应该报一个错或重新下载而不是静默通过。很多在线下载工具会显示“下载完成”但文件却打不开就是因为缺少这个校验。如果程序在读取过程中检测到当前字节数已经超过预设阈值它可以主动抛出异常或返回skipped状态。下面这个 Python 代码片段就实现了这个逻辑MAX_SIZE 59363 with requests.get(http://www.xxx.com/, streamTrue) as r: total int(r.headers.get(Content-Length, 0)) read 0 for chunk in r.iter_content(chunk_size8192): read len(chunk) if read MAX_SIZE: print(fskipped. Content of size {total} was truncated to {read}) break # process or save chunk这里的关键是read MAX_SIZE它保证了数据不会无限进入内存。4. 解决截断的实操方案4.1 Python 爬虫用流式下载取代一次性读取很多 Python 爬虫会因为requests.get(url).content这种写法遇到截断。严格来说requests并不会自动截断但当你把整个响应读入内存后如果某个中间层库比如 Scrapy、lxml 的解析过滤器设置了大小限制截断就会发生。正确的下载姿势是流式读取。以requests为例import requests def download(url, file_path, max_size1024 * 1024): with requests.get(url, streamTrue, timeout(5, 30)) as resp: resp.raise_for_status() content_len int(resp.headers.get(Content-Length, -1)) if content_len max_size: print(fskipped. Content of size {content_len} exceeds limit {max_size}) return False actual_len 0 with open(file_path, wb) as f: for chunk in resp.iter_content(chunk_size65536): if not chunk: continue f.write(chunk) actual_len len(chunk) if content_len ! -1 and actual_len ! content_len: print(fincomplete download: expected {content_len}, got {actual_len}) return False return True这段代码有三个细节值得讲streamTrue必须写在请求阶段这样requests不会提前把整个响应体读到resp.content。iter_content(chunk_size65536)是边下边写内存里始终只有 64 KB 的数据块。下载完做实际字节数和Content-Length的比对防止静默截断。如果服务器不支持Content-Length或者用了 chunked 编码content_len会是 -1那么最后的校验应该改成根据业务自定义。比如你可以下载完毕后检查文件大小是否大于某个阈值或者直接检查文件头魔数来判断文件类型是否完整。4.2 Java 服务端/客户端用 InputStream 消费不要 string()Java 生态里最常见的截断发生在HttpClient或第三方库自带的调试日志里。很多代码喜欢用String body EntityUtils.toString(response.getEntity());这一行坑很大如果响应体有 100 MB这一行就会尝试往内存里塞 100 MB轻则 GC 卡顿重则 OOM。即使没有 OOM有些库在toString()之前会把实体包装成带限制的流超过限制就截断返回的字符串就会少一截。推荐用流式消费OkHttpClient client new OkHttpClient(); Request request new Request.Builder() .url(http://www.xxx.com/) .build(); try (Response response client.newCall(request).execute()) { if (!response.isSuccessful()) { throw new IOException(Unexpected code response); } ResponseBody body response.body(); long contentLength body.contentLength(); long maxSize 59363L; if (contentLength maxSize) { System.out.printf(skipped. Content of size %d was truncated to %d%n, contentLength, maxSize); return; } try (InputStream in body.byteStream(); FileOutputStream out new FileOutputStream(page.bin)) { byte[] buffer new byte[8192]; int read; while ((read in.read(buffer)) ! -1) { out.write(buffer, 0, read); } } }这里的关键是body.byteStream()它给你一个原始的响应体流你可以控制每次读多少字节也就不会因为大小而截断。如果你确实需要把响应截断到某个上限可以在流外面包一个计数器超过上限就break。另外要注意如果你用的是 JDK 内置的java.net.http.HttpClientBodyHandlers.ofInputStream()是流式处理的好帮手不要用BodyHandlers.ofString()去下载大文件。4.3 使用现成扫描或抓取工具时调整限制如果你不是写代码而是在用一个现成的工具或框架遇到skipped和truncated to提示优先级最高的操作是查文档配置而不是写补丁。常见配置项通常长这样工具类别常见参数名作用ScrapyDOWNLOAD_MAXSIZE响应体超过该值直接丢弃ScrapyDOWNLOAD_WARNSIZE响应体超过该值打印警告JsoupmaxBodySize()HTML 解析时最大读取长度通用扫描器maxResponseSize/max-content-length限制单次响应读取量调试代理Response Size/Maximum Display Size仅在界面上限制展示长度以 Scrapy 为例你可以在settings.py里这样调大限制DOWNLOAD_WARNSIZE 2 * 1024 * 1024 # 2MB DOWNLOAD_MAXSIZE 10 * 1024 * 1024 # 10MB但要提醒一句放宽限制只是治标。如果你抓取的目标里混着超大文件视频、ZIP、PDF再大的限制也有被突破的时候。更好的策略是在 Spider 里按Content-Type做分流只对 HTML 和 JSON 完整下载对二进制附件直接跳过或单独走文件下载通道。4.4 从源头减小响应体积有时候截断是服务端“故意”或“无意”造成的。如果一个接口返回的 JSON 特别大超过了客户端限制可以考虑让服务端做三件事启用压缩Content-Encoding: gzip或br。文本类内容压缩后体积通常能减少 60% 到 80%。分页或精简字段不要在单个接口里返回全量数据通过page、page_size或字段筛选来控制响应体大小。合理设置缓存静态资源走 CDN业务接口只返回增量数据。如果你在抓外部站点没法改服务端可以在请求头里带上Accept-Encoding: gzip很多服务器就会自动压缩响应。压缩后从 67 KB 降到十几 KB截断问题自然消失。5. 常见问题速查与避坑清单5.1 典型问题对照表我把实际工作中遇到的与截断相关的问题整理成了表方便按图索骥现象可能原因处理方法日志显示skipped. Content of size ... was truncated to ...客户端/爬虫框架的响应体上限调整框架大小限制或改为流式下载下载后文件大小比Content-Length小几百字节网络抖动、代理中断、超时重试开断点续传增大超时时间内容被截断但curl可以完整下载原客户端库的默认限制参考第 4 节代码使用流式读取JSON 解析时报unexpected end of input响应体被截断导致 JSON 不完整先检查响应日志是否出现truncated调大限制或分页页面 HTML 只解析出一半标签不闭合HTML 解析器maxBodySize限制调大maxBodySize或对 HTML 流式解析抓包工具里看响应是完整的但代码不完整代码里用了一次性string()读取改用InputStream/iter_content响应超时后出现类似read timed out服务器响应太慢读取超时增大read_timeout或优化服务端日志截断但业务数据正常日志组件故意限制日志长度无需处理但要避免从日志拿数据5.2 排查思路录按步骤执行如果你不想看文档直接按下面的顺序排查也可以复现并保留原始响应。用curl -i -L下载一次确认完整字节数和大小。这是基准。找截断点。看日志里truncated to的数值是否和某个配置值一致。如果是一致的基本可以确定是配置限制如果不一致就要考虑超时、网络中断和编码问题。检查是否启用了压缩。响应头里Content-Encoding如果有值截断可能发生在解压前或解压后注意日志统计的是哪个阶段。检查代码读取方式。是否一次性读取是否用了string()或content属性如果是改为流式。查看框架的过滤规则。有些爬虫会对后缀为.zip、.mp4的 URL 直接跳过即使大小没超限日志里也会出现skipped。skipped不一定是截断也可能是规则匹配。第 5 点很容易被忽略。比如你在日志里看到http://www.xxx.com/setup.zip skipped但下面完全没有任何Content of size字段那说明这个跳过纯粹是业务规则而不是大小限制。要区分清楚别把自己绕进去。6. 个人实操里的几个小体会做接口调试和爬虫抓取这么多年遇到截断日志的次数少说也有几十次有些经验是通用的。第一遇到truncated时不要本能地调大限制。先看skipped前后有没有其他日志字段很多日志会同时记录 URL、耗时、下载字节数。如果程序已经在完整接收响应只是没处理完那调大限制没有意义如果确实是读取上限也要先算一下目标站点平均响应体再决定开多大的限制别为了一个 67 KB 的页面把限制放大到 100 MB。第二日志输出永远只打摘要。在写自己的下载工具时我习惯把响应体长度、URL、耗时打到日志里但绝不打印响应体内容本身。这样既能诊断问题又不会让日志系统崩溃。第三Content-Length这个头是可以被伪造的。某些服务端为了防爬会故意在头里写一个假的长度实际返回的数据更多。如果客户端严格按Content-Length限制可能恰好把真正有数据的部分截断了。所以在做大小限制时用“边读边统计的实际字节数”来判断比单纯信头部更稳。第四如果你在抓取大型网站尽量使用专业的流式解析器。比如 HTML 用 SAX 或 stream 模式解析JSON 用增量解析库而不是先把字符串装进内存再解析。这样即便响应到达了几百 MB程序也不会因此崩溃。最后再分享一个小技巧当你不确定某个 URL 到底有多大时用curl -sI看响应头里的Content-Length是最高效的办法但如果是 chunked 编码看不到长度那就执行curl -s URL -o /dev/null -w %{size_download}让 curl 把全部数据下载并丢弃再根据实际大小决定后续策略。这个操作不会产生太多开销但对后续配置上限非常有参考价值。