Apache Doris 入門 10 問
基於 Apache Doris 在讀寫流程、副本一致性機制、 儲存機制、高可用機制等方面的常見疑問點進行梳理,並以問答形式進行解答。在開始之前,我們先對本文相關的 名詞進行解釋:
-
FE :Frontend,即 Doris 的前端節點。主要負責接收和返回客戶端請求、後設資料以及叢集管理、查詢計劃生成等工作。
-
BE :Backend,即 Doris 的後端節點。主要負責資料儲存與管理、查詢計劃執行等工作。
-
BDBJE :Oracle Berkeley DB Java Edition, 在 Doris 中,使用 BDBJE 完成後設資料操作日誌的持久化、FE 高可用等功能。
-
Tablet :Tablet 是一張表實際的物理儲存單元,一張表按照分割槽和分桶後在 BE 構成分散式儲存層中以 Tablet 為單位進行儲存,每個 Tablet 包括元資訊及若干個連續的 RowSet。
-
RowSet :RowSet 是 Tablet 中一次資料變更的資料集合,資料變更包括了資料匯入、刪除、更新等。RowSet 按版本資訊進行記錄。每次變更會生成一個版本。
-
Version :由 Start、End 兩個屬性構成,維護資料變更的記錄資訊。通常用來表示 RowSet 的版本範圍,在一次新匯入後生成一個 Start、End 相等的 RowSet,在 Compaction 後生成一個帶範圍的 RowSet 版本。
-
Segment :表示 RowSet 中的資料分段,多個 Segment 構成一個 RowSet。
-
Compaction :連續版本的 RowSet 合併的過程成稱為 Compaction,合併過程中會對資料進行壓縮操作。
-
Key 列、Value 列 :在 Doris 中,資料以表(Table)的形式進行邏輯上的描述。一張表包括行(Row)和列(Column),Row 即使用者的一行資料,Column 用於描述一行資料中不同的欄位。Column 可以分為兩大類:Key 和 Value。從業務角度看,Key 和 Value 可以分別對應維度列和指標列。Doris 的 Key 列是建表語句中指定的列,建表語句中的關鍵字 unique key 或 aggregate key 或 duplicate key 後面的列就是 Key 列,除了 Key 列剩下的就是 Value 列。
-
資料模型 :Doris 的資料模型主要分為 3 類:Aggregate、Unique、Duplicate。
-
Base 表 :在 Doris 中,我們將使用者透過建表語句建立出來的表稱為 Base 表(Base Table),Base 表中儲存著按使用者建表語句指定方式儲存的基礎資料。
-
ROLLUP 表 :在 Base 表之上,使用者可以建立任意多個 ROLLUP 表。這些 ROLLUP 的資料是基於 Base 表產生的,並且在物理上是 獨立儲存的。ROLLUP 表的基本作用,在於在 Base 表的基礎上,獲得更粗粒度的聚合資料,類似於物化檢視。
Q1:Doris 分割槽跟分桶有什麼區別?
Doris 支援兩層資料劃分:
-
第一層是 Partition(分割槽),支援 Range 和 List 的劃分方式(類似於 MySQL 的分割槽表的概念)。若干個 Partition 組成一個 Table,Partition 可以視為是邏輯上最小的管理單元。資料的匯入與刪除,僅能針對一個 Partition 進行。
-
第二層是 Bucket(Tablet 也稱為分桶),支援 Hash 和 Random 的劃分方式。每個 Tablet 包含若干資料行,各個 Tablet 之間的資料沒有交集,並且在物理上是獨立儲存的。Tablet 是資料移動、複製等操作的最小物理儲存單元。
也可以僅使用一層分割槽,建表時如果不寫分割槽的語句即可,此時 Doris 會生成一個預設的分割槽,對使用者是透明的。
示意如下:
多個 Tablet 在邏輯上歸屬於不同的 分割槽(Partition),一個 Tablet 只屬於一個 Partition,而一個 Partition 包含若干個 Tablet。因為 Tablet 在物理上是獨立儲存的,所以可以視為 Partition 在物理上也是獨立。
從邏輯上來講,分割槽和分桶最大的區別就是分桶隨機分割資料庫,分割槽是非隨機分割資料庫。
怎麼保證資料多副本的?
為了提高儲存資料的可靠性和計算時的效能,Doris 對每個表複製多份進行儲存。資料的每份複製就叫做一個副本。Doris 按 Tablet 為基本單元對資料進行副本儲存,預設一個分片有 3 個副本。建表時可在 PROPERTIES 中設定副本的數量:
PROPERTIES ( "replication_num" = "3" );
下圖示例,有兩個表分別匯入 Doris,表 1 匯入後按 3 副本儲存,表 2 匯入後按 2 副本儲存。資料分佈如下:
Q2:為什麼需要分桶?
為了分桶裁剪,並且避免資料傾斜,同時也為了分散讀 IO,提升查詢效能,可以將 Tablet 的不同副本分散在不同機器上,查詢時可以充分發揮不同機器的 IO 效能。
Q3:物理檔案的儲存結構及格式是怎樣的?
Doris 的每次匯入可視為一個事務,會生成一個 RowSet 。而 RowSet 又包括多個 Segment,即 Tablet-->Rowset-->Segment 。那 BE 是如何儲存這些檔案的呢?
Doris 的儲存結構
Doris 透過 storage_root_path 進行儲存路徑配置,Segment 檔案存放在 tablet_id 目錄下按 SchemaHash 管理。Segment 檔案可以有多個,一般按照大小進行分割,預設為 256MB。儲存目錄以及 Segment 檔案命名規則為:
${storage_root_path}/data/${shard}/${tablet_id}/${schema_hash}/${rowset_id}_${segment_id}.dat
進入 storage_root_path 目錄,可以看到如下儲存結構:
-
${shard}:即上圖中的 0、1。是儲存目錄下 BE 自動建立的,是隨機的。會隨著資料的增多而增多。
-
${tablet_id}:即上圖中的 15123、27003 等,即上面提到的 Bucket 的 ID。
-
${schema_hash}:即上圖中的 727041558、1102328406 等。因為一個表的結構可能會被變更,所以對每個 Schema 的版本生成一個 SchemaHash,來標識該版本下的資料。
-
${segment_id}.dat:其中前面的為 rowset_id,即上圖中的 02000000000000e3ba4924368a21695d8cc3cf8525f80789;${segment_id}為當前 RowSet 的 segment_id,從 0 開始遞增。
Segment 檔案的儲存格式
Segment 整體的檔案格式分為資料區域,索引區域和 Footer 三個部分,如下圖所示:
-
Data Region: 用於儲存各個列的資料資訊,這裡的資料是按需分 Page 載入的,其中 Page 中包含了列的資料,每個 Page 為 64k。
-
Index Region:Doris 中將各個列的 Index 資料統一儲存在 Index Region,這裡的資料會按照列粒度進行載入,所以跟列的資料資訊分開儲存。
-
Footer 資訊:包含檔案的後設資料資訊、內容的 Checksum 等。
Q4:Doris 的不同表模型在 DML 方面有什麼限制?
-
Update:Update 語句目前僅支援 UNIQUE KEY 模型,並且只支援更新 Value 列。
-
Delete:1)如果是使用聚合類的表模型(AGGREGATE、UNIQUE),Delete 操作只能指定 Key 列上的條件;2)該操作會同時刪除和此 Base Index 相關的 Rollup Index 的資料。
-
Insert:所有資料模型均可 Insert。
Insert 怎麼實現?資料插入後如何被查詢到?
-
AGGREGATE 模型:Insert 階段將增量的資料按照 Append 的方式寫到 RowSet,查詢階段採用 Merge on Read 的方式進行進行合併。也就是說資料在匯入時先寫入一個新的 RowSet,寫入後並不會執行去重,只有在發起查詢時才會做多路併發排序,在進行多路歸併排序時,會將重複的 Key 排在一起,並進行聚合操作。其中高版本 Key 的會覆蓋低版本的 Key,最終只返回給使用者版本最高的那一條記錄。
-
DUPLICATE 模型:該模型寫入與上述類似,讀取階段不會有任何聚合操作。
-
UNIQUE 模型:在 1.2 版本之前,該模型本質上是聚合模型的一個特例,行為與 AGGREGATE 模型一致。由於聚合模型的實現方式是讀時合併(Merge on Read),因此在一些聚合查詢上效能不佳。Doris 在 1.2 版本後引入了 Unique 模型新的實現方式,寫時合併(Merge on Write),透過在寫入時將被覆蓋和被更新的資料進行標記刪除,在查詢的時候,所有被標記刪除的資料都會在檔案級別被過濾掉,讀取出來的資料就都是最新的資料,消除掉了讀時合併中的資料聚合過程,並且能夠在很多情況下支援多種謂詞的下推。
簡單來講,Merge on Write 的處理流程是:
-
對於每一條 Key,查詢它在 Base 資料中的位置(RowSetid + Segmentid + 行號)【記憶體中維護了 Segment 級別的主鍵區間樹,加速查詢】
-
如果 Key 存在,則將該行資料標記刪除。標記刪除的資訊記錄在 Delete Bitmap 中,其中每個 Segment 都有一個對應的 Delete Bitmap。
-
將更新的資料寫入新的 RowSet 中,完成事務,讓新資料可見,即能夠被使用者查詢到。
-
查詢時,讀取 Delete Bitmap,將被標記刪除的行過濾掉,只返回有效的資料【對於命中的所有 Segment,按照版本從高到低進行查詢】
下面介紹一下寫入流程和讀取流程的實現。
寫入流程 :寫入資料時會先建立每個 Segment 的主鍵索引,再更新 Delete Bitmap。
讀取流程 :Bitmap 的讀取流程如下圖所示,從圖片中我們可知:
-
一個請求了版本 7 的 Query,只會看到版本 7 對應的資料
-
讀取 RowSet5 的資料時,會將 V6 和 V7 對它的修改產生的 Bitmap 合併在一起,得到 Version7 對應的完整 DeleteBitmap,用來過濾資料
-
在上圖的示例中,版本 8 的匯入覆蓋了 RowSet1 的 Segment2 一條資料,但請求版本 7 的 Query 仍然能讀到該條資料
Update 怎麼實現的?
UNIQUE 模型 Update 過程本質上是 Select+Insert。
-
Update 利用查詢引擎自身的 Where 過濾邏輯,從待更新表中篩選出需要被更新的行,基於此維護 Delete Bitmap 以及生成新插入的資料。
-
接著再執行 Insert 邏輯,具體流程跟上述的 UNIQUE 模型寫入邏輯類似。
Q5:Doris 的 Delete 是怎麼實現的?也是會生成一個 RowSet?如何刪除對應的資料?
-
Doris 的 Delete 也是會生成一個 RowSet,DELETE 模式下沒有對資料進行實際刪除操作,而是對資料刪除條件進行了記錄。儲存在 Meta 資訊中。當執行 Base Compaction 時刪除條件會一起被合入到 Base 版本中。
-
Doris 在 UNIQUE KEY 模型下也支援了 LOAD_DELETE ,實現了透過批次匯入要刪除的 key 對資料進行刪除,能夠支援大量資料刪除能力。整體思路是在資料記錄中加入刪除狀態標識,在 Compaction 流程中會對刪除的 Key 進行壓縮。Compaction 主要負責將多個 RowSet 版本進行合併。
Q6:Doris 有哪些索引?
目前 Doris 主要支援兩類索引:
-
內建的智慧索引,包括字首索引和 ZoneMap 索引。
-
使用者手動建立的二級索引,包括倒排索引、 Bloomfilter 索引、 Ngram Bloomfilter 索引 和 Bitmap 索引。
其中 ZoneMap 索引是在列存格式上,對每一列自動維護的索引資訊,包括 Min/Max,Null 值個數等等。這種索引對使用者透明。
索引是什麼級別?
-
現在 Doris 裡所有索引都是 BE 級別 Local 的,例如:倒排索引、 Bloomfilter 索引、 Ngram Bloomfilter 索引 和 Bitmap 索引、字首索引和 ZoneMap 索引等
-
Doris 沒有 Global Index。廣義理解上,分割槽間+分桶鍵 這些也能算是 Global 的,但是比較粗粒度。
索引的儲存格式是怎樣的?
Doris 中將各個列的 Index 資料統一儲存在 Segment 檔案的 Index Region,這裡的資料會按照列粒度進行載入,所以跟列的資料資訊分開儲存。這裡以 Short Key Index 字首索引為例進行介紹。
Short Key Index 字首索引,是在 Key(AGGREGATE KEY、UNIQ KEY 和 DUPLICATE KEY)排序的基礎上,實現的一種根據給定字首列,快速查詢資料的索引方式。這裡 Short Key Index 索引也採用了稀疏索引結構,在資料寫入過程中,每隔一定行數,會生成一個索引項。這個行數為索引粒度預設為 1024 行,可配置。該過程如下圖所示:
其中,KeyBytes 中存放了索引項資料,OffsetBytes 存放了索引項在 KeyBytes 中的偏移
Short Key Index 採用了前 36 個位元組,作為這行資料的字首索引。當遇到 VARCHAR 型別時,字首索引會直接截斷。
讀的過程如何命中索引?
在查詢一個 Segment 中的資料時,根據執行的查詢條件,會對首先根據欄位加索引的情況對資料進行過濾。然後在進行讀取資料,整體的查詢流程如下:
-
首先,會按照 Segment 的行數構建一個 row_bitmap,表示記錄哪些資料需要進行讀取。沒有使用任何索引的情況下,需要讀取所有資料。
-
當查詢條件中按字首索引規則使用到了 Key 時,會先進行 ShortKey Index 的過濾,可以在 ShortKey Index 中匹配到的 Oordinal 行號範圍,合入到 row_bitmap 中。
-
當查詢條件中列欄位存在 BitMap Index 索引時,會按照 BitMap 索引直接查出符合條件的 Ordinal 行號,與 row_bitmap 求交過濾。這裡的過濾是精確的,之後去掉該查詢條件,這個欄位就不會再進行後面索引的過濾。
-
當查詢條件中列欄位存在 BloomFilter 索引並且條件為等值(eq,in,is)時,會按 BloomFilter 索引過濾,這裡會走完所有索引,過濾每一個 Page 的 BloomFilter,找出查詢條件能命中的所有 Page。將索引資訊中的 Ordinal 行號範圍與 row_bitmap 求交過濾。
-
當查詢條件中列欄位存在 ZoneMap 索引時,會按 ZoneMap 索引過濾,這裡同樣會走完所有索引,找出查詢條件能與 ZoneMap 有交集的所有 Page。將索引資訊中的 Ordinal 行號範圍與 row_bitmap 求交過濾。
-
生成好 row_bitmap 之後,批次透過每個 Column 的 OrdinalIndex 找到到具體的 Data Page。
-
批次讀取每一列的 Column Data Page 的資料。在讀取時,對於有 Null 值的 Page,根據 Null 值點陣圖判斷當前行是否是 Null,如果為 Null 進行直接填充即可。
Q7:Doris 如何進行 Compaction 的?
Doris 透過 Compaction 將增量聚合 RowSet 檔案提升效能,RowSet 的版本資訊中設計了有兩個欄位 Start、End 來表示 Rowset 合併後的版本範圍。未合併的 Cumulative RowSet 的版本 Start 和 End 相等。Compaction 時相鄰的 RowSet 會進行合併,生成一個新的 RowSet,版本資訊的 Start、End 也會進行合併,變成一個更大範圍的版本。另一方面,Compaction 流程大大減少 RowSet 檔案數量,提升查詢效率。
如上圖所示,Compaction 任務分為兩種,Base Compaction 和 Cumulative Compaction。cumulative_point 是分割兩種策略關鍵。
可以這樣理解:
-
cumulative_point 右邊是從未合併過的增量 RowSet,其每個 RowSet 的 Start 與 End 版本相等;
-
cumulative_point 左邊是合併過的 RowSet,Start 版本與 End 版本不等。
Base Compaction 和 Cumulative Compaction 任務流程基本一致,差異僅在選取要合併的 InputRowSet 邏輯有所不同。
Compaction 是按照什麼 Key 來的?
-
在一個 Segment 中,資料始終按照 Key(AGGREGATE KEY、UNIQ KEY 和 DUPLICATE KEY)排序順序進行儲存,即 Key 的排序決定了資料儲存的物理結構,確定了列資料的物理結構順序。
-
所以 Doris Compaction 過程是基於 AGGREGATE KEY、UNIQ KEY 和 DUPLICATE KEY 來進行的。
Q8:Doris 怎麼實現跨叢集資料複製功能?
為了實現跨叢集資料複製功能,Doris 引入了 Binlog 機制。透過 Binlog 機制自動記錄資料修改記錄和操作,以實現資料的可追溯性,同時還可以基於 Binlog 回放機制來實現資料的重放和恢復。
Binlog 怎麼記錄的?
在開啟 Binlog 屬性後,FE 和 BE 會將 DDL/DML 操作的修改記錄持久化成 Meta Binlog 和 Data Binlog。
-
Meta Binlog:Doris 對 EditLog 的實現進行了增強,以確保日誌的有序性。透過構建一個遞增序列的 LogID,對每個操作進行準確記錄,並按順序持久化。這種有序的持久化機制有助於保證資料的一致性。
-
Data Binlog:在 FE 發起 Publish Transaction 的時候,BE 會執行對應的 Publish 操作,BE 會將這次 Transaction 涉及 RowSet 的後設資料資訊寫入以 rowset_meta 為字首的 KV 中,並持久化到 Meta 儲存中,提交後會把匯入的 Segment Files 連結到 Binlog 資料夾下。
Binlog 生成:
BInlog 資料回放:
Q9:Doris 的表是多副本的,寫入階段怎麼保證多副本的,是否有主從概念?需要 Majority 後再返回寫入成功嗎?
-
Doris BE 的 3 副本沒有主從的概念,採用 Quorum 演算法保證多副本寫入。
-
在寫入過程中,FE 會判斷每一個 Tablet 成功寫入資料的副本數量是否超過了 Tablet 副本總數的一半,如果每一個 Tablet 成功寫入資料的副本數量都超過 Tablet 副本總數的一半(多數成功),則 Commit Transaction 成功,並將事務狀態設定為 COMMITTED;COMMITTED 狀態表示資料已經成功寫入,但是資料還不可見,需要繼續執行 Publish Version 任務,此後,事務不可被回滾。
-
FE 會有一個單獨的執行緒對 Commit 成功的 Transaction 執行 Publish Version,FE 執行 Publish Version 時會透過 Thrift RPC 向 Transaction 相關的所有 Executor BE 節點下發 Publish Version 請求,Publish Version 任務在各個 Executor BE 節點非同步執行,將資料匯入生成的 RowSet 變為可見的資料版本。
為什麼會有 Publish 機制 :類似於 MVCC,如果沒有 Publish 機制,使用者可能讀到還沒有提交的資料。
如果表為 3 副本,只寫入成功 1 個副本會怎樣 :這個時候事務會 ABORTED
如果表為 3 副本,只寫入成功 2 副本會怎樣 :這個時候事務會 COMMITTED,Doris FE 會定期執行 Tablet 監控巡檢,如果發現 Tablet 副本異常,會生成 Clone 任務,Clone 一個新的副本。
為什麼使用者執行完 Insert Into,立即執行查詢,結果可能為空呢 :原因是事務還沒有 Publish
Q10:Doris 的 FE 怎麼保證高可用的?
後設資料層面,Doris 採用 Paxos 協議以及 Memory + Checkpoint + Journal 的機制來確保後設資料的高效能及高可靠。
後設資料的資料流 具體過程如下:
-
只有 Leader FE 可以對後設資料進行寫操作。寫操作在修改 Leader 的記憶體後,會序列化為一條 Log,按照 key-value 的形式寫入 BDBJE。其中 Key 為連續的整型,作為 log id,Value 即為序列化後的操作日誌。
-
日誌寫入 BDBJE 後,BDBJE 會根據策略(寫多數/全寫),將日誌複製到其他 Non-Leader 的 FE 節點。Non-Leader FE 節點透過對日誌回放,修改自身的後設資料記憶體映象,完成與 Leader 節點的後設資料同步。
-
Leader 節點的日誌條數達到閾值(預設 10w 條)並且滿足 Checkpoint 執行緒執行週期(預設六十秒)。Checkpoint 會讀取已有的 Image 檔案,和其之後的日誌,重新在記憶體中回放出一份新的後設資料映象副本。然後將該副本寫入到磁碟,形成一個新的 Image。之所以是重新生成一份映象副本,而不是將已有映象寫成 Image,主要是考慮寫 Image 加讀鎖期間,會阻塞寫操作。所以每次 Checkpoint 會佔用雙倍記憶體空間。
-
Image 檔案生成後,Leader 節點會通知其他 Non-Leader 節點新的 Image 已生成。Non-Leader 主動透過 HTTP 拉取最新的 Image 檔案,來更換本地的舊檔案。
-
BDBJE 中的日誌,在 Image 做完後,會定期刪除舊的日誌。
解釋:
-
後設資料的每次更新,都首先寫入到磁碟的日誌檔案中,然後再寫到記憶體中,最後定期 Checkpoint 到本地磁碟上。
-
相當於是一個純記憶體的一個結構,也就是說所有的後設資料都會快取在記憶體之中,從而保證 FE 在當機後能夠快速恢復後設資料,而且不丟失後設資料。
-
Leader、Follower 和 Observer 它們三個構成一個可靠的服務,單機的節點故障的時候其實基本上三個就夠了,因為 FE 節點畢竟它只存了一份後設資料,它的壓力不大,所以如果 FE 太多的時候它會去消耗機器資源,所以多數情況下三個就足夠了,可以達到一個很高可用的後設資料服務。
-
使用者可以使用 MySQL 連線任意一個 FE 節點進行後設資料的讀寫訪問。如果連線的是 Non-Leader 節點,則該節點會將寫操作轉發給 Leader 節點。
作者介紹
隱形(邢穎) 網易資料庫核心工程師,畢業至今一直從事資料庫核心開發工作,目前主要參與 MySQL 與 Apache Doris 的開發維護和業務支援工作。作為 MySQL 核心貢獻者,為 MySQL 上報了 50 多個 Bug 及最佳化項,多個提交被合入 MySQL 8.0 版本。從 2023 年起加入 Apache Doris 社群,Apache Doris Active Contributor,已為社群提交併合入數十個 Commits。
來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70017904/viewspace-3003507/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- Apache Doris 輕鬆入門和快速實踐Apache
- [Apache Doris] Apache Doris 後設資料設計及DDL操作原始碼閱讀Apache原始碼
- Apache Doris 2.0.3 版本正式釋出Apache
- 大資料技術 - Apache Doris大資料Apache
- Apache Doris 2.0.4 版本正式釋出Apache
- Apache Doris 2.0.5 版本正式釋出Apache
- Apache Doris 2.0.5 版本正式釋出!Apache
- Apache Flume 入門教程Apache
- 基於Apache Doris的湖倉分析Apache
- Apache Doris 1.2.2 Release 版本正式釋出Apache
- Apache Hadoop 入門教程ApacheHadoop
- Apache Spark 入門簡介ApacheSpark
- 百度 Doris 專案進入 Apache 基金會孵化器Apache
- Flink CDC 系列 - 實現 MySQL 資料實時寫入 Apache DorisMySqlApache
- Apache Kafka教程--Kafka新手入門ApacheKafka
- Apache Commons IO入門教程Apache
- doris匯入匯出
- Apache Doris 1.2.4 Release 版本正式釋出|版本通告Apache
- [翻譯]Apache Spark入門簡介ApacheSpark
- 更穩定!Apache Doris 1.2.1 Release 版本正式釋出Apache
- Apache Kylin 入門 5 – 構建 CubeApache
- Apache Kylin 入門 6 - 優化 CubeApache優化
- Apache Kylin 入門 1 - 基本概念Apache
- Apache Kylin 入門 4 - 構建 ModelApache
- Apache Kylin 入門 4 – 構建 ModelApache
- Apache Kylin 入門 5 - 構建 CubeApache
- Apache Doris(incubating) 成功釋出第一個版本0.9.0ApacheBAT
- Apache Doris設計思想介紹與應用場景Apache
- 《PHP、MySQL和Apache入門經典(第5版)》一2.10Q&APHPMySqlApache
- Apache Flink X Apache Doris 構建極速易用的實時數倉架構Apache架構
- 教程:Apache Spark SQL入門及實踐指南!ApacheSparkSQL
- Apache Kylin 入門 3 - 安裝與配置Apache
- Apache Kylin 入門 2 - 原理與架構Apache架構
- Java日誌服務入門系列教程——(2)Apache log4j入門JavaApache
- 基於Ansible實現Apache Doris快速部署運維指南Apache運維
- 全面進化!Apache Doris 1.2.0 Release 版本正式釋出|版本通告Apache
- lucene入門問題
- Apache Flink 進階入門(二):Time 深度解析Apache