环境:3 FE + 5 BE,Doris 4.1.1,CentOS 7.9(glibc 2.17),JDK 17.0.2。
现象:
- 同一批库表挂了两个 catalog:
- 云上:Aliyun DLF(
hive.metastore.type=dlf)+ OSS-HDFS; - 云下:本地 Hive Metastore + HDFS。
- 云上:Aliyun DLF(
- 同一句 SQL:走云上 catalog 稳定让 BE 宕机(5 台 BE 几乎同时重启);走云下 catalog 完全正常。
- 规避手段:
SET max_file_scanners_concurrency = 1;可以不崩(但这是把线程销毁次数压下去,属于掩盖)。 - 升级 JindoSDK 6.10.8 → 6.10.9 无效。
- 调大 JVM
-Xmx无效(崩在原生分配器,不在 Java 堆)。
以下内容是结合ai排查结论,ai认为问题在于cnetos7.9的glibc版本过低,与doris有兼容性问题(在本文第三章节第3小结)
二、崩溃栈
生产 core(gdb 7.6 因 DWARF5 只能到函数名级别):
#0 pthread_sigmask libpthread.so.0
#1 PosixSignals::chained_handler libjvm.so
#2 JVM_handle_linux_signal libjvm.so
#3 <signal handler called>
#4 atomic_load_p (a=0x1a0000) jemalloc atomic.h:83
#5 rtree_leaf_elm_bits_read jemalloc rtree.h:182
#7 rtree_metadata_read jemalloc rtree.h:446
#8 emap_alloc_ctx_lookup (ptr=0x7fb1b40008c0) jemalloc emap.h:238
#9 ifree (tcache=0x0, slow_path=true) jemalloc jemalloc.c:2877
#10 je_free_default jemalloc jemalloc.c:3014
#11 _dl_deallocate_tls ld-linux-x86-64.so.2
#12 __free_stacks / #13 __deallocate_stack / #14 start_thread
在干净的 4.1.1 单机上用最小复现跑出来的栈(不需要 DLF/OSS):
*** SIGSEGV address not mapped to object (@0x49500) ***
0# doris::signal::FailureSignalHandler signal_handler.h:420
1# PosixSignals::chained_handler libjvm.so
2# JVM_handle_linux_signal libjvm.so
4# rtree_metadata_read jemalloc rtree.h:446
5# je_free_default jemalloc jemalloc.c:3014
6# __pthread_create_2_1 libpthread.so.0
7# os::create_thread libjvm.so
8# JVM_StartThread libjvm.so
这条栈与历史 issue #36641(2.0.10)、以及 #45210 正文里的栈基本同族。
关键细节:崩溃地址0x1a0000恰好等于((ptr >> 12) & 0x3FFFF) * 8—— 也就是 jemalloc 的 rtree leaf 指针是 NULL,
release 构建里 NULL 检查被裁掉,直接读NULL + subkey*8才会崩。这说明被 free 的指针根本不是 jemalloc 分配的。
三、根因(ai结合源码分析排查)
1. Doris 只接管了"公开"的 malloc 家族
be/src/runtime/memory/jemalloc_hook.cpp 用 __attribute__((alias)) 覆盖了 11 个公开符号:
malloc / free / calloc / realloc / memalign / aligned_alloc / valloc / pvalloc / posix_memalign / cfree / malloc_usable_size。
doris_be 又是 ENABLE_EXPORTS 1(-rdynamic)链接的,所以这些符号对进程内所有 .so 生效(包括 libjvm、glibc 的 free 调用)。
2. 但 jemalloc 自带的 __libc_* 拦截器没有被编译
jemalloc 只有不带 public prefix 构建时才会生成 __libc_malloc/free/calloc/realloc/memalign/valloc 这些 glibc 内部别名
(configure.ac:if test "x${JEMALLOC_PREFIX}" = "x";src/jemalloc.c:3252/3281-3298)。
而 Doris 的构建参数是 --with-jemalloc-prefix=je(thirdparty/build-thirdparty.sh),所以这些符号一个都没有。
在真 4.1.1 二进制上可以验证:
$ nm -D --defined-only lib/doris_be | grep -E ' (malloc|free|calloc|memalign)$'
T malloc / T free / T calloc / T memalign ... # 7 个,有
$ nm -D --defined-only lib/doris_be | grep __libc_memalign
(空) # 没有 ← 问题在这
3. glibc ≤ 2.27 的 TLS 分配/释放是不对称的
- glibc 2.17
elf/dl-tls.c:529:给"运行期 dlopen 的模块"分配每线程动态 TLS 块用__libc_memalign(→ glibc 自己的堆); - 释放却在
dl-tls.c:482用公开free(→ 被 Doris 接管到 jemalloc); - glibc 2.28 起改成用
malloc分配(2.28:346、2.34:436)→ 分配/释放都进 jemalloc,问题自然消失。
于是:glibc 堆分配的指针,被 jemalloc 去释放 → jemalloc 的 extent map 里没有它 → rtree leaf=NULL → SIGSEGV。
为什么"云上崩、云下不崩":BE 的 C++ 读取代码两边完全一样;差别只在于云上 catalog 会让 BE 内 JVM 运行期 dlopen JindoSDK 的 /tmp/libjindosdk-*.so(带 TLS),而本地 HDFS 路径不加载任何"运行期 dlopen 的带 TLS 原生库"。再叠加云上 I/O 慢导致的扫描线程高频创建/销毁,就把概率放大到"稳定复现"。
为什么和 JindoSDK 版本无关:触发条件只与"它是不是一个运行期 dlopen 的带 TLS 原生库"有关,与它内部修了什么无关。
四、证据(欢迎复核)
4.1 同一份二进制、同一份负载,两个 glibc 版本结果完全不同
| 环境 | 现象 | gdb 计数 |
|---|---|---|
| CentOS 7.9 / glibc 2.17 | 第 1 次运行就 SIGSEGV | _dl_allocate_tls=64,__libc_memalign=102 |
| Rocky 9.7 / glibc 2.34 | 10/10 不崩 | _dl_allocate_tls=484,__libc_memalign=0 |
(同一个测试程序:运行时 dlopen 一个带 __thread 的 .so,8 轮 × 64 线程,每线程 touch 该模块 TLS 后退出。)
4.2 真 Doris 4.1.1 单机复现(不需要 DLF)
单机部署 1 FE + 1 BE(同一版本串),自建 2000 行表,然后:
- 自建一个带
__thread的 native 库libtlsmod.so; - 用一个 Java UDF 在静态初始化里
System.load()它(等价于 JindoSDK 运行期 dlopen),每次evaluate()起几个线程调它的 native 函数后退出; SELECT COUNT(tls_udf(CAST(k AS STRING))) FROM t;→ 第 1 条查询就让 BE 崩(栈见上面第二节)。
4.3 修复验证(LD_PRELOAD,无需重编译 Doris)
只用 LD_PRELOAD 补一个符号:
void *__libc_memalign(size_t alignment, size_t size) { return memalign(alignment, size); }
| 修复前 | 修复后 | |
|---|---|---|
| 首条查询 | SIGSEGV | 正常返回 2000 |
| BE 进程 | 崩溃/重启 | pid 全程不变 |
| 压力 | — | 26 轮查询 × 2000 行 × 4 线程 ≈ 20.8 万次线程创建/销毁,零崩溃 |
shim 确实在被调用(be.out 里有标记),且 nm -D libmemalign_shim.so 只有 T __libc_memalign + U memalign。
五、修复建议(结合ai排查给出的方案,正在测试环境尝试方案2是否有效)
方案 1(推荐):在 jemalloc_hook.cpp 里补齐 6 个 __libc_* 别名
#ifdef __GLIBC__
// glibc <= 2.27 用 __libc_memalign 分配每线程动态 TLS 块,却用公开 free 释放;
// 只接管 free 会导致"glibc 堆分配、jemalloc 释放",在线程创建/退出时 SIGSEGV。
void* __libc_malloc(size_t n) __THROW { return jemalloc(n); }
void __libc_free(void* p) __THROW { jefree(p); }
void* __libc_calloc(size_t n, size_t s) __THROW { return jecalloc(n, s); }
void* __libc_realloc(void* p, size_t n) __THROW { return jerealloc(p, n); }
void* __libc_memalign(size_t a, size_t s) __THROW { return jealigned_alloc(a, s); }
void* __libc_valloc(size_t s) __THROW { return jevalloc(s); }
#endif
doris_be 已开 ENABLE_EXPORTS,加完这些符号会自动导出;与上面的 LD_PRELOAD 方案语义完全等价(已实测)。
方案 2(不改代码,临时止血):LD_PRELOAD 一个 shim
把上面的单符号 shim 编成 .so,放到 be/lib/,用 wrapper 在启动 BE 前 export LD_PRELOAD。
不需要重编译 Doris、可随时回退(ai演练过 apply → rollback → re-apply,每一步查询都正常)。
方案 3(长期):升级到 glibc ≥ 2.28 的操作系统
Rocky/Alma 8+、Ubuntu 20.04+ 等。实测同一份问题程序在这些系统上 10/10 不崩。
方案 4(仅规避,不修复)
固定扫描线程池(让线程只创建不销毁)> 改用原生 S3(不走 Jindo/OSS-HDFS)> max_file_scanners_concurrency=1。