[AI 协作笔记] gRPC 传输优化:基于 Flattening 与 Bitset 的高效方案

发布: (2025年12月17日 GMT+8 11:13)
6 分钟阅读
原文: Dev.to

Source: Dev.to

背景与技术挑战

在设计数据库中间件的 API 时,我们通常需要返回多行的查询结果。若采用传统的 gRPC 定义方式,会面临以下性能与实现上的问题。

1.1 Payload 冗余问题 (Key Repetition)

最直观的 Protobuf 定义通常是将每一行数据定义为一个 Map 或 Object:

message Row {
    map data = 1;
}
message Response {
    repeated Row rows = 1;
}

问题分析
这种结构会导致严重的 Payload 膨胀。假设查询结果有 10,000 条数据,且包含字段 customer_id, created_at, status。在传输过程中,这些字段名称 (Key) 会被重复传输 10,000 次,占用了大量的带宽资源。

1.2 Protobuf 对 NULL 值的限制

Protobuf (proto3) 的设计哲学将标量类型 (Scalar Types) 视为不可为 null。

  • string 字段若为 NULL,传输时会被序列化为空字符串 ""
  • 客户端无法区分这是真正的“空值”还是“原始数据库中的 NULL”。

虽然可以使用 google.protobuf.StringValue 等 Wrapper 类型解决,但会增加额外的 Message 嵌套层级与处理开销。

解决方案:类数据库底层架构

针对上述挑战,AI 建议跳脱传统的 API 对象思维,参考 数据库底层 (Columnar Storage)ODBC/JDBC 驱动 的实现方式。核心优化策略包含以下两个部分:

A. 数组扁平化 (Flattening)

  • Header (Metadata):单独传输一次字段定义 (columns)。
  • Body (Values):将所有数据值摊平成一个巨大的“一维数组” (values)。

此设计完全移除每行数据中的 Key 传输,显著降低 Payload 大小。

B. Bitset (Bitmap) 机制

  • 使用 Bitset 来标记每一个值是否为 NULL。
  • 1 个 bit 对应 1 个值:Bit = 1 表示该值为 NULL,Bit = 0 表示该值有效。

空间效率
每 8 个字段值仅需消耗 1 byte 的额外空间。对于 1,000 行 × 8 列的数据,仅需约 1 KB 的 Overhead 即可精确记录所有 NULL 状态。

实作细节 (Implementation Essentials)

3.1 Protobuf 定义 (proto/query.proto)

message QueryResponse {
    repeated string values = 3;   // 扁平化数值
    repeated Column columns = 4;  // 字段定义
    bytes null_bitmap = 7;        // NULL 标记位元流
    int32 row_count = 6;
}

3.2 Server 端:编码与压缩 (Encoding)

Server 端的任务是一次性遍历数据库结果,同时完成“数值扁平化”与“Bitmap 生成”。

// $result['rows'] 是数据库返回的二维数组
$values = [];
$packedBytes = "";
$currentByte = 0;
$bitIndex = 0;

foreach ($result['rows'] as $row) {
    foreach ($row as $value) {
        if ($value === null) {
            $values[] = "";                     // 值放空字符串占位
            $currentByte |= (1 << $bitIndex);   // 设置对应位元为 1
        } else {
            $values[] = (string)$value;
            // 位元保持为 0(默认)
        }

        $bitIndex++;
        if ($bitIndex === 8) {
            $packedBytes .= chr($currentByte);
            $currentByte = 0;
            $bitIndex = 0;
        }
    }
}

// 处理最后不足 8 位的情况
if ($bitIndex > 0) {
    $packedBytes .= chr($currentByte);
}

3.3 Client 端:解码与还原 (Decoding)

Client 端收到数据后,需要根据 columns 数量切割,并参考 null_bitmap 将 NULL 还原回来。

$fetchedRows = [];
$columns = $response->getColumns();
$colCount = count($columns);
$values = $response->getValues();       // 取得扁平化数组
$bitmap = $response->getNullBitmap();   // 取得 Bitmap string
$rowCount = $response->getRowCount();

$bytePos = 0;
$bitPos = 0;

for ($r = 0; $r < $rowCount; $r++) {
    $row = [];
    for ($c = 0; $c < $colCount; $c++) {
        $flatIndex = $r * $colCount + $c;

        // 取得对应位元
        $byte = ord($bitmap[$bytePos]);
        $isNull = ($byte >> $bitPos) & 1;

        $row[] = $isNull ? null : $values[$flatIndex];

        // 移动位元指针
        $bitPos++;
        if ($bitPos === 8) {
            $bitPos = 0;
            $bytePos++;
        }
    }
    $fetchedRows[] = $row;
}

通过上述对称的逻辑,我们即可以极低的运算成本完成数据的压缩与还原。

优化效益分析

采用此架构后,我们获得了以下具体效益:

  • 极致的传输效率
    扁平化设计使 Payload 大小与数据量呈线性增长,不受字段名称长度影响,对大数据量查询的带宽节省效果显著。

  • 精确的类型还原
    Client 端可通过 null_bitmap 精确还原数据库的 NULL 状态,解决了 gRPC 默认类型的限制。

  • 解析性能提升
    对于 PHP 与其他语言而言,处理一维数组通常比处理大量嵌套对象拥有更好的 CPU Cache 命中率与更低的内存碎片。

总结

这个优化案例展示了在现代分布式系统中,适度引入 底层系统设计思维 的重要性。通过与 AI 的协作,我们跳脱了单纯的 API 设计框架,利用 位元运算数据结构优化,以极低的成本解决了 gRPC/Protobuf 在数据库应用场景下的先天限制。

Back to Blog

相关文章

阅读更多 »

gRPC - 为什么使用 Mock Server?

为什么需要 Mock Server 来进行 gRPC?gRPC 提供紧凑的消息、基于 HTTP/2 的高效二进制传输,并对多种通信模式提供一流的支持……