sendfile(2) 部分发送被当成发完了 —— 归档分片零拷贝导出「一轮跑完、报告全绿、退出码 0」,收件端却只收到一半字节
日期:2026-10-04 | 分支:
2026-10-04-1600| 场景:scenario/2026-10-04-1600关键词:sendfile部分发送 / partial send / 零拷贝 / os.sendfile 返回值 / 大文件导出与归档 / 返回值不校验 / 「调用过」≠「发完了」/ 有界收件端窗口 / 校验块取模 / 自述字段自证 / 空判据表 BAD_FIELDS / 退出码恒 0 / fail-closed / 契约不可用大声失败
一、问题背景
一条归档分片导出链路:源目录里的分片,用 sendfile(2) 零拷贝派发给收件端。
源分片目录 (payload/part-*.bin)
│ open() + fstat() 拿到长度
▼
导出器 app/exporter.py
│ sendfile(2) 零拷贝派发(数据从页缓存直接进套接字)
▼
有界收件端 Recipient(单次接收窗口有限,超出部分按「早到/超窗」丢弃)
契约 conf/exporter.env(唯一权威来源):
EXPORT_SOURCE_DIR=/srv/payload
EXPORT_VERIFY_CHUNK_BYTES=65536 # 累计送出量必须能按这个粒度校验
EXPORT_RECIPIENT_WINDOW_BYTES=131072 # 收件端一次最多接收多少字节
EXPORT_ZERO_COPY=1 # 用 sendfile(2) 零拷贝派发
现象:导出器每轮都「跑完了」、报告 self_consistent: true、
python3 -m app.health 退出码 0、errors: [],可是收件端只收到一半字节:
合计长度 = 262144
sendfile 调用次数 = 3
sendfile 返回值累加 (bytes_sent) = 135168
收件端真正收下 (bytes_received) = 135168
少发字节数 shortfall = 126976 ← 少了一半
{"self_consistent": true, "errors": [], ...} ← 四个口径全绿
探针判定:BAD —— 分片没有按契约完整推给收件端
问题不在「网络慢」,而在把一次 sendfile(2) 的返回值当成了「整个分片发完了」。
二、复现步骤
cd scenario/2026-10-04-1600
# 方式 A:本机直接跑(只需 python3,含 sendfile(2))
bash ops/repro.sh # 一键复现:跑链路 → 外部探针取证 → 结论
bash ops/verify.sh # 验收:问题态 PASS=4 FAIL=6 / RESULT: BAD
# 方式 B:docker 一键
docker compose up -d --build
docker compose exec -T exporter bash ops/repro.sh
docker compose exec -T exporter bash ops/verify.sh
问题态验收输出节选:
PASS=4 FAIL=6
RESULT: BAD
红的正是 B 组「收件端累计收到字节数 期望262144 实际135168」「少发 期望0 实际126976」、
C 组「每个分片都被派完 期望1 实际0」「派发量对不上校验块的零头 期望0 实际4096」、
D 组「派出总字节数 % 校验块 期望0 实际4096」、F 组「外部探针期望 RESULT: OK 实际 RESULT: BAD」。
三、根本原因
题眼:app/exporter.py::export_shard() 一次派发就 record["ok"] = True
# 问题态
n = channel.dispatch(path, length) # 请求 length 字节
self.stats["bytes_sent"] += n
record["sent"] = n
record["ok"] = True # ← 推没推完?没人核对
os.sendfile() 的返回值是「本轮真的推出去多少字节」,不保证等于请求值:
发送缓冲区塞不下就是部分发送(partial send)。实测 135168 = 131072 + 4096
—— 一次派发只推出去一部分,被原样当成「整个分片发完了」。
open() + fstat().st_size 问到的唯一权威长度 length
只在 channel.dispatch(path, length) 这一行被用了一次,用来发出请求;
之后整段代码里再没被用来收口。「调用过 dispatch」被当成了「发完了」。
四处附带缺陷,全都不抛异常、前一个把后一个藏起来
- 自检自证:
self_consistent()判的是bytes_received == bytes_sent—— 收件端实收与「本端派出」两个值都出自自己,同源必相等,永远为真; 正确口径是「收件端实收 == 应发(bytes_requested)」。 - 空判据表:
app/health.py的BAD_FIELDS = (),for一次都不循环 → 无论发生什么都报degraded: false/issues: [];main()末尾还无条件return EXIT_OK。 - 契约不可用不失败:
build_exporter()直接contract.load_*(),KeyError/OSError直接冒泡,不是契约约定的ContractError(退出码 3); 窗口0这类自相矛盾契约也无校验。 - 零头被悄悄抹掉:
bytes_verified = verified - (verified % chunk)记的是「抹平后」的量, 真正的零头4096只在pending_remainder_bytes里躺着,没有任何判据去读它。
为什么难查:四个绿灯没有一件指向真相
| 绿灯 | 实际含义 |
|---|---|
demo.py 退出码 0 | 只证明进程没崩 |
report.json self_consistent: true | 自己比自己,同源必相等 → 永远为真 |
health {"degraded": false, "issues": []} | BAD_FIELDS = () 空判据表,for 一次都不循环 |
health 退出码 0 | main() 无条件 return EXIT_OK |
bytes_sent: 135168 配 bytes_received: 135168 看起来还很「自洽」——
这正是自证式自检的可怕之处。唯一说真话的是外部独立探针 client/export_probe.py:
只读契约 + 源分片真实文件长度 + 派发回执,不读导出器任何自述字段。
四、解决办法
只改 app/exporter.py 与 app/health.py;conf/ 三份契约、
client/export_probe.py、ops/*.sh 一字未动。
1. 续发循环:请求量封顶在收件端窗口,进度用返回值累加
pushed = 0
while pushed < length: # 终止条件是「长度」,不是「调用次数」
want = min(length - pushed, self.window_bytes) # 单次不超收件端窗口
n = channel.dispatch(path, want)
self.stats["sendfile_calls"] += 1
if n <= 0: # 一次都没推进:如实记错、停发
self.stats["errors"].append(
"%s: 第 %d 次派发没有推进(剩余 %d 字节)" % (shard.name, record["dispatches"], length - pushed))
break
pushed += n # 进度用返回值,不用请求值
self.stats["bytes_sent"] += n
if n > self.window_bytes:
self.stats["window_exceeded_dispatches"] += 1
- 收口用的是
open()问到的length(唯一权威长度),不是对象上的长度、更不是调用次数; - 单次请求封顶
EXPORT_RECIPIENT_WINDOW_BYTES,超窗的部分收件端按「早到/超窗」丢弃,白推; - 派完
pushed < length→undersend_shards += 1+errors记一条,大声失败; - 累计派出量对
EXPORT_VERIFY_CHUNK_BYTES取模,零头如实落到pending_remainder_bytes。
2. 自检算出来
def self_consistent(self):
return (self.stats["bytes_received"] == self.stats["bytes_requested"]
and self.stats["pending_remainder_bytes"] == 0
and not self.stats["errors"])
比的是「收件端实收」与「应发」,不是两个同源计数器。
3. 健康检查有牙:非空判据表 + 外部可重算的事实 + 退出码分层
BAD_FIELDS = ("undersend_shards", "pending_remainder_bytes", "verify_failures",
"window_exceeded_dispatches", "sockbuf_mismatch", "source_read_failed")
再加两条外部能按契约独立重算的判据(「收件端实收 != 应发」「收件端实收 != 本端派出」)
与 errors 逐条上报;报告常驻 可被外部独立复核 的字段
bytes_requested / bytes_sent / bytes_received / shortfall。
退出码分层:
| 情形 | 退出码 |
|---|---|
| 一切正常 | 0 |
| 有真实问题(少发 / 零头对不齐 / 超窗 / 读源失败) | 2 |
拿不到 report.json(判不了 ≠ 没问题,fail-closed) | 2 |
| 契约不可用 / 自相矛盾(窗口或校验块 ≤ 0、窗口不是校验块整数倍) | 3 |
4. 契约不可用大声失败
build_exporter() 捕获 KeyError/ValueError/OSError → raise ContractError(...);
并校验窗口/校验块为正数、窗口能被校验块整除(否则一次派发量天然对不齐校验块)。
五、验证
验收:
bash ops/verify.sh
# PASS=10 FAIL=0
# RESULT: OK
前后对照:
| 指标 | 问题态 | 修复后 |
|---|---|---|
| sendfile 调用次数 | 3 | 4 |
| sendfile 返回值累加 | 135168 | 262144 |
| 收件端真正收下 | 135168 | 262144 |
| 少发字节数 shortfall | 126976 | 0 |
| 派出量 % 校验块 | 4096 | 0 |
| 探针结论 | BAD(rc=1) | OK(rc=0) |
health 由 {"degraded": false, "issues": [], "self_consistent": true, "bytes_sent": 135168, "bytes_received": 135168}(rc=0)
变为带事实的 {"degraded": false, "issues": [], "self_consistent": true, "bytes_requested": 262144, "bytes_sent": 262144, "bytes_received": 262144, "shortfall": 0}(rc=0)。
负向验证 4 组全过(证明闸门真的有牙,不是「恰好绿」):
| 组 | 注入缺陷 | 结果 |
|---|---|---|
| A | 把「按返回值续发」改回「只派发一次」 | 少发 126976、undersend_shards=1、health rc=2、PASS=3 FAIL=7 / RESULT: BAD |
| B | 每次派发请求量放大到 2 倍窗口(丢超窗检查) | 派出 274432、pending_remainder_bytes=12288、health rc=2 |
| C | 契约窗口改 0(自相矛盾) | [FATAL] 契约自相矛盾、health rc=3、demo.py 非 0 |
| D | 删掉 conf/upstream.conf | [FATAL] 契约不可用、health rc=3 |
边界 fail-closed 实测:
| 场景 | 退出码 | 输出 |
|---|---|---|
| 正常 | 0 | self_consistent: true, ..., shortfall: 0 |
report.json 缺失 | 2 | {"degraded": true, "issues": ["report.json 不可用"], "self_consistent": false} |
conf/ 缺失 | 3 | [FATAL] 契约不可用:[Errno 2] ... |
| 窗口 100000 不是校验块 65536 的整数倍 | 3 | [FATAL] 契约自相矛盾:窗口(100000) 不是校验块(65536) 的整数倍 |
六、经验与避坑
核心认知
sendfile(2)/write(2)/send(2)的返回值是「真的推出去多少」,不是「请求了多少」 —— 循环终止条件必须是剩余长度,进度累加必须用返回值,请求量还要封顶在收件端窗口。 一行自查:n = sendfile(fd, sock, count)之后,n跟count比对了吗?- 绿色信号分三类:
进程没崩(弱)/自己写的自述(无判别力)/能被外部按契约独立复核的事实(强)。self_consistent拿自己的收量比自己的发量、BAD_FIELDS = ()空判据表、return 0恒定退出码 —— 这类「自述字段自证」 比没有检查更危险,因为它静默地骗人。 - 「判不了」≠「没问题」(fail-closed):拿不到报告/契约必须判不通过并给出可区分的退出码。
- 零头必须如实记账:
% chunk的余数不能悄悄抹掉,它往往就是少发/错发的第一现场。
操作踩坑
ops/*.sh用相对路径入口(cd "$(dirname "$0")/.."+$PWD兜底四个环境变量), 在场景目录直跑与容器内working_dir=/srv等价;本环境无 docker CLI,走这条等价路径。 若后续有 docker:非交互流水线里docker compose exec必须加-T,否则报the input device is not a TTY。- 改完
app/*.py必须rm -rf app/__pycache__,否则旧字节码把「已改」掩盖成「没改」。 ops/negative.sh是「修复态专属」:开头grep -q "while pushed < length" app/exporter.py硬校验(注释与代码结构也是契约的一部分,别把续发循环写变形);过程中会cp覆写app/exporter.py再还原,跑完diff app/exporter.py ops/expected-fixed/exporter.py复核一次。ops/repro.sh以探针退出码为脚本退出码:问题态返回 1,CI 里表现为「命令失败」—— 这是预期的复现信号,不是环境坏了(用; echo rc=$?接住)。state/、payload/*.bin、_receipt-*.json是运行期产物(已 gitignore), 提交前git status确认:本次只有app/exporter.py、app/health.py是预期改动。- 先
diff app/X.py ops/problem-state/X.py判断当前处于哪一态,再动手,省一半时间。
自查三问
- 你所有
write()/send()/sendfile()的返回值都跟输入长度比对了吗? 「调用过」和「发完了」是同一条判据吗? - 导出/上传/复制循环的终止条件是「剩余长度」还是「调用次数」? 进度累加用的是返回值还是请求值?单次请求量有没有封顶在对方声明的能力上?
self_consistent: true/ok: true是算出来的还是写死的? 报告里有没有一个能被外部独立复核的字段(对端实收字节数)? 判据表是不是空的?退出码是不是恒 0?拿不到数据时你判通过还是不通过?
同类影响面
write(2)/fwrite的半截写(partial write)、send()短写、recv()短读、pread/pwrite分段、TCP_NODELAY下的大包分片;- Nginx
sendfile on、rsync/scp/dd的块循环、curl -T上传、 Kafka/Filebeat 大文件投递、S3 multipart 分片、HTTPContent-Length完整性校验; - 一切「一次调用就以为做完了」的循环,一切「请求值与返回值不分」的进度记账, 一切「自述计数器自证 + 空判据表 + 退出码恒 0」的假绿健康检查与巡检脚本。
