OceanBase的向量化引擎shuffle有多强?
OceanBase 作为分布式关系型数据库,数据重分布(Shuffle)是其分布式执行链路中的核心环节。在向量化执行模式下,算子间以 ObChunk 为单位批量传输数据,以此最大化 CPU 指令流水线效率。但 Shuffle 流程不可避免地引入全量数据的序列化与反序列化操作,在宽表场景下,该部分开销会显著侵蚀向量化执行的性能增益,严重时甚至会导致整体执行性能回退至行式执行水平。
一、传输内存管理

1、ObDtlVectorBuffer结构

其中 data_ 与 block_ 是 union,恰好指回整块内存起始处(即 ObDtlVectorsBlock 自己)。
cur_pos_/cap_ 界定了中间数据区的可用范围:cur_pos_ 初值 sizeof(ObDtlVectorsBlock),cap_ = buf_size - sizeof(ObDtlVectorsBuffer)
2、ObVectorSegment结构

ObVectorSegment是DTL(数据传输层)向量化行存缓冲区ObDtlVectorsBuffer中用来存储单个列数据的基本存储单元,定义如下:
cpp
struct ObVectorSegment
{
...
int32_t cap_; // segment 总容量
int32_t data_start_pos_; // 数据区起始位置
int32_t data_pos_; // 当前数据写入位置(从头往后增长)
int32_t offset_pos_; // 变长数据的 offset 数组写入位置(从尾往前增长)
int32_t fixed_len_; // 定长列的固定长度
int32_t seg_rows_; // 该 segment 已成功追加的行数
VectorFormat format_; // 向量格式:VEC_FIXED(定长)或 VEC_CONTINUOUS(变长,走 offset)
ObVectorSegment *next_; // 指向下一个 segment(同一列数据量超过一个 segment 时链接起来)
ObBitVector *nulls_; // null 位图指针
char payload_[0]; // 柔性数组,实际数据紧跟在结构体之后
} __attribute__((packed));
1、ObVectorSegment是一段连续内存,紧跟在结构体头之后的 payload_ 区域内,同时容纳三类内容:
1)null 位图(nulls_,大小固定为MAX_ROW_CNT=512 行对应的 bit vector);
2)实际数据:定长格式(VEC_FIXED)时按 fixed_len_ 紧密排列;
3)变长格式(VEC_CONTINUOUS) 时数据从data_start_pos_ 向后增长,同时用 uint32_t 的 ****offset 数组从尾部(offset_pos_)****向前增长记录每行数据结束位置。
2、ObDtlVectorsBlock/ObDtlVectorsBuffer 分别位于该内存两端做"门面头"和"元数据管理"。
3、两个区域相向增长,当 data_pos_(数据区)与 offset_pos_(offset 区)相遇时,说明该 segment 已满,需要通过 next_ 链接一个新的 ObVectorSegment。(即每一列都一个segment链表)
4、init(format, len):初始化 segment,格式为定长或变长,并预留 null bitmap 空间。
5、append_col_in_one_row:向该 segment 追加某一行某一列的数据(定长直接 memcpy,变长写数据并记录 offset)。
6、can_append_col:判断当前 segment 剩余空间是否够追加一行数据。
7、calc_alloc_size:计算创建/扩容一个 segment 需要分配的内存大小(至少 DEFAULT_BLOCK_SIZE=4KB,或按实际数据大小取较大者)。
8、cols_seg_start_pos_col_idx(链表头位置 )和 cols_seg_pos_col_idx(当前正在写入的 segment 位置)分别记录该列的 segment 链信息。
需要注意的是:如果整个 ObDtlVectorsBuffer(也即整个 ObDtlLinkedBuffer 的 payload)本身容量不够分配新 segment(cur_pos_ + alloc_size > cap_),alloc_segmant() 会直接返回 OB_BUF_NOT_ENOUGH,这个错误会一路向上传播给调用方(ObDtlVectorMsgWriter/ObDtlChanAgent),触发切换到一个全新的 ObDtlLinkedBuffer(申请新缓冲区、把当前缓冲区加入发送队列/发送出去),而不是在当前缓冲区里继续硬塞。
3、小结

发送端 ObDtlVectorMsgWriter::init 拿到这个 ObDtlLinkedBuffer *buffer 后,直接把它的 buffer->buf()(即 payload 起始地址)和buffer->size()传给 ObDtlVectorsBuffer::init_vector_buffer,在这段内存上就地构造出 ObDtlVectorsBlock
ObDtlVectorsBlock 自身也是"头部 + 紧跟 payload_0"的模式,其 get_buffer() 通过指针运算,从 payload_ 的尾部(blk_size_ - sizeof(ObDtlVectorsBlock) - sizeof(ObDtlVectorsBuffer) 偏移处)取出 ObDtlVectorsBuffer 结构:
因payload_已经跳过了Block头,所以需要减去后走到基准地址,然后+blk_size_偏移,再减去Buffer大小才能找到ObDtlVectorsBuffer结构。
cpp
ObDtlLinkedBuffer(头部 + payload,物理发送 buffer,capacity默认64K起)
└─ payload区域
└─ ObDtlVectorsBlock(头部)+ payload_[0]
└─ ...实际数据从payload_头部开始向后长(多个列的segment链交错排列)...
└─ 尾部反向放 ObDtlVectorsBuffer 结构体
└─ data_ 指回 payload_ 起始处
└─ ObVectorSegment(每列一条链,placement new 直接构造于 data_+偏移处)
ObVectorSegment 链表节点、ObDtlVectorsBuffer 元信息、ObDtlVectorsBlock,三者都物理复用同一段由 ObDtlLinkedBuffer 持有的内存,因此发送时不需要"批量拷贝多个 segment 到发送缓冲" ------数据从写入的第一刻起就已经写在了将要被整块发出去的那块 buffer 里,
发送逻辑 (ObDtlVectorMsgWriter::serialize() :只写 msg_type_ 不做数据搬运、随后整块 write_buffer_ 通过 RPC 一次性发出)只是把这块内存原样传输。发送端 ObDtlBasicChannel::switch_buffer 申请新 buffer 时同样是 alloc_buf(std::max(send_buffer_size_, min_size))------如果这批数据(比如一行超大变长列)超过默认 buffer 容量,会临时分配更大的 buffer,而不是固定死 64K 。如果一个 batch 写完后只用了 10K,即便 buffer 本身分配了 64K,实际通过 RPC 发送的也只是那 10K(受 size_ 字段控制,接收端按 size_ 读取有效数据),不会把整块 64K 空间都发出去。
4、 是否每行每列值都要判断是否放得下 ?
写入路径是逐行、逐列判断的 :append_row() 对本行的每一个 expr(列)都单独调用一次 append_col_in_one_row(),该函数内部做本列本行的边界检查。
同时上层还有一个批量级别的预检查 can_append_row(),在决定要不要为整行数据切换新 buffer 时会预先逐列模拟检查一遍(但不是真正写入,只是估算),二者不冲突:can_append_row 用来做粗粒度的"要不要新起一个 block"决策,append_col_in_one_row 是实际写入时的细粒度边界检查与兜底。
5、batch数据是memcpy深拷贝到segment?
append_col_in_one_row() 中定长和变长两种情况都通过 memcpy(payload_ + data_pos_, vec->get_payload(batch_idx), len/fixed_len_) 把向量表达式(ObExpr/ObIVector)里的数据实实在在拷贝进 ObVectorSegment 的 payload_ 区域,而不是共享指针或零拷贝引用。
6、为了发送进行拷贝的次数?
RPC 发送(跨机):ObDtlRpcChannel::send_message()/do_single_send() 通过 RPC 代理 ap_send_message 把 ObDtlSendArgs{peer_id_, *buf} 作为 RPC 参数发出,而 ObDtlSendArgs 是按值持有 ObDtlLinkedBuffer buffer_ 并通过 OB_SERIALIZE_MEMBER 做序列化的普通类,因此这里会经历一次序列化拷贝 (构造 ObDtlSendArgs 时拷贝了整个 ObDtlLinkedBuffer 及其 payload ,随后 RPC 框架再把序列化结果写入网络发送缓冲区)。
1、第一次拷贝:逐行逐列写入 ObVectorSegment
ObDtlVectorsBuffer::append_row() 对一行数据中的每一列分别调用 ObVectorSegment::append_col_in_one_row(),该函数内部用 memcpy(payload_ + data_pos_, vec->get_payload(batch_idx), len/fixed_len_) 把这一行这一列的值从表达式的向量(ObIVector)拷贝进对应 ObVectorSegment 的 payload_ 区域。这一步确实是"一行一列"粒度的多次小 memcpy,而不是整批一次性拷贝,因为向量化执行引擎中每一列在原始 ObExpr/ObIVector 里是按列连续存放的,但 DTL 传输格式(ObDtlVectorsBuffer+ObVectorSegment)要求逐行追加以支持变长段扩展和行数统计(block_->rows_/seg_rows_),所以只能按行粒度调用。
2、第二次拷贝:整块 payload 拷贝到发送侧
并不是把多个 ObVectorSegment 单独一个个拷贝,而是把整个 ObDtlLinkedBuffer 的 buf_(即包含 ObDtlVectorsBlock 头 + 数据区中所有列的 ObVectorSegment + 尾部 ObDtlVectorsBuffer 元信息的这一整块连续内存)当作一段不透明字节流,一次性整体拷贝出去,因为它们本来就物理上连续存放在同一块 payload 内存里。
3、本地 channel
ObDtlLinkedBuffer::assign() 执行 MEMCPY(dst->buf_, src.buf_, src.size_),一次性把整个 payload(size_字节,覆盖了里面所有列的所有segment)拷贝到新分配的 buffer里。
需要说明的是:ObDtlVectorsBuffer目前的实现确实是逐行逐列判断+OB_BUF_NOT_ENOUGH时才扩段,它并没有采用"先算总量再一次写"的优化;真正做了这个优化的是同一文件里另一套格式 ObDtlVectorFixedMsgWriter(对应 PX_VECTOR_FIXED 消息类型,只支持定长列)以及 ObTempRowStore/ObTempColumnStore 这条更新的列存路径(对应 PX_VECTOR_ROW 消息类型)。也就是说,OceanBase 用"多种消息编码格式"来应对不同场景的性能取舍:ObDtlVectorsBuffer 逐行判断更通用但开销大,ObDtlVectors/ObTempColumnStore 走批量预计算更快但要求定长或提前扫描一遍变长长度。
7、先预计算大小再一次性拷贝优化
1. 批量预计算,一次性判断代替逐行判断
对于定长向量 ,DTL 提供了 ObDtlVectorFixedMsgWriter/ObDtlVectors::append_batch() 这条路径,与 ObDtlVectorsBuffer::append_row()(逐行逐列 alloc_segmant+边界检查)不同,它是先按列整体处理一批(selector+size) :外层先通过 can_append_row/need_new_buffer 一次性判断整批数据能否装下(本质仍是遍历,但判断与写入分离,判断阶段不做实际内存操作,属于纯计算,代价远低于内存写入+校验混合的逐行模式) ;写入阶段则是按列一次性 for 循环 memcpy(对定长且8字节对齐的列甚至用 int64_t 拷贝代替逐字节 memcpy),不再对每一行做"放不放得下"的分支判断,因为空间已经在上一步确保足够。
2. 变长字段的关键优化:calc_size/to_buf 两阶段模式
针对变长字段,ObTempColumnStore(列存临时结果,DTL 传输的另一种编码格式)采用了更彻底的"先算总量、再一次性写"模式:
calc_size() 先遍历一遍 offsets 数组算出这一整批变长数据的总字节数 (offsetsize - offset0),完全不涉及内存拷贝,只是整数运算;拿到总大小后一次性 ensure_write_blk()/申请好足够空间;to_buf() 再一次性 MEMCPY(data, vec->get_data() + base, offsetssize),把整批连续内存整体拷贝过去,而不是逐行拷贝+逐行判断剩余空间。
这与 ObVectorSegment/ObDtlVectorsBuffer::append_row() 那种"边写边判断、写不下就临时扩段"的策略相比,用一次预扫描(纯计算,cache 友好、可向量化)替换了 N 次"写入+分支判断"交织的操作,是降低变长字段拷贝代价的核心思路。ObTempRowStore::add_batch()/RowBlock::calc_rows_size() 也是同样模式:先为整批算好每行大小 row_size_array_,ensure_write_blk() 一次性确保空间足够,再调用 vectors.at(col_idx)->to_rows() 按列整体转换,写入阶段不再做逐行判断。
批量预计算大小时,NULL 值的处理关键在于:offset 数组本身就保证了 NULL 值不占用数据区空间,不需要在计算总长度时对每行做 NULL 特判分支;NULL 标记则统一走单独的 null bitmap,与数据长度计算解耦。
因此 ObTempColumnStore::calc_size() 在批量计算总大小时,直接对整个区间做 offsetsize - offset0(无 selector)或逐个累加 offsetidx+1-offsetidx(有 selector),完全不需要判断某一行是否为 NULL------因为 NULL 行自动贡献 0 字节,求和结果自动正确。真正记录"这一行是不是 NULL"的信息,是另外单独预留的 null_bitmap_size(size) 这部分固定大小空间(每行 1 bit,与实际数据长度计算完全独立。
to_buf() 写入数据时同理:会先写 null bitmap(to_buf(bitmap_null_vector, ...)),再写 offsets/data,对 NULL 行虽然仍会执行 offsetsi=offsetsi-1+0 的偏移量搬运,但 MEMCPY 长度为 0,等价于跳过,不需要额外分支。
- Fixed 长度向量:NULL 不影响定长空间计算
VEC_FIXED 格式每行固定占 fixed_len 字节(不管是否为 NULL 都预留这么多空间,只是 NULL 时不写内容/写内容也无意义),所以定长列的总大小计算是 fixed_len * size,与 NULL 与否无关,同样不需要逐行判断。写入时才会对每行判断 is_null,NULL 则只置位 null bitmap、跳过 memcpy,非 NULL 才真正拷贝数据,但这个判断只影响"要不要执行拷贝",不影响"分配多大空间"这个预计算结果 。
8、接收端反序列化优化
接收端 ObReceiveRowReader::attach_vectors() 对 dtl::ObDtlVectors(对应 PX_VECTOR/PX_VECTOR_FIXED 消息)的处理方式是"指针挂载"而不是逐行拷贝:
1、定长列调用 ObFixedLengthBase::from_fixed_vector(has_null, nulls, fixed_len, read_pos, read_rows, data_buffer.get_data(col_idx)),把 ObExpr 的向量直接指向接收缓冲区里的数据区域。
2、变长列调用 ObContinuousBase::from_continuous_vector(has_null, nulls, offsets, read_pos, read_rows, buf),同样是把 offset 数组和 data 指针指向接收缓冲区,而非拷贝数据本身。
9、总结
1、对于变长字段,offset数组记录每一列每一行的偏移,NULL值对应的offseti=0,预计算时可以去掉NULL值判断,对 offset数组做一次纯算术求和,实现SIMD 友好计算。
2、对于固定长度字段,分配空间时按照fixed_len * size分配,计算时也不考虑NULL,也就是说拷贝时虽然拷贝了这个值,若为NULL,则无意义,由外层null bitmap标记
3、shuffle序列化时先每行每列拷贝发送缓冲区中(默认64kb),每行每列都需要进行剩余空间判断,但是感觉可以使用1和2的方式进行优化,进行预计算,然后一次性拷贝。
4、针对64kb整体缓冲区需要再来一次整体拷贝到RPC网络发送缓冲区。其实经历了两次内存拷贝
经过这些优化,可以大大减小shuffle序列化带来的代价,同时接收端可以直接按照接收到的offset数组不经过反序列化直接可以复用接收缓冲区。当然,也避免了接收端内存拷贝次数,接收端反序列化代价也将大大减小。