[AI 協作筆記] gRPC 傳輸優化奇招:用 Bitset 解決 NULL 痛點並極致壓縮 Payload

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5175

    #1

    [AI 協作筆記] gRPC 傳輸優化奇招:用 Bitset 解決 NULL 痛點並極致壓縮 Payload

    [AI 協作筆記] gRPC 傳輸優化奇招:用 Bitset 解決 NULL 痛點並極致壓縮 Payload

    在開發基於 RoadRunner + gRPC 的高性能資料庫中間件 (hypool) 時,我們遇到了兩個 gRPC/Protobuf 常見的棘手問題。在與 AI 進行 Pair Programming 的過程中,AI 提出了一個非常「類似資料庫底層」的有趣解法,不僅優雅地解決了問題,還大幅提升了傳輸效率。


    這篇文章將記錄這個優化思路的誕生過程與實作細節。


    1. 遇到的痛點

    痛點一:Payload 臃腫 (The Bloated Payload)

    在傳統的 API 設計或 gRPC 定義中,當我們要回傳多行資料庫結果時,最直覺的寫法是「物件陣列 (Array of Objects)」。


    proto 定義範例 (直覺版):






    message Row {
    map<string, string> data = 1;
    }

    message response {
    repeated Row rows = 1;
    }







    這會導致一個嚴重的效能問題:Key 的重複

    假設我們撈取 10,000 筆使用者資料,欄位有 id, name, email。

    回傳的資料中,"id", "name", "email" 這三個字串會被重複傳輸 10,000 次!這對頻寬與序列化/反序列化 (CPU) 都是巨大的浪費。

    痛點二:Protobuf 的 NULL 消失之謎

    Protobuf v3 有一個廣為人知的特性(或限制):純量型別 (Scalar Types) 沒有 NULL

    如果資料庫裡的 name 是 NULL:
    • 定義為 string name -> 傳輸時會變成空字串 ""。
    • Client 端無法分辨這是「用戶沒填名字 (空字串)」還是「資料庫欄位為 NULL」。


    傳統解法通常是用 google.protobuf.StringValue (Wrappers),但這會讓訊息結構變得很深,增加 overhead。

    2. AI 的神來一筆:像資料庫一樣思考

    當我向 AI 描述這些困擾時,它沒有給出標準的 Wrapper 建議,而是提出了一個更有趣的視角:


    AI: 「既然我們在做資料庫中間件,為什麼不參考資料庫底層儲存資料的方式?資料庫通常會將 Schema (欄位定義) 與 Data (數值) 分開存儲,並且用 Bitmap 來標記 NULL。」


    這個思路轉化為 gRPC 實作,包含了兩個核心設計:

    設計 A:陣列扁平化 (Flattening)

    不要用 Map 或 Object。將所有資料攤平成一個巨大的一維陣列。
    • Header: 只傳一次欄位定義 (columns)。
    • Body: 所有的值依序排成一條長龍 (values)。

    設計 B:引入 Bitset (Bitmap) 處理 NULL

    既然 string 不能存 null,那我們就用額外的「位元資訊」來標記誰是 null。
    • 每個欄位值佔用 1 個 bit
    • 如果 bit 為 1,表示該位置的值是 NULL。
    • 如果 bit 為 0,表示該位置的值是有效值。


    這樣一來,1,000 行 x 8 個欄位 = 8,000 個值,只需要 1,000 bytes (約 0.97 KB) 的額外空間就能完美記錄所有 NULL 狀態!

    3. 實作細節

    Proto 定義 (proto/query.proto)

    修改後的 Proto 非常精簡:






    message QueryResponse {
    // 扁平化的所有數值 [r1c1, r1c2, ... r2c1, r2c2 ...]
    repeated string values = 3;

    // 欄位定義只傳一次
    repeated Column columns = 4;

    // 神奇的 Bitset:每 1 bit 代表 values 陣列中對應 index 是否為 NULL
    bytes null_bitmap = 7;

    int32 row_count = 6;
    }







    PHP 端的位元壓縮 (QueryHandler.php)

    在 PHP 端,我們利用位元運算子 (|, <<) 高效地生成這個 Bitmap。這段程式碼展現了 PHP 在處理二進位資料時的能力:






    // 初始化
    $values = [];
    $packedBytes = "";
    $currentByte = 0;
    $bitIndex = 0; // 當前 byte 的第幾個 bit (0-7)

    foreach ($result['rows'] as $rowMap) {
    foreach ($rowMap as $v) {
    if ($v === null) {
    $values[] = ""; // 值放空字串 (Client 會忽略它)

    // 【關鍵】將對應的 bit 設為 1
    // 1 << $bitIndex 產生如 00000100 的遮罩
    // 然後用 OR 運算寫入 currentByte
    $currentByte |= (1 << $bitIndex);
    } else {
    $values[] = (string)$v;
    // 值不為 NULL,bit 保持 0,不做事
    }

    // 移動到下一個 bit
    $bitIndex++;

    // 如果湊滿 8 個 bits (1 byte),就寫入字串並重置
    if ($bitIndex === 8) {
    $packedBytes .= chr($currentByte);
    $currentByte = 0;
    $bitIndex = 0;
    }
    }
    }

    // 處理剩餘未滿 8 bits 的尾數
    if ($bitIndex > 0) {
    $packedBytes .= chr($currentByte);
    }







    4. 優化成果

    這個設計達成了「一石三鳥」的效果:

    1. Payload 極小化:完全移除了欄位名稱的重複傳輸。對於長欄位名 (如 customer_shipping_address) 的大表查詢,節省流量極為可觀。
    2. 精確的 NULL 還原:Client 端收到資料後,只需讀取 null_bitmap,就能精準判斷哪個欄位是 NULL,徹底解決 Protobuf 空字串混淆問題。
    3. 解析速度快:PHP 處理一維陣列的速度遠快於建構複雜的巢狀物件,減少了記憶體碎片與 GC 壓力。


    5. 結語

    這次優化最有趣的地方在於,它不是依賴某個新出的 Library,而是回歸電腦科學的基礎——位元運算資料結構優化


    AI 在這個過程中扮演了「架構師」的角色,它跳脫了「如何修復 Protobuf 語法」的淺層問題,直接提出了改變資料傳輸結構的深層解法。這也提醒我們,在使用 gRPC 等強型別協議時,不必被其預設的模式 (Pattern) 限制住,適度引入底層思維,往往能帶來意想不到的性能突破。




    More...
Working...