第一次听到“写比读快”这种说法,很多人第一反应是“你在逗我”。我读书那会儿,老师讲的、书上写的,全是“读快、写慢”,因为磁盘寻道慢、内存写入有开销、随机写更是杀器。真实系统里,读操作怎么着也比写操作“轻”吧?直到我真正接触了以 LSMT(Log-Structured Merge-Tree,日志结构合并树)为核心的存储引擎,才意识到“写比读快”不仅成立,还在生产环境里被无数系统验证过。这背后不只是数据结构层面的取舍,更是对存储硬件物理特性的一种妥协与顺从。
顺着这个标题往下聊:黑胶唱片和 LSM 树之间,最妙的连接点是“顺序”。唱片在刻录时,唱针在母盘上连续刻出螺旋沟槽,这一趟走下来,物理介质上写入的是连续数据;而在播放时,唱针需要循迹读取,尤其你想跳到某一首曲子,唱针要准确地移动到对应沟槽,这一步在模拟世界里其实很“贵”。你看,这不就是活生生的“写的时候连续,读的时候随机定位”吗?现代存储系统和这张老唱片,本质上是同一个故事。
1. 黑胶唱片与 LSM 树:一个有关“顺序”的类比
1.1 唱片刻录本身就是顺序追加
黑胶唱片的生产流程很诚实:母盘刻录时,刻纹头在漆包盘上匀速旋转、持续刻槽,一段音乐对应一段连续声槽。这个动作的特点是什么?它没有“修改”的概念——你不可能在一首歌的声槽中间把某一小段单独抠出来重录,然后再让唱针正常播下去。模拟世界的写入,本质上是“一刀切不可逆”的连续追加。即使要重新录制某个段落,你也只能整轨重刻,或者用拼接的方式重新走一遍母盘。
这种“追加式”的写入模式,在今天的大数据存储里有一个很形象的名字:顺序写。顺序写的最大优势在于,物理介质不需要频繁做位置切换;对于机械硬盘,磁头不用来回寻道,对于 SSD,闪存块按持续区域擦写明显更稳。LSM 树的设计哲学正是如此:它不追求修改已有数据,而是先把所有写入操作当作新日志“往末尾堆”,等积累到一定量之后再统一做归并整理。这和黑胶唱片“整段追加”的核心逻辑是一模一样的。
1.2 读取的本质是索引查找
唱片刻录完成之后,用户面对的是一个固定不变的物理介质集合。读取时,唱针要精确避开灰尘和划痕,沿着声槽保持线性循迹;跳到指定曲目时,更需要依靠唱片沟槽间距、肉眼大致定位再到精细落针。这个过程看起来不复杂,但是如果你想“随机取某一秒的音频片段”,本质上是在模拟介质上做“随机访问”,难度反而比“从头到尾播一遍”大得多。
对照到存储系统,读操作的核心从来不是“读取字节本身”,而是“从海量字节中定位到目标字节”。定位越精准,路径越短,读就越快;定位过程越复杂,需要比较的层数越多,读就越慢。LSM 树把写入变成顺序追加之后,数据被分散到多个层次的不可变文件里,那么读一个 key 就需要去“定位这把 key 到底藏在哪一层、哪个文件、哪个区块”。这个定位过程,比单纯读一个已知道路位置的连续字节要复杂得多。类比一下就是:黑胶唱片“从头放到尾”很快,但要“跳着找”就麻烦;而写操作恰恰从头到尾都是“顺着刻”,自然就快。
1.3 从唱针跳到 LSM:同一个物理命题
有人会质疑,拿模拟时代的黑胶来类比现代分布式存储,是不是有点牵强?事实上,物理介质的读写不对称性从黑胶时代一直延续到了今天。机械硬盘的随机 IO 和顺序 IO 在延迟上能差一到两个数量级,SSD 的随机写虽然比机械盘好不少,但仍要面对垃圾回收、块擦除和写放大问题。不管介质怎么变,“顺序续写是最便宜的物理操作”这个底层规律始终成立。
所以,LSM 树本质上是在做一件事:把昂贵的随机写入,转化为廉价的顺序写入;把原本可以很快的读取,再通过缓存、索引、过滤器等手段尽量拉回来。这个“拆东墙补西墙”的动作,就是“为什么写比读快”的物理基础。分区、分层、批量归并,都是围绕这个核心命题展开的。
2. 为什么传统存储会“读比写快”:B+ 树的记忆
2.1 B+ 树家族统治下的读写惯性
在 LSM 树被大规模使用之前,关系数据库的索引结构几乎默认是 B+ 树。B+ 树的设计目标非常明确:让读操作始终在可控的树高度内完成。比如一个三层的 B+ 树,在按主键查询时,读一次内存根节点,再往下找中间层节点,最后读取叶子节点,路径基本固定。哪怕表里有千万条记录,一次点查可能也就 3 到 5 次磁盘 I/O。读性能被精心优化,这是传统数据库的看家本领。
但 B+ 树对写操作的容忍度并不高。一次插入或更新,不只是写叶子节点,还可能引发节点分裂、父节点更新,甚至连锁向上传播。简单说,一个写操作可能要把树中多个节点读到内存、修改、再写回磁盘。如果写入是随机 key,那么这些节点分布在磁盘各个位置,每一次读改写都伴随随机寻址,延迟自然就高。于是我们看到了一个经典格局:传统 B+ 树里,读性能稳定且可预期,写性能在面对随机写时很脆弱。
2.2 随机写为什么是“灾难”
学过文件系统的都知道,磁盘随机写的代价通常是顺序写的几十倍。这里要理解一个底层事实:写一个扇区 512 字节,机械盘需要把磁头移动到指定磁道,等盘片转到目标位置,再执行写入;即便 SSD,写入也要考虑擦除块的重写代价和垃圾回收开销。随机写等于让硬件的寻址机械结构反复做“热身运动”,大部分时间都花在“去目标地点的路上”,真正干活的时间反而少。
回到 B+ 树索引,如果业务场景是“大量插入、更新但很少删除”,那么每一秒都有大量随机 I/O 打到磁盘上。更麻烦的是,为了让崩溃恢复更可靠,数据库通常还要额外维护提交日志,也就是说同一个写操作可能在日志和索引结构中各写一遍。工地上的工人搬砖,如果每块砖都从不同位置拿、再放到不同位置,效率自然远低于按顺序一层层码放。随机写的代价贯穿整个过程,数据库从业者对此心知肚明,但 B+ 树索引结构本身很难逃脱这个宿命。
2.3 LSM 树的逆势出击
LSM 树并不想和 B+ 树正面比拼“单点读取最快”,它瞄准的是“提高写入吞吐”这个更硬核的诉求。设想一个实时写入非常大的系统:每秒几十万条记录,每条都要落盘。如果走 B+ 树,所有随机修改直接打到主索引上,磁盘很快就吃不消。LSM 的思路是:先把写入吸收到内存里,攒够一批之后,用顺序写的方式形成不可变文件(SSTable,排序字符串表),后台再定期把这些文件按 key 顺序合并起来。
这也解释了为什么 LSM 树在分布式系统、列式存储、键值存储等领域受到偏爱:它顺应了硬件特性,用相对固定的成本换取了极高的写入吞吐。黑胶唱片刻录时也是一样,不管歌曲顺序多乱,反正是一条道走到黑,全部按顺序刻进去;等到需要播放的时候,再做编排和筛选。所谓“先廉价接收,再集中整理”,就是这个思路的精髓。
3. 核心技术拆解:LSM 树写快读慢的原理
3.1 核心流程:内存 + 分层文件
LSM 树的基本模型并不复杂,通常由两部分组成:
- 一个可变更的内存结构(memtable),通常是一棵跳表或者排序树,负责接收最新写入。
- 一组不可变的落盘文件,按层级组织(L0、L1、L2……),每个文件内部按 key 有序。
写入请求来时,先进入 memtable。memtable 是有序结构,在内存中维护代价很低。当 memtable 达到阈值,就把它冻结成一个只读的 Immutable MemTable,后台会把这份有序数据刷成一个 SSTable 文件放到第 0 层。读请求处理时,先查 memtable,再查 Immutable MemTable,然后逐层搜索 SSTable,直到找到目标数据,或者确认数据不存在。
这个流程就是“写一条流水线,读一条查找线”。写入路径几乎没有随机操作,而读取路径要考虑多个层、多个文件,这直接体现在延迟上:稳定的写入延迟可能只有几十微秒到几毫秒,而读取最坏情况需要跨越多个层次,达到几毫秒甚至更差。要理解“为什么写比读快”,抓住这个不对称流程就够了。
3.2 为何插入只需一次顺序追加
以写入一条新记录为例,真正引起磁盘动作的,往往不是这条记录本身,而是 memtable 刷盘和后台合并。正常情况下,一个写请求的主要开销是:
- 写入预写日志(WAL),确保崩溃后能恢复;
- 更新内存中的 memtable;
- 在满足条件时触发 memtable 刷盘,形成顺序写的 SSTable。
这三步里,第 1 步是顺序日志追加,机械盘上按日志位置连续写,SSD 上也基本是连续写入;第 3 步虽然要写整个 memtable 的数据,但生成的目标文件是连续块,依然属于顺序写。真正有意思的是,写请求和后台合并是异步解耦的:客户端写入只保证“日志已落盘、内存已更新”,不需要等待跨层级的数据合并完成。
打个比方:收快递时,快递小哥把包裹放到驿站前台,填个签收单,人就可以走了。驿站后面要不要把包裹按小区分类整理,那是后续的事。快递前台只求“快速签收”,后台整理无论如何不能拖慢前台收件效率。LSM 树把“处理写请求”和“整理数据”分开,让前台响应永远轻快,这就是写操作快的第一层秘密。
3.3 Compaction 合并:为什么写会突然慢下来
万物有代价。LSM 树的写快,是把“整理数据”的负担推迟到了后台的 Compaction 过程。Compaction 会把多个小文件合并成大文件,剔除重复 key、删除已经标记为删掉的记录,然后重新按顺序写到底层。比如 L0 层文件到达一定数量后,会和 L1 层进行合并;L1 层达到阈值后又继续向 L2 层合并,一层层往下。
这里有一个非常重要的现象:Compaction 触发的瞬间,后台会大量读取旧文件内容、执行归并排序、写回新文件。这个过程的磁盘 IO 是“读大块、写大块”,本身是相对高效的,但如果触发的频率过高,或者合并涉及的数据量太大,会对正常读写造成明显的干扰。生产环境里你经常能看到“写入延迟毛刺”:平时 1 毫秒的写延迟,在 Compaction 高峰时能冲到几十毫秒甚至上百毫秒。
所以“写比读快”不是一条永远成立的曲线。当合并线程在后台抢磁盘带宽时,写入吞吐会被压制,但整体设计上,LSM 树依然保证了写入路径比“频繁随机修改”的传统方案更平滑。调优的关键,就是控制 Compaction 的触发频率和合并线程的算力占用。
3.4 布隆过滤器与多层查找:读操作的真实成本
写操作可以“先入内存、异步落盘”,读操作却没办法“先答应、后给数据”。读请求必须给出确定结果,于是它要正儿八经地去所有可能存放数据的地方查找。
典型点查流程是这样的:
- 查 memtable:如果没中,进入下一步;
- 查 Immutable MemTable:如果没中,进入下一步;
- 从 L0 到最底层,逐个检查每个 SSTable 文件的布隆过滤器;
- 布隆过滤器说“可能存在”,则加载文件索引并二分查找定位数据块;
- 真正读目标数据块并校验。
布隆过滤器是 LSM 读取效率的救星。它占用一部分内存或者存储,用来快速判断“这个 key 肯定不在该文件里”。如果过滤器判断“不在”,就可以直接跳过整个文件,不必加载索引。但布隆过滤器也有大小和假阳性率的问题:想要更小的假阳性率,就要给每个 key 分配更多 bit;分配少了,判断“可能存在”的概率上升,就会导致多余的数据块加载,白白增加读延迟。
读路径是分层推进的,每一层都可能命中,也可能落空。最坏情况下,一个不存在的 key,需要从 L0 一路遍历到最底层,每一层都被布隆过滤器“放行”,然后每个文件的索引都需要加载一遍,性能自然崩溃。这就是读慢的第一个原因:路径长、分支多,还必须等到最终结果才能返回。
3.5 三类放大效应的权衡:写放大、读放大与空间放大
在 LSM 树的语境里,优化始终绕不开三个“放大”概念:
- 写放大:实际写入物理介质的数据量与逻辑写入数据量的比值。每次 Compaction 都会把旧数据重新写一遍,层数越多,写放大越明显。
- 读放大:一次读请求实际读取的数据量与逻辑读取的数据量之比。因为数据可能分散在多层,点查也可能读多次 I/O。
- 空间放大:物理空间占用与数据实际大小的比值。旧版本数据在 Compaction 之前不会立即删除,所以空间占用有时会明显高于数据真实大小。
这三个指标互相牵制。想让写放大更低,可以保持“尽量少做 Compaction”或者“降低层数”,但这样会导致单层文件太多,读放大上升;想让读放大更低,可以扩大布隆过滤器、加剧 Compaction 频率,但写放大又会变大。生产系统里没有“最优配置”,只有“最符合你业务特征的配置”。这一点是 LSM 树调优和 B+ 树调优最大的不同:B+ 树是“读写平衡”,LSM 树是“牺牲部分读路径稳定性,换极端写吞吐”。
4. 实操配置与调优:怎么让“写快”真正落地
4.1 理解不同场景下的参数选择
以我用过的一套基于 LSM 的嵌入式键值存储引擎为例,它的配置参数可以视作一个“三角色平衡”:内存、写入并发、后台合并策略。写出真实参数时,我最关心这几项:
- memtable 大小:决定一次刷盘的数据量。值偏小,会导致刷盘频率过高,生成的小文件太多,增加后台合并负担;值偏大,虽然刷盘次数少,但一旦崩溃,需要从日志中恢复的数据变多。
- 合并阈值:指 L0 层文件达到多少个时开始向 L1 合并。阈值越低,合并越频繁,读性能越好,但写放大上升;阈值调高,写路径更稳,但 L0 查找会变慢。
- 布隆过滤器 bits 数:建议按 key 数量与期望假阳性率计算。例如希望假阳性率在 1% 左右,每个 key 分配的 bit 数大约为 9.6,也就是把布隆过滤器的 表项大小设为 key 数量乘以 10 左右。
- 后台合并线程数:取决于磁盘并发能力。磁盘强、IOPS 高,可以适当增加合并线程;磁盘弱,线程多了反而互相抢带宽,写延迟毛刺更明显。
4.2 一个写多读少场景的参数配置参考
写多读少的典型场景是:海量事件日志写入、行为采集、物联网传感器数据入库。这类场景下,查询频率低但写入压力大,所以优化目标就是最大程度降低写入路径的阻塞,同时保证偶尔读一次不要慢到不可接受。
参考配置可以这样设:
memtable_size_mb = 64 threshold_compaction_l0 = 8 max_write_buffer_count = 6 bloom_bits_per_key = 12 compaction_threads = 4 compression = "zstd"这里把 memtable 调到 64 MB,是为了让每一次刷盘都写入足够大的连续块,减少小文件数量;L0 触发合并的阈值提高到 8,是让系统在内存中多吸收一些新数据,再统一顺向刷出;布隆过滤器每 key 用 12 bit,假阳性率能降到大概 1% 以下,点查时不至于频繁误判,读放大会小很多。
但务必要明白,这套参数只在“对吞吐敏感、对延迟毛刺容忍度高”的场景下合适。如果业务是核心交易系统,读多写少,那应该反过来:调大布隆过滤器、增加合并频率、让数据尽量整理到底层,把读路径缩短。
4.3 压测时最容易误导结果的那些坑
我见过不少团队压测 LSM 引擎,结果出来很漂亮,上线后一打真实流量就崩。原因往往在压测方法上:
- 只测连续纯写入、不加读请求,最后看到的是“极限写吞吐”,但那不是系统稳定态;
- 不停并发读同一个热点 key,命中全在内存缓存上,根本反映不出分层查找的压力;
- 压测期间关闭 Compaction 或强制不合并,这相当于把负债全部推到上线后,正式运行后后台合并一开,把磁盘拖垮。
正确做法是混合压测:设定一个稳定比例,比如 90% 写入、10% 查找,再叠加后台合并的主动触发。观察写入 P99 延迟,而不是只看平均值。平均值在 LSM 树这种有后台任务机制的引擎里非常具有欺骗性,因为平时很稳,一旦 Compaction 风暴来了,一百个请求里总有几个被拖到极高延迟。如果 P99 毛刺明显,多半就是合并策略与磁盘能力不匹配。
5. 常见问题与排查实录
5.1 写入延迟突然飙升,为什么?
最典型的“写入突然变慢”原因,是后台合并风暴。生产里可能表现为:某几秒内写入延迟从 1 毫秒跳到 20 毫秒,过几十秒又恢复。排查方式看后台 IO 队列深度和合并任务数量,如果合并线程长期饱和,可以考虑调低合并写放大系数,或者将合并触发阈值提高,让合并“少做几次、每次做更大”。
另一种可能是预写日志所在磁盘性能不足。日志是顺序写,但如果被安排在同一块磁盘、还要和随机读写竞争,磁盘本身会成为瓶颈。最轻量的解法是:把预写日志放在独立物理盘或更高的 IO 优先级上。这一点和黑胶唱片“刻录刀头不能和回放唱针共用一个机械臂”一个道理,实际跑量之后体会特别深。
5.2 读不存在的数据特别慢
读一个不存在的 key,是最容易触发“最坏读路径”的操作。因为所有布隆过滤器都无法证明“肯定存在”,只能证明“肯定不在”。如果布隆过滤器设置得太小,几乎每个文件都会“放行”,导致一个不存在的 key 把所有层的索引和数据块全查一遍。
优化手法是:
- 单独为“空查询”场景增加最外层存在性判断,比如先查一个统一的 key 集合缓存;
- 适当增加布隆过滤器的 bit 分配,二八法则足够覆盖大部分场景;
- 如果业务场景里“读不存在”比例特别高,考虑换用倒排或其他索引结构,而不是硬扛。
5.3 空间占用比预期大一截
LSM 树的空间放大很容易让新手措手不及。明明只写入了 10 GB 数据,磁盘占用却到了 15 GB。原因通常是旧版本文件还未被合并清理,或者大量 Update 和 Delete 操作在底层留下了过期数据,等不到 Compaction 就不会真正消失。
排查时可以看各层 SSTable 的总大小与逻辑数据大小的比值。如果空间放大长期超过 2 倍,可以适当增加合并频率,或调整合并策略让最底层合并更积极。但要注意,增加合并频率会牺牲写入吞吐,这又回到“三大放大”权衡那条线上,没有银弹,只有取舍。
5.4 布隆过滤器到底用多大?
我习惯按公式估算:给定期望假阳性率 p,每 key 所需位数大约为ln(1/p) / ln2。期望 p 设为 1%,结果约为 9.6 bit;设为 0.1%,则约 14.4 bit。数据量越大、内存越紧,越不能盲目堆 bit,因为布隆过滤器占的内存是 key 数量乘以每 key bit 数。假设你有 1 亿个 key,每 key 用 12 bit,就要大约 143 MB 内存,这对于很多服务来说已经不低了。
如果内存紧张,还可以开“分片布隆过滤器”,只给热点新数据建过滤器,或者每隔一层设置不同大小的过滤器,把命中率低的层级省下来。这种细杆式的调优,才是 LSM 引擎真正体现经验的地方。
6. 一些更深的体会:不只会用,还得懂取舍
做存储调优这几年,我最深的感觉是,LSM 树的“写快读慢”不是缺陷,而是契约。它清楚地告诉你:你可以得到极高写入吞吐,但必须接受读取路径的分层补偿;你可以降低读放大,但必须承担额外内存或合并成本。任何数据结构都不是免费的午餐,LSM 只是把账单从“写入”这一栏移到了“读取与后台维护”这一栏。
回到黑胶唱片这个比喻上:刻录机可以在母盘上连续刻槽,整个过程一气呵成;但听众想要在任何位置播放,唱针就得费力地追踪沟槽、跳转曲目。写盘像镌刻,一气呵成;读盘像追忆,需要翻山越岭。我们能做的,不是幻想“读写都快”的完美结构,而是在理解物理介质的前提下,把不同操作的代价排布到它们最容易承受的位置上。
每次在群里看到新人问“为什么这里写比读快,是不是文档写反了”,我都忍不住想起当年自己第一次接触 LSM 树时的困惑。这东西确实反直觉,但它的合理性深植于磁盘、闪存这些硬件的底层逻辑里。搞懂这层关系,再去配置 memtable、合并策略、布隆过滤器,你就不只是在调参,而是在和一个沉默的物理世界对话。