ScyllaDB SSTable 2.x 文件格式深度解析:数据文件、压缩、摘要与语义解释
【免费下载链接】scylladbNoSQL data store using the Seastar framework, compatible with Apache Cassandra and Amazon DynamoDB项目地址: https://gitcode.com/GitHub_Trending/sc/scylladb
SSTable(Sorted Strings Table)是 ScyllaDB 与 Apache Cassandra 共用的持久化文件格式,以有序、不可变的磁盘文件集合保存数据。本文以 ScyllaDB 官方架构文档中 SSTable 2.x 章节为骨架,完整讲解 2.x 数据文件的字节级格式、分块压缩原理、Summary 摘要文件结构,并结合 ScyllaDB 源码(sstables/目录)佐证实现细节,帮助你理解 SSTable 如何在磁盘上布局、如何被随机访问,以及行、聚类键、静态列、集合、TTL 与墓碑等高级概念如何在其中编码。
SSTable 是什么
SSTable 是 ScyllaDB 与 Apache Cassandra 使用的持久化文件格式,保存为磁盘上一组持久、有序、不可变的文件。不可变意味着 SSTable 一旦生成就永不被修改:它由 MemTable 刷新(flush)创建,由压缩(compaction)删除。ScyllaDB SSTable 的位置由scylla.yaml中的data_file_directories参数指定(默认位置为/var/lib/scylla/data)。
作为对比,SSTable 3.x 比 2.x 更高效、占用更少磁盘空间。2.x 章节主要面向理解历史格式与兼容性,3.x 章节(docs/architecture/sstable/sstable3/index.rst)介绍其更紧凑的布局。本节内容源自 docs/architecture/sstable/_common/sstable_what_is.rst。
SSTable 版本支持
ScyllaDB 对不同 SSTable 版本的支持情况如下表:
| SSTable 版本 | ScyllaDB 版本 |
|---|---|
| 3.x('mt') | 2026.2 及以上 |
| 3.x('ms') | 2025.4 及以上 |
3.x(me) | 2022.2 及以上 |
3.x(md) | 2021.1 |
要点说明:
- 当前支持的写入格式为
me和mt。 md格式仅在从使用md的既有集群升级时使用;若将sstable_format参数设置为md,该参数会被忽略。ms格式已被mt取代,仅在从使用ms的既有集群升级时使用;若将sstable_format设置为ms,实际写入的将是mt文件。- 重要:
sstable_format参数指定的是写入时使用的 SSTable 格式。而旧版格式(ka、la、mc)仍然支持读取,这对于从既有备份恢复集群至关重要。
上述版本枚举在源码中有直接对应:sstables/version.hh中定义了enum class sstable_version_types { ka, la, mc, md, me, ms, mt }与enum class sstable_format_types { big },其中ka、la、mc属于 legacy 版本列表,md、me、ms、mt属于当前版本列表,从源码结构可以印证"旧格式只读、新格式可写"的版本策略。
ms 格式:基于 Trie 的 SSTable 索引
ms格式引入了基于trie(前缀树)的 SSTable 索引。与传统的两级索引(Index + Summary)相比,trie 索引可以更紧凑地表示 key 前缀,并支持更高效的前缀查询。其详细格式参见 docs/architecture/sstable/sstable3/sstable-ms-index.rst。
ScyllaDB 中的 SSTable 格式:与 Cassandra 2.1.8 的兼容性
ScyllaDB 支持与 Apache Cassandra 2.1.8 相同的 SSTable 格式,这意味着你可以直接把 Cassandra 数据目录中的 SSTable 放入 ScyllaDB 数据目录,它可以直接工作(见 docs/architecture/sstable/sstable2/sstable-format.rst)。
不过细看会发现,ScyllaDB 维护的 SSTable数量更多、单个更小:在 ScyllaDB 中,每个 CPU 核(shard)管理自己的一份 SSTable 子集。这种内部 sharding 使每个核可以更高效地独立工作,避免了多个核竞争同一份数据带来的复杂性与延迟。
SSTable 数据文件格式
数据文件包含数据库中存储的实际数据,本质上是一长串行(row),每行记录其 key 与各列(column)。本节内容源自 docs/architecture/sstable/sstable2/sstable-data-file.rst。
数据文件本身无法高效地按 key 查找某一行,为此存在SSTable Index File与Summary File;此外还有Bloom Filter File用于快速判断某个 key 是否存在于该 SSTable(一张 Cassandra 表会增量地写入多个 SSTable)。
数据文件可按 SSTable 压缩 一节所述进行压缩。压缩层对未压缩数据提供类似普通文件的随机访问,因此在讨论格式时可以假定数据文件未压缩。
数据文件整体结构
数据文件不过是一长串行的序列:
struct data_file { struct row[]; };代码通常借助索引文件直接跳到目标 key 所在行的位置,因此需要一个能高效读取整行的 API;同时也可能需要(例如 compaction 场景)一个能高效连续迭代各行、避免重复读取相同磁盘块的 API。
行的结构
参考实现:SSTableIdentityIterator.java构造函数、DeletionTime.java。
每行以一个头部开始,头部包含行的key、行是否(以及何时)被删除的信息,随后是一系列cells(列名与值)或其他类型的atoms(见下文)。一行中最后一个 atom 是特殊的行结束标记。
struct row { be16 key_length; char key[key_length]; struct deletion_time deletion_time; struct atom atoms[]; };注意行的定义不包含自身长度——读取器增量读取行,直到遇到行结束 atom。若想先把整行读入内存再解析,则可以利用 Index File 推算长度:如果是从某个索引项到达该行,那么下一个索引项会指向本行结束后的下一个字节。
如果通过索引到达某行,可能已经确认 key 正确,此时可以直接跳过行首的 key 而不必反序列化它。
deletion_time结构决定这是否是一个行墓碑(row tombstone)——即整行是否被删除,以及删除时间:
struct deletion_time { be32 local_deletion_time; be64 marked_for_delete_at; };特殊值LIVE = (MAX_BE32, MIN_BE64),即字节序列7F FF FF 80 00 00 00 00 00 00 00,是存活(未删除)行的默认值。marked_for_delete_at是数据应被视为已删除的时间戳(通常为自 UNIX 纪元起的微秒数);若为MIN_BE64则表示从未标记删除。local_deletion_time是创建该墓碑时的本地服务器时间戳(自 UNIX 纪元起的秒),仅用于在gc_grace_seconds过去后清除墓碑。
Atoms:cell、行结束标记及其他
参考实现:OnDiskAtom.java::deserializeFromSSTable()、ColumnSerializer::deserializeColumnBody()、RangeTombstone::deserializeBody()。
一行的值是一个atoms列表,其中每个 atom 通常是一个 cell(列名加值)或行结束 atom,但也可能是下文介绍的其他类型。
任何类型的 atom 都以列名开头——一个带 16 位长度的字节串。如果名字长度为 0(即 atom 以两个 null 字节开头),这就是行结束 atom,因为其他 atom 类型的名字总是非空的。注意:列名在每一行中都会重复出现。压缩层消除了大量磁盘空间浪费,但解析这种冗长编码的开销仍然存在。
struct atom { be16 column_name_length; char column_name[column_name_length]; }如果 atom 名字非空,则它不是行结束标记,列名之后紧跟一个单字节mask:
enum mask { DELETION_MASK = 0x01, EXPIRATION_MASK = 0x02, COUNTER_MASK = 0x04, COUNTER_UPDATE_MASK = 0x08, RANGE_TOMBSTONE_MASK = 0x10, };struct nonempty_atom : atom { char mask; }mask 决定 atom 的类型:
- 若
mask & (RANGE_TOMBSTONE_MASK | COUNTER_MASK | EXPIRATION_MASK) == 0,则是普通 cell:带 64 位时间戳(用于判断同一 cell 哪个值最新)与一个以 32 位长度前缀序列化的值:
struct cell_atom : nonempty_atom { be64 timestamp; be32 value_length; char value[value_length]; };(注意COUNTER_UPDATE_MASK与DELETION_MASK也可能在 cell_atom 上置位,从而修改其含义。)
- 若
mask & RANGE_TOMBSTONE_MASK,则是范围墓碑 atom:
struct range_tombstone_atom : nonempty_atom { u16 last_column_length; char last_column_name[last_column_length]; struct deletion_time dt; };该 atom 影响的不仅是column_name这一列,而是column_name到last_column_name之间的范围(范围按底层列名比较器定义)。
- 若
mask & COUNTER_MASK,则是计数器 cell:
struct counter_cell_atom : nonempty_atom { be64 timestamp_of_last_delete; be64 timestamp; be32 value_length; char value[value_length]; };- 若
mask & EXPIRATION_MASK,则是带过期时间的 cell:
struct expiring_cell_atom : nonempty_atom { be32 ttl; be32 expiration; be64 timestamp; be32 value_length; char value[value_length]; };注意:同一 atom 上不能同时置位多个RANGE_TOMBSTONE_MASK、COUNTER_MASK或EXPIRATION_MASK。
名字与值的序列化
参考实现:Composite.java、CompositeType.java。
需要记住:上文所述的列名和值都以字节串形式存储(前面带 16 位或 32 位长度)。但在 Cassandra 中,名字和值可能有各种类型(由 CQL schema 决定),这些类型在作为 atom 的一部分写入磁盘前,需要先序列化为字节串。
这对数据文件中列名的编码产生了显著影响。从 Apache Cassandra 1.2 起,除非表以 "WITH compact storage" 创建,列名总是composite(复合的),即一组组件的序列。复合列名序列化如下:
struct serialized_composite_name { struct { be16 component_length; char[] component; // length = component_length char end_of_component; // 通常为 0,可为 -1 (0xff) 或 1 (0x01) } component[]; }end_of_component通常为 0,但也可以是 -1 或 1,用于表示范围而非具体列(详见Composite.java与CompositeType.java的注释)。
因此出现一个"惊人"的结果:即使是单组件列名也会产生浪费的双重序列化(除非表是 WITH compact storage)。例如列名 "age"(只有一个组件的复合名),先序列化为\0 \3 a g e \0,再把这个序列化串作为列名、前面加上其长度 6 写入:\0 \6 \0 \3 a g e \0。
这意味着读取 SSTable 时必须知道列名是否是复合的——因此 SSTable 读取器必须知道该表是否声明了 "WITH compact storage"。
CQL Row Marker
在某些情况下(即通过 CQL 创建、且未使用 "WITH compact storage" 的表),每一行会包含一个奇怪的额外 cell,称为CQL Row Marker。Cassandra 开发者添加它是为了让行在所有列都被删除后依然存在。了解这个额外 cell 的存在是有价值的,因为它的存在可能让不了解的人感到意外。
CQL Row Marker 是行中一个普通的 cell,但具有"空复合名"和空值。注意它的列名并非空(空名是行结束标记),而是一个含单个空字符串组件的复合名。如上所述,这样的复合名序列化为\0 \0 \0——前两个 null 字节是空组件的长度,末尾还有一个序列化时附加的 null。这"三个 null 字节"就作为该 cell 的列名。
SSTable 压缩:数据文件的分块压缩
SSTable 压缩允许对数据文件(SSTable 中最大的部分;其他部分如索引无法压缩)进行可选压缩。本节内容源自 docs/architecture/sstable/sstable2/sstable-compression.rst。
因为对数据文件的随机访问很重要,Cassandra 实现了chunked(分块)压缩:未压缩文件被划分为固定大小的块(通常 64 KB),每个块单独压缩后写入压缩数据文件,块后紧跟该压缩块的 4 字节校验和。由于各压缩块长度不同,必须记录它们的偏移量才能定位到包含目标未压缩数据的任意块。这个偏移量列表存储在一个单独的Compression Info File中,如下所述。
在 ScyllaDB 源码中,压缩参数与元数据处理位于 sstables/compress.cc 与 sstables/compress.hh:compression_parameters::CHUNK_LENGTH_KB = "chunk_length_in_kb"对应压缩块大小参数,compress.hh头部注释明确说明 "crc_check_chance"(默认 1.0)决定读取压缩块时校验校验和的概率;compress.cc中还通过cm->options.elements.push_back({{"crc_check_chance"}, {"1.0"}})在未显式配置时写入默认值 1.0,印证了文档中"默认 1.0"的说法。
Compression Info File(压缩信息文件)
参考实现:CompressedRandomAccessReader.java、CompressionMetadata.java、CompressionParameters.java。
Compression Info File 仅在数据文件被压缩时存在。它指定了解压器所需的压缩参数(如压缩算法与块大小),以及压缩文件中各压缩块的偏移量列表:
struct short_string { be16 length; char value[length]; };struct option { short_string key; short_string value; };struct compression_info { short_string compressor_name; be32 options_count; option options[options_count]; be32 chunk_length; be64 data_length; be32 chunk_count; be64 chunk_offsets[chunk_count]; };- compressor_name可以是 Cassandra 支持的三类压缩器字符串之一:
"LZ4Compressor"、"SnappyCompressor"或"DeflateCompressor"。Cassandra 默认使用LZ4Compressor,用户可在三者中任选。在 ScyllaDB 源码 sstables/compressor.cc 中可以看到同样的算法到名字的映射:case algorithm::lz4: return "LZ4Compressor";、case algorithm::deflate: return "DeflateCompressor";、case algorithm::snappy: return "SnappyCompressor";;此外源码注释还提到为兼容性保留了 Cassandra 的完整类名(org.apache.cassandra.io.compress.LZ4Compressor),并说明 Cassandra 的 LZ4 压缩器会在块前附加其未压缩长度(见compressor.cc中相关注释)。 - options可能包含解压器需要的附加选项,但通常没有选项;如果存在,通常只有一项:
"crc_check_chance",其值为浮点字符串,默认(若未出现)为"1.0",决定读取某个压缩块时校验其校验和的概率。 - chunk_length是原始未压缩数据被划分的块长度。解压器需要知道这个块大小:给定未压缩文件中的目标字节偏移,就能确定需要解压哪个块。块长度默认为 65536 字节,但可以是任意 2 的幂。
- data_length是未压缩数据的总长度。
- chunk_offsets是压缩文件内各压缩块的偏移量列表。要读取未压缩文件中位置
p的数据,p / chunk_length即未压缩块编号;该块对应的压缩版本从chunk_offsets[p / chunk_length]开始。压缩块在下一个块开始位置前 4 个字节处结束(因为如前所述,每个压缩块后跟 4 字节校验和)。
压缩数据文件
如前所述,压缩数据文件是一系列压缩块的序列,每个块是固定大小(来自 Compression Info File 的chunk_length)未压缩块的压缩版本。每个压缩块后紧跟其压缩后数据的 be32(大端 4 字节整数)Adler32 校验和,可用于验证数据未被损坏。
SSTable 解释:从磁盘字节到 mutation_partition
SSTable 数据文件包含行数据。本节讨论如何在 ScyllaDB 上下文中解释 SSTable Data File 中描述的各种字段,并将其转换为 ScyllaDB 的原生数据结构:mutation_partition。本节内容源自 docs/architecture/sstable/sstable2/sstable-interpretation.rst。
SSTable 行其实是"变异"(mutation)
SSTable 中的每一行并不一定是一个完整的数据行。准确地说,它只是一个mutation——一组被修改(添加或删除)的列及其新值(删除的列对应 "tombstone"),每个修改带一个时间戳(用于调和冲突的 mutations)。请求所需的完整数据行可能由多个 SSTable 中的数据组合而成。
如后文聚类列部分所述,从 SSTable 某行读出的内容最恰当的称呼不是"row",而是partition。因此 ScyllaDB 内部将读自 SSTable 的行表示为class mutation_partition。
列名:从完整名字到列 ID
如 Data File 文档所述,SSTable 行(mutation partition)是一组cells(列值),每个 cell 前是完整列名。这在 Cassandra 设计支持"多列、任意列"时是合理的,但 ScyllaDB 更面向 CQL 用例——schema 是已知的。因此 ScyllaDB 的行并不存储完整列名,而是存储指向 schema 已知列列表的数字 ID。从 SSTable 读到列名形式的 ID 后,需要查 schema 把 ID 翻译回名字。
复合列名
但 SSTable cell 中提到的列名通常不是 schema 中的字段名,还需要进一步处理。
第一个问题是:从 Apache Cassandra 1.2 起(且除非使用WITH COMPACT STORAGE),列名不是普通字符串,而是composite名——一个简短的组件列表(其磁盘编码见 Data File 文档,为便于说明,下文用(part1:part2:...)表示复合名)。
在没有聚类键时最简:cell 名只有一个组件。例如:
CREATE TABLE harels ( name text, age int, PRIMARY KEY (name) ); INSERT INTO harels (name, age) VALUES ('nadav', 40);该表有一行 key 为 "nadav",行内有一个 cell,列名 "age" 编码为单组件复合串(age)(磁盘上为\003 a g e \0)。这唯一组件 "age" 可在表的 schema 中查到,并如前所述转换为mutation_partition中的列 ID。
CQL Row Marker
如 Data File 文档所述,Cassandra 总会(COMPACT 表及其他少数例外除外)在每行中添加一个名为空、值为空的伪 "cell",以解决"唯一列被删除后如何找到该行"等问题。可参见UpdateStatement.java中的注释以及 CASSANDRA-4361。
例如用tools/bin/sstable2json检查上例的表,会看到:
{"key": "nadav", "cells": [["","",1426688662900463], ["age","40",1426688662900463]]}第一个名字为空("")、值也为空(第二个 "")的 cell 就是 "CQL Row Marker"。与往常一样,sstable2json 显示的空名 "" 实际不是空字符串,而是含一个空组件的复合名()(磁盘上序列化为'\000 \000 \000')。ScyllaDB 希望直接忽略这些 CQL Row Marker cell,不在内部格式中重复它们,只需另辟蹊径让"只有 key、没有数据列"的空行得以存在,从而绕开 CASSANDRA-4361 与UpdateStatement.java注释中提到的问题。
聚类键(Clustering Keys)
当表有聚类键时,SSTable 中的列名不再只有单一组件:
USE try1; CREATE TABLE harels2 ( name text, nick text, age int, PRIMARY KEY (name, nick) ) WITH compression = {}; INSERT INTO harels2 (name, nick, age) VALUES ('nadav', 'nyh', 40);注意 name 和 nick 共同构成主键,但 CQL 语法规定分区键是 name,聚类键是 nick。这意味着 name 相同(nick 不同)的表条目会出现在同一个分区中,即同一个 SSTable 行内。在该分区内可以出现不同的 nick,各自带自己的 age。用sstable2json查看:
{"key": "nadav", "cells": [["nyh:","",1427032626839065], ["nyh:age","40",1427032626839065]]}也就是说,复合列名 (nyh:age) 用于存储 nick 为 nyh 的 age,其他 nick 的 age 会用不同的列存储。注意在 (nyh:age) 中,"nyh" 并不是 CQL 列名之一,而是聚类列 nick 的值;只有最后一个组件 "age" 才是 CQL schema 中的真实字段名。
在 ScyllaDB 术语中,这个单一partition(key 为 name="nadav")包含多个rows,每个 row 有不同的聚类键值(nick)。每个 row 照常有若干列,列名是 CQL schema 中的字段(如前所述,以列 ID 而非名字保存)。
因此,把 SSTable 行转换为mutation_partition时,需要查 schema 找聚类键。若 "nick" 是聚类键,就不应像往常一样寻找名为 (nick) 的 cell;相反,预期每个cell 名都有 ≥2 个组件,第一个组件是 nick 的值,第二个组件才是真实列名。在mutation_partition对象中,需要插入多个row对象,每个 row 对应第一个组件的一个值。
那个 key 为(nyh:)(第二组件为空)、值为空的奇怪 cell,就是前述 "CQL Row Marker",它为每个row(分区键与聚类键的组合)各出现一次。
静态列(Static Columns)
静态列是同一分区内所有 row 共享的特殊列。如上文所见,每个分区多 row 的情形发生在存在聚类列时。Datastax 在 Cassandra 2.0.6 引入静态列时使用了如下示例:
CREATE TABLE bills ( user text, balance int static, expense_id int, amount int, PRIMARY KEY (user, expense_id) );这是一张账单表(用户需支付的金额)。按 "PRIMARY KEY" 行,分区键是 "user","expense_id" 是聚类键;这意味着每个用户有一个分区(SSTable 行),每个分区内可有多个费用(rows),各有不同的聚类键 expense_id 与对应 amount。而 "balance" 列属于同一用户的所有费用。
插入一条 user1 的费用并设置 user1 的 balance:
INSERT INTO bills (user, balance) VALUES ('user1', 17); INSERT INTO bills (user, expense_id, amount) VALUES ('user1', 1, 8);写入 SSTable 的内容如下(同样来自sstable2json输出):
{"key": "user1", "cells": [[":balance","17",1428849747953348], ["1:","",1428849747970947], ["1:amount","8",1428849747970947]]}(1:amount) 与 (1:) 就是上文见过的形式,新的内容是 (:balance)——一个静态列。
所以 SSTable 中的静态列通过复合 cell 名的空首组件特殊标记。需要验证每个这样的 cell 确实对应表 schema 中的已知静态列,并将所有静态列收集到 ScyllaDBmutation_partition中存储的单独一行(_static_row)里。
(文档附注:CompositeType.java的注释说明静态列的第一组件实际上并非大小为 0 的空组件,而是伪造大小STATIC_MARKER = 0xFFFF (65536),需进一步验证。)
复合聚类键(Compound Clustering Key)
当聚类键是复合的(由多个列组成)时,SSTable 列名会包含两个以上组件。例如:
USE try1; CREATE TABLE bills3 ( user text, expense_id int, year int, amount int, PRIMARY KEY (user, year, expense_id) ); INSERT INTO bills3 (user, year, expense_id, amount) VALUES ('user1', 2015, 1, 8);照例,"PRIMARY KEY" 中第一个列名 user 是分区键,另外两个 year 与 expense_id 都是聚类列,构成复合聚类键。即每个分区包含若干行,每行由 (year, expense_id) 二元组定义并排序。
得到的 SSTable 行是:
{"key": "user1", "cells": [["2015:1:","",1428853746711253], ["2015:1:amount","8",1428853746711253]]}注意列名 "amount" 现在以两个组件为前缀,即两个聚类列的值。当然,schema 可以有任意数量的聚类列,相应地 SSTable 列名中就会出现同样数量的前缀组件。
与之前一样,除最后一个组件外的所有组件都预期是分区内各 row 的聚类键值,只有最后一个组件是需要在 schema 中查找的列名。不过更稳妥的做法是查阅 schema 中聚类列的数量,而不是猜"组件数减一";这既是良好的健全性检查,在涉及集合(见下)时也是必要的。
复合分区键(Compound Partition Key)
如果 schema 声明分区键是复合的,从 SSTable 读出的行 key 也可以是复合的。例如:
CREATE TABLE bills2 ( user text, expense_id int, amount int, PRIMARY KEY ((user, expense_id)) ); INSERT INTO bills (user, expense_id, amount) VALUES ('user1', 1, 8);注意 "PRIMARY KEY" 中多了一对括号,表示 expense_id 属于分区键而不是聚类键。此时每个 SSTable 行的 key 是二元组 (user, expense_id)——一个含两个组件的复合键。
集合(Collections)
集合在 SSTable 中的编码更复杂。考虑一个包含set集合列的表:
CREATE TABLE col2 ( user text, favorites set<text>, PRIMARY KEY (user) ); INSERT INTO col2 (user, favorites) VALUES ('user1', {'raindrops', 'kittens'});得到的 SSTable 行是:
{"key": "user1", "cells": [["","",1428855312063525], ["favorites:_","favorites:!",1428855312063524,"t",1428855312], ["favorites:6b697474656e73","",1428855312063525], ["favorites:7261696e64726f7073","",1428855312063525]]}这里列名同样有两个组件,但需要知道这不是聚类键的情形("favorites" 不是聚类列的值),而是集合。需要查 schema 来区分这两种情况(本例中没有聚类列,所以任何组件都不需要按聚类列处理;因此看到两个组件时,必然是集合)。
在 set 中,集合的每个元素是一个 cell,cell 名的第二组件是序列化后的元素值。例如 "kittens" 被 sstable2json 以十六进制显示为6b697474656e73——在真实 SSTable 中并不是十六进制文本,而是"字符串长度 + 实际字节"。对set而言每个 cell 的值是空的(其他集合类型不为空,见下)。
上面 sstable2json 输出开头那个奇怪的 cell(favorites:_)不是普通 cell——这是 sstable 打印的range tombstone,其范围从 "favorites:" 的开头到 "favorites:" 的结尾,markedForDeleteAt为 1428855312063524,localDeletionTime为 1428855312。这个范围墓碑存在的必要性如下:因为集合的每个元素是独立 cell,当设置整个集合(如本例的 INSERT)时,意图是删除集合中任何旧元素并加入新元素——范围墓碑负责删除所有旧元素。
真实 SSTable 中并没有 sstable2json 打印的 "_" 或 "!" 字符。它实际有的是"\00\09favorites\ff"与"\00\09favorites\01"。即这两个列名都只有一个组件,但结尾不是通常的结束组件字节\00:第一个以\ff(START)结尾,第二个以\01(END)结尾。这意味着范围墓碑横跨第一个列到最后一个列之间的所有内容,正是所期望的效果。
第二种集合类型map与set类似,只是值不为空,而是 map 中想要的值。例如:
CREATE TABLE col4 ( user text, favorites map<text,int>, PRIMARY KEY (user) ) WITH compression = {}; INSERT INTO col4 (user, favorites) VALUES ('user1', {'raindrops' : 1, 'kittens' : 2});用 sstable2json 查看 SSTable:
{"key": "user1", "cells": [["","",1428864848550739], ["favorites:_","favorites:!",1428864848550738,"t",1428864848], ["favorites:6b697474656e73","00000002",1428864848550739], ["favorites:7261696e64726f7073","00000001",1428864848550739]]}即与 set 的表示完全相同,只是 cell 的值分别是想要的 1 和 2。上面值以字符串("0000002")打印只是 sstable2json 的处理方式——该值在 SSTable 中实际是序列化的 int(32 位长度 4,后跟整数的 4 个字节)。
有序的list集合情况类似,但为保持期望的元素顺序而不完全相同:
CREATE TABLE col1 ( user text, favorites list<text>, PRIMARY KEY (user) ); INSERT INTO col1 (user, favorites) VALUES ('user1', ['raindrops', 'kittens']);得到的 SSTable 行是:
{"key": "user1", "cells": [["","",1428854738475900], ["favorites:_","favorites:!",1428854738475899,"t",1428854738], ["favorites:c2bcd290e12d11e49cac000000000000","7261696e64726f7073",1428854738475900], ["favorites:c2bcd291e12d11e49cac000000000000","6b697474656e73",1428854738475900]]}注意这一次元素("raindrops" 与 "kittens")是 cell 的值,而不是列名。列名中是一些旨在按所需列表顺序排序的长字符串。这些长十六进制串被 sstable2json 误读了——它们并不是十六进制串,而是 16 字节的 UUID。
仅仅保持列表顺序,Cassandra 本可以使用小整数而不是 UUID。但这些 UUID 有一个额外好处:Cassandra 希望对已有列表支持高效的append与prepend操作——无需先读列表(即 append/prepend 是快速的纯写变异,而非慢速的 read-modify-write)。为此 Cassandra 使用带符号的 time-UUID 作为列表排序串——正时间用于 append,负时间用于 prepend。这样保证后续 append 总排在更早 append 之后,而 append 无需知道列表中已有哪些元素。
ScyllaDB 内部在 mutation 中存储集合的类是collection_mutation,需要把上述表示转换为该类。(文档附注:collection_mutation的内部结构隐藏在 opaque 字节数组之后,具体构建函数有待明确。)
带过期时间的 cell(TTL)
SSTable cell 可以有过期时间。这样的 cell 在 mask 字节置位EXPIRATION_MASK,除了正常字段外还有两个附加字段 "ttl" 与 "expiration",均为 32 位、以秒计。"ttl" 是 cell 创建时指定的原始存活时间(距过期还有多少秒),"expiration" 是 cell 应过期的绝对时间(自 UNIX 纪元起的秒数)。
下面 CQL 示例创建了一个存活 3600 秒的 cell:
CREATE TABLE ttl ( name text, age int, PRIMARY KEY (name) ); INSERT INTO ttl (name, age) VALUES ('nadav', 40) USING TTL 3600;sstable2json 打印的 SSTable 行为:
{"key": "nadav", "cells": [["","",1430151018675502,"e",3600,1430154618], ["age","40",1430151018675502,"e",3600,1430154618]]}注意每个 cell(CQL Row Marker 与真实数据 cell)都有 3600 秒的 TTL,expiration 时间为服务器当前时间加 3600。Row Marker 也获得 TTL 未必是好事——相关讨论见 CASSANDRA-5762。
Cell 墓碑(Cell Tombstone)
前面已讨论过行墓碑(标记整行删除)与范围墓碑(标记列范围删除)。此外还有 cell 墓碑,标记单个 cell 的删除。被删除的 cell 在 SSTable 中编码为普通 cell,只是 mask 置位DELETION_MASK,并且 cell 的值包含序列化的local_deletion_time(自纪元起的本地服务器秒数)——这大概只用于在gc_grace_seconds过去后清除墓碑。
要在 Cassandra 中创建含被删除 cell 的 SSTable,先创建带数据的表:
CREATE TABLE deleted ( name text, age int, PRIMARY KEY (name) ); INSERT INTO deleted (name, age) VALUES ('nadav', 40);然后(用bin/nodetool flush keyspacename)把数据刷成 SSTable,再删除刚添加的 cell:
DELETE age FROM deleted WHERE name = 'nadav';第二个 SSTable 刷出后就会包含一个 cell 墓碑。sstable2json 显示如下:
{"key": "nadav", "cells": [["age",1430200516,1430200516937621,"d"]]}注意该 cell 置位了DELETION_MASK(打印为 "d"),其 "value" 是 local-deletion-time 1430200516,照常还有时间戳。
SSTable Summary 文件格式
Summary 文件用于在 Index File 之上建立粗粒度索引,让读取器无需加载完整索引即可快速定位可能包含目标 key 的索引区域。本节内容源自 docs/architecture/sstable/sstable2/sstable-summary-file.rst。
struct summary_entry { char key[...]; // 变长 be64 position; };struct summary_la { struct header { be32 min_index_interval; be32 size; be64 memory_size; be32 sampling_level; be32 size_at_full_sampling; } header;le32 positions[header.size]; summary_entry entries[header.size]; be32 first_key_size; char first_key[first_key_size]; be32 last_key_size; char last_key[last_key_size]; };其中min_index_interval为索引抽样间隔(决定每隔多少索引项取一个进 Summary),size为摘要项数量,memory_size为内存中占用大小,sampling_level与size_at_full_sampling用于在满采样(level 100)与当前采样级别之间换算;positions与entries分别保存每个摘要项在 Index File 中的位置与 (key, position) 对,first_key/last_key标记该 SSTable 的 key 范围,用于快速判定目标 key 是否可能落在这个文件内。
阅读指引与延伸
本文对应的原始文档位于 docs/architecture/sstable/sstable2/index.rst 及同目录下的五个子文档:
- SSTable Compression —— 数据文件分块压缩与 Compression Info File
- SSTable Data File —— 行、atom、mask 与序列化细节
- SSTable format in ScyllaDB —— 与 Cassandra 2.1.8 的兼容性及 per-core sharding
- SSTable Interpretation —— 从磁盘字节到
mutation_partition的语义映射 - SSTable Summary File —— Summary 文件字节布局
如需了解新一代格式,可继续阅读 SSTable 3.x 章节(数据文件、Statistics、Summary、Index 与基于 trie 的 ms 索引)。实现层面,ScyllaDB 的 SSTable 读写代码位于 sstables/ 目录:版本枚举见 sstables/version.hh,压缩算法映射见 sstables/compressor.cc,压缩元数据与参数处理见 sstables/compress.cc 与 sstables/compress.hh。理解 2.x 格式不仅是兼容旧数据与备份恢复的基础,也为理解 3.x 的压缩优化(更紧凑的索引、更少的数据冗余)提供了必要铺垫。
【免费下载链接】scylladbNoSQL data store using the Seastar framework, compatible with Apache Cassandra and Amazon DynamoDB项目地址: https://gitcode.com/GitHub_Trending/sc/scylladb
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考