JDBC连着连着,连接池、事务、批处理都玩过一轮后,DAY04最容易被问到的就是:到底要不要在Java里调用MySQL存储过程和存储函数?我的回答通常是:如果你的业务规则足够稳定、改动频率不高,JDBC调用存储过程是一把非常趁手的工具;如果只是写普通CRUD,那大概率你不需要折腾。这一篇我会把“调用存储过程、存储函数”这整条链路拆开,从MySQL侧建存储过程,到Java侧用CallableStatement调用,再到结果集、输出参数、多结果集、常见坑点,全部过一遍。不管你是刚学到JDBC的新手,还是被项目里“存储过程+Java”整得头疼的同学,这篇都能给你一份能直接抄作业的参考答案。
1. 存储过程这玩意儿,为什么非要在JDBC里调
1.1 存储过程与存储函数:一句话分清
很多同学容易把存储过程和存储函数混在一起,其实最核心的区别只有三个:存储过程用CALL调用,存储函数用SELECT调用或者表达式引用;存储过程没有返回值,只能通过OUT/INOUT参数往回带数据,存储函数必须有返回值;存储函数适合做“输入一个值、输出一个值”的计算,存储过程适合做“一段带流程控制的多步SQL操作”。
拿MySQL举例,一个存储过程可以包含多条SQL、循环、判断、异常处理,而存储函数只能返回单个值,不允许使用SELECT直接返回结果集。这个区别直接在JDBC调用时也有体现:存储函数用{? = CALL func(?)}这种语法,存储过程用{CALL proc(?,?)}。
1.2 JDBC里不直接拼SQL,非要用存储过程,图什么
以前我也嫌存储过程麻烦,Java里写好SQL不香吗?后来在几个项目里被教育了几次,才真正理解为什么要用存储过程。
第一个好处是减少网络往返。有些业务逻辑动不动就五六条SQL,比如订单支付要查余额、扣库存、写流水、更新订单状态。如果每步都在Java里单独发一个SQL,一次操作可能要4到6个网络往返;全部装进存储过程,客户端只需要发一次调用请求,MySQL内部顺序执行完,再一次性返回。内网环境不太明显,但跨机房、跨网络时差距立刻出来了。
第二个好处是统一逻辑入口。同一个“支付”流程,可能在Java端、报表端、定时任务里都会用到,如果把逻辑写在存储过程里,谁调都是同一份逻辑;否则同一套业务规则分散在多个系统的代码里,维护成本翻倍。
第三个好处是权限控制。我们可以让Java应用只连一个低权限账号,只允许它执行存储过程,不允许直接操作表。这样即使Web层被入侵或者业务代码写错,也摸不到底层表结构。
当然,这些都是“能用”的必要条件,不是“必须用”的铁律。到底什么时候用,我会留到最后一节再讲。
2. 写Java之前,先把MySQL侧的存储过程建好
2.1 先造一张能用的表
为了说明白,咱们用电商最常见的两张表:用户表和订单表。
CREATE DATABASE IF NOT EXISTS demo_jdbc DEFAULT CHARSET utf8mb4; USE demo_jdbc; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, register_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); INSERT INTO t_user (username) VALUES ('张三'), ('李四'), ('王五'); INSERT INTO t_order (user_id, amount, status) VALUES (1, 99.50, 1), (1, 39.00, 0), (2, 199.00, 1), (2, 59.00, 1), (3, 1000.00, 0);这里的t_user是用户主数据,t_order是订单流水。后面所有的存储过程都用这两张表。
2.2 建一个带IN和OUT参数的存储过程
现在建一个“根据用户ID统计订单总金额和订单数量”的存储过程,输入用户ID,输出两个值:订单总数、总金额。
USE demo_jdbc; DROP PROCEDURE IF EXISTS get_order_stat_by_user; DELIMITER $$ CREATE PROCEDURE get_order_stat_by_user( IN p_user_id INT, OUT p_order_count INT, OUT p_total_amount DECIMAL(10,2) ) BEGIN SELECT COUNT(*), IFNULL(SUM(amount), 0.00) INTO p_order_count, p_total_amount FROM t_order WHERE user_id = p_user_id; END$$ DELIMITER ;要注意MySQL存储过程的参数顺序非常关键,JDBC端的参数索引是从1开始的,而IN参数索引和OUT参数索引都参与排序。这个过程中p_user_id是第1个,p_order_count是第2个,p_total_amount是第3个。后面Java注册输出参数时,必须按这个顺序注册第2和第3个,不能错位。
另外,我的个人习惯是在存储过程里尽量用IFNULL处理聚合结果。为什么?因为COUNT本身不会返回NULL,但SUM在没有匹配行时一定返回NULL,如果JDBC端直接getBigDecimal一个NULL,很容易拿到NullPointerException。所以我在SUM外面套了IFNULL,MySQL侧就把默认值兜住了。
2.3 建一个返回单值的存储函数
存储函数必须返回一个值,我造一个“根据用户ID返回用户名”的函数。
USE demo_jdbc; DROP FUNCTION IF EXISTS get_username_by_id; DELIMITER $$ CREATE FUNCTION get_username_by_id(p_user_id INT) RETURNS VARCHAR(50) DETERMINISTIC BEGIN DECLARE v_username VARCHAR(50); SELECT username INTO v_username FROM t_user WHERE id = p_user_id; RETURN v_username; END$$ DELIMITER ;这里RETURNS VARCHAR(50)决定了JDBC侧要用getString来接。DECLARE在函数里定义一个变量,SELECT INTO把查到的用户名塞进去,最后RETURN。
补充一点:如果函数体里只有一条SQL,MySQL允许省略BEGIN END,直接写成RETURN (SELECT username FROM t_user WHERE id = p_user_id);。但多语句场景必须写BEGIN END,所以建议一开始就养成带BEGIN END的习惯。
2.4 MySQL 8下必须留神的一个开关
MySQL的存储函数有一个很经典的坑:当bin_log开启,也就是默认开启二进制日志的情况下,如果函数里面包含数据修改语句,或者函数不是DETERMINISTIC / NO SQL / READS SQL DATA,MySQL会拒绝创建,报错类似于This function has none of DETERMINISTIC, NO SQL...。JDBC调用时会直接抛SQLException。
解决办法有两种,看场景选。第一种,如果当前函数确实不修改数据,就在函数里声明DETERMINISTIC或者READS SQL DATA,就像我上面写的。第二种,如果公司的数据库允许,可以执行:
SET GLOBAL log_bin_trust_function_creators = 1;但注意这个变量是全局的,对生产库影响面很大,有DBA就找DBA评估,不要自己随手开。
重点:存储函数中如果使用了查询,必须明确声明
DETERMINISTIC、NO SQL或READS SQL DATA,否则MySQL会因为二进制日志校验拒绝创建。
3. JDBC调用存储过程:把接口和数据对起来
3.1 写一个最小可用的JDBC调用框架
先看Java侧怎么准备。如果是老项目用DriverManager,直接获取连接;如果是Spring项目,连接池拿到的Connection一样,只是不需要手动关连接。这里我写一个最简单的演示版本,方便看到全貌。
import java.sql.*; public class CallProcedureExample { static final String URL = "jdbc:mysql://localhost:3306/demo_jdbc?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"; static final String USER = "root"; static final String PASSWORD = "your_password"; public static void main(String[] args) { try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD)) { // 调用带OUT参数的存储过程 String sql = "{CALL get_order_stat_by_user(?, ?, ?)}"; try (CallableStatement cstmt = conn.prepareCall(sql)) { cstmt.setInt(1, 1); // IN 参数 cstmt.registerOutParameter(2, Types.INTEGER); // OUT 参数 cstmt.registerOutParameter(3, Types.DECIMAL); // OUT 参数 cstmt.execute(); int orderCount = cstmt.getInt(2); BigDecimal totalAmount = cstmt.getBigDecimal(3); System.out.println("订单数量: " + orderCount); System.out.println("订单总金额: " + totalAmount); } } catch (SQLException e) { e.printStackTrace(); } } }代码不长,但有两个细节值得讲。
第一个细节是prepareCall,JDBC中调用存储过程不能使用prepareStatement("{CALL ...}"),虽然这样写也能运行,但规范是用prepareCall,因为驱动会针对CallableStatement做特殊处理,包括提前注册OUT参数等。
第二个细节是registerOutParameter注册的参数类型必须和MySQL存储过程里定义的参数类型在同一组映射范围内。MySQL的INT对接Types.INTEGER,DECIMAL(10,2)对接Types.DECIMAL。如果注册成Types.VARCHAR再拿去取数值,有些驱动会警告,结果也可能异常。
3.2 存储过程返回结果集时该怎么拿
存储过程并不一定只有OUT参数,很多时候它还直接返回一个ResultSet。比如我改造一个过程:根据用户ID返回该用户的全部订单数据。
USE demo_jdbc; DROP PROCEDURE IF EXISTS get_orders_by_user; DELIMITER $$ CREATE PROCEDURE get_orders_by_user(IN p_user_id INT) BEGIN SELECT id, user_id, amount, status, create_time FROM t_order WHERE user_id = p_user_id ORDER BY create_time DESC; END$$ DELIMITER ;JDBC调用时不需要注册OUT参数,执行后直接用getResultSet()拿第一个结果集。
String sql = "{CALL get_orders_by_user(?)}"; try (CallableStatement cstmt = conn.prepareCall(sql)) { cstmt.setInt(1, 1); boolean hasResult = cstmt.execute(); if (hasResult) { try (ResultSet rs = cstmt.getResultSet()) { while (rs.next()) { int orderId = rs.getInt("id"); BigDecimal amount = rs.getBigDecimal("amount"); int status = rs.getInt("status"); System.out.println("订单ID: " + orderId + " 金额: " + amount + " 状态: " + status); } } } }这里有一个新手最容易被坑的点:调用存储过程获取结果集,不要直接while(rs.next()),要先看execute()返回的布尔值。如果为true,代表第一个结果是ResultSet;如果为false,代表第一个结果是更新计数或者没有结果。虽然大多数情况下存储过程都会返回结果集,但万一有分支语句导致第一次execute()没有返回结果集,程序就会漏掉。
3.3 一个过程返回多个结果集怎么办
复杂一点的情况是存储过程里先查列表,再查统计,最后返回两个结果集。
USE demo_jdbc; DROP PROCEDURE IF EXISTS get_orders_and_totals; DELIMITER $$ CREATE PROCEDURE get_orders_and_totals(IN p_user_id INT) BEGIN SELECT id, amount, status FROM t_order WHERE user_id = p_user_id; SELECT COUNT(*) AS cnt, IFNULL(SUM(amount), 0) AS total FROM t_order WHERE user_id = p_user_id; END$$ DELIMITER ;Java侧要循环获取:
String sql = "{CALL get_orders_and_totals(?)}"; try (CallableStatement cstmt = conn.prepareCall(sql)) { cstmt.setInt(1, 1); boolean hasResult = cstmt.execute(); while (true) { if (hasResult) { try (ResultSet rs = cstmt.getResultSet()) { while (rs.next()) { // 第一个结果集:订单明细 } } } else { int updateCount = cstmt.getUpdateCount(); if (updateCount == -1) { break; // 没有更多结果 } // 如果是 UPDATE/INSERT 的更新数,可以做日志 } hasResult = cstmt.getMoreResults(); } }getMoreResults()会关闭当前打开的ResultSet,并移动到下一个结果集,所以ResultSet没有必要再手动关闭,但我习惯还是放在try-with-resources里。这样循环直到hasResult == false且getUpdateCount() == -1,说明所有结果都读完了。
多结果集在报表类场景特别常见,第一个结果集放明细列表,第二个结果集放统计合计,一次数据库往返全部拿回Java端。虽然SQL也能用UNION拼接,但带不同列宽不同含义的表,用多结果集更清晰。
4. JDBC调用存储函数:参数少,但返回值别接错
4.1 语法差异只有一处,但很容易记混
调用存储函数的标准JDBC语法是:
{? = CALL 函数名(?, ?)}注意这里有三个符号:问号、等号、CALL。第一个问号是函数返回值所在位置,后面跟着的才是函数入参。和存储过程相比,存储函数的参数索引多了第0位或者第1位的问题,不同的驱动处理不一样。
为了兼容性最好统一理解:{? = CALL func(?)}里,等号左边的?是返回值,注册时使用registerOutParameter(1, Types.VARCHAR),而函数的入参从第2个索引开始。但有些老驱动或特殊配置对索引的定义不同,稳妥做法是在写完后先在本地JUnit里跑一下。
4.2 存储函数完整调用示例
接着用之前那个get_username_by_id函数。
String sql = "{? = CALL get_username_by_id(?)}"; try (CallableStatement cstmt = conn.prepareCall(sql)) { cstmt.registerOutParameter(1, Types.VARCHAR); cstmt.setInt(2, 1); cstmt.execute(); String username = cstmt.getString(1); System.out.println("用户名: " + username); }这段代码里,registerOutParameter(1, Types.VARCHAR)对应函数的RETURNS VARCHAR(50)。返回值类型是字符串,就必须用getString读取。如果函数返回DECIMAL却用getInt去取,结果虽然没有编译错误,但可能会丢精度或者拿到0。
有一点值得注意:MySQL的函数可以通过SELECT get_username_by_id(1)直接查,但JDBC的Statement.executeQuery("SELECT get_username_by_id(1)")其实也能拿返回值。那我为什么推荐用CallableStatement?
主要原因是参数绑定。SELECT get_username_by_id(1)这种方式如果改成字符串拼接,很容易引入SQL注入风险。用CallableStatement可以做到参数预编译,调用函数的过程和调用存储过程一样安全。另外,以后函数参数多了,{? = CALL func(?,?,?)}也比拼接可维护得多。
4.3 函数返回NULL时,Java侧怎么优雅处理
存储函数非常容易出现NULL返回值,比如用户ID不存在,SELECT username INTO会没有赋值,函数返回NULL。Java端如果直接getString,返回的是null,后续业务处理要注意空指针。
处理思路有两个。最好的方案在MySQL侧,在函数里用IFNULL或者COALESCE把默认值兜住:
CREATE FUNCTION get_username_by_id_safe(p_user_id INT) RETURNS VARCHAR(50) DETERMINISTIC BEGIN RETURN COALESCE( (SELECT username FROM t_user WHERE id = p_user_id), 'UNKNOWN' ); END$$Java侧处理则是另一个思路:读到null之后判断一下,再给默认值。
String username = cstmt.getString(1); if (username == null) { username = "默认用户"; }这两种方式各有利弊,MySQL侧兜底适合“返回值一定要有默认值”的业务规则,Java侧兜底适合把“默认值策略”留给上层业务决定。我建议核心逻辑尽量放在数据库侧,因为不管谁来用这个函数,行为都是一致的。
5. 踩坑指南:JDBC + 存储过程的高频翻车现场
5.1 参数索引和类型映射是重灾区
先说参数索引。存储过程里写了3个参数,JDBC就一定要按顺序注册和设置。很多同学的报错是Parameter index out of range,十有八九是registerOutParameter用的索引和存储过程定义对不上,尤其是前面有多个IN参数时,有人以为OUT参数从1开始,实际从IN参数之后继续往后排。
再说类型映射,我列一个常用对照表。
| MySQL参数类型 | JDBC注册/获取类型 | 说明 |
|---|---|---|
| INT / INTEGER | Types.INTEGER / getInt() | 注意范围,大整数用BIGINT |
| BIGINT | Types.BIGINT / getLong() | 主键常用 |
| VARCHAR / CHAR | Types.VARCHAR / getString() | 中文要保证连接编码utf8mb4 |
| DECIMAL / NUMERIC | Types.DECIMAL / getBigDecimal() | 金额必须用,别用double |
| DATETIME / TIMESTAMP | Types.TIMESTAMP / getTimestamp() | 时区要在URL里配serverTimezone |
| TEXT | Types.LONGVARCHAR / getString() | 有些驱动需要getString处理 |
一个很隐蔽的坑是:MySQL的DECIMAL(10,2)在驱动里映射到java.math.BigDecimal,如果直接getDouble(3)去取金额,可能在精度上出现微小的误差,特别是在做汇总统计时。所以强烈建议金额类全部用getBigDecimal。
5.2 空值、中文乱码和时区的坑
空值的坑前面讲到了函数返回值,其实存储过程也一样。比如你调用统计存储过程,如果没有匹配的订单,SUM(amount)返回NULL,Java端getBigDecimal拿到null,继续做加减计算就会出事。我建议在存储过程内部就处理好默认值,Java侧拿到后也最好做一次判空。
中文乱码这个坑,根子不在存储过程,而在JDBC URL。MySQL 8驱动默认使用utf8mb4,但老连接串如果写着characterEncoding=utf8,某些特殊字符比如表情符号会直接乱码。连接串里尽量写成:
jdbc:mysql://localhost:3306/demo_jdbc?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai时区问题就更常见了。MySQL的DATETIME没有时区概念,但你如果连接串没指定serverTimezone,驱动有时会拿本地默认时区去转换TIMESTAMP,导致Java取到的时间差8个小时。我在生产环境遇到过几次这种“阴间Bug”,所以在所有项目里都统一在URL里加serverTimezone=Asia/Shanghai。
5.3 连接、事务、性能,三件套不能少
调用存储过程本质还是数据库连接上的一次执行,要遵守三条纪律。
第一条是连接不要裸奔。DriverManager.getConnection()只能用于学习或脚本,真正的项目里一定要用连接池,比如HikariCP、Druid。存储过程通常执行时间较长,如果每次都新建连接,连接建立的开销会吃掉存储过程省下的性能。
第二条是事务边界要想清楚。存储过程内部可以写事务,但如果你在Java侧已经开启了事务,同时又希望在存储过程中提交,就必须注意事务边界。默认情况下,Java侧拿到Connection后,调用存储过程中的COMMIT会直接影响当前连接的事务状态,很容易把你的Java事务搞乱。我的习惯是:事务控制在Java侧做,存储过程内部只做数据操作,不轻易写START TRANSACTION或COMMIT,除非这个存储过程被设计成完全自治。
第三条是性能监控。存储过程难调试的根源在于一条语句失控可能拖死整个数据库。建议在MySQL侧用EXPLAIN分析存储过程中的主要SQL,再配合performance_schema去看调用高频过程有没有慢查询。平时只需要关注平均值和95分位,不要被个别极端值带偏。
6. 到底该不该把业务写进存储过程?说说我的取舍
6.1 适合用存储过程的场景
根据我过去的项目经验,适合用存储过程的场景往往有这几个特征:
- 业务逻辑涉及多条SQL,并且需要保证整体一致性;
- 业务逻辑被多个系统或团队复用;
- 表结构和规则相对稳定,迭代速度不快;
- 数据库连接网络质量不稳定,减少往返意义重大。
典型例子就是定时任务里的统计汇总、跨行转账、订单对账。这些操作如果用Java代码做,要么写一大堆事务模板,要么在多次网络调用里提心吊胆。
6.2 不适合用存储过程的场景
反过来,如果你的团队里Java开发经验丰富、DBA资源有限,或者业务规则每周都在变,那存储过程就会成为维护成本黑洞。尤其是一旦出现几百行甚至上千行的存储过程,新人上手会非常痛苦,Git版本管理也不如Java代码方便,测试也更难自动化。
我个人的原则是:超过10个参数的存储过程要警惕,超过50行的存储过程必须写注释,超过100行的存储过程就要考虑拆解。存储过程不是不能用,而是要有纪律地用。
6.3 和ORM框架配合的实践
现在很多团队用MyBatis或者MyBatis Plus,JDBC调用存储过程一样可以整合进去。
MyBatis里通过@Select注解或者XML的方式都可以调用。XML参数和CallableStatement是对应的,比如:
<select id="callOrderStat" statementType="CALLABLE"> {CALL get_order_stat_by_user( #{userId, mode=IN, jdbcType=INTEGER}, #{orderCount, mode=OUT, jdbcType=INTEGER}, #{totalAmount, mode=OUT, jdbcType=DECIMAL} )} </select>映射类里要定义和OUT参数同名的属性,调用后再从对象里取。MyBatis对存储过程的封装其实很薄,底层还是JDBC那套,所以你在JDBC层踩的坑,在MyBatis里基本都会遇到,理解了这一篇,再去看框架文档就轻松得多。
最后分享一个我自己的调试习惯
我平时在写JDBC调用存储过程之前,一定会先在MySQL命令行或者Navicat里手工调一遍。命令行就是:
CALL get_order_stat_by_user(1, @cnt, @total); SELECT @cnt, @total;看输出和预想是否一致。存储函数就:
SELECT get_username_by_id(1);先在数据库侧把SQL的正确性确认掉,再到Java里写CallableStatement。这样排查问题时可以快速定位是SQL写得不对,还是Java传参不对,能省掉大量联调时间。这个习惯陪了我好几个项目,每次都能在别人还在“查日志+猜问题”的时候直接把范围缩小到一两行。你也可以试试,至少不会一上来就拿着Java的异常栈在存储过程里大海捞针。