先讲一个我真实遇到的报警:凌晨三点,订单查询接口成功率突然往下掉,排查了一圈发现商品表里有一大批记录引用了一个根本不存在的类目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 List<Long> collectSubCategoryIds(Long rootId) { List<Long> result = new ArrayList<>(); Deque<Long> stack = new ArrayDeque<>(); stack.push(rootId); while (!stack.isEmpty()) { Long current = stack.pop(); result.add(current); List<Long> 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. 最后说点个人体会
在微服务环境下做数据清理,最容易被忽视的其实不是技术,而是“删除也分生命周期”这个意识。单体时代删除是一次性动作,微服务时代删除必须被当成一个贯穿多个服务的状态流转过程来设计。递归、墓碑标记、事件流这三样东西,并不是谁替代谁的关系,而是各管一段:递归管同一棵树内的局部清理,墓碑管删除状态的统一记录和追溯,事件流管跨服务的信息传播,对账任务管兜底。把它们串起来,才是一套能让系统在“删数据”这件事上睡得着觉的架构。如果你也在设计类似的删除链路,建议从小范围试起,先把墓碑表和幂等表建好,再逐步把同步删除改造成事件驱动,稳扎稳打踩完一轮坑,自然就形成适合你自己业务的闭环了。