微服务环境下如何避免孤儿数据:结合递归,墓碑标记与事件流

背景

在微服务环境下,很容易遇到的一个问题是,很多的数据都带着级联关系,但是却分散在不同的服务,各自的数据库中。

在单机时代,我们能够通过外键级联以及事务来打包。拆成微服务后,这些操作都不再可用,只能通过异步实现。

但异步清理又会引入更多的问题,比如事件丢了,删除到一半服务挂了,下游重复收到事件,以及在父资源删除的时间窗口内,交错了跨服务的子资源写入。

这些在真实业务场景下处理起来很头疼,在打补丁式地修复了几个级联删除功能后,我发现了一些可以复用的思想,于是整理出一篇文章,希望能对你有帮助。

依赖关系

在处理数据问题之前我们应该先理清不同资源的依赖关系,也就是删除一个父资源的时候到底应该连带删除哪些子资源。

在没有数据库来声明级联关系后,依赖关系很容易散落到各个删除函数里,我们希望数据的依赖关系不依靠散落的删除函数来隐式的表达,而是集中声明成一张关系图,让后续的删除流程能够照图执行。这对于多层级联的清理相当有帮助。

go 复制代码
func DeleteProject(id) {
    DeleteDevices(id)
    DeleteAlerts(id)
    DeleteTemplates(id)
}

对比

go 复制代码
var graph = map[string][]string{
    "project": {"device", "alert"},
    "device":  {"scene"},
}

// 各个服务实现相关资源的接口
type Deleter interface {
    Discover(id string) []Ref
    Remove(id string) error
}

// 递归工具函数...

声明与实现

声明集中到了一起,那么接下来就要考虑实现了,对于一种资源的删除事件,持有不同资源的服务各有各的响应方式,把所有响应的实现都塞进一个公共库里显然是臃肿而且不恰当的,我们将声明和执行框架放到公共层,让它承担递归的任务。

而对于具体的事件响应本身,在每一个服务内单独实现。

意图先行

递归解决了按序删除的问题,这很容易,但不能解决清理执行到一半被中断的问题。这里要引入一个思想,意图先行。

在还没有执行操作的时候,先记录好意图,避免在中断时事务没有记录,无法回滚或者重试,当然这个标记在这里还会有更多用处。

放到这里的删除实现中,我们可以先在真正执行删除前向Redis/或者任何能充当KV cache的软件,添加一个标记,然后再执行真正的删除。

这里需要注意递归时的执行顺序,我们要按照这样的写法

go 复制代码
func remove(kind, id string) error {
    mark(kind, id)                   // ① 进入后打标记
    var err error
    for _, c := range discover(id) {
        if e := remove(c.kind, c.id); e != nil {
            err = e                  // ② 子树失败聚合
        }
    }
    if e := delete(id); err == nil { // ③ 删自己
        err = e
    }
    if err != nil {                  // ④ 无论是本层还是子树失败,都撤回标记
        clearMark(kind, id)          //    失败后的处理要看具体业务;这里的错误继续向上冒泡
        return err
    }
    return nil
}

删除窗口

保证每一个资源删除前,父级资源的标记都已经被创建好,最后的删除操作从子树开始做后序遍历。

在最顶层的标记被创建,再到所有子树删除之间,这里有一个窗口,父资源实际还存活,依然有可能被写入新的子资源。我们依靠写侧回滚来避免,在子资源被写入后,检查对应父资源是否存在删除的标记,如果有,回滚这个写入操作。

这里对于写前检查和写后检查的选择要根据实际业务来确定,可以接受回滚的操作,比如只是单纯的添加记录或更新字段,写后检查在理论上能做到没有写入孤儿数据的可能。但对于不可回滚的操作,需要做写前检查,如果这里的检查和写入不能打包成原子操作(比如都是KV cache),确实需要接受一个理论上存在的极小窗口存在孤儿数据。

但这个业务场景比较刁钻,绝大部分和删除相关的场景都是可以接受回滚的。

跨服务删除

这条递归能够覆盖到当前服务拥有的所有数据,但还没有实现跨服务删除。现在我们给最开始要删的这条记录加上一个事件,当这条记录被删除后,持有这个资源的服务发布一条事件,其它服务监听到这个资源被删除后,会通过统一声明去寻找自己已有的子资源处理函数。

下面用两张图说明注册关系与完整删除流程。

graph TB A((A)) --> B((B)) B --> C((C)) subgraph 服务1 regA1[A 处理函数 打A删除标记 删A主记录 推送A删除事件] end subgraph 服务2 regA2[A 处理函数 监听A删除事件后 Discover B] regB2[B 处理函数 删B主记录后 Discover C] regC2[C 处理函数 删除C] end regA1 -. 持有主记录并注册 .-> A regB2 -. 持有主记录并注册 .-> B regC2 -. 持有主记录并注册 .-> C regA2 -. 不持有A但响应A删除 .-> A
sequenceDiagram participant S1 as 服务1(持有A) participant Stream as 清理事件流 participant S2 as 服务2(持有B,C) S1->>S1: 调 A 处理函数:打删除标记 A S1->>S1: 删 A 主记录 S1->>Stream: 发布 A 被删除的事件 Stream->>S2: 投递 A 被删除的事件 S2->>S2: 调 A 处理函数:Discover → B 实例 S2->>S2: 递归调 B 处理函数:打删除标记 B → Discover → C 实例 S2->>S2: 调 C 处理函数:删除 C S2->>S2: 删 B 主记录

异常处理

那么对于正常情况的流程我们已经走完了,接下来是两种常见的异常情况。

重复消费。事件流都是至少投递一次的,重复消费的情况无法避免,好在大部分删除动作本身是可以重入的。

但如果真的涉及到了不可重入的删除动作,我们也可以用删除标记来当作幂等的锚点,在处理前增加一个检查,查看该实体是否已经存在删除标记,命中后直接跳过。

事件丢失。服务宕机或者事件写入失败导致的事件丢失,这里可以依赖服务启动时的主动对账:在每一个服务启动时,检查相关父资源的删除标记和自身持有资源的父资源 ID 取交集,逐一走正常的删除流程。

对于重复执行的删除流程,同样可以靠上面的幂等机制来保证无害。

这里需要补充说明标记的 TTL,它不宜设得太短:重投间隔与孤儿数据存续的时间虽然都很短,但对于宕机情景,TTL 设计得更长可以保证服务宕机更久仍然可以继续兜底执行事务。

局限

这套方案主要还是聚焦在解决级联删除场景的最终一致性,删除本身是异步的,适用于能接受删除父资源的短窗口内子资源仍然存在的场景。

对于主记录被删除,后续清理失败的场景,不提供回滚的支持,只能通过幂等重试或者对账等方式收敛。

相关推荐
Joolun商城源码_Java3 小时前
Java 商城技术栈怎么选:单体、微服务还是前后端分离多端?
java·开发语言·微服务
小陈不好吃13 小时前
从单体到微服务:Spring Cloud Gateway 动态路由实战与踩坑记录
微服务·云原生·架构
重庆小透明1 天前
Kafka 完全指南:从基础组件到核心原理(包含面试题)
java·分布式·微服务·架构·kafka
lytao1232 天前
单体、微服务、Serverless:架构要匹配问题
微服务·架构·serverless·软件工程
程序员梅雨2 天前
微服务学习最终篇: Elasticsearch
学习·elasticsearch·微服务
「、皓子~2 天前
海狸IM 2.1 私有化部署从零到一:中间件、微服务与 Nginx 全流程
nginx·flutter·微服务·中间件·开源软件·im·海狸im
大牧师2 天前
Nest.js 微服务入门教程
开发语言·javascript·后端·微服务·node.js·nest.js·nest
nxb5562 天前
云原生:kubernetes的service
微服务·云原生·容器·kubernetes
风云3 天前
Vane.Dispatch 1.0.0 发布:一个与容器、传输层零耦合的 .NET 服务分发引擎
微服务·mvc·.net·ndf·vane.dispatch