微服务孤儿数据治理:递归+墓碑+事件流的三层删除方案
2026/9/19 14:28:34 网站建设 项目流程

上个月排查线上存储,发现用户服务里躺着四十多万条“无主记录”: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),删除一篇帖子时要查全部子孙评论ID:

WITH 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、原因链路ID(causationId)。这些额外的身份信息是整个方案能对抗乱序、重复消息的关键。

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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

每个字段都有用途: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 发消息,如果消息发送失败,用户数据删了但其他服务不知道,孤儿数据照样产生。解决这个问题的标准做法是本地消息表(Outbox):

CREATE 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 ) ENGINE=InnoDB;

核心思路是:业务数据和 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 整体时序拆解

三个技术点的关系可以这样概括:递归解决“删除范围”,墓碑解决“时间乱序”,事件流解决“空间传播”。把它们组合起来,就是一套标准的防孤儿数据方案。我按实际项目的编排方式,把完整链路拆成六个步骤:

  1. 用户在管理后台发起删除请求。用户服务开启本地事务,先给用户主体写入一条status=pending的墓碑,同时记录causationId为本次删除请求ID。
  2. 用户服务在自己边界内执行递归遍历,把用户相关的帖子、附件、凭证等数据全部标记为“待删除”,但先不物理删除。
  3. 用户服务提交本地事务,同时通过 outbox 机制发出UserDeleteRequested事件(底层对应EntityDeleted类型)。
  4. 订单服务、积分服务、搜索服务等所有订阅了该事件的下游,消费事件后在自己的服务内做同样的事:先写墓碑,再递归查找本地关联数据,标记为“待删除”。
  5. 当各服务完成标记后,通过事件或协调任务汇总确认,用户服务把墓碑升级为confirmed状态,并发布EntityHardDeleteRequested事件。
  6. 各服务消费该事件后执行真正物理删除或归档,同时把自己服务内关联的墓碑状态置为“已清理”。

编排方式上我偏向 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”的被动状态,变成“删除链路自动收敛、异常可以追踪”的可控状态。希望这套思路也能帮你把微服务里那些“看不见的垃圾”彻底清理干净。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询