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

write() 返回成功却丢数据?fsync / 页缓存 / 持久性契约导致 kill -9 后数据消失(实测复现 + 修复)

关键词:fsync fdatasync page cache 页缓存 持久性 durabilitywrite 返回成功不代表落盘 SIGKILL 数据丢失 kill -9 后数据没了 掉电丢数据flush 不等于 fsync os.write 什么时候落盘 目录 fsync POSIX 持久化契约ok=1 但数据丢了 MySQL innodb_flush_log_at_trx_commit Redis appendfsyncWAL journal durable_bytes volatile_pending 技术栈:Python 3.12 / POSIX 文件 I/O / Docker Compose / 自研 KV 存储引擎


一、问题背景

一个自研的极简 KV 存储引擎 tinykv,用来持久化「任务结果」。 它的部署配置(conf/store.env)里写死了一条持久化契约:

PUT 返回 ok=1  ⟺  该条写已经落到稳定存储
进程被 SIGKILL / 机器掉电后重启,所有收到过 ok=1 的写必须还在。

线上表现非常诡异:

  • 上游「提交任务结果」的服务从来没报过错,日志里 PUT ok=1 一条不少;
  • 每次运维 kill -9 掉 tinykv 再重启,最近几分钟写入的结果就凭空消失了;
  • 下游对账发现「上游说提交成功、下游查不到」;
  • 而现场看什么都正常:/healthz 200、写完立刻 /dump 数据都在、 journal.log 非空且在增长、PUT 响应里甚至带着 "flush_called": true。

一句话概括这个 bug 的气质: 进程内怎么查都对,只有真的把进程杀掉再起来,数据才没了。

这类问题的通用名字叫 durability(持久性)契约违背 —— 服务对调用方承诺了「已落盘」,实际只做到了「写进了内核页缓存」。


二、复现步骤(Docker 一键,不需要任何特权)

git clone <repo> && cd <repo>
git checkout 2026-09-24-1600
cd scenario/2026-09-24-1600

docker compose up -d --build          # python:3.12-slim + curl/procps
docker compose ps                     # 等 healthy
docker compose exec app bash          # 进入容器

容器内执行最小复现:

bash /ops/repro.sh

问题态实测输出(节选):

── 1) 写 3 条,客户端逐条收到 ok=1 ──
{"ok": true, "seq": 1, "sync": {"mode":"flush","fsync":false,"dir_sync":false,
                                "durable_bytes":0,"flush_called":true,"volatile_pending":36}}
{"ok": true, "seq": 2, "sync": {... "durable_bytes":0, "volatile_pending":72}}
{"ok": true, "seq": 3, "sync": {... "durable_bytes":0, "volatile_pending":108}}

── 2) 进程内立刻读,数据都在(最迷惑人的地方)──
{"ok": true, "seq": 3, "kv": {"k1":"djE=","k2":"djI=","k3":"djM="}}

── 3) 掉电(等价于 kill -9 + 页缓存消失)──
{"ok": true, "power_loss": {"lost_files": {"journal.log": 108}, "durable": {}},
 "replay": {"replayed": 0, "bytes": 0}, "keys": 0}

── 4) 重启后 dump ──
{"ok": true, "seq": 0, "kv": {}}

── 5) 逐条 GET ──
k1 -> {"ok": false, "error": "NOT_FOUND", "key": "k1"}
k2 -> {"ok": false, "error": "NOT_FOUND", "key": "k2"}
k3 -> {"ok": false, "error": "NOT_FOUND", "key": "k3"}

3 条全部 ok=1 的写,掉电后 3 条全军覆没。

判定集(8 条断言 A0~A7):

docker compose exec app bash /ops/verify.sh
# 问题态:PASS=4 FAIL=4,RESULT: BAD,退出码 1

⚠️ 必须用干净卷做基线:如果卷里有历史 durable 数据, A2(/sync 后零丢失)会侥幸通过,你会拿到 PASS=5 FAIL=3 这种对不上的结果。 先 docker compose down -v 再 up。


三、根本原因

3.1 直接原因:flush 档只写了一个「自我声明」的布尔值

存储服务的 do_sync() 里:

elif SYNC_MODE == "flush":
    # 配置里写着 flush,注释里写着「flush 到磁盘」
    rep["flush_called"] = True        # ← 唯一做的事
elif SYNC_MODE == "fsync":
    FS.fsync(JOURNAL_REL, sync_dir=False)   # ← 这个分支才是对的
    rep["fsync"] = True

flush 分支里没有调用任何 fsync()。 rep["flush_called"] = True 只是一句写在响应体里的字符串, 没有任何副作用,和磁盘状态毫无关系。

而部署实际使用的正是 SYNC_MODE=flush。

3.2 语义链条:承诺(durable)远大于实际(volatile)

契约声明            : PUT ok=1  ⟺  已落到稳定存储
代码实际行为        : PUT ok=1  ⟺  os.write() 返回(进页缓存) + 置个标志位
                                            ↑ 差距在这里
结论                : 掉电必然丢已 ack 的写

3.3 底层原理:write() 到底做了什么

POSIX 的写路径模型:

      write()                    fsync() / fdatasync()
应用 ─────────► 页缓存(page cache) ────────────────────► 稳定存储(磁盘)

掉电 = 丢弃页缓存,只保留已落盘的内容

write() 的返回值语义是「内核接收了多少字节」, 也就是复制到页缓存成功,跟磁盘没有任何关系。

下面这些操作都不算落盘(这是最常见的认知误区):

操作是否落盘说明
os.write(fd, data) 返回 512❌只到页缓存
file.flush() / f.flush()❌只是把应用层缓冲交给内核;os.write 早就交了
io.BufferedWriter.flush()❌同上,Python 层
close(fd) / f.close()❌只是放下 fd 引用,脏页仍在页缓存
os.fsync(fd)✅数据 + 元数据落盘,唯一可靠手段
os.fdatasync(fd)✅数据 + 必要元数据落盘(比 fsync 略快)
O_SYNC / O_DSYNC 打开✅每次 write 都同步,代价最高

「flush」这个词在计算机里至少有三种含义(用户态缓冲→内核、内核缓冲→磁盘、 CPU cache→内存),它从来都不等于 fsync。

3.4 三个让 bug 隐形的「观测陷阱」

这是本题最有价值的部分 —— 为什么「现场看什么都正常」:

  1. /dump 立刻能读到 —— 因为内存 KV(_kv)跟落盘无关, 写成功只是更新了内存字典;
  2. journal.log 非空、大小在增长 —— 增长的是 volatile_pending (页缓存待落盘计数),不是 durable_bytes(稳定存储字节数)。 问题态 durable_bytes 恒为 0;
  3. 响应里有 "flush_called": true —— 名字极其像「刷好了」, 实际只是一个手写常量,属于误导性埋点(比没有埋点更危险)。

3.5 附带的一个必须处理的点:目录项也要 fsync

fsync(file_fd) 只保证「文件数据 + 该文件的 inode」落盘。 如果这个文件是本次才新建的,它的目录项(dentry)可能还在页缓存里, 掉电后文件名本身都不存在。

所以首次创建文件/目录的场景必须额外刷目录:

dfd = os.open(os.path.dirname(path), os.O_RDONLY)
os.fsync(dfd)          # 目录项落盘
os.close(dfd)

四、解决办法

4.1 修复代码(三件事必须同时做到)

elif SYNC_MODE in ("fsync", "fsync_dir", "flush"):
    # ① 关键:真正把页缓存里的字节落盘(POSIX fsync 语义)
    FS.fsync(JOURNAL_REL, sync_dir=(SYNC_MODE in ("fsync_dir", "flush")))
    rep["fsync"] = True
    # ② 目录项一并 fsync(新建文件的场景必需)
    rep["dir_sync"] = SYNC_MODE in ("fsync_dir", "flush")
    if SYNC_MODE == "flush":
        rep["flush_called"] = True
        rep["upgraded_from"] = "flush"     # 如实标注:这个档名做了 fsync

# ③ ★ 回读自证:证据来自「落盘后再读一遍」,而不是「我以为刷了」
rep["durable_bytes"] = len(FS.durable.get(JOURNAL_REL, b""))
rep["volatile_pending"] = len(FS.volatile.get(JOURNAL_REL, b""))
if SYNC_MODE != "none" and rep["volatile_pending"] != 0:
    raise SystemExit("durability violated: volatile_pending=%d after fsync"
                     % rep["volatile_pending"])
#改动解决什么
①flush 档真正 FS.fsync(...)契约要求 ok=1 ⟺ 已落盘,就必须做真落盘
②sync_dir=True首次创建 journal 时目录项也要刷
③落盘后回读 volatile_pending,非 0 直接报错不拿「计划值」当事实;宁可失败也不回 ok=1

4.2 修复设计的三条原则(比代码更重要)

  1. fsync 加在「承诺持久」的那一刻,不是随便加 实测:单次 fsync() ≈ 3.7 ms,而 200 次 write() 累计只有 0.28 ms。 fsync 有真实成本,所以要加在 PUT 返回 ok=1 之前这一个点上, 而不是每个 write 都加。这也正是 innodb_flush_log_at_trx_commit=1 / Redis appendfsync always 这些参数在做的事 —— 它们在权衡「持久性 vs 吞吐」。
  2. 响应的证据字段必须来自「回读」,不能是「计划值」 问题态里 flush_called: true 之所以能骗人,就是因为它是个常量。 修好后 fsync: true 是调用完真的 fsync 之后才写的, 而且 volatile_pending 用回读值做二次校验。
  3. 不要用「降低承诺」来绕过 工程师最容易想到的「修法」是把契约改成 「ok=1 只代表已写入缓冲区」。这在技术上自洽,但违背了部署方的契约, 属于把 bug 转嫁给调用方。正确做法是让实现追上承诺。

4.3 应用修复

# 分支上已包含修复实现
docker compose up -d --build

# 或者从问题现场出发用参考脚本(⚠️ 不带参数=应用修复,不是还原)
bash /ops/apply_fix.sh
docker compose restart app      # 运行中的进程不会自动重载源码

五、验证

5.1 最小复现(修复后)

── 3) 掉电 ──
{"power_loss": {"lost_files": {},                      ← 零丢失
                "durable": {"journal.log": 258}},      ← 稳定存储真的有数据
 "replay": {"replayed": 6, "bytes": 258}, "keys": 6}

── 5) 逐条 GET ──
k1 -> {"ok": true, "key": "k1", "value": "djE="}       ← 全部活着
k2 -> {"ok": true, "key": "k2", "value": "djI="}
k3 -> {"ok": true, "key": "k3", "value": "djM="}

PUT 响应字段对比(这就是「自证落盘」):

问题态: {"fsync": false, "durable_bytes": 0,   "volatile_pending": 36}
修复态: {"fsync": true,  "durable_bytes": 186, "volatile_pending": 0,
         "dir_sync": true, "upgraded_from": "flush"}

5.2 判定集

docker compose down -v && docker compose up -d --build
docker compose exec app bash /ops/verify.sh
  [PASS] GET /healthz 返回 200
  [PASS] 默认档 20/20 条已 ack 的写在掉电后全部存活
  [PASS] /sync 是可用的落盘入口(掉电零丢失)
  [PASS] PUT 响应自证 fsync=true(落盘证据齐全)
  [PASS] SYNC_MODE 生效,当前档=flush
  [PASS] 掉电模型自洽(未落盘进 volatile / 落盘后清零)
  [PASS] 覆盖写掉电后读回最新值
  [PASS] 掉电语义未被阉割(有判别力)

PASS=8 FAIL=0 / RESULT: OK      # 退出码 0

持久性压测报告:

问题态: acked=20 verified=0  lost_ratio=1.0   result=BAD
修复态: acked=20 verified=20 lost_ratio=0.0   result=OK

5.3 反向验证 —— 证明判定「有牙」

把掉电模型阉割成 no-op(不丢 volatile),不动业务代码:

[FAIL] A7_BAD 掉电没有丢任何东西 —— /powerloss 可能被改成 no-op
PASS=7 FAIL=1 / RESULT: BAD

还原后回到 PASS=8 FAIL=0。说明断言确实能抓住 「靠把掉电改成 no-op 来假装修好」这条作弊路径。

5.4 四个档位的行为矩阵(实测)

SYNC_MODEPUT 响应 sync掉电后 5 条存活意图
nonefsync:false, durable_bytes:0, volatile_pending:1800/5诚实:不落盘、不承诺持久
flush(默认)fsync:true, dir_sync:true, volatile_pending:05/5修复后:升级为真 fsync
fsyncfsync:true, dir_sync:false, volatile_pending:05/5文件 fsync
fsync_dirfsync:true, dir_sync:true, volatile_pending:05/5文件 + 目录 fsync

六、经验与避坑

6.1 认知类(最值钱的部分)

  1. write() 成功 ≠ 落盘。write() 的返回是「内核收下了多少字节」,落到页缓存就返回了。 只有 fsync / fdatasync / O_SYNC 才保证落盘。
  2. flush 永远不等于 fsync。 这是本题的核心命名陷阱:Python 的 file.flush()、Redis 的 appendfsync、 各种框架的 flush() 都在做「把应用层缓冲交给内核」。 甚至 os.write 本身早就完成了这一步 —— 所以「已经 flush 过了」什么也不保证。
  3. 「进程内能读到」不能证明任何持久性。 内存里的数据结构(缓存、字典、_kv)跟落盘是两条独立路径。 验证一个持久化系统的唯一可信方法是真的把进程杀掉 / 掉电,再重启回读。
  4. 「文件非空、大小在增长」也不是落盘证据。 增长的可能只是页缓存的待落盘计数。 要看**「稳定存储上的字节数」**这个指标,而不是文件表观大小。
  5. 误导性的自证字段比没有字段更危险。"flush_called": true 这种「名字很对、实际是常量」的字段, 会让所有排查的人在第一分钟就走错方向。埋点必须回读真实状态。
  6. 判定标准要「有牙」。 只测「好输入返回正确」不够 —— 必须也能抓住「把失败路径改成 no-op 来假装成功」这种作弊。本题的 A7 就是专门为此设计的, 实测阉割掉电模型后 A7 立刻变红。

6.2 工程类

  1. 首次创建文件的场景,目录项也要 fsync。fsync(file_fd) 不保证文件名本身在掉电后存在。 要 os.open(dirname, O_RDONLY) 之后 os.fsync(dfd)。
  2. fsync 有真实成本,要加在正确的位置。 实测容器内单次 fsync() ≈ 3.7 ms,200 次 write() 仅 0.28 ms。 正确策略是「在承诺持久的那一刻 fsync」, 或用组提交(group commit)把多个事务的 fsync 合并。
  3. close() 不等于 fsync()。 很多语言里 close 只是释放 fd,脏页照样留在页缓存。 依赖 close 保证持久性是个经典错误。
  4. 不要用「降低承诺」来修复。 把 ok=1 的语义改成「已进缓冲区」在技术上自洽, 但违背了与调用方的契约,属于把 bug 转嫁给上游。

6.3 环境与操作类(实测踩到的)

  1. docker compose down -v 是干净基线的必要步骤。 有历史 durable 数据的卷会让判定「侥幸通过」, 我实测拿到了 PASS=5 FAIL=3 而不是文档里的 PASS=4 FAIL=4。
  2. env_file 的优先级高于 docker compose 的命令行环境变量。SYNC_MODE=fsync docker compose up -d 不生效, 因为 env_file: conf/store.env 覆盖了它。 要改档位得改 conf/store.env 本身。
  3. 改代码后必须重建镜像 + 重启进程。docker compose restart 不会重刷通过 COPY 进镜像的文件; 要用 docker compose up -d --build。 而且运行中的 Python 进程不会自动重载源码,必须真的重启。
  4. apply_fix.sh 不带参数是「应用修复」,不是还原。 还原要显式 --restore。想拿问题态基线, 直接用 git checkout -- app/store.py 更可靠。
  5. 健康检查不要只比大小。 「页缓存挂 108 字节、稳定存储 0 字节」这种状态, 如果只比 (文件名, 大小) 也是自洽的、永远发现不了。 要落到「掉电后回读是否还在」。

6.4 同类影响面(同一个原理,别的地方也会犯)

  • MySQL:innodb_flush_log_at_trx_commit=0/2 下 commit 返回了但掉电丢事务; 只有 =1 才保证 commit 落盘
  • Redis:appendfsync no/everysec 与 always 的持久性差异
  • PostgreSQL:synchronous_commit=off 时 commit ack 先于 WAL 落盘
  • RocksDB / LevelDB:WriteOptions.sync = false 是默认值
  • Kafka:acks=1 只保证 leader 写入(还在页缓存),acks=all + min.insync.replicas 才接近持久
  • 大多数语言的 file.flush() / f.close() 都不保证落盘
  • 任何「先回成功、后异步刷盘」的写法,都是把 durability 契约悄悄降级

七、可复现命令速查

git fetch origin && git checkout 2026-09-24-1600
cd scenario/2026-09-24-1600

# ① 问题态基线(干净卷)
docker compose down -v 2>/dev/null
git checkout -- app/store.py
docker compose up -d --build
docker compose exec app bash /ops/repro.sh       # 3 条 ok=1 的写全丢
docker compose exec app bash /ops/verify.sh      # PASS=4 FAIL=4 / BAD

# ② 修复态
git checkout 2026-09-24-1600 -- app/store.py
docker compose down -v && docker compose up -d --build
docker compose exec app bash /ops/repro.sh       # 3 条全活
docker compose exec app bash /ops/verify.sh      # PASS=8 FAIL=0 / OK

# ③ 反向验证判定有没有牙(把掉电阉割成 no-op)
#    改 app/crashfs.py 的 power_loss() 不丢 volatile → A7 必须变红

docker compose down -v                            # 清理

无 docker CLI 时,也可在 pipeline 容器内直接复现:

cd scenario/2026-09-24-1600
# 纯标准库,无第三方依赖(python3 >= 3.8 即可)
SYNC_MODE=flush DATA_DIR=/tmp/kvdata PORT=8080 python3 app/store.py &
sleep 1
BASE=http://127.0.0.1:8080 bash ops/verify.sh
kill %1

八、一句话总结

write() 只是把字节交给了内核页缓存,fsync() 才是落盘的唯一保证。 任何「返回成功就等于已持久」的承诺,都必须能追到一次真实的 fsync —— 否则你承诺的是 durability,用户得到的只是 volatility。

版权声明

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

分享这篇文章