ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

微服务孤儿数据治理:递归+墓碑+事件流的三层删除方案

微服务孤儿数据治理:递归+墓碑+事件流的三层删除方案 上个月排查线上存储发现用户服务里躺着四十多万条“无主记录”parent_id 指向的用户已经注销三个月数据却还在持续占用磁盘。更尴尬的是营销系统每周还在给这些“不存在的人”发短信。这个问题的本质不是某段删除代码写得草率而是微服务拆分之后“删除”这个动作从“一条SQL的事”变成“一个跨服务的一致性协议”。如果只是补几个 DELETE 语句下个月还会再冒出新的孤儿数据。真正要建立的是三层机制用递归把依赖关系算清楚用墓碑标记把删除意图固化下来用事件流把删除意图可靠地传到每一个相关服务。这三个词不是三个独立工具而是一套组合拳。这篇博客就按我实际落地的思路拆开讲从问题成因到完整方案再到线上踩过的坑适合所有维护微服务项目、被脏数据和删除逻辑困扰的读者。1. 微服务里的“孤儿”是怎么形成的一次删除事件引发的连锁反应1.1 从两个线上案例说起第一个案例是用户注销。用户在用户服务里点了注销用户服务执行了 DELETE用户表里这条记录消失了。但订单服务里还存着一堆关联该用户ID的订单积分服务里还有这个用户的积分流水消息服务中心还挂着未读通知的接收人ID。下游服务各自都不知道“人没了”还在照常写入和查询。那堆关联数据就成了孤儿。第二个案例是商品下架删除。商品服务删了商品主记录但订单快照里保存了当时的商品ID和商品名称购物车服务里还有一堆“已失效商品”条目搜索索引里还挂着这个商品的文档。用户在购物车列表里能看到商品图片和标题点进去就报“商品不存在”。这种“看得见但打不开”的数据就是最典型的孤儿数据形态。这两个案例的共性在于删除方以为删了就完了但数据的世界里没有“完了”只有“还有谁引用过它”。1.2 三个结构性原因拆分边界、异步窗口、被动接收方单体应用时代数据库外键 级联删除可以解决大部分问题。微服务把这些约束拆没了孤儿数据就成了必然结果。具体拆成三个原因看一是拆分边界。每个服务只对自己的表有完全控制权删除用户时用户服务管不到订单服务的事务。你不能跨服务写一条级联 DELETE。理论上可以写分布式事务但成本高、易锁表大部分团队不会这么干。二是异步窗口。跨服务的删除动作往往通过消息异步通知从“用户服务提交删除事务”到“订单服务消费删除事件”之间存在时间差。在这个窗口内任何下游查询都可能读到“父数据已消失、子数据还在”的中间态。如果删除事件再投递失败这个窗口就永远关闭不了孤儿数据变成硬伤。三是被动接收方。很多服务持有上游ID只是作为“引用快照”比如订单表里存商品ID评论表里存用户昵称。这些服务是“被动接收方”它们不知道上游什么时候删、删了哪些ID也没有订阅任何删除通知。这一类是最容易被忽略的因为你只盯着自己的表根本看不到别处有引用。1.3 为什么普通软删除救不了场很多人会想“我给每张表加一个 is_deleted 字段做逻辑删除不就行了”这不是解决问题的办法。软删除只是让本服务的查询看不到这条记录它解决不了三个问题通知问题订单服务改了 is_deleted用户服务根本不知道也不会因此去处理自己的关联数据。引用问题购物车服务里的商品ID是独立存的商品表加不加 is_deleted 对它没意义它只知道自己存了一个ID查不到就展示异常。复活问题如果一个更新事件在删除之后才到达下游下游在执行 UPDATE 时不会去看上游的删除状态很轻松就把一条“已删数据”重新写活。所以在微服务里逻辑删除只是墓碑标记的一种简化形态真正能防孤儿数据的是“带确定性身份的删除意图”这一点后面展开。2. 递归先把“删除范围”算彻底2.1 单个服务内的依赖树遍历在一个服务内部删除一个聚合根往往牵扯一整棵依赖树。举评论系统的例子删除一篇帖子要删掉帖子的所有一级评论每个一级评论下的二级回复每个回复的点赞记录以及这些评论附带的附件记录。这些数据通常在同一个服务、同一张库里。递归的第一层价值就是把这些“躲”在不同表里的关联数据全部找出来。实际操作中我倾向于用可递归的CTE查询先把ID集合捞出来而不是边删边查。以 MySQL 8 为例假设评论表是邻接表结构parent_id 指向本表的 id删除一篇帖子时要查全部子孙评论IDWITH RECURSIVE comment_tree AS ( SELECT id, parent_id, post_id FROM comment WHERE post_id ? UNION ALL SELECT c.id, c.parent_id, c.post_id FROM comment c INNER JOIN comment_tree p ON c.parent_id p.id ) SELECT id FROM comment_tree;拿到全部ID后再按依赖方向分批次标记或删除。先查后删的好处很直接删除过程中如果业务还在并发写入新的子记录一次快照式的查询不会漏掉整棵树的完整性因为你删的是“当时算出来的范围”而不是“一边数一边删”的不稳定边界。2.2 遍历策略深度优先还是批量标记递归遍历有两种常见策略我在不同场景都踩过。深度优先适合强外键约束的库表。先删最深叶子再删父节点最后删根节点不会触发外键冲突。缺点是每一次向下钻取都可能产生多条SQL图层深、表多时数据库压力大。广度优先适合以“标记”为主的处理。先查出第一层关联再查第二层每次批量处理一批ID分批提交事务。这种方式配合墓碑标记格外好用因为你可以把每一层的处理结果都记录成“待删除状态”而不是即时物理删除。我的经验是不要一开始就想着物理删除先把依赖树遍历出来把需要处理的范围写进一张临时表或者内存集合然后统一走“先标记、后清理”的流程。尤其是数据量大的业务表一次 DELETE 全表关联数据很容易锁范围扩大把线上的正常读写都拖慢。你可以在压测环境验证一下大批量级联删除时数据库连接池经常会先被打满。2.3 递归的边界跨服务时你算不动别人的数据递归再厉害也只能解决“单服务内部的依赖树”。订单服务能把订单、订单明细、支付流水、物流轨迹递归清楚但订单里关联的用户信息在用户服务关联的商品快照在商品服务你跨不过去。这就是微服务和单体的分水岭单体应用可以直接 JOIN 全库的表来做级联清理微服务之间不能。递归负责把“自己的地界”算干净剩下的部分只能靠事件流把删除意图广播出去让其他服务在自己的地界里也做一遍递归。所以递归不是孤立的它是整个方案里的“局部执行器”。3. 墓碑标记给删除意图一个“确定性身份”3.1 Tombstone不只是软删除是删除事件的物化数据库里通常只认数据行存在与否。但分布式场景下你不仅要知道“这行数据现在没了”还要知道“这行数据在什么时间、因为什么事件没了”并且这个信息要能被其他机制查询和校验。这就是墓碑标记。Kafka 里的 tombstone 概念很容易理解某个 key 写入一条 value 为 null 的消息代表该 key 已被删除。业务系统里的墓碑也是类似它是一张表或者一组标记记录着“哪个实体类型、哪个实体ID、在什么时候因为哪个事件被标记删除”。墓碑和普通软删除最大的区别在于身份属性。软删除只有一个布尔值墓碑带事件ID、操作人ID、原因链路IDcausationId。这些额外的身份信息是整个方案能对抗乱序、重复消息的关键。3.2 墓碑该记录什么我落地时的表结构大致如下可以直接参考CREATE TABLE entity_tombstone ( id BIGINT AUTO_INCREMENT PRIMARY KEY, entity_type VARCHAR(64) NOT NULL COMMENT 实体类型user/order/product..., entity_id VARCHAR(128) NOT NULL COMMENT 实体ID, deleted_at DATETIME NOT NULL COMMENT 标记删除时间, delete_event_id VARCHAR(64) NOT NULL COMMENT 对应的删除事件ID用于幂等, causation_id VARCHAR(64) NOT NULL COMMENT 触发链路ID如注销请求ID, operator_id VARCHAR(64) NULL COMMENT 操作人ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待清理 1已物理删除, expire_at DATETIME NOT NULL COMMENT 墓碑过期时间, UNIQUE KEY uk_event (delete_event_id), KEY idx_entity (entity_type, entity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;每个字段都有用途delete_event_id是幂等判断的关键某条删除事件只能被处理一次causation_id用来追踪整条删除链路比如“用户注销”这个根因事件触发了哪些下游动作expire_at是墓碑的TTL防止表无限膨胀。为什么唯一键建在delete_event_id而不是(entity_type, entity_id)因为同一个实体可能因为不同事件被反复删除比如重复提交注销每次事件ID都不同但处理动作只能执行一次。以事件ID为唯一约束天然支持幂等。3.3 墓碑的清理与防重建机制墓碑不能永远留着否则表越来越大查询越来越慢。清理策略要平衡两个风险清太早乱序事件会把已删数据“复活”清太晚存储成本上升。我的经验是TTL 建议设为事件流最大延迟时间的两倍以上。比如线上 Kafka 积压最多两周墓碑保留30天比较稳妥。清理动作本身不要直接 DELETE而是先把status置为“已物理删除”再定期归档到历史表。防重建是墓碑最容易被忽略的价值。所有服务在写入数据之前都要先检查墓碑表——如果目标实体的墓碑存在说明这个实体已经被删除拒绝这次写入。具体代码逻辑可以统一收敛在一个公共组件里public void checkEntityActive(String entityType, String entityId) { boolean tombstoned tombstoneRepository .existsByEntityTypeAndEntityId(entityType, entityId); if (tombstoned) { throw new DataAlreadyDeletedException( 实体[ entityType : entityId ]已删除拒绝写入); } }这样就能挡住“删除事件先到、更新事件后到”的乱序场景。没有这层检查你事后会发现诡异的“僵尸数据”明明删了某天又冒出来了。4. 事件流删除意图跨服务传播的通道4.1 为什么RPC同步级联在微服务里不靠谱有人问删除的时候直接 RPC 调下游服务逐个让它们删数据不也能解决吗短暂的小规模场景可以但链路长了以后问题非常明显一是分布式事务成本高。删除用户要同步调订单服务、积分服务、消息服务任何一个失败都要补偿补偿本身又是一堆事务逻辑。实际项目中这种“同步大串联”很容易让删除接口的 TP99 从几十毫秒涨到几秒。二是新增服务漏接。每次新增一个消费用户数据的服务都要回去改删除主链路的代码忘了改就漏删。这是微服务最常见的“手艺活陷阱”靠人工记住所有依赖方根本不现实。三是故障放大。同步调用链路里下游一个服务抖动上游删除就失败而失败的原因可能跟数据本身毫无关系。所以跨服务传播删除意图应该用事件流而不是同步调用。下面是两种方式的对比维度同步 RPC 级联事件流广播耦合方式删除方要知道所有下游删除方只发事件下游自行订阅失败处理需逐层补偿、回滚消息队列重试 死信失败可追溯新增下游需要改删除方代码新服务自己订阅即可上游无感删除接口延迟随下游数量线性增长只需写本地事务发事件延迟稳定一致性强度强一致但不是所有场景必要最终一致配合墓碑可接受4.2 删除事件的建模与投递删除事件的定义要足够明确下游拿到之后不需要再回查上游。我常用的 JSON 结构长这样{ eventId: e_01HZ9K2Q..., eventType: EntityDeleted, entityType: user, entityId: u_20240101_001, occurredAt: 2025-04-15T10:00:00.000Z, causationId: c_20250415_001, operatorId: admin, tenantId: t_10001, metadata: { sourceService: user-service, deletedReason: USER_CANCELLATION } }分区键建议用entityType entityId保证同一个实体的事件进入同一个分区消费端才能保持相对顺序。事件投递的最大坑是“业务事务提交了事件没发出去”。比如用户服务在本地事务里把用户标记删除然后调用 MQ producer 发消息如果消息发送失败用户数据删了但其他服务不知道孤儿数据照样产生。解决这个问题的标准做法是本地消息表OutboxCREATE TABLE outbox_event ( id BIGINT AUTO_INCREMENT PRIMARY KEY, event_id VARCHAR(64) NOT NULL UNIQUE, aggregate_type VARCHAR(64) NOT NULL, aggregate_id VARCHAR(128) NOT NULL, event_type VARCHAR(64) NOT NULL, payload JSON NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待发送 1已发送, send_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NULL, created_at DATETIME NOT NULL ) ENGINEInnoDB;核心思路是业务数据和 outbox 事件表在同一个本地事务里写入。事务提交后后台任务扫描 outbox 表把事件投递给 MQ。这样业务数据删除和事件写入要么同时成功、要么同时失败不会出现“业务成功、事件丢失”的情况。投递成功后更新状态。即使 MQ 挂了事件记录还在恢复后继续补发。4.3 消费侧幂等与顺序保障事件流解决“删除意图能不能到”的问题消费侧要解决“到了之后能不能处理好”的问题。最常见的是两类第一类是重复投递。MQ 一般保证至少一次投递消费端必须幂等。幂等的关键就是用eventId去重处理前先查 outbox/已处理事件表如果这个事件已经处理过就直接返回。注意检查防重和业务处理要在同一个事务里否则并发场景下还是可能重复。第二类是乱序。不同实体的删除事件天然没有全局顺序同一个实体的删除和更新事件也可能因为重试而乱序。这个时候墓碑就派上用场——先写墓碑、再处理关联数据后续任何乱序的更新事件都会被墓碑拦截不会把已删数据复活。消费侧核心逻辑可以抽象成下面这个模板Transactional public void handleDeleteEvent(DeleteEvent event) { // 幂等判断 if (processedEventRepository.exists(event.getEventId())) { return; } // 墓碑先行确保乱序写入无法复活数据 tombstoneRepository.insert(event.toTombstone()); // 在本服务内递归查找并标记所有关联数据 for (RelatedEntity related : dependencyResolver.findRelated(event)) { markAsDeleted(related); } // 记录已处理事件占用事务保证一致性 processedEventRepository.save(event.getEventId()); }消费端要有死信队列兜底。连续重试仍然失败的事件不能一直卡在 MQ 里占用分区先丢到死信队列人工排查后再手动补偿。真要等事件积压后续扫描孤儿数据的任务会被迫背锅。5. 三个机制如何拧成一股绳一套可落地的方案5.1 整体时序拆解三个技术点的关系可以这样概括递归解决“删除范围”墓碑解决“时间乱序”事件流解决“空间传播”。把它们组合起来就是一套标准的防孤儿数据方案。我按实际项目的编排方式把完整链路拆成六个步骤用户在管理后台发起删除请求。用户服务开启本地事务先给用户主体写入一条statuspending的墓碑同时记录causationId为本次删除请求ID。用户服务在自己边界内执行递归遍历把用户相关的帖子、附件、凭证等数据全部标记为“待删除”但先不物理删除。用户服务提交本地事务同时通过 outbox 机制发出UserDeleteRequested事件底层对应EntityDeleted类型。订单服务、积分服务、搜索服务等所有订阅了该事件的下游消费事件后在自己的服务内做同样的事先写墓碑再递归查找本地关联数据标记为“待删除”。当各服务完成标记后通过事件或协调任务汇总确认用户服务把墓碑升级为confirmed状态并发布EntityHardDeleteRequested事件。各服务消费该事件后执行真正物理删除或归档同时把自己服务内关联的墓碑状态置为“已清理”。编排方式上我偏向 choreography编排式事件流而不是把所有步骤写进一个中心化的 Saga 协调器。删除链路本身天然是异步可容忍的最终一致就够用。如果你们业务对一致性要求更严格可以在第5步引入一个确认表等所有下游都上报完成后再统一物理清理。5.2 查询链路如何规避孤儿与墓碑方案上线后最怕的是查询逻辑没有同步改造用户明明删了页面还能看到残留。查询侧要统一过滤掉已标记删除的数据。有两个层面数据库查询层面所有涉及可能被删除实体的 SQL都要带上status ! TOMBSTONED或deleted_at IS NULL条件。手工在每个 mapper 里加很容易漏建议在 ORM 层做一个全局的“逻辑删除拦截器”统一在 SQL 后面拼接过滤条件。RuoYi 这类脚手架项目通常也提供了逻辑删除注解要确认它是否覆盖了所有联表查询尤其是多表 JOIN 的场景注解经常失效。缓存层面删除操作发生后不止要清缓存还要防止缓存击穿后回源把墓碑数据重新写进缓存。可以结合本地缓存做一个短时间的“空值缓存”或者在回源查询时先检查墓碑表。我在一个项目里遇到过这样的坑缓存过期后回源源表数据已经逻辑删除但查询 SQL 没有带过滤条件把已删数据又缓存了半小时前端半夜还能孜孜不倦地报错。查询方还要做到“即使读到引用的是已删除实体也能优雅降级”。比如订单详情里引用了一个已下架商品不要直接空白显示给个“商品已失效”的状态位用户看着才不觉得系统坏了。5.3 存量孤儿数据的对账清扫方案只能防增量线上的存量孤儿数据还得靠对账任务清理。这部分我见过太多人直接写一条 DELETE 去删结果删错又被业务投诉。我的建议是三步走第一步血缘反查。从主数据源出发把所有“引用过它”的服务和表梳理出来建立一张血缘关系清单。事件流日志是很好的辅助数据源从事件日志里可以重建“哪个实体在哪个时间被哪些服务消费过”。第二步定期扫描对账。每天跑一个低峰期任务扫描业务表里 parent_id 指向的记录是否存在、父实体是否已标记删除。如果父实体已删而子数据还在就把这些子数据标记为ORPHANED状态进入观察期。观察期建议至少保留一个事件重试周期比如 7 天防止误判。第三步自动清理 人工审批。观察期结束仍未恢复关联的子数据自动生成清理任务由 DBA 或运维审批后执行物理删除。清理动作要记录日志方便事后审计。对账任务本身要纳入监控每天跑完要上报发现孤儿数量、清理数量和异常告警。如果你是刚接手一个微服务老项目我建议先跑一轮这种对账把基线数据摸清楚再上方案。不然你根本不知道线上已经有多少孤儿后续优化效果也没法量化。6. 线上踩过的坑一次说清6.1 事件重复与乱序的应对细节我踩过的最经典的坑是这样的下游服务先消费到UserUpdated事件再消费到UserDeleted事件。正常情况下顺序没错但一旦 MQ 重试或者网络抖动更新事件被延后投递消费端执行 UPDATE 时发现用户已删顺势把一条已删数据又更新了一遍墓碑拦都拦不住——因为更新入口没有做墓碑检查。所以当时改旅行两件事所有写操作入口统一过墓碑检查更新事件消费前也查一次tombstoneRepository。另外删除事件和更新事件建议使用同一个分区键这样同一实体的事件在分区内能保持相对顺序降低乱序概率。另一类重复事件问题用户重复提交删除请求同一个实体触发两次删除事件。如果没有以eventId为唯一键做幂等下游会插入两条墓碑记录后续物理清理时处理逻辑要处理两次虽然能靠status幂等但没必要。事件唯一 ID 必须全局唯一不能只靠时间戳。6.2 墓碑表该不该索引、要不要分表墓碑表作为高频检查点索引设计直接影响线上性能。除了delete_event_id的唯一索引还建议建(entity_type, entity_id)联合索引因为绝大部分业务操作都是“给定实体类型和ID检查是否存在墓碑”。不要只建单列索引entity_type的区分度不高单靠它过滤不到多少数据。堆积规模大的时候可以考虑按entity_type分表。比如用户类实体一张墓碑表、订单类实体一张墓碑表避免所有类型的删除记录挤在同一张表里导致单表数据量过大。分表之后查询路由也简单因为业务入口的类型是确定的不会出现跨表查询的需求。墓碑表不建议长期保留海量历史。status变成“已物理删除”超过 30 天就归档到历史库线上只留活数据这样查询性能才能稳定。6.3 方案上线时的可观测性指标方案上线不能只看代码逻辑还得盯指标。我在项目里重点跟踪五个指标指标说明预警阈值参考墓碑堆积量待清理墓碑数量超过基线 2 倍告警墓碑过期未清理量TTL 到期仍未物理删除持续大于 0删除事件发送/消费延迟outbox 发送到下游消费的耗时超过 5 分钟消费失败重试次数单个事件重试次数超过 5 次孤儿数据扫描结果每日对账发现的孤儿数超过 0 需要关注很多微服务项目包括不少基于脚手架搭建的主业务系统平时功能测试很难发现删除链路的问题但在高并发压测阶段下游消费 lag 一拉大孤儿数据就会批量冒出来压测人员用 JMeter 跑删除场景时很容易暴露。我的建议是压测脚本里一定要加入“创建-删除-查询”的组合场景专门验证删除后的数据一致性别只盯着接口吞吐量。6.4 几个让我长记性的原则最后说几个实操中沉淀下来的原则。第一别一上来就上分布式事务。先发删除事件 墓碑检查能覆盖大多数场景等真出问题再考虑升级方案。第二事件消费必须配置死信队列。第三对账任务要每天跑不要等用户投诉了再手工修。第四墓碑的保留时间一定要大于事件流最大延迟我习惯留两倍余量线上最久积压过两周所以墓碑 TTL 设了 30 天。第五所有删除代码要像写业务代码一样写单元测试和压测用例删除不是“一次性动作”它是系统持续运行的一部分。微服务下的孤儿数据问题本质上是你对“删除”这件事有没有建立完整的跨服务认知。递归、墓碑标记和事件流这组组合让我从“每次出问题就补 SQL”的被动状态变成“删除链路自动收敛、异常可以追踪”的可控状态。希望这套思路也能帮你把微服务里那些“看不见的垃圾”彻底清理干净。
返回列表