上传、下载与流式处理:字节怎样穿过内存、磁盘和网络
文件在网络中表现为字节序列。文件名、媒体类型、长度和摘要描述这些字节;业务系统另外保存文件归属、可见状态和存储位置。
上传把字节移入服务端,下载把已经选定的文件表示送给客户端。服务端接收完成后,还要完成持久化并决定何时允许访问。
文件怎样装进 HTTP 消息
四种常用承载方式
| 承载 | 请求体形态 | 常用场景 | 服务端首先处理什么 |
|---|---|---|---|
multipart/form-data | 多个带字段名的部分,每部分可有文件名和类型 | 浏览器表单、文件加描述字段 | 解析 boundary 与各个 Part |
| 原始二进制 | 消息体直接是文件字节 | 单个文件 API、对象存储 PUT | 读取输入流,元数据放 Header 或单独请求 |
| JSON 中的 Base64 | 二进制编码成字符串 | 很小的附件、已有 JSON 协议 | JSON 解析和 Base64 解码 |
| 客户端直传 | 客户端直接向存储服务发起上传 | 大文件、减少应用带宽占用 | 应用先授权,完成后查询和校验对象 |
Base64 每三个输入字节通常编码为四个字符,JSON 和 Java 字符串还会增加解析及对象分配成本。对大文件,原始二进制或 multipart 更便于保持有界内存。直传改变的是数据经过的服务器,授权、大小约束和内容检查仍有各自的执行位置。
一个 multipart 请求可以直接拆成以下结构。name 标识表单字段,filename 是客户端提交的展示信息,不能直接拼进磁盘路径。RFC 7578规定了消息体的分隔和字段形式。
POST /files
Content-Type: multipart/form-data; boundary=boundary21
│
├─ --boundary21
│ Content-Disposition: form-data; name="description"
│ 空行
│ 文件说明
│
├─ --boundary21
│ Content-Disposition: form-data; name="file"; filename="report.csv"
│ Content-Type: text/csv
│ 空行
│ 文件字节
│
└─ --boundary21--浏览器使用 FormData 时,由浏览器生成配套的 Content-Type 和 boundary;手工只设置 Content-Type: multipart/form-data,却没有与消息体对应的 boundary,解析器就无法正确分割内容。curl -F 也会同时构造两者。
HTTP multipart 允许一个请求带多个部分;对象存储分片上传让一个文件由多个独立请求上传并最终合并。两者都叫 multipart,但上传会话、失败重传和清理方式各不相同。
消息长度与实际文件长度
原始二进制请求的 Content-Length 可以等于文件长度;multipart 的消息长度还包括各部分的 Header、分隔符和普通字段。以整个请求长度限制单个文件,会使相同大小的文件因字段数量不同而得到不同结果。
未知总长度也可以传输。HTTP/1.1 可使用 chunked 编码确定消息结束,HTTP/2 使用 DATA 帧与流结束信号。应用读取的是容器处理过消息分帧后的输入流,不能把某次 read() 返回的字节数当成一个上传分片。HTTP 消息完整性、内容长度和表示编码见 HTTP Semantics。
容量限制同时存在于入口和真实读取过程。入口根据已知长度尽早拒绝明显超额请求;输入流每读一块就累计文件字节,处理未知长度和虚报长度。如果还支持 gzip 解码,压缩后的传输字节与解码后的内容字节需要分别计数。
启动上传服务并读回同一份字节
构建和运行
下载文件传输实验,解压进入 file-stream-lab。工程包含 Tomcat 11.0.25 / Servlet 6.1 服务、HTTP 测试和完整 Maven 配置;Java 编译目标为 17。API 只用于本机练习,没有业务登录或持久化文件索引,不应暴露到公网。进程重启后旧文件索引消失,磁盘文件保留在 work/objects 供检查。
以下命令在 Linux Bash 执行。宿主用户需要 Docker 操作权限和当前目录写权限,已安装 unzip、curl、sha256sum。一次性 Maven 容器使用宿主 UID/GID,依赖缓存和构建目录归该用户写入。Docker daemon 的权限单独管理,不能由 --user 推导为非特权服务。
mkdir -p .m2 work
docker run --rm --user "$(id -u):$(id -g)" \
--entrypoint mvn -e MAVEN_CONFIG=/m2 \
-v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-17 \
-B -Dmaven.repo.local=/m2 clean verify正常构建结束为 BUILD SUCCESS,HTTP 测试会启动随机回环端口再关闭。Maven 镜像的实际 Java 补丁通过下面命令读取;标签中的 17 表示发行线,不是补丁号。构建失败先看具体依赖下载或测试异常,不继续启动旧 target 目录中的产物。
docker run --rm --entrypoint java \
maven:3.9.12-eclipse-temurin-17 -version企业内网可让 Maven 使用经审核的 settings.xml 和私服,将文件只读挂载后加 -s /config/settings.xml。镜像在可联网环境拉取并校验来源后,可以用 docker save / docker load 转移;离线构建前应在相同 POM 和命令上预热依赖,不把无法访问 Central 当成 Java 编译错误。
启动运行容器:
docker run -d --name fs21-http --user "$(id -u):$(id -g)" \
--read-only --tmpfs /tmp:rw,nosuid,nodev,size=64m \
--memory 256m --cpus 1 -p 127.0.0.1:18081:8080 \
-v "$PWD:/work" -w /work eclipse-temurin:17.0.20_8-jdk \
java -Xmx128m -cp 'target/classes:target/dependency/*' \
dev.example.files.FileServer /work/work 8080
docker logs fs21-http看到 LISTENING port=8080 uploadLimit=1048576 后发请求。进程最多存活十分钟,便于练习后释放端口;超时退出后删除已停止的本实验容器,再用同一启动命令重建,并重新上传 sample.bin、读取新 FILE_ID 和 ETag。这里的 1 MiB、四个并发上传和内存限制都是演示值。
原始上传与完整下载
创建一份 1,000 字节文件并上传。curl 的 -q 是第一个选项,禁用默认配置;--noproxy '*' 让本机实验不继承代理路径。成功请求使用 --fail-with-body,避免 HTTP 错误被当成成功。
head -c 1000 /dev/urandom > sample.bin
BASE=http://127.0.0.1:18081
curl -q --noproxy '*' --fail-with-body \
-D upload.headers -o upload.txt \
-H 'Content-Type: application/octet-stream' \
--data-binary @sample.bin "$BASE/files"
sed -n '1,2p' upload.txt输出中的 id 每次不同,bytes=1000 应保持一致。响应的 Location 指向刚接收的对象,不能把另一轮运行的文件 ID 复制过来。
FILE_ID=$(sed -n 's/^id=//p' upload.txt)
curl -q --noproxy '*' --fail-with-body \
-o downloaded.bin "$BASE/files/$FILE_ID"
sha256sum sample.bin downloaded.bin
cmp sample.bin downloaded.bin两条 SHA-256 相同且 cmp 退出码为 0,表示下载内容和本次上传样本逐字节一致。服务端先写私有临时文件,计算长度和摘要,再移动到 objects 并登记 ID;客户端获得 201 后才能使用该 ID 读取。
表单上传使用同一个入口:
curl -q --noproxy '*' --fail-with-body \
-F 'description=sample' -F 'file=@sample.bin' "$BASE/files"-F 中的字段名必须是 file。同一份样本应仍返回 bytes=1000,不会把 description 或 multipart Header 算进文件长度。
缓冲、临时盘与传输线程怎样配合
解析器暂存与应用暂存
客户端
→ socket / 容器协议缓冲
→ multipart 解析器(仅表单上传)
├─ 小 Part:内存
└─ 大 Part:work/parts 临时盘
→ 应用 64 KiB 复制缓冲
→ work/receiving 私有文件
→ 完整接收后移动到 work/objects
→ 文件索引允许读取Servlet 的 Part 代表一个表单部分,getInputStream() 读取内容,getSubmittedFileName() 返回客户端文件名,delete() 释放该 Part 的底层存储。Part API还定义了 write(),但其行为受容器和存储位置影响,业务代码不应假定同一个 Part 可以无限次搬移。
实验的 MultipartConfigElement 设置如下:
new MultipartConfigElement(
partsDirectory, // 容器临时目录
1024 * 1024, // 单个文件上限
1024 * 1024 + 65536, // 整个 multipart 请求上限
32768 // 转向磁盘暂存的阈值
);转向磁盘能减少大文件堆占用,但会增加临时盘容量和 I/O。后续再复制到应用暂存目录时,两份磁盘数据可能短暂共存。容器临时目录、应用临时目录和最终文件目录要分别计算容量;容器根文件系统只读时,还应显式提供可写路径。
Spring MVC 的 MultipartFile 常由同一套 multipart 解析过程提供内容。调用 getBytes() 会把文件聚合为数组;使用 getInputStream() 后若又调用 readAllBytes() 或写入 ByteArrayOutputStream,也会重新聚合整个文件。
StreamingResponseBody 能把响应工作交给异步执行器,执行器中的阻塞写依然可能占用线程。判断同步、异步与非阻塞,要看谁等待 I/O、谁继续推进处理,仅凭返回类型带有“流”还无法确定。
实际读取上限与失败清理
复制循环只分配一个固定缓冲,按本次读取的有效长度写入:
byte[] buffer = new byte[65536];
long count = 0;
for (int n; (n = source.read(buffer)) != -1;) {
if (n > limit - count) throw new TooLarge();
output.write(buffer, 0, n);
digest.update(buffer, 0, n);
count += n;
}read() 可以少于缓冲长度,只有 -1 表示输入结束。available() 描述无需阻塞便能读取的估计量,不适合推断文件总大小。精确跳过下载起点可用 skipNBytes(),普通 skip() 可能少跳。InputStream API明确区分这些返回约定。
完整写入后,实验在同一文件系统移动临时文件,使用 ATOMIC_MOVE 使其他读取者不会看到一半文件。底层文件系统若不支持会抛出异常,应保留未发布状态并清理;不能悄悄退化后仍宣称原子发布。原子可见性和崩溃后的持久性还需分开设计,后者涉及文件与目录同步、存储保证及数据库提交。Files.move说明移动选项与异常。
清理时保留最初失败:
catch (IOException failure) {
try {
Files.deleteIfExists(temporary);
} catch (IOException cleanup) {
failure.addSuppressed(cleanup);
}
throw failure;
}请求许可放在外层 finally 归还,Part 单独释放。磁盘满、客户端断开和删除失败可能连续出现,把删除异常覆盖到最前面会丢失最初原因。进程被强制终止时 finally 没有执行机会,因此重启后还需要扫描没有活跃会话、超过宽限的残留文件;先取得候选并核对使用情况,再删除对应路径。
下面发送一个没有预先给出文件长度的超额请求。-T - 从标准输入流式传输,显式 POST 保持同一接口;指定 HTTP/1.1 与 chunked,避免把读取限制实验变成长度 Header 检查。
head -c 1100000 /dev/zero > too-large.bin
status=$(curl -q --noproxy '*' --http1.1 \
-sS -o rejected.txt -w '%{http_code}' \
-X POST -H 'Content-Type: application/octet-stream' \
-H 'Transfer-Encoding: chunked' -T - "$BASE/files" < too-large.bin)
transport=$?
test "$transport" -eq 0 || { printf '传输未完成\n' >&2; exit 1; }
test "$status" = 413 || { printf '状态不是 413\n' >&2; exit 1; }
test "$(cat rejected.txt)" = TOO_LARGE || { printf '错误内容不匹配\n' >&2; exit 1; }
find work/receiving -maxdepth 1 -type f负例故意不用 --fail-with-body,分别检查传输完成、精确状态和错误内容。receiving 应没有遗留文件。随后重新上传 sample.bin 应返回 201,确认许可和临时资源没有妨碍下一次合法请求。objects 中成功上传的文件不应被这次失败删除。
背压与吞吐预算
同步复制中,下游写入变慢会延迟下一次上游读取,压力逐步传回 socket。中间如果有无界队列,积压就转移到堆;如果代理先收完整个请求再转发,压力则先由代理临时盘承担。
峰值预算需要沿传输路径逐层相加:每层在途块的数量和大小,以及解析器元数据、TLS/直接缓冲与连接开销。Java 复制数组只是其中一项。
阻塞 Servlet 请求持续占用处理线程。Tomcat NIO Connector 的 socket 管理采用 NIO,不会把应用的普通阻塞读写自动变成非阻塞。maxThreads、maxConnections、acceptCount 与 multipart 的 maxPartCount / maxPartHeaderSize 分别约束不同资源;maxPostSize 也并非所有 POST 消息体的总限额。具体含义见 Tomcat HTTP Connector。
非阻塞 Servlet 使用 ReadListener / WriteListener,在 isReady() 允许时读取或写入,把暂不能处理的情况交回容器。它适合高并发慢连接,但状态、取消和缓冲所有权更复杂。虚拟线程能降低阻塞线程调度成本,磁盘容量、存储连接和出口带宽仍需限额。大文件直传则直接移走应用的数据面负担。
条件下载与 Range 的完整响应
下载头和文件表示
下载先检查用户对文件 ID 的权限,再固定对象版本、长度和 ETag,随后计算响应。文件名只影响浏览器保存时的展示;原始名称必须安全编码,不允许换行注入响应头。RFC 6266定义 attachment、filename 与国际化文件名形式。
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="report.csv"; filename*=UTF-8''report.csv
Content-Length: 1000
ETag: "selected-representation-tag"
Accept-Ranges: bytes
X-Content-Type-Options: nosniffETag 是选定表示的验证标识。实验以 SHA-256 形成强 ETag;对象存储或框架生成的 ETag 未必使用同一算法。若代理按 gzip 生成另一种表示,应正确处理其 ETag、缓存键和 Vary;压缩字节上的 Range 偏移不能套到未压缩文件上。
HEAD 返回和 GET 对应的元数据,不发送正文。预先用 HEAD 看大小便于客户端决定下载方式,但到真正 GET 时文件可能已变化;稳定续传应固定不可变对象,或者携带验证条件。
条件先于区间
If-None-Match 在 GET/HEAD 中使用弱比较,判断缓存表示是否仍可使用;If-Match 使用强比较。If-Range 用于选择“接着取区间”还是“重新取完整文件”,ETag 必须满足强比较。它不匹配时忽略 Range,通常返回完整 200,而不是 412。HTTP 还定义了时间条件及优先次序;完整实现应按前述 RFC 处理。实验只实现 ETag 路径,带日期的 If-Range 会走完整响应。
| 请求区间,文件长 1,000 字节 | 返回内容 |
|---|---|
bytes=200-499 | 300 字节,含两端 |
bytes=980- | 最后 20 字节 |
bytes=-20 | 最后 20 字节 |
bytes=0-9999 | 截到文件末端,1,000 字节 |
bytes=1000- | 无可满足字节,416 |
bytes=0-9,990-999 | 支持多区间可返回 multipart/byteranges;实验选择忽略并返回完整 200 |
服务端可以忽略 Range。返回 206 后则必须让 Content-Range、Content-Length 和实际发送范围一致,不能发完整文件却只标一个小区间。
curl -q --noproxy '*' --fail-with-body \
-D range.headers -H 'Range: bytes=200-499' \
-o range.bin "$BASE/files/$FILE_ID"
wc -c range.bin
sed -n '/^[Cc]ontent-[Rr]ange:/p' range.headers
dd if=sample.bin of=expected.bin bs=1 skip=200 count=300 status=none
cmp range.bin expected.bin应得到 300 字节和 Content-Range: bytes 200-499/1000,cmp 成功。不能只检查 206:Header 到达后连接仍可能中断,curl 传输退出码和文件完整性也要检查。
取得本次 ETag,验证 If-Range 两条路径:
ETAG=$(sed -n 's/^[Ee][Tt][Aa][Gg]:[[:space:]]*//p' upload.headers | tr -d '\r')
curl -q --noproxy '*' --fail-with-body -o matching.bin \
-H 'Range: bytes=200-499' -H "If-Range: $ETAG" "$BASE/files/$FILE_ID"
curl -q --noproxy '*' --fail-with-body -o changed.bin \
-H 'Range: bytes=200-499' -H 'If-Range: "old"' "$BASE/files/$FILE_ID"
wc -c matching.bin changed.binmatching.bin 应为 300 字节,changed.bin 应为 1,000 字节。续传请求收到 200 时,应重新写完整文件,不能追加到旧文件尾部。curl -C - 适合支持续传的稳定资源,业务客户端还应保存对象版本和验证标识,防止把两版内容拼成一份损坏文件。
提交后发生错误
下载前的权限拒绝可以返回完整 403;发送部分正文后发生存储读错或客户端断开,响应可能已经提交,此时无法可靠改成另一状态码。向剩余字节追加 JSON 错误会污染文件。应停止传输、关闭存储流,记录选定对象、区间、已写字节和错误类别。
已知长度响应可由客户端按实际字节数检测截断;未知长度流还需要业务级结束标识或摘要。服务端 write 调用完成后,用户是否保存成功仍需客户端确认。监控分别记录授权、开始传输、服务端写完和客户端确认,才能区分错误发生的位置。
从响应和资源定位传输故障
请求被拒绝、等待或中断
先保留本次响应头、状态和传输错误,再检查相应层,不对所有失败统一重试。
| 首个现象 | 优先检查 | 修复后的观察 |
|---|---|---|
| 400 / multipart 解析失败 | boundary、字段名、完整请求体、Part 数量 | 同样文件通过 curl -F 成功 |
| 413 | 代理请求上限、Servlet file/request 限制、实际字节计数 | 超额样本仍拒绝,正常样本成功 |
| 415 | 接口允许的媒体类型与 Content-Type | 改正确承载,文件逐字节一致 |
| 请求长时间没有首字节 | 代理是否先缓存请求、存储写耗时、请求线程 | 分段耗时下降且资源不持续累积 |
| 206 但文件损坏 | Content-Range、实际长度、If-Range、压缩表示 | 同区间与原文件对应字节相同 |
| curl 提示正文不完整 | 网络断开、服务端读错、超时、错误长度 | 完整重试或受验证条件控制的续传成功 |
用 curl 保存时延和字节量:
curl -q --noproxy '*' --fail-with-body -o /dev/null \
-w 'status=%{http_code} first=%{time_starttransfer} total=%{time_total} bytes=%{size_download}\n' \
"$BASE/files/$FILE_ID"
docker stats --no-stream fs21-http
df -h work
df -i work
du -sh work/parts work/receiving work/objects首字节慢且总下载很短,优先检查授权、元数据和打开存储流;首字节很快、总耗时很长,则结合长度检查吞吐和慢客户端。df -h 尚有空间但 df -i 耗尽,会使大量小临时文件创建失败。按目录看增量,再按已结束会话清理,不对整个宿主临时目录无差别删除。
代理缓冲与浏览器观察
Nginx 的 proxy_request_buffering 控制是否先缓存请求体,proxy_buffering 控制响应缓冲,作用于不同方向。关闭请求缓冲能更早把字节交给应用,但一旦开始发送,代理重新选择上游的能力会受限;临时文件与超时配置也要一起评估。Nginx proxy 模块给出了各选项的限制。
在浏览器按 F12 打开 Network,清空记录后执行上传或下载。检查 Headers 中的 URL、方法和响应头,Payload 中的字段名与表单内容,Timing 中的排队、连接、等待与内容下载;跨域直传还要找 OPTIONS 预检与最终请求。跨域脚本读取 ETag 等 Header 需要服务端配置相应暴露字段。界面项见 Chrome Network 参考。
开发者工具展示当前浏览器所观察的请求,不能替代存储端完整性检查。导出 HAR 前移除 Cookie、Authorization、签名 URL 和业务文件信息,不把完整预签名地址贴入公共日志。
停止本机练习只操作这一容器:
docker stop fs21-http
docker rm fs21-http停止容器会保留样本和 work 目录,可以继续核对下载字节与暂存文件;诊断结束后再清理本次实验目录。持续运行的文件服务应持久保存元数据和对象,并实现隔离扫描、对象授权及生命周期清理。
权威资料与规范地址
消息体、条件请求与下载
- RFC 7578:https://www.rfc-editor.org/rfc/rfc7578.html
- HTTP Semantics:https://www.rfc-editor.org/rfc/rfc9110.html
- Content-Disposition:https://www.rfc-editor.org/rfc/rfc6266.html
Java 与 Servlet
- Servlet Part:https://jakarta.ee/specifications/servlet/6.1/apidocs/jakarta.servlet/jakarta/servlet/http/part
- InputStream:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/io/InputStream.html
- Files:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/nio/file/Files.html
- Tomcat HTTP Connector:https://tomcat.apache.org/tomcat-11.0-doc/config/http.html
代理与观察工具
- Nginx proxy 模块:https://nginx.org/en/docs/http/ngx_http_proxy_module.html
- Chrome Network:https://developer.chrome.com/docs/devtools/network/reference
