Cassandra 数据模型概述
关于数据模型的基本概念先参考 http://wiki.apache.org/cassandra/DataModel 。 注意:这篇文章中关于 Column Families 和 Rows 的描述是不准确的,而且缺少 column family store (CFS) 这一重要概念。 下面对 Column Families 做进一步的说明。
Column-family 数据模型
为了简化,我们先忽略 SuperColumn。
在 Cassandra 中一个 Keyspace 可以看作一个二维索引结构:
- 第一层索引所用的 key 为 (row-key, cf-name), 即用一个 row-key 和 column-family-name 可以定位一个 column family。column family 是 column 的集合。
- 第一层索引所用的 key 为 column-name, 即通过一个 column-name 可以在一个 column family 中定位一个 column。
Column 是这个数据模型里面最基本的数据单元,它是一个三元组 (name, value, timestamp)。 一个 column family 里面,所有的 column 是按照 column-name 排序的。所以可以根据 column-name 快速找到 column。
数据定位
Cassandra 通过 (row-key, cf-name) 对来定位一个 column-family,具体过程如下:
- 首先根据 row-key 生成一个哈希值,根据哈希值确定在网络中哪几个节点上放置该 column family。
- 在每一个放置了该 column family 的节点上,具有相同的 cf-name 的 column-family 会被保存在一起,称为一个 column family store (CFS)。在一个 CFS 中,各个 column family 是按照 row-key 排序的。所以,在该节点上,首先通过 cf-name 来找到对应的 CFS,然后用 row-key 在这个 CFS 中查找这个 column family。
需要注意的地方是,上面的定位过程中 row-key 被使用了两次。 总结来说,在每个节点本地看来,一个 CFS 相当于数据库的一个表, Column Family 相当于表中的一行, Column 相当于一行中的一个域。
数据的本地存储
在阅读关于本地存储实现的代码分析之前,建议先阅读关于 Google 的 Big Table 系统的论文:"Bigtable: A Distributed Storage System for Structured Data"。为了简化讨论,以下均不考虑 super column。
数据结构
数据模型相关的数据结构可以分成两部分:内存中的数据结构和磁盘上的数据结构。
每个 Cassandra 节点在内存中维护一系列层次性的数据结构
- 在最顶层的是 Table 类,一个 Table 对应于一个 keyspace。它是 ColumnFamilyStore 对象的集合,可以通过 column family 的名字查找到对应的 CFS;
- ColumnFamilyStore 类包含两个主要的域:
- 一个 Memtable 对象。该对象保存了最近的对该 CFS 中的 Column family 的修改,这些修改还没有被 flush 到磁盘上。
- 一个 SStableTracker 对象。这是一个 SStableReader 对象的集合,每个 SSTableReader 对象维护了关于磁盘上的 CF 数据的必要信息。
Memtable 对象是一个修改的集合,修改包括两种:插入和删除。在 Cassandra 中,删除并不直接删除数据,而只是添加一条特殊的修改记录;修改某一个 column 也并不直接修改原有的数据,而是插入一条新的记录。一条修改记录对应一个 ColumnFamily 对象,需要注意的是,在 Cassandra 的代码里面,ColumnFamily 对象表示对某个 Column family 的修改,而不是表示这个 CF 的全部内容。Memtable 类包含一个 NonBlockingHashMap<DecoratedKey, ColumnFamily> 域,这个哈希表用于从 row-key 查找对应的 ColumnFamily 对象。
ColumnFamily 对象是一个 Column 对象的集合,同样的,每一个 Column 对象表示对某一列的修改。
在一定的条件下,Memtable 里面的修改记录会被 flush 到磁盘上,flush 之后会创建一个新的 Memtable。数据在磁盘上按照 SStable (Sorted String Table) 的格式保存,一个 SSTable 包括三个文件
- cfname-seqno-Data.db:保存修改记录的主数据文件;
- cfname-seqno-Index.db:从 row-key 到相应的 ColumnFamily 在 data.db 文件中的偏移量的索引;
- cfname-seqno-Filter.db:Bloom filter,是 row-key 的集合,用于快速检查一个 row-key 对应的 CF 修改是否在 cfname-seqno-Data.db 文件中存在。
其中,cfname 为 column family 的名字,seqno 用于区分同一个 CFS 的多个 SSTable,seqno 依次递增。
Data.db 文件按照 row-key 的顺序保存了 Memtable 中所有的 CF 修改信息。Memtable 中每个 ColumnFamily 在 Data.db 文件中按照如下格式保存
- bloom filter length
- bloom filter:关于 column name 的 Bloom filter,用于快速查找某个列是否被修改;
- index size
- index info 表。由于 column 按照 name 的顺序保存,所以可以通过这个索引表来快速查找。每个 index info 包括
- first column name:起始 column
- last column name:结束 column
- offset:起始 column 在 column 表中的偏移量
- width:起始 column 到结束 column 之间所有 column 的大小之和
- column 表:column 的修改信息。
逻辑上看,在 SSTable 中所有的 CF 按照 row-key 排序,每个 CF 的 column 按照 column name 排序,所以利用上述的多级索引结构可以很快查找到一个 column。
本地读/插入/删除操作的实现
Cassandra 支持三个主要的操作:read, insert, remove。操作的基本数据单元是一个 column。
在传统的文件系统或者数据库中,insert 操作需要对系统的元信息和数据进行更新,而 remove 操作则需要对元信息进行更新。Cassandra 的设计思路与这些系统不同,无论是 insert 还是 remove 操作,都是在已有的数据后面进行追加,而不修改已有的数据。这种设计称为 Log structured 存储,顾名思义就是系统中的数据是以日志的形式存在的,所以只会将新的数据追加到已有数据的后面。Log structured 存储系统有两个主要优点:
- 数据的写和删除效率极高。传统的存储系统需要更新元信息和数据,因此磁盘的磁头需要反复移动,这是一个比较耗时的操作,而 Log structured 的系统则是顺序写,可以充分利用文件系统的 cache,所以效率很高;
- 错误恢复简单。由于数据本身就是以日志形式保存,老的数据不会被覆盖,所以在设计 journal 的时候不需要考虑 undo,简化了错误恢复。
但是,Log structured 的存储系统也引入了一个重要的问题:读的复杂度和性能。理论上说,读操作需要从后往前扫描数据,以找到某个记录的最新版本。相比传统的存储系统,这是比较耗时的。
插入操作
Cassandra 实现单次插入操作的函数为 CassandraServer.insert,该函数的原型如下:
public void insert(String table, String key, ColumnPath column_path, byte[] value, long timestamp, int consistency_level)
其中,ColumnPath 类描述了需要插入的数据在 Cassandra 的索引层次结构中的路径,包括 column_family, super_column, column 三个主要的域。由这个函数的参数就可以唯一地确定数据需要插入的位置。Cassandra 首先通过 table, key, column_family 几个参数确定存储该数据的节点,然后将 table, key, column_family, super_column, column 这几项信息封装在一个 RowMutation 数据结构里面,分配到选定的节点上执行插入操作。
我们先来看一下 RowMutation 这个数据结构,它包括三个主要的域:
- table:数据所在的 table;
- key:column family 对应的 row-key;
- modifications:类型为 Map<String, ColumnFamily>,是一个 CF 的集合,每个 CF 是对一个 column family 的修改。
RowMutation 对象被发送到每个节点之后,节点调用 Table.apply 函数来将修改插入到本地存储中。本地执行插入操作的流程如下:
- 将修改记录到 commit log 里面,需要注意的是,每次添加修改到 commit log 之后并不立即将 commit log flush 到磁盘上,所以该操作不会阻塞;
- 对 RowMutation 中的每个 CF,找到对应的 CFS,然后调用 ColumnFamilyStore.apply 函数。该函数将 CF 加入到 memtable 中,如果 memtable 的大小或者包含的对象个数超过阈值,则创建一个新的 memtable,并返回当前的 memtable 以便将它的内容 flush 到 SSTable 中,否则返回 null;
- 对于每个 CFS 的 apply 函数返回的非空的 memtable,Table.apply 函数都需要将它 flush 到 SSTable 中持久保存。flush 的操作由 ColumnFamilyStore.maybeSwitchMemtable 函数来执行,大致流程如下:
- 调用 submitFlush 函数将 memtable flush 到 SSTable。flush 分为两个阶段:(1)对 memtable 中的 CF 按照 row-key 进行排序;(2)将排序好的 CF flush 到 SSTable 中。前一个操作是计算密集型的,而后一个操作是 I/O 密集型的,所以将这两个操作分配到两个不同的线程池中执行,提高并行性。
- 当 memtable 的内容确信已经写到磁盘上之后,需要删除 commit log 中相应的修改记录。submitFlush 函数返回一个 condition,当负责 flush 的线程完成了写磁盘操作,就会 signal 这个 condition。所以,switchMemtable 函数在 submitFlush 函数返回之后,会启动一个新的线程,等待该 condition 被 signal,之后就可以安全地执行删除 commit log 的操作了。
删除操作
删除一个 column 其实只是插入一个关于这个 column 的墓碑(tombstone),并不直接删除原有的 column。该墓碑被作为对该 CF 的一次修改记录在 Memtable 和 SSTable 中。墓碑的内容是删除请求被执行的时间,该时间是接受客户端请求的存储节点在执行该请求时的本地时间(local delete time),称为本地删除时间。需要注意区分本地删除时间和时间戳,每个 CF 修改记录都有一个时间戳,这个时间戳可以理解为该 column 的修改时间,是由客户端给定的。
由于被删除的 column 并不会立即被从磁盘中删除,所以系统占用的磁盘空间会越来越大,这就需要有一种垃圾回收的机制,定期删除被标记了墓碑的 column。垃圾回收是在 compaction 的过程中完成的。Compaction 过程在后面介绍。
读操作
Cassandra 中读操作的接口有以下五个:
public ColumnOrSuperColumn get(String table, String key, ColumnPath column_path, ConsistencyLevel consistency_level) public List<ColumnOrSuperColumn> get_slice(String keyspace, String key, ColumnParent column_parent, SlicePredicate predicate, ConsistencyLevel consistency_level) public Map<String, ColumnOrSuperColumn> multiget(String table, List<String> keys, ColumnPath column_path, ConsistencyLevel consistency_level) public Map<String, List<ColumnOrSuperColumn>> multiget_slice(String keyspace, List<String> keys, ColumnParent column_parent, SlicePredicate predicate, ConsistencyLevel consistency_level) public List<KeySlice> get_range_slices(String keyspace, ColumnParent column_parent, SlicePredicate predicate, KeyRange range, ConsistencyLevel consistency_level)
这些接口形式上虽然不同,但是它们的底层操作都是一样的:对客户端指定的key集合 keys = {k_1, k_2, ...} 中的每个 k_i,利用 (k_i, cf-name) 确定一个 Column Family,然后根据一定的标准从中选择出一个 column 集合并返回。不同 key 的读取是分开进行的。
存储节点首先根据 cf-name 在本地找到相应的 CFS,这个 CFS 包括了一个或者多个 Memtable以及多个 SSTable。对于每个 k_i,本地的读操作主要有两个步骤:
- 对每一个 Memtable 或者 SSTable,读取一个 cf,该 cf 中的 column 必须满足客户端指定的条件。cf 中的 column 按照 column-name 排序。
- 对上一步得到的多个 cf 进行归并(merge)为一个 cf。
由于每个 cf 都已经根据 name 来排序,所以可以使用多路归并算法来找到这些 cf 中所有 name 相等的 column。cassandra 使用 Apache 的 IteratorUtils.collatedIterator 类来实现多路归并,这个类实现了一个最小堆,可以从给定的一个迭代器集合里面找到下一个值(next)最小的迭代器并返回它的值。cassandra 得到每个 cf 中 column 的迭代器,并且将他们作为参数传给 collatedIterator。由于 collatedIterator 每次调用 next() 都会返回给定的迭代器中值最小的值,所以所有 name 相等的 column 会被连续返回。接下来就是对这些 name 相等的 column 进行合并了。
cf 中的每一个 column 都是关于这个 column 的一次修改,而我们要得到最新的修改,所以合并 cf 的过程就是保留同名的 column 中时间戳最新的那个 column。
可见,为了提高写的性能,cassandra 增加了读操作的复杂度,因此读操作的性能会相对较低。不过,可以通过在每个存储节点上增加 cache 来提高读的性能,在 Cassandra 0.6.1 版本里面已经加入了这个优化。这种设计体现了 Log structured 文件系统最初提出者的思想:写操作是主要的性能瓶颈,而读操作可以通过 cache 来提高性能,因此需要让文件系统尽量优化写。
Cassandra 读性能分析
从上面的分析可知,Cassandra 的性能瓶颈在于读操作。下面通过 Cassandra 的代码来分析它是如何实现对 SSTable 的读操作的,从而分析具体的性能瓶颈在哪里以及目前系统中都做了哪些读操作优化。
假如给定 cf-name, key, column-name,读取的总体过程如下:
- 根据 cf-name 找到 SSTableReader 对象;
- 根据 key 在 SSTableTracker.RowCache 中查找缓存的 cf;
- 如果在缓存中找到一个 cf,则只在 Memtable 中读取 cf,然后对两个 cf 进行归并;
- 如果没有找到,则调用 ColumnFamilyStore.getTopLevelColumns 函数,该函数实现了上一节所描述的读取算法(迭代,归并等);
- 在磁盘上的 SSTable 读取 cf 的过程如下:
- 在 SSTable 的 Bloom filter 中查找是否有该 key,如果没有则无须读取;
- 在 SSTable 的 index 中查找 cf 所在的位置,如果找不到则无须读取;
- 在 SSTable 的 data 文件中读取 cf。
读取 SSTable 的过程中有两个可能的瓶颈:一个是查找 index,一个是读取 data 文件。由于 index 是一个线性数组,所以如果 SSTable 中 key 的数量很多,那么查找 index 将会很费时;由于 data 文件中每个 cf 中 column 的索引也是一个线性数组(每个元素对应一个区间),所以如果一个 row 里面的 column 数量很多,查找也是很费时的。
目前针对前一个瓶颈,Cassandra 做的优化有两个:
- 在 SSTableReader 中增加一个 KeyCache,记录一些访问过的 key 在 data 文件中的偏移量。
- 在创建 SSTableReader 对象时,会对 index 文件进行一次索引,即对 index 文件中的每 128 个索引项在内存中保存一个二级索引项。由于二级索引完全保存在内存里面,所以可以用二分查找。
对第二个瓶颈,目前系统做的优化不多。读取一个 column 的时候,需要先在索引中做线性查找,找到一个区间,再在区间中做线性查找。
对数据存储模型设计的启示
所以假设给定的数据集可以表示为 (key1, key2, value),如:
(a, 12, value) (a, 13, value) (a, 16, value) (b, 12, value) (b, 17, value) ...
如果经常要给定一个 key1,范围的读取所有的 key1 对应的数据,那么适合把 key1 作为 cassandra 里的 key, key2 作为 cassandra 里的 name。 反之,如果经常要给定一个 (key1, key2) 对来读取数据,那么适合把 (key1, key2) 组合在一起作为 cassandra 里的 key。
Compaction
简单地说,compaction 就是将一个 CFS 中的多个 SSTable 合并为一个。Cassandra 里面 compaction 机制主要有三个功能:
- 垃圾回收:上面提到cassandra并不直接删除数据,因此磁盘空间会消耗得越来越多,compaction 会把标记为删除的数据真正删除;
- 提高读效率:compaction 将多个 SSTable 合并为一个,因此能提高读操作的效率;
- 生成 MerkleTree:在合并的过程中会生成关于这个 CFS 中数据的 MerkleTree,用于与其他存储节点对比以及修复数据。
Cassandra 中的 compaction 分为 minor, major, cleanup, readonly, anti-entropy 几种,它们各实现了上面几个功能中的一个或者几个,其中 major compaction 实现了上述所有三个功能,因此我们在这里只对它进行介绍。MerkleTree 以及数据修复在Cassandra 中的数据一致性中介绍。
cassandra 每增加一个新的 SSTable,就会调用 CompactionManager.submitMinorIfNeeded 函数,这个函数当该 CFS 中的 SSTable 数量达到一定阈值就会触发 compaction 操作,具体的 compaction 操作在 doCompaction 函数中完成。compaction 操作其实就是将一个 CFS 的所有 SSTable 合并成一个 SSTable,然后将原有的 SSTable 删除。如果是 major compaction 的话,在合并过程中不会保留标记为删除的 column 或者 CF。
Commit Log
在数据库领域,commit log 可以分为 undo-log, redo-log 以及 undo-redo-log 三类,由于 cassandra 不会覆盖已有的数据,所以无须使用 undo 操作,因此它的 commit log 使用的是 redo log。commit log 的文件格式以及相关的操作在 CommitLog.java 文件开始的注释里面已经有比较详细的说明:
/*
* Commit Log tracks every write operation into the system. The aim
* of the commit log is to be able to successfully recover data that was
* not stored to disk via the Memtable. Every Commit Log maintains a
* header represented by the abstraction CommitLogHeader. The header
* contains a bit array and an array of longs and both the arrays are
* of size, #column families for the Table, the Commit Log represents.
*
* Whenever a ColumnFamily is written to, for the first time its bit flag
* is set to one in the CommitLogHeader. When it is flushed to disk by the
* Memtable its corresponding bit in the header is set to zero. This helps
* track which CommitLogs can be thrown away as a result of Memtable flushes.
* Additionally, when a ColumnFamily is flushed and written to disk, its
* entry in the array of longs is updated with the offset in the Commit Log
* file where it was written. This helps speed up recovery since we can seek
* to these offsets and start processing the commit log.
*
* Every Commit Log is rolled over everytime it reaches its threshold in size;
* the new log inherits the "dirty" bits from the old.
*
* Over time there could be a number of commit logs that would be generated.
* To allow cleaning up non-active commit logs, whenever we flush a column family and update its bit flag in
* the active CL, we take the dirty bit array and bitwise & it with the headers of the older logs.
* If the result is 0, then it is safe to remove the older file. (Since the new CL
* inherited the old's dirty bitflags, getting a zero for any given bit in the anding
* means that either the CF was clean in the old CL or it has been flushed since the
* switch in the new.)
*/
我们在这里只是对那里没有说明的几个要点进行补充:
- Cassandra 中所有 Table 共用一个 commit log,那么如何区分不同 CF 的 commit log 记录呢?cassandra 在启动加载配置文件的时候会给所有的 CF 分配一个全局的 ID,这个 ID 在 commit log 中被用于区分不同 CF 的记录,这个 ID 也被用作 CommitLogHeader 中两个数组的下标。
- Cassandra 每次重启的时候会从 commit log 中恢复数据,最后删除 commit log 文件。
- 如果对 cassandra 的配置文件进行了修改,增加或者删除了一个 CF,那么原有的 commit log 就不能工作了,有可能引起数据的丢失。因此,安全的做法是:
- 在修改配置之前,先重启一次 cassandra,并且保证没有任何写操作,经过这次重启,上次的 commit log 已经被删除;
- 修改配置,并再次重启 cassandra,这样就能保证 commit log 头部的格式是正确的。