write() 返回成功却丢数据?fsync / 页缓存 / 持久性契约导致 kill -9 后数据消失(实测复现 + 修复)
关键词:
fsyncfdatasyncpage cache页缓存持久性durabilitywrite 返回成功不代表落盘SIGKILL 数据丢失kill -9 后数据没了掉电丢数据flush 不等于 fsyncos.write 什么时候落盘目录 fsyncPOSIX 持久化契约ok=1 但数据丢了MySQL innodb_flush_log_at_trx_commitRedis appendfsyncWALjournaldurable_bytesvolatile_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再重启,最近几分钟写入的结果就凭空消失了; - 下游对账发现「上游说提交成功、下游查不到」;
- 而现场看什么都正常:
/healthz200、写完立刻/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 隐形的「观测陷阱」
这是本题最有价值的部分 —— 为什么「现场看什么都正常」:
/dump立刻能读到 —— 因为内存 KV(_kv)跟落盘无关, 写成功只是更新了内存字典;journal.log非空、大小在增长 —— 增长的是volatile_pending(页缓存待落盘计数),不是durable_bytes(稳定存储字节数)。 问题态durable_bytes恒为 0;- 响应里有
"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 修复设计的三条原则(比代码更重要)
- 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 吞吐」。 - 响应的证据字段必须来自「回读」,不能是「计划值」
问题态里
flush_called: true之所以能骗人,就是因为它是个常量。 修好后fsync: true是调用完真的 fsync 之后才写的, 而且volatile_pending用回读值做二次校验。 - 不要用「降低承诺」来绕过
工程师最容易想到的「修法」是把契约改成
「
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_MODE | PUT 响应 sync | 掉电后 5 条存活 | 意图 |
|---|---|---|---|
none | fsync:false, durable_bytes:0, volatile_pending:180 | 0/5 | 诚实:不落盘、不承诺持久 |
flush(默认) | fsync:true, dir_sync:true, volatile_pending:0 | 5/5 | 修复后:升级为真 fsync |
fsync | fsync:true, dir_sync:false, volatile_pending:0 | 5/5 | 文件 fsync |
fsync_dir | fsync:true, dir_sync:true, volatile_pending:0 | 5/5 | 文件 + 目录 fsync |
六、经验与避坑
6.1 认知类(最值钱的部分)
write()成功 ≠ 落盘。write()的返回是「内核收下了多少字节」,落到页缓存就返回了。 只有fsync/fdatasync/O_SYNC才保证落盘。flush永远不等于fsync。 这是本题的核心命名陷阱:Python 的file.flush()、Redis 的appendfsync、 各种框架的flush()都在做「把应用层缓冲交给内核」。 甚至os.write本身早就完成了这一步 —— 所以「已经 flush 过了」什么也不保证。- 「进程内能读到」不能证明任何持久性。
内存里的数据结构(缓存、字典、
_kv)跟落盘是两条独立路径。 验证一个持久化系统的唯一可信方法是真的把进程杀掉 / 掉电,再重启回读。 - 「文件非空、大小在增长」也不是落盘证据。 增长的可能只是页缓存的待落盘计数。 要看**「稳定存储上的字节数」**这个指标,而不是文件表观大小。
- 误导性的自证字段比没有字段更危险。
"flush_called": true这种「名字很对、实际是常量」的字段, 会让所有排查的人在第一分钟就走错方向。埋点必须回读真实状态。 - 判定标准要「有牙」。 只测「好输入返回正确」不够 —— 必须也能抓住「把失败路径改成 no-op 来假装成功」这种作弊。本题的 A7 就是专门为此设计的, 实测阉割掉电模型后 A7 立刻变红。
6.2 工程类
- 首次创建文件的场景,目录项也要 fsync。
fsync(file_fd)不保证文件名本身在掉电后存在。 要os.open(dirname, O_RDONLY)之后os.fsync(dfd)。 fsync有真实成本,要加在正确的位置。 实测容器内单次fsync()≈ 3.7 ms,200 次write()仅 0.28 ms。 正确策略是「在承诺持久的那一刻 fsync」, 或用组提交(group commit)把多个事务的 fsync 合并。close()不等于fsync()。 很多语言里close只是释放 fd,脏页照样留在页缓存。 依赖close保证持久性是个经典错误。- 不要用「降低承诺」来修复。
把
ok=1的语义改成「已进缓冲区」在技术上自洽, 但违背了与调用方的契约,属于把 bug 转嫁给上游。
6.3 环境与操作类(实测踩到的)
docker compose down -v是干净基线的必要步骤。 有历史 durable 数据的卷会让判定「侥幸通过」, 我实测拿到了PASS=5 FAIL=3而不是文档里的PASS=4 FAIL=4。env_file的优先级高于docker compose的命令行环境变量。SYNC_MODE=fsync docker compose up -d不生效, 因为env_file: conf/store.env覆盖了它。 要改档位得改conf/store.env本身。- 改代码后必须重建镜像 + 重启进程。
docker compose restart不会重刷通过COPY进镜像的文件; 要用docker compose up -d --build。 而且运行中的 Python 进程不会自动重载源码,必须真的重启。 apply_fix.sh不带参数是「应用修复」,不是还原。 还原要显式--restore。想拿问题态基线, 直接用git checkout -- app/store.py更可靠。- 健康检查不要只比大小。
「页缓存挂 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。
