
我最近在项目里碰上一个特别“基础”但特别容易翻车的问题用 JDBC 查询数据时怎么判断ResultSet里到底有没有数据。本来以为直接rs.next()就行结果测试时发现第一条记录莫名其妙丢了。后来我特意拿这个场景去问 DeepSeek它给出了几个方案看着都挺有道理但直接抄作业又撞上了驱动兼容性问题。这篇文章就把我从踩坑到整理的完整思路写出来希望对还在跟 JDBC 和 ResultSet 较劲的朋友有用。1. 问题是怎么来的一次“丢数据”事故1.1 最初的做法看起来天经地义当时业务很简单根据用户 ID 查订单表有订单就返回详情集合没有订单就提示“暂无订单”。我顺手就写了类似这样的代码ResultSet rs stmt.executeQuery(sql); if (rs.next()) { // 有数据 ListOrder orderList new ArrayList(); while (rs.next()) { orderList.add(mapRow(rs)); } return orderList; } else { // 无数据 return Collections.emptyList(); }第一轮自测通过了但到了联调环节前端反馈“只显示了一条订单第二天订单数据全部丢失”。后来翻了日志才发现if (rs.next())已经把游标从第一行之前挪到了第一行上进入while循环后又是一次rs.next()直接跳到了第二行。也就是说第一行永远被“跳过”了。1.2 问题本质ResultSet 像一条“单向游标”初学 JDBC 时ResultSet被很多人当成“表”但它实际更像一个单向移动的游标。初始状态下游标位于第一行之前并不指向任何数据。rs.next()会先判断有没有下一行如果有则移动到下一行并返回true。所以上面的代码里“判断有没有数据”这个动作本身就把游标推进到了第一行后续再遍历自然就丢掉了这一行。这个问题的本质不在于rs.next()本身而在于“判断”和“遍历”共用了一个移动游标的方法它们之间存在冲突。理解到这一层才能彻底解决判空问题。2. 判空的几种“正统”写法用 DeepSeek 问了一圈再加上我自己的验证判断ResultSet是否为空大致有四种可行思路。每种思路都有适用场景不能说哪个绝对最好关键要看你的游标类型和驱动兼容性。2.1 用 rs.next()最直接但必须会“圆”回来rs.next()是唯一不依赖驱动特殊实现、所有 JDBC 驱动都支持的方法。用它判空关键是要把“判断用的下一次移动”合并到遍历逻辑里去。刚才丢数据的问题就可以这样修复ListOrder orderList new ArrayList(); if (rs.next()) { do { orderList.add(mapRow(rs)); } while (rs.next()); }这个写法的精髓在于第一次rs.next()成功后当前游标已经在第一行上所以先do处理当前行然后再rs.next()看是否还有下一行。只要循环体不脑抽再调一次rs.next()第一行就不会丢。不过这种写法有个副作用如果业务逻辑比较复杂希望“先判断有没有数据再决定走哪条分支”那这个do-while会让代码结构变得别扭。比如判断和数据处理的逻辑离得比较远时阅读代码的人可能很难一眼看出游标已经指向第一行。2.2 用 isBeforeFirst()不移动游标的标准方案JDK 文档里为ResultSet提供了isBeforeFirst()方法它的语义是“当前游标是否位于第一行之前”。如果ResultSet为空那么游标自然不可能位于第一行之前所以isBeforeFirst()会返回false如果非空初始游标确实在第一行之前所以返回true。利用这一点可以写出不含移动痕迹的判空逻辑boolean isEmpty !rs.isBeforeFirst(); if (isEmpty) { return Collections.emptyList(); } while (rs.next()) { // 正常遍历 }由于isBeforeFirst()不会移动游标判断完之后游标仍停留在初始位置后续rs.next()能从头开始遍历。从逻辑上说这是最干净的方案。但请注意这里是有一个“魔法”的它要求ResultSet支持“定位”能力也就是游标类型必须是TYPE_SCROLL_*系列而不是默认的TYPE_FORWARD_ONLY。不过实测经验是许多主流驱动即使使用默认 forward-only 游标也会完整实现isBeforeFirst()因为它本身并不是一个特别重的操作。话虽如此代码跑到一些开源驱动或老版本驱动上时仍然可能出现行为异常下面第 4 章会单独讲。2.3 用 last()getRow()可滚动游标下的精确方案如果你的Statement明确使用TYPE_SCROLL_INSENSITIVE或TYPE_SCROLL_SENSITIVE创建了可滚动游标那可以用last()和getRow()组合判断int rowCount 0; boolean notEmpty rs.last(); // 尝试移到最后一行为 true空结果集为 false if (notEmpty) { rowCount rs.getRow(); rs.beforeFirst(); // 恢复游标到第一行之前以便后续遍历 } boolean isEmpty (rowCount 0);这里rs.last()如果返回false代表没有最后一行也就是空结果集。成功移动到某一行后getRow()返回当前行号这个行号其实就代表了总行数。判断完还需要调用rs.beforeFirst()把游标恢复到初始位置否则后续遍历会从最后一行之后开始什么都查不到。这个方案的优势是准确性高因为getRow()的数值由驱动直接生成几乎不会出现isBeforeFirst()那种部分驱动的“错报”。但它的代价也很明显可滚动游标会占用更多内存尤其在数据量大的场景下性能可能比 forward-only 差不少。如果只是判断一个“是否存在”的问题用这种重量级方案多少有些杀鸡用牛刀。2.4 用 SELECT COUNT(*)简单粗暴但保熟如果完全不想碰游标位置还有一个朴素思路单独执行一条SELECT COUNT(*)语句然后取第一列的值判断大于 0 即可。这个方法不依赖任何游标类型也不会污染主查询逻辑上最容易理解。PreparedStatement countStmt conn.prepareStatement(SELECT COUNT(*) FROM orders WHERE user_id ?); countStmt.setLong(1, userId); ResultSet countRs countStmt.executeQuery(); countRs.next(); int count countRs.getInt(1); boolean isEmpty count 0;当然它的缺点也很明显多了一次数据库往返。如果查询条件本身很复杂你会被迫把同一套 WHERE 条件写两遍稍微改了一处而忘了另一处就会出现数据不一致。所以这个方法更适合“表数据量不大、且对准确性要求极高的场景”比如做权限判断或唯一性校验。3. 实操写一个可复用的判空工具方法3.1 工具方法代码与设计取舍既然项目里多次用到这问题我干脆封装了一个小工具方法。考虑到驱动兼容性我并没有把赌注全部押在某个单一方法上而是采用“宏定义式”的切换策略。先看完整代码import java.sql.ResultSet; import java.sql.SQLException; public final class ResultSetUtils { private ResultSetUtils() { } /** * 判断 ResultSet 是否为空不移动游标。 * p * 使用 isBeforeFirst() 判断。如果驱动不支持可滚动游标 * 某些驱动可能会抛异常或返回非预期结果此时可改用 next() 方案。 * * param rs 需要判断的 ResultSet * return true 表示结果集为空false 表示非空 * throws SQLException 数据库访问异常 */ public static boolean isEmpty(ResultSet rs) throws SQLException { if (rs null) { return true; } return !rs.isBeforeFirst(); } }这个方案基于isBeforeFirst()因为它在绝大多数主流驱动MySQL Connector/J、PostgreSQL JDBC、Oracle JDBC、SQL Server JDBC上表现得都很稳定。调用时也不需要关心游标有没有被移动过因为判断不会改变位置。3.2 集成到业务代码一个完整的查询示例下面用一个比较复杂的查询场景来演示用户可能存在多个订单我需要判断是否有订单如果有就返回订单列表如果没有就执行另一段逻辑。public ListOrder getUserOrders(Connection conn, long userId) throws SQLException { String sql SELECT id, user_id, total_amount, status, create_time FROM orders WHERE user_id ? ORDER BY create_time DESC; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, userId); try (ResultSet rs ps.executeQuery()) { // 方法一使用工具类判断是否为空 if (ResultSetUtils.isEmpty(rs)) { return Collections.emptyList(); } // 注意执行到这里 result set 非空且游标仍在初始位置 ListOrder orderList new ArrayList(); while (rs.next()) { orderList.add(mapRow(rs)); } return orderList; } } }这里有两处关键细节当操作进行到while循环时游标必须处于初始位置。isEmpty()用了isBeforeFirst()不会破坏这个前提。如果你在调用isEmpty()之前已经手动调过rs.next()那后续结果不可预测。所以工具方法的名称和注释里我都刻意写了“不移动游标”提醒调用方不要乱序使用。3.3 测试用例覆盖“空表、单行、多行”三种情况写单元测试时我用的是内存数据库 H2建了一张orders表并分别造了三种数据。核心测试代码如下Test public void testIsEmptyWithRows() { // 插入两行数据后 ResultSet rs stmt.executeQuery(SELECT * FROM orders); assertFalse(ResultSetUtils.isEmpty(rs)); while (rs.next()) { // 正常遍历 } } Test public void testIsEmptyWithNoRows() { // 不插入任何数据 ResultSet rs stmt.executeQuery(SELECT * FROM orders); assertTrue(ResultSetUtils.isEmpty(rs)); assertFalse(rs.next()); // 游标未移动仍然能正确返回 false } Test public void testIsEmptyWithSingleRow() { // 插入一行数据 ResultSet rs stmt.executeQuery(SELECT * FROM orders); assertFalse(ResultSetUtils.isEmpty(rs)); assertEquals(1, countRows(rs)); }单行数据是最容易被误伤的场景。很多人在多行数据上测试没问题但换成单行数据就出 bug。用isBeforeFirst()判断后再通过rs.next()遍历单行数据也能完整地进循环体。4. 常见坑、异常与性能问题记录4.1 第一个大坑调用顺序导致“伪空集”我在另一个模块里见过这样的代码while (rs.next()) { // 处理一些逻辑 } if (ResultSetUtils.isEmpty(rs)) { // 没有任何数据 }这显然是逻辑错误。因为while循环已经把游标移到了最后一行之后此时isBeforeFirst()返回falseisEmpty()就会错误地把这个结果集判成“空集”。这不是isBeforeFirst()的问题而是调用顺序问题。判空一定要在第一次移动游标之前进行否则任何基于游标位置的判断都会失真。如果你确实需要“先遍历一部分行再判断里面有没有数据”那不如用一个布尔标志位。比如boolean hasData false; while (rs.next()) { hasData true; // 处理数据 }这样语义更清晰也不依赖游标状态。4.2 第二个大坑某些驱动的 isBeforeFirst() 实现“很迷”我用一个国产老牌驱动测试时发现空结果集上调用isBeforeFirst()一直返回true导致程序走错分支。翻看源码后才发现这个驱动根本没有正确区分“空集”和“非空集”它只是简单地“没有实现游标定位所有位置都返回 true”。遇到这种情况isBeforeFirst()就不靠谱了必须降级到rs.next()方案。遇到这种“很迷”的驱动最稳妥的兼容写法是boolean isEmpty; if (rs.getType() ResultSet.TYPE_FORWARD_ONLY) { // 可滚动游标不可用时先 next 判断再回归 do-while if (!rs.next()) { isEmpty true; } else { isEmpty false; // 注意这里游标已经在第一行后续需要 do-while 或单独处理当前行 processCurrentRow(rs); while (rs.next()) { processCurrentRow(rs); } } } else { isEmpty !rs.isBeforeFirst(); }这个降级方案很实用但会让代码变长。所以我通常的做法是先写一个基础版本用isBeforeFirst()在压测或联调阶段如果有异常再加一层getType()判断。生产环境里没必要一开始就把所有分支都铺满避免过度设计。4.3 第三个坑可滚动游标背后的性能代价有些朋友看到isBeforeFirst()有驱动兼容问题就想着干脆把连接串上的游标类型全部改成TYPE_SCROLL_INSENSITIVE一劳永逸。但我劝你慎重因为可滚动游标意味着 JDBC 驱动可能要把所有查询结果缓存到客户端内存里才能实现“随便往前跳”的能力。当查询返回几十万行数据时内存占用会非常夸张甚至直接引发 OOM。我在一个导出报表的功能里试过把游标改成可滚动结果一次导出 40 万行的操作直接把 JVM 堆打到 5GB最后无奈回退到 forward-only 加流式遍历。所以不要把“判空”这个简单需求和“可滚动游标”绑在一起能用轻量方法解决就用轻量方法。4.4 第四个坑多个结果集混在一起时判空会失灵如果你执行的是存储过程或者一条 SQL 里包含多个SELECT那么ResultSet可能不止一个。此时调用stmt.getMoreResults()会切换当前结果集而你持有的ResultSet对象也会被驱动内部替换。这种情况下判空逻辑必须放在每次获取到新结果集之后立刻执行不能提前存一个引用再回头判断否则很可能拿到一个已经关闭或失效的结果集。踩过一次坑后我的习惯是写一个processResultSet(ResultSet rs)方法把所有判空和遍历逻辑都收口在方法内部。这样不管是单结果集还是多结果集代码路径都是唯一且清晰的。4.5 性能对比与选型建议为了直观展示不同判空方式的开销我分别跑了一个 10 万行数据的查询统计各方案在判空阶段消耗的时间不包含后续遍历方案是否移动游标需要额外SQL平均耗时ms适用场景rs.next() do-while是否0.2任何驱动推荐优先考虑isBeforeFirst()否否0.3主流驱动逻辑清晰last() getRow()是否0.8可滚动游标精度要求高SELECT COUNT(*)不适用是2.1数据量小对准确性要求极高从数字上看各种方案的时间差异并不大真正的分水岭在于“代码逻辑是否被你内心的模型彻底理解”。rs.next()和do-while虽然最通用但经常被用错isBeforeFirst()最优雅却偶有驱动实现buglast()getRow()最准确但需要可滚动游标。我个人建议如果项目背后是你信任的主流数据库驱动直接用isBeforeFirst()加工具方法封装如果会跑在一些小众或老驱动上就老实点用rs.next()do-while组合运行在 forward-only 游标上最省心。4.6 和 ORM 框架的衔接小技巧这个话题虽然讲的是原生 JDBC但很多开发其实是写 MyBatis 或 Spring JdbcTemplate 的。如果哪天你觉得框架帮你封装好了不用关心ResultSet判空那可真要留个心眼了。MyBatis 的selectList如果查询无结果会返回空List但selectOne无结果会返回null。Spring 的JdbcTemplate.query也会返回空列表但queryForObject在没有结果时抛EmptyResultDataAccessException。这些框架层面的“判空”和原生 JDBC 是两套逻辑别混着用。如果正在用一个轻量 ORM内部把 JDBCResultSet暴露给你自定义映射那你可以直接复用上面的ResultSetUtils。如果用的是全自动框架那还是优先用框架提供的 API不要让 JDBC 游标状态泄露到业务层。5. 聊聊 DeepSeek 在排查问题过程中给我的启发这个坑虽然不深但让我印象最深的是 DeepSeek 的第一个回答。它直接给出了isBeforeFirst()方案解说也头头是道几乎挑不出毛病。但我在老驱动上测试后发现行为完全不符合预期。顺着这个线索我又问了它关于“驱动兼容性”的问题它才补充了getType()分支和降级方案。说白了AI 能帮你快速打开思路但它没法替你跑一遍所有驱动和所有版本。生产环境里任何从网上或 AI 那里拿到的代码都必须经过本地实测尤其是这种和数据库驱动底层行为强相关的问题。后来又让 DeepSeek 帮我把rs.next()do-while方案重构了一遍它的改进建议其实挺有价值它提议把“处理单行”和“判空”完全解耦比如用一个RowHandler接口。这样一来即便代码里先判空再遍历也不会因为处理逻辑变化而影响游标状态。这个设计我后来应用到项目里确实让代码清晰了不少。最后分享一个小技巧凡是碰到ResultSet相关的疑难杂症我建议你在复现问题之前先把rs.getType()、rs.getConcurrency(),以及驱动的版本号打印出来。这三个参数基本能解释掉 80% 的“诡异”行为。否则你可能会在错误的方向上排查很久最后发现只是驱动版本旧了换个新版本就一切正常。这是我的个人经验希望对你有用。