微服务架构下如何用递归、墓碑与事件流彻底清除孤儿数据
2026/9/19 15:40:41 网站建设 项目流程

先说一个我线上踩过的坑。有一次用户注销账号后,用户服务直接把主记录物理删了,结果订单服务里还挂着这个用户的 ID,数据分析任务一跑到历史订单就抛空指针,客服后台点开用户详情直接白屏,最后翻了几小时日志才定位到是一堆“幽灵引用”在作怪。这种数据在微服务架构里有个专门的名字——孤儿数据:主数据已经不存在了,但其他服务里还残留着对它的引用。单体时代这事根本轮不到你操心,数据库外键加级联删除就收拾得干干净净;到了微服务,数据按领域拆到各自的服务里,外键彻底失效,本地事务也管不到别人家,稍不留神就能造出一片“数据坟场”。

解决这个问题,我这几年的经验是三个机制组合起来用:递归负责把引用关系全部挖出来,墓碑标记负责把删除动作变成可感知、可追溯、可延迟的软删除,事件流负责把清理动作广播到所有关联服务,最终把物理删除放到一个安全的回收窗口里执行。这套方案看起来有三个名词,实际落地并不复杂,核心是把“删除”从单次数据库操作,改造成一个有发现、有标记、有通知、有回收的完整流程。这篇文章我会从问题拆解讲起,把三个机制各自的原理、实现要点和协同方式一次讲透,最后附上我在实际项目中踩过的坑和排查技巧,希望能帮你少走点弯路。

1. 微服务里为什么这么容易产出孤儿数据

1.1 数据库外键失效与本地事务管不到别人家

单体应用时代,删除一个商品分类,数据库外键带着 ON DELETE CASCADE 就能把关联的商品、SKU、属性一并删干净,事务保证要么全成功要么全失败。微服务把数据按领域边界拆开之后,订单数据在订单库,商品数据在商品库,它们物理上就不在一个数据库实例里,外键约束直接没法建——你总不能跨库建外键吧?就算用了分库分表中间件,也没人敢拿外键去约束异构数据源。

这带来的连锁反应就是:本地事务只管得到自己库里的那几张表。商品服务在自己的事务里删掉一个 SKU,它完全不知道订单服务里还有一万条订单记录引用着这个 SKU,更不可能帮你去把订单表里的 sku_id 置空。两个服务之间唯一能沟通的方式就是 RPC 或消息,而在删除这个场景下,你根本不知道到底有哪些服务、哪些表在引用这条数据,想发通知都不知道发给谁。

1.2 引用关系散落在各服务的表里,没有人有全局视角

这是最要命的一点。一个电商系统里,一个商品 SKU 可能被这些地方引用:订单表的 order_item.sku_id、购物车表的 cart_item.sku_id、收藏表的 favorite.sku_id、价格历史表、促销活动的活动商品表、用户浏览记录……而且这些表分布在六个服务里,由六个团队维护。你作为商品服务的开发者,大概率只知道订单服务里会存 sku_id,至于收藏、浏览、促销那边有没有引用,你根本没数。

我见过很多团队的第一反应是“我们约定好,删除前通知所有服务自查”。这个约定在只有两三个服务的时候还能勉强转起来,服务一多,很快就有人漏了。而且服务之间的引用还有传递性:商品被订单引用,订单又被售后单引用,你只清理了订单引用,售后单里残留的 order_id 照样是孤儿数据。这种多级引用链,靠人肉梳理根本不现实。

1.3 级联删除逻辑在微服务里根本无从下手

单体应用做级联删除,本质是数据库帮你在一个事务里把关联数据全删了,中间不会有任何外部观察者看到“中间状态”。微服务里你把级联逻辑搬到业务代码里试试:先删商品,再调订单服务删关联数据,再调购物车服务删关联数据……调三个服务就要面对三份网络超时,任何一个服务调用失败,数据就处于“删了一半”的状态。而且级联删除的深度还可能不止一层,一个服务删完别家的数据后,别家又触发对第四个服务的调用,整个链路一长,超时、重复调用、数据错乱全来了。

所以说,微服务环境下处理引用删除,不能用“事务性级联删除”的思路,得换成“先发现、再标记、后清理”的思路。这就是递归、墓碑标记和事件流要解决的问题。

2. 递归:把关联关系网完整地找出来

2.1 先建一张引用注册表,让引用关系从隐式变成显式

递归要发挥作用,前提是你得知道“谁引用了谁”。如果引用关系只存在于各个服务的业务表里,你没有一个全局的入口能够查询,递归就无从谈起。所以第一步,我建议在基础设施层维护一张“引用注册表”,专门记录跨服务的引用关系。

这张表不用很复杂,核心字段是这些:引用来源服务的名称、来源实体类型、来源实体 ID,被引用服务名称、被引用实体类型、被引用实体 ID,外加创建时间。每个服务在写入一条会引用到其他服务实体的数据时,也都往这张表里插一条引用记录。举个例子,订单服务创建订单,订单项里存了商品 SKU 的 ID,那么同时就会在引用注册表里写一条:order-service 的 ORDER_ITEM 表引用了 product-service 的 SKU。

这个表谁来维护?最省事的方案是在基础架构团队提供的通用 SDK 里封装好,业务代码在写入数据后自动上报引用关系;如果不想动业务代码,也可以用监听 binlog 的方式,在数据变更时异步解析出引用关系写入注册表。两种方案我都试过,前者语义更清晰,后者对业务侵入小,看团队基建能力选就行。

2.2 从根实体出发,用队列做引用关系的多级下钻

引用注册表建好之后,删除前要做的事情就清晰了:给一个根实体,递归找出所有直接或间接引用它的记录。这个递归我用得最多的是 BFS,原因很简单——微服务里的引用关系大概率是网状而不是简单树状,BFS 能天然避免递归深度过大,也能配合剪枝。核心逻辑就是维护一个待检查队列,初始放入根实体,每检查一条引用记录,就把这条记录的来源实体也加入队列继续查,直到队列为空。

from collections import deque def collect_all_referrers(registry, root_entity): """ registry: 引用注册表的查询接口 root_entity: (service, entity_type, entity_id) """ visited = set() # 防止环形引用导致死循环 queue = deque() queue.append(root_entity) results = [] while queue: current = queue.popleft() if current in visited: continue visited.add(current) # 查出所有引用了当前实体的记录 ref_rows = registry.find_referrers( target_service=current[0], target_type=current[1], target_id=current[2] ) for row in ref_rows: results.append(row) # 引用方的记录可能又被其他记录引用,继续下钻 source_entity = (row.owner_service, row.owner_type, row.owner_id) queue.append(source_entity) return results

2.3 环状引用与递归深度失控怎么防

真实业务里引用关系很容易出现环:A 引用 B,B 又引用 A。如果递归不做判重,这个环能让你死循环到地老天荒。上面伪代码里的 visited 集合就是干这个的,它保证同一个实体只被处理一次。同时我还建议给下钻层数设一个上限,比如最多下沉十层,超出直接告警——真出现超过十层的引用链,大概率是数据建模出了问题,需要人工介入,而不是让程序闷头去挖。

另外一个容易被忽略的细节是:递归查询引用注册表属于跨服务调用,一定要设超时和熔断。否则删除一个热点实体的引用关系时,注册表服务被打挂,连带其他服务一起雪崩。我的做法是把递归下钻整体封装成一个独立的“引用发现服务”,对外只暴露一个接口,内部做超时控制、结果缓存和限流。

3. 墓碑标记:把删除动作从“瞬间消失”变成“可控隐形”

3.1 为什么不直接物理删除,而是先立一块墓碑

先想一个场景:用户刚下单成功,商品服务这边有个人在后台把商品 SKU 删了。如果商品服务直接物理删除,那用户在订单详情页刷新一下,发现商品名、图片、价格全没了,只剩一个报错的空壳;如果订单列表要调用商品服务回显商品信息,直接就 404 了。这种体验显然不能接受。

墓碑标记的思路其实就是在实体上写一个删除标记,而不是真正删掉记录。被标记的数据对业务查询不可见,但物理行还在,可以随时查出来做审计、补偿或者延迟清理。这在业界还有个更常见的名字叫软删除,但墓碑标记比普通软删除多了两样东西:一是标记本身就说明“这个实体正在经历删除流程”,二是标记上带版本号和删除原因,方便追踪删除的上下文。

3.2 墓碑字段怎么设计才够用又不啰嗦

我的建议是最少加四个字段:deleted_at 记录删除时间;deleted_by 记录操作人;delete_reason 记录删除原因(用户注销、违规下架还是数据订正);tombstone_version 做乐观锁,用于处理删除流程中的并发写操作。查询侧所有默认查询都要带上 deleted_at IS NULL 的条件,确保墓碑数据不会出现在正常业务路径里。

ALTER TABLE product_sku ADD COLUMN deleted_at DATETIME NULL COMMENT '删除时间,NULL 表示未删除', ADD COLUMN deleted_by VARCHAR(64) NULL COMMENT '删除操作人', ADD COLUMN delete_reason VARCHAR(255) NULL COMMENT '删除原因', ADD COLUMN tombstone_version INT NOT NULL DEFAULT 0 COMMENT '墓碑版本号';

3.3 墓碑的延迟回收机制,是整条删除链路的安全阀

墓碑标记只是“隐形”,不代表数据该永远留在表里。表数据不断累积,索引膨胀、备份变慢、磁盘成本上升,这些都是实打实的问题。所以必须有一个延迟回收机制,在确认所有下游清理完之后,把墓碑数据物理删掉。

我用的回收策略是分两步:第一步,标记删除后先保留一段时间,默认 72 小时,给所有下游服务留出处理事件流的时间窗口,也给自己留出人工介入的余地;第二步,定时任务扫描超过保留期的墓碑数据,每次批量取一批,再去引用注册表查一遍是否还有业务侧残留引用,确认没有才物理删除。如果发现还有残留引用,说明事件流消费失败了,这时候要触发告警,而不是强行删除。这套机制相当于给删除流程上了双保险。

4. 事件流:让每一个关联服务都感知删除并完成清理

4.1 为什么要用事件流,而不是同步 RPC 通知

前面递归找到了完整的引用关系,下一步自然是通知对应服务去清理。这里有两个选择:同步 RPC 逐个通知,或者发一条事件让所有订阅者自己消费。我强烈建议用事件流,原因有三个:一是引用关系方可能很多,同步 RPC 会拉长删除接口的响应时间;二是通知某个服务失败会导致整个删除失败,同步太重;三是后期新增一个引用方服务时,只要它订阅对应事件就能自动被覆盖到,不需要改删除发起方的代码。

事件流在这里充当的是一个广播通道的角色。删除发起方只需要把“哪个实体被删了,有哪些引用关系”作为事件消息发出去,剩下的清理工作交给各服务的消费者异步执行。下单链路和后台删除链路天然解耦,哪怕清理消费慢一点,也不会拖慢用户的请求。

4.2 事件消息里应该带什么数据最合适

很多团队在这里容易走极端:要么只发一个实体 ID,让消费者自己去查引用关系再决定清不清;要么把全量业务数据都塞进事件里。我的建议是事件消息里带两部分内容:一部分是被删除实体的基本信息,包括服务名、实体类型和实体 ID;另一部分是递归发现的引用清单,也就是每个服务持有的具体引用记录。这样做的核心价值在于,消费者拿到消息后可以立刻判断哪些引用属于自己,不需要再回头查引用注册表,效率高,也避免引用注册表查询接口被打爆。

{ "eventId": "a1b2c3d4-...", "eventType": "ENTITY_DELETED", "entityType": "PRODUCT_SKU", "entityId": "sku_998877", "occurredAt": "2025-01-18T10:32:00Z", "referrers": [ { "service": "order-service", "entityType": "ORDER_ITEM", "entityId": "order_item_5566" }, { "service": "cart-service", "entityType": "CART_ITEM", "entityId": "cart_item_3344" }, { "service": "favorite-service", "entityType": "FAVORITE_SKU", "entityId": "favorite_1122" } ] }

4.3 消费者端必须有幂等处理和失败重试

事件消费和网络一样,不可靠,同一个事件可能被重复投递,也可能消费到一半服务就崩了。所以消费者端第一件必须做的事就是幂等处理。我用得最多的方案是维护一张消费记录表,以 eventId 作为唯一键,处理前先查一下这个事件是不是已经消费过,消费过就直接忽略。

第二件必须做的事是失败重试。消费者清理自己库里的引用数据时,可能因为临时锁冲突、表不存在等原因失败,这种失败应该捕获后抛异常,让消息中间件按照配置的重试策略重新投递。连续重试多次仍然失败的消息,转入死信队列并告警,最终由人工介入处理。这里有个坑要注意:重试次数和间隔要设置得合理,太频繁容易雪上加霜,太慢会导致清理动作迟迟完成不了。

5. 递归、墓碑、事件流怎么协同成一条完整链路

5.1 一次规范的实体删除到底走哪几步

三套机制单独拿出来都能解决一部分问题,但真正把孤儿数据消灭干净,靠的是它们协同跑完整个删除流程。我整理一下规范的删除流程,你在设计自己的方案时可以直接参考:

第一步,发起删除请求后,先调用引用发现服务,用递归下钻把该实体的所有直接和间接引用关系全部查出来。第二步,在当前服务里给实体写墓碑标记,把实体置为逻辑删除状态,正常业务查询立即不可见。第三步,把实体信息加上第一步查到的引用清单封装成事件消息,发布到消息中间件的删除主题。第四步,各下游服务的消费者收到事件后,按清单清理自己库里的引用数据,或者把外键字段置空。第五步,延迟回收任务定期扫描超过保留期的墓碑数据,确认在引用注册表里已经查不到残留引用后,执行物理删除。

5.2 流程走一半失败了怎么办,补偿机制怎么设计

流程越长,失败的环节就越多,所以补偿机制必须提前设计好。我的经验是把失败分成两类来定策略。

一类是引用发现失败,也就是递归下钻没跑完。这种情况下绝不能继续做墓碑标记和事件发布,因为引用清单不完整,后面的事件消费就会漏清理。正确做法是直接让删除请求失败,返回提示“删除前置检查未完成”,等引用发现服务恢复后重试。另一类是事件订阅方消费失败,这种问题发生在墓碑标记之后,主数据已经不可见了,下游清理的早晚只影响脏数据残留的时间,不影响核心业务,所以交给重试和死信队列慢慢消化即可。两类失败的共同点是要有可观测性,删除全链路每个环节都要打日志、埋指标,哪一步卡住了要能第一时间发现。

5.3 这个方案有没有场景不需要全上

再好的方案也不是银弹,我不建议所有删除场景都套这三板斧。如果实体的引用范围非常可控,比如只被当前服务自己引用,没有跨服务引用,那直接物理删除完全没问题。如果实体有跨服务引用,但服务总数在两个以内,同步 RPC 通知清理成本也不高,不一定要引入消息中间件。而如果系统里存在大量多级引用、跨团队协作频繁,那递归加墓碑加事件流这套组合就是值得投入的标配。

我自己的判断标准是看两个维度:一个是被删除实体被多少个其他服务引用,另一个是引用关系会不会传到两层以上。只要有一个答案是“是”,就会直接按完整链路来处理。两三个服务的项目里过度设计反而拖慢开发节奏,这一点提醒一下也是必要的。

6. 实操阶段踩过的坑和排查技巧

6.1 递归下钻把引用发现服务查挂了

第一次上线这套方案时,我没对引用发现服务做限流,结果运营同学在后台批量删商品分类,一次性并发了上百个删除请求,每个请求都要递归查引用注册表。注册表服务直接被打满,数据库连接池耗尽,整个后台接口大面积超时。后来我加了三个措施:第一,引用发现接口做单机限流,超过阈值直接返回繁忙;第二,批量删除改成串行处理,每个实体删除前先做引用预检查,检查通过再继续;第三,给引用注册表加了一级本地缓存加远程缓存,热点实体的引用关系几分钟内不会变化,直接命中缓存,数据库压力瞬间降下来。

6.2 事件流乱序导致清理完后引用又复活了

这个坑比较隐蔽。某个订单项的 sku_id 本来被清理置空了,但因为这个订单项随后被更新过,更新逻辑又带上了旧的 sku_id,等清理事件到达时已经把数据覆盖了。这本质上不是清理逻辑的问题,而是消息乱序导致引用数据被写了两次。排查了半天,最后是用一个很朴素的办法解决的:消费端每次清理时先判断当前引用 ID 是否等于事件里的引用 ID,等于才清,不等于就跳过。这样即使事件乱序,也不会误清掉新写入的引用。

6.3 墓碑数据把唯一索引搞炸了

给实体加墓碑字段之后,原来建立在业务唯一键上的唯一索引全废了。比如商品 SKU 的 sku_code 有唯一索引,第一次删除只是打了墓碑标记,物理行还在,如果后面又上架一个同 code 的 SKU,唯一索引直接冲突。解决思路有两个:要么把 deleted_at 拼进索引构成复合唯一索引,要么在查询唯一约束时强制带 deleted_at IS NULL 条件,用部分索引实现“只对未删除数据做唯一约束”。两种方案我都试过,复合索引对现有代码侵入小,但业务代码里所有查重 SQL 都要记得把 deleted_at 条件拼进去。

常见问题典型现象排查思路解决办法
引用注册表数据缺失递归返回的引用清单不全,下游清理漏项对比业务表与注册表记录数在通用 SDK 里加引用关系上报对账日志,定期巡检
事件消费重复清理逻辑执行多次,偶发数据错乱查看消费记录表的事件 ID以 eventId 唯一键做幂等,重复事件直接跳过
墓碑数据膨胀物理表行数持续增长,查询变慢查墓碑占比,看回收任务日志缩短回收周期,或分批加大每次物理删除的批量大小
删除请求超时递归发现引用耗时过长看引用发现服务调用链给递归下钻加整体超时,必要时改为异步预审

6.4 适合直接抄作业的搭建顺序建议

如果你打算在团队里落地这套方案,我建议按这样的顺序推进:先建引用注册表,把各服务的引用关系显式维护起来,这步最基础也最容易被忽略;再引入事件流,把删除通知从 RPC 改为消息发布订阅,这一步能立刻缓解同步调用的压力;然后给核心实体加墓碑标记,先从用户、商品这类被广泛引用的实体开始试点;最后再上递归下钻,把引用发现从“拍脑袋通知”升级成“全量自动扫描”。每一步做完都留出观察时间,确认没有引入新的问题再进行下一步。

我个人的体会是,微服务里的孤儿数据问题不会因为你换一个框架、加一个中间件就消失,它本质上是一个数据治理问题。递归让你看清全局,墓碑给你留出回旋余地,事件流让清理动作自动扩散到每一个角落——这三者缺一个,删除链路都不闭环。希望这篇文章能帮你把这条链路搭起来,少踩几个我已经替你踩过的坑。

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

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

立即咨询