AIGC标识 Arrow/Parquet 写入段错误

一次 Arrow/Parquet 写入段错误的排查实录:gdb 指向线程池,ASAN 揪出 Builder 临时对象悬垂

背景

一个基于 C++17 + Arrow/Parquet 的行情数据处理平台,负责把逐笔成交与盘口快照聚合后写入 Parquet。某次对快照聚合链路重构后,快照写入流程在远程生产机上随机段错误(SIGSEGV,退出码 139),而同进程内的逐笔写入却一直正常——这个"同机制、一个正常一个崩"的差异,让排查绕了很大一圈。

环境信息

操作系统 CentOS 7.9(远程开发机)
编译器 GCC 12.2.0
构建系统 CMake(版本 < 3.13,不支持 -B 参数,需手动 out-of-source 配置)
第三方库 Apache Arrow / Parquet 21.0.0(静态库链接)
内存 216GB(排除 OOM 可能)
并发模型 读/排序/写各 4 线程,CPU 绑核
日志框架 fmtlog(logcb + 1ms 轮询 + flushDelay 0,日志延迟 ≤1ms)

错误现象与日志

聚合阶段一切正常,日志正常打印:

[快照读模块::read] data_summary aggregate done, codes:5153
[快照读模块::writeFactor] data_summary aggregate write, file:<输出目录>/price_bidask_2025082820250828.parquet0.parquet

随后进程直接退出,退出码 139,写入完成日志(正常情况下紧跟其后的 write cost: ...从未出现

由于 fmtlog 日志延迟 ≤1ms,可以断定:崩溃精确发生在 writeFactor 日志之后、写完成日志之前的写函数内部

另一个现象:同一次运行中,逐笔写入成功write cost: 1166ms / 826ms,两个交易所各一次),快照写入崩溃——两者调用的是几乎逐行相同的 Arrow 写代码。

排查过程

1. 系统级证据:dmesg

segfault at 1420 ip 00007f0761e4aee7 sp 00007ffc75e8eaf0 error 4 in libReadWrite.so

关键信息:sp 地址高位 0x7ffc 表明崩溃发生在主线程(历史崩溃都是 0x7fdf/0x7fc6 低位,属 worker 线程);访问地址 0x1420空指针 + 偏移,典型的解引用空对象成员。

2. gdb backtrace:矛头指向 Arrow 内部

#0 arrow::internal::Executor::Submit
#1 arrow::internal::ParallelFor<FileWriterImpl::WriteRecordBatch lambda>
#2 parquet::arrow::FileWriterImpl::WriteRecordBatch
#3 parquet::arrow::FileWriterImpl::WriteRecordBatch
#4 psi::data_summary::parquet::writeAndCompareSnapshots
#5 PsiReadWriteSnapshot::writeFactor

崩溃栈显示崩溃发生在 Arrow 内部线程池的任务提交(set_use_threads(true) 时写入走 ParallelFor 并行编码列)。

3. 两个被证伪的假设(弯路)

假设 A:跨 DSO 全局单例分裂。Arrow 是静态库,被主程序 + 5 个动态库各自链接,进程内疑似存在多份 Arrow 全局状态(线程池、内存池)——跨库释放错乱会导致延迟崩溃。用 nm -D 实证后证伪:符号经 ELF 符号插桩统一归一到单一动态库导出副本,其余全部是 U(undefined)引用,进程内单例一致。

假设 B:聚合阶段堆损坏。快照聚合在内存中处理 2473 万行、峰值约 6GB,多线程合并/重采样若越界写,堆损坏会延迟到写阶段才爆。对全部多线程聚合函数的共享写边界逐一静态检查后证伪

4. ASAN 一击命中

编译 ASAN 版本复现,一次抓到:

ERROR: AddressSanitizer: stack-use-after-scope on address 0x7ffe22cf6ae8
WRITE of size 1 at 0x7ffe22cf6ae8 thread T0
    #0 parquet::ArrowWriterProperties::Builder::set_use_threads(bool)  properties.h:1246
    #1 writeParquetSummary  <逐笔写模块>/TradeSummaryParquet.cpp:706
    #2 operator()            <逐笔写模块>/TradeSummaryParquet.cpp:946
    ...

stack-use-after-scope——作用域已结束的栈对象被写入。而且这次抓到的是逐笔路径!ASAN 帧对象布局显示:set_use_threadsthis 指向的栈槽早已标记 f8(已释放)。

根因分析

写代码中有一段"标准"的 Builder 链式写法:

// 修复前 —— 悬垂指针!
auto arrowPropsBuilder = ::parquet::ArrowWriterProperties::Builder().store_schema();
if (writerUseThreads) arrowPropsBuilder->set_use_threads(true);
auto arrowProps = arrowPropsBuilder->build();

问题出在第一行:Builder() 创建的是临时对象store_schema() 返回它的 this 指针——完整表达式结束(分号)时临时对象即析构arrowPropsBuilder 从此是悬垂指针,后续 set_use_threads() / build() 都在写一块已销毁的栈内存。

这是典型的 C++ 陷阱:链式调用临时对象 + 方法返回 this,返回的指针生命周期不随临时对象延长(与引用绑定临时对象时生命周期延长的规则不同)。

而"为什么逐笔正常、快照崩溃"的谜底是:快照写代码是从逐笔写代码逐行复制改写的,两处都有这个 UB。非 ASAN 构建下行为完全随机——逐笔的栈槽碰巧没被复用(侥幸成功),快照的栈槽被后续调用破坏(随机段错误)。gdb 看到的 Executor::Submit 崩溃栈只是悬垂写破坏栈内存后的表象,Arrow 线程池是受害者而非元凶。

解决方法

改为具名值对象,让 Builder 存活到函数结束:

// 修复后 —— 具名对象,生命周期安全
auto arrowPropsBuilder = ::parquet::ArrowWriterProperties::Builder();
arrowPropsBuilder.store_schema();
if (writerUseThreads) arrowPropsBuilder.set_use_threads(true);
auto arrowProps = arrowPropsBuilder.build();

逐笔与快照两处同款代码同步修复,全局 grep 确认无其他 Builder().xxx() 悬垂模式残留。

验证结果

ASAN 版本完整跑通三个流程,全程零报错

流程 数据规模 写结果
逐笔(交易所一) 2867 只股票 / 1708 万行 write 8960ms,对比通过
逐笔(交易所二) 2277 只股票 / 1245 万行 write 6779ms,对比通过
快照(原崩溃点) 5153 只股票 / 2474 万桶 write 11396ms 完成

原崩溃点(快照写入)在 ASAN 下完整执行 9983ms 的 parquet 写入并成功落盘。

经验与教训

  1. 先上 ASAN,别先搭理论。跨 DSO 单例分裂、聚合阶段堆损坏两个假设都有相当的说服力,各花了不少时间证伪;ASAN 一次运行就给出确定性结论。对"随机段错误"类问题,ASAN 应是第一顺位工具。
  2. gdb 崩溃栈可能是表象。悬垂写破坏的是栈内存,真正的受害者(本次是 Arrow 线程池)与施害者相距甚远——回溯到业务代码 + 工具链上层往往就断了。
  3. 复制代码会复制 bug。逐笔/快照写路径"几乎逐行相同",一处 UB 两处爆。改动同源代码时,所有副本都要一起审查。
  4. Builder().method() 返回 this 是经典陷阱。凡涉及链式临时对象调用,检查方法是否返回 this/内部指针;具名对象 + 分步调用是最稳妥的写法。
posted @ 2026-08-04 09:53  Ke_scholar  阅读(3)  评论(0)    收藏  举报