JDBC实验全链路解析:连接、执行、事务与异常排查
简介:这份资源是吉林大学《数据库应用程序开发》课程的实验报告文档,面向学习数据库编程、需要完成JDBC实验或准备课程提交的高校学生。内容围绕Java与DB2的交互展开,记录Tasks 1至8的完整调试过程,涵盖权限授予与GRANT语句、表别名导致的未定义名称、DriverManager建立连接、Statement与ResultSet遍历、PreparedStatement参数设置、setAutoCommit事务提交开关、SQL异常错误码与状态捕获,以及salary放大倍数引发的类型转换溢出分析和GUI版JOptionPane改造、单行多行与子查询插入等实践环节。资源包共1个doc文件,大小约1.76MB,按任务分节列出典型错误、解决办法与运行结果截图,可直接作为实验报告的写作模板与排错对照手册。已有903人学习,适合希望少走弯路、快速理清JDBC操作要点与事务细节的读者参考。
1. 一张地图看懂 JDBC 实验:七个任务其实是一条链路
这套数据库应用程序开发实验报告,表面上是七八个互不相干的任务拼在一起,梳理下来会发现它其实是一条完整链路:先解决“连不上”,再解决“取不到”,然后解决“改不动”,最后解决“出错说不清”。链条上的每一环,恰好对应 JDBC 编程里最容易被跳过的那部分知识:驱动加载与连接串、Statement 与 PreparedStatement 的分工、结果集游标与空值判断、事务提交时机、异常分类与 GUI 封装。它适合已经写过两行 Java 但一碰数据库就卡住的人,也适合考前想把“连接—执行—结果—事务—异常”这条线一次性打通的人。把这条链路看成地图,比逐个任务背代码有效得多——因为考试和实际开发里,坑永远出在环节之间的衔接处,而不是单个 API 的拼写上。
2. 连接与权限:从 DriverManager 到 schema 归属
2.1 一次连接要过的四道关:驱动、URL、账号、schema
JDBC 连接从来不是一行getConnection就完事的事,它要同时过四道关:驱动类能不能加载、URL 指不指向正确的库、账号密码能不能通过认证、SQL 里的对象名能不能在当前 schema 下解析出来。这四道关任何一道没过,报出来的错看起来都像“程序写错了”,实际上八成跟代码无关。实验里最典型的一次排查,就是程序里查TEMPL一直报“未定义的名称”,改了半天 Java 代码毫无进展,最后发现是对象根本不在当前用户的默认 schema 下,把表名写成带前缀的全限定名就通了。这个教训的价值在于:报错信息里的“名称未定义”,先怀疑解析上下文,再怀疑拼写。
// 固定骨架:加载驱动 → 建立连接 → 创建语句对象 Class.forName(DRIVER_CLASS); // 驱动类全名,缺失会抛 ClassNotFoundException Connection conn = DriverManager.getConnection(url, user, password); // 库名/账号/密码三项都要对 conn.setAutoCommit(false); // 提前关自动提交,给后续批量更新留出回滚余地上面三段代码里,DRIVER_CLASS要填数据库厂商提供的驱动类全名,写成常量便于换库时只改一处;url的形态通常是jdbc:子协议://主机:端口/库名,子协议写错和库名写错报的是完全不同的异常,别混为一谈;setAutoCommit(false)放的位置很关键,它必须在任何 DML 之前执行,放到executeUpdate后面就等于没设。连接对象用完必须关闭,顺序是 ResultSet → Statement → Connection,倒着关,否则连接池很快会被耗光,本地跑十几次就卡住,很多人第一反应是“数据库挂了”,其实是自己漏了 close。
连接阶段的失败,用一张对照表定位最快:
| 报错现象 | 大概率原因 | 该做的验证 |
|---|---|---|
| ClassNotFoundException | 驱动包没进 classpath | 检查构建路径或运行时的-cp参数 |
| 认证失败 / 拒绝连接 | 账号密码错、库名错、实例没起 | 用同一组凭据在命令行工具里连一次 |
| 表或视图不存在 | 对象不在默认 schema 下 | 查系统表确认对象的真实归属 |
| 第一次查询耗时异常长 | 驱动初始化 + 首次语句编译 | 预热一次再计时,别当性能问题处理 |
提示:连接串里的库名和 SQL 里写的 schema 是两个概念,前者决定“连到哪个库”,后者决定“在库里找谁”。这两件事混淆,是初学者最常见的定位失误。
2.2 权限与对象命名:为什么会出现“未定义的名称”
权限问题在实验里出现得非常早:用管理员账号登录后查某张业务表,直接报没有访问权限。解决方式不是改代码,而是回到数据库侧,用对象的拥有者身份把权限授出去。这一步考察的其实是“数据库权限模型”这一块知识——谁能授权、授给谁、授哪些操作。GRANT语句的四个要素必须写全:操作类型、对象全名、被授权者、以及可选的WITH GRANT OPTION。少写对象前缀是最常见的失误,因为GRANT SELECT ON EMPLOYEE在某些环境下会按当前用户的默认 schema 解析,授出去的可能是另一张表,甚至直接报对象不存在。
-- 以对象拥有者身份执行,把四类 DML 权限授给应用账号 GRANT SELECT, INSERT, UPDATE, DELETE ON APP.EMPLOYEE TO APP_USER; -- 确认对象究竟挂在哪个 schema 下,避免“名称未定义”瞎猜 SELECT TABSCHEMA, TABNAME FROM SYSCAT.TABLES WHERE TABNAME = 'TEMPL';第一句的关键在于对象名要带 schema 前缀,把它写成APP.EMPLOYEE而不是裸的EMPLOYEE,授权才是确定的;第二句查系统目录表,把同名对象的所有归属列出来,一眼就能看出代码里该写哪个前缀。很多“未定义名称”的报错,用第二句一查就真相大白——对象存在,只是不在你以为的地方。
比权限更隐蔽的是别名冲突。实验里出现过“无法创建表”的情况,原因是之前为了图省事给另一张表建过同名的别名(alias),别名和表共享同一个命名空间,先占位的别名会让后建的实名表直接失败。处理办法是把旧别名删掉再建表:
-- 先清掉占位的别名,再创建实名表 DROP ALIAS APP.TEMPL; CREATE TABLE APP.TEMPL ( ID INTEGER NOT NULL PRIMARY KEY, NAME VARCHAR(30), SALARY DECIMAL(9,2) );这里的DECIMAL(9,2)值得单独记一笔:它表示总共 9 位有效数字、其中 2 位是小数,整数部分只剩 7 位,最大约 9999999.99。这个精度定义在后面“更新时溢出”的实验里是决定性因素——溢出不是 Java 的int装不下,而是数据库列的精度装不下。凡是涉及金额的列,写建表语句时就要把精度算清楚,事后改列类型要迁移数据,成本高得多。
3. 执行链路:Statement、PreparedStatement 与结果集遍历
3.1 三种执行入口的分工
JDBC 里执行 SQL 的入口就那么几个,但选哪个直接决定了代码的安全性和性能。裸Statement适合执行完全固定、不带任何外部输入的语句,比如建表、建索引;PreparedStatement适合带参数、需要重复执行的语句,它预编译一次、绑定多次,既避开字符串拼接带来的注入风险,也省掉重复解析的开销;CallableStatement面向存储过程,实验里没涉及,但概念上要知道它是对前两者的扩展。
| 入口 | 典型用法 | 返回值 | 主要风险 |
|---|---|---|---|
Statement.executeQuery | 固定查询 | ResultSet | 拼接参数易出注入与引号错误 |
Statement.executeUpdate | 固定 DML/DDL | 受影响行数 | 同上,且无法复用执行计划 |
PreparedStatement | 带参数、循环执行 | ResultSet或行数 | 参数下标从 1 开始,易错位 |
CallableStatement | 调用存储过程 | 混合 | 出参注册顺序易漏 |
参数下标从 1 开始这一条,几乎每个人都栽过一次。下面这段是更新任务的骨架,注意setInt(1, ...)里的 1 指的是第一个问号,不是第 0 个:
String sql = "UPDATE APP.STAFF SET SALARY = SALARY * ? WHERE DEPT = ?"; PreparedStatement pstmt = conn.prepareStatement(sql); pstmt.setBigDecimal(1, new BigDecimal("1.01")); // 第 1 个问号:放大系数 pstmt.setInt(2, deptNo); // 第 2 个问号:部门号 int updateCount = pstmt.executeUpdate(); // 返回真正被改动的行数executeUpdate的返回值是“实际受影响的行数”,不是“SQL 有没有执行成功”。这个区别在做实验时会制造巨大的困惑:输入一个数据库里根本不存在的部门号,程序不报错,返回值是 0,很多人以为更新成功了,回头查表发现毫无变化。返回 0 就代表没有一行匹配,它是结果信息,不是异常。想确认某次更新到底动了哪些行,要么先用同样的 WHERE 条件查一遍,要么把返回值打到界面上盯着看。
3.2 结果集遍历与游标位置
ResultSet是 JDBC 里最容易“看着会、写起来错”的部分,核心只有一个概念:游标。游标初始位置在第一行之前,每调用一次next()才向前挪一行,同时返回“挪过去之后还有没有数据”。所以第一次next()返回true,说明结果集至少有一行,此时可以直接getXxx取值;返回false,说明结果集是空的,这时候再去取值就会报“游标未定位”之类的错误。
ResultSet rs = pstmt.executeQuery(); boolean more = rs.next(); // 首次调用:游标移到第一行,并告知是否有数据 while (more) { int id = rs.getInt("ID"); // 按列名取值,比按下标更抗表结构变动 String name = rs.getString("NAME"); if (rs.wasNull()) { // 必须紧跟在上一次 getXxx 之后调用 name = "空"; } System.out.println(id + "\t" + name); more = rs.next(); // 推进到下一行,循环条件由它驱动 }这段代码里有两处值得反复看。第一处是getInt与wasNull的配对:数据库里的 NULL 映射到 Java 的int会变成 0,你无法区分“这一列真的是 0”和“这一列是 NULL”,所以必须用wasNull()补一刀,而它判断的是最近一次 getXxx 读到的值,中间夹了别的取值调用就失效了。第二处是循环写法,把next()的返回值存进变量再判断,比while (rs.next())多一行,但能让“首次定位”和“后续推进”这两件事在代码里显式分开,读起来不容易误解游标状态。
按列名取值和按下标取值各有取舍。列名可读性好、加列不影响,但大小写敏感度取决于驱动实现,写"NAME"和"name"在部分驱动上行为不同;下标从 1 开始、性能略好,但一旦 SELECT 列表顺序调整,整段取值代码全错位。实验阶段建议统一按列名取,遇到大小写问题时把 SELECT 里的别名显式写出来,比如SELECT NAME AS NAME,用别名把大小写固定住。
3.3 事务开关 setAutoCommit 的隐性影响
实验里最折磨人的一个现象是:程序提示更新成功,返回值大于 0,去数据库里一查,数据纹丝不动。这不是玄学,是事务没提交。默认情况下连接是自动提交的,每条 DML 执行完立刻落库;一旦代码里调用了setAutoCommit(false),之后所有改动都挂在一个未提交的事务里,不commit()就永远是“看起来改了”。
conn.setAutoCommit(false); // 关闭自动提交,进入手动事务模式 try { int n = pstmt.executeUpdate(); if (n == 0) { conn.rollback(); // 没有匹配行,主动回滚,避免悬挂事务 return; } conn.commit(); // 确认改动落库 } catch (SQLException e) { conn.rollback(); // 任何异常都回到事务起点 throw e; }这段骨架的重点不在语法,而在“每条路径都有归宿”:正常提交、零行回滚、异常回滚,三条路都写死了,才不会留下一个既不提交也不回滚的连接。实验里出现的“改了不生效”,排查顺序应该是这样的——先看setAutoCommit是不是false,再看有没有对应的commit(),然后确认连的是不是同一个库同一个 schema,最后才怀疑 SQL 本身。把顺序倒过来查,会在错误的方向上耗掉大量时间。
顺带说清楚另一个高频误解:更新不生效和更新溢出是两回事。溢出的表现是抛异常,根本走不到提交那一步;不生效的表现是不抛异常、返回值正常、数据没动。两种现象长得完全不像,但如果只看“界面显示成功没成功”,很容易把前者当成后者,或者把后者当成前者,这就是为什么异常处理和事务控制必须放在一起设计。
4. GUI 化与插入三态:单行、多行、子查询
4.1 JOptionPane 的输入输出拆解
把命令行程序改成图形界面,实验要求的做法是用JOptionPane的静态方法接管输入输出:showInputDialog负责要参数,showMessageDialog负责报结果。这两个 API 用起来只有几行,但有两个必须处理的边界——用户点“取消”时返回null,用户输入非数字时parseInt抛NumberFormatException。这两类错误属于“输入校验”,和数据库异常完全是两个层次,混在一个 catch 里处理,用户会看到一堆看不懂的技术信息。
String input = JOptionPane.showInputDialog(null, "请输入部门号:"); if (input == null) { // 用户点了取消 return; } int deptNo; try { deptNo = Integer.parseInt(input.trim()); // 去掉首尾空格,避免" 15"这种输入 } catch (NumberFormatException e) { JOptionPane.showMessageDialog(null, "部门号必须是整数"); return; }这段代码体现的分层思路值得记住:界面层只做“取值 + 格式校验”,校验通过再把干净的参数交给业务层;业务层碰到的才是真正的 SQLException。如果界面上直接 catch SQLException 并弹原始错误文本,用户看到的是一长串驱动内部信息,既不友好也不利于排查。
4.2 三种插入写法的差别
插入任务的三种形态——单行插入、多行插入、通过子查询插入——表面上是三个功能,本质是三种数据来源:来自一条手工输入、来自一批手工输入、来自库里另一张表。搞清来源,写法自然就定了。
// 形态一:单行插入,一条语句一次绑定 String sql1 = "INSERT INTO APP.TEMPL (ID, NAME, SALARY) VALUES (?, ?, ?)"; // 形态二:多行插入,同一模板反复绑定,攒批提交 for (Record r : records) { pstmt.setInt(1, r.id); pstmt.setString(2, r.name); pstmt.setBigDecimal(3, r.salary); pstmt.addBatch(); // 只入批,不执行 } int[] counts = pstmt.executeBatch(); // 一次性提交整批,返回每条的受影响行数 // 形态三:子查询插入,数据来源是库内另一张表,一次搞定 String sql3 = "INSERT INTO APP.TEMPL (ID, NAME) " + "SELECT ID, NAME FROM APP.STAFF WHERE DEPT = ?";形态二的addBatch/executeBatch组合,在数据量大时优势明显:网络往返从 N 次降到 1 次。但要注意executeBatch返回的是数组,长度和批大小对应,某一条失败时整批的行为取决于驱动,有的驱动抛异常就中断,有的会继续执行后面的。实验阶段一行一行插更稳妥,量大了再上批处理,别一上来就批处理然后被难懂的报错劝退。
形态三的难点不在语法,在于列要一一对应:INSERT后面的列清单和SELECT后面的列清单必须数量一致、类型兼容,顺序对不上不一定报错,但插进去的数据会错位,这种错误最难查,因为程序一路绿灯。写完这句一定要顺手SELECT一遍新表确认。
4.3 空值显示与异常分类提示
结果集里的 NULL 在界面上直接拼字符串会显示成"null"或者空白,实验要求统一显示为“空”。这件事的正确做法不是把null替换成字符串了事,而是在取值时用wasNull()判断,然后赋一个展示用的占位文本。写入方向用setNull(index, java.sql.Types.VARCHAR),读出来再按同一套规则展示,读写两头的口径才一致。
异常处理这块,实验要求把“违反约束、类型不匹配、溢出、非法符号”四类错误都用对话框提示出来。这四类错误在SQLException体系里有明确的对应关系,按getSQLState()的前两位分支是最稳的做法:
| 错误类别 | SQLState 前缀 | 常见场景 | 提示语建议 |
|---|---|---|---|
| 违反完整性约束 | 23 | 主键重复、非空列给 NULL、外键悬空 | 数据不符合约束条件 |
| 类型不匹配 | 22 | 往数值列塞字符串、日期格式错 | 字段类型与输入不符 |
| 数值溢出 | 22003 | 放大后的值超出列精度 | 数值超出字段允许范围 |
| 语法或非法符号 | 42601 | SQL 拼错、符号放错位置 | 语句有语法错误 |
| 权限或连接问题 | 08 / 42501 | 没授权、连接断了 | 无权限或连接已断开 |
注意:不同驱动抛出的具体异常子类不完全一样,但 SQLState 前两位是相对稳定的。分支判断用
state.startsWith("23")这种写法,比instanceof更扛驱动差异,也比直接解析错误文本可靠得多。
除了SQLException,实验里还要求处理SQLWarning。它是“非致命提醒”,不会中断执行,但能告诉你某次操作有隐含问题,比如数据被截断。标准写法是在语句执行后主动取一次:
SQLWarning warn = pstmt.getWarnings(); while (warn != null) { // 警告可能是一条链,要循环取完 System.out.println(warn.getSQLState() + " " + warn.getMessage()); warn = warn.getNextWarning(); }getWarnings取完一次就清空,所以要在执行完语句后立刻取,中间插了别的操作就取不到了。这个 API 在实际项目里用得不多,但在教学实验里它是“完整的错误处理”这一环,知道它存在、知道它是一次性消费的,就够用了。
5. 易错点:这五处翻车率最高
5.1 易漏点:把“未定义名称”当成拼写错误
为什么漏:报错文本里带着对象名,第一反应总是去核对代码里那几个字母有没有敲错,而实际上代码里写的名字往往一字不差,错的是“解析上下文”——对象挂在 A schema,连接用的是 B 用户的默认 schema,名字再对也找不到。这类错误在换环境后集中爆发,本地跑得好好的,换台机器就报错,因为两台机器的默认 schema 不一样。
怎么补:遇到“表或视图不存在 / 名称未定义”,先不碰代码,直接连上去查系统目录表,把同名对象的所有TABSCHEMA列出来;确认归属后,把所有 SQL 里的裸表名统一改成SCHEMA.TABLE的全限定写法。养成“只要跨 schema,就写全名”的习惯,这一类错误基本可以清零。
5.2 易错点:把溢出当成程序 bug,把零行当成执行失败
为什么漏:这两个错误的表象都很“像程序出问题”。溢出的时候界面弹出一段英文,直觉是代码写错了;零行更新的时候返回值是 0,直觉是“没成功”,于是反复改 SQL 语法。实验里有一个很漂亮的验证过程:把放大系数从 2 改成 1.01,同一个部门号就不溢出了,说明根因是数值大小而不是代码逻辑——这种“改一个变量看现象是否变化”的对照法,比读十遍文档有用。
怎么补:遇到溢出,先算列的精度上限。DECIMAL(9,2)的整数部分只有 7 位,原值乘 2 之后很容易越界,乘 1.01 就安全得多。遇到零行,先查 WHERE 条件在库里到底能查出几行,查询结果为空就说明条件本身不匹配,和 UPDATE 语句无关。把“数据问题”和“代码问题”分开验,能省掉大量无效调试。
5.3 易漏点:事务没提交,成功提示是假的
为什么漏:executeUpdate返回了正数,程序没抛异常,界面显示“更新成功”,三条证据都指向“已经改好了”,唯一没验证的是“数据库里到底变了没”。手动事务模式下,改动只在当前连接的事务里可见,commit()之前别的会话查不到,连接一断就全丢。
怎么补:排查顺序固定成三步——看setAutoCommit是不是false、看有没有配对的commit()、看 catch 分支里是不是悄悄rollback()了。更进一步的做法是把“执行—提交—回滚”写成一个不可分割的小代码块,让提交和回滚在视觉上永远挨着,不给自己留忘记的机会。
5.4 易错点:想改结果集里的任意行,却用了只读结果集
为什么漏:ResultSet默认是只读、只能向前滚动的,这是很多人没注意到的默认值。代码里写了rs.updateString(...)然后rs.updateRow(),运行时报“结果集不可更新”,看着像方法名写错了,其实是创建语句对象时没声明并发模式。
怎么补:要支持“任意行任意列修改”,创建语句时必须显式指定两个参数——可滚动、可更新:
Statement stmt = conn.createStatement( ResultSet.TYPE_SCROLL_INSENSITIVE, // 游标可前后移动 ResultSet.CONCUR_UPDATABLE // 结果集可回写数据库 ); ResultSet rs = stmt.executeQuery("SELECT ID, NAME, SALARY FROM APP.STAFF"); rs.absolute(3); // 直接跳到第 3 行 rs.updateBigDecimal("SALARY", new BigDecimal("8888.00")); rs.updateRow(); // 没有这一句,前面的 update 全是空转可滚动结果集给了absolute、first、last、previous这些定位方法,改完必须调updateRow()才真正写回,只调updateXxx相当于在内存里改了值但没提交。另外这类结果集对 SQL 有要求:查询里带聚合、DISTINCT、多表连接时往往退化回只读,写之前先用最简单的单表查询验证一遍。
5.5 易漏点:用 0 判断空值,用列下标写死取值
为什么漏:getInt返回int,NULL 到了 Java 侧就变成 0,这个转换是隐式的、不报错的,所以没人会主动去查“这一列原本是不是空”。列下标写死的问题同理,写的时候表结构是那样,后来 SELECT 加了一列,代码不报错但取出来的全错位了。
怎么补:所有可能为空的列,取值后立刻跟一句wasNull()判断,中间不插入任何其他取值调用;能按列名取就别按下标,实在要按下标,把下标抽成常量并在 SELECT 语句旁边写注释标好对应关系。这两条写进自己的检查清单,比事后调试便宜太多。
6. 复习清单:从能跑通到能讲清楚
复习这套内容,最容易掉进的陷阱是“代码能跑就算会了”。但实验报告里真正值钱的部分,恰恰是每个任务下面那段“调试过程”——错误现象、怀疑方向、验证动作、最终根因。把这四步复盘一遍,比再抄一遍代码有用得多。下面这份清单是按“能不能讲清楚”来设计的,逐条过一遍,讲不清楚的那条就是漏洞。
| 模块 | 自检问题 | 讲不清就回去看 |
|---|---|---|
| 连接 | 驱动类、URL、账号、schema 四者各管什么?报错怎么区分? | 2.1 节的四道关与失败对照表 |
| 权限 | GRANT的四个要素是什么?为什么对象名要带前缀? | 2.2 节的授权语句与别名冲突 |
| 执行 | Statement和PreparedStatement什么时候各用哪个?参数下标从几开始? | 3.1 节的入口对照表 |
| 结果集 | 游标初始在哪?next()返回值含义?wasNull必须紧跟谁? | 3.2 节的遍历骨架 |
| 事务 | 更新成功但没生效,按什么顺序排查? | 3.3 节的三步排查法 |
| 插入 | 单行、批量、子查询三种写法各自的数据来源是什么? | 4.2 节的三段代码 |
| 异常 | 约束、类型、溢出、语法四类错误分别对应哪个 SQLState 前缀? | 4.3 节的映射表 |
| 可更新结果集 | 要改任意行,创建语句时必须指定哪两个参数? | 5.4 节的两参数写法 |
过完表格,再做一件更有用的事:把整条链路用一段最小可运行代码串起来,不看笔记手写一遍。写的时候刻意把“容易漏的那几句”放在显眼位置——Class.forName、setAutoCommit(false)、next()、wasNull()、commit()、updateRow()。这六句是全篇出现频率最高的失误点,能默写出来,说明链路真的进脑子了。
Class.forName(DRIVER_CLASS); // 驱动 try (Connection conn = DriverManager.getConnection(url, user, pwd)) { // 连接,自动关闭 conn.setAutoCommit(false); // 事务开关,DML 之前 try (PreparedStatement ps = conn.prepareStatement(SQL)) { // 预编译 ps.setInt(1, deptNo); // 参数从 1 开始 if (ps.executeUpdate() == 0) { // 零行不是异常 conn.rollback(); return; } conn.commit(); // 提交,别忘了 } catch (SQLException e) { conn.rollback(); // 异常回滚 throw e; } }最后一条经验来自我自己踩过的坑:早些年做这类实验,我总是先写代码再补文档,结果每次报错都只能靠回忆猜当时改了什么,来回折腾好几轮。后来改成先把“预期现象”写在一行注释里,再动手写代码,跑出来对不上就立刻能定位是环境问题还是逻辑问题。从那以后我每次碰数据库连接相关的代码,都强制走一遍“连接—事务—执行—结果—异常”这五步自检,哪怕只写十行也照走。希望帮到你。
本文还有配套的精品资源,点击获取