很多人第一次接触"样板代码"这个词,是在写 Java 的时候。不管是早年用 raw JDBC 连数据库,还是后来切换到 MyBatis,总有一大段结构固定、内容重复、改了这行忘那行的代码横在业务逻辑前面。这些代码就是 boilerplate code——计算机编程里那些"必须写、但几乎不改"的胶水代码。今天这篇就想把 raw JDBC 和 MyBatis 这两代数据库访问方式里的样板代码掰开揉碎聊一遍。对不同基础的读者都有用:刚入门的朋友能看懂"为什么前辈们那么讨厌写 JDBC";用 MyBatis 写了一阵子但没深究过原理的朋友,也能借此理解框架替我们省掉了什么、又带来了哪些新的负担。
1. 样板代码是什么,为什么说它是 productivity killer
1.1 从定义说起:那些"不得不写"的固定代码
在计算机编程里,boilerplate code 指的是"在程序很多地方都必须出现、但内容和结构几乎完全一致"的代码片段。它像印刷报纸时预先排好的固定版面,你每次只需要改掉中间一小块内容,周围一圈版式纹丝不动。
以数据库访问为例。一个 Java 程序不管查什么表、查什么字段,只要用最原始的 JDBC 方式,代码骨架永远是同样的六步:
// 加载驱动 → 建立连接 → 创建 Statement → 执行 SQL → 处理结果集 → 关闭资源 Class.forName("com.mysql.cj.jdbc.Driver"); Connection conn = DriverManager.getConnection(url, user, password); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("select * from user"); while (rs.next()) { // 处理每一行数据 } rs.close(); stmt.close(); conn.close();这六步里真正跟业务相关的,只有"执行 SQL"和"处理结果集"两步。其他四步在每一个 DAO 方法里都会原封不动地再出现一遍。你写 10 个查询,就要把这四步复制粘贴 10 次;你写 50 个增删改查,那就是 200 次重复。
这就是样板代码最直观的形态。它本身没有多难,甚至可以说毫无技术含量,但它的杀伤力恰恰来自"没有技术含量却占据大量时间"。一个 CRUD 项目里,真正有业务价值的逻辑可能只占三成,剩下七成时间都在跟连接、关闭、异常处理搏斗。生产效率就是这么被拖垮的——不是被某个复杂的算法拖垮,而是被海量的、无脑的、机械的重复劳动淹没。
1.2 样板代码的真正危害:认知负担和注意力涣散
如果说"浪费时间"还只是表面损失,样板代码对开发者认知状态的破坏才更致命。
心理学上有个概念叫"注意力残留":当你从一个任务切换到另一个任务时,前一个任务的思维会残留在脑中,干扰你对当前任务的专注。写代码也是同理。当你正在处理一条 SQL 的查询逻辑,脑子里突然要切到"连接有没有关""异常有没有处理""驱动加载会不会报 ClassNotFoundException"——这些切换非常频繁,每写一个 DAO 方法就要切换好几次。
我见过很多刚转 Java 的同事,在 JDBC 代码里栽跟头,栽得最多的反而不是复杂的 SQL,而是:
- 忘记关闭 ResultSet,导致数据库连接池被耗尽
- 异常发生时连接没释放,线上服务三两下就挂了
Class.forName在不同驱动版本里写错类名,启动就报错
这些问题都不是"不会",而是"顾不上"。人的工作记忆空间是有限的,当你把大量脑力花在样板代码上时,分给真正业务逻辑的注意力自然就少了。代码质量下降、Bug 增多、排查时间变长,形成恶性循环。
所以我说样板代码是 productivity killer,它不是杀死你的程序,而是杀死你的产出效率和写代码时的专注状态。理解了这个前提,再看 raw JDBC 和 MyBatis 的对比,会有完全不同的感觉。
2. 亲手写一遍 raw JDBC:每一步都是痛点
2.1 一段更贴近真实项目的 JDBC 代码解剖
理论讲完,上一段真实的老式 JDBC 代码。假设你要写一个"按 ID 查询用户"的方法:
public User findById(Long id) throws SQLException { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; User user = null; try { conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD); String sql = "select id, name, email, created_at from user where id = ?"; ps = conn.prepareStatement(sql); ps.setLong(1, id); rs = ps.executeQuery(); if (rs.next()) { user = new User(); user.setId(rs.getLong("id")); user.setName(rs.getString("name")); user.setEmail(rs.getString("email")); user.setCreatedAt(rs.getTimestamp("created_at")); } return user; } finally { if (rs != null) rs.close(); if (ps != null) ps.close(); if (conn != null) conn.close(); } }这段代码我相信很多人看着眼熟。它已经是"写对"的版本了:用了 PreparedStatement 防注入,用了 finally 保证资源释放,结果集和语句都在关闭。但即便写得再规范,问题依然很明显。
首先是冗长。一个真正有业务价值的操作就是"select 一个用户,把它转换成 User 对象",大概 15 行代码能解决。但为了保证连接正确、资源释放、异常不吞,你需要额外写三倍以上的代码。所有 DAO 方法都在重复这个模式,而且这次是手动处理每个字段:rs.getLong("id")、rs.getString("name")……你写第 50 个查询时,这种体力活已经开始麻木。
其次每写一个方法都要重复造轮子。网上有很多"JDBC 工具类""DBUtils"之类的封装,本质上是想把这套连接管理和结果集映射抽出来。但抽出来之后你会发现,新的抽象又带来了新的学习成本和维护成本。
2.2 六步连接模板:每步里的雷你都踩过
我们把 raw JDBC 的固定流程拆成六步,逐个看你到底踩过多少坑:
| 步骤 | 固定写法 | 典型问题 |
|---|---|---|
| 加载驱动 | Class.forName("com.mysql.jdbc.Driver") | 驱动类名随版本变化,老代码经常 ClassNotFoundException |
| 建立连接 | DriverManager.getConnection(url, user, password) | 每次请求都新建物理连接,性能极差 |
| 创建声明 | conn.createStatement()/prepareStatement(sql) | 稍不注意就 SQL 拼接,注入风险 |
| 执行 SQL | executeQuery(sql)/executeUpdate(sql) | 动态条件拼 SQL 极其痛苦 |
| 处理结果 | while (rs.next())手动取值 | 字段一变,这里跟着全改 |
| 释放资源 | rs.close(); stmt.close(); conn.close() | 漏一个,连接池就慢慢消失 |
关于资源释放,我吃过一次大亏。在某次线上故障排查中,数据库连接数一直飙升,重启后几分钟又涨满。最后查下来,某个查询方法的ResultSet在异常路径上没有关闭,连接没有归还到连接池。更麻烦的是,这种问题不是每次都复现,要看那台机器的 GC 和并发情况。在 JDBC 时代,这种问题每个团队几乎都遇到过一次——模板代码里面最不起眼的"关闭资源"四步,恰恰是整个链路里最容易出事的地方。
2.3 为什么说连接管理和异常处理是 JDBC 的两座大山
如果只是重复,JDBC 还有个更大的问题:连接管理和异常处理的边界模糊。
物理连接的创建和销毁是昂贵的。每次DriverManager.getConnection都要经过 TCP 握手、认证、分配资源,一次几毫秒到几十毫秒不等。在高并发场景下,每次请求都新建连接,数据库很快就会被拖垮。所以有了连接池(比如 C3P0、DBCP、后来的 HikariCP)。但连接池的引入又带来新的样板代码——从池里借用连接、归还连接、处理连接失效……这些虽然比直连好,但依然是每个 DAO 方法都要关心的细节。
异常处理更痛苦。SQLException是 checked exception,Java 编译器强制你处理。于是每个方法都要 try-catch,或者往上层抛。问题是,SQLException 的语义太粗了:连接失败、SQL 语法错误、约束冲突、唯一键重复,全是同一个异常类。你要么用getSQLState()去猜错误码,要么在业务层再包一层自定义异常。不管怎么弄,都是在本来就厚的样板代码上再摞一叠。
这就是 raw JDBC 的核心困境:它把数据库访问的技术复杂度完全暴露给开发者。你不仅要懂业务 SQL,还要懂网络、连接池、异常处理、资源管理。对老手来说这也是一大堆心事,对新手来说基本是劝退。
3. MyBatis 到底解决了什么:把 SQL 带回主舞台
3.1 核心设计逻辑:只关心 SQL 本身
MyBatis 出现的背景,就是受够了上面这一整套模板代码。它的核心思路用一句话就能概括:让开发者只关心 SQL 本身,连接管理、资源释放、参数设置、结果集映射这些琐事,框架全部接管。
这是一次思路上的转变。JDBC 的思维是"你告诉我怎么连、怎么执行、怎么处理",MyBatis 的思维是"你只要告诉我 SQL 是什么、参数是什么、结果要映射成什么对象,剩下的我来"。
拿上面"按 ID 查询用户"的例子,在 MyBatis 里你会这样写:
<select id="findById" resultType="com.example.User"> select id, name, email, created_at from user where id = #{id} </select>对应的 Java 接口方法只需要一行:
User findById(@Param("id") Long id);六个步骤缩减成了两个核心表述:SQL 长什么样、结果映射成什么。连接谁去管?框架管。参数怎么传?框架管。结果怎么转换成 User 对象?框架管。你不用再写DriverManager.getConnection,不用再写finally { rs.close(); },更不用手动一行行rs.getLong("id")。
这块设计我特别想强调一点:#{} 参数占位符。在 JDBC 里你写ps.setLong(1, id),在 MyBatis 里你只需要写#{id}。框架在背后帮你做了类型判断、PreparedStatement 参数填充、类型转换。动态 SQL 更是 JDBC 时代不敢想象的东西——<if>、<where>、<foreach>这些标签,直接让"根据条件拼 SQL"这种事从地狱级痛苦变成了配置文件里的几百字节。
3.2 三种写法的代码量对比:同样功能的边界
我们做一个量化对比。同样是"按 ID 查用户并转成对象":
- raw JDBC:大约 30 行(连接 + 预编译 + 执行 + 手动映射 + 资源释放)
- MyBatis XML:一个
<select>标签 + 一个接口方法,约 5 行 - MyBatis 注解:一个接口方法上加
@Select("select ... from user where id = #{id}"),约 3 行
注意,这是只算"当前这个查询"本身的代码。JDBC 那套连接管理代码在 30 行里占了大头,但它会在每个 DAO 方法里重复。也就是说,写 20 个查询,JDBC 的总代码量不是 30 行乘以 20,而是连接模板代码 + 每个查询的具体逻辑,大部分是重复的机械代码;MyBatis 则是每个查询只需要写真正跟业务相关的部分。
几千行代码差距就是这么出来的。一个普通的后台管理系统如果有 100 张表、几百个 CRUD 操作,用 MyBatis 比用 raw JDBC 少写的代码量是以万行计的。这不是夸张,是实际经验。
但我也要说句公道话:MyBatis 并没有把代码完全消灭,它只是把样板代码从 Java 代码里搬到了 XML 或注解里。你在 XML 里依然要写resultMap、要配置<sql>片段、要维护<if>分支。只是这些写起来比 Java 里的连接模板轻量得多,而且集中在一起,更容易维护。
3.3 动态 SQL 和自动映射:少掉的那些代码都去哪了
如果你用过 MyBatis,一定体会过动态 SQL 的爽感。举一个最常见的场景:列表查询,查询条件可能有用户名、可能有用状态、可能还要按创建时间过滤。在 raw JDBC 时代,你要这样写:
String sql = "select * from user where 1=1"; if (name != null && !name.isEmpty()) { sql += " and name = '" + name + "'"; // 这里还带着注入隐患 } if (status != null) { sql += " and status = " + status; }这种字符串拼接写多了,你会遇到两个经典问题:一个是拼接条件过多时 SQL 混乱,另一个是稍不留神就拼出一个语法错误。更别提有人习惯性地用字符串直接拼变量,把 SQL 注入漏洞亲手送上线。
在 MyBatis 里,同样的逻辑是这样写的:
<select id="listByCondition" resultType="User"> select * from user <where> <if test="name != null and name != ''"> and name like concat('%', #{name}, '%') </if> <if test="status != null"> and status = #{status} </if> </where> </select><where>标签会自动处理开头的 and 或 or,<if>负责条件是否拼入 SQL。你不需要在 Java 代码里做任何字符串拼接,也不会因为忘写空格或多写了 and 而报语法错误。这种体验上的提升,比单纯少写代码更值得关注——它把容易出错的部分框架化了。
自动映射也一样。table的created_at列自动对应 Java 对象的createdAt属性(配好驼峰映射后),不用一个个写 getter 和 setter。那几百行rs.getXxx()赋值代码直接就消失了。MyBatis 做的是"列名到属性名"的映射,你只要保证两者对得上(或者写一个<resultMap>兜底),剩下全是体力活。
4. MyBatis 也逃不掉的"新样板代码"问题
4.1 XML 配置的负担:mybatis-config.xml 和 mapper 的 boilerplate
框架解决问题的方式,是把问题转移到自己定义的配置层。MyBatis 也有自己的样板代码,集中在 XML 配置里。
首先是mybatis-config.xml。每个项目都要写一遍几乎雷同的设置:数据源、事务管理器、mapper 扫描路径、驼峰映射开关、日志实现。这些配置不复杂,但格式固定、不写不行。换个项目,这些 XML 片段往往是从老项目里直接复制的。
其次是 mapper XML 里每个语句的骨架。举个例子:
<insert id="insertUser" parameterType="User"> insert into user (id, name, email) values (#{id}, #{name}, #{email}) </insert>这种<insert>固定结构,写 100 个表就要写 100 遍。如果想再要主键回填,还得多加几个子元素:
<insert id="insertUser" useGeneratedKeys="true" keyProperty="id">有经验的开发会写一个代码生成器,自动生成 mapper 接口、XML、实体类。把样板代码从"手写"变成"生成"。这算是 MyBatis 社区应对样板代码的主流方式——既然这些代码结构固定,就让工具去生成,人只改生成后的业务部分。
这引出一个观点:完全消灭样板代码是不现实的,你能做的是把样板代码交给工具和框架处理。MyBatis 把 JDBC 的连接样板消除了,但引入了 XML 配置样板;代码生成器把 XML 样板消除了,但生成出来的代码仍然需要人工 review。每一层抽象都在把某些重复代码收编,但永远有新的重复待处理。
4.2 高频翻车点:TypeHandler、二级缓存、param 索引
从热搜词里能看到,大家最关心的 MyBatis 问题集中在几个点:TypeHandler、二级缓存、参数索引、初始化流程。这些都是"框架替你干活,但干得不对时非常难查"的代表。
TypeHandler的具体作业流程是:查询时它把 JDBC 的ResultSet里的值转成 Java 类型,写入时它把 Java 参数转成 JDBC 类型。默认的 TypeHandler 覆盖了常用类型,但遇到枚举、JSON、自定义业务对象时,你得自己写一个 TypeHandler。我第一次自定义 TypeHandler 的时候踩过一个坑:setParameter和getResult里都要处理 null 值。结果查询正常、插入的时候总是报"未知列类型"。查了半天,发现是没有在setParameter里处理parameter == null的情况,PreparedStatement 不知道该用什么类型去 setNull。这东西看起来不起眼,但它确实是 MyBatis 定制化开发里很典型的隐藏问题。
二级缓存更是重灾区。MyBatis 的二级缓存默认是 namespace 级别的,也就是说同一个 mapper 里的查询结果会缓存下来。听起来很好,但问题在于:如果两个 mapper 操作了同一张表,二级缓存会不同步。你删一条数据是用UserMapper的接口,但查询缓存是放在UserMapper下的,另一个 mapper 的更新操作不会自动通知它。结果就是:用户删了数据,列表还是旧数据。这是让很多人头疼的经典缓存一致性问题。
param 索引是另一个高频问题。MyBatis 参数绑定有两种写法:#{param1}、#{param2}或者@Param("id")。在很多老代码里看到#{param1}这种写法,它的问题在于一旦你调整了方法参数顺序,SQL 里的引用全乱。更麻烦的是如果你的参数恰好是List或数组,配合foreach时索引会更容易懵。写代码的时候我强烈建议明确使用@Param注解,别贪省事用默认索引——这个习惯能在半年后帮你省掉至少三个小时的排查时间。
4.3 源码角度:初始化流程里最容易被忽略的环节
热搜词还提到了mybatis 中初始化工作流程和基于 XML 配置的初始化工作原理。这确实值得看一眼。
MyBatis 启动初始化大致是:
- 读取
mybatis-config.xml,创建Configuration对象 - 加载所有 mapper 映射文件,解析每个
<select>、<insert>、<update>、<delete>、<resultMap> - 为每个 SQL 语句创建
MappedStatement,注册到 Configuration 中 - 根据配置创建
Executor、SqlSession等核心对象
这里面最容易忽略的环节是 MappedStatement 的注册顺序和 id 唯一性。MyBatis 里每个 SQL 语句的 id 是 namespace + id 拼接的,必须全局唯一。很多人在复制粘贴 XML 时,忘了改 namespace,或者两个 mapper 的<select>用了同一个 id,启动时才会发现冲突。
还有一点是 XML 中<sql>片段和<include>的组合。当你的系统有很多公共查询条件(比如逻辑删除标记、租户隔离条件),你会想把它们抽成<sql>片段。但抽多了以后,片段之间相互引用会让整个 XML 变得难以阅读;加上不同环境(开发、测试、生产)的数据源配置占位符如果不一致,启动时也能直接报错。
从源码层面理解初始化流程,最大的价值是:遇到"启动时 DAO 方法不生效""新版驱动下 MyBatis 连不上数据库"这类问题时,你能快速定位是配置解析的问题、驱动加载的问题,还是 SQL 映射的问题。这比死记面试题要有用得多——面试题问初始化流程,你背一遍就算了;线上报错时,你能不能顺着堆栈找到XMLConfigBuilder和XMLStatementBuilder,才是真本事。
5. 生产力之争:raw JDBC 和 MyBatis 的选择建议
5.1 实际选型:不是"谁更好",而是"你能 hold 住谁的复杂度"
现在回到标题里的 "productivity killer"。我的观点是:真正杀死生产力的不是技术选型本身,而是选型与需求规模不匹配。
先看什么样的情况适合用 raw JDBC:
- 项目极小(比如一个工具脚本、一个内部小报表),只有两三个 SQL,引入 MyBatis 反而显得重量级
- 数据库操作极度简单,没有复杂的动态条件、没有复杂的对象映射
- 或者你在写一个性能极其敏感的批量处理程序,希望绕开框架反射和映射带来的开销,完全掌控 SQL 执行路径
对于这类场景,raw JDBC 的样板代码是可控的。写两个 DAO、每个方法四五十行,半天搞定,引入 MyBatis 反而要配 XML、配驼峰映射、配插件,花费更多时间。这是"杀鸡用牛刀"的问题。不过即使这种情况,我也建议至少用 Spring 的JdbcTemplate或 DbUtils 这类轻量封装,把连接管理样板消掉一部分——裸 JDBC 连我自己都不想写第二次。
而一旦你进入以下任一场景,建议直接上 MyBatis:
- 业务表超过 20 张,CRUD 操作几十上百个
- SQL 经常需要根据条件动态拼接
- 团队多人协作,需要一个相对统一的数据访问风格
我刚才说过,MyBatis 的 XML 本身也会有样板代码(resultMap、sql 片段、分页 SQL 重复),但这些大多是"生成一次、改时再动"。而 raw JDBC 的样板代码是"每个方法都从头写一遍"。代数量级完全不同。
5.2 关于 MyBatis-Plus 和二三级缓存:进阶开发者常纠结的问题
很多团队现在直接用 MyBatis-Plus。它提供BaseMapper,连 XML 里最简单的 CRUD 都帮你写好了:
public interface UserMapper extends BaseMapper<User> { }然后你就可以用userMapper.selectById(id)、userMapper.selectList(wrapper)。连<select>都不用写了,实体类的字段会自动映射成列名。这块相当于把 MyBatis 的"新样板代码"又收敛了一层。说实话,对于普通后台管理系统的增删改查,MyBatis-Plus 的体验确实好很多。但 MyBatis-Plus 也不是没有问题:它的Wrapper链式写法虽然有文档,但当查询条件复杂时,可读性和动态性反而不如手写 XML 清晰。尤其是多表 join、子查询、复杂 group by,MyBatis-Plus 的通用方法作用有限,你还是得写自定义 SQL。
我的经验是:BaseMapper 负责 80% 的常规 CRUD,自定义 SQL 负责剩下的 20% 复杂查询。这样既保留了框架的效率,又没有丢掉 MyBatis 最强的动态 SQL 能力。
二级缓存这块,我的建议是保守使用。MyBatis 默认开启的是 SqlSession 级别的一级缓存;二级缓存在多个 SqlSession 之间共享,但一致性维护麻烦。除非你非常清楚自己的数据变更频率和缓存失效场景,否则宁可不开。我见过不止一个项目因为开了二级缓存,在"逻辑删除 + 多表关联"的组合操作里缓存错乱,最后只能清掉重来。缓存是优化手段,不是救命稻草,业务正确性永远是第一位。
5.3 不要神化框架,也不要妖魔化样板代码
最后说点掏心窝的话。
很多人有个误解,觉得用了 MyBatis 就不用写 SQL 了。这恰恰是反的——MyBatis 把你从"怎么写连接代码"的琐事里解放出来,是为了让你把精力花在"怎么写好 SQL"上。SQL 性能优化、索引设计、事务边界,这些依然是核心能力。MyBatis 解放的是生产力,不是思维。
样板代码本身也不全是坏的。它的存在其实保证了程序结构的一致性。JDBC 时代你复制粘贴 50 遍连接管理代码,虽然烦,但至少每个方法的结构都是规范的——只要最后一遍不漏资源。框架把这些样板收编之后,程序结构确实变简洁了,但对开发者的要求反而变了:你得理解框架的约定、理解参数映射规则、理解缓存失效条件。你不再被样板代码困扰,但你被框架概念困扰。
所以不管你是刚入门在学 raw JDBC,还是工作中天天写 MyBatis,我给你的建议都是同一句话:先把底层的样板代码亲手写一遍,理解它每一步在干什么,然后再去用框架。亲手写过Connection、PreparedStatement、ResultSet的完整生命周期之后,你再看到 MyBatis 的SqlSessionTemplate、MappedStatement、Executor这些概念,会觉得它们一点都不神秘——它们就是把你当年手写的那套流程,做得更标准、更高效、更可维护而已。
说句实在的,我到现在都记得第一次用 MyBatis 时的感觉:原来"查一个用户"真的只需要写一条 SQL。那种从生产线式的模板代码里解放出来的爽快感,是驱动我持续研究其背后原理的直接动力。也希望读这篇文章的你,能先踩一遍 raw JDBC 的泥泞,再去感受框架的轻快——那样的体验会完全不同。