ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

EMQX大文件下载ChunkedEncodingError:超时机制与流式下载优化实战

EMQX大文件下载ChunkedEncodingError:超时机制与流式下载优化实战 1. 问题现场一次“不友好”的断连先描述一下我实际碰到的场景。设备接入层用的是 EMQX业务侧需要从 EMQX 集群上拉取一批由规则引擎落盘的文件。单文件体积不小大概在 200MB 到 1.2GB 之间波动走的接口是GET /api/v4/file/{filename}如果你用的是 5.x接口路径略有差异但问题表现是共通的。客户端用的是 Python 的requests库写得很“标准”resp requests.get( http://emqx-cluster:8081/api/v4/file/history-20250411.dat, headers{Authorization: token secret}, timeout30 ) data resp.content结果就是标题里那个报错requests.exceptions.ChunkedEncodingError: (Connection broken: ConnectionResetError(104, Connection reset by peer), ConnectionResetError(104, Connection reset by peer))。翻译成人话就是响应还没读完服务器主动把 TCP 连接掐了。这类问题我在不同项目里反复遇到过表象各异但根子上的原因高度集中。先给结论绝大多数情况下是 EMQX 服务端针对 HTTP 请求的超时保护机制先动手了跟“网络不好”关系不大。2. 先从服务端找原因EMQX 的请求超时机制2.1 真正的“凶手”request_timeoutEMQX 从 4.x 开始管理 API 走的是内置的 HTTP 服务器默认监听 8081 端口5.x 默认 18083但 API 端口可以在配置里单独指定。这个 HTTP 服务在启动时会加载一套针对请求生命周期的配置其中影响最大的就是request_timeout。request_timeout的单位是毫秒默认值是60000也就是 60 秒。它指的是从 HTTP 请求头被接收完毕到整个响应体发送完成允许的最大耗时。超过这个时间EMQX 会直接关闭连接不给你任何商量的余地。这里要注意一个细节EMQX 的request_timeout不是常见的“读超时”或“写超时”它覆盖的是整个请求处理链路的总时间。你下载一个大文件时只要文件没有在 60 秒内全部吐完连接就会被强制断开。文件越大、网络带宽越低、客户端消费速度越慢越容易触发这个限制。排查时可以用emqx_ctl直接看配置./bin/emqx_ctl conf get request_timeout返回结果一般就是60000。这就是问题最直接的证据。2.2 怎么改配置文件与热更新两条路修改request_timeout有两种方式我建议两条路都掌握。第一种改配置文件后重启在etc/emqx.conf里找到 HTTP 服务相关配置段4.x 在emqx.conf底部5.x 在etc/emqx.conf的dashboard或api相关段落里增加或修改request_timeout 300000改成 300000 毫秒5 分钟。如果文件更大可以继续往上加但我不建议无脑调成几小时。原因后面讲。改完重启 EMQX./bin/emqx stop ./bin/emqx start第二种命令行热更新如果不想中断服务EMQX 提供了配置热更新能力。4.x 可以用./bin/emqx_ctl conf load --all或者直接改配置后用emqx reload。5.x 上我习惯用 REST API 或者 Dashboard 集群配置页面去调整。不过热更新有一个坑部分配置项在集群模式下需要逐节点生效且 Dashboard 上如果配置被集中管理本地改完可能被覆盖。所以如果是集群环境建议走 Dashboard 或配置文件同步而不要只在单机上emqx_ctl完事。2.3 别忘了文件大小上限file_upload_max_size如果你下载的动作其实是从规则引擎落盘的文件目录里读取那么还要检查file_upload_max_size。这个配置控制的是通过 API 上传文件的体积上限默认只有16MB。如果你的“超大文件”其实是通过 API 传进去的而你下载时拿到的文件名是同一个那么服务端在保存时可能已经把文件截断了或者直接拒绝写入——表现到下游就是“下载到一半断开”甚至下载的内容根本不完整。这个配置和request_timeout是两码事但经常被一起触发。因为上传大文件同样需要耗时上传太慢一样会撞上request_timeout。所以排查大文件下载问题时我强烈建议把request_timeout和file_upload_max_size一起检查掉。3. 客户端改造requests 的正确姿势3.1 别再resp.content一把梭上面那段示例代码是我见过最典型的错误写法resp.content会把整个响应体一次性读入内存。200MB 的文件内存占用直接起飞。但内存问题不是最致命的最致命的是requests 在读取content时如果不指定streamTrue会尝试一次性接收完整个响应体。这个过程发生在库内部超时控制变得很不可控。正确的打开方式是流式下载import requests url http://emqx-cluster:8081/api/v4/file/history-20250411.dat headers {Authorization: token your-secret} with requests.get(url, headersheaders, streamTrue, timeout(10, 120)) as resp: resp.raise_for_status() with open(history-20250411.dat, wb) as f: for chunk in resp.iter_content(chunk_size1024 * 256): f.write(chunk)这里有几个关键点streamTrue拿到响应头就返回不会等整个 body 下载完。timeout(10, 120)第一个值是连接超时第二个是读取超时。读取超时不是“总时长限制”而是“两个 chunk 之间的最大间隔”。这正好绕开了服务端 60 秒总耗时的限制——只要你持续有数据在传输即使总时长超过 60 秒客户端这边也不会主动掐。iter_content(chunk_size262144)每 256KB 写一次磁盘内存占用恒定。这样改完之后只要服务端允许下载可以持续几分钟甚至几小时。因为服务端的request_timeout是针对整个请求生命周期的如果之前是 60 秒下载 200MB 文件在百兆带宽下本来就捉襟见肘流式下载不会缩短服务端计算时间它只是把压力转移到了服务端超时配置上。3.2 自定义 Timeout 与 Retry 的配合流式下载改完后还存在一个边角场景如果网络抖动某个 chunk 卡了 120 秒没数据客户端还是会断。这时候需要权衡timeout的取值。我一般设成(10, 300)也就是 chunk 之间最多等 5 分钟。对于内网环境这个值很安全对于跨公网传输可以根据实际带宽测试。另一个容易忽视的点是重试。大文件下载在断点处续传是刚需requests 本身不带断点续传能力但配合Range头很好用headers { Authorization: token your-secret, Range: bytes104857600- } with requests.get(url, headersheaders, streamTrue, timeout(10, 120)) as resp: if resp.status_code 206: with open(file_path, ab) as f: for chunk in resp.iter_content(chunk_size1024 * 256): f.write(chunk)注意EMQX 的静态文件下载接口对 Range 请求的支持取决于文件服务实现。我在 4.x 的某些版本上试过Range 请求不一定返回206可能直接给你完整文件。所以做续传逻辑时一定要先探测服务端行为别盲目按206做分支判断。3.3 长连接复用与连接池还有一个小优化默认情况下 requests 每次调用get都是新建连接。下载大文件时TCP 握手和 TLS 握手的开销占比不高但如果你的业务是“大量小文件 偶尔大文件”建议用requests.Session()复用连接session requests.Session() adapter requests.adapters.HTTPAdapter( pool_connections10, pool_maxsize20, max_retries3 ) session.mount(http://, adapter) session.mount(https://, adapter)连接池的好处在于TCP 连接复用避免了每次重新握手同时max_retries3能自动重试幂等请求。但要注意对于非幂等的写操作自动重试要谨慎大文件下载是幂等的可以放心用。4. 链路里的其他“拦路虎”Nginx、Keepalive 与 TCP4.1 Nginx 反代超时如果你不是直连 EMQX而是通过 Nginx 做反代很多生产环境都这么干那问题链条里多了一个极易踩坑的环节。Nginx 默认的proxy_read_timeout是 60 秒和 EMQX 的request_timeout几乎一样——两台服务器各自掐表谁先到点谁先断。所以即使你把 EMQX 的request_timeout调到了 5 分钟Nginx 那边 60 秒一到照样掐 TCP 连接。客户端看到的依然是ChunkedEncodingError。排查方法很简单看 Nginx 错误日志tail -f /var/log/nginx/error.log如果看到upstream timed out基本就是 Nginx 超时干的。解决方案调整nginx.conf对应 location 或 server 块location /api/v4/file/ { proxy_pass http://emqx-cluster:8081; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_connect_timeout 10s; }proxy_read_timeout控制的是两次读取之间的间隔不是总时长proxy_send_timeout影响上游响应的发送。这两个我都建议调成和 EMQX 的request_timeout同一量级避免出现“上游还在传下游代理先断”的尴尬。另外proxy_buffering默认是开启的。大文件下载时开了缓冲Nginx 会先把上游的数据攒一攒再发给客户端这对小文件是性能优化对超大文件反而可能导致内存占用飙升。如果内存紧张可以针对大文件路径关掉location /api/v4/file/ { proxy_buffering off; }关掉后 Nginx 变成“收到多少发多少”配合proxy_read_timeout调大整个链路会顺畅很多。4.2 Keepalive 与 TCP 层还有一个常见问题是 TCP keepalive。EMQX 作为服务端如果经过 NAT 网关或负载均衡长时间没有数据传输的闲置连接可能被中间设备回收。但大文件下载是持续有数据的理论上不会被 NAT 回收——所以 keepalive 问题更多出现在“下载开始前的那几秒”如果客户端连接到服务端后过了很久才发起请求比如连接池里挂着的闲置连接被服务端断了请求一进去就被重置。这类问题在日志里的表现是连接刚建立一读响应就被 reset。排查时可以抓包看 TCP 报文序列如果发现RST之前有FIN说明是服务端主动关的如果直接RST无FIN通常是中间设备或对端异常。对客户端来说最直接的规避方式就是“每次下载新建连接”或“连接池定时清理”。requests.Session()默认不清理闲置连接但你可以加一层定时器定期关闭不活跃的 session。4.3 带宽与传输速率的矛盾再往底层挖一层如果服务端和客户端之间的带宽小于文件大小除以超时时间那么无论怎么调超时都是治标不治本。举个例子EMQX 到客户端的链路带宽只有 5MB/s下载 200MB 文件需要 40 秒request_timeout默认 60 秒还够但如果带宽只有 2MB/s100 秒才能下完默认配置必然掐断。这时候有两条路一是调大超时二是优化文件分片。EMQX 的规则引擎文件目录在落盘时是没有自动分片的但业务上你可以提前把大文件拆成多个 50MB 的分片文件下载时各分片并行拉取最后合并。这个方案能同时解决超时和内存问题代价是业务逻辑复杂度上升。5. 常见问题速查表与避坑经验5.1 按报错现象定位问题报错/现象大概率原因优先排查方向ChunkedEncodingError: Connection broken服务端request_timeout触发调大 EMQXrequest_timeoutConnectionResetError(104, Connection reset by peer)Nginx/上游超时查 Nginx error log调proxy_read_timeoutRead timed out(requests 内部超时)客户端读取超时设置过小调大timeout的 read 部分下载文件不完整但没有异常服务端文件被截断或写入异常检查file_upload_max_size和磁盘空间Connection aborted/RemoteDisconnected中间设备断连或 keepalive 失效抓包确认 RST 来源调整连接池策略偶尔成功偶尔失败负载均衡/多节点配置不一致逐个节点检查request_timeout是否统一5.2 我踩过的坑提前写给你第一个坑只调客户端不调服务端。我之前在一个项目里客户端把超时改成了 30 分钟服务端还是默认 60 秒结果下载大文件永远在 60 秒左右断开查了半天才反应过来是服务端的request_timeout在作祟。记住超时是两端各自的独立约束任何一端先到点都会断。第二个坑在集群环境中只改单节点配置。EMQX 集群里每个节点都有自己的request_timeout如果只改了其中一个请求被负载均衡转发到其他节点时还是会按旧配置断连。正确做法是把配置文件同步到所有节点或者用 Dashboard/配置中心统一修改。第三个坑把request_timeout改得过大。调成 10 分钟甚至 1 小时表面能下载大文件了但代价是服务端连接资源被长时间占用如果并发下载多连接池很快被占满其他 API 请求全部排队超时。我现在的做法是把request_timeout调到 300 秒作为保底同时业务侧做分片下载分担单连接的压力。第四个坑忽略代理和网关。有一次排查到最后发现问题是机房防火墙针对长连接做了“空闲 120 秒即断”的策略而我们下载过程中刚好有一段 5 分钟的静默期服务端在处理文件索引连接直接被防火墙清了。这种问题调request_timeout没用得从传输节奏上解决——比如服务端提前写入响应头、客户端增加读取频率。5.3 终极排查路径清单我把大文件下载超时问题的排查路径整理成一份清单遇到类似问题按顺序走一遍基本能定位到根因抓包确认断开方向tcpdump -i any port 8081看看是客户端先断还是服务端先断还是 RST 直接飞过去。查 EMQX 配置确认request_timeout和file_upload_max_size的实际值。查 Nginx 日志如果链路里经过反代看有没有upstream timed out。查负载均衡/防火墙策略确认有没有针对长连接或超大传输的特殊限制。客户端改造streamTrueiter_content把超时控制权从库内部拿回自己手里。最终兜底分片下载或断点续传彻底摆脱单连接传输的物理限制。6. 一个更优雅的方案分片并发下载如果你觉得上面这些调参总是不够“根治”我分享一个我实际落地过的方案分片并发下载。思路很简单事先把大文件在服务端记录好大小和校验值客户端拿到这些元信息后并发开多个请求每个请求通过Range头指定下载不同区间。比如 1GB 文件开 8 个连接每个连接下载 128MB最后按偏移量写入同一个文件再做一次整体校验。这样单个连接的数据量小了单连接的耗时就短了对request_timeout的敏感性大幅降低。import concurrent.futures import requests def download_part(url, headers, start, end, part_idx, save_path): headers {**headers, Range: fbytes{start}-{end - 1}} with requests.get(url, headersheaders, streamTrue, timeout(10, 180)) as r: r.raise_for_status() with open(f{save_path}.part{part_idx}, wb) as f: for chunk in r.iter_content(chunk_size1024 * 256): f.write(chunk) file_size 1 * 1024 * 1024 * 1024 # 1GB part_size 128 * 1024 * 1024 # 128MB parts [] with concurrent.futures.ThreadPoolExecutor(max_workers8) as executor: for idx, start in enumerate(range(0, file_size, part_size)): end min(start part_size, file_size) parts.append(executor.submit(download_part, url, headers, start, end, idx, output.bin)) for f in parts: f.result()注意这个方案有个前提EMQX 的静态文件接口必须支持Range响应。这方面不同版本行为不完全一致实现前先用一个文件测试一下发起带Range的请求看返回的是206还是200。如果是200说明服务端忽略了 Range 头这时候分片并发反而会重复下载整文件要做合并的时候还得先去重。另一个前提是并发连接数。EMQX 的 HTTP 服务有最大连接数限制分片并发 8 个连接问题不大但如果单文件分片数超过 20要考虑其他业务的连接是否会被挤掉。最后说两句实在话回到标题里的那个ChunkedEncodingError它本质上不是 Python 库的问题而是超时边界没有对齐。我在实际项目里处理过至少四次同类问题每次的最终根因都不同第一次是 EMQX 服务端配置第二次是 Nginx 反代第三次是防火墙长连接策略第四次是客户端内存溢出导致进程被系统杀掉这个更隐蔽进程一死连接自然断。所以遇到这类报错我的第一反应永远是“看日志、抓包、逐层排查”而不是急着改代码。把上面那份排查清单走一遍大多数问题都能在半小时内定位。如果你时间紧优先看服务端request_timeout——它是最常见的原因其次是 Nginx 的proxy_read_timeout。这两个调完至少能解决八成以上的“下载大文件必断连”问题。如果调完还是断那就是网络链路里有防火墙或负载均衡在“帮忙”这时候别犹豫直接上分片下载或断点续传别跟超时设置死磕。我自己的经验是凡是需要把超时调到“很大”才能跑通的功能架构上一定有需要改的地方要么分片要么换协议比如 MQTT 的离线消息或者对象存储硬撑不是一个好办法。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进