Cassandra 分散式資料庫詳解,第 2 部分:資料結構與資料讀寫

iteye_18139發表於2012-08-01

 原文 http://www.ibm.com/developerworks/cn/opensource/os-cn-cassandraxu2/

 

Cassandra 的資料儲存結構

Cassandra 中的資料主要分為三種:

  1. CommitLog:主要記錄下客戶端提交過來的資料以及操作。這個資料將被持久化到磁碟中,以便資料沒有被持久化到磁碟時可以用來恢復。
  2. Memtable:使用者寫的資料在記憶體中的形式,它的物件結構在後面詳細介紹。其實還有另外一種形式是 BinaryMemtable 這個格式目前 Cassandra 並沒有使用,這裡不再介紹了。
  3. SSTable:資料被持久化到磁碟,這又分為 Data、Index 和 Filter 三種資料格式。

CommitLog 資料格式

CommitLog 的資料只有一種,那就是按照一定格式組成 byte 組數,寫到 IO 緩衝區中定時的被刷到磁碟中持久化,在上一篇的配置檔案詳解中已經有說到 CommitLog 的持久化方式有兩種,一個是 Periodic 一個是 Batch,它們的資料格式都是一樣的,只是前者是非同步的,後者是同步的,資料被刷到磁碟的頻繁度不一樣。關於 CommitLog 的相關的類結構圖如下:


圖 1. CommitLog 的相關的類結構圖
圖 1. CommitLog 的相關的類結構圖 

它持久化的策略也很簡單,就是首先將使用者提交的資料所在的物件 RowMutation 序列化成 byte 陣列,然後把這個物件和 byte 陣列傳給 LogRecordAdder 物件,由 LogRecordAdder 物件呼叫 CommitLogSegment 的 write 方法去完成寫操作,這個 write 方法的程式碼如下:


清單 1. CommitLogSegment. write

				
public CommitLogSegment.CommitLogContext write(RowMutation rowMutation, 
 Object serializedRow){ 
        long currentPosition = -1L; 
 ... 
            Checksum checkum = new CRC32(); 
            if (serializedRow instanceof DataOutputBuffer){ 
                DataOutputBuffer buffer = (DataOutputBuffer) serializedRow; 
                logWriter.writeLong(buffer.getLength()); 
                logWriter.write(buffer.getData(), 0, buffer.getLength()); 
                checkum.update(buffer.getData(), 0, buffer.getLength()); 
            } 
            else{ 
                assert serializedRow instanceof byte[]; 
                byte[] bytes = (byte[]) serializedRow; 
                logWriter.writeLong(bytes.length); 
                logWriter.write(bytes); 
                checkum.update(bytes, 0, bytes.length); 
            } 
            logWriter.writeLong(checkum.getValue()); 
 ... 
 } 

這個程式碼的主要作用就是如果當前這個根據 columnFamily 的 id 還沒有被序列化過,將會根據這個 id 生成一個 CommitLogHeader 物件,記錄下在當前的 CommitLog 檔案中的位置,並將這個 header 序列化,覆蓋以前的 header。這個 header 中可能包含多個沒有被序列化到磁碟中的 RowMutation 對應的 columnFamily 的 id。如果已經存在,直接把 RowMutation 物件的序列化結果寫到 CommitLog 的檔案快取區中後面再加一個 CRC32 校驗碼。Byte 陣列的格式如下:


圖 2. CommitLog 檔案陣列結構
圖 2. CommitLog 檔案陣列結構 

上圖中每個不同的 columnFamily 的 id 都包含在 header 中,這樣做的目的是更容易的判斷那些資料沒有被序列化。

CommitLog 的作用是為恢復沒有被寫到磁碟中的資料,那如何根據 CommitLog 檔案中儲存的資料恢復呢?這段程式碼在 recover 方法中:


清單 2. CommitLog.recover

				
 public static void recover(File[] clogs) throws IOException{ 
 ... 
         final CommitLogHeader clHeader = CommitLogHeader.readCommitLogHeader(reader); 
         int lowPos = CommitLogHeader.getLowestPosition(clHeader); 
            if (lowPos == 0) break; 
            reader.seek(lowPos); 
            while (!reader.isEOF()){ 
                try{ 
                    bytes = new byte[(int) reader.readLong()]; 
                    reader.readFully(bytes); 
                    claimedCRC32 = reader.readLong(); 
                } 
 ... 
                ByteArrayInputStream bufIn = new ByteArrayInputStream(bytes); 
                Checksum checksum = new CRC32(); 
                checksum.update(bytes, 0, bytes.length); 
                if (claimedCRC32 != checksum.getValue()){continue;} 
            final RowMutation rm = 
              RowMutation.serializer().deserialize(new DataInputStream(bufIn));
            } 
 ... 
 } 

這段程式碼的思路是:反序列化 CommitLog 檔案的 header 為 CommitLogHeader 物件,尋找 header 物件中沒有被回寫的最小 RowMutation 位置,然後根據這個位置取出這個 RowMutation 物件的序列化資料,然後反序列化為 RowMutation 物件,然後取出 RowMutation 物件中的資料重新儲存到 Memtable 中,而不是直接寫到磁碟中。CommitLog 的操作過程可以用下圖來清楚的表示:


圖 3. CommitLog 資料格式的變化過程
圖 3. CommitLog 資料格式的變化過程 

Memtable 記憶體中資料結構

Memtable 記憶體中資料結構比較簡單,一個 ColumnFamily 對應一個唯一的 Memtable 物件,所以 Memtable 主要就是維護一個 ConcurrentSkipListMap<DecoratedKey, ColumnFamily> 型別的資料結構,當一個新的 RowMutation 物件加進來時,Memtable 只要看看這個結構是否 <DecoratedKey, ColumnFamily> 集合已經存在,沒有的話就加進來,有的話取出這個 Key 對應的 ColumnFamily,再把它們的 Column 合併。Memtable 相關的類結構圖如下:


圖 4. Memtable 相關的類結構圖
圖 4. Memtable 相關的類結構圖 

Memtable 中的資料會根據配置檔案中的相應配置引數刷到本地磁碟中。這些引數在上一篇中已經做了詳細說明。

前面已經多處提到了 Cassandra 的寫的效能很好,好的原因就是因為 Cassandra 寫到資料首先被寫到 Memtable 中,而 Memtable 是記憶體中的資料結構,所以 Cassandra 的寫是寫記憶體的,下圖基本上描述了一個 key/value 資料是怎麼樣寫到 Cassandra 中的 Memtable 資料結構中的。


圖 5. 資料被寫到 Memtable
圖 5. 資料被寫到 Memtable 

SSTable 資料格式

每新增一條資料到 Memtable 中,程式都會檢查一下這個 Memtable 是否已經滿足被寫到磁碟的條件,如果條件滿足這個 Memtable 就會寫到磁碟中。先看一下這個過程涉及到的類。相關類圖如圖 6 所示:


圖 6. SSTable 持久化類結構圖
圖 6. SSTable 持久化類結構圖 

Memtable 的條件滿足後,它會建立一個 SSTableWriter 物件,然後取出 Memtable 中所有的 <DecoratedKey, ColumnFamily> 集合,將 ColumnFamily 物件的序列化結構寫到 DataOutputBuffer 中。接下去 SSTableWriter 根據 DecoratedKey 和 DataOutputBuffer 分別寫到 Date、Index 和 Filter 三個檔案中。

Data 檔案格式如下:


圖 7. SSTable 的 Data 檔案結構
圖 7. SSTable 的 Data 檔案結構 

Data 檔案就是按照上述 byte 陣列來組織檔案的,資料被寫到 Data 檔案中是接著就會往 Index 檔案中寫,Index 中到底寫什麼資料呢?

其實 Index 檔案就是記錄下所有 Key 和這個 Key 對應在 Data 檔案中的啟示地址,如圖 8 所示:


圖 8. Index 檔案結構
圖 8. Index 檔案結構 

Index 檔案實際上就是 Key 的一個索引檔案,目前只對 Key 做索引,對 super column 和 column 都沒有建索引,所以要匹配 column 相對來說要比 Key 更慢。

Index 檔案寫完後接著寫 Filter 檔案,Filter 檔案存的內容就是 BloomFilter 物件的序列化結果。它的檔案結構如圖 9 所示:


圖 9. Filter 檔案結構
圖 9. Filter 檔案結構 

BloomFilter 物件實際上對應一個 Hash 演算法,這個演算法能夠快速的判斷給定的某個 Key 在不在當前這個 SSTable 中,而且每個 SSTable 對應的 BloomFilter 物件都在記憶體中,Filter 檔案指示 BloomFilter 持久化的一個副本。三個檔案對應的資料格式可以用下圖來清楚的表示:


圖 10. SSTable 資料格式轉化
圖 10. SSTable 資料格式轉化 

檢視大圖

這個三個檔案寫完後,還要做的一件事件就是更新前面提到的 CommitLog 檔案,告訴 CommitLog 的 header 所存的當前 ColumnFamily 的沒有寫到磁碟的最小位置。

在 Memtable 往磁碟中寫的過程中,這個 Memtable 被放到 memtablesPendingFlush 容器中,以保證在讀時候它裡面存的資料能被正確讀到,這個在後面資料讀取時還會介紹。

資料的寫入

資料要寫到 Cassandra 中有兩個步驟:

  1.  
    1. 找到應該儲存這個資料的節點
    2. 往這個節點寫資料。客戶端寫一條資料必須指定 Keyspace、ColumnFamily、Key、Column Name 和 Value,還可以指定 Timestamp,以及資料的安全等級。

資料寫入涉及的主要相關類如下圖所示:


圖 11. Insert 相關類圖
圖 11. Insert 相關類圖 

大慨的寫入邏輯是這樣的:

CassandraServer 接收到要寫入的資料時,首先建立一個 RowMutation 物件,再建立一個 QueryPath 物件,這個物件中儲存了 ColumnFamily、Column Name 或者 Super Column Name。接著把使用者提交的所有資料儲存在 RowMutation 物件的 Map<String, ColumnFamily> 結構中。接下去就是根據提交的 Key 計算叢集中那個節點應該儲存這條資料。這個計算的規則是:將 Key 轉化成 Token,然後在整個叢集的 Token 環中根據二分查詢演算法找到與給定的 Token 最接近的一個節點。如果使用者指定了資料要儲存多個備份,那麼將會順序在 Token 環中返回與備份數相等的節點。這是一個基本的節點列表,後面 Cassandra 會判斷這些節點是否正常工作,如果不正常尋找替換節點。還有還要檢查是否有節點正在啟動,這種節點也是要在考慮的範圍內,最終會形成一個目標節點列表。最後把資料傳送到這些節點。

接下去就是將資料儲存到 Memtable 中和 CommitLog 中,關於結果的返回根據使用者指定的安全等級不同,可以是非同步的,也可以是同步的。如果某個節點返回失敗,將會再次傳送資料。下圖是當 Cassandra 接收到一條資料時到將資料寫到 Memtable 中的時序圖。


圖 12. Insert 操作的時序圖
圖 12. Insert 操作的時序圖 

檢視大圖

資料的讀取

Cassandra 的寫的效能要好於讀的效能,為何寫的效能要比讀好很多呢?原因是,Cassandra 的設計原則就是充分讓寫的速度更快、更方便而犧牲了讀的效能。事實也的確如此,僅僅看 Cassandra 的資料的儲存形式就能發現,首先是寫到 Memtable 中,然後將 Memtable 中資料刷到磁碟中,而且都是順序儲存的不檢查資料的唯一性,而且是隻寫不刪(刪除規則在後面介紹),最後才將順序結構的多個 SSTable 檔案合併。這每一步難道不是讓 Cassandra 寫的更快。這個設計想想對讀會有什麼影響。首先,資料結構的複雜性,Memtable 中和 SSTable 中資料結構肯定不同,但是返回給使用者的肯定是一樣的,這必然會要轉化。其次,資料在多個檔案中,要找的資料可能在 Memtable 中,也可能在某個 SSTable 中,如果有 10 個 SSTable,那麼就要在到 10 個 SSTable 中每個找一遍,雖然使用了 BloomFilter 演算法可以很快判斷到底哪個 SSTable 中含有指定的 key。還有可能在 Memtable 到 SSTable 的轉化過程中,這也是要檢查一遍的,也就是資料有可能存在什麼地方,就要到哪裡去找一遍。還有找出來的資料可能是已經被刪除的,但也沒辦法還是要取。

下面是讀取資料的相關類圖:


圖 13. 讀取相關類圖
圖 13. 讀取相關類圖 

根據上面的類圖讀取的邏輯是,CassandraServer 建立 ReadCommand 物件,這個物件儲存了使用者要獲取記錄的所有必須指定的條件。然後交給 weakReadLocalCallable 這個執行緒去到 ColumnFamilyStore 物件中去搜尋資料,包括 Memtable 和 SSTable。將找到的資料組裝成 Row 返回,這樣一個查詢過程就結束了。這個查詢邏輯可以用下面的時序圖來表示:


圖 14. 查詢資料時序圖
圖 14. 查詢資料時序圖 

檢視大圖

在上圖中還一個地方要說明的是,取得 key 對應的 ColumnFamily 要至少在三個地方查詢,第一個就是 Memtable 中,第二個是 MemtablesPendingFlush,這個是將 Memtable 轉化為 SSTable 之前的一個臨時 Memtable。第三個是 SSTable。在 SSTable 中查詢最為複雜,它首先將要查詢的 key 與每個 SSTable 所對應的 Filter 做比較,這個 Filter 儲存了所有這個 SSTable 檔案中含有的所有 key 的 Hash 值,這個 Hsah 演算法能快速判斷指定的 key 在不在這個 SSTable 中,這個 Filter 的值在全部儲存在記憶體中,這樣能快速判斷要查詢的 key 在那個 SSTable 中。接下去就要在 SSTable 所對應的 Index 中查詢 key 所對應的位置,從前面的 Index 檔案的儲存結構知道,Index 中儲存了具體資料在 Data 檔案中的 Offset。,拿到這個 Offset 後就可以直接到 Data 檔案中取出相應的長度的位元組資料,反序列化就可以達到目標的 ColumnFamily。由於 Cassandra 的儲存方式,同一個 key 所對應的值可能存在於多個 SSTable 中,所以直到查詢完所有的 SSTable 檔案後再與前面的兩個 Memtable 查詢出來的結果合併,最終才是要查詢的值。

另外,前面所描述的是最壞的情況,也就是查詢在完全沒有快取的情況下,當然 Cassandra 在對查詢操作也提供了多級快取。第一級直接針對查詢結果做快取,這個快取的設定的配置項是 Keyspace 下面的 RowsCached。查詢的時候首先會在這個 Cache 中找。第二級 Cache 對應 SSTable 的 Index 檔案,它可以直接快取要查詢 key 所對應的索引。這個配置項同樣在 Keyspace 下面的 KeysCached 中,如果這個 Cache 能命中,將會省去 Index 檔案的一次 IO 查詢。最後一級 Cache 是做磁碟檔案與記憶體檔案的 mmap,這種方式可以提高磁碟 IO 的操作效率,鑑於索引大小的限制,如果 Data 檔案太大隻能在 64 位機器上使用這個技術。

資料的刪除

從前面的資料寫入規則可以想象,Cassandra 要想刪除資料是一件麻煩的事,為何這樣說?理由如下:

  1. 資料有多處 同時還可能在多個節點都有儲存。
  2. 資料的結構有多種 資料會寫在 CommitLog 中、Memtable 中、SSTable 中,它們的資料結構都不一樣。
  3. 資料時效性不一致 由於是叢集,所以資料在節點之間傳輸必然有延時。

除了這三點之外還有其它一些難點如 SSTable 持久化資料是順序儲存的,如果刪除中間一段,那資料有如何移動,這些問題都非常棘手,如果設計不合理,效能將會非常之差。

本部分將討論 Cassandra 是如何解決這些問題的。

CassandraServer 中刪除資料的介面只有一個 remove,下面是 remove 方法的原始碼:


清單 3. CassandraServer.remove

public void remove(String table, String key, ColumnPath column_path, 
          long timestamp, ConsistencyLevel consistency_level){
        checkLoginDone();
        ThriftValidation.validateKey(key);
        ThriftValidation.validateColumnPathOrParent(table, column_path);
        RowMutation rm = new RowMutation(table, key);
        rm.delete(new QueryPath(column_path), timestamp);
        doInsert(consistency_level, rm);
    }

仔細和 insert 方法比較,發現只有一行不同:insert 方法呼叫的是 rm.add 而這裡是 rm.delete。那麼這個 rm.delete 又做了什麼事情呢?下面是 delete 方法的原始碼:


清單 4. RowMutation. Delete

public void delete(QueryPath path, long timestamp){
...
        if (columnFamily == null)
            columnFamily = ColumnFamily.create(table_, cfName);
        if (path.superColumnName == null && path.columnName == null){
            columnFamily.delete(localDeleteTime, timestamp);
        }else if (path.columnName == null){
            SuperColumn sc = new SuperColumn(path.superColumnName, 
              DatabaseDescriptor.getSubComparator(table_, cfName));
            sc.markForDeleteAt(localDeleteTime, timestamp);
            columnFamily.addColumn(sc);
        }else{
            ByteBuffer bytes = ByteBuffer.allocate(4);
            bytes.putInt(localDeleteTime);
            columnFamily.addColumn(path, bytes.array(), timestamp, true);
        }
    }

這段程式碼的主要邏輯就是,如果是刪除指定 Key 下的某個 Column,那麼將這個 Key 所對應的 Column 的 vlaue 設定為當前系統時間,並將 Column 的 isMarkedForDelete 屬性設定為 TRUE,如果是要刪除這個 Key 下的所有 Column 則設定這個 ColumnFamily 的刪除時間期限屬性。然後將這個新增的一條資料按照 Insert 方法執行下去。

這個思路現在已經很明顯了,它就是通過設定同一個 Key 下對應不同的資料來更新已經在 ConcurrentSkipListMap 集合中存在的資料。這種方法的確很好,它能夠達到如下目的:

  1. 簡化了資料的操作邏輯。將新增、修改和刪除邏輯都統一起來。
  2. 解決了前面提到的三個難點。因為它就是按照資料產生的方式,來修改資料。有點以其人之道還治其人之身的意思。

但是這仍然有兩個問題:這個只是修改了指定的資料,它並沒有刪除這條資料;還有就是 SSTable 是根據 Memtable 中的資料儲存的,很可能會出現不同的 SSTable 中儲存相同的資料,這個又怎麼解決?的確如此,Cassandra 並沒有刪除你要刪除的資料,Cassandra 只是在你查詢資料返回之前,過濾掉 isMarkedForDelete 為 TRUE 的記錄。它能夠保證你刪除的資料你不能再查到,至於什麼時候真正刪除,你就不需要關心了。Cassandra 刪除資料的過程很複雜,真正刪除資料是在 SSTable 被壓縮的過程中,SSTable 壓縮的目的就是把同一個 Key 下對應的資料都統一到一個 SSTable 檔案中,這樣就解決了同一條資料在多處的問題。壓縮的過程中 Cassandra 會根據判斷規則判定哪些資料應該被刪除。

SSTable 的壓縮

資料的壓縮實際上是資料寫入 Cassandra 的一個延伸,前面描述的資料寫入和資料的讀取都有一些限制,如:在寫的過程中,資料會不停的將一定大小的 Memtable 刷到磁碟中,這樣不停的刷,勢必會產生很多的同樣大小的 SSTable 檔案,不可能這樣無限下去。同樣在讀的過程中,如果太多的 SSTable 檔案必然會影響讀的效率,SSTable 越多就會越影響查詢。還有一個 Key 對應的 Column 分散在多個 SSTable 同樣也會是問題。還有我們知道 Cassandra 的刪除同樣也是一個寫操作,同樣要處理這些無效的資料。

鑑於以上問題,必然要對 SSTable 檔案進行合併,合併的最終目的就是要將一個 Key 對應的所有 value 合併在一起。該組合的組合、該修改的修改,該刪除的刪除。然後將這個 Key 所對應的資料寫在 SSTable 所對應的 Data 檔案的一段連續的空間上。

何時壓縮 SSTable 檔案由 Cassandra 來控制,理想的 SSTable 檔案個數在 4~32 個。當新增一個 SSTable 檔案後 Cassandra 會計算當期的平均 SSTable 檔案的大小當新增的 SSTable 大小在平均 SSTable 大小的 0.5~1.5 倍時 Cassandra 就會呼叫壓縮程式壓縮 SSTable 檔案,導致的結果就是重新建立 Key 的索引。這個過程可以用下圖描述:


圖 15 資料壓縮
圖 15 資料壓縮 

總結

本文首先描述了 Cassandra 中資料的主要的儲存格式,包括記憶體中和磁碟中資料的格式,接下去介紹了 Cassandra 處理這些資料的方式,包括資料的新增、刪除和修改,本質上修改和刪除是一個操作。最後介紹了資料的壓縮。

接下去兩篇將向軟體開發人員介紹 Cassandra 中使用的設計模式、巧妙的設計方法和 Cassandra 的高階使用方法——利用 Cassandra 搭建儲存與檢索一體化的實時檢索系統

 

 

 

相關文章