
先讲一个我真实遇到的报警凌晨三点订单查询接口成功率突然往下掉排查了一圈发现商品表里有一大批记录引用了一个根本不存在的类目ID前端为了展示类目名回源去查类目服务结果全部落空最后只能靠一个写死的默认值兜底。这个问题的本质不是代码bug而是数据在微服务之间“走散”了——类目服务把类目删了商品服务里的商品还留着对它的引用。像这种父记录已经没了、子记录却还活着的脏数据就是我们常说的孤儿数据。日常开发里凡是拆过微服务的人基本都跟孤儿数据打过交道用户注销了订单里还显示用户名商品SKU删了购物车里还躺着这个SKU组织架构调整部门删了员工的部门ID变成了无效ID。表面看是“删除逻辑没写全”往深了说是整个系统缺少一套让删除动作在多个服务之间可靠传播的机制。这也是我这篇文章想展开的核心在处理微服务孤儿数据时怎么把递归、墓碑标记、事件流三样东西配合起来用既能把该清的清干净又不至于把正常的业务数据误伤掉。1. 孤儿数据到底怎么产生的从“数据库不再管你”说起1.1 单体时代的删除为什么几乎不用操心很多人是从纯前端或者单体应用转到微服务之后才第一次听到“孤儿数据”这个词因为单体应用时代这个问题基本被数据库天然屏蔽了。一个典型的单体系统订单表和用户表在同一个MySQL实例里外键一建删除用户时数据库直接报错或者级联删除事务包住要么全成功要么全失败。就算不建外键也可以在同一个事务里先删用户再删订单两行SQL的事。这个阶段的“删除”是强一致、同步、由数据库保证的。你可以说它笨但它就是不会产生孤儿数据。到了微服务架构事情完全不一样了。拆服务意味着拆库订单数据归订单服务管用户数据归用户服务管两个服务之间只通过接口通信。此时你不可能在订单库建一个指向用户库的外键很多团队也明确禁止跨库JOIN。数据库层面的完整性约束在服务拆完之后就从“可用”变成了“不可用”。这个变化的影响被很多人低估了。服务拆了之后你不仅失去了数据库约束还失去了一个隐性的“删除协调器”。以前删除用户时数据库能告诉你还有多少订单在引用他现在你连对方库的表结构都看不到更别说遍历所有引用方。1.2 微服务环境下产生孤儿数据的几种典型路径根据我自己的观察微服务环境下的孤儿数据大概有下面几条产生路径你会发现它们几乎都跟“跨服务删除”有关直接删主数据没通知下游用户服务把用户DELETE了订单服务、积分服务、客服工单服务完全不知情各自库里还存着这个用户的ID。这是最常见的一种。同步调用下游清理但调用失败用户服务删用户时同步去调订单服务的清理接口结果订单服务超时了。用户服务选择了重试重试也失败最终用户删掉了下游清理没做成。服务间数据以快照/冗余方式存储订单表里直接冗余了用户名、商品名用户改名或商品改名后冗余数据没有同步更新用户删除后冗余的用户名成了一个没有任何意义的字符串。定时任务或迁移脚本跑了一半断了一次性清理脚本在跑的过程中因为网络、服务重启或数据量太大中断没有续跑机制留下了半截没清完的数据。级联依赖链路过深只清了第一层删了一个父类目子类目没清子类目下的商品也没清形成了多层孤儿链。这些路径有一个共同特征删除信息没有跨越服务边界传播出去。不管你是同步调用失败还是根本没想过去通知下游本质都是“删除这件事只发生在了一个服务的数据库里”。所以我才说解决微服务孤儿数据第一原则不是“怎么删得干净”而是“怎么让删除这件事可靠地触达所有相关服务”。2. 递归清理树形结构删父必删子为什么单靠它走不通2.1 递归清理的正确适用场景树形数据在同一服务内先别急着否定递归它是整个清理链路里不可替代的一环只是要把它放在正确的位置上。递归最典型的应用场景是树形结构的数据组织架构、商品类目、评论楼中楼、权限菜单。这类数据的特征是父记录和子记录在同一张表里通过parent_id自关联。删除一个父节点时必须把它的整棵子树都处理掉否则父节点没了子节点还在表里指着一个不存在的parent_id同样是孤儿数据。如果这张表所在的树完全由同一个服务管理递归清理是最高效、最符合直觉的方案。比如商品服务自己的类目表假设类目树完全在商品服务库内删除ID为10086的类目时先查出所有子类目再逐层下钻直到叶子节点然后从叶子向上删除或用状态标记。代码实现上一种做法是应用层递归把节点查出来放到栈里循环处理public ListLong collectSubCategoryIds(Long rootId) { ListLong result new ArrayList(); DequeLong stack new ArrayDeque(); stack.push(rootId); while (!stack.isEmpty()) { Long current stack.pop(); result.add(current); ListLong children categoryMapper.selectIdsByParentId(current); for (Long child : children) { stack.push(child); } } return result; }另一种做法是直接用数据库的递归CTE一次查询把所有子孙节点扫出来WITH RECURSIVE category_tree AS ( SELECT id, parent_id FROM category WHERE id #{rootId} UNION ALL SELECT c.id, c.parent_id FROM category c INNER JOIN category_tree ct ON c.parent_id ct.id ) SELECT id FROM category_tree;不管是应用层循环还是数据库递归CTE只要树是完整的、在同一个库内这套方案都没有问题性能也够用。递归在这里解决的是“同一棵树内部怎么删干净”的问题它是局部武器不是全局方案。2.2 递归在微服务分布式环境下的三重困境如果把这套递归逻辑直接搬到微服务之间问题就来了。第一重困境是递归变成了跨服务调用。假设一个商品类目树在商品服务里但类目下的品牌归属在品牌服务里类目下的商品库存在库存服务里。你在商品服务里递归删类目删到每个节点时发现还要调用品牌服务、库存服务去清理递归的每一层都变成了一次RPC。跨服务调用的失败率不是单次失败率而是层级累积的。树深度是5每层调用成功率是99%整体成功率就是95%。如果深度是10就只剩90%。一旦中间某一层失败你甚至不知道已经清到哪一层了。第二重困境是分布式事务。有人会想那我把整个递归清理过程包在一个分布式事务里不就完了理论上可以但实际没人敢这么干。一棵大树可能有几万个子节点每个节点要调多个下游服务事务会持续很长时间锁住的资源极多。更别说很多微服务团队用的还是不同数据库分布式事务在跨异构存储场景下基本是噩梦。最终结果是能用但代价大到业务方完全无法接受。第三重困境是递归深度和性能。极端深度的树比如用户自定义目录嵌套了几十层在应用层递归时JVM/C栈可能会溢出在数据库里用递归CTE超过默认的递归深度上限也会报错。即便深度不深如果同一棵树下数据量特别大单次递归要处理的数据量也会非常大一个大事务删几十万条数据数据库压力直接拉满。所以我的结论很明确递归应该被限制在“单个服务内部、单棵树内部”使用它负责把同一个服务里能够直接看到的数据清理干净但不适合作为跨服务清理的传输协议。跨服务这一层需要交给墓碑标记和事件流来做。3. 墓碑标记把“删掉”变成“标记-传播-回收”三态流转3.1 墓碑标记不是简单的逻辑删除很多人一听到“标记删除”就说不就是逻辑删除吗加个deleted字段其实墓碑标记tombstone和逻辑删除有本质区别。逻辑删除的重点是“让数据在业务上不可见”它往往只服务当前查询对下游没有传播能力而墓碑标记的重点是“为删除动作留下可供其他服务消费的追溯痕迹”它服务于整个系统的数据生命周期治理。一个标准的墓碑标记至少要包含这些信息{ entityType: CATEGORY, entityId: cat_10086, deletedAt: 2025-01-01T12:00:00Z, deletedBy: ops, version: 3, retentionDays: 30, status: TOMBSTONED }实际设计时我不会建议每个表都贴俩字段而是根据场景分两种做法轻量做法在业务表上增加tombstone_status、tombstone_time、tombstone_version字段。适用于删除频率不高、表行数可控的场景。重量做法单独建一张entity_tombstone流水表记录所有被删除实体的ID、类型、删除时间、删除原因、删除人、受影响的服务列表。适用于数据量大、需要审计追溯的场景。墓碑标记的核心价值是把“删除”这个瞬间动作变成了一个持续存在的状态让下游系统在任意时刻都可以来问一句“这个实体是不是已经删了什么时候删的”这个查询能力是后续一切异步传播和对账的基础。3.2 删除状态机Alive、Tombstoned、Purged有了墓碑删除就不再是二态的而是三态的状态含义数据所在位置业务可见性Alive正常存活业务库原表可见Tombstoned已标记删除等待清理传播原表保留 墓碑表记录不可见Purged已物理回收或归档冷存储/已删除不可见从Alive变成Tombstoned是同步的发生在删除主数据的事务里从Tombstoned变成Purged是异步的由后续的清理任务和事件消费者完成。这个状态机带来一个非常大的好处删除主干链路被缩短了。以前删除一条类目必须在同一个事务里把商品、库存、营销全清掉现在只需要把它标记成Tombstoned然后发布一条删除事件主干事务立刻结束。下游服务的清理各自慢慢来系统整体从“强一致删除”降级成了“最终一致删除”但换来的是可用性和响应速度。有个细节值得注意墓碑一定要带版本号。为什么因为可能发生“旧数据覆盖新状态”的问题。比如一个类目被标记删除后又因为数据回流或同步任务被重新插入如果下游清理任务拿着旧版本号来比对发现版本号不一致就能意识到数据已经被“复活”从而取消清理。版本号是防误删的关键屏障。3.3 墓碑标记驱动的异步清理链路墓碑标记怎么驱动清理简单来说就是让一个扫描任务定期去捞状态为Tombstoned的数据根据受影响的实体类型向对应的消息队列或事件总线发布删除事件下游消费事件后再去删自己的关联数据。链路大致是业务请求删除主数据服务在本地事务里把记录标记为Tombstoned写入墓碑信息。事务提交后发一条CategoryDeleted事件到消息队列。下游服务商品、库存、营销各自消费这条事件执行自己的清理逻辑。如果某些下游服务没有及时消费后台会有一个“墓碑扫描任务”定期扫墓碑表找出超过N分钟还没被确认清理完成的记录重新发布事件。数据在墓碑状态下保留一个可配置的保留期比如7天或30天之后才真正物理删除或归档到冷存储。这套链路里墓碑表其实是系统的一个“删除日志中枢”它把一次删除的传播过程变成了可追踪、可重放、可对账的数据流而不只是靠消息队列自身的可靠性。4. 事件流让每个服务自己动手清理自己的关联数据4.1 事件驱动删除的通信模型如果说墓碑标记解决的是“删除事实如何记录”事件流解决的就是“删除事实如何传播”。事件驱动在这里的核心理念是上游服务不直接告诉下游“你去删XX表”而是只声明一个客观事实“用户ID为10086的用户已注销”。至于下游怎么处理这个事实是物理删除、逻辑删除、匿名化保留还是直接忽略完全由下游服务自己决定。很多人第一次听到这个会觉得不踏实这不就是把责任甩给下游了吗它要是不处理怎么办这就是为什么前面要配墓碑扫描和对账事件流负责传播意图兜底机制负责确认结果这两者配合才是一个完整的闭环。事件流相比同步调用的优势最直观的有三点解耦用户服务不需要知道有哪些服务依赖用户数据只要把UserDeleted事件发出去就行。这正好解决微服务环境下“我删了数据但不知道谁会受影响”的根本困境。削峰填谷大促后批量清理无效订单时下游服务不会被打爆消息队列天然起到了缓冲作用。可回溯消息队列里的删除事件本身就是一条审计日志任何一条关联数据被清理时都能追溯到是哪个事件触发的。4.2 事件结构的标准设计与幂等消费事件流清理的代码本身不复杂真正难的是把事件结构设计对、把幂等消费做好。我给出一个比较通用的事件结构模板{ eventId: evt_20250101120000_10086, eventType: CategoryDeleted, eventVersion: 1.0, occurredAt: 2025-01-01T12:00:00Z, aggregateId: cat_10086, aggregateType: CATEGORY, payload: { categoryId: cat_10086, parentId: cat_10000, deletedBy: ops_delete_task } }事件结构里eventId必须是全局唯一的occurredAt是删除发生的时间aggregateId是被删除实体的ID。payload里放的是下游服务清理时需要用到的信息比如父类目ID这样下游在清理子类目时才拿得到血缘关系。幂等是事件消费的生死线。消息队列一般提供的是“至少一次at least once”投递语义也就是说消费端可能会收到重复消息。如果直接拿消息里的关联ID去删除数据第N次收到重复消息时数据已经被删过了再删就会把后续新增的数据误删掉。我常用的幂等做法是在消费端维护一张幂等记录表CREATE TABLE IF NOT EXISTS idempotent_record ( event_id VARCHAR(64) PRIMARY KEY, biz_id VARCHAR(64) NOT NULL, handler VARCHAR(64) NOT NULL, handled_at DATETIME NOT NULL, UNIQUE KEY uk_biz_handler (biz_id, handler) );消费者处理逻辑是先根据event_id查幂等记录存在就直接返回不存在则在同一个本地事务里执行清理并写入幂等记录。这里的关键是“清理动作和写幂等记录必须在同一个事务里”否则中间宕机重启后仍可能重复处理。4.3 事件乱序、丢失与积压的应对方式事件流方案在真实生产环境里会遇到几个很头疼的问题这里逐一展开。事件乱序。比如一个分类先被删除后来又重新创建但删除事件和创建事件因为消息队列的吞吐原因消费顺序反了下游先处理创建事件再处理删除事件结果把刚创建的类目逻辑删掉了。应对方式是在事件里带版本号或时间戳消费端判断如果当前事件的时间早于本地记录的最后处理时间就丢弃这条事件。事件丢失。消息队列本身有持久化正常不会丢消息但会有极端情况比如消息过期、topic被误删、消费者写入数据库失败导致位点错误跳过了消息。不管概率多低都要有一个兜底回收机制。我最常用的是对账任务定时扫描墓碑表里状态还是Tombstoned且距删除时间超过N小时的数据重新发布一次删除事件。这种场景下幂等表就保证了重发消息不会产生副作用。消息积压。大促期间的批量清理可能让下游消费跟不上这个没有银弹一般就是横向扩容消费者或者把一次事件的清理粒度拆小比如一个事件只清一个类目而不是一个事件清整棵树。从架构上看积压不是数据问题是容量问题做好消费延迟监控就好。5. 落地案例电商系统三级类目删除的完整链路设计说了这么多理论我来拆一个完整的实际案例在电商微服务环境里删除一个三级类目“微单相机”系统里哪些地方会被波及怎么用递归墓碑事件流把这条链路搭起来。5.1 场景设定一次删除波及了五个服务假设系统拆了这几个服务商品服务维护类目树和SPU/SKU、库存服务维护SKU维度库存、营销服务维护优惠券适用类目、订单服务保存订单快照、搜索服务维护商品索引。删除“微单相机”这个类目几乎每个服务都有数据要处理服务关联数据处理方式商品服务类目树自关联、SPU、SKU递归清理类目子树SKU逻辑删除库存服务SKU库存记录物理清理或归档营销服务优惠券绑定的类目ID解绑或下架订单服务订单明细冗余了类目名称历史订单保留展示用快照搜索服务商品索引里的类目字段商品下架索引删除这个表一列出来就能明白为什么不能同步删这些服务分属不同团队、不同数据库任何一个服务不可用都会导致整个删除事务失败。而用事件流方案一次删除可以异步广播给所有服务。5.2 主链路设计本地事务标记墓碑并发布事件删除动作首先发生在商品服务里这里是操作入口。商品服务收到“删除微单相机类目”的请求后在一个本地事务里做了三件事把类目记录的状态从Alive改成Tombstoned写入删除人、删除原因。通过递归CTE查出这个类目下的所有子类目这里假设类目树全部在商品服务库内同样全部标记为Tombstoned。把所有受影响类目ID写入category_tombstone表。事务提交后商品服务发布一条CategoryDeleted事件到Kafka{ eventId: evt_20251012_083000_10086, eventType: CategoryDeleted, occurredAt: 2025-10-12T08:30:00Z, aggregateId: cat_10086, payload: { categoryId: cat_10086, childCategoryIds: [cat_10087, cat_10088], spuIds: [spu_10001, spu_10002] } }这里有一个设计要点payload里面把受影响的子类目ID和SPU ID直接带上而不是让下游服务再去商品服务查一次。因为删除事件消费时可能已经是几小时后商品服务里这些数据可能已经物理删了下游想查也查不到。事件里尽量包含下游清理所需的最小数据集合避免回查。5.3 下游各服务的独立处理策略库存服务订阅到CategoryDeleted事件后根据spuIds查到所有SKU编码把对应的库存记录标记为待清理再异步物理删除。这里注意库存服务不直接删业务主数据而是先标记再异步清避免库存锁表时间过长影响在售商品。营销服务订阅到事件后会扫描优惠券表里applicable_category_id在受影响的类目ID列表中的记录把优惠券状态改为下架或者把绑定的类目ID置空。这一步很关键如果不处理用户在下单时拿着一个已经失效的类目优惠券容易产生资损类客诉。订单服务的处理比较特殊不清理而是让历史订单的类目快照继续保留。因为订单是交易凭证用户随时可能发起售后订单里展示的类目名称应当和下单时保持一致。这个决策说明了一件事下游服务不一定必须“清理”数据只是必须“根据删除事件作出响应”响应的内容可以是清理、匿名化、保留快照或者冻结。搜索服务订阅事件后把对应SPU的索引记录删除或标记为下架确保前台商品搜索不会展示已经删掉类目的商品。5.4 兜底对账事件流失效时保证最终一致事件流方案最大的软肋就是不确定性消息可能积压、消费者可能宕机、代码可能有什么bug导致清理逻辑抛异常。所以任何严肃的架构设计都必须配一个兜底。我设计的对账任务是这么跑的定时任务每分钟扫描category_tombstone表找出状态为Tombstoned且deleted_at在10分钟之前的数据向Kafka重新发送CategoryDeleted事件。因为消费者是幂等的重复事件不会造成误删。对于超过24小时仍未确认清理完成的数据说明消费者处理逻辑有问题对账任务会转向发送一条CategoryDeleteRecheck消息给告警系统触发人工介入。这里我还会加一个清理进度统计墓碑表里面有一个cleanup_status字段下游服务每确认一个清理动作就回调更新进度当所有关联服务都搞定后状态才变成Purged。6. 几个真实的坑和排查思路6.1 事件重复消费导致的“二次删除”问题有一次我在排查一个线上问题本来只是删除一个测试类目结果把测试类目下新创建的一个SPU也删了。查了一遍发现是消费者收到了两条重复的CategoryDeleted事件第一条把旧SPU删了第二条消费时刚好有个测试同学在那个类目下新建了一个SPU然后幂等判断只用了event_id新SPU没有被event_id关联于是被误删。这个问题的根因是幂等键粒度太小。解决方法是幂等键要结合“本次删除对应的实体ID范围”比如用event_id aggregateId payload里的spuId列表作为幂等条件。更稳一点的做法是在消费时先检查目标数据是否已存在确认存在才执行删除不能只依赖事件ID。6.2 循环依赖导致“死人复活”还有一次是删除A服务的数据时B服务的清理逻辑反过来调用了A服务的新增接口把一条已经被标记删除的类目又插了回来。等到对账任务扫描墓碑时发现这条记录状态是Tombstoned又重新发删除事件结果两边互相触发形成循环。这类问题在跨团队协作的系统里特别容易碰到排查思路是给每一次删除定义“源头链路ID”在整个事件传播过程中透传这个链路ID任何一个服务在处理时如果发现自己接收的事件链路ID和本地触发链路的ID相同就说明发生了环回必须丢弃。另一个实用做法是限制同一实体的删除和新增操作要经过同一个服务入口避免下游直接回写上游的数据。6.3 墓碑表只增不减数据库越来越肥墓碑标记用久了最典型的副作用就是墓碑表膨胀。特别是删除频率高、保留期设置得又长的系统墓碑表动不动上千万行扫描任务每次捞数据都慢到超时。我的处理建议是把墓碑表设计成按月分区的结构删除时间作为分区键过期数据直接按分区删除比逐条DELETE效率高得多。扫描任务也尽量走“水位线”模式记住上次扫描到的时间点避免每次都从头扫。如果你连分区表的成本都不想付出还有一种更轻量的做法墓碑表不要存全部数据只保留最近一个保留周期的更早的数据归档到冷存储扫描任务永远只处理热区。另外墓碑保留期不能一刀切。用户注销类的数据考虑到审计投诉风险可能得保留半年类目删除这种业务变更数据30天就够。不同实体类型用不同的保留期既能满足追溯需求又不至于无限膨胀。6.4 递归深度过深导致的性能瓶颈处理类目树数据时我见过一个极端案例某个客户的自定义目录嵌套了两百多层应用层递归直接栈溢出数据库递归CTE也报了递归深度超限的错。排查下来发现这个客户把“目录”当成了一种灵活的父子关系来用任意两个节点之间都可能建立父子关系根本不像常规树。这种场景靠递归是扛不住的。后来我们改成了“层级字段祖先链路径”的存储方案每条记录维护一份从根节点到自身的祖先ID路径删除父节点时直接用LIKE root_id/%来匹配所有子孙节点一条SQL就能把整棵子树找出来完全没有递归深度问题。这个案例说明当数据结构出现极端深度时不要硬扛递归换一种存储和查询思路往往更有效。能用标记位和路径匹配解决的问题就不要让流程去绕递归。6.5 事务范围控制不当导致数据库锁扩散最后提醒一个很多新手会犯的错误在消费删除事件时为了图方便在一个数据库事务里把所有关联数据全部删掉一个事务处理几千行甚至几万行。看起来逻辑很完整实际上是拿数据库锁在换代码简洁度。一旦清理的数据量很大这个长事务会阻塞同表的所有读写线上故障就是这么来的。我的建议是消费者内部一定要拆批一次事务最多处理几百条记录处理完一批就提交一次事务实在要处理大批量数据就先查出来放到内存队列里分批执行。墓碑状态和清理进度要及时更新这样即使中途崩溃重启后还能从墓碑表的进度继续跑而不是从头再来。7. 最后说点个人体会在微服务环境下做数据清理最容易被忽视的其实不是技术而是“删除也分生命周期”这个意识。单体时代删除是一次性动作微服务时代删除必须被当成一个贯穿多个服务的状态流转过程来设计。递归、墓碑标记、事件流这三样东西并不是谁替代谁的关系而是各管一段递归管同一棵树内的局部清理墓碑管删除状态的统一记录和追溯事件流管跨服务的信息传播对账任务管兜底。把它们串起来才是一套能让系统在“删数据”这件事上睡得着觉的架构。如果你也在设计类似的删除链路建议从小范围试起先把墓碑表和幂等表建好再逐步把同步删除改造成事件驱动稳扎稳打踩完一轮坑自然就形成适合你自己业务的闭环了。