三个 OceanBase 4.x内核缺陷的案例

三个生产工单,三个已确认的内核缺陷:一个让查询结果悄悄少了一半,一个让 DROP 空表等了 40 秒,一个直接把进程 core 掉。

写在前面:先别怀疑自己用错了

数据库出问题时,DBA 的第一反应往往是「我 SQL 写错了 / 我姿势不对」。这三个案例最后都被官方定性为内核缺陷,跟使用方式无关。所以这篇想传递的第一个经验是:

当现象诡异到「不可能」的时候,先认真考虑它是 bug。

三个案例的诡异之处分别在于:

同一条 SQL,两个会话返回不同行数,而且各自稳定;

DROP 一张空表要 40 秒,且 sql_audit 显示等待在网络上;

一条普通的 COUNT 校验 SQL 让 observer 进程 core 掉。

下面逐个说。

案例一:同一条 SQL,两个会话返回了不同行数

现象

一条多层外连接的报表 SQL(WITH 多段 CTE + 多个 LEFT JOIN 派生表 + 结尾一个 NOT EXISTS 排除条件),在 A 会话执行返回 25 行,在 B 会话执行返回 52 行。换个会话有时 52、有时 25。

关键背景:这是测试环境,数据静止,没有任何并发写入。

排除法:不是数据在变,也不是旧快照

这类现象通常有四种解释:数据在变、会话读了旧快照、读的不是同一份数据、同一份数据走了不同执行计划。前两种可以当场证伪:

1.双会话交叉验证:A 窗口 25 → B 窗口 52 → 回到 A 仍是 25。两个会话各自稳定、互不影响,说明不是「某一瞬间数据被改了」。

2.提交事务前后各跑一次:在同一会话里 COMMIT 前后分别执行,结果都是 25;再用 GVOB_SQL_AUDIT 的 SNAPSHOT_VERSION、SNAPSHOT_SOURCE、TRANSACTION_HASH 三个字段核对,读快照来源与版本完全一致。会话停在旧读快照这个假设也排除。

剩下只有两种可能。逐字节比对两条语句的 QUERY_SQL(长度都是 6683 字符)、SQL_ID 相同、登录用户与租户相同 —— 语句文本这一层彻底排除,于是问题必然在执行计划上。

定位分叉点:同一个 SQL_ID,两个 PLAN_HASH

从 GVOB_SQL_AUDIT 里取两次执行的 PLAN_HASH:

返回 25 那次:PLAN_HASH 9479711821387333098,存储层扫描 396236 行

返回 52 那次:PLAN_HASH 7588877514654446452,存储层扫描 12626 行

这里有个容易踩的认知误区:「SQL_ID 只有一个」不能证明两次执行方式相同。同一个 SQL_ID 在不同节点上可以生成不同的 PLAN_HASH。 这两次执行恰好落在了集群的不同节点上。

两份计划逐层比对,表级估算行数一致、NOT EXISTS 段结构完全相同,唯一差异是返回 25 那份最外层多了一个 HASH_GROUP_BY 算子。

再往下就是优化器追踪(dbms_xplan.enable_opt_trace + set_opt_trace_parameter,level 3 才含 SQL 改写过程)。两份追踪记录里只有一个改写规则的选择不同:

采纳「分组上拉」 放弃「分组上拉」
结果 25 行(错) 52 行(对)
代价变化 239926.87 → 237857.41(变便宜,故采纳) 579913.25 → 1241826.52(变贵,故放弃)

代价变化 239926.87 → 237857.41(变便宜,故采纳) 579913.25 → 1241826.52(变贵,故放弃)

也就是说,这个改写规则本身算错了代价,把一个错误的计划判成了更优的计划。

根因

官方定性(原文):属产品缺陷,根因是 SQL 优化器的查询重写模块(聚合上拉与视图合并规则)导致物理执行计划错误,进而造成结果集异常。

机理拆开是这样:

子查询里的 GROUP BY 被聚合上拉(GROUPBY_PULLUP)搬到外层,同时该子查询自己的 GROUP BY 被清空 → 它退化成一个纯连接结构;

紧接着视图合并(VIEW_MERGE)把这个子查询的表嵌进外层查询块;

两层改写叠加后,LEFT JOIN 的语义被破坏,行在 GROUP BY 之前就已经变少。

触发条件:

多层外连接场景,且外层 ON 条件里含引用右侧连接子树的过滤条件(形如 NVL(s.attrc,‘1’) <> ‘YY’)。连接重排后主表提前参与外连接,而该过滤条件延后作为连接结果的 post-filter 执行;当存在不满足过滤条件的右侧匹配记录时,本应保留并补 NULL 的主表记录被整体过滤掉,导致结果集缺行。

正确结果是 52 行,25 行是缺行后的错误结果。

影响版本 / 规避 / 修复状态

影响范围:官方答复 4.x 各版本均存在(本例复现于 4.2.1.11)。

规避方式(官方推荐,已实测生效):在子查询上加 /*+ NO_MERGE */ hint,并用 explain extended 确认 hint 生效。

修复状态:官方已确认修复计划,但截至本文写作,修复版本尚未发布。预计在4.2.1bp11hf21和425BP8hotfix2上进行修复,版本在11月份发布。。

自查:3 张表 7 行数据就能复现

这是官方给的最小复现脚本,也是判断「我这条 SQL 是不是同一个缺陷」最快的办法:

CREATE TABLE OB25_P (ID NUMBER PRIMARY KEY, BG_ID NUMBER);
CREATE TABLE OB25_M (MID NUMBER PRIMARY KEY, PID NUMBER, AMOUNT NUMBER);
CREATE TABLE OB25_S (BG_ID NUMBER PRIMARY KEY, ATTRC VARCHAR2(10));
INSERT INTO OB25_P VALUES (1, 10);
INSERT INTO OB25_P VALUES (2, 20);
INSERT INTO OB25_S VALUES (10, 'YY');
INSERT INTO OB25_S VALUES (20, 'ZZ');
INSERT INTO OB25_M VALUES (11, 1, 100);
INSERT INTO OB25_M VALUES (21, 2, 200);
INSERT INTO OB25_M VALUES (22, 2, 20);
-- 正确:2 行(ID 1 → 0,ID 2 → 220)
SELECT i.id, NVL(SUM(m.amount), 0) AS amt
FROM OB25_P i
LEFT JOIN ( (OB25_P p LEFT JOIN OB25_M m ON m.pid = p.id)
            LEFT JOIN OB25_S s ON s.bg_id = p.bg_id )
       ON p.id = i.id AND NVL(s.attrc, '1') <> 'YY'
GROUP BY i.id ORDER BY i.id;

-- 错误:仅 1 行(ID 2 → 220),主表 ID 1 被整体过滤掉
SELECT /*+ LEADING(((p s) i) m) */ i.id, NVL(SUM(m.amount), 0) AS amt
FROM OB25_P i
LEFT JOIN ( (OB25_P p LEFT JOIN OB25_M m ON m.pid = p.id)
            LEFT JOIN OB25_S s ON s.bg_id = p.bg_id )
       ON p.id = i.id AND NVL(s.attrc, '1') <> 'YY'
GROUP BY i.id ORDER BY i.id;

要点是用 LEADING 强制连接顺序,就能把错误形态稳定逼出来。这说明问题出在「改写 + 连接顺序」的组合上,与数据量、并发、读快照全都无关 —— 7 行数据就能触发。

顺带记几个坑

  • GVOB_PLAN_CACHE_PLAN_EXPLAIN 取计划时,SVR_IP / SVR_PORT / TENANT_ID / PLAN_ID 四个条件必须等值且取自同一次执行,写成 IN 或漏一个都查不到;计划被淘汰时返回空行,需重新执行取新 PLAN_ID。
  • Oracle 模式下该视图的 NAME、ROWS 是关键字,反引号写法不被接受,改用 SELECT * 或双引号标识符。
  • 优化器追踪文件落在会话实际所在节点的 observer log 目录,从开启到关闭之间不要执行其他 SQL;并且如果已经有计划缓存,不会生成trace文件,可以清空下plan cache。
  • 还有一句提醒:ODC 的结果集导出是重新执行 SQL,导出文件的行数反映导出那一刻的读取结果,不是你在窗口里那次的快照。别拿导出行数当证据。

案例二:DROP 一张空表,等了 40 秒

现象

业务执行 DROP TABLE(删的是空表)稳定慢,单条要 20s、40s 才完成;CREATE 也慢。测试 3 次均复现。同集群其他租户无反馈,当天无 unit 扩容、无 unit 迁移、无 observer 重启。

排查中最大的方向纠偏:RPC 超时 ≠ 网络故障

这条工单在 4 天里换了四次方向,值得单独说:

schema 刷新失败 → 怀疑并行高频 truncate → 怀疑节点负载(tsar 看 cpu/mem/traffic/tcp,全正常)→ 怀疑网络(因为 sql_audit 显示等待在网络上、RS 日志报 RPC 通信超 2s)→ 最后才回到真相。

在官方给出「节点间通信有问题」的阶段性结论之后,追问了一句「那和之前查的 RPC 超时还有关系吗」。官方随即明确:

RPC 超时是因为这些表被频繁操作,导致 RPC 去刷新 schema 失败。

也就是说,DDL 慢 + RPC 超时 + sql_audit 显示等待在网络,这三件事同时出现时,先查 table_history,别先查网络。 而 tsar 各项指标正常本身就已经在暗示这不是网络问题了 —— 只是当时没人把它和 RPC 超时串起来。

真正定位根因的两步

**第一步:**看 DDL 到底堵在哪。 抓 obstack(4.x ARM 版 /home/admin/oceanbase/bin 自带,./obstack observer pid > xx.trc),从 rs.log 里找到执行该 DDL 的线程号,再看这个线程当时还在做什么:

[13485][DDLQueueTh0] lock_table (ob_ddl_service.cpp:5479)

同一个线程此刻还在刷 schema、建索引。4.x 是共用 DDL 线程,于是「drop 慢」被解释成了串行 DDL 线程排队。

**第二步:**看谁把线程占住的。

SELECT tenant_id, table_id, table_name, COUNT(*)

FROM __all_virtual_table_history

GROUP BY 1,2,3 ORDER BY COUNT(*) DESC LIMIT 20;

结果一目了然:某几张表的 history 记录数异常膨胀。

根因

官方结论(原文逻辑):

SQL 慢是慢在 schema 刷新慢;

该版本 TRUNCATE 的实现有问题 —— truncate 需要读 table_history 表,而被 truncate 的表在 table_history 里的记录不会被清理,只有把这张表 drop 掉才能刷掉;

于是 truncate 用得越多,这张表记录越堆越多,后续所有 DDL 都要为它排队。

这里有个必须记住的对比:__all_virtual_table_history 里,drop 掉的表记录会自动清理,truncate 的记录只累加。 所以这张表会随时间单调膨胀,长期只有重建才能压下去。

影响版本 / 规避 / 修复

修复版本:4.2.1 BP10,4.2.5 BP7(该版本让 truncate 执行过程中 schema 刷新速度不再太受 table_history 数量影响)。我的问题是出现在 4.2.1.7。

不升级的规避:对记录量大的表做 DROP + CREATE 重建。官方给的清理阈值口径是「10W 级别的记录都要清理下」。

适用范围:不只 DROP,ALTER 等所有 DDL 都存在该问题(官方明确「只要是 DDL 都存在这个问题」)。

案例三:进程直接 Core

现象

生产环境 observer 进程 core。CRASH ERROR 关键字段:

sig=11 (SIGSEGV)   IP=ffffffffffffffff   RBP=ffffffffffffffff
sig_code=1         lbt=(空,崩溃栈未落盘)
tname=T1002_L0_G0  tid=960657

触发 SQL 长这样:

SELECT COUNT(1) cnt
FROM (
  SELECT ... FROM ... UNION ALL SELECT ... FROM ...
  ORDER BY province_code = '10' LIMIT 1000
) t;

根因

关单小结原文:

此问题是当同一个子查询里同时包括 UNION 和 ORDER BY + 表达式触发的内核缺陷。低版本为了解决 sort 算子不能有共享子表达式的问题,强制把 sort 算子的表达式下压到子节点计算,但下压后 union 算子会 core 掉。

触发条件比官方描述多一个:必须有 LIMIT

官方给的三个要素是「同一子查询内 + UNION 与 ORDER BY + ORDER BY 的是表达式」。我自己搭了个最小复现,测出来还差一个必要条件:必须有 LIMIT。

CREATE TABLE test_source_a (user_id VARCHAR(50), cnt INT);
CREATE TABLE test_source_b (user_id VARCHAR(50), cnt INT);
INSERT INTO test_source_a VALUES ('user_001', 10), ('user_002', 20);
INSERT INTO test_source_b VALUES ('user_003', 30), ('user_004', 40);

不带 LIMIT —— 计划里根本没有 TOP-N SORT,不下压,正常执行:

0  SCALAR GROUP BY                     1

1  └─SUBPLAN SCAN        t             4

2    └─UNION ALL                       4

3      ├─TABLE FULL SCAN test_source_a 2

4      └─TABLE FULL SCAN test_source_b 2

带上 LIMIT —— TOP-N SORT 出现,并且被下压进了 UNION ALL 的每个分支(ID 4、ID 6 两处):

0  SCALAR GROUP BY                         1

1  └─SUBPLAN SCAN            t             4

2    └─TOP-N SORT                          4

3      └─UNION ALL                         4

|4 |      ├─TOP-N SORT       |             |2       |   ← 下压进了分支
5        │ └─TABLE FULL SCAN test_source_a 2

|6 |      └─TOP-N SORT       |             |2       |   ← 下压进了分支
7          └─TABLE FULL SCAN test_source_b 2

执行结果是先正常返回一次,紧接着连接丢失、进程 core:

obclient> SELECT COUNT(1) cnt FROM ( ... ORDER BY user_id <> 'user_001', cnt DESC LIMIT 1000 ) t;
+------+
cnt

+------+
4

+------+
1 row in set

obclient> <同一条语句再执行一次>
ERROR 2013 (HY000): Lost connection to MySQL server during query

所以准确的触发条件是四要素:同一子查询内 + UNION ALL + ORDER BY 表达式 + LIMIT。TOP-N SORT 被下压到 UNION ALL 各分支这一步才是致命动作。

工单里工程师后来反馈新版本已经修复。我拿本地环境把两条线都验了一遍:

版本 TOP-N SORT 是否下压进 UNION 分支 是否 core

版本 TOP-N SORT 是否下压进 UNION 分支 是否 core
4.2.1.4 下压 core(第二次执行丢连接)
4.2.5.0 下压 core(第二次执行丢连接)
4.2.1.11 不再下压 不复现
4.2.5.7 不再下压 不复现

修复后的计划里,TOP-N SORT 与 UNION ALL 之间多了一层 SUBPLAN SCAN (VIEW1) 作为屏障,下压被挡住了:

0  SCALAR GROUP BY                         1

1  └─SUBPLAN SCAN            t             4

2    └─TOP-N SORT                          4

|3 |    └─SUBPLAN SCAN       |VIEW1        |4       |   ← 新增的屏障
4        └─UNION ALL                       4

5          ├─TABLE FULL SCAN test_source_a 2

6          └─TABLE FULL SCAN test_source_b 2

结论:

这个缺陷已经修复,4.2.1 线内打到 4.2.1.11、4.2.5 线内打到 4.2.5.7 及之后版本即可修复(其中 4.2.1.11 与 4.2.5.7 经本地复现验证)。

三个案例总结

附:说明

本文的缺陷定性与修复版本信息来自 OceanBase 官方工单沟通结论,案例三的修复版本另经本地环境复现验证。

三个案例的根因描述均为官方结论的转述,机理部分为便于理解做的展开,如与官方后续说明不一致,以官方为准。

1 个赞