Doris 2.1.11 BE 多节点异常掉线:AutoReleaseClosure callback reuse 数据竞争与 std::bad_alloc 双故障归因
关键词:Doris BE 掉线、Doris BE 崩溃、SIGSEGV、std::bad_alloc、AutoReleaseClosure、callback reuse、PR #61782、ExchangeSendCallback、brpc、Doris 2.1.11、core dump 分析
目录
- 一、问题现象
- 二、环境信息
- 三、排查过程:从"一个故障"到"两个故障"
- [四、第一类故障:AutoReleaseClosure callback reuse](#四、第一类故障:AutoReleaseClosure callback reuse "#%E5%9B%9B%E7%AC%AC%E4%B8%80%E7%B1%BB%E6%95%85%E9%9A%9Cautoreleaseclosure-callback-reuse")
- [五、第二类故障:std::bad_alloc → SIGABRT](#五、第二类故障:std::bad_alloc → SIGABRT "#%E4%BA%94%E7%AC%AC%E4%BA%8C%E7%B1%BB%E6%95%85%E9%9A%9Cstdbad_alloc--sigabrt")
- [六、Host is down / CANCELLED 的正确解读](#六、Host is down / CANCELLED 的正确解读 "#%E5%85%ADhost-is-down--cancelled-%E7%9A%84%E6%AD%A3%E7%A1%AE%E8%A7%A3%E8%AF%BB")
- 七、处置方案
- 八、总结
一、问题现象
1.1 告警表现
某 Doris 2.1.11 生产集群(3 FE + 12 BE)连续两个晚间出现多台 BE 异常掉线。
| 阶段 | 告警内容 |
|---|---|
| 第一晚 | BE 不存活节点总数 > 0,当前值 2(104、108) |
| 第二晚 | 同上,当前值 1(101) |
监控层面看,是同一个现象:多个 BE 在短时间窗口内发生进程级异常退出,被 FE 判定为 Dead。
1.2 初步判断(事后证明是错的)
第一反应是"三个 BE 掉线 = 同一个 bug"。
这个判断看起来很合理:同一集群、同一版本、同一时间窗口、同样的告警文案。
但它差点让我们在生产上换错补丁。
二、环境信息
| 组件 | 版本/配置 |
|---|---|
| Doris | 2.1.11 |
| 集群规模 | 3 FE + 12 BE |
| 部署方式 | BE 由 SelectDB Manager 启动(非 systemd 托管) |
| 业务负载 | 夜间批量 INSERT INTO ... SELECT / Stream Load |
| 数据格式 | Duplicate / Unique 模型混合 |
三、排查过程:从"一个故障"到"两个故障"
3.1 第一步:拿到每台的信号类型
不要先看业务日志,先看 core 的信号。
| BE | 崩溃信号 | 核心特征 |
|---|---|---|
| 101 | SIGSEGV | Status::create → AutoReleaseClosure → ExchangeSendCallback → brpc::EndRPC |
| 108 | SIGSEGV | 与 101 相同的调用链 |
| 104 | SIGABRT | std::bad_alloc → terminate → abort → DeltaWriterV2 |
信号不同,根因必然不同。
SIGSEGV 是"访问了非法内存",SIGABRT 是"程序主动中止"。这两个信号在 C++ 里对应完全不同的故障类型:
- SIGSEGV → 空指针、野指针、数据竞争导致的对象被回收
- SIGABRT → 未捕获异常(如
std::bad_alloc)、断言失败
3.2 第二步:确认 core 栈
101 / 108 的栈(关键片段):
ruby
SIGSEGV
↓ doris::Status::create()
↓ doris::AutoReleaseClosure<
PTransmitDataParams,
doris::pipeline::ExchangeSendCallback<PTransmitDataResult>
>::_process_status()
↓ doris::AutoReleaseClosure<...>::Run()
↓ brpc::Controller::EndRPC()
↓ brpc::policy::ProcessRpcResponse()
↓ bthread::TaskGroup::task_runner()
104 的栈:
c
std::bad_alloc
↓ std::terminate()
↓ abort()
↓ SIGABRT
RuntimeState::cancel_reason()
DeltaWriterV2::write()
vectorized::VTabletWriterV2::write_memtable()
vectorized::VTabletWriterV2::write()
PlanFragmentExecutor
3.3 修正后的故障模型
c
BE 多节点异常掉线
│
├── 101 / 108
│ └── SIGSEGV → AutoReleaseClosure callback reuse race
│
└── 104
└── SIGABRT → std::bad_alloc → 内存分配失败
这张表是本次复盘最重要的修正。
如果把 104 也归到 AutoReleaseClosure,会与 core 证据直接冲突------104 的栈里根本没有 AutoReleaseClosure。
四、第一类故障:AutoReleaseClosure callback reuse
4.1 调用栈为什么重要
101/108 的栈有一整条完整的链路,指向一个具体类:
swift
AutoReleaseClosure<PTransmitDataParams, ExchangeSendCallback<PTransmitDataResult>>
ExchangeSendCallback 说明这条路径是 BE 之间的 Exchange 数据传输 (PTransmitData 是 Doris 内部 BE-to-BE 的 RPC)。
换句话说:这不是普通查询,是分布式执行计划里的数据交换阶段。
4.2 溯源:这个问题上游已经修了
拿着这段栈去搜 Apache Doris 的 issue/PR,能找到:
makefile
PR #61782
Title: [fix](rpc) Fix AutoReleaseClosure data race with callback reuse
Commit: 0a1d842
合并时间: 2026-04-16(master)
这个 PR 的崩溃栈与我们的 core 高度一致。
4.3 官方对机制的描述
PR 的核心说明是:
callback 的
call()可能复用 callback 对象,例如vdata_stream_sender.h中的get_send_callback();新的 RPC 会修改response_和cntl_。 如果AutoReleaseClosure::Run()在执行call()后才检查cntl_->Failed()或response_->status(),它读取到的就可能已经是新 RPC 的状态,而不是原 RPC 的结果。
4.4 问题代码
2.1.11 的 be/src/util/ref_count_closure.h:
scss
void Run() override {
Defer defer {[&]() { delete this; }};
if (auto tmp = callback_.lock()) {
tmp->call(); // ← 可能触发 callback 复用,发起新的 RPC
}
if (cntl_->Failed()) { // ← 此时 cntl_ 可能已被新 RPC 改写
_process_if_rpc_failed();
} else {
_process_status<ResponseType>(response_.get()); // ← response_ 同理
}
}
问题在于顺序:
scss
callback_->call()
↓
这次调用可能发起新 RPC,复用同一个 callback
↓
新 RPC 改写 cntl_ / response_
↓
旧 Run() 继续读 cntl_ / response_
↓
读到的是别人的状态
4.5 官方不是"理论推断",是抓到了实证
PR 里有一段调试日志,是在生产/测试环境真实捕获的:
csharp
RACE DETECTED:
AutoReleaseClosure response_ was reused by a new RPC
while still in Run().
The old closure is about to read response_->status()
but the new RPC may be concurrently writing to the same
response_ object.
这一点很关键:不是"可能有问题",是"确实捕获到了"。
4.6 修复方式
修复版本引入了新的 be/src/util/brpc_closure.h,把 Run() 改成只调用 callback:
scss
void Run() override {
Defer defer {[&]() { delete this; }};
if (auto tmp = callback_.lock()) {
tmp->call();
}
}
同时新增 HandleErrorBrpcCallback,把"错误检查"的职责移进 callback 内部:
sql
旧版本:
AutoReleaseClosure::Run()
├── callback->call()
├── RPC error check
└── response status check
修复版本:
AutoReleaseClosure::Run()
└── callback->call()
├── RPC error check
├── response status check
└── Query cancel
4.7 单元测试证据
PR 新增了 be/test/exec/exchange/exchange_sink_test.cpp,专门测"callback 复用后状态是否被污染":
| 阶段 | 结果 |
|---|---|
| 修复前 | 7 个测试,4 通过、3 失败 |
| 修复后 | 7 个测试,全部通过 |
失败的三个用例名非常说明问题:
test_closure_call_must_not_corrupt_status_check
test_closure_call_must_not_hide_error_status
test_closure_call_must_not_hide_rpc_failure
4.8 完整故障链
css
业务 SQL
↓
Distributed Plan
↓
Pipeline Exchange
↓
BE ↔ BE 之间 PTransmitData BRPC
↓
AutoReleaseClosure
↓
RPC 完成 → AutoReleaseClosure::Run()
↓
callback->call()
↓
Exchange callback 被复用,发起下一次 RPC
↓
新 RPC 修改 cntl_ / response_
↓
旧 Run() 继续读取 → 数据竞争
↓
Status::create() 访问非法内存
↓
SIGSEGV → BE 进程退出
五、第二类故障:std::bad_alloc → SIGABRT
5.1 能确认的三件事
第一,进程因 SIGABRT 退出(不是 SIGSEGV)。
第二,直接触发是 std::bad_alloc:
c
terminate called after throwing an instance of 'std::bad_alloc'
what(): std::bad_alloc
含义:某个动态内存分配操作失败(new / malloc / vector 扩容 / Block 分配等)。
第三,崩溃发生在 Tablet Writer 路径:
arduino
DeltaWriterV2::write()
VTabletWriterV2::write_memtable()
VTabletWriterV2::write()
说明崩溃时 BE 正在处理写入,不是读取。
5.2 不能直接下的结论
不能仅凭 std::bad_alloc 就说"机器内存耗尽"。
bad_alloc 至少有六种可能来源:
| 可能 | 说明 |
|---|---|
| A. Linux OOM | 系统级内存耗尽,malloc 失败 |
| B. Doris Memory Tracker 限制 | Doris 自身内存水位限制触发分配失败 |
| C. allocator 碎片 | 内存没耗尽但无法分配连续大块 |
| D. 单次超大对象申请 | 例如一次性申请过大的 Block |
| E. MemTable / Buffer 瞬时暴涨 | 写入峰值导致 |
| F. 写入 + Compaction 叠加 | 多任务并发造成内存压力 |
本次没有拿到足够证据区分这六种。
所以 104 的结论只能写成:
已确认"内存分配失败导致进程 abort",但具体是系统 OOM、Doris 内存限制还是单次异常大分配,仍需进一步证据。
5.3 下一步需要采集什么
perl
# 1) 内核 OOM
dmesg -T | grep -Ei "oom|out of memory|killed process"
journalctl -k | grep -Ei "oom|out of memory|killed process"
# 2) BE 内存日志
grep -Ei "bad_alloc|memory limit|memory exceeded|MemTracker|MemTable|DeltaWriter" \
be/log/be.INFO* be/log/be.WARNING*
# 3) 崩溃前 5~10 分钟的 Load / Insert 时间线
# 4) BE Memory Tracker 的 Query / Load / MemTable / Compaction 分项
六、Host is down / CANCELLED 的正确解读
FE Audit Log 里大量出现:
csharp
[CANCELLED] failed to send brpc when exchange
Host is down
这条不能单独当作 BE 掉线的根因。
正确的因果顺序是:
swift
BE 异常退出
↓
目标 BE 不再响应 BRPC
↓
其他 BE / Coordinator 发 Exchange RPC
↓
Host is down / connection reset
↓
Fragment cancel
↓
Query CANCELLED
Host is down 和 CANCELLED 都是 BE 进程退出后的二次传播结果。
同理,Compaction 日志里那些:
arduino
estimate batch size for vertical compaction
succeed to do cumulative compaction
只能说明"当时 Compaction 在跑",不能证明它是 SIGSEGV 的原因------101/108 的栈里根本没有 Compaction 相关符号。
七、处置方案
7.1 P0:先分类,再处置
必须形成完整的归因表:
sql
BE → 崩溃时间 → 信号 → 栈顶 → Query ID → SQL → 根因
没有分类,就没有正确的修复。
7.2 P1:101/108 的 backport 要整套做
不建议 直接把 master 的 brpc_closure.h 覆盖到 2.1.11 的 ref_count_closure.h。
原因:这个 PR 涉及多个调用方:
bash
be/src/util/brpc_closure.h
be/src/exec/exchange/vdata_stream_sender.h
be/src/exec/operator/exchange_sink_buffer.cpp
be/src/exec/runtime_filter/runtime_filter.cpp
be/src/exec/runtime_filter/runtime_filter_mgr.cpp
be/src/exec/runtime_filter/runtime_filter_producer.cpp
be/src/exec/sink/writer/vtablet_writer.cpp
be/test/exec/exchange/exchange_sink_test.cpp
正确做法:
css
2.1.11 源码树
↓ 建专门 backport 分支
逐文件迁移
↓ 解决 2.1 分支代码差异
完整编译
↓ Exchange UT + Runtime Filter UT
测试环境长时间运行
↓ 灰度 BE
生产滚动升级
7.3 P1:修复 BE 进程托管
本次事故的放大因素 :BE 由 SelectDB Manager 启动,systemctl 管不到真实进程。
ini
BE crash
↓
systemd 未托管
↓
Restart=on-failure 无法按预期拉起
↓
BE 长时间 Dead
↓
业务持续受影响
检查方法:
bash
systemctl status doris-be
systemctl cat doris-be
ps -ef | grep doris_be
确认 MainPID 是否等于当前 BE 的 PID。如果不是,说明托管是假的。
7.4 P1:104 单独做内存专项
104 与 PR #61782 无关,必须走另一条路:
arduino
DeltaWriterV2::write()
↓
VTabletWriterV2::write_memtable()
↓
MemTable / Block / Buffer / Arena
↓
到底是哪一次 allocation 抛出了 bad_alloc?
八、总结
8.1 四个判断
① 同一个告警文案,可能是完全不同的故障
三个 BE 同时掉线,监控上是同一行字。但信号不同(SIGSEGV / SIGABRT),根因就不同。
排查第一步永远是看信号和栈,不是看告警。
② "静默的二次传播"会骗人
Host is down、CANCELLED 这些错误在 FE 日志里刷屏,看起来像是根因,实际是结果。
判断方法:看它发生的时间点,是在进程退出之前还是之后。
③ 上游补丁不等于能直接拿来用
找到了 PR 不等于问题解决。这个 PR 跨了 8 个文件、改了 callback 生命周期模型、还依赖 UT 验证。
"能不能 backport"和"找到了什么 bug"是两个不同难度的问题。
④ 不能归因的地方要明说
104 至今没有完整根因------只能说"bad_alloc 导致 abort",说不出是哪一次分配。
在报告里明确写出"未确认",比编一个合理的解释更专业。
8.2 排查 Checklist
| 检查项 | 方法 |
|---|---|
| 每台 BE 的崩溃信号 | gdb -c core / eu-stack,看 SIGSEGV / SIGABRT / SIGBUS |
| 栈顶符号 | 提取前 10 帧,重点看 Doris 命名空间 |
| 是否命中已知上游问题 | 拿栈顶符号 + 版本号搜 Apache Doris issues |
| FE 侧错误的时间线 | 确认 Host is down 是否晚于 BE 退出时间 |
| 进程托管是否有效 | MainPID 与真实 PID 对比 |
| 内存类崩溃的证据 | dmesg + Memory Tracker + 崩溃前 10 分钟日志 |
参考
- Apache Doris PR #61782:
[fix](rpc) Fix AutoReleaseClosure data race with callback reuse - Apache Doris commit
0a1d842 - Apache Doris 源码:
be/src/util/ref_count_closure.h、be/src/exec/exchange/vdata_stream_sender.h - Apache Doris 官方邮件归档:
commits@doris.apache.org