☰
从JDBC到ORM:数据库访问技术演进与踩坑实录
2026/10/14 13:27:33 网站建设 项目流程

做后端这么多年,跟数据库打了无数交道。最近整理硬盘,翻出一个十年前的老项目,里面有一段手写的JDBC工具类,没有Spring Boot,没有MyBatis,连连接池都是自己用同步锁硬写的。看着那些泛黄风格的代码(其实是IDE配色问题),忽然想把这些年跟"数据库访问"这五个字纠缠的历史捋一遍。

这篇文章不是完整的框架教程,更像是一个老开发者的回忆录:从裸JDBC一路走到DAO、ORM、连接池、分布式中间件,把每个阶段我在做什么、踩过什么坑、为什么要换方案,说给你听。适合正在学Java/后端但对数据库访问底层逻辑还不太清楚的年轻人,也适合工作几年想回头复盘"数据库访问这件事到底是怎么演变的"的朋友。

1. 裸JDBC的野蛮年代

1.1 从一段老代码说起:JDBC的固定套路

先看一段我最早写的代码,那个年代的典型风格:

Class.forName("com.mysql.jdbc.Driver"); Connection conn = DriverManager.getConnection(url, user, password); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("select * from user where id = 1"); while (rs.next()) { String name = rs.getString("name"); System.out.println(name); } rs.close(); stmt.close(); conn.close();

你没看错,这差不多十行代码就是当时"数据库访问"的全部真相。每一次查询都要手动加载驱动、创建连接、创建语句、执行、遍历结果集,最后还要按顺序释放资源。最恶心的是这段代码几乎会原样出现在项目里每一个数据操作方法中,唯一的区别就是SQL字符串和后面绑定参数的逻辑不一样。

我翻了下当年那个项目的代码库,一个只有二十多张表的进销存系统,居然有接近两百个方法都在重复这套模板。有的方法里SQL是拼出来的,有的干脆把rs.getXxx()这一套也写了三遍。那会儿还没有什么Lombok,实体类和数据库字段的映射全是手工的,改一个字段名,像地震一样要波及十几个文件。用今天的眼光看,这种代码的维护成本高得离谱,但它确实真实地存在过。

1.2 为什么这门手艺曾经这么磨人:数据库访问的四个底层问题

回头看,所有数据库访问的演进,本质上都是围绕着四个底层问题打转:连接管理、语句执行、结果映射、资源释放。

  • 连接管理:每次访问数据库都要建立连接,而建立连接是一次完整的网络握手、鉴权、会话初始化的过程。在局域网内可能感觉不到,放到公网或高并发场景,这个开销就是致命的。
  • 语句执行:JDBC提供Statement、PreparedStatement、CallableStatement三种执行方式,绕不开SQL怎么写、参数怎么绑定。
  • 结果映射:关系型数据库查出来的是二维表结构,而业务代码里需要的是对象。字段名、类型、嵌套关系,都要开发者自己处理。
  • 资源释放:连接、语句、结果集都是有限的系统资源,不释放就会泄漏,泄漏到一定程度数据库就拒绝服务了。

这四个问题就是数据库访问的"永动机",后边每一个框架、中间件的出现,都是为了把这四件事从开发者手里接管过去。只不过接管的程度不同,有的只帮你管连接,有的连SQL都帮你生成。当时的我还不懂这些,只知道每次写查询都要小心翼翼,生怕哪个close()忘了写,心里就一直有个疙瘩。

1.3 那一代人的肌肉记忆:用不用PreparedStatement,是个态度问题

那个年代,很多人其实图省事直接写Statement执行拼接SQL,这在当时是巨大的安全隐忧。我碰过一档子事:一个查询条件上直接拼接了前端传进来的字符串参数,结果一个单引号就能让整张表的数据裸奔出去。

后来项目组内部立了个规矩:一律用PreparedStatement,参数一律用?占位。当时的理由主要是"能防止SQL注入",但其实PreparedStatement的好处远不止安全。它会让数据库预编译SQL,执行计划可以被复用,在循环批量执行时性能差距非常明显。

PreparedStatement ps = conn.prepareStatement( "select * from user where name like ?"); ps.setString(1, "%" + keyword + "%"); ResultSet rs = ps.executeQuery();

这条规矩我一直保持到今天,哪怕是写示例代码,也坚决不写字符串拼接SQL。这算是我这个年代的程序员留下的一条比较保守的肌肉记忆。

2. 第一次封装:连接工具类与DAO模式

2.1 把Connection塞进ThreadLocal:一场精妙的暂时性胜利

代码里散落的连接管理实在让人受不了,我很快写了第一个工具类,核心思路是维护一个Connection的全局持有者:

public class DBUtil { private static ThreadLocal<Connection> tl = new ThreadLocal<>(); public static Connection getConnection() throws SQLException { Connection conn = tl.get(); if (conn == null || conn.isClosed()) { conn = DriverManager.getConnection(url, user, password); tl.set(conn); } return conn; } public static void close() throws SQLException { Connection conn = tl.get(); if (conn != null) { conn.close(); tl.remove(); } } }

当时的出发点很简单:每个线程只需要一个连接,线和线之间互不干扰,于是ThreadLocal就成了完美的容器。这解决了两个问题:一是方法之间要共享同一个事务连接时,不用把Connection参数在七八层调用链里传来传去;二是线程内的所有操作天然落在同一个连接上,事务边界清晰了。

但这里有个隐患当时我没意识到:如果应用用了线程池,线程不会被销毁,ThreadLocal里的连接也不会被清理。一旦某个请求异常退出,没有调用close(),这个线程上的连接就会被一直占着。时间长了,连接池里的连接会被慢慢"染黑",全部耗尽。这个坑后面会细说,这里想强调的是:事物总是在解决一个问题的同时埋下下一个问题。

2.2 DAO模式的价值:把SQL从业务代码里隔离出去

光有工具类还不够,SQL还是散落在业务逻辑里。于是我又引入了DAO模式,每个业务实体对应一个接口加一个实现类:

public interface UserDao { User findById(Long id); List<User> findByName(String keyword); void insert(User user); } public class UserDaoImpl implements UserDao { @Override public User findById(Long id) { // 获取连接、执行PreparedStatement、手工映射ResultSet } }

Service层只依赖接口,不关心SQL细节;单元测试时可以轻松替换Mock实现;要换数据库只要换实现类,基本不用动业务代码。这套思想到今天依然不过时,你会在Spring Data、MyBatis等框架里看到它的影子。

不过DAO模式也有很绕的地方:每个实体一套DAO,每套DAO里有大量重复的模板代码。表多了以后,绝大多数工作量都消耗在"把ResultSet里的值取出来塞到对象里"这种极其机械的动作上。我当时写过一段挺低级的极端做法:用反射遍历实体类的字段,自动调用rs.getObject(fieldName)完成映射。

public <T> T map(ResultSet rs, Class<T> clazz) throws Exception { T obj = clazz.newInstance(); for (Field f : clazz.getDeclaredFields()) { f.setAccessible(true); f.set(obj, rs.getObject(f.getName())); } return obj; }

这个粗糙的动态映射工具,本质上就是早期ORM框架的雏形。只不过它没有考虑类型转换、字段命名不一致、嵌套对象等复杂问题。但当时用它省下来的时间,让我第一次真切感受到"把重复的事交给代码去做"的甜头。

2.3 连接为什么必须复用:new Connection的账单

说到连接管理,必须提一下为什么连接池是必然出现的。有人算过一笔账:建立一条数据库连接,至少涉及DNS解析、TCP三次握手、数据库服务端新建会话、鉴权校验,这一整套流程在繁忙时期能达到几十毫秒甚至更久。如果每次查询都重新建连、用完就断,不仅响应时间难看,数据库服务端也要不断处理新建会话的开销。

更现实的问题是数据库服务端有连接数上限。我当年接手过一个老系统,高峰期数据库报too many connections,一查发现代码里每个请求都是DriverManager.getConnection,没有复用。用户一多,连接数直接打穿上限。后来虽然改了共享连接,但单线程单连接的模式又带来新的并发瓶颈。最终正确解法就是连接池:预先创建一批连接,用完之后不销毁而是归还,谁需要谁去池子里取。这相当于把"每次重建"变成"来回借还",成本一下降了两个数量级。

3. ORM框架登场:从全自动到半自动的路线之争

3.1 告别手工映射:ORM到底替你干了什么

DAO模式解决的是"SQL不散落"的问题,但没有解决"每次都要写重复映射"。ORM的出现,就是为了把数据库表直接映射成对象,把ResultSet的取值逻辑交给框架完成。在这个阶段,我印象最深的几个词是映射关系、一级缓存、级联操作。你定义好实体类,配置好表关系,框架在运行时会帮你完成对象-关系映射,甚至根据实体状态自动生成SQL。

@Entity @Table(name = "user") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; }

这一段配置加上几句代码,就能完成以前十几行JDBC才能完成的插入和查询,开发效率提升非常明显。告别手工拼接结果集,告别一个字段一个getter/setter调用的日子,真有点从写信时代跨入电子邮件时代的感觉。

不过全自动ORM也不是没有代价。它隐藏了太多细节,在一段时间里,我一度以为SQL就是被框架自动生成的,不用关心执行效率。这个想法在数据量小的时候完全没事,等到表里几百万行数据、多表关联复杂起来,就慢慢开始尝到苦头。

3.2 全自动与半自动:Hibernate和MyBatis的本质区别

ORM领域有个经典的路线之分:全自动派代表(比如Hibernate或者后来的Spring Data JPA)主张"彻底干掉SQL",把数据库访问抽象成对对象图的操作;半自动派代表(比如MyBatis)主张"SQL还是要掌握在人类手中,框架只负责映射和参数绑定"。

全自动派的核心优势是开发效率高,尤其对于单表CRUD,几乎不用写SQL。坏处是遇到复杂查询时,你得学会用HQL/Criteria去驾驭框架,或者干脆退回去写原生SQL,这时候反而显得笨重。而且ORM的级联查询、懒加载策略,如果配置不当,很容易出现大量"隐式SQL",一个简单列表页背后可能跑了上百条查询。

半自动派的核心优势是SQL完全可控,执行计划也是你自己熟悉的。缺点是每个操作都要手写SQL,表结构字段一变,SQL也要同步维护。团队里要是SQL基础不扎实,写出慢查询的概率反而更高。

我个人的选择逻辑很简单:业务密集、查询复杂多变的项目,用半自动;表结构稳定、CRUD为主的内部系统,用全自动。但不管用哪套,都必须对底层SQL执行有清晰的感知。ORM不是银弹,它只是帮你省了手写映射的时间,物理上的表结构、索引、锁、事务,依然要你自己负责。

3.3 从自动到手动的一步退让:N+1查询带来的教训

我印象很深的一次性能事故,就是典型N+1问题。当时一个订单列表页面,需求是展示订单和下单人的用户昵称。用某个ORM框架实现时,逻辑很自然:先查出订单列表,然后遍历每个订单,去取关联的用户信息。听起来没什么问题,但数据量上来之后,页面接口活活要跑200多条SQL,一次请求慢到按秒计。

排查过程也很有代表性:打开框架的SQL日志,发现先有一条select * from order,然后循环200次,每次都是select * from user where id = ?。这就是经典的N+1。

解决思路三条线同步进行:

  • 第一个方案是改查询方式,把关联查询改成一次join或者一次in查询,把200次查询压成1条或2条;
  • 第二个方案是用二级缓存,但缓存一致性不好控制,小范围场景可以接受,大范围场景很容易出脏数据;
  • 第三个方案是合理配置懒加载策略,但伪懒加载在某些场合不仅没用,还会加剧问题。

那次之后我给自己立了条规矩:只要涉及列表查询,第一件事就是打开SQL日志,数一数一屏数据背后到底跑了多少条SQL。这个习惯让之后很多项目少走了很多弯路。

4. 连接池参数调优与踩坑记录

4.1 主流连接池选型:参数之外还要看可靠性

连接池几乎是现代数据库访问的标配。我接触过的连接池主要有三大类:早期流行过的c3p0和dbcp,后来的Druid,以及现在Spring Boot默认的HikariCP。

简单整理一下我的选型判断:

连接池特点适用场景
dbcp/c3p0老牌,配置繁琐,性能一般老项目维护,不太推荐新用
Druid自带监控面板、SQL拦截、慢日志,功能丰富需要监控、排查慢SQL的系统
HikariCP轻量、性能强、默认参数合理Spring Boot新项目首选

选型不能只看名气。HikariCP胜在字节码级优化,连接获取速度快;Druid胜在功能全面,尤其适合团队没有专职DBA、想靠可视化面板快速发现问题的时候。我见过不少团队为了"监控功能"选了Druid,结果监控面板开了半年也没人看一眼,这纯属资源浪费。选哪个,取决于你的团队有没有能力去利用连接池多出来的那些功能。

4.2 关键参数背后的原理:initialSize、maxActive、maxWait、minIdle

连接池参数是调优的重头戏,很多新手一看配置模板就照抄,出了事也不知道是哪个参数的问题。我先把几个关键参数讲透:

  • initialSize:启动时预先创建的连接数。设太小,刚启动时遇到流量高峰会手忙脚乱;设太大,冷启动成本高,而且数据库连接数容易被白白占着。
  • minIdle:池中保底连接数。低于这个值,连接池会尝试补充连接。它决定了低峰期池子里至少有多少条连接。
  • maxActive/maximumPoolSize:池中最大连接数。它决定了系统能同时承担多少个并发数据库操作,不是越大越好,因为每条连接都会占用数据库服务端资源。
  • maxWait:获取连接的最大等待时间。超过这个时间拿不到连接,就抛异常,防止请求无限阻塞。

合理的做法是根据服务的并发估算来设。假设一个服务高峰期每秒约500个请求,平均每个请求要访问数据库20毫秒,粗略估算并发访问数据库的连接数在10条左右,那maxActive设置在20到30之间就比较稳妥。注意给突发流量留一点缓冲,但不要无脑设成几百,否则数据库服务端先扛不住。

下面是一个相对合理的初始化配置(以Druid为例,但重点看参数含义):

spring: datasource: druid: initial-size: 5 min-idle: 5 max-active: 20 max-wait: 5000 validation-query: SELECT 1 test-while-idle: true

test-while-idle和validation-query的组合很关键:连接池里的连接如果长期空闲,可能被数据库服务端断开(受wait_timeout影响),下次取出来直接用就会报错。SELECT 1这种轻量探测语句能提前发现失效连接,把它清掉再补新的。

4.3 我踩过的三个连接池大坑

第一个坑是连接超时时间设得太长。之前有个接口在下游数据库发生故障时迟迟不响应,排查发现max-wait被某次改成了30000毫秒。数据库连不上时,所有请求都在池子里干等30秒,线程池一满,整个服务像得了脑血栓。后来把max-wait调整到3000毫秒,配合熔断之后,系统在故障时至少能快速失败,而不是连带拖垮所有线程。

第二个坑是连接池大小和线程池大小混淆。有哥们儿为了"性能好",给线程池配了200个线程,连接池却只有10条连接。结果200个线程里190个全堵在等待连接上。连接池大小取决于"同时活跃并发的数据库操作",而不是线程数量。线程池可以有几百个线程在忙计算,但真正同时发起JDBC调用的线程通常没有那么多,两者要分开估算。

第三个坑是忘记把连接归还。我在老项目里见过一个框架的封装,内部自定义了一个ConnectionProxy,却漏掉了close()的调用链,导致每次用完的连接其实没真正归还。症状非常典型:系统启动时一切正常,运行到下午高峰期突然大面积报connection is not available。最后是通过监控连接池的活跃数发现的,曲线一路向上不回头,指向性非常明显。后来加了一层切面,在方法出口统一做释放检查,问题才彻底消失。

5. 数据库访问的经典故障与排查实录

5.1 连接泄漏与连接超时:表面现象和根因经常不一样

连接泄漏是所有数据库访问故障里最容易迷惑人的一种。表象非常统一:请求越来越慢、连接池报waiting for connection、数据库端max_connections被打满。根因却千奇百怪,可能是代码路径里某个分支没释放连接,可能是连接池创建时配置不合法导致连接从没被检测,也可能是某个线程池异常退出后连接被threadlocal绑死。

排查的时候我一般按这个顺序来:先看应用端的连接池监控曲线,确认为什么在涨;接着在数据库端查当前会话数和来源IP,把有问题的连接轨迹定位到具体服务实例;再回到代码里,对着事务边界逐个检查有没有分支提前return却没清理连接;最后如果还找不到,就开启连接泄漏检测告警,让程序在连接未归还时打印异常堆栈。

这里补一个务实的建议:连接池的检测参数要开着,比如把removeAbandoned、logAbandoned这类开关打开,连接池会在连接被借出超时未归还时打印堆栈,一次性就能定位到业务代码的哪个方法出了问题。很多团队嫌这些开关有性能损耗就关掉了,但边际损失远比一次线上事故小得多。

5.2 慢查询与索引失效:一条SQL引发的血案

慢查询的问题,很多时候不是SQL语句本身的"形状"写错了,而是它没走索引。我遇到过一个比较经典的案例:业务表里有个下单日期字段,上面建了索引,但查询语句在字段上套了一层函数:

select * from order_info where date(create_time) = '2024-05-20'

看着很自然,对不对?但索引对字段做了函数计算之后,优化器基本无法走正常索引,只能老老实实把全表扫一遍。时间一长,这张表数据积累到几百万行,接口性能急转直下。

排查三步走:先打开慢查询日志,锁定SQL;然后explain看执行计划,重点看type列是不是从ref/range变成了ALL;最后改写SQL,把函数运算挪到等号右侧:

select * from order_info where create_time >= '2024-05-20 00:00:00' and create_time < '2024-05-21 00:00:00'

改完之后执行计划立刻回到走索引,接口响应时间从两秒多降到几十毫秒。这个案例给我最深的启发不是函数索引的技巧,而是排查慢查询,第一反应应该是看执行计划,而不是急着往SQL上加hint或者堆缓存。

5.3 大事务与锁等待:定时任务里埋着的暗雷

有一种故障特质很强:平时系统安稳,每到深夜定时任务跑完,白天的业务就时不时报锁等待超时。罪魁祸首通常是定时任务在同一个事务里做大量更新,事务迟迟不提交,把表或行上的锁攥了好几分钟。白天过来的请求一旦要操作同一批数据,就会卡在innodb_lock_wait_timeout上。

我当时处理过一个类似案例:一个对账任务,每天晚上拉取全量明细,逐条核对,核对完再逐条更新状态。由于整个方法都标注了事务注解,事务一直从第一条数据撑到最后一条,中间几万条数据更新都在这个事务里,毫不收敛。从数据库的information_schema.innodb_trx表里能看到一个事务持续了十几分钟。

解决方案不复杂,但要敢于动结构:

  • 先把"核对逻辑"和"更新逻辑"拆成两段,更新逻辑单独小事务处理;
  • 再改成分批提交,每500条一个批次,批次之间释放锁和连接;
  • 最后调整执行时间,尽量避开业务高峰。

此外还要注意,事务范围并不是越大越好。很多人习惯把整个方法和所有内部调用都包进事务,似乎是"严谨",实际上把锁的范围扩大到了本不需要的边界。事务只应该包裹真正需要原子性的写操作,查询、外部调用、耗时计算这些尽量放到事务外面。

5.4 参数化查询:一条绕不开的底线

说到数据库访问,永远绕不开SQL注入。虽然现在框架大多做了预编译,但在一些手写SQL模板拼接的场景里,依然有人会写出这种代码:

String sql = "select * from user where account = '" + account + "'";

如果account来自用户输入,一个' or 1='1就能让查询返回全表数据。正确做法是使用占位符:

String sql = "select * from user where account = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, account);

参数化查询除了防注入,还因为可以复用执行计划,循环执行时性能也更好。这条底线不管项目多急、工期多紧,都不能让步。我在代码评审里看到字符串拼接SQL,基本都是直接打回。不是说这样写一定会出事,而是没必要为了省几行代码就把系统的安全边界押在一个偶然上。

6. 分库分表与微服务时代的数据访问

6.1 数据量上来之后:读写分离与分库分表的访问模式变化

当单库单表扛不住流量和数据量,第一件事通常不是上分布式事务,而是做两层非常朴素的拆分。

第一层是读写分离。把主库负责写入、从库负责读取,应用层通过数据源路由在多个数据源之间切换。访问模式的改变很直接:你的代码不再面向"一个数据源"编程,而是面向"一组数据源"编程,路由规则、主从延迟问题都要考虑。

第二层是分库分表。按业务维度(比如用户ID、订单ID)把数据分散到多个库、多张表里。访问模式的变化更剧烈:单表的不再是select,而是要带上分片键去路由;跨分片的查询会变得非常痛苦,排序、聚合、批量更新都要重写思路。

这个阶段,手写DAO已经不够了,需要引入数据库中间件来屏蔽细节。分片规则一般会选一个业务上最稳定、分布最均匀的字段作为分片键,比如订单号或用户ID。选键的原则是:查询条件里必须高频带上这个键,否则你的查询会退回广播模式,所有分片扫一遍,性能比单库还差。

6.2 分布式事务:从强一致到最终一致

分库之后,原来单库的事务就失效了,因为你的一次操作可能涉及多个数据库。这个问题的复杂度属于另一个量级。常见的方案有几类:两阶段提交(2PC)强调强一致,但性能和可用性都被协议本身拖累;TCC要求业务方实现try-confirm-cancel三段逻辑,侵入性强,但是可靠;SAGA强调补偿机制,适合长流程业务,对业务设计能力要求更高。

我见过最稳妥的落地方式是:核心链路尽量把多个跨库操作收敛到同一个分片内,能在一个库里完成就绝不分到两个库,只有实在无法避免时才引入分布式事务框架。很多看起来需要分布式事务的场景,其实通过调整事务边界、数据聚合方式,是可以降级成单库事务或最终一致的。分布式事务每多一个参与方,故障概率和处理复杂性就翻一倍,能不用尽量不用。

6.3 框架会继续演进,但四个问题不会变

聊到未来,我不敢说自己看得多准。但数据库访问的这四件底层事——连接管理、语句执行、结果映射、资源释放,无论框架怎么变,都绕不开。今天的Spring Data、MyBatis-Plus帮开发者省掉了大量模板代码,甚至ShardingSphere这类中间件连分库分表都透明化了,但底层的连接池、事务、SQL优化,依然是决定系统上限的地方。

我见过很多新人上手就是MyBatis-Plus的insert()、selectById(),觉得数据库访问太简单了。直到线上出了问题,才发现自己对底层怎么执行SQL、连接怎么管理,完全没有概念。我的态度始终是:框架可以用,但框架替你做的事,你至少要知道它大概是怎么做的。这样当它表现出让你困惑的行为时,你才有地方可以排查。

写到这里,我又想起当年那些手写JDBC的日子。现在回头看,那段"低效"经历反而是最宝贵的训练,它让我在之后用任何框架时都保持着一种敬畏心:数据库访问从来不是配置一个数据源就能高枕无忧的事,连接、事务、SQL、并发,每一个环节都在暗处等着出错。

如果你还在学习阶段,我的建议是:找个老项目,哪怕手写一遍DAO的增删改查,把连接管理、资源释放、结果映射这几个环节亲手过一遍。这个笨功夫花不了太久,但能让你在用框架时,知道背后发生了什么。日后线上再遇到数据库访问的问题,你心里会有一个清晰的排查地图,而不是只能对着监控面板发愣。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询