背景
在微服务环境下,很容易遇到的一个问题是,很多的数据都带着级联关系,但是却分散在不同的服务,各自的数据库中。
在单机时代,我们能够通过外键级联以及事务来打包。拆成微服务后,这些操作都不再可用,只能通过异步实现。
但异步清理又会引入更多的问题,比如事件丢了,删除到一半服务挂了,下游重复收到事件,以及在父资源删除的时间窗口内,交错了跨服务的子资源写入。
这些在真实业务场景下处理起来很头疼,在打补丁式地修复了几个级联删除功能后,我发现了一些可以复用的思想,于是整理出一篇文章,希望能对你有帮助。
依赖关系
在处理数据问题之前我们应该先理清不同资源的依赖关系,也就是删除一个父资源的时候到底应该连带删除哪些子资源。
在没有数据库来声明级联关系后,依赖关系很容易散落到各个删除函数里,我们希望数据的依赖关系不依靠散落的删除函数来隐式的表达,而是集中声明成一张关系图,让后续的删除流程能够照图执行。这对于多层级联的清理相当有帮助。
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),确实需要接受一个理论上存在的极小窗口存在孤儿数据。
但这个业务场景比较刁钻,绝大部分和删除相关的场景都是可以接受回滚的。
跨服务删除
这条递归能够覆盖到当前服务拥有的所有数据,但还没有实现跨服务删除。现在我们给最开始要删的这条记录加上一个事件,当这条记录被删除后,持有这个资源的服务发布一条事件,其它服务监听到这个资源被删除后,会通过统一声明去寻找自己已有的子资源处理函数。
下面用两张图说明注册关系与完整删除流程。
异常处理
那么对于正常情况的流程我们已经走完了,接下来是两种常见的异常情况。
重复消费。事件流都是至少投递一次的,重复消费的情况无法避免,好在大部分删除动作本身是可以重入的。
但如果真的涉及到了不可重入的删除动作,我们也可以用删除标记来当作幂等的锚点,在处理前增加一个检查,查看该实体是否已经存在删除标记,命中后直接跳过。
事件丢失。服务宕机或者事件写入失败导致的事件丢失,这里可以依赖服务启动时的主动对账:在每一个服务启动时,检查相关父资源的删除标记和自身持有资源的父资源 ID 取交集,逐一走正常的删除流程。
对于重复执行的删除流程,同样可以靠上面的幂等机制来保证无害。
这里需要补充说明标记的 TTL,它不宜设得太短:重投间隔与孤儿数据存续的时间虽然都很短,但对于宕机情景,TTL 设计得更长可以保证服务宕机更久仍然可以继续兜底执行事务。
局限
这套方案主要还是聚焦在解决级联删除场景的最终一致性,删除本身是异步的,适用于能接受删除父资源的短窗口内子资源仍然存在的场景。
对于主记录被删除,后续清理失败的场景,不提供回滚的支持,只能通过幂等重试或者对账等方式收敛。