
先说一个我线上踩过的坑。有一次用户注销账号后用户服务直接把主记录物理删了结果订单服务里还挂着这个用户的 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_servicecurrent[0], target_typecurrent[1], target_idcurrent[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 results2.3 环状引用与递归深度失控怎么防真实业务里引用关系很容易出现环A 引用 BB 又引用 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 改为消息发布订阅这一步能立刻缓解同步调用的压力然后给核心实体加墓碑标记先从用户、商品这类被广泛引用的实体开始试点最后再上递归下钻把引用发现从“拍脑袋通知”升级成“全量自动扫描”。每一步做完都留出观察时间确认没有引入新的问题再进行下一步。我个人的体会是微服务里的孤儿数据问题不会因为你换一个框架、加一个中间件就消失它本质上是一个数据治理问题。递归让你看清全局墓碑给你留出回旋余地事件流让清理动作自动扩散到每一个角落——这三者缺一个删除链路都不闭环。希望这篇文章能帮你把这条链路搭起来少踩几个我已经替你踩过的坑。