
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 测试环境说明项MySQLOceanBase版本5.7.44社区版5.7.25-OceanBase_CE-v4.5.0.0社区版MySQL 兼容模式接入方式默认 3306直连 OB 内核的 MySQL 协议端口 2881未经过 ODP/obproxy 代理客户端Navicat / MySQL CLIODC(OceanBase Developer Center)特别说明本文结论均基于**直连 OB 内核2881**的场景实测。社区里不少 OceanBase 兼容性问题的成因与 ODPobproxy代理层配置相关走代理的同学需要另行验证。二、坑一GET_LOCK / RELEASE_LOCK 用不了2.1 现象Bamboo 的存储过程由数据库事件每 10 秒 / 30 秒循环触发多节点部署时靠 MySQL 的咨询锁advisory lock函数互斥防止两个节点同时跑同一个过程SELECTGET_LOCK(v_proc_name,0)INTOlock_flag;-- 抢锁...业务逻辑...SELECTRELEASE_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 即锁名主键唯一CREATETABLEbs_lock(lock_nameVARCHAR(128)NOTNULLCOMMENT锁名称对应 v_proc_name,lock_timedatetime(6)NOTNULLCOMMENT本次加锁时间,expire_timedatetime(6)NOTNULLCOMMENT锁过期时间超过时间自动释放,PRIMARYKEY(lock_name))ENGINEInnoDBDEFAULTCHARSETutf8mb4;统一加锁模板每个带锁过程开头的固定动作DECLAREv_begin_timedatetime(6)DEFAULTnow(6);-- 本次锁的时间戳加锁前先取好-- 抢锁先清理过期锁再插入本过程的锁deletefrombs_lockwherelock_namev_proc_nameandexpire_timev_begin_time;insertintobs_lock(lock_name,lock_time,expire_time)value(v_proc_name,v_begin_time,DATE_ADD(v_begin_time,INTERVAL120SECOND));-- 主键冲突 → insert 直接异常 → 进入 EXIT HANDLER释放锁的代码与异常处理配套设计这是整个方案的精髓所在DECLARElock_flagINTDEFAULT0;-- 异常统一收口先尝试释放本次锁再写日志、抛错DECLAREEXITHANDLERFORSQLEXCEPTIONBEGIN-- 只能删除本次锁用 lock_time 加以限定绝不误删别人的锁deletefrombs_lockwherelock_namev_proc_nameandlock_timev_begin_time;SETlock_flagROW_COUNT();IFlock_flag1THENsetv_message_detailCONCAT_WS( ,v_message_detail,执行失败已释放锁);ELSEsetv_message_detailCONCAT_WS( ,v_message_detail,执行失败获取锁失败, 可能存在并发任务);ENDIF;insertintobs_bsp_log(begin_time,proc_name,msg_code,msg_detail)value(v_begin_time,v_proc_name,99999,v_message_detail);SIGNAL SQLSTATE45000SETMESSAGE_TEXTv_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 … ENGINEMEMORY 创建失败3.1 现象Bamboo 的过程里有不少中间结果集处理比如流程树状态机里找出本轮需要收尾的 GROUP、“统计每个 run 下各子任务的完成情况”MySQL 版本用的是内存临时表CREATETEMPORARYTABLEtmp_runs_to_finish(idBIGINTPRIMARYKEY,new_statusINT)ENGINEMEMORY;INSERTINTOtmp_runs_to_finish(id,new_status)SELECTp.id,...FROMbs_run_item p...GROUPBYp.id;UPDATEbs_run_item rINNERJOINtmp_runs_to_finish tONt.idr.idSETr.statust.new_status;DROPTEMPORARYTABLEIFEXISTStmp_runs_to_finish;切到 OceanBase CE 4.5.0.0 后CREATE TEMPORARY TABLE ... ENGINEMEMORY直接报错而且创建失败后后续所有依赖该临时表的 INSERT/UPDATE 一路抛错异常处理器里的DROP TEMPORARY TABLE清理也随之失效。说明OceanBase 对临时表的支持随版本与接入方式差异很大社区大量反馈与 ODP 代理配置相关且 OB 的存储引擎是统一的 LSM-Tree本就没有ENGINEMEMORY概念。我们没有去逐一验证临时表本身能不能用而是做了产品级决策中间结果集不依赖临时表一劳永逸。3.2 解决方案普通表 session_id 会话隔离既然临时表靠不住我们改用普通物理表 UUID 会话隔离每张中间表加一列session_id过程开头UUID()生成本次调用的会话 ID所有读写都带上这个条件用完即删。多节点并发调用同名字的过程也互不干扰何况还有坑一的锁兜底。-- 替代临时表的中间表bs_bsp_ 前缀bsp bamboo stored procedureCREATETABLEbs_bsp_runs_to_finish(session_idVARCHAR(64)NOTNULLCOMMENTUUID()会话隔离,idBIGINT,new_statusINT,create_timedatetimeNOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT创建时间排查与残留清理用);过程内写法DECLAREv_session_idVARCHAR(64)DEFAULTUUID();-- 写入带上本会话 IDINSERTINTObs_bsp_runs_to_finish(session_id,id,new_status)SELECTv_session_id,p.id,...FROMbs_run_item p...GROUPBYp.id;-- 读取同样限定会话UPDATEbs_run_item rINNERJOINbs_bsp_runs_to_finish tONt.idr.idANDt.session_idv_session_idSETr.statust.new_status;-- 用完清理本会话数据DELETEFROMbs_bsp_runs_to_finishWHEREsession_idv_session_id;异常处理器里同样清理保证任何路径都不残留脏数据DECLAREEXITHANDLERFORSQLEXCEPTIONBEGIN-- 清理 bs_bsp 表本会话数据DELETEFROMbs_bsp_task_doneWHEREsession_idv_session_id;DELETEFROMbs_bsp_runs_to_finishWHEREsession_idv_session_id;DELETEFROMbs_bsp_circuit_break_runsWHEREsession_idv_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 返回错误结果集疑似 BUG4.1 现象同一条 SQLMySQL 和 OceanBase 结果集不一样这是最隐蔽、排查成本最高的一个坑。bsp_run_proc调度计划 → 生成执行实例里有一条核心 INSERT…SELECT每个计划需要从已有执行实例的最大触发时间续跑点之后继续生成新实例——避免重复生成历史数据-- 原写法JOIN ON 条件里COALESCE 函数参数内嵌相关子查询SELECTep.id,t.time_valueFROMbs_schedule epINNERJOINbs_time tONt.time_valueCOALESCE((SELECTGREATEST(MAX(pt.schedule_trigger_time),NOW())FROMbs_run ptWHEREpt.schedule_idep.id),NOW())WHEREep.status1ANDep.schedule_type2AND(ep.second*ORFIND_IN_SET(t.second,REPLACE(ep.second, ,)))AND(ep.minute*ORFIND_IN_SET(t.minute,REPLACE(ep.minute, ,)))...;同一条 SQL、同一份数据两个库的结果复现数据中bs_run为空表子查询MAX()为 NULL、续跑点回落到当前时间为使结果可复现脚本将基准时间固定为 2026-10-05 09:40:00MySQL 5.7.44 —— 3 行正确OB测试A 09:42:00 ✓second0, minute42 均命中 OB测试A 09:52:00 ✓ 深层流程测试 10:00:30 ✓second30, minute0 均命中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 ✗ 该计划 second30、minute0,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-- 方式 2LEFT JOIN 聚合派生表函数参数里已经没有子查询了LEFTJOIN(SELECTschedule_id,MAX(schedule_trigger_time)ASmax_trigger_timeFROMbs_runGROUPBYschedule_id)pt_maxONpt_max.schedule_idep.idINNERJOINbs_time tONt.time_valueCOALESCE(GREATEST(pt_max.max_trigger_time,NOW()),NOW())结果MySQL 结果集与方式 1 一致OceanBase 依然返回错误结果集。说明问题不在子查询本身而是 OceanBase 优化器对「JOIN ON 条件里的函数表达式 内联视图派生表列」这一整类形态的处理有 BUG。继续试方式 3——把聚合结果物化成一张真实的表-- 方式 3聚合结果先落到普通表再 JOIN 比较DELETEFROMbs_bsp_schedule_max;INSERTINTObs_bsp_schedule_max(id,max_trigger_time)SELECTs.id,COALESCE(GREATEST(pt_max.max_trigger_time,NOW()),NOW())FROMbs_schedule sLEFTJOIN(SELECTschedule_id,MAX(schedule_trigger_time)ASmax_trigger_timeFROMbs_runGROUPBYschedule_id)pt_maxONpt_max.schedule_ids.id;SELECTep.id,t.time_valueFROMbs_schedule epINNERJOINbs_bsp_schedule_max pt_maxONpt_max.idep.idINNERJOINbs_time tONt.time_valueCOALESCE(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即上面方式 3bs_bsp_schedule_max物化续跑点JOIN 只做列比较。这张表是全程持锁的单例物化表——每次运行全量重建即可不需要 session_id 会话隔离坑一的bs_lock锁保证了同一时刻只有一个过程在跑。② 能取列就不算表达式bsp_dispatch_proc负载均衡派发原写法是ORDER BY (子查询算已有负载) COALESCE((SELECT extra_load ...), 0)改为 LEFT JOIN 批次负载物理表后直接取列SELECTr.idINTOv_best_instanceFROMbs_executor_registryrLEFTJOINbs_bsp_batch_loadblONbl.instance_idr.idANDbl.session_idv_session_idWHEREr.executor_idv_eidANDr.status1ORDERBY(SELECTCOUNT(*)FROMbs_run_itemt2WHEREt2.executor_instance_idr.idANDt2.handler_idv_hidANDt2.statusIN(1,2,3,4))COALESCE(bl.extra_load,0)ASC,-- 直接取物理表列不再在函数参数里写子查询r.update_timeDESCLIMIT1;③ 配置值顶部 SELECT INTO 变量bsp_run_item_proc原写法在 UPDATE 的 CASE 表达式里COALESCE((SELECT config_value FROM bs_global_config ...), 300)改为过程开头一次性取到局部变量DECLAREv_misfire_windowINT;SELECTconfig_valueINTOv_misfire_windowFROMbs_global_configWHEREconfig_keymisfire_windowANDstatus1;IFv_misfire_windowISNULLTHENSETv_misfire_window300;ENDIF;-- 后面直接引用变量UPDATEbs_runSETstatus6,cancel_reasonMISFIREWHERE...ANDTIMESTAMPDIFF(SECOND,schedule_trigger_time,NOW())CASEWHENmisfire_window0THENmisfire_window-- 列bs_run 自身的失火窗口ELSEv_misfire_windowEND;-- 变量全局配置兜底值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 ... ENGINEMEMORY创建失败临时表支持随版本/接入方式差异大ENGINEMEMORY 无对应概念普通表 session_idUUID()会话隔离EXIT HANDLER 双保险清理子查询同一 SQL 结果集不同多出错误行、漏掉正确行OceanBase 优化器对「函数表达式 内联视图列」形态疑似重写 BUG物化表 / 物理表取列 / SELECT INTO 变量几点体会及建议“完全兼容 MySQL” 要打引号。OceanBase 的 MySQL 兼容模式覆盖了大部分 SQL 语法但存储过程是重灾区咨询锁、临时表、优化器重写行为都是迁移前不容易想到的暗礁。凡是把核心逻辑写在存储过程里的系统迁移前务必把过程全量在目标库跑一遍。教科书式改写不一定对。方式 2LEFT JOIN 派生表是社区标准答案在 OceanBase 上照样翻车。遇到结果集不一致别急着改业务逻辑先用最小复现锁定数据库差异逐种替代写法验证。把 BUG 复现脚本留在仓库里。我们把中英文复现脚本都提交进了项目仓库结果集对比记录在英文版脚本中既方便日后反馈跟进也成了团队新人最好的避坑教材。OB 前置检查别忘了OceanBase 上event_scheduler默认是关闭的需要手动开启SET GLOBAL event_scheduler ON且数据库事件连续失败达到阈值可能被自动停用——迁移完一定要检查调度事件是否真的在跑。使用obclient建表和存储过程亲测在ODC上运行脚本一次建多个存储过程会失败建议用 obclient 建表和存储过程。命令行样例obclient -h127.0.0.1 -P2881 -urootsys -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 共建项目地址https://gitee.com/winterzhong/bamboo-system附参考资料OceanBase 子查询 BUG 最小复现脚本中文含两份结果集对比ocean-base-bug.sqlOceanBase 子查询 BUG 最小复现脚本英文含两份结果集对比ocean-base-bug-en.sql全部存储过程源码sql/init-all.sqlBamboo 项目介绍《轻量极简、零额外依赖自研分布式任务调度 Bamboo 系统深度介绍》