Append-only系统如何实现更新删除:LSM-Tree、MVCC与物化视图的权衡实践

发布时间:2026/8/26 6:12:43
Append-only系统如何实现更新删除:LSM-Tree、MVCC与物化视图的权衡实践
1. 项目概述当“只增”遇上“删改”的必然碰撞在数据系统的世界里“Append-only”只追加和“Update/Delete”更新/删除就像一对天生的矛盾体。前者是流式日志、时序数据、事件溯源架构的基石它承诺了极高的写入吞吐、天然的数据不可变性与完美的审计追溯能力而后者则是传统业务逻辑的核心是满足用户“反悔”、修正错误、维护数据一致性的刚性需求。当我们在谈论SLS这里我们将其聚焦于一个广义的“流式日志服务”或“时序日志系统”的抽象模型时这种矛盾尤为尖锐。一个设计为高效吞入海量日志的管道如何优雅地处理那条“需要被修改或删除的坏数据”这绝非简单的功能叠加而是一场深入到存储引擎、索引结构、查询优化乃至事务语义的深度设计权衡。我经历过不止一次这样的架构讨论业务方拿着合规要求要求能“彻底删除”某个用户的所有日志运维同学指着监控大盘上因为零星删除操作而剧烈波动的延迟曲线皱眉而研发则纠结于如何在不重写整个数据分区的情况下实现一个高效的“更新”语义。这背后是写入路径与读取路径的博弈是存储成本与计算成本的互换更是最终一致性与操作延迟之间的取舍。本文将从一个实践者的角度解构当Append-only的SLS遇上Update/Delete时那些经典与创新的实现方案剖析其背后的权衡逻辑并分享在真实场景中做选择时的血泪经验。2. 核心设计权衡的立体透视在Append-only的基座上支持Update/Delete不是一个“是否”的问题而是一个“如何”以及“代价多大”的问题。任何方案都是在多个维度上寻找平衡点我们可以从以下几个核心维度进行立体解构。2.1 写入放大与读取放大永恒的跷跷板这是最根本的权衡。Append-only之所以快是因为它几乎做到了极致的顺序写。任何原地修改in-place update或直接删除都会破坏这种连续性。方案A标记删除Tombstone与补偿记录这是最经典的做法。对于Delete操作不物理删除数据而是写入一条特殊的“墓碑记录”Tombstone标记原记录逻辑失效。对于Update操作则直接追加一条包含新版本数据的新记录。读取时需要合并所有版本并过滤掉被墓碑标记的记录以返回最新的有效数据。权衡分析写入放大几乎为零。写入路径保持纯粹的追加性能无损。这是其最大优势。读取放大显著增加。查询时需要扫描或回溯更多的记录多个版本墓碑特别是对于频繁更新的键读取性能会严重下降。需要依赖高效的索引如LSM-Tree的SSTable级别索引和压缩合并Compaction来缓解。设计意图优先保障写入吞吐和写入延迟接受在读取端通过额外的计算和后台合并来消化成本。适用于写多读少、读延迟不敏感如离线分析或更新删除频率较低的场景。方案B原地更新与逻辑删除传统数据库的做法。直接定位到原数据存储位置进行覆盖或将其标记为“已删除”状态以复用空间。权衡分析写入放大可能很高。需要先查找数据位置可能涉及随机I/O。对于SSD随机写会磨损寿命并可能降低性能对于HDD则是灾难。同时若涉及B树等结构的节点分裂与合并会有额外的写开销。读取放大通常较低。通过索引可以直接定位到最新数据的位置读取路径短且高效。设计意图优先保障点查Point Query的读取性能适用于读多写少、需要强一致性事务的场景。但这与Append-only架构的初心相悖通常意味着底层存储引擎的彻底改变。实操心得在真正的SLS如Apache Kafka、各类时序数据库中方案A几乎是唯一的选择。因为它们的核心价值就是高吞吐、低延迟的摄入。我曾在一个日志分析平台中为了满足GDPR“被遗忘权”而引入Delete就是采用Tombstone方案。初期一切良好直到某天一个热门键被频繁更新导致针对它的查询延迟从毫秒级飙升到秒级。教训是必须配套设计高效的后台压缩策略定期将多个版本和墓碑物理合并否则读取放大问题会随时间积累而爆发。2.2 数据一致性与可见性延迟从最终到强一致的频谱在分布式SLS中Update/Delete操作的可见性是一个关键问题。最终一致性这是最容易实现的。操作请求被接受并写入日志后异步地应用到索引或状态中。不同副本、不同查询可能在短时间内看到不同的状态。这对于审计日志、监控指标等场景通常是可接受的。会话一致性保证同一个客户端会话内能看到自己之前写入的更新/删除。这需要在客户端或代理层维护一定的会话状态。线性一致性/强一致性要求任何一个更新/删除操作完成后所有后续的读取无论来自哪个客户端都必须能看到这个结果。这在Append-only系统中代价高昂通常需要引入分布式锁、共识协议如Raft来同步所有副本的“逻辑时间”或“版本水位线”严重牺牲可用性和性能。设计权衡一致性越强系统的复杂度和请求延迟越高可用性可能降低。对于SLS最终一致性是默认且最合理的选择。如果需要更强的一致性往往意味着需要在业务层或通过一个外部的强一致性元数据服务如ZooKeeper、etcd来协同而不是由日志存储层直接提供。2.3 存储成本与计算成本时间与空间的转换Append-only Tombstone方案会导致存储冗余存了多份版本和墓碑增加了存储成本。为了回收空间、提升读取效率就需要引入后台的压缩Compaction任务。这是一个典型的用计算成本CPU、I/O换取存储成本优化和读取性能提升的过程。权衡点在于压缩策略大小分级压缩Size-Tiered当一定数量的小文件生成后合并成一个更大的文件。写放大较低但读取可能需要查找更多文件。分层压缩Leveled数据被组织成多层每层数据量呈指数增长并通过后台任务将上层数据合并到下层。读取放大更小每层只有一个文件可能包含某个键但写放大更高。时间窗口压缩对于时序数据可以按时间分区。针对过期的分区如只保留30天直接整体删除或归档这是最彻底的“压缩”。对于未过期分区内的更新/删除仍采用Tombstone并可能设置更激进的子压缩策略。设计意图通过可配置的压缩策略让系统管理员能够在存储成本、写入吞吐、读取延迟之间找到一个符合业务需求的平衡点。例如对于近期的热数据采用更频繁的压缩以保障查询速度对于冷数据则减少压缩频率以节省资源。2.4 操作语义的精确性与性能Delete的“强度”一个容易被忽略但至关重要的权衡是Delete操作的语义。软删除Soft Delete即前述的Tombstone。数据物理上仍存在只是逻辑上不可见。它允许“反删除”恢复操作因为数据还在。硬删除Hard Delete要求数据从物理介质上被彻底抹除通常出于极端的合规如GDPR或安全要求。在Append-only系统中实现真正的硬删除非常困难且昂贵因为你需要定位数据在所有副本、备份、甚至WAL日志中的每一个比特并覆写。设计权衡性能软删除性能极佳硬删除可能引发全盘扫描和重写性能极差。合规与安全某些法规要求硬删除。此时一个折中的方案是加密删除密钥数据始终以加密形式存储删除操作等价于销毁该数据的加密密钥。数据物理上依然存在但已无法被解密读取在合规上通常可被认定为“有效删除”。这巧妙地用密码学成本替代了巨大的I/O成本。3. 主流实现模式深度解析理解了权衡维度我们来看几种在真实系统中常见的实现模式。3.1 LSM-Tree模式标记删除与压缩的艺术这是流存储和NoSQL数据库如Cassandra, RocksDB, HBase的基石也是处理Append-only与Update/Delete矛盾的典范。核心流程写入所有写入包括Insert, Update, Delete都先进入内存中的可变数据结构MemTable。Delete被转化为一个针对该Key的Tombstone标记。持久化当MemTable写满它被冻结并异步刷写到磁盘形成一个不可变的、有序的SSTable文件。这个过程是纯顺序写。读取需要从最新的MemTable和磁盘上的多层SSTable文件中查找目标Key并合并所有找到的版本以Tombstone为准决定其可见性。压缩后台进程定期将多个SSTable合并成一个新的SSTable在这个过程中被Tombstone标记的旧版本数据会被物理丢弃从而回收空间。设计权衡在此模式中的体现写入高性能得益于内存缓冲和批量顺序刷盘。读取代价读取可能涉及多个文件的查找读放大。通过布隆过滤器Bloom Filter快速跳过肯定不包含Key的文件可以极大缓解。写放大压缩过程会产生写放大。Leveled压缩的写放大高于Size-Tiered。空间放大在压缩发生前旧版本和Tombstone会占用额外空间。# 一个概念性的示意非真实命令 # 1. 写入一系列操作 PUT key1valueA PUT key2valueB DELETE key1 PUT key1valueC # 2. 在内存和SSTable中数据可能以如下逻辑形式存在 # MemTable/SSTable1: [key1:valueA, key2:valueB] # SSTable2 (包含Delete): [key1:Tombstone] # SSTable3: [key1:valueC] # 3. 读取key1时需要反向扫描(SSTable3 - SSTable2 - SSTable1) # 找到valueC最新并看到Tombstone但Tombstone被更新的valueC覆盖所以返回valueC。 # 4. 读取key2时返回valueB。 # 5. 压缩后SSTable1和SSTable2中的key1旧记录和Tombstone被清除 # 可能生成新的SSTable: [key1:valueC, key2:valueB]3.2 多版本并发控制模式将时间变为维度MVCC天然适合Append-only。每一次Update/Delete都不覆盖旧数据而是创建数据的一个新版本并打上唯一的时间戳或版本号。核心流程写入UPDATE table SET colnew WHERE id1会转化为INSERT INTO table (id, col, version, is_deleted) VALUES (1, new, 2, false)。DELETE则插入一条is_deletedtrue的记录。读取查询需要带上一个“时间戳”或“版本号”参数系统返回在该时间点之前最新且未被删除的版本。例如SELECT * FROM table AS OF TIMESTAMP t1 WHERE id1。清理后台任务根据数据保留策略删除超过一定时间且已被新版本覆盖的旧版本记录。设计权衡在此模式中的体现读一致性快照MVCC提供了近乎免费的读一致性快照非常适合分析型查询。历史查询能力可以轻松查询任意历史时刻的数据状态审计能力强大。存储与计算成本同样面临存储多版本和清理的问题。查询需要按版本排序和过滤计算开销比直接读最新数据大。删除的语义删除变成了“在时间线某点之后该记录不可见”而非物理消失。3.3 增量日志与物化视图模式分离读写路径这是数据仓库和流处理系统中的常见模式。核心思想是将“更新”视为一种特殊的事件日志。核心流程源表是一个Append-only的变更数据捕获CDC日志流每条记录代表一次Insert、Update或Delete操作。物化视图是一个根据业务查询需求预计算好的、可高效查询的数据快照。它由流处理引擎如Flink, Materialize持续消费源表的日志流来维护。更新维护当流处理引擎消费到一条Update日志时它会计算出这条更新对物化视图的增量影响并应用到视图上。对于Delete则是从视图中移除对应行。设计权衡在此模式中的体现写入极简源端只需无脑追加日志复杂度极低。读取高效查询直接打在优化后的物化视图上延迟低。最终一致性物化视图的更新是异步的存在毫秒到秒级的延迟。计算资源消耗需要常驻的计算资源来维护物化视图。更新越频繁计算开销越大。灵活性可以为不同的查询模式构建不同的物化视图但这也增加了存储和计算成本。4. 实操场景下的方案选型与避坑指南理论之后我们来点实在的。面对一个具体的SLS需求如何做选择4.1 场景一操作日志审计系统需求记录所有用户操作极少更新或删除但需满足合规性强制删除要求。推荐方案Append-only存储 加密 密钥管理。实现要点日志以纯Append-only方式写入如直接写文件或到Kafka。每条日志在写入前使用一个与日志主体如用户ID绑定的密钥进行加密。将加密密钥存储在另一个独立的、支持强删除的密钥管理服务KMS中。当需要删除某个用户的所有日志时直接在KMS中销毁该用户对应的加密密钥。避坑密钥轮换定期轮换密钥并重新加密旧数据否则密钥长期有效会增加风险。性能每条日志的加密/解密操作会带来CPU开销需测试评估。合规认可提前与法务或合规部门确认这种“密码学擦除”方案是否符合当地法规要求。4.2 场景二实时指标监控与告警需求海量时间序列数据如CPU利用率写入偶尔需要修正错误的数据点Update并需要低延迟查询最近数据。推荐方案时序数据库TSDB内置的Tombstone与压缩策略。实现要点选择成熟的TSDB如Prometheus TSDB、InfluxDB、TimescaleDB等。它们内部都采用类似LSM-Tree的机制处理更新/删除。理解其数据保留和压缩策略通常按时间分块Block/Chunk。对于尚未压缩的近期数据块更新/删除通过Tombstone实现。对于已压缩的旧数据块通常不再允许修改。设置合理的块大小和压缩周期。小块如2小时查询灵活但文件多大块如1天压缩效率高但更新不灵活。避坑避免高频更新TSDB并非为高频更新设计。如果需要频繁修正应考虑将数据先写入消息队列由下游进行清洗和去重后再写入TSDB。关注删除性能批量删除某个时间范围内的所有数据通常很快直接删除文件。但删除某个特定时间序列的特定点可能触发昂贵的全块扫描在生产环境需谨慎。4.3 场景三用户画像与特征存储需求用户属性标签不断更新需要能查询任意用户的最新画像也需要能回溯历史变化。推荐方案CDC日志 流处理 键值存储/OLAP。实现要点用户属性变更以CDC日志形式发出用户ID, 属性名, 新值, 时间戳。使用流处理引擎如Flink消费日志维护一个最新的用户画像快照到一个高性能的键值存储如Redis、Cassandra供实时API查询。同时将完整的CDC日志保存到数据湖如Iceberg/Hudi表或OLAP数据库如ClickHouse用于历史回溯和批量分析。避坑数据一致性实时快照与历史日志之间是最终一致的。确保业务能接受秒级延迟。流处理状态管理Flink作业的状态可能很大要规划好状态后端RocksDB和检查点配置防止故障恢复时间过长。冷热分离将不常访问的旧画像从KV存储归档到成本更低的存储中。5. 性能调优与问题排查实录即使选对了方案不当的配置和用法也会让你踩坑。以下是一些血泪教训。5.1 Tombstone导致的查询性能骤降现象针对某个热点Key的查询响应时间从毫秒级逐渐增长到数秒。排查检查该Key的更新/删除频率。如果非常高那么它可能在多个SSTable中留下了大量Tombstone和旧版本。检查压缩Compaction任务的运行状态和积压情况。可能由于资源不足或配置不合理压缩速度跟不上数据更新速度。解决调优压缩增加压缩任务的并发度或CPU/IO资源分配。对于LSM-Tree可以考虑从Size-Tiered切换到Leveled压缩以减少该Key的查找文件数。业务侧优化与业务方沟通能否降低该Key的更新频率或采用批量合并后写入的方式。强制合并某些系统提供手动触发特定Key范围压缩的API可以临时缓解。5.2 磁盘空间增长失控现象数据保留策略是30天但磁盘占用远超30天数据量的预期。排查未合并的Tombstone这是最常见原因。使用系统工具查看Tombstone的数量和年龄。如果存在大量长期未清理的Tombstone它们会阻止旧数据文件被物理删除。压缩策略过于保守压缩间隔设置过长导致多个版本数据长期共存。小文件问题高频写入生成了大量小SSTable文件而压缩合并速度慢导致文件数量膨胀元数据占用空间大。解决调整压缩策略缩短压缩间隔或调整触发压缩的文件大小/数量阈值。检查删除逻辑确认Delete操作是否真的发出了。有时业务代码逻辑错误导致本应删除的数据变成了永不过期的数据。监控与告警建立对Tombstone数量、文件数量、压缩延迟的监控在问题萌芽期就发出告警。5.3 “已删除”数据在查询中意外出现现象明明执行了Delete但后续查询偶尔还能看到“已删除”的数据。排查最终一致性延迟如果系统是最终一致的在删除操作传播到所有副本或索引之前从落后副本读取就会看到旧数据。检查副本同步延迟。快照隔离如果查询使用了旧的时间戳或快照它会看到删除操作发生之前的世界状态。Tombstone压缩延迟在Tombstone被压缩清理前如果查询逻辑有Bug比如忽略了Tombstone也可能返回旧数据。解决理解语义首先要明确系统提供的一致性级别是什么不要做超出承诺的假设。查询时指定正确版本对于MVCC系统确保查询使用正确的时间戳。检查客户端缓存某些客户端或代理层可能有缓存导致读到旧数据。5.4 高频Update导致写入吞吐瓶颈现象系统以Update为主整体吞吐量远低于预期的Append-only吞吐。根源即使在Tombstone方案下高频Update同一个Key也会在内存MemTable和后续压缩中造成额外开销。对于LSM-Tree这可能导致MemTable频繁刷新以及压缩任务频繁处理同一个Key的大量条目。优化思路客户端合并在业务应用层对短时间内同一Key的多次更新进行合并只写入最终状态。这需要业务逻辑支持。使用增量聚合如果更新是数值型的累加如计数器考虑使用支持增量合并的数据结构或格式如Apache Druid的Roll-up或使用Sum、Max等聚合函数在写入时或压缩时进行预聚合。换用更优数据结构如果Update是绝对的性能瓶颈可能需要重新评估架构考虑使用原生支持高效更新的存储引擎如B树的变种但这意味着要放弃一部分Append-only的高吞吐优势。设计一个能妥善处理Update/Delete的Append-only系统本质上是在理解所有技术约束和业务需求后进行一系列精准的权衡。没有银弹只有最适合当前场景的组合拳。我的经验是在早期明确数据的生命周期、访问模式、一致性要求和合规底线比在后期性能着火时再救火要重要得多。记住Append-only的优雅在于它用空间的冗余和计算的延迟换来了写入的纯粹与时间的维度。而我们要做的就是管理好这种交换的成本让系统在业务的长跑中始终保持稳健。