
提到JDBC驱动和Servlet容器很多刚入行的Java开发者会觉得这是两个再基础不过的概念甚至觉得老掉牙了。但我做了这么多年Java Web开发面试过不少人也接手过不少烂摊子发现真正把这两块吃透的人其实不多。很多人能把Spring Boot项目跑得飞起但问他Mysql驱动在JDBC里扮演什么角色Tomcat容器到底在他请求的哪一环做了什么事往往答不上来。这篇就围绕这两个主题把我实际开发中总结的经验、踩过的坑、以及为什么理解了它们就能解决很多奇怪线上问题全部摊开聊一聊。内容适合所有写Java后端的朋友不管是刚学Servlet的菜鸟还是用了好几年框架的老手相信都能找到点有用的东西。1. JDBC驱动通往数据库的第一道大门1.1 驱动到底是什么简单说JDBCJava Database Connectivity是Java官方定义的一套访问数据库的接口规范它本身只是一堆接口没有任何实现。真正干活的是各个数据库厂商提供的驱动比如Mysql的com.mysql.cj.jdbc.DriverPostgreSQL的org.postgresql.DriverOracle的oracle.jdbc.OracleDriver。打个比方JDBC接口是通用的三脚插座标准驱动就是插在插座上的各种电器插头。插头必须符合插座标准才能通电驱动必须实现JDBC接口才能被Java程序调用。你换数据库时程序代码基本不用动只需要换掉驱动依赖和连接字符串即可这正是JDBC存在的意义。驱动内部做的事情说起来其实不复杂建立一条到数据库的物理网络连接通常走TCP把你的Java方法调用翻译成数据库能懂的协议指令比如MySQL的客户端/服务端协议把数据库返回的二进制结果集转换成Java对象比如ResultSet、RowSet处理游标、事务、预处理语句等底层细节驱动分几种类型最常见的是纯Java实现的那种Type 4驱动直接通过Socket连接数据库不需要本地代码库跨平台部署非常方便。现在主流的MySQL、PostgreSQL驱动基本都是Type 4。1.2 类加载与DriverManager的机制很多人在项目里都不写Class.forName(com.mysql.cj.jdbc.Driver)这行代码了因为现在的JDBC 4.0以上版本支持SPI自动加载。但如果你不理解类加载机制还是会踩坑。驱动里的Driver实现类通常会有一个静态代码块static { try { DriverManager.registerDriver(new Driver()); } catch (SQLException e) { throw new RuntimeException(Failed to register driver!, e); } }当JVM加载这个类时静态代码块执行驱动实例自动注册到DriverManager。JDBC 4.0之后驱动包的META-INF/services/java.sql.Driver文件里声明了驱动类全限定名DriverManager初始化时会通过ServiceLoader机制自动加载它。这就是为什么你几乎不需要手写Class.forName的原因。但要注意一个冷知识如果你把驱动Jar包放在容器比如Tomcat的lib目录同时又在应用里也放了一份就可能出现驱动类被加载两次、DriverManager里注册了两个相同驱动实例的情况。虽然通常不会报错但在某些严格场景下比如反序列化、类比较时会引发诡异问题。1.3 驱动版本选择真的不能大意我在真实项目里见过太多因为驱动版本不合适导致的线上故障。比如MySQL 8.0的驱动早期版本mysql-connector-java 5.x连接MySQL 8.0时如果不调整认证插件配置直接报Unable to load authentication plugin caching_sha2_password。那个年代很多人被这个错误折磨过解决办法无非两种把数据库用户的认证插件改回mysql_native_password或者升级驱动到8.x。我个人建议是升级驱动因为改认证插件虽然方便但长期来看旧的认证插件安全性不如新插件而且迟早要升级。驱动版本选择还要注意大版本号尽量和数据库大版本保持一致比如连MySQL 8.0就用8.x驱动连MySQL 5.7用5.1.x或8.x注意参数差异驱动版本和JDK版本有隐性关联比如新版MySQL驱动要求JDK 8及以上老的JDK 7项目硬上8.x驱动会报UnsupportedClassVersionError生产环境优先使用GA版本不要用SNAPSHOT或刚发布的初版实操心得升级驱动后一定要在测试环境回归一遍连接池的创建、连接获取、预处理语句、事务提交回滚这几个核心路径。不要光用SELECT 1测通就完事驱动升级引发的问题往往在复杂SQL或者大批量操作时才会暴露。2. Servlet容器请求的接驳站和处理中枢2.1 Servlet容器在整个请求链路中的位置浏览器发一个HTTP请求到后端经过DNS解析、TCP握手、HTTP报文解析最后到达你的业务代码。那从网络报文到你写的Java方法之间谁在做苦力答案是Servlet容器。业内最常用的Servlet容器就是Tomcat、Jetty、Undertow。Spring Boot内嵌的默认容器就是Tomcat。容器负责的事比你想象的多得多监听端口、接受TCP连接解析HTTP请求头、请求体、Cookie等封装成HttpServletRequest对象创建一个HttpServletResponse对象供后端写响应根据URL映射规则找到对应的Servlet或Spring MVC的DispatcherServlet调用其service()方法管理Servlet的生命周期加载、初始化、服务、销毁处理线程池、连接复用、Keep-Alive、异步请求等底层细节类加载器的隔离管理保证不同应用之间的依赖互不冲突如果你用原生Servlet写过接口你就会发现写在doGet()、doPost()里的业务逻辑只是整个链路里最薄的一层。真正复杂的HTTP解析和连接管理全被容器干完了。2.2 线程模型是性能的关键Tomcat的经典IO模型常被误传这里说个清楚。Tomcat有BIO阻塞IO和NIO非阻塞IO两种模式。现在默认是NIO即用少量线程处理大量连接避免了一个连接一个线程的资源浪费。NIO模式下Poller线程负责扫描所有Socket事件发现某个连接有数据可读时就把这个Socket分配给一个工作线程Worker Thread然后由工作线程执行Servlet业务逻辑。工作线程执行完响应写入Socket线程归还线程池。理解这个模型有什么用非常有价值。你调优Tomcat时配的maxThreads就是工作线程池上限。如果业务逻辑里有慢SQL、外部API调用这些线程会被长时间占用池子满了之后新请求就会排队。有个经典案例某个系统一到高峰期接口就变慢但CPU、内存都正常数据库压力也不大。后来一看Tomcat线程池线程全部卡在调用外部第三方HTTP接口上那接口自身响应要5秒线程被拖死。解决方案是把外部调用改成异步方式或者用单独的线程池隔离避免拖垮Tomcat的工作线程。2.3 Servlet生命周期和容器的启动加载顺序Servlet的生命周期有明确规范容器启动时或首次请求时加载Servlet类实例化对象调用init()方法之后每次请求调用service()方法容器关闭时调用destroy()方法。这里有个开发中很常见的坑在init()里做了耗时操作比如初始化数据库连接池但加载时机设置成首次请求时load-on-startup不设置或为负数那么第一个访问这个接口的用户会非常慢等的时间可能就是init()执行的时间。要想让Servlet在容器启动时就初始化用load-on-startup1/load-on-startup配置数字越小优先级越高。在Spring Boot里如果你想在应用启动时做一些初始化工作更优雅的方式是实现ApplicationRunner或CommandLineRunner接口。另外一个常见问题容器关闭时destroy()里没有正确释放资源导致数据库连接池里的连接没有关闭在开发环境可能看不出来但在频繁重启的生产环境数据库端会积累大量僵尸连接直到把数据库连接数打满。3. 实际开发中两者的深度绑定3.1 连接获取从DriverManager到DataSource早期Java开发里拿到数据库连接的办法就是Connection conn DriverManager.getConnection(url, username, password);这种方法直来直去但不适合生产。原因很简单DriverManager.getConnection每次都会创建一个物理连接频繁创建和销毁数据库连接的开销非常大。生产上必须用连接池而连接池的核心就是DataSource接口。不管用的是DBCP、C3P0、HikariCP还是Druid对外暴露的都是DataSource。为什么连接池能大幅提升性能原理和线程池一样池里预先创建一批物理连接业务代码用的时候从池里借borrow用完了归还return而不是关闭物理连接。这样省掉了大量TCP握手和数据库认证的开销。注意事项来了连接池中的连接是共享资源如果你在代码里写了Connection conn dataSource.getConnection(); // 业务逻辑 // 忘了close连接永远不会归还给池子池子里的可用连接越来越少最终池子耗尽所有线程都在等待获取连接系统直接卡死。排查这种问题你要看连接池活跃连接数是否一直居高不下同时看代码里是否有连接从未释放。3.2 事务边界控制谁管连接谁管事务事务和连接是强关联的——一个事务必须在一个连接上完成事务的提交和回滚都是通过Connection接口来操作的。所以分布式事务的难点也在这里多个数据库连接无法共享同一个本地事务。在Servlet JDBC编程模式中事务控制可以这样实现Connection conn dataSource.getConnection(); try { conn.setAutoCommit(false); // 多个SQL操作 conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); }这种写法在纯Servlet阶段没问题但用Spring的Transactional之后事务边界就交给Spring管理了。有一点必须明白Spring事务管理的核心原理就是把数据库连接绑定到当前线程上TransactionSynchronizationManager在这个线程中执行的DAO操作都拿同一个连接从而确保事务一致性。这个机制和Servlet容器有什么关系非常相关。Tomcat的工作线程池里每个线程处理完一个请求后会归还线程池而Spring在请求处理完时清理当前线程的事务资源和连接绑定。万一有异常导致清理没执行或者说你用了ThreadLocal又没清理就会出现连接泄漏和事务串号的诡异问题。3.3 类加载器的恩怨容器级依赖与应用级依赖Tomcat采用了父委托机制但和JVM默认的双亲委派不完全一样。Tomcat的Web应用类加载器会优先加载WEB-INF/classes和WEB-INF/lib里的类然后才委托给父类加载器。这就解释了为什么你经常遇到ClassNotFoundException或NoSuchMethodError但排查半天找不到原因你的应用里有一个老版本的库Tomcat的lib目录里有一个新版本的库而某些类在应用类加载器加载某些又在公共类加载器加载版本错乱导致方法签名对不上。JDBC驱动正好是个典型场景。正确做法是把JDBC驱动放在容器级类加载器能加载到的位置Tomcat的lib目录而应用里不要放。原因在于DriverManager的getConnection是驱动注册在哪个类加载器加载的其实有讲究但主流做法是驱动放在容器共享目录连接池自己管理驱动加载则另说。更简单粗暴的方法是直接弃用DriverManager统一走DataSource让连接池自己负责驱动加载和连接管理。4. 从构建到部署实操指南与经验总结4.1 在传统Web应用中配置Servlet容器和JDBC尽管现在大多数新项目都用Spring Boot内嵌容器但理解和掌握传统的Web应用部署仍然非常必要。很多公司遗留系统还是war包方式部署在独立Tomcat里面试时也常问这个问题。传统部署有六个关键环节创建一个Maven Web项目打包类型设为war编写Servlet类继承HttpServlet重写doGet或doPost方法在web.xml中注册Servlet及其URL映射或用注解WebServlet在pom.xml中加入JDBC驱动依赖比如连接MySQL用mysql-connector-java把war包扔到Tomcat的webapps目录下启动Tomcat自动解压部署注意驱动Jar包和容器自带Jar包的冲突一个最简单的Servlet代码WebServlet(/user/list) public class UserListServlet extends HttpServlet { private DataSource dataSource; Override public void init() throws ServletException { // 在init中初始化连接池避免首次访问慢 HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/test?useSSLfalseserverTimezoneAsia/Shanghai); config.setUsername(root); config.setPassword(123456); config.setMaximumPoolSize(20); dataSource new HikariDataSource(config); } Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType(application/json;charsetUTF-8); String sql SELECT id, name FROM t_user LIMIT 10; try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { StringBuilder json new StringBuilder([); while (rs.next()) { if (json.length() 1) { json.append(,); } json.append({\id\:).append(rs.getInt(id)) .append(,\name\:\) .append(rs.getString(name)) .append(\}); } json.append(]); resp.getWriter().write(json.toString()); } catch (SQLException e) { resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); resp.getWriter().write({\error\:\database error\}); log.error(Query user list failed, e); } } }注意init()方法里初始化连接池的写法这比在doGet里每次创建连接靠谱得多。用try-with-resources确保连接、语句、结果集都被关闭。4.2 Spring Boot中的内嵌容器和驱动管理Spring Boot项目写起来简单是因为框架把复杂性封装在底层了。你加入spring-boot-starter-web内嵌的Tomcat自动配好你加入mysql-connector-j依赖自动拉下来你再配置spring.datasource.url、spring.datasource.username、spring.datasource.password连接池默认HikariCP自动创建。但正因为封装得好出了问题也更难排查。最常见的翻车现场场景一MySQL驱动包没加或版本不对。启动日志里出现Cannot load driver class: com.mysql.cj.jdbc.Driver。检查pom.xml里有没有dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency场景二时区问题。连接URL里没指定serverTimezone报The server time zone value *** is unrecognized。解决办法就在URL里加serverTimezoneAsia/Shanghai。场景三连接池配置太小。默认maximum-pool-size是10如果你系统并发较高又没调大请求会大量排队等待连接。配置项直接写spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 300004.3 Tomcat运行时的关键参数调优接线到这一个大块必须聊一下Tomcat的线程池参数。无论Spring Boot内嵌Tomcat还是独立Tomcat你去调整server.tomcat.threads.maxSpring Boot或maxThreads独立Tomcat都是在调整同一批工作线程的数量。参考经验值业务逻辑以IO为主查数据库、调接口线程数可以设到200~400业务逻辑以CPU计算为主复杂计算、加解密线程数建议不要超过CPU核心数的2倍线程数不是越大越好线程切换有开销且线程太多会导致连接池、数据库、下游服务压力骤增server: tomcat: threads: max: 300 min-spare: 20 accept-count: 100 max-connections: 10000accept-count是等待队列长度。当工作线程满后新连接会进队列等待。如果队列也满了后面的请求会被拒绝。这些值要根据实际压测结果来调不要凭感觉拍脑袋。4.4 容器和驱动的监控与排查利器线上出了问题别急着敲代码先用工具看清楚现状JVisualVM / VisualVM可以看到Tomcat工作线程池的状态线程是RUNNABLE还是WAITING还是TIMED_WAITING一目了然。Arthas阿里巴巴开源的Java诊断工具。线上排查神器比如你想看某个接口到底卡在哪行代码用trace命令你想看某个类的实际加载路径用classloader命令。JDBC监控连接池一般都有监控指标HikariCP可以通过MBean暴露ActiveConnections、TotalConnections、IdleConnectionsDruid有专门的监控页面。平时多盯一个指标等待获取连接的时间connectionTimeout相关的统计数据。如果这个值开始增大说明连接池快不够用了要么是流量涨了要么是存在连接泄漏。4.5 常见故障排查清单整理一份我工作中遇到的高频问题速查表问题1启动报Unable to load authentication plugin caching_sha2_password。快查驱动版本8.x驱动对MySQL 8.0默认认证插件才兼容。问题2No suitable driver found for jdbc:mysql://...。要么驱动包没打入要么DriverManager无法加载驱动检查Classpath或者改用DataSource方式连接。问题3Too many connections。数据库端连接数被打满查看连接池最大连接数、应用的连接是否泄漏、是否有其他服务把数据库连接抢占完了。临时可以调大数据库max_connections但根本办法是定位哪个客户端在泄漏连接。问题4Connection is closed或Broken pipe。可能原因数据库超时杀掉了空闲连接而连接池没有正确检测和剔除。HikariCP有maxLifetime和connectionTestQuery配置确保池里的连接是健康的。问题5应用启动时卡住不动。检查Tomcat初始化进程看是不是某个Servlet的init()方法阻塞了比如连了一个不通的数据库、调了一个不通的中间件。定位方式jstack 打印线程栈看线程卡在哪个调用上。问题6ClassNotFoundException: org.springframework.web.servlet.DispatcherServlet。典型的war包部署时Spring相关的Jar包没有打入WEB-INF/lib或者容器类加载器顺序异常。检查打包插件配置。5. 写在最后吃透底层才能不慌做Java Web这些年我越来越觉得很多东西表面上是框架帮你搞定的但真是出了问题框架帮不了你能帮你的只有自己对这个底层机制的理解。JDBC驱动不是简单的依赖包它是Java程序连接数据库的契约和通道理解了驱动加载、连接池管理、事务与连接的绑定关系SQL层面的问题基本都有方向。Servlet容器不是简单的启动器它是请求从网络到业务的翻译官和调度中心理解了线程模型、生命周期、类加载隔离Web层面的问题也都能按图索骥。如果你正在学Spring Boot我建议你反着学先回到原生的Servlet和JDBC自己写一个不用框架的Web应用再回过头来看框架帮你做了什么。这个过程里的恍然大悟比看十篇教程都有用。最后的实操建议把依赖清单列清楚知道每个Jar包的来龙去脉把配置项弄明白每改一个参数都清楚它影响的是哪一层把监控用起来线上系统必须能看到连接池、线程池、GC这几类核心指标。做到这三件事Java Web开发里大部分疑难杂症在你面前都不过是例行排查而已。