Bamboo 调度系统 OceanBase 适配实战:存储过程迁移踩过的三个大坑
本文记录了分布式任务调度平台 Bamboo(竹节) 从 MySQL 适配 OceanBase 的完整实战过程。 由于 Bamboo 的调度引擎完全由 MySQL 存储过程 + 数据库事件驱动,这次适配的"主战场"就在存储过程上。 踩过三个大坑:锁、临时表、子查询 BUG,逐一复盘现象、根因与最终方案,希望帮后来者少走弯路。
一、背景:为什么 Bamboo 适配 OceanBase 这么"痛"?
先简单介绍下 Bamboo:一个自研的分布式任务调度平台,核心设计理念是 Simple is beautiful,架构上与 XXL-JOB、DolphinScheduler 完全不同:
- 调度引擎不在 Java 里,而在 MySQL 存储过程 + 数据库事件里。时间窗预生成、Cron 匹配、任务展开、流程树状态机、超时取消、负载均衡派发,全部由 13 个存储过程(12 个业务过程 + 1 个加锁模板)+ 5 个数据库事件完成;Java 侧只负责数据 CRUD 和展示。
- 执行器是 Pull 模式,主动到库里拉自己的任务,天然支持多实例、免内网穿透。
- 所有变更走 工单审批,审批通过前不碰在用数据,企业级合规管控。
一句话:存储过程就是 Bamboo 的发动机。
所以当我们在国产数据库 OceanBase 上跑这套 SQL 时,MySQL 上一切正常的过程,直接暴露出了三个方向的兼容性问题。
1.1 测试环境说明
| 项 | MySQL | OceanBase |
|---|---|---|
| 版本 | 5.7.44(社区版) | 5.7.25-OceanBase_CE-v4.5.0.0(社区版,MySQL 兼容模式) |
| 接入方式 | 默认 3306 | 直连 OB 内核的 MySQL 协议端口 2881(未经过 ODP/obproxy 代理) |
| 客户端 | Navicat / MySQL CLI | ODC(OceanBase Developer Center) |
特别说明:本文结论均基于**直连 OB 内核(2881)**的场景实测。社区里不少 OceanBase 兼容性问题的成因与 ODP(obproxy)代理层配置相关,走代理的同学需要另行验证。
二、坑一:GET_LOCK / RELEASE_LOCK 用不了
2.1 现象
Bamboo 的存储过程由数据库事件每 10 秒 / 30 秒循环触发,多节点部署时靠 MySQL 的咨询锁(advisory lock)函数互斥,防止两个节点同时跑同一个过程:
SELECT GET_LOCK(v_proc_name, 0) INTO lock_flag; -- 抢锁
... 业务逻辑 ...
SELECT RELEASE_LOCK(v_proc_name); -- 释放锁
这套代码在 MySQL 5.7/8.0 上运行良好。切到 OceanBase CE 4.5.0.0(直连 2881)后,GET_LOCK/RELEASE_LOCK 直接报错(提示该特性/函数不支持),调度链整体瘫痪。
说明:OceanBase 官方文档虽在锁函数清单中列出了这两个函数,但社区大量反馈其可用性与版本、接入方式(是否走 ODP)相关。对我们来说结论只有一个:作为跨数据库产品,不能把调度互斥押在一个"可能修好"的函数上,彻底绕开才是正解。
2.2 解决方案:用一张表做分布式锁
我们设计了一张 bs_lock 表,用「主键冲突」来模拟抢锁互斥:
-- 分布式锁表:lock_name 即锁名,主键唯一
CREATE TABLE `bs_lock` (
`lock_name` VARCHAR(128) NOT NULL COMMENT '锁名称,对应 v_proc_name',
`lock_time` datetime(6) NOT NULL COMMENT '本次加锁时间',
`expire_time` datetime(6) NOT NULL COMMENT '锁过期时间,超过时间自动释放',
PRIMARY KEY (`lock_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
统一加锁模板(每个带锁过程开头的固定动作):
DECLARE v_begin_time datetime(6) DEFAULT now(6); -- 本次锁的时间戳(加锁前先取好)
-- 抢锁:先清理过期锁,再插入本过程的锁
delete from bs_lock where lock_name = v_proc_name and expire_time < v_begin_time;
insert into bs_lock(lock_name, lock_time, expire_time)
value(v_proc_name, v_begin_time, DATE_ADD(v_begin_time, INTERVAL 120 SECOND));
-- 主键冲突 → insert 直接异常 → 进入 EXIT HANDLER
释放锁的代码与异常处理配套设计,这是整个方案的精髓所在:
DECLARE lock_flag INT DEFAULT 0;
-- 异常统一收口:先尝试释放本次锁,再写日志、抛错
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
-- 只能删除本次锁:用 lock_time 加以限定,绝不误删别人的锁
delete from bs_lock where lock_name = v_proc_name and lock_time = v_begin_time;
SET lock_flag = ROW_COUNT();
IF lock_flag = 1 THEN
set v_message_detail = CONCAT_WS(' ', v_message_detail, '执行失败,已释放锁');
ELSE
set v_message_detail = CONCAT_WS(' ', v_message_detail, '执行失败,获取锁失败, 可能存在并发任务');
END IF;
insert into bs_bsp_log(begin_time, proc_name, msg_code, msg_detail)
value (v_begin_time, v_proc_name, '99999', v_message_detail);
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = v_message_detail;
END;
2.3 三个关键设计点(及两个边界)
- 主键冲突即抢锁失败:
INSERT主键冲突会抛异常,天然进入EXIT HANDLER,不需要IF判断,代码极简。 - 用
lock_time区分"我删的是谁的锁":异常处理器里无条件执行一次DELETE ... WHERE lock_name = ? AND lock_time = v_begin_time,再用ROW_COUNT()判别——- 删到 1 行:说明锁是自己加的,业务逻辑中途出错,锁已释放;
- 删到 0 行:说明锁根本是别人的(抢锁那一刻就冲突了),日志里明确写出"获取锁失败,可能存在并发任务"。 一个小技巧解决了"异常是发生在抢锁前还是抢锁后"这个判断难题。
- 过期锁自愈:过程持有锁时进程被杀(比如节点宕机),锁会残留。下一次抢锁前先
delete ... expire_time < now,过期锁自动清理,不需要看门狗线程。
两个需要知道的边界:
- TTL 语义差异:MySQL 的
GET_LOCK无过期时间,bs_lock带 120 秒 TTL——单次执行超过 TTL 的过程会失去互斥保护。Bamboo 的业务过程都是秒级到几十秒的批量 UPDATE,实测不受影响;如果你的过程可能长时间运行,要么延长 TTL,要么做续期。 - 删到 0 行的另一种可能:理论上"自己的锁已过期被别的节点清走"也会表现为删 0 行,此时日志会误报为"获取锁失败"。实操中 Bamboo 过程远短于 TTL,不会触发,但排查问题时值得知道。
这套模板覆盖了全部 9 个带锁业务过程(另有一个 safe_procedure 作为新过程的复制起点),在 MySQL 和 OceanBase 上行为一致。
三、坑二:CREATE TEMPORARY TABLE ... ENGINE=MEMORY 创建失败
3.1 现象
Bamboo 的过程里有不少中间结果集处理(比如流程树状态机里"找出本轮需要收尾的 GROUP"、"统计每个 run 下各子任务的完成情况"),MySQL 版本用的是内存临时表:
CREATE TEMPORARY TABLE tmp_runs_to_finish (
id BIGINT PRIMARY KEY,
new_status INT
) ENGINE=MEMORY;
INSERT INTO tmp_runs_to_finish (id, new_status)
SELECT p.id, ... FROM bs_run_item p ... GROUP BY p.id;
UPDATE bs_run_item r
INNER JOIN tmp_runs_to_finish t ON t.id = r.id
SET r.status = t.new_status;
DROP TEMPORARY TABLE IF EXISTS tmp_runs_to_finish;
切到 OceanBase CE 4.5.0.0 后,CREATE TEMPORARY TABLE ... ENGINE=MEMORY 直接报错,而且创建失败后后续所有依赖该临时表的 INSERT/UPDATE 一路抛错,异常处理器里的 DROP TEMPORARY TABLE 清理也随之失效。
说明:OceanBase 对临时表的支持随版本与接入方式差异很大(社区大量反馈与 ODP 代理配置相关,且 OB 的存储引擎是统一的 LSM-Tree,本就没有
ENGINE=MEMORY概念)。我们没有去逐一验证"临时表本身能不能用",而是做了产品级决策:中间结果集不依赖临时表,一劳永逸。
3.2 解决方案:普通表 + session_id 会话隔离
既然临时表靠不住,我们改用普通物理表 + UUID 会话隔离:每张中间表加一列 session_id,过程开头 UUID() 生成本次调用的会话 ID,所有读写都带上这个条件,用完即删。多节点并发调用同名字的过程也互不干扰(何况还有坑一的锁兜底)。
-- 替代临时表的中间表(bs_bsp_ 前缀,bsp = bamboo stored procedure)
CREATE TABLE bs_bsp_runs_to_finish (
`session_id` VARCHAR(64) NOT NULL COMMENT 'UUID(),会话隔离',
id BIGINT,
new_status INT,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(排查与残留清理用)'
);
过程内写法:
DECLARE v_session_id VARCHAR(64) DEFAULT UUID();
-- 写入:带上本会话 ID
INSERT INTO bs_bsp_runs_to_finish (`session_id`, id, new_status)
SELECT v_session_id, p.id, ...
FROM bs_run_item p ...
GROUP BY p.id;
-- 读取:同样限定会话
UPDATE bs_run_item r
INNER JOIN bs_bsp_runs_to_finish t
ON t.id = r.id AND t.session_id = v_session_id
SET r.status = t.new_status;
-- 用完清理本会话数据
DELETE FROM bs_bsp_runs_to_finish WHERE session_id = v_session_id;
异常处理器里同样清理,保证任何路径都不残留脏数据:
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
-- 清理 bs_bsp 表(本会话数据)
DELETE FROM bs_bsp_task_done WHERE session_id = v_session_id;
DELETE FROM bs_bsp_runs_to_finish WHERE session_id = v_session_id;
DELETE FROM bs_bsp_circuit_break_runs WHERE session_id = v_session_id;
...
END;
极端情况(进程被 kill、HANDLER 未执行)下的残留行,可凭
create_time排查并定期清理,不影响正确性(每轮读写都限定本会话)。
最终 6 张中间表按这个模式落地:bs_bsp_circuit_break_runs、bs_bsp_runs_to_finish、bs_bsp_task_done、bs_bsp_assign、bs_bsp_batch_load(主键 session_id + instance_id)、bs_bsp_trigger_candidates。
小提示:
bs_bsp_batch_load这类有查询需求的表建议把session_id加进主键或索引,避免全表扫描。
四、坑三:JOIN ON 里的子查询/派生表表达式——OceanBase 返回错误结果集(疑似 BUG)
4.1 现象:同一条 SQL,MySQL 和 OceanBase 结果集不一样
这是最隐蔽、排查成本最高的一个坑。bsp_run_proc(调度计划 → 生成执行实例)里有一条核心 INSERT...SELECT,每个计划需要从"已有执行实例的最大触发时间"(续跑点)之后继续生成新实例——避免重复生成历史数据:
-- 原写法:JOIN ON 条件里,COALESCE 函数参数内嵌相关子查询
SELECT ep.id, t.time_value
FROM bs_schedule ep
INNER JOIN bs_time t
ON t.time_value > COALESCE(
(SELECT GREATEST(MAX(pt.schedule_trigger_time), NOW())
FROM bs_run pt
WHERE pt.schedule_id = ep.id),
NOW())
WHERE ep.status = 1
AND ep.schedule_type = 2
AND (ep.second = '*' OR FIND_IN_SET(t.second, REPLACE(ep.second, ' ', '')))
AND (ep.minute = '*' OR FIND_IN_SET(t.minute, REPLACE(ep.minute, ' ', '')))
...;
同一条 SQL、同一份数据,两个库的结果(复现数据中 bs_run 为空表,子查询 MAX() 为 NULL、续跑点回落到"当前时间";为使结果可复现,脚本将基准时间固定为 2026-10-05 09:40:00):
MySQL 5.7.44 —— 3 行,正确:
OB测试A 09:42:00 ✓(second=0, minute=42 均命中)
OB测试A 09:52:00 ✓
深层流程测试 10:00:30 ✓(second=30, minute=0 均命中)
OceanBase 5.7.25-OceanBase_CE-v4.5.0.0 —— 4 行:多出 2 行错误数据,同时漏掉 1 行正确数据:
OB测试A 09:42:00 ✓
OB测试A 09:52:00 ✓
深层流程测试 09:42:00 ✗ 该计划 second='30'、minute='0,30',这行一个条件都不满足
深层流程测试 09:52:00 ✗ 同上
(正确行 10:00:30 反而消失了)
多出来的行连 WHERE 里的 second/minute 匹配条件都不满足——这不是兼容性差异能解释的,基本可以判定是 OceanBase 优化器对这种形态的 SQL 做了错误的重写。我们把最小复现脚本整理成中英文两份(doc/ocean-base-bug.sql / doc/ocean-base-bug-en.sql,因OceanBase Issue 在github 上只接受全英文,所以才做了中英两份文档)随项目开源;也曾尝试向 OceanBase 官方提交 issue,因提交平台报错未能发出(截图保留在仓库 doc/ocean-base-issue-create-error.png),欢迎有官方渠道的同学协助反馈跟进。
4.2 排查过程:连"标准答案"都是错的
第一反应当然是教科书式的改写——把子查询改成派生表 LEFT JOIN:
-- 方式 2:LEFT JOIN 聚合派生表,函数参数里已经没有子查询了
LEFT JOIN (
SELECT schedule_id, MAX(schedule_trigger_time) AS max_trigger_time
FROM bs_run
GROUP BY schedule_id
) pt_max ON pt_max.schedule_id = ep.id
INNER JOIN bs_time t
ON t.time_value > COALESCE(GREATEST(pt_max.max_trigger_time, NOW()), NOW())
结果:MySQL 结果集与方式 1 一致,OceanBase 依然返回错误结果集。说明问题不在"子查询"本身,而是 OceanBase 优化器对「JOIN ON 条件里的函数表达式 + 内联视图(派生表)列」这一整类形态的处理有 BUG。
继续试方式 3——把聚合结果物化成一张真实的表:
-- 方式 3:聚合结果先落到普通表,再 JOIN 比较
DELETE FROM bs_bsp_schedule_max;
INSERT INTO bs_bsp_schedule_max(id, max_trigger_time)
SELECT s.id, COALESCE(GREATEST(pt_max.max_trigger_time, NOW()), NOW())
FROM bs_schedule s
LEFT JOIN (
SELECT schedule_id, MAX(schedule_trigger_time) AS max_trigger_time
FROM bs_run
GROUP BY schedule_id
) pt_max ON pt_max.schedule_id = s.id;
SELECT ep.id, t.time_value
FROM bs_schedule ep
INNER JOIN bs_bsp_schedule_max pt_max ON pt_max.id = ep.id
INNER JOIN bs_time t
ON t.time_value > COALESCE(GREATEST(pt_max.max_trigger_time, NOW()), NOW())
WHERE ...;
这次 MySQL 与 OceanBase 的结果集完全一致。问题定位:只要把复杂表达式的计算从"内联视图列"上挪开,OceanBase 就正常了。注意方式 3 的 JOIN ON 条件里仍然有 COALESCE(GREATEST(...)) 函数表达式,但此时列来自物理表 bs_bsp_schedule_max(聚合值已在物化阶段算好,这里的 COALESCE 只是冗余保险),OceanBase 处理正常——说明触发 BUG 的形态是「函数表达式 + 内联视图列」,而不是"函数表达式"本身。
4.3 最终改法:三类已验证的替代路径
基于这个结论,我们把全部 5 处同形态写法逐一改造(commit 9e5711d "ocean base left join not work" 及后续收尾,最终代码见 sql/init-create.sql):
① 聚合结果物化成表(bsp_run_proc):即上面方式 3,bs_bsp_schedule_max 物化续跑点,JOIN 只做列比较。这张表是全程持锁的单例物化表——每次运行全量重建即可,不需要 session_id 会话隔离(坑一的 bs_lock 锁保证了同一时刻只有一个过程在跑)。
② 能取列就不算表达式(bsp_dispatch_proc,负载均衡派发):原写法是 ORDER BY (子查询算已有负载) + COALESCE((SELECT extra_load ...), 0),改为 LEFT JOIN 批次负载物理表后直接取列:
SELECT r.`id` INTO v_best_instance
FROM `bs_executor_registry` r
LEFT JOIN `bs_bsp_batch_load` bl
ON bl.`instance_id` = r.`id` AND bl.`session_id` = v_session_id
WHERE r.`executor_id` = v_eid AND r.`status` = 1
ORDER BY (
SELECT COUNT(*) FROM `bs_run_item` t2
WHERE t2.`executor_instance_id` = r.`id`
AND t2.`handler_id` = v_hid
AND t2.`status` IN (1, 2, 3, 4)
) + COALESCE(bl.`extra_load`, 0) ASC, -- 直接取物理表列,不再在函数参数里写子查询
r.`update_time` DESC
LIMIT 1;
③ 配置值顶部 SELECT INTO 变量(bsp_run_item_proc):原写法在 UPDATE 的 CASE 表达式里 COALESCE((SELECT config_value FROM bs_global_config ...), 300),改为过程开头一次性取到局部变量:
DECLARE v_misfire_window INT;
SELECT `config_value` INTO v_misfire_window
FROM `bs_global_config`
WHERE `config_key` = 'misfire_window' AND `status` = 1;
IF v_misfire_window IS NULL THEN SET v_misfire_window = 300; END IF;
-- 后面直接引用变量
UPDATE bs_run SET status = 6, cancel_reason = 'MISFIRE'
WHERE ...
AND TIMESTAMPDIFF(SECOND, schedule_trigger_time, NOW()) >
CASE WHEN misfire_window > 0 THEN misfire_window -- 列:bs_run 自身的失火窗口
ELSE v_misfire_window END; -- 变量:全局配置兜底值
4.4 经验法则(按形态实测结论)
| 写法形态 | OceanBase 实测 | 结论 |
|---|---|---|
| 函数参数(COALESCE/IFNULL/GREATEST 等)内写子查询 | ❌ 结果集错误 | 禁用 |
| JOIN ON 条件里「函数表达式 + 内联视图(派生表)列」 | ❌ 结果集错误 | 禁用(最隐蔽,务必记住) |
| JOIN ON 条件里「函数表达式 + 物理表列」 | ✅ 正确 | 可用(聚合值先物化再引用) |
| ORDER BY / SELECT 列表中的标量子查询 | ✅ 正确 | 可用(见 §4.3 ②) |
顶部 SELECT ... INTO 变量 后引用 | ✅ 正确 | 配置类取值首选 |
给所有往 OceanBase 迁移存储过程的同学的忠告:不要在函数参数里写任何子查询,也尽量不要把函数表达式和内联视图列一起放进 JOIN ON 条件;复杂计算提前物化成表、物理表取列或 SELECT INTO 局部变量。这些替代写法不仅规避了 BUG,对 bsp_run_proc 这类逐行求值的 JOIN ON 场景性能还更好——原来的写法每一行都要重复执行相关子查询。
五、适配心得总结
回顾整个适配过程,三个坑的共性很有意思:
| 坑 | 现象 | 根因 | 最终方案 |
|---|---|---|---|
| 锁 | GET_LOCK/RELEASE_LOCK 报错 | 咨询锁函数在目标环境不可用(可用性与版本/接入方式相关) | bs_lock 表锁:主键冲突抢锁 + lock_time 限定解锁 + 过期自愈 |
| 临时表 | CREATE TEMPORARY TABLE ... ENGINE=MEMORY 创建失败 | 临时表支持随版本/接入方式差异大,ENGINE=MEMORY 无对应概念 | 普通表 + session_id=UUID() 会话隔离,EXIT HANDLER 双保险清理 |
| 子查询 | 同一 SQL 结果集不同,多出错误行、漏掉正确行 | OceanBase 优化器对「函数表达式 + 内联视图列」形态疑似重写 BUG | 物化表 / 物理表取列 / SELECT INTO 变量 |
几点体会及建议:
- "完全兼容 MySQL" 要打引号。OceanBase 的 MySQL 兼容模式覆盖了大部分 SQL 语法,但存储过程是重灾区:咨询锁、临时表、优化器重写行为,都是迁移前不容易想到的暗礁。凡是把核心逻辑写在存储过程里的系统,迁移前务必把过程全量在目标库跑一遍。
- "教科书式改写"不一定对。方式 2(LEFT JOIN 派生表)是社区标准答案,在 OceanBase 上照样翻车。遇到结果集不一致,别急着改业务逻辑,先用最小复现锁定数据库差异,逐种替代写法验证。
- 把 BUG 复现脚本留在仓库里。我们把中英文复现脚本都提交进了项目仓库(结果集对比记录在英文版脚本中),既方便日后反馈跟进,也成了团队新人最好的"避坑教材"。
- OB 前置检查别忘了:OceanBase 上
event_scheduler默认是关闭的,需要手动开启(SET GLOBAL event_scheduler = ON);且数据库事件连续失败达到阈值可能被自动停用——迁移完一定要检查调度事件是否真的在跑。 - 使用obclient建表和存储过程:亲测在ODC上运行脚本一次建多个存储过程会失败,建议用 obclient 建表和存储过程。命令行样例:obclient -h127.0.0.1 -P2881 -uroot@sys -p'your-password' < /mnt/my_share/share/init-create.sql
六、关于 Bamboo
最后按惯例安利一下项目。Bamboo(竹节)是一个数据库驱动的分布式任务调度平台,主打轻量极简、零额外依赖:不需要 Redis、ZooKeeper,仅需 MySQL/OceanBase + 一个 Spring Boot 应用(OB 上记得开启 event_scheduler,见上文)即可拥有完整的分布式调度能力。
- 数据库驱动调度:调度逻辑全部在存储过程 + 事件中,所有调度状态持久化,可直观预览未来时间窗口的全部调度计划;
- Flow Tree 树形编排:以"阶段串行、同阶段并发"替代复杂 DAG,表单化配置,零学习成本;
- Pull 模式执行器:执行器自取任务,天然支持多实例、广播分片,Java 侧可做到零依赖;
- 工单制变更审批:编辑-审批-执行三步隔离,多级审批、变更快照、全程审计,适配生产合规要求;
- 运行干预双人复核:手动触发、取消、暂停、恢复等操作全部双人复核留痕。
目前已完成 MySQL 5.7/8.0 与 OceanBase CE 4.5 双数据库适配,下一步计划往达梦、人大金仓等国产数据库继续迁移,支持国货。项目基于 Apache 2.0 协议开源,代码结构简单,欢迎 Star、试用、提 PR 共建!
附:参考资料
- OceanBase 子查询 BUG 最小复现脚本(中文,含两份结果集对比):ocean-base-bug.sql
- OceanBase 子查询 BUG 最小复现脚本(英文,含两份结果集对比):ocean-base-bug-en.sql
- 全部存储过程源码:sql/init-all.sql
- Bamboo 项目介绍:《轻量极简、零额外依赖!自研分布式任务调度 Bamboo 系统深度介绍》