一般的强一致性存储分为 replicated log 和 db storage 两层。replicated log 用于日志的复制,通过一致性协议(如 rDSN)进行组间同步,日志同步完成后,数据方可写入 db storage。通常来讲,在数据写入 db storage 之后,与其相对应的那一条日志即可被删除。因为 db storage 具备持久性,既然 db storage 中已经存有一份数据,在日志中就不需要再留一份。为了避免日志占用空间过大,我们需要定期删除日志,这一过程被称为 log compaction。
这个简单的过程在 pegasus 中,问题稍微复杂了一些。
首先 pegasus 在使用 rocksdb 时,关闭了其 write-ahead-log,这样写操作就只会直接落到不具备持久性的 memtable。显然,当数据尚未从 memtable 落至 sstable 时,日志是不可随便清理的。因此,pegasus 在 rocksdb 内部维护了一个 last_flushed_decree,当数据从 memtable 写落至 sstable 时,它就会更新,表示从〔0, last_flushed_decree〕之间的日志都可以被清除。
有经验的读者应当都知道,当日志文件分段存储时,log compaction 就是简单地将日志文件删除。举个例子:
要删除 [0, 100] 的日志,有 [0, 40)[40, 80) [80, 120) 这三段日志文件。这时我们只需要删除前两段文件 [0, 40)[40, 80) 即可。
故事到了这里还要再加一层复杂性:有一些日志只是心跳(WRITE_EMPTY),它们不含有任何数据。设想一下,如果一个 server 长时间没有写请求,那么它就会不断积累心跳日志。而假设心跳日志不写入 rocksdb,它们不会触发 last_flushed_decree 的更新,也就都不会被清除(如下图)。
= means cleanable
- means uncleanable
last_flushed_decree
|
| heartbeats (not compacted)
-> ===================== | ------------------------------>
但这通常不是什么问题,因为积累的心跳日志从存储量级上并不会造成困扰,即使长时间不删,应当也没有关系。真正让我们需要 将心跳写入 rocksdb 的理由,下面来阐述。
每个 pegasus 的 replica server 上都有许多 rocksdb 实例,每个 rocksdb 维护一个 last_flushed_decree。所有的实例都会写入同一个日志,这被称为 shared log。每个实例自己会单独写一个 WAL,被称为 private log。复杂点在 shared log。
<r:1 d:1> 表示 replica id 为 1 的实例所写入的 decree = 1 的日志
0 1 2 3 4 5
<r:1 d:1> <r:2 d:1> <r:2 d:2> <r:2 d:3> <r:2 d:4> <r:2 d:5>
可以看到,r1 写入 1 条日志后,r2 不断地写入。假设 r2 的 last_flushed_decree = 5,那么当前 shared_log 应当将日志序从 [0, 5] 的日志全部删掉,即删掉从 <r:1 d:1> 到 <r:2 d:5>。
这时候问题来了:如果 <r:1 d:1> 是一个心跳请求,且不写 rocksdb 的话,那就意味着 r1 的 last_flushed_decree = 0,也就意味着 <r:1 d:1> 不可被删。这就给我们带来了困扰,因为日志只能 “前缀删除”,即只能删除 [0, 5],不能删除 [1, 5]。所以
- 我们等待 r1 接收写请求,以更新其 last_flushed_decree;
- 我们就在每次心跳时,将其写入 rocksdb,这样就能及时更新 last_flushed_decree。方法 1 的长期等待会导致 shared log 过大。所以选择方法 2。