CockroachDB 本身可以认为是一个无限大的有序 map,他本身没有采用一致性哈希的方式,因为这种 scaling 的方式对 key 的 scan 不友好,所以设计了一个类似操作系统页表的分层索引的结构。
keyspace 的组织结构
逻辑上,这张表包含了一些保留的系统 key/value 对,保存在真正的用户数据之前,并且由 SQL 子系统管理。\x02: 以 \x03结尾的 metadata range. meta1 key.
...
\x02: 以 \x03结尾的 metadata range. meta1 key.
\x03: 以 结尾的 range metadata. meta2 key.
...
\x03: 以 结尾的 range metadata. meta2 key.
\x04{desc,node,range,store}-idegen: ID generation oracles for various component types.
\x04status-node-: store 的 metadata.
\x04tsd: 时序数据的 key
: 用户的 key,实际上是由 SQL 子系统管理的,不纯粹是用户插入的数据,比如一些表的数据,事务的数据也是 SQL 子系统在这段范围内管理的。
整体结构如下图
注意:我们把 range 的最后一个key作为range的索引,这是因为RocksDB迭代器仅支持Seek()接口,其功能类似Ceil()。使用range开始的Key将会使Seek()函数要找两次才能找到key所属于的 range,而用结尾做划分的话,直接找到一个比自己大的key作结尾的 range 就能找到所属的 range 了。
range 的元数据
一个 range 默认大小接近 64M (2\^26 B),要支持 1PB(2^50 B) 的逻辑数据,大概需要 2^(50 - 26) = 2^24 个range 的元数据。一个元数据的最大合理上限大约是 256B(其中3*12字节保存三元组节点位置信息,余下220字节保存range本身的key)。 2^24 range *(2^8 B)大概需要4G (2^32 B)字节存储,这太大而不能在机器间进行复制。我们的结论是,对于大型集群部署,range 的元数据必须是分布式的。
面对分布的元数据,为了保持 key 检索的相对高效,我们把所有顶层的元数据保存在单一 range 里(第一个range)。这些顶层元数据的 key 被称为 meta1 key,并加上前缀以使得它们排序时在 key 空间的起始位置。前述给定一个元数据大小是256字节,一个64M的range可以保存 64M/256B=2^18 个range元数据,总共可以提供64M *(2^18) =16T的存储空间。为了提供上述1P的存储空间,我们需要两层寻址,第一层用来定位第二层的地址,第二层用来保存用户数据。采用两层寻址,我们可以寻址 2^(18 + 18) = 2^36 个range;每个 range 寻址2^26 B ,则总共可寻址 2^(36+26) B = 2^62 B = 4E 的用户数据。
对于一个给定的用户地址 key1 ,其对应的 meta1 记录位于meta1空间中**key1**的后驱 key 中。因为meta1空间是稀疏的,后驱key被定义为下一个存在的key。meta1记录标识了包含*meta2*记录的range,查找方式相同。meta2记录标识了包含**key1**的range,查找过程与前面一样也采用了相同方法(参见下面的例子)。
key的元数据被增加了前缀:\x02 (meta1) 、\x03 (meta2);前缀 \x02 和 \x03是为了得到期望的排序结果。这样,key1的meta1记录就被保存在 \x02的后驱key中。
下面的例子展示了有三个range的map目录结构。省略号表示补充的填满数据整个range的key/value对。为了清晰,例子使用meta1和meta2来指代前缀\x02和\x03。除了有切分 range 时需要更新range的元数据,需要知道元数据的分布信息,range元数据本身不需要特殊对待或者自举。
Range 0 (冗余在 dcrama1:8000, dcrama2:8000,
dcrama3:8000)meta1\xff: dcrama1:8000, dcrama2:8000, dcrama3:8000
meta2: dcrama1:8000, dcrama2:8000, dcrama3:8000
meta2: dcrama4:8000, dcrama5:8000, dcrama6:8000
meta2\xff: dcrama7:8000, dcrama8:8000, dcrama9:8000
...
:
Range 1 (冗余在 dcrama4:8000, dcrama5:8000,
dcrama6:8000)...
:
Range 2 (冗余在 dcrama7:8000, dcrama8:8000,
dcrama9:8000)...
:
如果数据量比较小的话,元数据和数据都会凑在一个 range 里面,对应如下表所示。
Range 0 (located on servers dcrama1:8000, dcrama2:8000,
dcrama3:8000)*meta1\xff: dcrama1:8000, dcrama2:8000, dcrama3:8000
meta2\xff: dcrama1:8000, dcrama2:8000, dcrama3:8000
:
...
以上都是复用了 range0 的例子,在 range0 需要存的索引比较小的时候会存储一些二级索引和真实数据。
下面这个例子是在数据量比较大的时候,range 指代了被用于冗余该range的节点的集合。
Range 0meta1: Range 0
meta1\xff: Range 1
meta2: Range 1
meta2: Range 2
meta2: Range 3
...
meta2: Range 262143
Range 1meta2: Range 262144
meta2: Range 262145
...
meta2\xff: Range 500,000
...
:
Range 2...
:
Range 3...
:
Range 262144...
:
Range 262145...
:
注意:选择range262144只是一个近似值。通过单一元数据range可寻址的range的实际数量依赖于key的大小。如果努力保持key的尺寸越小,则可寻址的range越多,反之亦然。
如下图所示
从上面的例子可以清楚的看到,至多3次key寻址就可获取 对应的值:找到一个比 meta1大的meta1,获取对应的 range
找到一个比 meta2大的meta2,获取对应的 range
从 range 中找到,获取 value
对于小 Map,可以在 Range 0 上一次 RPC 调用内完成所有检索。包含 16T 以下的 Map 需要 2 次检索。客户端缓存 range 元数据的各层,我们期望客户端各自都具有很高的数据局部性。如果在一次检索中,cache 中的索引没有命中,就要重新进行一次检索并且更新缓存。