数据库写入成功的五个层级:内存、SSD、对象存储、多数派、跨可用区

写进内存就返回成功,和等数据真正落盘再返回,中间隔着一场关于“可靠性”的赌博。所有数据库都得在这道选择题前站队。

数据库写入速度的秘密不在硬件,而在“什么时候告诉你成功了”这个决策点上。本地SSD刷盘、对象存储上传、多节点复制,每种路径对可靠性的承诺完全不同。从内存拷贝到跨机房容灾,成功返回的时机决定了数据到底能活多久。

快不一定是好事

数据库返回“写入成功”的那一刻,数据可能只存在于内存里。机器一断电,这趟写入就白干了。

fdatasync() 这个系统调用,在Linux世界里负责把数据真正推到硬盘上。等它执行完再返回,意味着进程崩溃、内核崩溃都不会丢数据。但要是只依赖内存拷贝,write() 调用返回时数据还在内核的页缓存里晃荡,机器一重启全完蛋。

NVMe是个接口标准,不是存储位置的保证。一块NVMe硬盘可以插在本地主板上,也可以是一个远程网络卷披着NVMe的外衣。系统调用名字一样,等待的东西完全两码事。本地SSD的 fdatasync() 扛得住进程崩溃,扛不住硬盘物理损坏或者整台机器被端掉。

测量延迟的时候,计时器停在哪儿至关重要。是停在内存拷贝完成?还是本地SSD刷盘结束?还是远程存储服务返回确认?不搞清楚这个锚点,任何延迟数字都是耍流氓。

一次刷盘养活无数写请求

每个写入都单独调用 fdatasync(),逻辑最清晰,但性能最烂。一秒一千次刷盘就到顶了,硬盘就算能飙到几GB每秒,IOPS能力也会在频繁刷盘面前败下阵来。

攒批是经典的优化手段。把几百个写入请求塞进同一个批次,最后只调一次 fdatasync()。批次里所有等待的写入请求一起拿到成功返回。吞吐量瞬间就上去了,但第一批里的请求得等到最后一批的最后一个哥们儿写完才能走,尾端延迟变长了。

攒批得设限:字节数上限、写入数量上限、时间上限,还得盯着待处理队列长度。存储设备慢下来的时候,无限制的排队能把所有内存都吃光。批次满了以后,还没等上一批刷完下一批就满了,这时候再磨蹭只会徒增延迟。

设备成本跟实际落盘的写入大小直接挂钩。每秒搬1GB数据,用256KB的块写只需要约4096次IOPS,用16KB的块写就得65536次IOPS。客户端请求数量本身说明不了什么,真正决定IOPS的是那些最后落到设备上的写入尺寸。

数据出了这台机器才算硬气

等本地SSD刷完盘就返回成功,对象存储的上传动作完全甩在客户端等待时间之外。但成功的含义变了——最新的WAL记录只存在于一台数据库主机的一块SSD上。这台机器要是挂了,恢复出来的只能是上次上传之后的老数据。

数据库因此变得有状态了。调度器不能随意把它挪到任何一台机器上还指望所有成功写入都在那儿等着。要么保证这块SSD一直活着,要么接受一个时间窗口内的数据丢失,要么在返回成功之前再搞一份可靠副本。

把远程存储的 PUT 放到成功条件里,窗口就关掉了。但每次写入都得等HTTP请求回来。批量化可以减少请求次数,但批次里最先到的那些请求,得等后面的请求把批次填满,等待时间更长。

对象存储的条件写入能解决特定场景的并发冲突。If-None-Match: * 保证只有key不存在时才创建,If-Match: 保证只有版本匹配时才更新。输掉条件检查的请求直接失败返回,调用者可以基于预期版本做单对象更新。但这不解决多个对象作为一个整体变更的问题,也不解决旧写入者对其他key的访问权限问题。

当对象 PUT 的延迟能接受,数据库可以把持久化责任全甩给存储服务。当延迟不能接受,但每个成功写入又必须扛住数据库主机消失的风险,那就只能在多个数据库服务器之间复制WAL,等足够多的节点刷完盘再返回。

复制WAL是一场协调游戏

单机状态数据库变三机状态。客户端把 PUT 发给领导者节点,领导者先在本地追加WAL记录,再把同一条记录发给两个追随者。领导者和任意一个追随者都刷盘成功就可以返回成功——这样丢一台服务器数据还在。

相比单机本地SSD,每次提交都多了一跳网络传输和另一块盘的刷盘等待。副本放在同一个机架里,多数派刷盘可能比一次远端对象存储请求还快。副本分散在不同的故障域,扛得住更大范围的故障,但网络耗时也上去了。

协调不止是拷贝字节。服务器之间得决定谁有权写,哪些写入已经提交。存储引擎不一定要自己跑领导者选举,别的服务可以指定写入者并分配一个递增的编号,存储层直接拒绝携带过期编号的写入。协调逻辑还在,只是搬到了存储引擎外面。

Raft协议里,领导者记录当前任期号和写入在日志中的位置。把写入发给追随者,等多数派把这条日志刷到磁盘就提交。磁盘操作和网络操作可以重叠,正常情况下的延迟接近两者中较慢的那个,而不是两者之和。

有个常见陷阱是让追随者在内存里确认就返回。Raft原始论文明确要求刷盘之后才能返回确认。内存确认跑得快,但机器一起重启或断电,那些已经返回给客户端成功的写入就人间蒸发了。

复制写入甩给系统的额外负担:领导者选举、故障后日志修复、落后节点的日志回补、全量快照加速重启、安全增删节点、监控复制延迟。这些活儿在单次健康写入的延迟数字里全看不见。

读得快需要索引和打扫战场

光靠重放WAL恢复数据,读请求没法快。每次读都扫描整个WAL,读成本会随着历史增长而膨胀。存储引擎必须维护一个从key到最新值或磁盘位置的独立查找结构。

哈希索引提供精确查找,key不用排序。B-tree支持有序读取和范围扫描,但树的高度增加会带来更多工作,页面更新和分裂也产生额外维护成本。LSM把新key放内存和排序文件里,后台再合并重写。

LSM和多版本并发控制(MVCC)描述的是数据库的不同层面,可以共存在同一套系统里。LSM管key怎么在内存和排序文件之间流动,MVCC管哪个版本的记录对读者和写者可见。LSM后台合并排序文件,追加型blob存储把活数据从包含死字节的文件里拷贝出来,基于页面的MVCC引擎清理不再可见的行版本。逻辑删除或替换之后,每个算法都会留下老字节在身后。

O(key字节数) 的删除可以追加一条删除记录并更新查找结构。真正释放老值占用的空间,后续需要读文件索引、拷贝仍然活着的值、把读请求切到替换文件、删除没有读者再用的文件。一套流程走完之前,空间没释放,成本也没降下来。

大O符号不说的秘密

回到最初的复杂度表。同样一个客户端 PUT,复杂度都是 O(key字节数 + payload字节数),不管它是等本地SSD刷盘、等对象存储返回、还是等多数派数据库服务器刷完。大O告诉我们key或value变大时工作量怎么增长,但完全看不出设备刷盘、远程HTTP请求、网络往返加两次刷盘之间的延迟差异。

GETDELETE 的复杂度排除了正常读操作里扫描每个对象、重放完整WAL找key、删一个值扫描全量数据的可能性。但复杂度不说明固定开销是微秒还是毫秒。看延迟结果必须同时搞清楚成功锚点和百分位数。

留给后台做的事情也得设限。WAL文件得有最大尺寸限制,虽然上传它本身的复杂度是 O(文件字节数)。清理任务最终可能要审视或重写整个数据集,但每一轮执行都必须有字节数上限和保存的进度位置,以便崩溃后能接着干。待处理队列也必须设限——写入产生老数据的速度可能比清理消费的速度快,没有限制的话,廉价的前台路径只是把无限膨胀的队列藏起来了。

大模型能飞快地写出上传器、清理循环、复制日志的实现代码,却决定不了系统能容忍多少未完成工作堆积、什么时候必须踩刹车减速写入。超时意味着什么、哪种崩溃会导致丢数据,这些决策还是得写下来,放在 fdatasync() 周围、对象当前版本切换逻辑周围、写入者替换逻辑周围、多数派复制逻辑周围,反复测试验证。

跟着写入路径从内存走到本地SSD、走到远端存储、走到副本集群,权衡关系就清晰了。走得更快的路径通常做的活儿更少,扛住的故障也更少。把成功点往后推能买来更多可靠性,但代价是数据库得自己扛起协调、修复、清理的责任。GETPUTDELETE 的性能数字只有在这些决策写明白之后才有意义。

从内存到三副本,成功返回的时机每往后推一步,可靠性就涨一截,但代价是延迟和系统复杂度同步飙升。所有号称“飞快”的写入数字,都得先问一句:快给谁看?丢了谁负责?



原文期刊:数据库内参 / 发表日期:2026-07-15 / 原文标题:Every Fast Write Moves Work Somewhere Else


要点总结:
  • 为什么fdatasync()返回成功但数据仍可能丢失?系统没说谎,只是定义不同
  • 选数据库前先看写入成功定义:每秒十万写入和每秒十万持久化是两码事
  • 最快的写入最危险——0.5ms的“成功”可能只是内存里的一缕青烟,一断电就没了