这个五一过的比较平淡,在家想了两个 go-storage 的新 RFC,然后看了看 IPFS 的白皮书,顺便开了一些脑洞。这一期的周报我们聊聊 go-storage 新通过的 Proposal: 幂等删除 AOS-46: Idempotent Storager Delete Operation

幂等

首先搞清楚什么叫做幂等,让我们来看一下 MDN

一个HTTP方法是幂等的,指的是同样的请求被执行一次与连续执行多次的效果是一样的,服务器的状态也是一样的。换句话说就是,幂等方法不应该具有副作用(统计用途除外)。在正确实现的条件下, GET,HEAD,PUT 和 DELETE 等方法都是幂等的,而 POST 方法不是。所有的 safe 方法也都是幂等的。

MDN 关于幂等的介绍中还有这样一条:

幂等性只与后端服务器的实际状态有关,而每一次请求接收到的状态码不一定相同。例如,第一次调用 DELETE 方法有可能返回 200,但是后续的请求可能会返回 404。

而本文中所讲的幂等要更严格一些:在没有外部干涉的前提下,每一次成功的操作返回的状态都是一致的。

删除

对于 Delete 操作而言,不同的服务有不同的处理方式,我们这里列举三种服务的 Delete 实现作为参考。

S3 DeleteObject

S3 现在已经完整的实现了 Versioned Object,它的大体逻辑是这样的:

  • PutObject 会生成一个全新的 version id
  • DeleteObject 操作会生成一个 delete marker,并作为最新的 version 插入
  • GetObject 总是会 Get 最新的 version,如果这个 Object 不存在或者是一个 delete marker,则会返回 404 Not Found

这里需要特别解释一下 DeleteObject 的行为:

  • 出于兼容性考虑,在开启版本管理功能之前的 Object 都会作为一个特殊的 null version 存在,此时 DeleteObject 会删除这个 null version
  • 但是不管这个 null version 是否存在,DeleteObject 都会返回删除成功的状态码,即: 204 No Content

由于 go-storage 目前还没有提供 version 支持的计划,所以 DeleteObject with versionId 的行为暂时不做探讨

总结一下就是,S3 的 DeleteObject 是幂等的,重复删除同一个 Path 总是会得到一致的状态。

Azblob DeleteBlob

Azblob 除了实现了 Versioned Object 之外,还实现了软删除(Soft Delete):

  • azblob 的 Delete 都是标记删除,索引中不存在,数据在 GC 过程中才会彻底删除
  • 用户在启用软删除之后,blob 并不会彻底删除
  • 用户通过配置 DeleteRetentionPolicy 来决定被软删除的 blob 保留多久
  • blob 允许通过 UndeleteBlob 接口来恢复

此外,Azblob 还有彻底删除功能:

  • 在启用彻底删除之后,文件允许通过指定 deletetype=permanent 来彻底一个被 (soft) delete 的 snapshot 或者 version
  • 注意,这个功能与软删除是正交的

但无论用户是否启用了软删除 / 彻底删除,Azblob 的 DeleteBlob 返回的状态是一致的:

  • 如果 blob 之前存在,则返回 202 Accepted,表示这个删除请求已经接收到了
  • 如果 blob 不存在,则返回 404 Not Found,表示这个资源不存在

也就是说,Azblob 的 DeleteObject 实现不是幂等的。

File System

本地文件系统的 Delete 操作也不是幂等的。

以 Linux 平台为例,用于删除文件的 syscall unlink 会在文件不存在时,会返回错误 ENOENT 表示文件不存在:

> strace unlink x
execve("/usr/bin/unlink", ["unlink", "x"], 0x7ffed2e39128 /* 51 vars */) = 0
...
unlink("x")                             = -1 ENOENT (No such file or directory)
...
write(2, "unlink: ", 8unlink: )                 = 8
write(2, "cannot unlink 'x'", 17cannot unlink 'x')       = 17
...
write(2, ": No such file or directory", 27: No such file or directory) = 27
...
+++ exited with 1 +++

我们常用的 rm 命令通常会在调用 unlink 之前,调用 stat 来检查文件的状态,如果文件不存在则会结束删除流程:

> rm x
rm: cannot remove 'x': No such file or directory

通过 strace 我们能看到 rm 首先调用了一次 newfstatat,在它返回错误后就直接走错误处理流程了:

> strace rm x
execve("/usr/bin/rm", ["rm", "x"], 0x7ffca67862f8 /* 51 vars */) = 0
...
newfstatat(AT_FDCWD, "x", 0x5632bebcf778, AT_SYMLINK_NOFOLLOW) = -1 ENOENT (No such file or directory)
...
write(2, "rm: ", 4rm: )                     = 4
write(2, "cannot remove 'x'", 17cannot remove 'x')       = 17
...
write(2, ": No such file or directory", 27: No such file or directory) = 27
...
+++ exited with 1 +++

Delete in go-storage

好,我们前面已经看了三种服务的实现,接下来我们看看 go-storage 中的 Delete 操作。

AOS-25: Object Mode 中,我们引入了 Object Mode 的概念,将用户能对 Object 执行的操作进行了正交分解:

const (
	// ModeDir means this Object represents a dir which can be used to list with dir mode.
	ModeDir ObjectMode = 1 << iota
	// ModeRead means this Object can be used to read content.
	ModeRead
	// ModeLink means this Object is a link which targets to another Object.
	ModeLink
	// ModePart means this Object is a Multipart Object which can be used for multipart operations.
	ModePart
	// ModeBlock means this Object is a Block Object which can be used for block operations.
	ModeBlock
	// ModePage means this Object is a Page Object which can be used for random write with offset.
	ModePage
	// ModeAppend means this Object is a Append Object which can be used for append.
	ModeAppend
)

所以我们拆分出 AppenderPagerMultiparter 这样不同形式的 Object 写入接口,但是他们都共享同样的 Stat / Read / Delete 实现。也就是说,根据 Service 自身支持的情况不同,他们的 Delete 实现也有很大的差异,我们以 qingstorfsdropbox 来举例说明。

Delete in go-service-qingstor

qingstor 服务支持 MultiparterAppender

其中 Multiparter 对应 qingstor 的分段上传接口,用户需要调用 AbortMultipartUpload(path, uploadId) 来删除一个分段上传,在分段上传完成前,这个 Object 是不可读的。所以 Delete PartObject 需要传入 multipart_id

Appender 对应 qingstor 的 AppendObject 接口,一旦 Append 成功,这个 Object 就是可读的。所以 Delete AppendObject 不需要额外的参数。

Delete in go-service-fs

我们知道文件系统本身就支持追加写和随机写操作,所以 fs 底层并没有做额外的处理,无论删除什么 Mode 的 Object 都是调用 os.Remove

Delete in go-service-dropbox

dropbox 支持 Appender 的方式是返回一个临时的 upload-session-id,只有持有这个 id 才能上传,在显式的调用 close 之后才可读。此外没有任何方式能再次获取到这个 id,也无法取消这次上传(48 小时后会自动过期,相关的数据也会被清除)。所以对 Dropbox 来说,它的 Delete 操作只能删除一个常规的文件,无法删除一个 AppendObject。

幂等删除

前面我们花了一些时间探讨了 AOS-46 的背景,接下来可以进入正题了。既然 go-storage 立志要做一个存储抽象层,那就需要屏蔽掉底层实现的细节,我们需要明确的告诉调用者在使用 Delete 的时候会发生什么以及不会发生什么。

摆在我们面前的有两种选择:

  • 将删除操作定义为 幂等操作
  • 将删除操作定义为 删除已存在的对象

幂等操作 意味着只要不出现其他报错,无论这次 Delete 操作是否真的删除了数据,无论被 Delete 的对象是否存在,go-storage 总是返回成功。也就是说,go-storage 会忽略所有在 Delete 期间出现的形如 ObjectNotExist 的报错。

而定义为 删除已存在的对象 意味着所有服务都必须确保被删除的对象真的存在。

  • 对删除是幂等的 s3 服务来说,它需要先 HeadObject 来确认对象存在
  • 对不是幂等的其他服务来说,它可以直接返回来自底层的错误

在我看来 删除已存在的对象 这样的定义有这些的问题:

  • 不公平:正如 AOS-7 中提到的那样,采用 删除已存在的对象 的定义使得不在乎文件是否真的存在的用户也需要承担额外的 Stat 开销
  • 不完备:Stat & Delete 并不是一个原子操作,作为一个服务的外部调用者,我们实现不了这样的定义

而采纳 幂等操作 的定义则不存在这样的问题:

  • 关心文件是否存在的用户可以自行调用 Stat 来检查
  • go-storage 也能封装形如 CheckedDelete(path string) (deleted bool, err error) 这样的操作来满足用户的需求

所以我们最终采用了 幂等删除 的方案。

相关的实现已经在 Issue Implement AOS-46: Idempotent Storager Delete Operation 中展开,感兴趣的同学欢迎加入 #go-storage:aos.dev 来沟通~