在Append-Only日志系统中实现Update/Delete的设计权衡与工程实践 📅 2026/8/26 6:12:45 1. 项目概述当“只增”遇上“删改”的经典矛盾在数据系统的世界里“Append-only”只追加和“Update/Delete”更新/删除代表了两种截然不同的设计哲学它们之间的碰撞与权衡是每个架构师和开发者都会面临的经典难题。最近我在一个基于SLS这里我们将其理解为一个泛指的高吞吐、低成本日志服务数据系统的项目中就深度卷入了这场设计风暴。项目需求很明确我们需要利用SLS强大的实时日志摄取和分析能力但业务上又不可避免地需要对已写入的数据进行修正或清理。这听起来就像是要让一个天生擅长“只写不擦”的铅笔去完成一份需要反复涂改的草稿。这个矛盾点恰恰是很多现代数据系统设计的核心。Append-only模型因其简单、高效和高吞吐量而备受青睐它避免了原地更新带来的锁竞争、数据碎片化等复杂问题特别适合日志、事件流、审计追踪等场景。然而现实业务是动态的用户会改错订单状态运营需要下线无效活动数据合规要求强制删除特定用户信息。纯粹的Append-only在面对这些“删改”需求时就显得有些力不从心。我这次要分享的就是如何在SLS这类倾向于Append-only的体系内巧妙地引入Update和Delete能力并深入剖析这背后一系列精彩的设计权衡、实现策略以及那些“踩坑”后才明白的实操细节。无论你是正在选型数据架构还是试图优化现有系统的数据生命周期管理相信这些从一线实战中总结的经验都能给你带来直接的参考。2. 核心设计思路与权衡解析面对“在Append-only系统上支持Update/Delete”这个命题最直接的冲动可能就是“那就修改存储引擎让它支持原地更新呗”。但这往往是最昂贵、最危险的选择因为它动摇了系统的根基。我们的设计思路必须建立在另一个原则上在应用层或中间层模拟出“更新”和“删除”的语义而非在物理存储层实现真正的原地操作。这引出了几个核心的设计权衡。2.1 权衡一数据一致性视图 vs. 查询复杂度与性能这是最根本的权衡。我们的目标是让用户查询时看到的是一个符合预期的、包含了所有更新和删除操作后的最新数据视图。实现方式主要有两种方案A标记删除与合并读取这是最经典的做法。对于Delete操作不物理删除原记录而是写入一条带有特殊标记的“墓碑记录”Tombstone。对于Update操作视为“Delete Insert”即写入一条墓碑记录标记旧记录失效再写入一条包含新数据的新记录。在查询时系统需要合并扫描原始数据和这些标记记录过滤掉已被标记删除或更新的记录只返回最新的有效记录。优势完全遵循Append-only写入极其高效与SLS的原始设计完美契合。数据恢复和回溯能力极强因为历史数据都在。代价查询端压力巨大。每次查询都需要进行额外的过滤、合并和去重逻辑随着更新/删除操作的增多查询性能会线性下降。这相当于把“改”的成本转移到了“读”上。方案B构建物化视图或下游衍生表不在原始日志流上直接进行复杂查询而是定期或实时将原始日志与更新/删除日志合并生成一个新的、干净的“物化视图”或衍生数据表。业务查询直接面向这个视图。优势查询性能极佳体验与支持原生更新的数据库无异。代价架构复杂需要引入额外的计算和存储组件如流处理作业、OLAP表。数据有延迟并非实时一致。存储成本翻倍存了原始日志和衍生表。我们的选择与理由在SLS的上下文中其核心优势在于实时摄入和查询原始日志。因此我们优先选择了方案A的变体并对其查询性能问题进行了重点优化。我们通过为“墓碑记录”建立倒排索引并设计高效的合并查询语法使得在大多数场景下查询性能的损耗控制在可接受的范围内通常额外开销在20%-30%。对于少量对查询延迟极度敏感的核心报表我们才采用方案B通过轻量级的流计算任务生成小时级或分钟级的汇总视图。2.2 权衡二操作语义的即时性 vs. 系统最终一致性在分布式Append-only系统中实现跨多分片、立即可见的强一致性Update/Delete代价非常高。我们需要权衡用户对操作“生效时间”的期望。强一致性需求要求Update/Delete操作完成后后续所有读请求必须立刻看到新状态。这通常需要分布式事务、行级锁或全局序列号与Append-only的高吞吐目标背道而驰。最终一致性可接受允许Update/Delete操作在短暂延迟如几秒到几分钟后生效。这更符合日志系统的设计哲学可以通过异步处理“墓碑记录”的传播和索引构建来实现。我们的设计我们明确将系统定位为最终一致性。当用户发起一个Delete操作时系统会立即返回成功但实际是在后台异步写入一条“墓碑记录”。这条记录被索引和全局可见可能需要数秒时间。我们通过控制台和API明确告知用户这一特性并将此作为一项设计约束。实践表明对于日志分析、用户行为追踪、运营监控等场景秒级延迟的最终一致性是完全可接受的。这为我们换来了系统整体的高可用和水平扩展能力。2.3 权衡三存储成本与生命周期管理Append-only不删除数据意味着存储成本会无限增长。虽然SLS本身有基于时间的日志生命周期策略但一旦引入逻辑删除情况变得更复杂。那些被“逻辑删除”的旧数据虽然对业务不可见但仍占用存储空间。策略一永久保留保留所有历史数据包括被删除的。成本最高但提供了完整的数据考古和能力。策略二定期物理清理设置一个策略例如标记删除超过30天的数据可以被一个低优先级的后台任务真正从物理存储中移除。策略三分层存储将“冷”的、已被逻辑删除的数据转移到更廉价的存储介质上。我们的实现我们采用了策略二和策略三的结合。首先我们定义了一个“逻辑删除数据保留期”默认为7天。这意味着一条数据被标记删除后在7天内仍可通过特殊查询进行恢复或审计。超过7天这些数据所在的存储块会被标记为“可压缩”。系统在低峰期会对这些存储块进行压缩重组物理剔除已被标记删除且过期的数据从而回收存储空间。对于需要更长期归档的数据我们提供接口将其导出至对象存储实现成本与价值的平衡。3. 核心实现机制与实操要点明确了设计权衡后我们来看看具体的实现机制。这套机制的核心在于如何定义和组织那两条关键的“流水线”数据写入流和元信息控制流。3.1 数据模型定义双流驱动我们设计了两种特殊的日志主题Topic或数据流数据流data_stream这就是原始的、标准的Append-only日志流。所有业务产生的原始事件、日志都写入这里。每条记录包含业务字段和一个全局唯一的row_id通常由时间戳、机器标识、序列号组成。控制流ctrl_stream这是实现Update/Delete的“魔法”所在。所有更新和删除操作都被转化为一条特殊的记录写入这个流。删除操作写入一条记录类型为DELETE并包含要删除的目标记录的row_id。更新操作写入一条记录类型为UPDATE。它包含目标记录的row_id以及一个包含变更字段的键值对例如{status: cancelled, operator: user123}。这里我们采用“部分更新”语义只更新指定的字段。// 控制流记录示例 { “op_type”: “UPDATE”, // 操作类型 “op_timestamp”: 1629984000000, “target_row_id”: “2021-08-26T10:40:00_ServerA_0001”, “changes”: { “status”: “completed”, “end_time”: 1629984005000 } }3.2 查询引擎的改造合并与过滤查询引擎是用户体验的关键。我们需要让用户像查询普通数据一样无需关心背后的复杂性。这需要对SLS的查询引擎进行增强。查询语法扩展 我们引入了一个特殊的查询函数或语法例如叫apply_ctrl_stream。用户发起查询时查询语句在底层会被重写。原始用户查询* | select user_id, action, status from data_stream where date ‘2023-10-27’引擎重写后的实际执行查询概念示意( SELECT d.* FROM data_stream d WHERE date ‘2023-10-27’ LEFT JOIN ( SELECT target_row_id, MAX(op_timestamp) as latest_op_ts, op_type, changes FROM ctrl_stream WHERE op_timestamp NOW() -- 只关联查询时间点之前的控制操作 GROUP BY target_row_id, op_type, changes ) c ON d.row_id c.target_row_id WHERE c.op_type IS NULL OR c.op_type ! ‘DELETE’ -- 过滤掉已删除的 ) AS latest_data -- 然后应用最新的UPDATE changes到latest_data上索引优化 为了加速data_stream.row_id与ctrl_stream.target_row_id的关联查询我们必须为这两个字段建立高效的倒排索引。尤其是在控制流上对target_row_id和op_timestamp的复合索引至关重要这能快速定位到影响某条记录的最新控制操作。3.3 写入API与原子性保证如何保证“写入一条数据”和“标记这条数据被更新/删除”之间的原子性在分布式环境下这是一个挑战。我们采用了“尽力而为”的补偿机制而非强原子性。操作流程客户端发起Update请求指定row_id和changes。服务端首先同步向ctrl_stream写入一条UPDATE记录。这一步必须成功否则整个操作失败。写入控制流成功后操作即对客户端返回成功。系统会异步尝试去data_stream中验证row_id是否存在。如果发现不存在可能是数据延迟到达或row_id错误会在控制流中追加一条WARNING记录但不回滚之前的UPDATE记录。因为在高吞吐日志场景下先有更新请求后有原始数据到达乱序是一个可能出现的场景我们的设计需要包容这种情况。实操心得我们放弃了跨双流的分布式事务因为它太“重”了。我们通过将“操作日志”控制流作为唯一事实源并接受极小概率的“幽灵更新”更新了一个不存在的记录但该记录后续可能到达换来了极高的写入可用性和吞吐量。业务上可以通过监控“警告记录”来发现异常模式。4. 性能优化与关键参数调优在模拟Update/Delete的系统中性能瓶颈主要出现在查询端。以下是几个关键的优化点和参数调优经验。4.1 控制流索引策略控制流的大小和索引效率直接决定查询性能。分区键Partition Key将target_row_id作为控制流的主分区键之一确保对同一行数据的操作尽量落在同一个存储分片上减少查询时的跨分片合并开销。TTL设置必须为控制流设置合理的TTL。它不需要像数据流一样保留很长时间。一个基本原则是控制流TTL 数据流查询最大时间范围 逻辑删除保留期。例如业务最多查询最近30天的数据逻辑删除保留7天那么控制流TTL至少应设为37天。过期后控制记录可被自动清理因为对应的原始数据也已超出查询范围。索引字段除了target_row_id和op_timestamp如果业务经常按操作类型或操作者查询可以考虑将op_type和changes中的某些关键字段也加入索引但需谨慎评估避免索引膨胀。4.2 查询加速布隆过滤器与缓存对于“数据是否存在删除标记”这种判断可以使用布隆过滤器Bloom Filter进行快速排除。为每个数据存储块Shard维护一个布隆过滤器记录该块内所有被控制流引用的row_id。查询时先通过布隆过滤器判断目标row_id是否肯定不存在于控制流中。如果是则可以直接读取数据块无需关联控制流。这能大幅提升那些未被修改过的数据的查询速度。对于热点数据的更新状态可以引入一层短期缓存。例如将最近频繁被更新的row_id及其最新状态缓存在查询节点的内存中时效性设为秒级可以减轻对控制流的实时查询压力。4.3 合并查询的并行度调整关联data_stream和ctrl_stream的查询是一个典型的JOIN操作。需要调整查询引擎的并行度参数。join_parallelism增加此参数可以让关联操作在更多计算节点上并行执行充分利用集群资源缩短查询耗时。通常可以设置为集群核心数的2-4倍进行测试。shard_prefetch在扫描数据流之前预先读取控制流中相关分片的数据到内存可以减少IO等待。下表展示了一次针对不同数据量级的参数调优对比数据规模 (日增)控制流比例默认参数查询延迟优化后 (join_parallelism32,shard_prefetchon) 查询延迟优化效果100 GB0.1% (低更新)~1.2 秒~0.8 秒提升 33%1 TB1% (中等更新)~15 秒~7 秒提升 53%10 TB5% (高更新)超时 (120秒)~45 秒从超时到可接受注意事项并行度并非越高越好。设置过高会导致任务调度开销增大并可能挤占其他查询的资源。最佳值需要通过实际负载测试来确定。5. 典型问题排查与实战避坑指南在实际运行中我们遇到了不少问题。这里记录几个最有代表性的案例和解决思路。5.1 问题一查询结果偶尔出现“数据闪回”现象用户查询最近5分钟的数据偶尔会发现一条本该被更新状态的数据突然变回了旧状态几秒后又恢复正常。排查检查控制流确认UPDATE记录已成功写入。检查查询日志发现出现“闪回”时查询SQL中关联控制流的子查询其op_timestamp NOW()条件中的NOW()时间略微早于了UPDATE记录的实际写入时间。根源在于分布式系统中不同节点数据节点、查询节点、客户端之间存在毫秒级的时间偏差。解决短期方案在查询条件中为控制流的时间戳引入一个小的负向缓冲例如op_timestamp NOW() - INTERVAL 2 SECOND。这牺牲了2秒的“操作可见性”即时性但换来了结果的一致性。根本方案推动基础设施团队为整个集群部署更精确的NTP时间同步服务将节点间时间差控制在毫秒级以内。5.2 问题二控制流体积增长过快影响查询性能现象系统运行一段时间后复杂查询的延迟稳步上升。监控发现ctrl_stream的存储量和索引大小增长异常远超预期。排查分析控制流数据发现大量对同一row_id的、字段完全相同的重复UPDATE操作。原因是业务端在某些重试逻辑中没有做好幂等控制导致网络超时后重复发送了相同的更新请求。解决应用层修复要求业务方在发送UPDATE请求时携带一个唯一的请求IDUUID。服务端在写入控制流前先检查近期是否已处理过相同请求ID对同一row_id的操作如果是则直接跳过实现幂等。服务端优化在控制流写入层增加一个轻量级的短期缓存如5分钟缓存(row_id, 请求ID, 变更摘要)三元组用于快速过滤重复请求。这显著减少了无效的控制流记录。5.3 问题三范围删除Batch Delete操作导致查询超时现象运营执行一个“删除某用户所有历史数据”的范围删除操作实质是批量写入大量DELETE记录后一段时间内所有涉及该用户数据的查询都超时失败。排查范围删除操作瞬间写入了数万条DELETE记录到控制流。后续查询在关联这些DELETE记录时由于需要合并过滤的数据量巨大单次查询的负载激增导致执行超时。解决流控与异步化对批量删除操作进行流控将其拆分成多个小批次并放入队列异步执行避免瞬时冲击。查询优化对于已知的、大规模删除所涉及的数据范围查询引擎可以识别这种模式。当检测到查询条件命中一个“大规模删除集”时自动调整查询计划例如优先利用布隆过滤器进行快速过滤或临时提高该查询的资源配额。业务规范与业务方沟通尽量避免不加条件的大范围删除。优先采用基于时间的滚动删除策略或引导其将需要删除的数据先查询出来确认后再发起删除。5.4 问题四如何验证“逻辑删除”的数据已被正确清理需求我们设置了逻辑删除保留7天后物理清理的策略需要验证后台清理任务是否正常工作是否有数据被“漏清”。方案 我们建立了一个定期的审计任务每天在低峰期扫描ctrl_stream中所有op_typeDELETE且op_timestamp早于当前时间 - 7天的记录。根据这些记录的target_row_id去data_stream中尝试查找对应的原始数据块。如果还能找到未被压缩清理的原始数据块则发出告警。同时审计任务会抽样检查已被标记为“可压缩”的存储块确认其物理存储空间是否在压缩后得到释放。这个过程确保了数据生命周期策略的执行是可观测、可验证的避免了“黑洞”操作。回顾整个设计与实现过程在Append-only的SLS上引入Update/Delete本质上是一场精妙的“成本转移”游戏。我们将“更新”的即时性和一致性成本转移到了查询的复杂度和延迟上将“删除”的存储成本转移到了后台计算和生命周期管理的复杂性上。所有的设计决策无论是最终一致性、异步补偿还是双流分离都是基于一个核心判断在这个特定场景下写入吞吐量、系统简单性和水平扩展能力的优先级高于强一致的事务语义和极致的点查性能。这套方案不是银弹但它为海量日志数据的“有限可管理性”提供了一个务实而高效的解。如果你也面临类似的架构选择关键是想清楚你的业务到底能接受哪些妥协然后像我们这样把每一种妥协带来的具体影响和应对方案都明明白白地设计到系统中去。