把DDIA读厚(六):一次“写入”的奇幻漂流——从应用到磁盘
这是《把 DDIA 读厚》系列的第六篇文章。在 DDIA 的精读之旅中,我们已经聊过了可靠性、可扩展性等宏大主题。今天,我想和你一起,做一次微观探险。我们将聚焦于一个最基础、最频繁的动作——“写入”,跟踪一次小小的写入请求,从它在应用程序中诞生,到最终在磁盘上安家,看看这段旅程中都发生了哪些惊心动魄、充满权衡的故事。
这一切,都始于 DDIA 第三章中的一句话:
“对于极其简单的场景,日志追加式的写入拥有非常好的性能。”
这句话很符合直觉。我们深挖下去,发现无论是对于 HDD 还是 SSD,顺序追加都能完美地顺应硬件的“天性”,获得极佳的性能。不仅如此,我们还了解到,操作系统(OS)的**页缓存(Page Cache)**机制,更是将这个优势发挥到了极致——它将我们应用层无数次微小的写入,在内存中“攒”成大块,再如行云流水般一次性顺序刷入磁盘。
一切看起来如此完美。
但一个致命的疑问也随之浮现:如果 OS 还没来得及把缓存中的数据刷盘,就发生了断电,数据不就丢了吗?
对于任何严肃的业务系统,这都是不可接受的。为了堵上这个漏洞,我们找到了操作系统提供的“保险开关”——fsync
系统调用。它能强制命令 OS 将缓存数据刷入磁盘,确保数据“落盘为安”。
然而,当我们尝试为每一次写入都系上fsync
这条“安全带”时,一个诡异的现象出现了:我们引以为傲的性能优势不仅消失殆尽,整个写入模式甚至会退化。这正是本次探索的核心谜题:
为什么在一个繁忙的系统里,为一个逻辑上“顺序追加”的文件频繁调用fsync
,最终会导致物理上“随机 I/O”的性能表现?
这个看似矛盾的现象背后,隐藏着一场应用、操作系统与硬件之间,关于性能与持久性的复杂博弈。别急,泡杯咖啡,让我们顺着这个疑问,一探究竟。
第一站:理想乡——顺序写入的物理学优势
想象一下最纯粹的写入模型,就像 DDIA 开篇的db_set
脚本一样,它只是简单地将数据追加(append)到一个文件末尾。这在物理上意味着什么?
- 对于机械硬盘(HDD):磁头无需耗费毫秒级的“寻道时间”在盘片上空到处乱飞,只需移动到文件末尾,然后就可以像老式唱片机一样,在盘片旋转时连续不断地刻录数据。
- 对于固态硬盘(SSD):它避免了 SSD 最头疼的“先擦除再写入”的放大效应。对于一个已有的数据块,原地修改意味着“读取整个块 -> 内存中修改 -> 擦除整个块 -> 写回整个块”的酷刑。而顺序追加,则是轻松地在干净的闪存页上连续写入,轻快而优雅。
无论在哪种介质上,顺序 I/O 都牢牢抓住了硬件的“天性”,是写入操作的“理想态”。
第二站:缓冲区——操作系统的好心与谎言
既然顺序写入这么快,那问题出在哪?答案在我们和硬件之间隔着的第一个“中间商”——操作系统。
当我们调用write()
时,应用程序很快就拿到了“写入成功”的回执。但这是一个“善意的谎言”。数据并未抵达磁盘,而是进入了操作系统内核的页缓存(Page Cache)。
这是 OS 的一片苦心:
- 性能:把成百上千次微小的写入请求,在内存里“攒”成一大块,然后一次性交给磁盘,将零散的写入合并成大块的顺序 I/O,效率倍增。
- 响应:让应用程序不必等待缓慢的磁盘,立刻就能返回,继续处理其他任务。
这个缓冲机制也顺带解答了一个底层细节问题:我们逻辑上是连续写入,物理上如何保证磁盘在写完扇区 N 后,紧接着就去写 N+1 呢?答案就在于,文件系统在为这“一大块”攒好的数据申请磁盘空间时,会倾向于分配一片连续的物理块(Extent),从而将逻辑上的顺序追加,转化为了物理上的顺序写入。
然而,这个“中间商”在带来效率的同时,也带来了第一个致命风险:断电会丢数据! 内存是易失的,任何在页缓存里还没来得及去磁盘“安家落户”的数据,都会在断电瞬间烟消云散。
第三站:fsync
的审判——追求真理的代价
为了对抗这种风险,操作系统给了我们一个“杀手锏”——fsync()
系统调用。它像一张神圣的审判令,强制命令 OS:“放下一切缓冲和优化,把我要求的数据,立刻、马上、同步地刷到磁盘上去!直到确认它安全了再回来见我。”
有了fsync
,我们似乎拥有了“数据金身”。但回到我们最初的问题,如果我们为每一次微小的写入都调用fsync
,为何反而会陷入“同步随机 I/O”的泥潭?
想象一下你的服务器后台,它是一个繁忙的十字路口:
- 你的应用刚为
database
文件完成了一次fsync
,磁头停在了它的末尾。 - 就在下几毫秒,操作系统的日志服务
syslog
抢到了 CPU,要求在/var/log/messages
里写一条日志。为此,磁盘磁头不得不长途跋涉,飞到盘片的另一个位置。 - 紧接着,你的应用又来了一次写入请求,再次调用
fsync
。此时,磁头必须从syslog
文件的位置,再千里迢迢地飞回来。
看到了吗?在多任务环境下,磁盘磁头的所有权在不同进程间被疯狂抢夺。我们逻辑上对单一文件的顺序追加,在物理层面被“插队”的其他进程打碎,磁头的移动轨迹和随机写入毫无二致。每一次fsync
都几乎要支付一次完整的“寻道+旋转”的重税。
我们为了追求极致的“真”(数据持久性),却付出了极致的“慢”作为代价。
第四站:架构师的智慧——“成组提交"的救赎
那么,真正的数据库系统是如何走出这个两难困境的呢?它们引入了一种更高级的博弈策略——预写日志(WAL)与成组提交(Group Commit)。
这个策略的精髓在于:用吞吐量换延迟,用批处理摊销成本。
当 100 个事务在同一时刻请求提交时,一个繁忙的数据库并不会傻傻地调用 100 次fsync
。它会:
- 组队:对第一个请求说“稍等”,然后开启一个极短的计时窗口。
- 缓冲:将这 100 个事务的日志记录,在内存的 WAL 缓冲区里拼接成一个大大的数据块。
- 冲刺:对这个包含了 100 个事务的大数据块,执行一次
write()
和一次fsync()
。 - 解放:一旦这次
fsync
成功返回,数据库会同时唤醒那 100 个等待的线程,告诉它们:“你们都成功了!”
这个过程就像坐公交车,虽然第一个到站的乘客需要等其他人上车,牺牲了一点即时性(Latency),但整辆公交车一次运送了大量乘客,极大地提升了系统的总运力(Throughput)。
通过“成组提交”,数据库把对fsync
的调用,从“为每一次写入”变成了“为每一批写入”。昂贵的fsync
成本被几十上百个事务分摊,而写入 WAL 这个动作本身,又保持了纯粹的顺序 I/O 特性。这是一个近乎完美的工程壮举。
终点站,也是新的起点:不确定性的世界
我们的探险似乎已接近尾声,但最极限的挑战才刚刚开始。一个直击灵魂的问题是:
“如果在
fsync
成功后,数据库还没来得及通知客户端,就崩溃了,会发生什么?”
这是一个制造了“不确定性”的幽灵时刻:
- 数据库(重启后):知道事务已成功,数据永不丢失。
- 客户端:只知道连接断了,完全不知道事务的最终状态。
如果客户端冒失地重试,可能会导致用户被重复扣款。此时,我们发现,问题的边界已经超出了数据库自身。数据库保证了它的D
(Durability),但无法解决网络通信的D
(Delivery uncertainty)。
解决这个问题的责任,历史性地交到了我们应用架构师手中。业界的标准答案是:设计幂等(Idempotent)接口。
通过在请求中加入唯一的事务 ID,让服务器有能力识别出:“哦,这个请求我处理过了,虽然上次没来得及告诉你,但现在可以直接给你成功回执,不会重复执行。”
旅程总结
一次写入的奇幻漂流,带我们穿越了硬件物理、操作系统内核、数据库引擎和应用架构四个截然不同的层面。我们看到:
- 为了性能,我们拥抱顺序写入和 OS 缓冲。
- 为了持久性,我们引入
fsync
进行约束。 - 为了在持久性下重获吞吐量,我们发明了 WAL 和成组提交。
- 为了应对系统间的不确定性,我们必须在应用层设计幂等性。
这趟旅程的每一站,都充满了精妙的权衡。没有绝对的“好”与“坏”,只有面向特定场景的“取”与“舍”。而理解这些权衡,并能在自己的设计中运用自如,或许就是“把 DDIA 读厚”的真正意义。
希望这次的探险,能让你在未来每一次写下db.save()
时,都能会心一笑,洞察其背后那波澜壮阔的世界。
- 原文作者:饶全成
- 原文链接:https://qcrao.com/post/ddia-6-journey-of-a-write/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。