kafka-穩定性-事務
穩定性
事務
一、事務場景
- 如producer發的多條訊息組成一個事務這些訊息需要對consumer同時可見或者同時不可見 。
- producer可能會給多個topic,多個partition發訊息,這些訊息也需要能放在一個事務裡面,這就形成了一個典型的分散式事務。
- kafka的應用場景經常是應用先消費一個topic,然後做處理再發到另一個topic,這個consume-transform-produce過程需要放到一個事務裡面,比如在訊息處理或者傳送的過程中如果失敗了,消費偏移量也不能提交。
- producer或者producer所在的應用可能會掛掉,新的producer啟動以後需要知道怎麼處理之前未完成的事務 。
- 在一個原子操作中,根據包含的操作型別,可以分為三種情況,前兩種情況是事務引入的場景,最後一種沒用。
- 只有Producer生產訊息;
- 消費訊息和生產訊息並存,這個是事務場景中最常用的情況,就是我們常說的consume-transform-produce 模式
- 只有consumer消費訊息,這種操作其實沒有什麼意義,跟使用手動提交效果一樣,而且也不是事務屬性引入的目的,所以一般不會使用這種情況
二、幾個關鍵概念和推導 - 因為producer傳送訊息可能是分散式事務,所以引入了常用的2PC,所以有事務協調者(Transaction Coordinator)。Transaction Coordinator和之前為了解決腦裂和驚群問題引入的Group Coordinator在選舉上類似。
- 事務管理中事務日誌是必不可少的,kafka使用一個內部topic來儲存事務日誌,這個設計和之前使用內部topic儲存偏移量的設計保持一致。事務日誌是Transaction Coordinator管理的狀態的持久化,因為不需要回溯事務的歷史狀態,所以事務日誌只用儲存最近的事務狀態。
__transaction_state - 因為事務存在commit和abort兩種操作,而客戶端又有read committed和read uncommitted兩種隔離級別,所以訊息佇列必須能標識事務狀態,這個被稱作Control Message。
- producer掛掉重啟或者漂移到其它機器需要能關聯的之前的未完成事務所以需要有一個唯一標
識符來進行關聯,這個就是TransactionalId,一個producer掛了,另一個有相同TransactionalId的producer能夠接著處理這個事務未完成的狀態。kafka目前沒有引入全域性序,所以也沒有transaction id,這個TransactionalId是使用者提前配置的。 - TransactionalId能關聯producer,也需要避免兩個使用相同TransactionalId的producer同時存在,所以引入了producer epoch來保證對應一個TransactionalId只有一個活躍的producer
事務語義
多分割槽原子寫入
事務能夠保證Kafka topic下每個分割槽的原子寫入。事務中所有的訊息都將被成功寫入或者丟棄。首先,我們來考慮一下原子 讀取-處理-寫入 週期是什麼意思。簡而言之,這意味著如果某個應用程式在某個topic tp0的偏移量X處讀取到了訊息A,並且在對訊息A進行了一些處理(如B = F(A))之後將訊息B寫入topic tp1,則只有當訊息A和B被認為被成功地消費並一起釋出,或者完全不釋出時,整個讀取過程寫入操作是原子的。
現在,只有當訊息A的偏移量X被標記為已消費,訊息A才從topic tp0消費,消費到的資料偏移量(record offset)將被標記為提交偏移量(Committing offset)。在Kafka中,我們通過寫入一個名為offsets topic的內部Kafka topic來記錄offset commit。訊息僅在其offset被提交給offsets topic時才被認為成功消費。
由於offset commit只是對Kafkatopic的另一次寫入,並且由於訊息僅在提交偏移量時被視為成功消費,所以跨多個主題和分割槽的原子寫入也啟用原子 讀取-處理-寫入 迴圈:提交偏移量X到offset topic和消
息B到tp1的寫入將是單個事務的一部分,所以整個步驟都是原子的
粉碎“殭屍例項”
我們通過為每個事務Producer分配一個稱為transactional.id的唯一識別符號來解決殭屍例項的問題。
在程式重新啟動時能夠識別相同的Producer例項。
API要求事務性Producer的第一個操作應該是在Kafka叢集中顯示註冊transactional.id。 當註冊的
時候,Kafka broker用給定的transactional.id檢查開啟的事務並且完成處理。 Kafka也增加了一個與
transactional.id相關的epoch。Epoch儲存每個transactional.id內部後設資料。
一旦epoch被觸發,任何具有相同的transactional.id和舊的epoch的生產者被視為殭屍,Kafka拒絕
來自這些生產者的後續事務性寫入。
簡而言之:Kafka可以保證Consumer最終只能消費非事務性訊息或已提交事務性訊息。它將保留來
自未完成事務的訊息,並過濾掉已中止事務的訊息
事務訊息定義
生產者可以顯式地發起事務會話,在這些會話中傳送(事務)訊息,並提交或中止事務。有如下要求:
- 原子性:消費者的應用程式不應暴露於未提交事務的訊息中。
- 永續性:Broker不能丟失任何已提交的事務。
- 排序:事務消費者應在每個分割槽中以原始順序檢視事務訊息。
- 交織:每個分割槽都應該能夠接收來自事務性生產者和非事務生產者的訊息
- 事務中不應有重複的訊息。
如果允許事務性和非事務性訊息的交織,則非事務性和事務性訊息的相對順序將基於附加(對於非事務性訊息)和最終提交(對於事務性訊息)的相對順序
分割槽p0和p1接收事務X1和X2的訊息,以及非事務性訊息。時間線是訊息到達Broker的
時間。由於首先提交了X2,所以每個分割槽都將在X1之前公開來自X2的訊息。由於非事務性訊息在X1和
X2的提交之前到達,因此這些訊息將在來自任一事務的訊息之前公開
事務配置
1、建立消費者程式碼,需要:
將配置中的自動提交屬性(auto.commit)進行關閉
而且在程式碼裡面也不能使用手動提交commitSync( )或者commitAsync( )
設定isolation.level:READ_COMMITTED或READ_UNCOMMITTED
2、建立生成者,程式碼如下,需要:
配置transactional.id屬性
配置enable.idempotence屬性
事務概覽
生產者將表示事務開始/結束/中止狀態的事務控制訊息傳送給使用多階段協議管理事務的高可用事
務協調器。生產者將事務控制記錄(開始/結束/中止)傳送到事務協調器,並將事務的訊息直接傳送到
目標資料分割槽。消費者需要了解事務並緩衝每個待處理的事務,直到它們到達其相應的結束(提交/中
止)記錄為止。
事務組
事務組中的生產者
事務組的事務協調器
Leader brokers(事務資料所在分割槽的Broker)
事務的消費者
事務組
事務組用於對映到特定的事務協調器(基於日誌分割槽數字的雜湊)。該組中的生產者需要配置為該組事務生產者。由於來自這些生產者的所有事務都通過此協調器進行,因此我們可以在這些事務生產者之間實現嚴格的有序
生產者ID和事務組狀態
事務生產者需要兩個新引數:生產者ID和生產組。
需要將生產者的輸入狀態與上一個已提交的事務相關聯。這使事務生產者能夠重試事務(通過為該事務重新建立輸入狀態;在我們的用例中通常是偏移量的向量)
可以使用消費者偏移量管理機制來管理這些狀態。消費者偏移量管理器將每個鍵(
consumergroup-topic-partition )與該分割槽的最後一個檢查點偏移量和後設資料相關聯。在事務生產
者中,我們儲存消費者的偏移量,該偏移量與事務的提交點關聯。此偏移提交記錄(在
__consumer_offsets 主題中)應作為事務的一部分寫入。即,儲存消費組偏移量的
__consumer_offsets 主題分割槽將需要參與事務。因此,假定生產者在事務中間失敗(事務協調器隨後
到期);當生產者恢復時,它可以發出偏移量獲取請求,以恢復與最後提交的事務相關聯的輸入偏移
量,並從該點恢復事務處理。
為了支援此功能,我們需要對偏移量管理器和壓縮的 __consumer_offsets 主題進行一些增強。
首先,壓縮的主題現在還將包含事務控制記錄。我們將需要為這些控制記錄提出剔除策略。
其次,偏移量管理器需要具有事務意識;特別是,如果組與待處理的事務相關聯,則偏移量提取請
求應返回錯誤
事務協調器
事務協調器是 __transaction_state 主題特定分割槽的Leader分割槽所在的Broker。它負責初始
化、提交以及回滾事務。事務協調器在記憶體管理如下的狀態:
對應正在處理的事務的第一個訊息的HW。事務協調器週期性地將HW寫到ZK。
事務控制日誌中儲存對應於日誌HW的所有正在處理的事務:
事務訊息主題分割槽的列表。
事務的超時時間。
與事務關聯的Producer ID。
需要確保無論是什麼樣的保留策略(日誌分割槽的刪除還是壓縮),都不能刪除包含事務HW的日誌分段。
事務流程
- 初始階段
- Producer:計算哪個Broker作為事務協調器。
- Producer:向事務協調器傳送BeginTransaction(producerId, generation, partitions… )請
求,當然也可以傳送另一個包含事務過期時間的。如果生產者需要將消費者狀態作為事務的一
部分提交事務,則需要在BeginTransaction中包含對應的 __consumer_offsets 主題分割槽資訊。 - Broker:生成事務ID
- Coordinator:向事務協調主題追加BEGIN(TxId, producerId, generation, partitions…)訊息,
然後傳送響應給生產者。 - Producer:讀取響應(包含了事務ID:TxId)
- Coordinator (and followers):在記憶體更新當前事務的待確認事務狀態和資料分割槽資訊
- 傳送階段
Producer:傳送事務訊息給主題Leader分割槽所在的Broker。每個訊息需要包含TxId和TxCtl欄位。
TxCtl僅用於標記事務的最終狀態(提交還是中止)。生產者請求也封裝了生產者ID,但是不追加到日誌
中。 - 結束階段 (生產者準備提交事務)
- Producer:傳送OffsetCommitRequest請求提交與事務結束狀態關聯的輸入狀態(如下一個
事務輸入從哪兒開始) - Producer:傳送CommitTransaction(TxId, producerId, generation)請求給事務協調器並等待
響應。(如果響應中沒有錯誤資訊,表示將提交事務) - Coordinator:向事務控制主題追加PREPARE_COMMIT(TxId)請求並向生產者傳送響應。
4 Coordinator:向事務涉及到的每個Leader分割槽(事務的業務資料的目標主題)的Broker傳送
一個CommitTransaction(TxId, partitions…)請求。 - 事務業務資料的目標主題相關Leader分割槽Broker
. 如果是非 __consumer_offsets 主題的Leader分割槽:一收到
CommitTransaction(TxId, partition1, partition2, …)請求就會向對應的分割槽Broker
傳送空(null)訊息(沒有key/value)並給該訊息設定TxId和TxCtl(設定為
COMMITTED)欄位。Leader分割槽的Broker給協調器傳送響應。 - 如果是 __consumer_offsets 主題的Leader分割槽:追加訊息,該訊息的key是 GLAST-COMMIT ,value就是 TxId 的值。同時也應該給該訊息設定TxId和TxCtl欄位。
Broker向協調器傳送響應。 - Coordinator:向事務控制主題傳送COMMITTED(TxId)請求。 __transaction_state
- Coordinator (and followers):嘗試更新HW。
事務的中止
當事務生產者傳送業務訊息的時候如果發生異常,可以中止該事務。如果事務提交超時,事務協調
器也會中止當前事務。
Producer:向事務協調器傳送AbortTransaction(TxId)請求並等待響應。(一個沒有異常的響應
表示事務將會中止)
Coordinator:向事務控制主題追加PREPARE_ABORT(TxId)訊息,然後向生產者傳送響應。
Coordinator:向事務業務資料的目標主題的每個涉及到的Leader分割槽Broker傳送
AbortTransaction(TxId, partitions…)請求。(收到Leader分割槽Broker響應後,事務協調器中止
動作跟上面的提交類似。)
基本事務流程的失敗
生產者傳送BeginTransaction(TxId):的時候超時或響應中包含異常,生產者使用相同的TxId重
試。
生產者傳送資料時的Broker錯誤:生產者應中止(然後重做)事務(使用新的TxId)。如果生
產者沒有中止事務,則協調器將在事務超時後中止事務。僅在可能已將請求資料附加並複製到
Follower的錯誤的情況下才需要重做事務。例如,生產者請求超時將需要重做,而
NotLeaderForPartitionException不需要重做。
生產者傳送CommitTransaction(TxId)請求超時或響應中包含異常,生產者使用相同的TxId重
試事務。此時需要冪等性
主題的壓縮
壓縮主題在壓縮過程中會丟棄具有相同鍵的早期記錄。如果這些記錄是事務的一部分,這合法嗎?
這可能有點怪異,但可能不會太有害,因為在主題中使用壓縮策略的理由是保留關鍵資料的最新更新。
如果該應用程式正在(例如)更新某些表,並且事務中的訊息對應於不同的鍵,則這種情況可能導
致資料庫檢視不一致
事務相關配
- Broker
-
生產者
-
消費者
冪等性
Kafka在引入冪等性之前,Producer向Broker傳送訊息,然後Broker將訊息追加到訊息流中後給Producer返回Ack訊號值。實現流程如下
生產中,會出現各種不確定的因素,比如在Producer在傳送給Broker的時候出現網路異常。比如以下這種異常情況的出現
上圖這種情況,當Producer第一次傳送訊息給Broker時,Broker將訊息(x2,y2)追加到了訊息流中,
但是在返回Ack訊號給Producer時失敗了(比如網路異常) 。此時,Producer端觸發重試機制,將訊息
(x2,y2)重新傳送給Broker,Broker接收到訊息後,再次將該訊息追加到訊息流中,然後成功返回Ack信
號給Producer。這樣下來,訊息流中就被重複追加了兩條相同的(x2,y2)的訊息
冪等性
保證在訊息重發的時候,消費者不會重複處理。即使在消費者收到重複訊息的時候,重複處理,也
要保證最終結果的一致性。
所謂冪等性,數學概念就是: f(f(x)) = f(x) 。f函式表示對訊息的處理。
比如,銀行轉賬,如果失敗,需要重試。不管重試多少次,都要保證最終結果一定是一致的
冪等性實現
新增唯一ID,類似於資料庫的主鍵,用於唯一標記一個訊息。
Kafka為了實現冪等性,它在底層設計架構中引入了ProducerID和SequenceNumber
- ProducerID:在每個新的Producer初始化時,會被分配一個唯一的ProducerID,這個ProducerID對客戶端使用者是不可見的。
SequenceNumber:對於每個ProducerID,Producer傳送資料的每個Topic和Partition都對應一個從0開始單調遞增的SequenceNumber值
同樣,這是一種理想狀態下的傳送流程。實際情況下,會有很多不確定的因素,比如Broker在傳送
Ack訊號給Producer時出現網路異常,導致傳送失敗。異常情況如下圖所示
當Producer傳送訊息(x2,y2)給Broker時,Broker接收到訊息並將其追加到訊息流中。此時,Broker返回Ack訊號給Producer時,發生異常導致Producer接收Ack訊號失敗。對於Producer來說,會觸發重試機制,將訊息(x2,y2)再次傳送,但是,由於引入了冪等性,在每條訊息中附帶了PID(ProducerID)和SequenceNumber。相同的PID和SequenceNumber傳送給Broker,而之前Broker快取過之前傳送的相同的訊息,那麼在訊息流中的訊息就只有一條(x2,y2),不會出現重複傳送的情況
客戶端在生成Producer時,會例項化如下程式碼
// 例項化一個Producer物件
Producer<String, String> producer = new KafkaProducer<>(props);
在org.apache.kafka.clients.producer.internals.Sender類中,在run()中有一個
maybeWaitForPid()方法,用來生成一個ProducerID,實現程式碼如下
private void maybeWaitForPid() {
if (transactionState == null)
return;
while (!transactionState.hasPid()) {
try {
Node node = awaitLeastLoadedNodeReady(requestTimeout);
if (node != null) {
ClientResponse response =
sendAndAwaitInitPidRequest(node);
if (response.hasResponse() && (response.responseBody()
instanceof InitPidResponse)) {
InitPidResponse initPidResponse = (InitPidResponse)
response.responseBody();
transactionState.setPidAndEpoch(initPidResponse.producerId(),
initPidResponse.epoch());
} else {
log.error("Received an unexpected response type for
an InitPidRequest from {}. " +
"We will back off and try again.", node);
}
} else {
log.debug("Could not find an available broker to send
InitPidRequest to. " +
"We will back off and try again.");
}
} catch (Exception e) {
log.warn("Received an exception while trying to get a pid.
Will back off and retry.", e);
} l
og.trace("Retry InitPidRequest in {}ms.", retryBackoffMs);
time.sleep(retryBackoffMs);
metadata.requestUpdate();
}
}
事務操作
在Kafka事務中,一個原子性操作,根據操作型別可以分為3種情況。情況如下:
只有Producer生產訊息,這種場景需要事務的介入;
消費訊息和生產訊息並存,比如Consumer&Producer模式,這種場景是一般Kafka專案中比較常見的模式,需要事務介入;
只有Consumer消費訊息,這種操作在實際專案中意義不大,和手動Commit Offsets的結果一樣,而且這種場景不是事務的引入目的
// 初始化事務,需要注意確保transation.id屬性被分配
void initTransactions();
// 開啟事務
void beginTransaction() throws ProducerFencedException;
// 為Consumer提供的在事務內Commit Offsets的操作
void sendOffsetsToTransaction(Map<TopicPartition, OffsetAndMetadata>
offsets,
String consumerGroupId) throws
ProducerFencedException;
// 提交事務
void commitTransaction() throws ProducerFencedException;
// 放棄事務,類似於回滾事務的操作
void abortTransaction() throws ProducerFencedException;
案例1:單個Producer,使用事務保證訊息的僅一次傳送
public class MyTransactionalProducer {
public static void main(String[] args) {
Map<String, Object> configs = new HashMap<>();
configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "node1:9092");
configs.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
StringSerializer.class);
configs.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
StringSerializer.class);
// 提供客戶端ID
configs.put(ProducerConfig.CLIENT_ID_CONFIG, "tx_producer");
// 事務ID
configs.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "my_tx_id");
// 要求ISR都確認
configs.put(ProducerConfig.ACKS_CONFIG, "all");
KafkaProducer<String, String> producer = new KafkaProducer<String,
String>(configs);
// 初始化事務
producer.initTransactions();
// 開啟事務
producer.beginTransaction();
try {
// producer.send(new ProducerRecord<>("tp_tx_01", "tx_msg_01"));
producer.send(new ProducerRecord<>("tp_tx_01", "tx_msg_02"));
// int i = 1 / 0;
// 提交事務
producer.commitTransaction();
} catch (Exception ex) {
// 中止事務
producer.abortTransaction();
} finally {
// 關閉生產者
producer.close();
}
}
}
案例2:在 消費-轉換-生產 模式,使用事務保證僅一次傳送
public class MyTransactional {
public static KafkaProducer<String, String> getProducer() {
Map<String, Object> configs = new HashMap<>();
configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "node1:9092");
configs.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
StringSerializer.class);
configs.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
StringSerializer.class);
// 設定client.id
configs.put(ProducerConfig.CLIENT_ID_CONFIG, "tx_producer_01");
// 設定事務id
configs.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "tx_id_02");
// 需要所有的ISR副本確認
configs.put(ProducerConfig.ACKS_CONFIG, "all");
// 啟用冪等性
configs.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
KafkaProducer<String, String> producer = new KafkaProducer<String,
String>(configs);
return producer;
} p
ublic static KafkaConsumer<String, String> getConsumer(String
consumerGroupId) {
Map<String, Object> configs = new HashMap<>();
configs.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "node1:9092");
configs.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class);
configs.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class);
// 設定消費組ID
configs.put(ConsumerConfig.GROUP_ID_CONFIG, "consumer_grp_02");
// 不啟用消費者偏移量的自動確認,也不要手動確認
configs.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
configs.put(ConsumerConfig.CLIENT_ID_CONFIG, "consumer_client_02");
configs.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
// 只讀取已提交的訊息
// configs.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG,
"read_committed");
KafkaConsumer<String, String> consumer = new KafkaConsumer<String,
String>(configs);
return consumer;
}
p
ublic static void main(String[] args) {
String consumerGroupId = "consumer_grp_id_101";
KafkaProducer<String, String> producer = getProducer();
KafkaConsumer<String, String> consumer =
getConsumer(consumerGroupId);
// 事務的初始化
producer.initTransactions();
//訂閱主題
consumer.subscribe(Collections.singleton("tp_tx_01"));
final ConsumerRecords<String, String> records =
consumer.poll(1_000);
// 開啟事務
producer.beginTransaction();
try {
Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>
();
for (ConsumerRecord<String, String> record : records) {
System.out.println(record);
producer.send(new ProducerRecord<String, String>
("tp_tx_out_01", record.key(), record.value()));
offsets.put(
new TopicPartition(record.topic(),
record.partition()),
new OffsetAndMetadata(record.offset() + 1)); // 偏
移量表示下一條要消費的訊息
} /
/ 將該訊息的偏移量提交作為事務的一部分,隨事務提交和回滾(不提交消費偏移
量)
producer.sendOffsetsToTransaction(offsets, consumerGroupId);
// int i = 1 / 0;
// 提交事務
producer.commitTransaction();
} catch (Exception e) {
e.printStackTrace();
// 回滾事務
producer.abortTransaction();
} finally {
// 關閉資源
producer.close();
consumer.close();
}
}
相關文章
- 穩定性
- 思考:如何保證服務穩定性?
- 【穩定性】穩定性建設之依賴設計
- 大型微服務架構穩定性建設策略微服務架構
- Kafka 的穩定性Kafka
- 如何利用 “叢集流控” 保障微服務的穩定性?微服務
- App穩定性測試APP
- 【穩定性】從專案風險管理角度探討系統穩定性
- SAP QM 穩定性研究功能研習系列1 - 穩定性研究總流程
- OpenSergo & CloudWeGo 共同保障微服務執行時流量穩定性GoCloud微服務
- 概念解讀穩定性保障
- 淺談系統的不確定性與穩定性
- Node.js 指南(ABI穩定性)Node.js
- 研發效能與穩定性保障
- 穩定性保障,如何慢慢放量灰度
- app穩定性測試-iOS篇APPiOS
- 智慧支付穩定性測試實戰
- 李亞普洛夫穩定性演示圖
- 伺服器如何測試穩定性伺服器
- 伺服器穩定性測試方法伺服器
- 穩定性領導者!阿里雲獲得信通院多項系統穩定性最高階認證阿里
- 直播系統原始碼,利用重試機制保證服務穩定性原始碼
- 淘寶如何保障業務穩定性——諾亞(Noah)自適應流控
- 下單穩定性治理 | 得物技術
- 如何維持網站穩定性的方式?網站
- FastHook——遠超YAHFA的優異穩定性ASTHook
- GaussDB(for Redis)穩定性與擴容表現Redis
- Kubernetes 穩定性保障手冊:洞察+預案
- 如何確保有狀態 Kubernetes 的穩定性
- Kubernetes 穩定性保障手冊 -- 極簡版
- 當我們談微服務,我們在談什麼 (3) — 如何保障微服務的穩定性微服務
- 應對 DevOps 中的技術債務:創新與穩定性的微妙平衡dev
- 五年磨一劍:滴滴順風車服務端之穩定性規範服務端
- Runaway Queries 管理:提升 TiDB 穩定性的智慧引擎TiDB
- 伺服器的穩定性怎麼檢測?伺服器
- 四個步驟,教你落地穩定性保障工作
- 【知識分享】linux伺服器穩定性如何Linux伺服器
- 軟體穩定性測試的測試點