第九章:Cassandra讀寫數據--Cassandra:The Definitive Guide 2nd Edition

與前一章一樣,我們使用DataStax Java驅動程序包含了代碼示例,以幫助說明這些概念在實踐中如何工作。

讓我們首先注意向Cassandra寫入數據的一些基本屬性。首先,在Cassandra中寫入數據非常快,因爲它的設計不需要執行磁盤讀取或搜索。 memtables和SSTables使Cassandra不必在寫入時執行這些操作,從而減慢了許多數據庫的速度。 Cassandra中的所有寫入都是僅附加的。

由於數據庫提交日誌和提示切換設計,數據庫始終是可寫的,並且在列族中,寫入始終是原子的。

插入,更新和Upsert

由於Cassandra使用追加模型,因此插入和更新操作之間沒有根本區別。如果插入與現有行具有相同主鍵的行,則替換該行。如果更新行並且主鍵不存在,Cassandra會創建它。

出於這個原因,人們常說Cassandra支持upsert,這意味着插入和更新被視爲相同,只有一個小的例外,我們將在輕量級事務中看到它。

寫入一致性級別

Cassandra的可調整一致性級別意味着您可以在查詢中指定寫入所需的一致性。更高的一致性級別意味着更多的副本節點需要響應,表明寫入已完成。更高的一致性級別還伴隨着可用性的降低,因爲更多節點必須可操作才能使寫入成功。表9-1中顯示了對寫入使用不同一致性級別的含義。

Consistency level Implication
ANY Ensure that the value is written to a minimum of one replica node before returning to the client, allowing hints to count as a write.
ONE, TWO, THREE Ensure that the value is written to the commit log and memtable of at least one, two, or three nodes before returning to the client.
LOCAL_ONE Similar to ONE, with the additional requirement that the responding node is in the local data center.
QUORUM Ensure that the write was received by at least a majority of replicas ((replication factor / 2) + 1).
LOCAL_QUORUM Similar to QUORUM, where the responding nodes are in the local data center.
EACH_QUORUM Ensure that a QUORUM of nodes respond in each data center.
ALL Ensure that the number of nodes specified by replication factor received the write before returning to the client. If even one replica is unresponsive to the write operation, fail the operation.

寫入的最顯着的一致性級別是任何級別。此級別意味着寫入保證至少到達一個節點,但它允許提示計爲成功寫入。也就是說,如果執行寫入操作並且操作針對該值的節點關閉,則服務器將對其自身進行註釋,稱爲提示,它將存儲,直到該節點重新啓動。一旦節點啓動,服務器將檢測到這一點,查看它是否有任何以稍後以提示形式保存的寫入,然後將值寫入已恢復節點。在許多情況下,使提示的節點實際上不是存儲它的節點;相反,它將其發送到已關閉的節點的一個非重複鄰居。

在寫入時使用ONE的一致性級別意味着寫入操作將寫入提交日誌和memtable。這意味着在ONE處寫入是持久的,因此該級別是用於實現快速性能和持久性的最低級別。如果此節點在寫入操作之後立即關閉,則該值將被寫入提交日誌,當服務器重新啓動時可以重播該日誌,以確保它仍具有該值。

默認一致性級別

Cassandra客戶端通常支持爲所有查詢設置默認一致性級別,以及單個查詢的特定級別。例如,在cqlsh中,您可以使用CONSISTENCY命令檢查並設置默認一致性級別:


cqlsh> CONSISTENCY;
Current consistency level is ONE.
cqlsh> CONSISTENCY LOCAL_ONE;
Consistency level set to LOCAL_ONE.

在DataStax Java驅動程序中,可以通過提供com.datastax.driver.core.QueryOptions對象在Cluster.Builder上設置默認一致性級別:

QueryOptions queryOptions = new QueryOptions();
queryOptions.setConsistencyLevel(ConsistencyLevel.LOCAL_ONE);

Cluster cluster = Cluster.builder().addContactPoint("127.0.0.1").
  withQueryOptions(queryOptions).build();


可以在單個語句上覆蓋默認一致性級別:

Statement statement = ...
statement.setConsistencyLevel(ConsistencyLevel.LOCAL_ONE);


Cassandra寫路徑

寫入路徑描述瞭如何處理客戶端發起的數據修改查詢,最終導致數據存儲在磁盤上。我們將根據節點之間的交互以及在單個節點上存儲數據的內部過程來檢查寫入路徑。圖9-1顯示了多數據中心集羣中節點之間的寫路徑交互概述。

當客戶端向Cassandra節點發起寫入查詢時,寫入路徑開始,該節點充當該請求的協調者。協調器節點根據密鑰空間的複製因子使用分區器來識別集羣中的哪些節點是副本。協調器節點本身可以是副本,尤其是在客戶端使用令牌感知驅動程序的情況下。如果協調器知道沒有足夠的副本滿足請求的一致性級別,則會立即返回錯誤。

接下來,協調器節點向所有正在寫入的數據的副本發送同時寫入請求。這確保了所有節點只要它們啓動就會得到寫入。關閉的節點將不具有一致的數據,但它們將通過反熵機制之一進行修復:暗示切換,讀取修復或反熵修復。

在這裏插入圖片描述

如果羣集跨越多個數據中心,則本地協調器節點在每個其他數據中心中選擇遠程協調器,以協調對該數據中心中的副本的寫入。 每個遠程副本直接響應原始協調器節點。

協調員等待副本響應。 一旦足夠數量的副本響應以滿足一致性級別,協調器就會確認寫入客戶端。 如果副本在超時內沒有響應,則假定它已關閉,併爲寫入存儲提示。 除非使用一致性級別ANY,否則提示不會計爲成功的副本寫入。

圖9-2描述了每個副本節點內處理寫入請求的交互。

在這裏插入圖片描述

首先,副本節點接收寫請求並立即將數據寫入提交日誌。接下來,副本節點將數據寫入memtable。如果使用行緩存並且該行在緩存中,則該行無效。我們將在讀取路徑下更詳細地討論緩存。

如果寫入導致提交日誌或memtable通過其最大閾值,則計劃運行刷新。我們將在第12章學習如何調整這些閾值。

此時,寫入被認爲已成功,並且節點可以回覆協調器節點或客戶端。

返回後,如果安排了一個節點,節點將執行刷新。每個memtable的內容都作爲SSTable存儲在磁盤上,並清除提交日誌。刷新完成後,將安排其他任務以檢查是否需要壓縮,然後在必要時執行壓縮。

寫路徑的更多細節

當然,這是寫路徑的簡單概述,沒有考慮諸如計數器修改和物化視圖之類的變體。寫入具有物化視圖的表更復雜,因爲必須鎖定分區。 Cassandra在內部利用已記錄的批次以維護物化視圖。

有關寫入路徑的更深入處理,請參閱Michael Edge關於Apache Cassandra Wiki的優秀描述,網址爲https://wiki.apache.org/cassandra/WritePathForUsers。

將文件寫入磁盤

讓我們看一下Cassandra寫入磁盤的文件的更多細節,包括提交日誌和SSTables。

提交日誌文件

Cassandra將提交日誌作爲二進制文件寫入文件系統。提交日誌文件位於$ CASSANDRA_HOME / data / commitlog目錄下。

提交日誌文件根據模式CommitLog- - .log命名。例如:CommitLog-6-1451831400468.log。版本是表示提交日誌格式的整數。例如,3.0版本的版本是6.您可以在org.apache .cassandra .db.commitlog.CommitLogDescriptor類中找到發行版中正在使用的版本。

SSTable文件

在刷新期間將SSTable寫入文件系統時,實際上有幾個文件是根據SSTable寫入的。讓我們看看$ CASSANDRA_HOME / data / data 目錄,看看文件在磁盤上的組織方式。

強制SSTables到磁盤

如果您在真正的Cassandra節點上跟隨本書中的練習,那麼您可能希望此時執行nodetool flush命令,因爲您可能還沒有爲Cassandra輸入足夠的數據以自動將數據刷新到磁盤。我們將在第11章中瞭解有關此命令的更多信息。

查看數據目錄,您將看到每個鍵空間的目錄。反過來,這些目錄包含每個表的目錄,包括表名和UUID。 UUID的目的是區分多個模式版本,因爲表的模式可以隨時間改變。

每個目錄都包含SSTable文件,其中包含存儲的數據。這是一個示例目錄路徑:hotel / hotels-3677bbb0155811e5899aa9fac1d00bce。

每個SSTable由多個共享通用命名方案的文件表示。這些文件根據模式<version> - <generation> - <implementation> - <component> .db命名。模式的意義如下:

  • 該版本是一個雙字符序列,表示SSTable格式的主要/次要版本。 例如,3.0版本的版本是ma。 您可以在org.apache.cassandra.io.sstable.Descriptor類中瞭解有關各種版本的更多信息。
  • 生成是索引號,每次爲表創建新的SSTable時,該索引號都會遞增。
  • 該實現是對org.apache.cassandra.io.sstable.format.SSTableWriter接口的實現的引用。 從3.0版本開始,該值爲“big”,它引用了org.apache.cassandra.io.sstable.format.big.BigFormat類中的“Bigtable格式”。

每個SSTable都分爲多個文件或組件。 這些是3.0版本中的組件:

  • * -Data.db
    • 這些是存儲實際數據的文件,是Cassandra備份機制保留的唯一文件,我們將在第11章中瞭解這些文件。
  • * -CompressionInfo.db
    • 提供有關Data.db文件壓縮的元數據。
  • * -Digest.adler32
    • 包含* -Data.db文件的校驗和。 (在3.0之前發佈使用CRC 32校驗和和.crc32擴展。)
  • * -Filter.db
    • 包含此SSTable的bloom過濾器。
  • * -Index.db
    • 在相應的* -Data.db文件中提供行和列偏移。
  • Summary.db
    • 索引的樣本,用於更快的讀取。
  • Statistics.db
    • 存儲nodetool tablehistograms命令使用的有關SSTable的統計信息。
  • TOC.txt
    • 列出此SSTable的文件組件。

舊版本支持不同的版本和文件名。 2.2之前的版本將密鑰空間和表名稱添加到每個文件,而2.2及更高版本將這些文件留下,因爲它們可以從目錄名稱推斷出來。

我們將在第11章中研究一些使用SSTable文件的工具。

輕量級事務

正如我們之前在第1章中討論的那樣,Cassandra和許多其他NoSQL數據庫不支持具有關係數據庫支持的完整ACID語義的事務。但是,Cassandra確實提供了兩種提供某些事務行爲的機制:輕量級事務和批量。

Cassandra的輕量級事務(LWT)機制使用第6章中描述的Paxos算法.LWTs在2.0版本中引入。 LWT支持以下語義:

  • 每個事務的範圍僅限於一個分區。
  • 每個事務都包含讀取和寫入,也稱爲“比較和設置”操作。僅在比較成功時才執行該設置。
  • 如果事務因現有值與您預期的值不匹配而失敗,Cassandra將包含當前值,以便您可以決定是否重試或中止而無需提出額外請求。
    不支持USING TIMESTAMP選項。

假設我們想要使用我們在第5章中介紹的數據模型爲新酒店創建記錄。我們希望確保我們不會覆蓋具有相同ID的酒店,因此我們將IF NOT EXISTS語法添加到我們的插入命令:

cqlsh> INSERT INTO hotel.hotels (id, name, phone) VALUES (
  'AZ123', 'Super Hotel at WestWorld', '1-888-999-9999') IF NOT EXISTS;

 [applied]
-----------
      True


此命令檢查是否存在包含分區鍵的記錄,該表包含hotel_id。 因此,讓我們看看第二次執行此命令時會發生什麼:

cqlsh> INSERT INTO hotel.hotels (id, name, phone) VALUES ( 
  'AZ123', 'Super Hotel at WestWorld', '1-888-999-9999') IF NOT EXISTS;

 [applied] | id    | address | name                     | phone          | pois
-----------+-------+---------+--------------------------+----------------+------
     False | AZ123 |    null | Super Hotel at WestWorld | 1-888-999-9999 | null


在這種情況下,事務失敗,因爲已經有一個ID爲“AZ123”的酒店,並且cqlsh有助於回顯包含失敗指示的行和我們嘗試輸入的值。

它以類似的方式工作以進行更新。 例如,我們可能會使用以下語句來確保我們正在更改此酒店的名稱:

cqlsh> UPDATE hotel.hotels SET name='Super Hotel Suites at WestWorld'
... WHERE id='AZ123' IF name='Super Hotel at WestWorld';

 [applied]
-----------
      True

cqlsh> UPDATE hotel.hotels SET name='Super Hotel Suites at WestWorld'
... WHERE id='AZ123' IF name='Super Hotel at WestWorld';

 [applied] | name
-----------+---------------------------------
     False | Super Hotel Suites at WestWorld


與我們在多個INSERT語句中看到的類似,再次輸入相同的UPDATE語句會失敗,因爲已經設置了該值。 由於Cassandra的upsert模型,INSERT上可用的IF NOT EXISTS語法和UPDATE上的IF x = y語法表示這兩個操作之間唯一的語義差異。

在架構創建上使用事務

CQL還支持在創建鍵空間和表時使用IF NOT EXISTS選項。 如果您編寫多個架構更新的腳本,這將特別有用。

讓我們在使用DataStax Java驅動程序之前實現酒店創建示例。 執行條件語句時,ResultSet將包含一個Row,其列名爲applied,類型爲boolean。 這表明條件語句是否成功。 我們還可以在語句中使用wasApplied()操作:

SimpleStatement hotelInsert = session.newSimpleStatement(
  "INSERT INTO hotels (id, name, phone) VALUES (?, ?, ?) IF NOT EXISTS",
  "AZ123", "Super Hotel at WestWorld", "1-888-999-9999");
        
ResultSet hotelInsertResult = session.execute(hotelInsert);

boolean wasApplied = hotelInsertResult.wasApplied());

if (wasApplied) {
  Row row = hotelInsertResult.one();
  row.getBool("applied");
}


除常規一致性級別外,條件寫入語句還可以具有串行一致性級別。 串行一致性級別確定當參與節點正在協商建議的寫入時,必須在寫入的Paxos階段中回覆的節點數。 表9-2中顯示了兩個可用選項。

Consistency level Implication
SERIAL This is the default serial consistency level, indicating that a quorum of nodes must respond.
LOCAL_SERIAL Similar to SERIAL, but indicates that the transaction will only involve nodes in the local data center.

串行一致性級別也可以應用於讀取。如果Cassandra檢測到查詢正在讀取屬於未提交事務的數據,則它會根據指定的串行一致性級別將事務作爲讀取的一部分提交。

您可以使用SERIAL CONSISTENCY語句爲cqlsh中的所有語句設置默認的串行一致性級別,或使用Query Options .setSerialConsistencyLevel()操作在DataStax Java驅動程序中設置默認的串行一致性級別。

雖然輕量級事務僅限於單個分區,但Cassandra提供了一種批處理機制,允許您將對多個分區的修改分組到一個語句中。

批處理操作的語義如下:

  • 批處理中可能只包含修改語句(INSERT,UPDATE或DELETE)。
  • 批處理是原子的 - 也就是說,如果批處理被批處理,批處理中的所有語句最終都會成功。這就是爲什麼Cassandra的批次有時被稱爲原子批次或記錄批次。
  • 屬於給定分區鍵的批處理中的所有更新都是獨立執行的,但跨分區沒有隔離保證。這意味着可以在批處理完成之前讀取對不同分區的修改。
  • 批處理不是事務機制,但您可以批量包含輕量級事務語句。批處理中的多個輕量級事務必須應用於同一分區。
  • 只有在稱爲計數批次的特殊批次形式中才允許進行計數器修改。計數器批次只能包含計數器修改。

Deprecation of Unlogged Batches

在3.0之前的版本中,Cassandra支持未記錄的批次或批處理,其中跳過涉及批處理日誌的步驟。未記錄批次的缺點是無法保證批次成功完成,這可能使數據庫處於不一致狀態。

使用批處理可以在客戶端和協調器節點之間保存來回流量,因爲客戶端能夠在單個查詢中對多個語句進行分組。但是,批處理在協調器上放置了額外的工作來協調各種語句的執行。

Cassandra的批次非常適合用例,例如對單個分區進行多次更新,或者保持多個表同步。一個很好的例子是對非規範化表進行修改,這些表爲不同的訪問模式存儲相同的數據。

Batches Aren’t for Bulk Loading

第一次用戶經常混淆批處理以獲得更快的批量更新性能。絕對不是這種情況 - 批次實際上會降低性能並且可能導致垃圾收集壓力。

讓我們看一下我們可能用於在非規範化表設計中插入新酒店的示例批處理。我們使用CQL BEGIN BATCH和APPLY BATCH關鍵字來包圍批處理中的語句:


cqlsh> BEGIN BATCH
  INSERT INTO hotel.hotels (id, name, phone)
    VALUES ('AZ123', 'Super Hotel at WestWorld', '1-888-999-9999');
  INSERT INTO hotel.hotels_by_poi (poi_name, id, name, phone) 
    VALUES ('West World', 'AZ123', 'Super Hotel at WestWorld', 
    '1-888-999-9999');
APPLY BATCH;

DataStax Java驅動程序通過com.datastax.driver.core.BatchStatement類支持批處理。 以下是Java客戶端中相同批處理內容的示例:

SimpleStatement hotelInsert = session.newSimpleStatement(
  "INSERT INTO hotels (id, name, phone) VALUES (?, ?, ?)",
  "AZ123", "Super Hotel at WestWorld", "1-888-999-9999");
SimpleStatement hotelsByPoiInsert = session.newSimpleStatement(
  "INSERT INTO hotels_by_poi (poi_name, id, name, phone) 
  VALUES (?, ?, ?, ?)", "WestWorld", "AZ123", 
  "Super Hotel at WestWorld", "1-888-999-9999");
        
BatchStatement hotelBatch = new BatchStatement();
hotelBatch.add(hotelsByPoiInsert);
hotelBatch.add(hotelInsert);
        
ResultSet hotelInsertResult = session.execute(hotelBatch);


您還可以通過傳遞其他語句,使用QueryBuilder.batch()操作創建批處理。您可以找到用於處理BatchStatement和com.cassandraguide.readwrite.BatchStatementExample的代碼示例。

在DataStax驅動程序中創建計數器批次

DataStax驅動程序不爲計數器批處理提供單獨的機制。相反,您必須記住創建僅包含計數器修改或僅包含非計數器修改的批次。

以下是批處理的工作原理:協調器將稱爲批處理日誌的批處理副本發送到另外兩個節點,並存儲在system.batchlog表中。然後,協調器執行批處理中的所有語句,並在語句完成後從其他節點中刪除批處理日誌。

如果協調器未能完成批處理,則其他節點在其批處理日誌中具有副本,因此能夠重播批處理。每個節點每分鐘檢查一次批處理日誌,以查看是否有任何應該已完成的批處理。爲了給協調器提供足夠的時間來完成任何正在進行的批處理,Cassandra使用批處理語句中時間戳的寬限期等於write_request_timeout_in_ms屬性值的兩倍。任何早於此寬限期的批次都將被重播,然後從剩餘節點中刪除。第二個批日誌節點提供額外的冗餘層,確保批處理機制的高可靠性。

Cassandra強制限制批處理語句的大小,以防止它們變得任意大,並影響集羣的性能和穩定性。 cassandra.yaml文件包含兩個控制其工作方式的屬性:batch_size_warn_threshold_in_kb屬性定義節點將在WARN日誌級別記錄的級別,該級別已收到大批量,而任何超過設置值batch_size_fail_threshold_in_kb的批次將被拒絕並導致向客戶端發送錯誤通知。批量大小是根據CQL查詢語句的長度來度量的。警告閾值默認爲5KB,而失敗閾值默認爲50KB。

Cassandra的讀取功能有一些基本屬性值得注意。首先,它很容易讀取數據,因爲客戶端可以連接到羣集中的任何節點以執行讀取,而無需知道特定節點是否充當該數據的副本。如果客戶端連接到沒有其嘗試讀取的數據的節點,則它所連接的節點將充當協調器節點,以從具有它的節點讀取數據,由令牌範圍標識。

在Cassandra中,讀取通常比寫入慢。爲了完成讀取操作,Cassandra通常必須執行搜索,但您可以通過添加節點,使用具有更多內存的計算實例以及使用Cassandra的緩存來在內存中保留更多數據。 Cassandra還必須在讀取時同步等待響應(基於一致性級別和複製因子),然後根據需要執行讀取修復。

讀取一致性級別

讀操作的一致性級別與寫一致性級別類似,但它們的含義略有不同。更高的一致性級別意味着更多節點需要響應查詢,從而更加確保每個副本上存在的值相同。如果兩個節點以不同的時間戳響應,則最新值將獲勝,這將返回給客戶端。在後臺,Cassandra將執行所謂的讀取修復:它注意到一個或多個副本響應具有過期值的查詢的事實,並使用最新值更新這些副本以使它們全部一致。

表9-3中顯示了可能的一致性級別以及爲讀取查詢指定每個級別的含義。

Consistency level Implication
ONE, TWO, THREE Immediately return the record held by the first node(s) that respond to the query. A background thread is created to check that record against the same record on other replicas. If any are out of date, a read repair is then performed to sync them all to the most recent value.
LOCAL_ONE Similar to ONE, with the additional requirement that the responding node is in the local data center.
QUORUM Query all nodes. Once a majority of replicas ((replication factor / 2) + 1) respond, return to the client the value with the most recent timestamp. Then, if necessary, perform a read repair in the background on all remaining replicas.
LOCAL_QUORUM Similar to QUORUM, where the responding nodes are in the local data center.
EACH_QUORUM Ensure that a QUORUM of nodes respond in each data center.
ALL Query all nodes. Wait for all nodes to respond, and return to the client the record with the most recent timestamp. Then, if necessary, perform a read repair in the background. If any nodes fail to respond, fail the read operation.

從表中可以看出,讀操作不支持ANY一致性級別。請注意,一致性級別ONE的含義是響應讀取操作的第一個節點是客戶端將獲得的值 - 即使它已過期。在返回記錄之後執行讀取修復操作,因此任何後續讀取都將具有一致的值,而不管響應節點如何。

值得注意的另一個項目是在一致性級別ALL的情況下。如果指定ALL,則表示您需要響應所有副本,因此如果具有該記錄的任何節點關閉或者在超時之前無法響應,則讀取操作將失敗。如果節點在配置文件中的rpc_timeout_in_ms指定的值之前沒有響應查詢,則認爲該節點沒有響應。默認值爲10秒。

對齊讀寫一致性級別

您選擇在應用程序中使用的讀寫一致性級別是Cassandra爲我們在一致性,可用性和性能之間進行權衡的靈活性示例。

正如我們在第6章中學到的,Cassandra可以通過使用總和超過複製因子的讀寫一致性級別來保證讀取的強一致性。實現此目的的一種簡單方法是要求QUORUM進行讀寫。例如,在複製因子爲3的鍵空間上,QUORUM表示來自三個節點中的兩個的響應。因爲2 + 2> 3,所以保證了強大的一致性。

如果您願意犧牲強一致性以支持增加的吞吐量和對已故節點的更大容忍度,則可以使用較低的一致性級別。例如,對於寫入使用QUORUM而對於讀取使用ONE不能保證強一致性,因爲2 + 1僅等於3。

通過實際考慮這一點,如果你只保證寫入三個副本中的兩個,那麼其中一個副本肯定有可能沒有收到寫入但尚未修復,並且在一致性級別ONE的讀取可能會那節點。

Cassandra Read Path

現在讓我們來看看客戶端請求數據時會發生什麼。這稱爲讀取路徑。我們將從查詢單個分區鍵的角度描述讀取路徑,從圖9-3中所示的節點之間的交互開始。

在這裏插入圖片描述

當客戶端向協調器節點發起讀取查詢時,讀取路徑開始。與寫入路徑一樣,協調器使用分區程序來確定副本,並檢查是否有足夠的副本來滿足請求的一致性級別。與寫入路徑的另一個相似之處在於,對於涉及多個數據中心的任何讀取查詢,每個數據中心選擇一個遠程協調器。

如果協調器本身不是副本,則協調器然後向最快的副本發送讀取請求,這由動態告警確定。協調器節點還向其他副本發送摘要請求。摘要請求類似於標準讀取請求,除了副本返回所請求數據的摘要或散列。

協調器計算從最快副本返回的數據的摘要哈希值,並將其與從其他副本返回的摘要進行比較。如果摘要一致,並且已滿足所需的一致性級別,則可以返回來自最快副本的數據。如果摘要不一致,則協調器必須執行讀取修復,如以下部分所述。

圖9-4顯示了每個副本節點內處理讀取請求的交互。

在這裏插入圖片描述

當副本節點收到讀取請求時,它首先檢查行緩存。如果行緩存包含數據,則可以立即返回。行緩存有助於加快頻繁訪問的行的讀取性能。我們將在第12章討論行緩存的優缺點。

如果數據不在行緩存中,則副本節點將在memtables和SSTable中搜索數據。對於給定的表,只有一個memtable,因此搜索的一部分很簡單。但是,單個Cassandra表可能有許多物理SSTable,每個表都可能包含一部分請求的數據。

Cassandra實現了一些優化SSTable搜索的功能:密鑰緩存,Bloom過濾器,SSTable索引和摘要索引。

在磁盤上搜索SSTables的第一步是使用Bloom過濾器來確定給定SSTable中是否存在請求的分區,這樣就不必搜索該SSTable。

調整Bloom過濾器

Cassandra在內存中維護了Bloom過濾器的副本,儘管您可能還記得我們之前對上述文件的討論,Bloom過濾器與SSTable數據文件一起存儲在文件中,因此如果重新啓動節點則不必重新計算它們。

Bloom過濾器不保證SSTable包含分區,只保證它可能包含分區。您可以在每個表上設置bloom_ filter_ fp_chance屬性,以控制Bloom過濾器報告的誤報百分比。這種提高的準確性是以額外的內存使用爲代價的。

如果SSTable通過Bloom過濾器,Cassandra會檢查密鑰緩存,看它是否包含SSTable中分區鍵的偏移量。密鑰緩存實現爲映射結構,其中密鑰是SSTable文件描述符和分區密鑰的組合,值是偏移位置到SSTable文件中。密鑰緩存有助於消除SSTable文件中針對頻繁訪問的數據的搜索,因爲可以直接讀取數據。

如果未從密鑰緩存中獲取偏移量,則Cassandra使用存儲在磁盤上的兩級索引來定位偏移量。第一級索引是分區摘要,用於獲取在第二級索引(分區索引)內搜索分區鍵的偏移量。分區索引是存儲分區密鑰的SSTable偏移量的位置。

如果找到分區鍵的偏移量,Cassandra將以指定的偏移量訪問SSTable並開始讀取數據。

從所有SSTable獲取數據後,Cassandra通過選擇具有每個請求列的最新時間戳的值來合併SSTable數據和可記憶數據。遇到的任何墓碑都會被忽略。

最後,可以將合併的數據添加到行緩存(如果已啓用)並返回到客戶端或協調器節點。摘要請求的處理方式與常規讀取請求的處理方式大致相同,另外一步是在結果數據上計算摘要而不是數據本身。

讀取路徑上的更多細節

有關讀取路徑的更多詳細信息,請參閱Apache Cassandra Wiki。

Read Repair

以下是讀取修復的工作原理:協調器從所有副本節點發出完整的讀取請求。協調器節點通過爲每個請求的列選擇一個值來合併數據。它比較從副本返回的值並返回具有最新時間戳的值。如果Cassandra發現使用相同時間戳存儲的不同值,它將按字典順序比較值並選擇具有更大值的值。這種情況應該非常罕見。合併數據是返回給客戶端的值。

異步地,協調器識別返回過時數據的任何副本,並向每個副本發出讀取修復請求,以根據合併數據更新其數據。

可以在返回客戶端之前或之後執行讀取修復。如果您使用兩個更強的一致性級別之一(QUORUM或ALL),則在將數據返回到客戶端之前進行讀取修復。如果客戶端指定弱一致性級別(例如ONE),則在返回到客戶端之後可選地在後臺執行讀取修復。導致給定表的後臺修復的讀取百分比由表的read_repair_chance和dc_local_read_repair_chance選項確定。

範圍查詢,排序和過濾

到目前爲止,在我們的旅行中,我們將閱讀查詢限制在非常簡單的示例中。讓我們看一下Cassandra在SELECT命令中提供的更多選項,例如WHERE和ORDER BY子句。

首先,讓我們研究一下如何使用Cassandra提供的WHERE子句來讀取分區內的數據範圍,有時也稱爲slice。

但是,爲了進行範圍查詢,有助於使用一些數據。雖然我們還沒有很多數據,但我們可以通過使用Cassandra的批量加載工具快速獲得一些數據。

批量加載選項

在使用Cassandra時,您經常會發現將測試或引用數據加載到集羣中很有用。 幸運的是,有幾種簡單的方法可以將格式化數據批量加載到Cassandra和從Cassandra加載。

cqlsh支持通過COPY命令加載和卸載逗號分隔變量(CSV)文件。

例如,以下命令可用於將hotels表的內容保存到文件中:

cqlsh:hotel> COPY hotels TO 'hotels.csv' WITH HEADER=TRUE;

TO值指定要寫入的文件,HEADER選項爲TRUE導致列名稱在我們的輸出文件中打印。 我們可以使用以下命令編輯此文件並讀回內容:

cqlsh:hotel> COPY hotels FROM 'hotels.csv' WITH HEADER=true;


COPY命令支持其他選項來配置引號,轉義和時間的表示方式。

Brian Hess創建了一個名爲Cassandra Loader的命令行工具,可以加載和卸載CSV文件以及其他分隔文件,並且足夠靈活,可以使用逗號作爲小數分隔符。

我們可以使用cqlsh將一些樣本酒店庫存數據加載到我們的集羣中。 您可以在本書的GitHub存儲庫中訪問一個簡單的.csv文件。 available_rooms.csv文件包含兩個小旅館的一個月的庫存,每個小旅館有五個房間。 讓我們將數據加載到集羣中:


cqlsh:hotel> COPY available_rooms_by_hotel_date FROM 
  'available_rooms.csv' WITH HEADER=true;

310 rows imported in 0.789 seconds.

如果您快速查詢以閱讀這些數據,您會發現我們有兩家酒店的數據:“AZ123”和“NY229”。

現在讓我們考慮如何支持我們標記爲“Q4”的查詢。 在第5章中查找給定日期範圍內的可用空間。請記住,我們使用主鍵設計了available_rooms_by_hotel_date表以支持此查詢:

 PRIMARY KEY (hotel_id, date, room_number)


這意味着hotel_id是分區鍵,而date和room_number是聚類列。

這是一個CQL語句,允許我們搜索特定酒店和日期範圍的酒店房間:


cqlsh:hotel> SELECT * FROM available_rooms_by_hotel_date 
  WHERE hotel_id='AZ123' and date>'2016-01-05' and date<'2016-01-12';

 hotel_id | date       | room_number | is_available
----------+------------+-------------+--------------
    AZ123 | 2016-01-06 |         101 |         True
    AZ123 | 2016-01-06 |         102 |         True
    AZ123 | 2016-01-06 |         103 |         True
    AZ123 | 2016-01-06 |         104 |         True
    AZ123 | 2016-01-06 |         105 |         True
...
(60 rows)

請注意,此查詢涉及分區鍵hotel_id和一系列值,這些值表示我們在羣集關鍵日期上搜索的開始和結束。

如果我們想嘗試在AZ123酒店找到101號房間的記錄,我們可能會嘗試如下查詢:

cqlsh:hotel> SELECT * FROM available_rooms_by_hotel_date 
  WHERE hotel_id='AZ123' and room_number=101;
InvalidRequest: code=2200 [Invalid query] message="PRIMARY KEY column 
  "room_number" cannot be restricted as preceding column "date" is not 
  restricted"

如您所見,此查詢會導致錯誤,因爲我們嘗試限制第二個羣集鍵的值,而不限制第一個羣集鍵的值。

WHERE子句的語法包含以下規則:

  • 必須標識分區鍵的所有元素
  • 如果所有先前的羣集密鑰都受到限制,則只能限制給定的羣集密鑰

這些限制基於Cassandra如何在磁盤上存儲數據,該數據基於CREATE TABLE命令中指定的羣集列和排序順序。 聚類列上的條件僅限於允許Cassandra選擇連續行排序的條件。

此規則的例外是ALLOW FILTERING關鍵字,它允許我們省略分區鍵元素。 例如,我們可以使用此查詢在特定日期搜索多個酒店的房間狀態以查找房間:


cqlsh:hotel> SELECT * FROM available_rooms_by_hotel_date 
  WHERE date='2016-01-25' ALLOW FILTERING;

 hotel_id | date       | room_number | is_available
----------+------------+-------------+--------------
    AZ123 | 2016-01-25 |         101 |         True
    AZ123 | 2016-01-25 |         102 |         True
    AZ123 | 2016-01-25 |         103 |         True
    AZ123 | 2016-01-25 |         104 |         True
    AZ123 | 2016-01-25 |         105 |         True
    NY229 | 2016-01-25 |         101 |         True
    NY229 | 2016-01-25 |         102 |         True
    NY229 | 2016-01-25 |         103 |         True
    NY229 | 2016-01-25 |         104 |         True
    NY229 | 2016-01-25 |         105 |         True

(10 rows)

但是,不推薦使用允許過濾,因爲它有可能導致非常昂貴的查詢。如果您發現自己需要這樣的查詢,則需要重新訪問數據模型,以確保設計了支持查詢的表。

IN子句可用於測試與列的多個可能值的相等性。例如,我們可以使用以下命令通過命令查找每週兩個日期的庫存:


cqlsh:hotel> SELECT * FROM available_rooms_by_hotel_date 
  WHERE hotel_id='AZ123' AND date IN ('2016-01-05', '2016-01-12');

請注意,使用IN子句可能會導致查詢性能降低,因爲指定的列值可能對應於行中的非連續區域。

最後,SELECT命令允許我們覆蓋在創建表時在列上指定的排序順序。 例如,我們可以使用ORDER BY語法按日期降序獲取我們之前任何查詢的房間:

cqlsh:hotel> SELECT * FROM available_rooms_by_hotel_date
  WHERE hotel_id='AZ123' and date>'2016-01-05' and date<'2016-01-12' 
  ORDER BY date DESC;


函數和聚合

Cassandra 2.2引入了兩個功能,允許客戶端將一些處理工作轉移到協調器節點:用戶定義的函數(UDF)和用戶定義的聚合(UDA)。 使用這些功能可以通過減少必須返回到客戶端的數據量並減少客戶端上的處理負載來提高某些情況下的性能,但代價是服務器上的其他處理。

用戶定義的函數

UDF是作爲查詢處理的一部分在Cassandra節點上應用於存儲數據的函數。 在羣集中使用UDF之前,必須在每個節點上的cassandra.yaml文件中啓用它們:

enable_user_defined_functions: true

以下是其工作原理的快速摘要:我們使用CQL CREATE FUNCTION命令創建UDF,該命令會將函數傳播到集羣中的每個節點。 當您執行引用UDF的查詢時,它將應用於查詢結果的每一行。

讓我們創建一個示例UDF來計算available_rooms_by_hotel_date表中可用房間的數量:


cqlsh:hotel> CREATE FUNCTION count_if_true(input boolean) 
  RETURNS NULL ON NULL INPUT 
  RETURNS int 
  LANGUAGE java AS 'if (input) return 1; else return 0;';

我們將一次解剖這個命令。我們創建了一個名爲count_if_true的UDF,它對布爾參數進行操作並返回一個整數。我們還包括一個空檢查,以確保該函數在未定義值的情況下有效工作。請注意,如果UDF失敗,則會中止查詢的執行,因此這可能是一項重要的檢查。

UDF安全

3.0版本添加了一個安全功能,可以在單獨的沙箱中運行UDF代碼,以限制惡意函數未經授權訪問節點的Java運行時的能力。

接下來,請注意我們已將此聲明爲具有LANGUAGE子句的Java實現。 Cassandra本身支持Java和JavaScript中定義的函數和聚合。它們也可以使用JSR 223中指定的Java Scripting API支持的任何語言實現,包括Python,Ruby和Scala。在這些語言中定義的函數需要向Cassandra的Java CLASSPATH添加其他腳本引擎JAR文件。

最後,我們在AS子句中包含函數的實際Java語法。現在這個函數本身就有些微不足道了,因爲我們所做的就是將真值計算爲1.我們將使用這個UDF做一些更強大的功能。

首先,讓我們在available_rooms_by_hotel_date表上嘗試我們的UDF,看看它是如何工作的:

cqlsh:hotel> SELECT room_number, count_if_true(is_available) 
  FROM available_rooms_by_hotel_date
  WHERE hotel_id='AZ123' and date='2016-01-05';

 room_number | hotel.count_if_true(is_available)
-------------+-----------------------------------
         101 |                                 1
         102 |                                 1
         103 |                                 1
         104 |                                 1
         105 |                                 1

(5 rows)


如您所見,具有函數結果的列使用酒店鍵空間名稱限定。這是因爲每個UDF都與特定鍵空間相關聯。如果我們要在DataStax Java驅動程序中執行類似的查詢,我們會在每一行中找到名爲hotel_count_if_true_is_available的列。

用戶定義的聚合

正如我們剛剛學到的,用戶定義的函數在單行上運行。爲了跨多行執行操作,我們創建了一個用戶定義的聚合。 UDA利用兩個UDF:狀態函數和可選的最終函數。對每一行執行狀態函數,而最終函數(如果存在)對狀態函數的結果進行操作。

讓我們看一個簡單的例子來幫助研究它是如何工作的。首先,我們需要一個狀態函數。 count_if_true函數接近我們需要的函數,但是我們需要進行一些小的更改以允許可用的計數在多行中求和。讓我們創建一個新函數,允許傳入總計,遞增並返回:


cqlsh:hotel> CREATE FUNCTION state_count_if_true(total int, input boolean)
  RETURNS NULL ON NULL INPUT    
  RETURNS int    
  LANGUAGE java AS 'if (input) return total+1; else return total;';

請注意,total參數作爲第一個參數傳遞,其類型與函數的返回類型(int)匹配。 要將UDF用作狀態函數,第一個參數類型和返回類型必須匹配。 第二個參數是我們在原始count_if_true UDF中的布爾值。

現在我們可以創建一個使用此狀態函數的聚合:

cqlsh:hotel> CREATE AGGREGATE total_available (boolean)   
  SFUNC state_count_if_true   
  STYPE int  
  INITCOND 0;


讓我們一塊一塊地分解這個陳述:首先,我們聲明瞭一個名爲total_available的UDA,它對boolean類型的列進行操作。

SFUNC子句標識此查詢使用的狀態函數 - 在本例中爲state_count_if_true。

接下來,我們確定用於通過STYPE子句從狀態函數累積結果的類型。 Cassandra維護這種類型的值,它傳遞給狀態函數,因爲它在每個連續的行上調用。 STYPE必須與state函數的第一個參數和返回類型相同。 INITCOND子句允許我們設置結果的初始值;在這裏,我們將初始計數設置爲零。

在這種情況下,我們選擇省略最終函數,但我們可以包含一個函數,該函數接受STYPE的參數並返回任何其他類型,例如接受整數參數的函數並返回一個布爾值,指示是否庫存處於低水平,應生成警報。

現在讓我們使用我們的聚合來獲取我們之前的一個查詢返回的可用房間數。請注意,我們的查詢必須只包含UDA,而不包含其他列或函數:

cqlsh:hotel> SELECT total_available(is_available) 
  FROM available_rooms_by_hotel_date    
  WHERE hotel_id='AZ123' and date='2016-01-05';

 hotel.total_available(is_available)
-------------------------------------
                                   5

(1 rows)



如您所見,此查詢會生成指定酒店和日期的五個可用房間的結果。

其他UDF / UDA命令選項

在創建UDF和UDA時,您可以使用熟悉的IF NOT EXISTS語法,以避免嘗試創建具有重複簽名的函數和聚合的錯誤消息。 或者,您可以在實際打算覆蓋當前函數或聚合時使用CREATE OR REPLACE語法。

使用DESCRIBE FUNCTIONS命令或DESCRIBE AGGREGATES命令瞭解已定義了哪些UDF和UDA。 當存在具有相同名稱但具有不同簽名的函數時,這尤其有用。

最後,您可以使用DROP FUNCTION和DROP AGGREGATE命令刪除UDF和UDA。

內置函數和聚合

除了用戶定義的函數和聚合之外,Cassandra還提供了一些我們可以使用的內置函數或本機函數和聚合:

  • COUNT

SELECT COUNT(*) FROM hotel.hotels;

此命令還可用於計算指定列的非空值的數量。 例如,以下內容可用於計算提供電子郵件地址的訪客數量:

SELECT COUNT(emails) FROM reservation.guests; 

  • MIN和MAX

MIN和MAX函數可用於計算查詢爲給定列返回的最小值和最大值。 例如,此查詢可用於確定在給定酒店和抵達日期預訂的最短和最長逗留時間(以夜晚爲單位):

SELECT MIN(nights), MAX(nights) FROM reservations_by_hotel_date 
  WHERE hotel_id='AZ123' AND start_date='2016-09-09'; 

  • sum

sum函數可用於彙總查詢爲給定列返回的所有值。 我們可以將多個預訂的住宿天數相加如下:

SELECT SUM(nights) FROM reservations_by_hotel_date
  WHERE hotel_id='AZ123' AND start_date='2016-09-09'; 

  • 平均

avg函數可用於計算查詢爲給定列返回的所有值的平均值。 要獲得夜晚的平均逗留時間,我們可能會執行以下查詢:

SELECT AVG(nights) FROM reservations_by_hotel_date
  WHERE hotel_id='AZ123' AND start_date='2016-09-09';  

這些內置聚合在技術上是系統鍵空間的一部分。 因此,包含上一次查詢結果的列名稱爲system_avg_nights。

分頁

在Cassandra的早期版本中,客戶必須確保一次小心地限制所請求的數據量。 對於大型結果集,即使到了內存不足的情況,也有可能壓倒節點和客戶端。

值得慶幸的是,Cassandra提供了一種分頁機制,允許逐步檢索結果集。 通過使用CQL關鍵字LIMIT顯示了一個簡單的示例。 例如,以下命令將返回不超過100個酒店:

cqlsh> SELECT * FROM hotel.hotels LIMIT 100;

當然,LIMIT關鍵字(雙關語)的限制是無法獲得包含超出請求數量的額外行的其他頁面。

Cassandra 2.0版本引入了一種稱爲自動分頁的功能。 自動分頁允許客戶端請求查詢返回的數據子集。 服務器將結果分解爲在客戶端請求它們時返回的頁面。

您可以通過PAGING命令在cqlsh中查看分頁狀態。 以下輸出顯示了檢查分頁狀態,更改提取大小(頁面大小)和禁用分頁的順序:

cqlsh> PAGING;
Query paging is currently enabled. Use PAGING OFF to disable
Page size: 100
cqlsh> PAGING 1000;
Page size: 1000
cqlsh> PAGING OFF;
Disabled Query paging.
cqlsh> PAGING ON;
Now Query paging is enabled


現在讓我們看看如何在DataStax Java驅動程序中進行分頁。 您可以爲Cluster實例全局設置默認提取大小:

Cluster cluster = Cluster.builder().addContactPoint("127.0.0.1").
    withQueryOptions(new QueryOptions().setFetchSize(2000)).build();


也可以在單個語句上設置提取大小,覆蓋默認值:

Statement statement = new SimpleStatement("...");
statement.setFetchSize(2000);


如果在語句上設置提取大小,則優先; 否則,將使用羣集範圍的值(默認爲5,000)。 請注意,設置提取大小並不意味着Cassandra將始終返回所請求的確切行數; 它可能會稍微或多或少地返回結果。

驅動程序代表我們處理自動分頁,允許我們迭代ResultSet而不需要知道分頁機制。 例如,請考慮以下代碼示例來迭代酒店的查詢:

SimpleStatement hotelSelect = session.newSimpleStatement(
  "SELECT * FROM hotels");

ResultSet resultSet = session.execute(hotelSelect);

for (Row row : resultSet) {
  // process the row
}


幕後發生的事情如下:當我們的應用程序調用session.execute()操作時,驅動程序向Cassandra執行查詢,請求結果的第一頁。 我們的應用程序迭代結果,如for循環中所示,當驅動程序檢測到當前頁面上沒有剩餘項目時,它會請求下一頁。

請求下一頁的小暫停可能會影響我們應用程序的性能和用戶體驗,因此ResultSet提供了額外的操作,允許對分頁進行更細粒度的控制。 這是一個示例,說明我們如何擴展應用程序以對行進行預取:

for (Row row : resultSet) {
  if (resultSet.getAvailableWithoutFetching() < 100 && 
      !resultSet.isFullyFetched())
        resultSet.fetchMoreResults();
  // process the row
}


此附加語句使用getAvailableWithoutFetching()檢查當前頁面上是否剩餘少於100行。 如果有另一個要檢索的頁面,我們通過檢查isFullyFetched()來確定,我們啓動異步調用以通過fetchMoreResults()獲取額外的行。

驅動程序還公開了更直接訪問分頁狀態的能力,因此可以保存並在以後重用。 如果您的應用程序是無狀態Web服務,而不支持跨多個調用的會話,那麼這可能很有用。

我們可以通過ResultSet的ExecutionInfo訪問分頁狀態


PagingState nextPage = resultSet.getExecutionInfo().getPagingState();

然後我們可以在應用程序中保存此狀態,或將其返回給客戶端。 可以使用toString()將PagingState轉換爲字符串,或使用toBytes()將字節數組轉換爲字節數組。

請注意,在字符串或字節數組形式中,狀態不應該嘗試使用不同的語句來操作或重用。 這樣做會導致分頁狀態異常。

要從給定的PagingState恢復查詢,我們在Statement上設置它:


SimpleStatement hotelSelect = session.newSimpleStatement(
  "SELECT * FROM hotels");
hotelSelect.setPagingState(pagingState);

Speculative Retry

我們之前在第8章中討論過DataStax Java驅動程序提供的SpeculativeExecutionPolicy,如果初始節點在可配置的時間內沒有響應,它會先使用不同的節點重試讀取查詢。

我們可以在每個節點上配置相同的行爲,以便當節點充當協調器時,它可以啓動對備用節點的推測請求。可以通過speculative_retry屬性在每個表上配置此行爲,該屬性允許以下值:

  • ALWAYS
    重試讀取所有副本。
  • PERCENTILE
    如果在第X百分位響應時間內未收到響應,則啓動重試。
  • ms
    如果在Y毫秒內未收到響應,則重試。
  • NONE
    不要重試讀取。

默認值爲99.0PERCENTILE。這通過加速“離羣值”緩慢執行請求而不會使羣集充滿大量重複讀取請求來實現良好的平衡。

此功能也稱爲快速讀取保護,並在2.0.2版中引入。請注意,它對一致性級別ALL的查詢沒有影響,因爲沒有其他節點可以重試。

刪除

刪除數據在Cassandra中與在關係數據庫中不同。在RDBMS中,您只需發出一個delete語句來標識要刪除的行。在Cassandra中,刪除實際上不會立即刪除數據。這有一個簡單的原因:Cassandra的耐用,最終一致的分佈式設計。如果Cassandra有一個傳統的刪除設計,那麼在刪除時關閉的任何節點都不會收到刪除。一旦這些節點中的一個重新聯機,就會錯誤地認爲已經收到刪除的所有節點實際上都錯過了寫入(因爲它錯過了刪除而仍然存在的數據),並且它將開始修復所有節點其他節點。所以Cassandra需要一種更復雜的機制來支持刪除。這種機制稱爲墓碑。

邏輯刪除是在刪除中發出的一種特殊標記,它覆蓋已刪除的值,充當佔位符。如果任何副本沒有收到刪除操作,則墓碑可以在以後再次可用時傳播到這些副本。此設計的淨效果是您的數據存儲在刪除後不會立即縮小。每個節點都會跟蹤其所有墓碑的年齡。一旦它們達到gc_grace_seconds(默認爲10天)中配置的年齡,則運行壓縮,對邏輯刪除進行垃圾收集,並恢復相應的磁盤空間。

由於SSTable是不可變的,因此不會從SSTable中刪除數據。在壓縮時,會考慮邏輯刪除,對合並數據進行排序,在排序數據上創建新索引,並將新合併,排序和索引的數據寫入單個新文件。假設在壓縮運行之前10天是足夠的時間讓故障節點重新聯機。如果您覺得這樣做很舒服,可以縮短寬限期以更快地回收磁盤空間。

在DataStax Java驅動程序中簡單刪除整行如下所示:

SimpleStatement hotelDelete = session.newSimpleStatement(
  "DELETE * FROM hotels WHERE id=?", "AZ123");   
        
ResultSet hotelDeleteResult = session.execute(hotelDelete);

您可以通過在查詢中按名稱標識非主鍵列來刪除它們。

您還可以使用PreparedStatements,QueryBuilder和MappingManager刪除數據。

以下是使用QueryBuilder刪除整行的示例:


BuiltStatement hotelDeleteBuilt = queryBuilder.delete().all().
  from("hotels").where(eq("id", "AZ123"));
     
session.execute(hotelDeleteBuilt);

**

刪除的一致性級別

由於刪除是一種寫入形式,因此可用於刪除的一致性級別與爲寫入列出的一致性級別相同。

總結

在本章中,我們瞭解瞭如何使用cqlsh和客戶端驅動程序讀取,寫入和刪除數據。 我們還在幕後瞭解了Cassandra如何實現這些操作,這有助於我們在使用Cassandra設計,實現,部署和維護應用程序時做出更明智的決策。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章