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>"}, timeout=30 ) 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_timeout
EMQX 从 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时,如果不指定stream=True,会尝试一次性接收完整个响应体。这个过程发生在库内部,超时控制变得很不可控。
正确的打开方式是流式下载:
import requests url = "http://emqx-cluster:8081/api/v4/file/history-20250411.dat" headers = {"Authorization": "token <your-secret>"} with requests.get(url, headers=headers, stream=True, timeout=(10, 120)) as resp: resp.raise_for_status() with open("history-20250411.dat", "wb") as f: for chunk in resp.iter_content(chunk_size=1024 * 256): f.write(chunk)这里有几个关键点:
stream=True:拿到响应头就返回,不会等整个 body 下载完。timeout=(10, 120):第一个值是连接超时,第二个是读取超时。读取超时不是“总时长限制”,而是“两个 chunk 之间的最大间隔”。这正好绕开了服务端 60 秒总耗时的限制——只要你持续有数据在传输,即使总时长超过 60 秒,客户端这边也不会主动掐。iter_content(chunk_size=262144):每 256KB 写一次磁盘,内存占用恒定。
这样改完之后,只要服务端允许,下载可以持续几分钟甚至几小时。因为服务端的request_timeout是针对整个请求生命周期的,如果之前是 60 秒,下载 200MB 文件在百兆带宽下本来就捉襟见肘,流式下载不会缩短服务端计算时间,它只是把压力转移到了服务端超时配置上。
3.2 自定义 Timeout 与 Retry 的配合
流式下载改完后,还存在一个边角场景:如果网络抖动,某个 chunk 卡了 120 秒没数据,客户端还是会断。这时候需要权衡timeout的取值。我一般设成(10, 300),也就是 chunk 之间最多等 5 分钟。对于内网环境,这个值很安全;对于跨公网传输,可以根据实际带宽测试。
另一个容易忽视的点是重试。大文件下载在断点处续传是刚需,requests 本身不带断点续传能力,但配合Range头很好用:
headers = { "Authorization": "token <your-secret>", "Range": "bytes=104857600-" } with requests.get(url, headers=headers, stream=True, timeout=(10, 120)) as resp: if resp.status_code == 206: with open(file_path, "ab") as f: for chunk in resp.iter_content(chunk_size=1024 * 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_connections=10, pool_maxsize=20, max_retries=3 ) session.mount("http://", adapter) session.mount("https://", adapter)连接池的好处在于:TCP 连接复用避免了每次重新握手,同时max_retries=3能自动重试幂等请求。但要注意,对于非幂等的写操作,自动重试要谨慎,大文件下载是幂等的,可以放心用。
4. 链路里的其他“拦路虎”:Nginx、Keepalive 与 TCP
4.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/s,100 秒才能下完,默认配置必然掐断。
这时候有两条路:一是调大超时,二是优化文件分片。EMQX 的规则引擎文件目录在落盘时是没有自动分片的,但业务上你可以提前把大文件拆成多个 50MB 的分片文件,下载时各分片并行拉取,最后合并。这个方案能同时解决超时和内存问题,代价是业务逻辑复杂度上升。
5. 常见问题速查表与避坑经验
5.1 按报错现象定位问题
| 报错/现象 | 大概率原因 | 优先排查方向 |
|---|---|---|
ChunkedEncodingError: Connection broken | 服务端request_timeout触发 | 调大 EMQXrequest_timeout |
ConnectionResetError(104, Connection reset by peer) | Nginx/上游超时 | 查 Nginx error log,调proxy_read_timeout |
Read 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。 - 查负载均衡/防火墙策略:确认有没有针对长连接或超大传输的特殊限制。
- 客户端改造:
stream=True+iter_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": f"bytes={start}-{end - 1}"} with requests.get(url, headers=headers, stream=True, 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_size=1024 * 256): f.write(chunk) file_size = 1 * 1024 * 1024 * 1024 # 1GB part_size = 128 * 1024 * 1024 # 128MB parts = [] with concurrent.futures.ThreadPoolExecutor(max_workers=8) 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 的离线消息,或者对象存储),硬撑不是一个好办法。