Milvus 2.0 資料插入與持久化

Zilliz發表於2022-03-31
編者按:本文詳細介紹了Milvus2.0資料插入流程以及持久化方案

Milvus 2.0 整體架構介紹
資料寫入相關的元件介紹

  • Proxy
  • Data coord
  • Data node
  • Root coord & Time tick

Data allocation 資料分配

  • 資料組織結構

檔案結構及資料持久化

Milvus 2.0 整體架構介紹

上圖是 Milvus 2.0 的一個整體架構圖,從最左邊 SDK 作為入口,通過 Load Balancer 把請求發到 Proxy 這一層。接著 Proxy 會和最上面的 Coordinator Service(包括 Root Coord 、 Root Query、Data 和 Index)通過和他們進行互動,然後把 DDL 和 DML 寫到我們的 Message Storage 裡。

在下方的 Worker Node:包括 Query Node、Data Node 和 Index Node, 會從 Message Storage 去消費這些請求資料。query node 負責查詢,data node 負責寫入和持久化,index node 負責建索引和加速查詢。

最下面這一層是資料儲存層 (Object Storage),使用的物件儲存主要是 MinIO、S3 和 AzureBlob,用來儲存 Log、Delta 和 Index file。

資料寫入相關的元件介紹

Proxy

Proxy 作為一個資料請求的入口,它的作用從一開始接受 SDK 的插入請求,然後把這些請求收到的資料雜湊到多個桶裡,然後向 DataCoord (data coordinator) 去請求分配 segment 的空間。(Segment 是 Milvus 資料儲存的一個最小的單元,後文會詳細介紹)接下來的一步就是把請求到的空間的這一部分資料插入到 message storage 裡面。插入到 message storage 之後,這些資料就不會再丟失了。

接下來我們看資料流的一些細節:

  • Proxy 可以有多個
  • Collection 下有 V1、V2、V3、V4 的 VChannel
  • C1、C2、C3、C4 就是一些 PChannel,我們叫它物理 channel
  • 多個 V channel 可以對應到同一個 PChannel
  • 每一個 proxy 都會對應所有的 VChannel:對於一個 collection 不同的 proxy 也需要負責這個 collection 裡邊的所有的 channel。
  • 為了避免 VChannel 太多導致資源消耗太大,多個 VChannel 可以對應一個 PChannel

DataCoord

DataCoord 有幾個功能:

  • 分配 Segment 資料
    把 Segment 空間分配到 proxy 後,proxy 可以使用這部分空間來插入資料。
  • 記錄分配空間及其過期時間
    每一個分配都不是永久的,都會有一個過期時間。
  • Segment flush 邏輯
    如果這個 Segment 寫滿,就會落盤。
  • Channel 分配訂閱
    一個 collection 可以有很多 channel 。哪些 channel 被哪些 Data Node 消費則需要 DataCoord 來做一個整體的分配。

Data Node

Data Node 有幾個功能:

  • 消費來自這個資料流的資料,以及進行這個資料的序列化。
  • 在記憶體裡面快取寫入的資料,然後達到定量之後把它自動 flush 到磁碟上面。

總結:
DataCoord 管理 channel 與 segment 的分配;Data Node 主要負責消費和持久化。

DataNode 與 Channel 的關係

如果一個 collection 有四個 channel 的話,可能的分配關係就是兩個 Data Node 各消費兩個 VChannel。這是由 DataCoord 來分配的。那為什麼一個 VChannel 不能分到多個 Data Node上?因為這樣的話就會導致會這個資料被消費多次,進而導致一個 segment 資料的重複。

RootCoord & Time Tick

Time Tick (時間戳)在 Milvus 2.0 中算是一個非常重要的概念,它是整個系統推進的一個關鍵的概念;RootCoord 是一個 TSO 服務的作用,它負責的是全域性時鐘的分配,每個請求都會對應一個時間戳。 Time Tick 是遞增的,表示系統推進到哪個時間點,與寫入和查詢都有很大關係;RootCoord 負責分配時間戳,預設 0.2 秒。

Proxy 寫入資料的時候,每一個請求都會帶一個時間戳。Data Node 每次以時間戳為區間進行消費。以上圖為例,箭頭方向就是這個資料寫入的一個過程, 126578 這些數字就是時間戳的一個大小。Written by 這一行代表 proxy 寫入, P1 就是 proxy 1。如果以 Time Tick 為區間來進行消費的話,在 5 這個區間之前,我們第一次讀的話是隻會讀到1、2 這兩個訊息。因為 6 比 5 大,所以他們在下一次 5 到 9 這個區間被消費到。

Data Allocation 資料分配

資料組織結構

Collection,Partition, Channel 和 Segment 的關係:

  • Collection:最外層是一個 collection (相當於表的概念), collection 裡面會分多個 partition。
  • Partition:每個 partition 以時間為單位去劃分; partition 和 channel 是一個正交的關係,就是每一個 partition 和每一個 channel 會定義一個 segment 的位置。
    (備註:Channel 和 shard 是一樣的概念: 我們文件裡可能有些地方寫的是 shard,shard 這個概念和 channel 是等價的。為了前後統一,我們這裡統稱為 channel 。)
  • Segment:
    Segment 是由 collection+partition+channel 這三者一起來定義的。Segment 是資料分配的一個最小的單元。索引以 Segment 為單位建立,查詢也會以 Segment 為單位在不同的 QueryNode 上做 load balance。在 Segment 內部會有一些 Binlog,就是當我們消費資料之後,會形成一個 Binlog 檔案。

Segment 在記憶體中的狀態有 3 種,分別是 Growing、Sealed 和 Flushed。
Growing:當新建了一個 segment 時就是 growing 的狀態,它在一個可分配的狀態。
Sealed:Segment 已經被關閉了,它的空間不可以再往外分配。
Flushed:Segment 已經被寫入磁碟

Growing segment 內部的空間可以分為三部份:

  • Used (已經使用的空間):已經被 Data Node 消費掉。
  • Allocated:Proxy 向 DataCoord deletor 去請求 segment 分配出的空間。
  • Free:還沒有用到的空間。

  • Channel:

    • Channel 的分配邏輯為何?
      每一個 collection 它會分為多個 channel,然後每一個 channel 都會給到一個 Data Node 去消費這裡面的資料,然後我們會有比較多的策略去做這個分配。Milvus 內部目前實現了 2 種分配策略:

      1. 一致性雜湊
        現在系統內部的一個預設的策略是通過一致性雜湊來做分配。就是每個 channel 先做一個雜湊,然後在這個環上找一個位置,然後通過順時針找到離它最近的一個節點,把這個 channel 分配給這個 DataNode,比如說 Channel 1 分給 Data Node 2, Channel 2 分給 Data Node 3。
      2. 儘量將同一個 collection 的 channel 分佈到不同的 DataNode 上,且不同 DataNode 上 channel 數量儘量相等,以達到負載均衡。
        如果 DataCoord 通過一致性希這種方案來做的話,DataNode 的增減,也就是它上線或者下線都會導致一個 channel 的重新分配。然後我們是怎麼做的呢?DataCoord 通過 etcd 來 watch DataNode 狀態,如果 DataNode 上下線的話會通知到 DataCoord,然後 DataCoord 會決定這個 channel 之後分配到哪裡。
    • 那什麼時候分配 Channel?

      • DataNode 啟動/下線
      • Proxy 請求分配 segment 空間時

什麼時候進行資料分配?

這個流程首先從 client 開始 (如上圖所示)

  1. 插入請求,然後產生一個時間戳- t1。
  2. Proxy 向 DataCoord 傳送一個分配 segment 的請求。
  3. DataCoord 進行分配,並且把這個分配的空間存到 meta server 裡面去做持久化。
  4. DataCoord 再把分配的空間返回給 proxy, proxy 就可以用這部分空間來儲存資料。從圖中我們可以看到有一個 t1 的插入請求,而我們返回的那個 segment 裡面有一個過期時間是 t2。從這裡就可以看到,其實我們的 t1 一定是小於 t2。這一點在後面的文章將詳細解釋。

如何分配 segment?

當我們 DataCoord 在收到分配的請求之後,如何來做分配?

首先我們來了解 InsertRequest 包含了什麼?它包含了 CollectionID、PartitionID、Channel 和 NumOfRows。

Milvus 目前有多個策略:

預設的策略:如果目前有足夠空間來存這些 rows,就優先使用已建立的 segment 空間;如果沒有,則新建 segment。如何判斷空間足夠?前文我們講到 segment 有三部分,一個是已經使用的部分,一個是已經分配的部分,還有空餘的部分,所以,空間=總大小-已經使用-已分配的,結果可能比較小,分配空間隨著時間會過期,Free 部分也就會變大。

1 個請求可以返回 1 或多個 segment 空間,我們 segment 最大的大小是在 data_coord.yaml 這個檔案裡有清楚定義。

資料過期的邏輯

  1. 每一次分配出去的空間都會帶一個過期時間(Time Tick 可比較)
  2. 資料 insert 時會分配一個 time tick,然後再請求 DataCoord 分配 segment,所以這個 time tick 一定小於 T。
  3. 過期的時間預設是 2000 毫秒,這個是通過這 data_coord.yaml 裡的 segment.assignmentExpiration這個引數來定義的。

何時 seal segment?

上面提到的分配一定是針對對 growing 這個狀態的 segment,那什麼時候狀態會變成 sealed?

Sealed segment 表示這個 segment 的空間不可以再進行分配。有幾種條件可以 seal 一個 segment:

  1. 空間使用了達到上限(75%)。
  2. 收到 Flush collection 要把這個 collection 裡面所有的資料都持久化,這個 segment 就不能再分配空間了。
  3. Segment 存活時間太長。
  4. 太多 Growing segment 會導致 DataNode 記憶體使用較多,進而強制關閉存活時間最久的那一部分 segment。

何時落盤?

Flush 是把 segment 的資料持久化到物件儲存。

我們需要等待它所被分配到的空間過期,然後我們才能去執行 flush 操作。Flush 完了之後,這個 segment 就是一個 flushed segment。

那這個等待具體的操作為何?

DataNode 上報消費到的 time tick ,接著與分配出去空間的 time tick 做比較,如果 time tick 較大,說明這部分空間已經可以釋放了。如果比最後一次分配的時間戳大,說明分配出去空間都釋放了,不會再有新的資料寫入到這個 segment,可以 Flush。

常見的問題和細節

  1. 我們怎麼保證所有的資料都被消費了之後,這個 segment 才被 flush?
    Data Node 會告訴 DataCoord 目前 channel 消費到那個時間戳,time tick 表示之前的資料都已經消費完了,這時候關閉是安全的。
  2. 在 segment flush 之後,如何保證沒有資料再寫入?
    因為 flush 和 sealed 的這個狀態的 segment 都不會再去分配空間了,所以它就不會再有資料寫入。
  3. Segment 大小是嚴格限制在 max size 這個空間嗎?
    無嚴格限制,因為 segment 可以容納多少條資料是估算得到的。
  4. 怎麼估算的呢?
    通過 schema 來估算。
  5. 如果使用者頻繁的呼叫 Flush 會發生什麼事?
    會生成很多小的 segment,導致查詢效率受影響。
  6. DataNode 在重啟之後,如何避免資料被消費多次?
    DataCoord 會記錄最新 segment 的資料在 message channel 中的位置,下次分配 channel 時,告訴 Data Node segment 已經消費的位置,Data Node 再進行過濾。(不是全量過濾)
  7. 什麼時候來建立索引?
  8. 使用者手動呼叫 SDK 請求
  9. Segment flush 完畢後會自動觸發

檔案結構及資料持久化

DataNode Flush

DataNode 會訂閱 message store,因為我們的插入請求都是在 message store 裡面。通過訂閱它就可以不斷地去消費這個 insert message, 接著我們會把這個插入請求放到一個記憶體的 buffer 裡面。在積累到一定的大小後,它會被 flush 到一個物件儲存裡面。(物件儲存裡面儲存的就是 Binlog。)

因為這個 buffer 的大小是有限的,所以不會等到 segment 全部消費完了之後再往下寫,這樣的話容易造成記憶體緊張。

檔案結構

Binlog 檔案的結構和 MySQL 相似。

Binlog 主要有兩個作用,第一個就是通過 Binlog 來恢復資料,第二個就是索引建立。

Binlog 裡面分成了很多 event,每個 event 都會有兩部分,一個是 event header 和 event data。Event header 存的就是一些元資訊,比如說建立時間、寫入節點 ID、event length 和 NextPosition (下個 event 的偏移量)

Event data 分成兩部分,一個是 fixed part (固定長度部分的大小);另一個是 variable part (可變部分的大小),是為我們之後做擴充套件來保留的一部分。

INSERT_EVENT 的 event data 固定的部分主要有三個,StartTimestamp、EndTimestamp 和 reserved。Reserved 也就是保留了一部分空間來擴充套件這個 fixed part。
Variable part 存的就是實際的插入資料。我們把這個資料序列化成一個 Parquet 的形式存到這個檔案裡。

Binlog 持久化

如果 schema 裡有 12345 多列,Milvus 會以列存的形式來存 Binlog。

從上圖來看,第一個是 primary key 的 Binlog,再來是 Time Stamp 這個 column,再往後是 schema 裡面定義的 12345 每一個 column,它存在 MinIO 裡的個路徑是這樣定義的:首先是一個租戶的 ID,之後是一個 insert log,然後再往後是 collection、partition、segment ID、field ID 和 log index。log index 是一個 unique ID。反序列化時把多個 Binlog merge 起來。

最近釋出的版本中,有使用者反饋說需要指定ID進行刪除,於是我們實現了細粒度刪除( delete by ID )的功能,自此我們可以高效的來刪除指定的內容了,而不用進行等待啥的;同時我們增加了 compaction 功能,它可以把 delete 已經釋放了的一部分空間做釋放,同時把小的 segment 合併起來,提高查詢效率。

目前,為了解決使用者在資料量較大且資料是逐條插入的情況下的低效問題,我們正在做一個 Bulk load 的功能,讓使用者把資料組織成一定形式之後,可以將它一次載入到我們的系統裡面。

如果你在使用的過程中,對 milvus 有任何改進或建議,歡迎在 GitHub 或者各種官方渠道和我們保持聯絡~

相關文章