前言:一个要"原样跑起来"的新项目
今年年初,我们组接了个内部交易中台的重构活儿。架构早就定了,Spring Boot + MyBatis + Druid,数据库这次要换成金仓数据库。组里大部分人之前都是写 Oracle 上的应用,对金仓不算熟。leader 把数据库接入这块甩给了我,原话是:“先把连接打通,存储过程跑顺,别出幺蛾子。”
我当时答应得挺痛快,心想不就换个数据库嘛。真上手才知道,"打通"这俩字背后全是坑。这篇就把我在驱动、连接池、MyBatis、存储过程调试这几处栽过的跟头记一下,给后来人提个醒。
目录
- 前言:一个要"原样跑起来"的新项目
- 痛点:看似只是换个数据库,实则处处暗礁
- 方案:JDBC + Druid + MyBatis + KStudio 调试
- 踩坑实录
- 坑一:JDBC 驱动 jar 跟 JDK 版本没对上
- 坑二:Druid 连接池"假活",连接泄漏
- 坑三:MyBatis 里的 Oracle 方言 SQL
- 坑四:存储过程跑不对,靠 KStudio 单步揪出 bug
- 关键优化:大结果集的 fetchsize
- 落地效果
- 写在最后
痛点:看似只是换个数据库,实则处处暗礁
动手前我盘了一下,麻烦主要在三处。
一是驱动。金仓的 JDBC 驱动按 JDK 版本分了好几个 jar,跟平时用 MySQL 那种一个 jar 走天下完全不一样。版本没对上,启动直接类加载失败,连数据库的影儿都摸不着。
二是连接池。原来 Druid 配的是针对另一套库的参数,验证语句、超时这些照搬过来,连接池看着是活的,其实是"假活",线上隔三差五就报错,排查起来特别费劲。
三是存储过程。业务里有一批 PL/SQL 存储过程,迁过来逻辑跑不对。光盯着代码看,看不出来毛病,得靠调试一步步过。
方案:JDBC + Druid + MyBatis + KStudio 调试
技术栈没得选,就是那套。JDBC 用金仓原生驱动,连接池继续 Druid,ORM 还是 MyBatis,存储过程调试用金仓自带的 KStudio。
整体思路其实挺简单:先把连接跑通,再调连接池参数,接着把 MyBatis 里的方言 SQL 适配掉,最后用 KStudio 把存储过程的 bug 一个个揪出来。听起来就四步,实际每一步都够喝一壶。我画了张图,照着这个顺序往下啃:
踩坑实录
坑一:JDBC 驱动 jar 跟 JDK 版本没对上
第一个坑来得特别快。周一早上,我把驱动 jar 往项目里一丢,启动,直接报ClassNotFoundException: com.kingbase8.Driver。
第一反应是 jar 没引进来。检查了半天 pom 和 lib 目录,jar 明明就躺在那儿。我又怀疑是 IDE 缓存,清缓存重启,还是一样。卡了快一个小时,后来翻了金仓的文档才反应过来–它的 JDBC 驱动是按 JDK 版本分的。我项目跑 JDK8,结果手滑丢进去的是 jre6 那个。版本不匹配,类加载直接挂,怪不得报找不到类。
| 驱动 jar 文件 | 对应 JDK 版本 |
|---|---|
| kingbase8-9.0.0.jre6.jar | JDK 1.6 |
| kingbase8-9.0.0.jre7.jar | JDK 1.7 |
| kingbase8-9.0.0.jar | JDK 1.8 |
这些 jar 都在数据库安装目录的Interface/jdbc下。别想当然随便抓一个就塞进去,先看清楚自己项目的 JDK 版本。换回对应版本,连接立马通了:
// 金仓 JDBC 连接:注意驱动类名和连接串前缀都跟 Oracle/MySQL 不一样Class.forName("com.kingbase8.Driver");Stringurl="jdbc:kingbase8://10.0.0.12:54321/trade"+"?currentSchema=trade";try(Connectionconn=DriverManager.getConnection(url,"appuser","pwd123")){System.out.println("连通了,版本: "+conn.getMetaData().getDatabaseProductVersion());}这种坑说实话挺低级,但越是低级的坑,越容易因为"这还能有错"的心态忽略掉。
坑二:Druid 连接池"假活",连接泄漏
这个坑折磨了我小两天。
应用本地跑起来一切正常,压测也没事。可一上预发,隔三差五就报"无法获取连接"。看日志,连接池里的连接全被占满了。重启就好,过会儿又犯,跟闹钟似的。
我第一反应是连接没关。把代码里所有getConnection翻了一遍,都老老实实try-with-resources了,没漏。这就卡住了–代码没问题,连接池却满了,邪门。
后来盯 Druid 的监控页面才看出门道。连接池里有大量连接处于"活动"状态,但实际上对数据库已经失效了。问题出在validationQuery上。原来这套配置是从别的库照搬的,验证语句写的是那套库的写法,到金仓这儿语义对不上,等于没验证。连接早断了,池子还以为它活着,业务一拿就是个死连接,占着茅坑不拉屎,很快就被耗光了。
排查这玩意儿我走了弯路,后来总结了个流程,照着走能少绕不少弯子:
光看池子不行,我还直接去数据库里数了一把活动会话,确认是不是真的有一堆僵死连接赖在那儿:
-- 直连金仓,查当前活动会话,看有没有一堆连接赖着不动SELECTpid,usename,application_name,client_addr,state,query_start,state_change,LEFT(query,60)ASqueryFROMsys_stat_activityWHEREdatname='trade'ORDERBYquery_startDESC;一查果然,一堆state = idle的连接,state_change时间还是十几分钟前的,典型的僵死连接。证据确凿,改配置就有了方向。把验证语句换成金仓能识别的,再配上testWhileIdle,问题立马消停:
spring:datasource:type:com.alibaba.druid.pool.DruidDataSourcedriver-class-name:com.kingbase8.Driverurl:jdbc:kingbase8://10.0.0.12:54321/trade?currentSchema=tradeusername:appuserpassword:pwd123druid:# 关键:验证语句得是金仓认的,别照搬别家的validation-query:SELECT 1test-while-idle:truetest-on-borrow:falsetest-on-return:false# 空闲连接存活检测间隔,别让死连接赖在池子里time-between-eviction-runs-millis:60000min-evictable-idle-time-millis:300000max-active:50min-idle:5顺带说一句,金仓兼容模式下SELECT 1 FROM DUAL也能跑,但直接SELECT 1更省事,没必要绕那一下。
坑三:MyBatis 里的 Oracle 方言 SQL
MyBatis 接入本身不难。驱动类名、URL 换掉,配置基本就齐了。金仓对 MyBatis 3.x 的几个版本都做过适配验证,这块挺省心。
# jdbc.properties jdbc.driverClassName=com.kingbase8.Driver jdbc.url=jdbc:kingbase8://10.0.0.12:54321/trade?currentSchema=trade jdbc.username=appuser jdbc.password=pwd123<!-- mybatis config.xml --><environmentsdefault="development"><environmentid="development"><transactionManagertype="JDBC"/><dataSourcetype="POOLED"><propertyname="driver"value="${jdbc.driverClassName}"/><propertyname="url"value="${jdbc.url}"/><propertyname="username"value="${jdbc.username}"/><propertyname="password"value="${jdbc.password}"/></dataSource></environment></environments>真正的坑不在配置,在 Mapper 的 SQL 里。原来有一段拿主键的逻辑,直接调了 Oracle 的序列:
<!-- 原写法:直接调序列 nextval,Oracle 上没毛病 --><selectid="nextOrderId"resultType="long">SELECT seq_order.nextval FROM DUAL</select>金仓兼容模式下序列和 DUAL 都支持,这段能跑。但我心里不踏实–这种方言写法留在代码里,以后就是个隐患,指不定哪天换个驱动版本、换个模式就炸了。索性趁这次接入,把拿主键的方式统一换掉。新表直接用 IDENTITY 列,插入时用useGeneratedKeys一把拿回主键,干净利落,跟序列彻底说再见:
<!-- 改造后:用 RETURNING 直接拿自增主键,跟序列说再见 --><insertid="insertOrder"parameterType="Order"useGeneratedKeys="true"keyProperty="id">INSERT INTO orders(order_no, amount, create_time) VALUES(#{orderNo}, #{amount}, #{createTime})</insert>我的建议是,接入的时候顺手把这类方言写法扫一遍,能换的就换掉,别图省事留着。当时省的事,以后都得还。
坑四:存储过程跑不对,靠 KStudio 单步揪出 bug
前面几个坑好歹是应用层的,看得见摸得着。存储过程这个就恶心了。
有个算订单优惠金额的存储过程,迁过来以后个别订单算出来的结果跟预期差几分钱。光盯着代码看,逻辑好像没毛病,变量赋值、循环、条件分支,都对得上。瞪了半天眼睛,没看出问题。
没办法,上 KStudio 调试。这工具是金仓自带的图形界面,能对 PL/SQL 函数和存储过程断点单步调试,比光看代码硬啃强太多。操作不复杂,我后来画了张图,照着点就行:
具体操作就是:在对象树里找到那个函数,右键选"调试",把入参填进去,点"开始调试"。然后就是工具栏那几个按钮–"单步跳过"一步步往下走,"单步跳入"钻进嵌套调用的函数里。变量值实时显示在旁边,哪一步算错了看得一清二楚。
调了两轮,bug 现形了。是中间一步SELECT ... INTO取折扣率的时候,没处理空值。遇到某类没配折扣的商品,INTO取回来是空,后续乘法一算,结果就飘了。代码大概长这样:
-- 出问题的存储过程片段:INTO 没兜住空值CREATEORREPLACEPROCEDUREcalc_order_amount(p_order_idINNUMERIC)ASv_discountNUMERIC;v_amountNUMERIC;BEGIN-- 这里:某些商品没配折扣,discount 取回来是 NULLSELECTdiscount_rateINTOv_discountFROMproduct_ruleWHEREproduct_id=(SELECTproduct_idFROMordersWHEREid=p_order_id);-- NULL 参与乘法,结果直接飘SELECTtotal_amountINTOv_amountFROMordersWHEREid=p_order_id;UPDATEordersSETpay_amount=v_amount*v_discountWHEREid=p_order_id;END;/加个兜底就好了,没配折扣就当不打折,折扣率按 1.0 算:
-- 修复:NVL 兜底,没折扣就当 1.0(不打折)SELECTNVL(discount_rate,1.0)INTOv_discountFROMproduct_ruleWHEREproduct_id=(SELECTproduct_idFROMordersWHEREid=p_order_id);这种 bug,不调试光看代码真的很难发现–逻辑流程是对的,错的是数据。NULL 这玩意儿在关系库里就是个坑,参与运算默默给你搞出个空,还不报错。KStudio 那个单步调试,在这儿救了我半条命。
关键优化:大结果集的 fetchsize
接入跑通之后,又冒出一个性能问题。有个导出接口,查几十万行数据,跑着跑着内存就飙上去了,时不时 OOM。
查下来是 fetchsize 没设。默认情况下 JDBC 驱动会一次性把结果集全捞到客户端内存里,数据量一大直接撑爆。设上setFetchSize,让驱动按需分批拉,内存立马就稳了。
另外金仓有个enable_autocommit_fetch参数,在自动提交模式下控制是全量返回还是按 fetchsize 按需返回。大结果集场景值得留意一下,默认行为不一定符合你预期:
// 大结果集查询:务必设 fetchsize,别让驱动一把全捞进内存publicvoidexportOrders(OutputStreamout)throwsSQLException,IOException{Stringsql="SELECT id, order_no, amount, create_time FROM orders WHERE create_time >= ?";try(Connectionconn=dataSource.getConnection();PreparedStatementps=conn.prepareStatement(sql,ResultSet.TYPE_FORWARD_ONLY,ResultSet.CONCUR_READ_ONLY)){// 自动提交 + 按需获取,配合 fetchsize 省内存conn.setAutoCommit(true);ps.setFetchSize(500);// 每次只拉 500 行ps.setTimestamp(1,Timestamp.valueOf("2025-01-01 00:00:00"));try(ResultSetrs=ps.executeQuery()){while(rs.next()){writeRow(out,rs);// 流式写出,内存占用恒定}}}}落地效果
踩完这些坑,应用接入稳定运行,预发压测也过了。前后对比大致这样:
| 指标 | 接入初期(踩坑中) | 调优后 |
|---|---|---|
| 启动成功率 | 偶发类加载失败 | 100% |
| 连接池稳定性 | 间歇性"无法获取连接" | 连续压测无泄漏 |
| 大结果集导出内存峰值 | OOM 频发 | 稳定在 300MB 以内 |
| 存储过程排查耗时 | 肉眼盯代码,半天起步 | KStudio 单步,分钟级定位 |
写在最后
这趟接入干下来,我最大的感受是:换数据库这事,难的不是"能连上",而是"连得稳、跑得对"。
驱动版本、连接池验证语句、方言 SQL、存储过程空值,这几个坑单拎出来都不大,但凑一块儿够你忙活好几天。而且它们有个共同特点–本地测试的时候基本不冒头,非得上预发、压一压、跑跑真实数据才现形。这也是我为什么特别强调要尽早把应用丢到预发环境上去跑,别在本地自嗨。
几条心得,掏心窝子说一下。驱动 jar 一定对照 JDK 版本,别手滑,这步错了后面全白搭。连接池的validationQuery别照搬,得是目标库认的语句,配合testWhileIdle才管用。MyBatis 里的方言写法,趁接入的机会能清就清,别留给以后当定时炸弹。存储过程有 bug 别干瞪眼,KStudio 单步调试比肉眼强一百倍,变量值一目了然。大结果集别忘了 fetchsize,这个最容易忽略,也最容易 OOM。
金仓数据库在 JDBC、MyBatis 这些主流开发框架上的适配做得挺到位,驱动、连接、ORM 基本都能平滑接上,KStudio 的调试功能也确实好用,省了我不少排查时间。但适配归适配,该抠的细节一个都不能少。开发接入这活儿,慢点没关系,把每个环节都踩实了,后面上线才踏实。