朕喜技术(ZhenxiJS)
2026.10.04
1 分钟阅读

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」被当成了「发完了」。

四处附带缺陷,全都不抛异常、前一个把后一个藏起来

  1. 自检自证:self_consistent() 判的是 bytes_received == bytes_sent —— 收件端实收与「本端派出」两个值都出自自己,同源必相等,永远为真; 正确口径是「收件端实收 == 应发(bytes_requested)」。
  2. 空判据表:app/health.py 的 BAD_FIELDS = (),for 一次都不循环 → 无论发生什么都报 degraded: false / issues: [];main() 末尾还无条件 return EXIT_OK。
  3. 契约不可用不失败:build_exporter() 直接 contract.load_*(), KeyError/OSError 直接冒泡,不是契约约定的 ContractError(退出码 3); 窗口 0 这类自相矛盾契约也无校验。
  4. 零头被悄悄抹掉: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 退出码 0main() 无条件 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 调用次数34
sendfile 返回值累加135168262144
收件端真正收下135168262144
少发字节数 shortfall1269760
派出量 % 校验块40960
探针结论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 实测:

场景退出码输出
正常0self_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 的余数不能悄悄抹掉,它往往就是少发/错发的第一现场。

操作踩坑

  1. ops/*.sh 用相对路径入口(cd "$(dirname "$0")/.." + $PWD 兜底四个环境变量), 在场景目录直跑与容器内 working_dir=/srv 等价;本环境无 docker CLI,走这条等价路径。 若后续有 docker:非交互流水线里 docker compose exec 必须加 -T,否则报 the input device is not a TTY。
  2. 改完 app/*.py 必须 rm -rf app/__pycache__,否则旧字节码把「已改」掩盖成「没改」。
  3. ops/negative.sh 是「修复态专属」:开头 grep -q "while pushed < length" app/exporter.py 硬校验(注释与代码结构也是契约的一部分,别把续发循环写变形);过程中会 cp 覆写 app/exporter.py 再还原,跑完 diff app/exporter.py ops/expected-fixed/exporter.py 复核一次。
  4. ops/repro.sh 以探针退出码为脚本退出码:问题态返回 1,CI 里表现为「命令失败」—— 这是预期的复现信号,不是环境坏了(用 ; echo rc=$? 接住)。
  5. state/、payload/*.bin、_receipt-*.json 是运行期产物(已 gitignore), 提交前 git status 确认:本次只有 app/exporter.py、app/health.py 是预期改动。
  6. 先 diff app/X.py ops/problem-state/X.py 判断当前处于哪一态,再动手,省一半时间。

自查三问

  1. 你所有 write() / send() / sendfile() 的返回值都跟输入长度比对了吗? 「调用过」和「发完了」是同一条判据吗?
  2. 导出/上传/复制循环的终止条件是「剩余长度」还是「调用次数」? 进度累加用的是返回值还是请求值?单次请求量有没有封顶在对方声明的能力上?
  3. 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 分片、HTTP Content-Length 完整性校验;
  • 一切「一次调用就以为做完了」的循环,一切「请求值与返回值不分」的进度记账, 一切「自述计数器自证 + 空判据表 + 退出码恒 0」的假绿健康检查与巡检脚本。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-04-1600-sendfile-0

分享这篇文章