1. 为什么我至今仍坚持手写 PreparedStatement先说结论PreparedStatement 用得好不好直接决定了一个 Java 数据访问层是能用还是扛得住。我在几个中型业务系统里做过统计同样的业务逻辑用 Statement 拼接字符串和用 PreparedStatement 传参在峰值时段的数据库 CPU 占用差距能到三成以上而这还没算上 SQL 注入这个随时可能炸的老雷。PreparedStatement 是 JDBC 规范里对预编译语句的对象抽象它把SQL 结构和数据值彻底分开让驱动程序有机会在真正执行前就把语句解析好、把执行计划缓存住。这件事听起来简单但真正落地时会牵出一堆细节占位符索引从几开始、参数类型怎么映射、批处理什么时候才真的批量、结果集怎么才不会把堆撑爆。这篇内容适合三类人看刚接触 JDBC、还在用字符串拼 SQL 的新手写过几年业务代码、但没深究过驱动参数的熟手以及正在排查批量插入为什么没变快大查询为什么内存打满这类具体问题的一线开发者。我不会只列 API而是把每个关键选择背后的原因讲清楚——为什么这里要这么写不这么写会出什么事出事后怎么定位。1.1 字符串拼接 SQL 的三条死路很多人最早写 JDBC 都是这么干的String sql select id, name from account where name name ; Statement st conn.createStatement(); ResultSet rs st.executeQuery(sql);这段代码能跑通测试环境也一切正常问题在于它同时踩了三个坑。第一条是注入。这里的name只要被拼进字符串它就变成了 SQL 语法的一部分。传一个带单引号和or的值进去查询条件就被改写了。这不是注意一下就行的事情——只要代码里存在拼接就意味着任何一处忘记过滤都会成为缺口而人是会忘的。PreparedStatement 的价值在于它把这条路彻底堵死参数值永远只作为数据传给数据库驱动程序负责转义和类型绑定语法结构在编译阶段就固定了再花哨的输入也改不了结构。第二条是执行计划复用。数据库解析一条 SQL 要经历词法分析、语法分析、生成执行计划、优化这一整套流程代价不低。如果每次传进来的 SQL 文本都因为拼了不同的值而不同数据库就得把它当成一条全新的语句重新解析。批量执行一万次就解析一万次。而 PreparedStatement 发送给数据库的语句结构是固定的驱动程序和服务端都有机会把它和已有的执行计划对上号后面的执行直接复用。第三条是可读性和可维护性。拼接式的 SQL 一旦带上七八个动态条件代码就会变成一坨引号、加号和三目运算符的组合改一个字段要在三个地方同时动还极容易漏掉某个空格。参数化之后 SQL 是一条完整可读的字符串值全部在下面按序号绑定改动范围一目了然。1.2 预编译到底预在哪一步这一点很多人其实没搞清楚导致后面调优方向跑偏。所谓预编译在客户端预编译和服务端预编译两种模式下含义完全不同。客户端预编译驱动程序在本地把带?的 SQL 拼成最终文本把参数值转义后填进去再发给数据库执行。这种情况下的预编译实际上只是驱动内部的语句模板缓存数据库那边收到的还是一条完整 SQL执行计划复用的收益有限但防注入的收益是完整的。服务端预编译驱动程序先发一条PREPARE给数据库数据库返回一个语句句柄之后每次执行只发参数值和句柄数据库自己缓存执行计划。这种模式网络传输更省不用每次发整条 SQL执行计划复用也更彻底但多了一次往返开销而且服务端的预编译缓存是有容量上限的语句种类太多时会互相挤掉。以常见的开源数据库驱动为例它默认走的是客户端预编译需要显式打开服务端预编译开关才会切换到后者。这个默认值的选择其实很务实大多数业务系统的 SQL 种类多、单条执行频次不高服务端预编译的往返成本反而更明显。所以下面这个判断很有用——如果你的系统里同一类 SQL 高频重复执行比如每秒几百次的商品详情查询打开服务端预编译收益明显如果是长尾的后台管理查询保持默认反而更划算。提示不要为了看起来更专业就无脑打开服务端预编译。它的收益取决于语句复用率盲目打开可能因为额外的PREPARE/CLOSE往返让整体吞吐下降。1.3 一次真实的注入复盘早些年我接手过一个后台报表模块导出功能的筛选条件是这么写的拼接where之后直接executeQuery。测试阶段一切正常上线三个月后监控发现某张表出现了异常的批量删除记录。查下来是导出接口的某个筛选字段被构造了特殊值把where条件整个短路掉了。修复过程其实很简单把拼接改成占位符参数用setString绑定二十分钟改完。但真正让我印象深的是复盘时发现的问题——这个模块里一共有十七处拼接 SQL只有这一处被利用了其余十六处只是暂时没人利用。换句话说拼接式代码的风险不是会不会出事而是什么时候出事。从那以后我在团队里推的规矩很简单业务代码里不允许出现任何形式的 SQL 字符串拼接动态部分一律走占位符或白名单校验。2. 创建与关闭PreparedStatement 的生命周期怎么写才不出事写对生命周期比写对 SQL 更重要。我见过太多连接池耗尽游标数超限的事故根因都是 PreparedStatement 或者 ResultSet 没关干净。这一节把从建连到关闭的完整链路捋一遍。2.1 从 Connection 拿到对象的两种姿势最基础的写法是通过Connection.prepareStatement(String sql)创建try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement( select id, balance from account where id ?)) { ps.setLong(1, accountId); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { long balance rs.getLong(balance); } } }如果需要拿自增主键创建时就要指定PreparedStatement ps conn.prepareStatement( insert into orders(user_id, amount, created_at) values (?, ?, ?), Statement.RETURN_GENERATED_KEYS);是否需要指定结果集的滚动方式也在创建时决定PreparedStatement ps conn.prepareStatement(sql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);这里有个容易被忽略的点结果集的类型和并发模式必须在创建时就定下来创建之后改不了。很多人习惯性传TYPE_SCROLL_INSENSITIVE想要能往回翻结果发现某些驱动会因此放弃流式读取把整个结果集一次性拉到内存里。如果没有明确的滚动需求老老实实用TYPE_FORWARD_ONLYCONCUR_READ_ONLY对内存和性能都更友好。2.2 关闭顺序与资源泄漏的真实代价关闭顺序必须是 ResultSet → PreparedStatement → Connection从内到外。用 try-with-resources 时资源按声明的逆序关闭所以只要嵌套正确顺序自然是对的。如果不关会怎样ResultSet 不关游标就一直挂在数据库会话上某些数据库有会话级游标上限达到上限后会直接拒绝新的查询。PreparedStatement 不关服务端预编译模式下对应的语句句柄不会释放长时间运行就会出现预编译缓存被打满的情况。Connection 不关连接池很快见底表现为应用整体卡死。还有一个隐蔽的坑在 try-with-resources 里捕获异常后又吞掉会导致外层 Connection 回池时状态不干净。连接池通常会在归还时做一次rollback或者状态重置但如果你在事务中途抛异常又被外层吞了事务边界就变得模糊。我的做法是数据访问层的方法不吞异常让异常往上抛到统一的事务边界处理。2.3 一个 PreparedStatement 能不能重复用可以而且这是它比 Statement 更好用的地方之一try (PreparedStatement ps conn.prepareStatement( update inventory set qty qty - ? where sku ? and qty ?)) { for (OrderItem item : items) { ps.setInt(1, item.getQty()); ps.setString(2, item.getSku()); ps.setInt(3, item.getQty()); ps.addBatch(); } ps.executeBatch(); }同一个对象参数可以反复设置、反复执行。但有两个前提要记住一是每次执行前所有占位符都必须重新绑值驱动不会帮你保留上一次的值漏绑的话要么报错要么拿到上一次的残值——后者更可怕因为它不报错二是执行后没有显式清空参数的说法下一次setXxx会覆盖对应位置只要你不漏就没问题。如果是循环里反复executeUpdate记得不要每次循环都conn.prepareStatement新建对象那样等于把预编译的收益全扔了。复用同一个对象、只改参数才是正确姿势。3. 参数绑定setXxx 里那些不报错但会咬人的细节占位符编号从 1 开始这是新手最容易栽的第一个跟头。真实情况是编号错误往往不报错只是把值绑到了错误的位置然后 SQL 语义悄悄变了查出来一堆对不上的数据。这一节把类型映射和边界值处理拆开讲。3.1 数字与字符串类型错配的隐性代价ps.setLong(1, 10086L); ps.setString(2, alice); ps.setInt(3, 3); ps.setBigDecimal(4, new BigDecimal(19.99));看起来简单但类型错配会带来两个后果。第一是隐式转换导致索引失效某张表的order_no是字符型且建了索引如果用setLong传数字进去数据库可能把列做类型转换后再比较索引就用不上了全表扫描。第二是精度丢失金额字段用setDouble传碰到0.1 0.2这种经典问题存进去的值可能和你预期差一点点日积月累对账就会翻车。金额一律用BigDecimal这应该是肌肉记忆。setObject看着最省事但我建议少用// 不推荐类型由驱动推断行为不稳定 ps.setObject(1, someValue); // 推荐类型明确 ps.setBigDecimal(1, amount);setObject在某些驱动下会把String推断成VARCHAR、把包装类推断成对应数值类型但如果传入的是自定义对象或者null推断结果可能出乎意料甚至直接抛异常。更稳的写法是显式指定 SQL 类型ps.setObject(1, null, Types.INTEGER);3.2 时间类型java.util.Date 和 java.sql 的裂缝这个坑几乎每个 Java 开发者都踩过。java.util.Date不能直接塞给setDate正确做法是转换java.util.Date utilDate order.getCreatedAt(); ps.setTimestamp(1, new java.sql.Timestamp(utilDate.getTime()));三个类型的分工要分清楚java.sql.Date只保留年月日java.sql.Time只保留时分秒java.sql.Timestamp带纳秒精度。字段是什么类型就用什么 setter跨着用会出现时分秒莫名变成 00:00:00这种问题。JDBC 4.2 之后标准的java.time类型也能直接绑定前提是驱动版本够新ps.setObject(1, LocalDateTime.now()); ps.setObject(2, LocalDate.of(2024, 6, 1)); ps.setObject(3, Instant.now());这条路径比手动转换干净得多但要注意两点一是老版本驱动不支持升级驱动前先确认版本二是setObject传java.time时如果对应的数据库列是带时区的类型不同驱动的时区处理策略不一样跨时区业务要先验证一遍别等上线才发现日期偏了八小时。3.3 null、空串与默认值三个不同的东西在 Java 里和null是两种东西在数据库里也是。setString(1, )存进去是空字符串setNull(1, Types.VARCHAR)存进去才是NULL。这两者在where col ?和where col is null的语义里完全不同在唯一索引和not null约束上也完全不同。正确的 null 绑定写法if (nickname null) { ps.setNull(1, Types.VARCHAR); } else { ps.setString(1, nickname); }setNull的第二个参数是 SQL 类型不要随便传Types.NULL或者0某些驱动在处理未指定类型的 null 时会报无法确定参数类型尤其是在insert ... values(?, ?)这种数据库需要推断列类型的场景。一个小技巧如果你有一批可空的参数封装一个辅助方法会省很多重复代码private static void setNullableString(PreparedStatement ps, int idx, String v) throws SQLException { if (v null) { ps.setNull(idx, Types.VARCHAR); } else { ps.setString(idx, v); } }3.4 大字段与流式参数图片、附件、长文本这类大字段不要先读成byte[]再setBytes那样会在大对象上多占一份堆内存。用流式绑定try (InputStream in new FileInputStream(file)) { ps.setBinaryStream(1, in, file.length()); ps.setString(2, file.getName()); ps.executeUpdate(); }注意第三个参数要传长度。某些数据库驱动在没有长度信息时会退化成一次性读完整个流再发等于没省内存。文本大字段对应setCharacterStream参数是Reader和长度。读取端也是一样用getBinaryStream或者getCharacterStream拿流边读边写出去别用getBytes一把梭。3.5 LIKE、IN、ORDER BY参数化不了的三个位置这是必须讲清楚的一点占位符只能替代值不能替代语法结构。所以这三类需要特殊处理。LIKE—— 通配符要拼在值里String keyword userInput.replace(\\, \\\\) .replace(%, \\%) .replace(_, \\_); ps.setString(1, % keyword %); ps.setString(2, % keyword %);SQL 写成where name like ? or code like ?。注意通配符必须转义否则用户输入一个%就变成了全表匹配。不同数据库的转义字符可以自定义写like ? escape \显式声明更稳妥。IN—— 占位符数量要动态生成ListLong ids ...; String placeholders ids.stream().map(i - ?).collect(Collectors.joining(,)); String sql select * from product where id in ( placeholders ); try (PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i ids.size(); i) { ps.setLong(i 1, ids.get(i)); } ... }拼接的是问号而不是值安全性没问题。但要注意ids为空时不能生成in ()这是语法错误得在业务层短路掉另外ids特别大比如几万个时超长 SQL 本身就会拖慢解析这时候更适合临时表或者分批查询。ORDER BY / 表名 / 列名—— 绝对不能参数化只能用白名单映射MapString, String allowed Map.of( created, created_at, amount, amount); String column allowed.get(sortKey); if (column null) { column created_at; } String sql select * from orders order by column desc limit ?;用户传什么进来都只做查表查不到就落到默认值永远不把它拼进 SQL。4. 批处理让批量写入真的跑起来的三个前提批处理是 PreparedStatement 最能体现价值的场景但也是照着文档写完发现一点没变快的高发区。我见过最典型的情况是加了一万个addBatch测下来比循环单条还慢。原因往往不在代码而在驱动配置和提交策略。4.1 addBatch 到底攒了什么try (PreparedStatement ps conn.prepareStatement( insert into log_record(user_id, action, created_at) values (?, ?, ?))) { for (LogRecord r : records) { ps.setLong(1, r.getUserId()); ps.setString(2, r.getAction()); ps.setTimestamp(3, new Timestamp(r.getCreatedAt())); ps.addBatch(); } int[] affected ps.executeBatch(); }addBatch并不是把拼好的 SQL 字符串攒起来而是把当前这组参数值序列化后暂存。执行executeBatch时驱动把这些参数组按自己的策略发出去。这个策略差异极大有的驱动会逐条发只是省了网络往返的建连开销有的驱动会改写成一条多值insert一次性发。返回的int[]长度理论上等于批次大小但有些驱动会返回SUCCESS_NO_INFO-2表示执行了但不知道影响了几行所以不要依赖这些数字做精确计数需要精确结果就拆开单独查。4.2 决定批处理速度的三个开关第一个是重写开关。以常见的开源数据库驱动为例它有个参数能把手写的一批insert改写成多值形式insert into t(a,b) values (1,2),(3,4),(5,6)性能数量级提升。这个开关默认是关的很多人不知道傻乎乎地写批处理然后纳闷为什么没变快。连接串上加对应参数即可。第二个是提交策略。一次性addBatch十万条再执行会让驱动在内存里攒下十万组参数同时数据库端的事务日志也在持续膨胀。我的经验值是每 500 到 2000 条执行一次executeBatch并提交具体数字看单条参数的大小。这个区间既能摊薄网络往返又不会让单次事务过大导致锁持有时间过长。第三个是参数数量上限。改写后的多值insert会受数据库的 SQL 长度限制和参数个数限制约束如果单次批次太大改写可能被驱动主动放弃退回逐条发送。这也是要分批的原因之一。4.3 批处理失败后到底发生了什么这一点必须写进规范。批处理执行中途某条失败不同数据库、不同驱动的行为不一样有的会停止后续执行前面的已生效有的会继续执行后面的。如果不包在事务里你会得到一个部分成功的中间状态而且很难反查是哪几条成功了。正确做法是外面套事务conn.setAutoCommit(false); try { // 分批 addBatch executeBatch conn.commit(); } catch (BatchUpdateException e) { conn.rollback(); int[] counts e.getUpdateCounts(); throw new DataAccessException(批处理失败已完成 counts.length 条, e); } catch (SQLException e) { conn.rollback(); throw e; }BatchUpdateException.getUpdateCounts()告诉你失败前成功了多少条但这个数字在事务回滚后就没有业务意义了——因为全回滚了。它的价值在于日志定位别拿它当业务判断依据。还有个小细节同一个 PreparedStatement 上executeBatch之后批次内容是清空的但如果执行前想主动清空用clearBatch()。有人习惯在循环开头调clearBatch防止残留其实没必要但如果是复用对象跨业务方法调用保险起见清一下也无妨。4.4 自增主键怎么拿回来插入后要拿自增 ID必须两步配合try (PreparedStatement ps conn.prepareStatement( insert into orders(user_id, amount) values (?, ?), Statement.RETURN_GENERATED_KEYS)) { ps.setLong(1, userId); ps.setBigDecimal(2, amount); ps.executeUpdate(); try (ResultSet keys ps.getGeneratedKeys()) { if (keys.next()) { long orderId keys.getLong(1); } } }几个注意点RETURN_GENERATED_KEYS必须在prepareStatement时指定事后加不了getGeneratedKeys返回的结果集列名依赖驱动实现不同数据库不一样所以用列序号 1 而不是列名取最稳批处理模式下拿自增主键不同驱动支持的完整度差异很大有的只返回第一行的 ID这种场景更稳妥的做法是插入前自己生成 ID比如雪花算法或者插入后用业务唯一键回查。5. 结果集读取为什么查询还是把内存撑爆了写好了 PreparedStatement绑定好了参数结果查询一跑堆内存直接告警。这种情况十有八九是结果集读取方式的问题而不是 SQL 本身的问题。5.1 fetchSize 在不同数据库里的含义完全不同setFetchSize(n)的语义是每次从数据库往客户端拉多少行。但这句描述在不同数据库上落地的差别极大数据库类型fetchSize 默认行为需要额外配置某开源数据库默认配置忽略 fetchSize一次把全部结果拉进内存需要开启游标读取开关并且设置 fetchSize 才生效某开源数据库流式模式设置特殊值时才逐行拉取流式模式不能与某些特性共存PostgreSQL需要非自动提交事务下才生效必须setAutoCommit(false)Oracle默认 10 行调大明显提速直接setFetchSize即可这张表的意思是同一行ps.setFetchSize(500)在不同数据库上可能是完全无效、稍微有用、效果显著三种结果。所以排查内存爆了的时候第一件事是确认用的是什么驱动、这个驱动的 fetchSize 是什么语义别照着别人的博客硬套。顺带说一句fetchSize 调太小会导致往返次数过多查询变慢调太大又接近全量拉取。常规业务查询设 200 到 1000 是比较舒服的区间导出类任务才需要认真调。5.2 大结果集的三种处理写法写法一游标分批拉取。适合需要完整遍历但不想一次性占内存的场景。conn.setAutoCommit(false); try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setFetchSize(500); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { process(rs); } } } conn.commit();注意setAutoCommit(false)是必须的对某些数据库而言否则游标模式不生效。处理完后必须提交或回滚否则连接一直挂着事务影响其他操作。写法二主键分段查询。这是我认为最稳的做法不依赖任何驱动的游标特性。long lastId 0; while (true) { ListRow batch queryPage(lastId, 1000); // where id ? order by id limit ? if (batch.isEmpty()) { break; } for (Row r : batch) { process(r); } lastId batch.get(batch.size() - 1).getId(); }好处是每批都是独立短事务对数据库压力均匀也不会长时间占用连接坏处是需要有序的主键且如果有并发写入新数据边界处理要小心。导出、对账、数据迁移这类场景我基本都用这个。写法三流式读取。内存占用最低但对驱动和事务状态的要求最严格一旦中途异常流没读完可能导致连接不可复用。用它的时候务必把连接直接销毁而不是归还池子避免污染连接池。5.3 一个容易忽略的点读取顺序和列名rs.getString(name)比rs.getString(2)可读性好但每次按列名取都会做一次映射查找在百万行级别的循环里是有成本的。纯性能敏感的批量读取用序号更快。不过这也带来一个风险SQL 里列顺序改了按序号的代码不会报错只是取到了错误的列。我的折中方案是——业务查询用列名明确的批量导出用序号并且把 SQL 和读取代码写在同一屏内肉眼能对照。6. 和连接池、事务搅在一起时的边界问题单个 PreparedStatement 用好不难难的是它嵌在连接池和事务里的时候。这一节说两个最容易出问题的交界处。6.1 预编译缓存到底存在哪语句缓存有两个层次连接内部的缓存和语句池缓存。连接内部缓存同一个Connection上创建的语句驱动可能缓存其句柄但Connection一旦关闭缓存全没了。所以每次请求新建连接、用完关闭的模式下连接级缓存几乎没用。语句池缓存很多连接池支持把PreparedStatement对象本身池化连接关闭时语句被回收到池里下次同样的 SQL 可以复用。需要开启对应开关并设置池大小。这个特性的收益在于省掉了重复的prepare开销但会带来内存占用且如果 SQL 文本里带大量不同参数比如in动态拼的占位符数量不同会生成大量看起来相似但实际不同的语句把池子挤爆命中率趋近于零。我的判断标准比较简单SQL 文本高度稳定参数个数固定的核心链路开语句池文本动态变化多的链路别开开了反而拖慢。6.2 事务边界该画在哪PreparedStatement 本身不管事务事务边界由Connection的setAutoCommit和commit/rollback控制。常见的错误是把事务边界的控制散落在数据访问层里导致一个业务操作里出现多次提交中途失败就留下脏数据。我习惯的写法是把事务边界提到业务服务层public void transfer(long from, long to, BigDecimal amount) { Connection conn dataSource.getConnection(); try { conn.setAutoCommit(false); accountDao.deduct(conn, from, amount); accountDao.add(conn, to, amount); conn.commit(); } catch (SQLException e) { conn.rollback(); throw new BizException(转账失败, e); } finally { conn.setAutoCommit(true); conn.close(); } }DAO 层只接收Connection不自己提交。这样每一层职责清晰出问题也容易定位。注意finally里要恢复自动提交状态再关连接否则连接归还池子时状态是脏的某些连接池会因此丢弃连接高并发下表现为连接数莫名其妙地减少。另一个点是锁的范围。批处理大事务时事务开得越久持有的行锁越多并发冲突概率越大。这就是前面建议每 500 到 2000 条提交一次的另一个理由。7. 排查实录批量插入从 3 万条/分 到 40 万条/分最后讲一个我实际处理过的性能问题完整还原排查链路因为这类问题的定位方法比结论更有用。现象某个数据同步任务往一张日志表里批量写入测下来每分钟三万条左右目标是每分钟几十万条。代码看着没毛病——用的就是 PreparedStatement addBatch。第一步确认是不是真的在批量。在数据库端开了慢查询日志发现执行的是几万条独立的单行insert。也就是说驱动根本没有把批次改写成多值插入。这里就锁定了第一个问题重写开关没开。第二步打开开关后复测提升到十二万条/分。有改善但不够。继续看发现每 100 条才提交一次但批次执行频率很高日志里有大量事务提交记录。把批次大小调到 1000提升到二十万条/分。第三步看参数绑定的类型。用数据库的执行计划工具比对发现其中一列user_id在表里是整型代码里用的是setString。这意味着每次插入都要做一次隐式类型转换而且这一列上有索引转换会导致索引维护成本上升。改成setLong之后提升到三十二万条/分。第四步看表结构。这张表上有五个索引和一个触发器。触发器里做了一次额外的统计表更新每插一行触发一次。把统计逻辑改成批量结束后统一算提升到四十万条/分。最终配置String url jdbc:xxx://host:port/db ?rewriteBatchedStatementstrue cachePrepStmtstrue prepStmtCacheSize256 prepStmtCacheSqlLimit2048;配合代码里每 1000 条executeBatchcommitsetObject全部换成精确类型的 setter。这里我想强调的经验是性能问题的定位顺序应该是确认行为 → 确认配置 → 确认类型 → 确认表结构而不是一上来就调参数。第一步那个看数据库实际收到什么的动作就省掉了后面大量瞎试的时间。很多人调优卡住就是因为一直在猜应用层做了什么而没有去数据库那一端看结果。提示验证批处理是否真的生效最直接的办法是打开驱动的调试日志或者数据库的通用查询日志看实际到达数据库的语句形态。这一步花五分钟比读十篇调优文章都值。7.1 顺手记下的几条零散经验写到这里再补几条不太成体系但确实踩过的executeQuery只能执行返回结果集的语句executeUpdate只能执行不返回结果集的语句。用错不会编译报错运行时才炸。拿不准的时候用execute()再根据getResultSet()/getUpdateCount()判断。一条 SQL 里同一个参数被用到多次时比如where a ? or b ?必须绑定两个占位符不能共享一个。想复用只能写到两个位置绑同一个值。占位符不能出现在引号内部。where name like %?%里的问号是普通字符不是占位符这个错误极其常见而且不报错只是查不出数据。大批量的in查询把列表切成每批 500 到 1000 个再查比一次传两万个稳定得多。用getLong取一个可能为NULL的列返回的是 0 而不是null。需要区分没有值和值就是 0时必须先rs.getObject(col)判空或者用rs.wasNull()。每次executeUpdate之后不要急着clearParameters()这玩意会清空所有已绑定的参数下一轮漏绑就会踩坑。我个人在实际操作中的体会是PreparedStatement 这套 API 本身并不复杂难点全在边界上——类型边界、事务边界、缓存边界、内存边界。把这些边界想清楚了代码写出来自然就稳只记 API 名字遇到问题就只能靠试。上面这些坑我基本都是真金白银踩出来的希望你看完能少踩两个。
企业数字化 ERP 产品动态
相关推荐
Notepad++绿色版下载配置全攻略:便携模式与避坑指南 简介:这是一份面向Windows用户的免安装文本编辑器资源,解压后即可直接运行,无需繁琐的安装流程,适合需要轻量级编辑工具的开发者、学生及日常办公人群。它不仅能处理普通纯文字说明文件,更凭借语法高亮、语法折叠、宏录… · 2026/11/4 3:14:22
绿色版Notepad++实战指南:免安装配置与高效文本处理 简介:这是一份面向Windows用户的免安装Notepad绿色版资源,适合需要轻量文本编辑与代码编写的开发者、运维人员及编程初学者使用,下载解压后即可直接运行,省去安装与配置环节。压缩包共303个文件,约6.67MB,以… · 2026/11/4 3:14:22
Pandas进阶技巧:电商数据分析实战经验分享 1. Pandas基础操作进阶指南刚接触数据分析时,Pandas的DataFrame总让我想起小时候玩的乐高积木——看似简单的方块,通过不同组合却能搭建出无限可能。但真正开始处理实际业务数据时,才发现光会拼装基础积木远远不够。今天要分享的这些Pandas进… · 2026/11/4 3:14:22
SSM人事信息管理系统设计:表结构、配置链路与避坑指南 简介:《基于JavaWeb的企业人事信息管理系统》论文文档面向计算机专业毕业设计学生及Java Web初学者,围绕企业人事管理场景,梳理从需求分析到系统实现的完整写作思路。全文以SSM(Spring、SpringMVC、MyBatis)为核心架构… · 2026/11/4 9:01:41
Kingsway Ultra:出海企业全链路数字化解决方案解析 1. 项目概述Kingsway Ultra是一套面向出海企业的全链路数字化解决方案,它打通了从视频内容生产到AI智能应用的完整闭环。这个方案特别适合正在拓展海外市场的中国品牌,帮助他们解决跨文化传播中的内容适配、本地化运营和智能决策等核心痛点。我在服务跨境… · 2026/11/4 9:01:41
TypeScript数据字典在前端项目中的实践与优化 1. 为什么前端项目需要数据字典护城河? 在复杂前端项目中,我们经常遇到这样的场景:后端API返回的status字段可能是1/0、"success"/"error"、true/false等不同形式;同一业务实体在不同接口中的字段命名可能不一… · 2026/11/4 9:01:41
CefSharp WinForms 多标签浏览器:生命周期与进程管理实战 简介:面向具备一定 .NET 基础、希望在 WinForms/WPF 应用中嵌入 Chromium 内核的开发者,这份 CefSharp 示例工程聚焦浏览器式多标签交互,覆盖 Tab 新建与切换、页面刷新、标签关闭及新窗口打开等常见能力,可用于理解控件生命周期与… · 2026/11/4 9:01:41
Pyradiomics放射组学特征提取:环境搭建、掩膜对齐与参数设置实战 放射组学特征提取这件事,真正上手做过一遍的人都会有一个共同感受:难的不是理解公式,而是把第一份能用的特征表跑出来。Pyradiomics 这个库把这些公式都封装好了,它能把影像和勾画区域一起读进去,吐出一张几百列的特征… · 2026/11/4 9:01:41
多目标优化算法MMOCOCA-SC在盘式制动器设计中的应用 1. 项目背景与核心价值 多目标优化问题在工程领域普遍存在,传统优化算法在处理高维、非线性问题时往往力不从心。盘式制动器作为车辆关键部件,其设计涉及制动性能、热稳定性和重量等多重目标,是典型的多目标优化场景。我们团队提出的MMOCOCA-… · 2026/11/4 9:00:38
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38
AI辅助技术栈评估:提升研发决策效率60% 1. 项目背景与核心价值技术决策一直是研发团队面临的重大挑战。随着技术生态的快速演进,技术栈的选择、维护和迭代变得愈发复杂。我们团队在过去两年里,先后经历了三次重大技术栈迁移,每次都需要投入大量人力进行技术评估和风险分析。直到去年… · 2026/11/4 0:00:17
从生成Token到直接决策:Jev如何重塑AI Agent工程实践 1. 从"生成文字"到"拍板决策":Jev 到底在解决什么问题大多数人第一次接触大模型,脑子里建立的模型是"输入问题、输出文字"。你问它今天天气怎么样,它给你一段描述;你让它写个周报,它给你… · 2026/11/4 0:00:17
32G统一内存跑大模型:内存账、TPS实测与端云分流 想在一台 32G 统一内存的小主机上把大模型跑成"日常可用",这件事我前后折腾了小半年。标题里说的这台 Mac mini M6,是我们这次讨论的目标平台,具体到手规格以实物为准;但下面这套推算方法和实测思路,对任何统… · 2026/11/4 0:00:17
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38