
聊到Servlet数据库访问很多人的第一反应是这不是上古时代的写法吗别急着关页面。我在实际项目里见过不下十种数据库访问姿势从最原始的JDBC到带连接池的DAO再到套上各种ORM每一种都有它存在的理由。这篇就把Servlet场景下的数据库访问彻底讲透从JDBC底层链路到连接池选择从分层设计到实战代码再到排查高频故障一次说清。如果你正在维护老项目、在小规模Web系统里做后端接口或者刚开始学Java Web但被各种框架绕得云里雾里这篇文章应该能省下你不少试错时间。1. Servlet做数据库访问到底是被淘汰还是被低估1.1 Servlet在Web请求链路里的真实位置Servlet本质上是一段跑在Servlet容器里的Java代码负责接收HTTP请求、处理业务、生成HTTP响应。它的生命周期非常简单容器启动时加载请求来了调用service方法容器关闭时销毁。在Spring MVC这类框架还没流行的年代几乎所有Java Web系统都是直接在Servlet里写业务逻辑其中就包括打开数据库连接、执行SQL、拼HTML输出。这套代码不是不能跑而是很容易长成一团乱麻。一个LoginServlet里既有参数校验又有SQL拼串还有HTML片段三件事混在一起后续改任何一处都提心吊胆。但反过来看Servlet本身并没有错它只是Web层的一个入口。真正的问题是很多人把“数据库访问”这件底层的事直接塞到了入口里导致连接管理、事务控制、SQL逻辑全都没有边界。现在很多新项目看起来不用Servlet了但框架底层翻来覆去还是那套东西监听HTTP请求调用某个方法返回结果。所以说Servlet被淘汰不如说是“直接在Servlet里裸写JDBC”的做法被淘汰了。1.2 什么场景下你仍然绕不开Servlet至少三类场景绕不开Servlet。第一类是历史项目的维护。很多运行了好几年的系统还是Servlet加JSP没有引入复杂框架。你要改一个查询条件就得打开Servlet文件顺着doGet或doPost往下读。这时候不懂Servlet和JDBC的底层关系连问题都定位不准。第二类是轻量级接口服务。一个内部小系统、一个硬件设备上报接口或者一个只用返回少量JSON的服务纯Servlet加数据库连接池反而是最直接、最少依赖的方案。项目启动快部署简单没有一堆框架配置要维护。第三类是学习框架前的必修课。Spring MVC封装了太多细节参数绑定、事务代理、数据源管理。如果不理解Servlet如何拿请求参数、如何获得数据库连接、如何控制事务提交出了诡异问题会非常被动。所以我的观点很直接Servlet数据库访问不是一个“淘汰技术”而是一块必须补的底层地基。2. JDBC访问数据库的核心链路先把它走通再说2.1 一次完整查询的六个步骤JDBC给Java程序提供了一套统一访问数据库的接口。不管底层是哪种数据库你面对的都是一样的Connection、Statement、ResultSet。一个最基本的查询流程可以拆成六步加载并注册数据库驱动类。通过DriverManager或DataSource获取Connection连接。创建Statement或PreparedStatement。执行SQL得到ResultSet。遍历ResultSet封装成Java对象。逆序关闭ResultSet、Statement、Connection。代码写出来大概是这个样子Connection conn null; PreparedStatement ps null; ResultSet rs null; try { Class.forName(com.mysql.cj.jdbc.Driver); conn DriverManager.getConnection(url, username, password); ps conn.prepareStatement(SELECT id, nickname FROM t_user WHERE username ?); ps.setString(1, username); rs ps.executeQuery(); while (rs.next()) { User user new User(); user.setId(rs.getLong(id)); user.setNickname(rs.getString(nickname)); } } catch (SQLException e) { // 处理异常 } finally { if (rs ! null) rs.close(); if (ps ! null) ps.close(); if (conn ! null) conn.close(); }很多资料会在第一步写Class.forName但在JDBC 4.0之后只要驱动jar包在classpath里DriverManager会自动通过SPI机制加载驱动这一步不写也能跑。真正报“No suitable driver”的时候多半是jar没打进去或者URL写错了而不是少写了Class.forName。2.2 为什么PreparedStatement是默认选择同样一条SQL可以用Statement直接拼字符串也可以用PreparedStatement先用问号占位。我见过不少新手图省事选择前者Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery( SELECT * FROM t_user WHERE username username );这种写法最大的问题不是性能而是注入。username传一个 OR 11整张表都能被带出来。更严重的写法是username x; DROP TABLE t_user; --一次请求就能把表删掉。PreparedStatement把SQL结构和参数分离参数通过setString传给数据库数据库端会把参数当作纯数据来处理。这样既避免了拼串导致SQL语法被破坏也天然挡掉了注入攻击。另外PreparedStatement在多数数据库上还会预编译SQL同样一条SQL重复执行时可以减少解析开销。这就是为什么默认选择永远是PreparedStatement。2.3 资源关闭的顺序常被写反JDBC里的Connection、Statement、ResultSet都是资源不关闭就会造成连接泄漏。关闭顺序有讲究先关ResultSet再关Statement最后关Connection。原因是ResultSet依赖StatementStatement依赖Connection如果先关Connection底层连接可能还没把结果集处理完就断开资源释放也不干净。Java 7之后可以用try-with-resources简化String sql SELECT id, nickname FROM t_user WHERE username ?; try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { return new User(rs.getLong(id), rs.getString(nickname)); } } }注意ResultSet单独包了一层try这样三个资源都能按正确顺序关闭。用连接池时conn.close()并不是真正断开数据库而是把连接归还给连接池。这也是很多人容易误解的地方连池之后close变“归还”了该关还得关不关池子会被耗尽。3. 连接池高并发下的第一道保命符3.1 直连到底慢在哪如果不使用连接池每次请求都走DriverManager.getConnection。这条路的开销远超你想象TCP三次握手、数据库认证、分配会话资源一套流程下来少说几十毫秒。对于访问量稍大的接口这几十毫秒会直接拖垮吞吐量。更麻烦的是直连没有并发上限控制。请求一多数据库连接数可能瞬间冲到几百上千把数据库服务器的线程和内存资源全部占满最后整个系统雪崩。连接池干的事很简单提前创建一批连接放在池子里请求来了直接借用完还回去。池子还会控制最大连接数超过上限就让请求排队等待。本质上和餐厅提前备好餐具、客人吃完洗好复用是一个逻辑。3.2 DataSource的正确打开方式在Java里连接池通常表现为DataSource。你不用再关心Class.forName和DriverManager只需要从DataSource里拿ConnectionConnection conn dataSource.getConnection();DataSource的生命周期应该和应用一致项目启动时创建项目关闭时销毁。千万不要在每个Servlet里new一个DataSource那样等于不用连接池每次请求还要额外创建池对象。一个比较稳的做法是在应用启动阶段初始化一个全局的DataSource比如在某个初始化类里统一创建或者干脆放到ServletContext的attribute里。伪代码如下public class AppInitializer { public static DataSource dataSource; public static void init() { // 创建连接池 // 配置初始连接数、最大连接数、连接超时时间 } }选择连接池实现时优先看三个指标连接复用是否稳定、拿连接超时机制是否完善、监控和日志是否友好。成熟的开源连接池实现都很接近真正拉开差距的是你对参数的理解。3.3 初始化连接池的最佳时机连接池不要等到第一个请求来了才初始化。有些连接池实现支持懒加载但高并发场景下第一波请求会同时去创建连接出现短暂的连接创建风暴。最好在应用启动阶段就预热一批连接把初始化时间放在启动过程里而不是用户点击时。同时要给连接池设置合理的存活检测。数据库连接长时间空闲可能被网络设备或数据库服务端断开连接池里的连接实际上已经失效。如果拿到的是一条死连接第一条SQL就会直接抛异常。常见的处理方式是定期发送一个轻量查询来验证连接可用性比如SELECT 1。参数到底怎么配取决于你的数据库连接空闲超时设置不是随便拍脑袋填。4. 分层设计把SQL从Servlet里拆出去4.1 为什么不能把SQL堆在Servlet里直接在Servlet里写SQL短平快但代价明显。第一是复用难。两个接口都要查用户信息SQL只能复制粘贴。后面加一个筛选条件你得记得把所有复制过的地方都改一遍。第二是测试难。你想验证某条SQL的查询结果是否正确必须先启动Servlet容器、发HTTP请求才能看到输出。如果SQL在DAO层写个普通测试就能直接调。第三是事务边界混乱。一个Servlet方法里可能要更新用户表、更新订单表、写日志表这三步必须在一个事务里。如果每步都从连接池各取各的连接那就不叫一个事务而是三个独立事务中间出了错根本没法治。所以我会在所有项目里强调这个原则Servlet只做HTTP协议解析和响应输出业务逻辑下沉到Service数据库操作下沉到DAO。4.2 DAO加Service的组合方式先看DAO层。DAO的职责很单一一个方法对应一条SQL操作。拿用户查询举例public class UserDao { private final DataSource dataSource; public UserDao(DataSource dataSource) { this.dataSource dataSource; } public User findByUsername(String username) throws SQLException { String sql SELECT id, nickname FROM t_user WHERE username ?; try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { User user new User(); user.setId(rs.getLong(id)); user.setNickname(rs.getString(nickname)); return user; } } } return null; } }Service层把多个DAO调用组合起来处理事务和业务规则public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao userDao; } public User login(String username, String password) { User user userDao.findByUsername(username); if (user ! null checkPassword(password, user.getPasswordHash())) { return user; } return null; } }Servlet层只负责参数获取和响应组装WebServlet(/user/login) public class UserLoginServlet extends HttpServlet { private final UserService userService new UserService(new UserDao(Db.getDataSource())); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); User user userService.login(username, password); resp.setContentType(application/json;charsetUTF-8); if (user ! null) { resp.getWriter().write({\success\:true,\nickname\:\ user.getNickname() \}); } else { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); resp.getWriter().write({\success\:false}); } } }这样的分层结构里每个类都只有一个职责。Servlet不知道SQL怎么写的DAO不知道HTTP参数怎么拿的Service在中间做业务协调。维护和测试都会舒服很多。4.3 连接从哪来到哪去事务边界放哪个层分层之后还得想清楚连接归谁管。最忌讳的是每个DAO方法内部自己getConnection因为这意味着每次DAO调用都是一个独立事务。如果Service层需要两步操作保持同一事务就必须保证两步用的是同一条连接。常见的做法是把事务控制在Service层。Service拿到连接后设置setAutoCommit(false)执行完所有DAO操作再commit()出错就rollback()。DAO方法不再自行获取连接而是接收外部传入的Connectionpublic void updateUser(Connection conn, User user) throws SQLException { try (PreparedStatement ps conn.prepareStatement( UPDATE t_user SET nickname ? WHERE id ?)) { ps.setString(1, user.getNickname()); ps.setLong(2, user.getId()); ps.executeUpdate(); } }这样连接从Service层传入SQL执行完关闭PreparedStatement但Connection的提交和关闭由Service控制。需要注意的是如果DAO里的try-with-resources把Connection也包进去那连接会在DAO方法结束时被关掉或者归还连接池事务直接“断开”。这也是很多事务失效问题的根源之一。5. 实战一个登录查询接口的完整落地5.1 项目骨架和依赖我习惯用一个最普通的Maven Web项目来演示因为这样最接近Servlet的真实运行环境不会被框架的自动化处理掩盖细节。pom.xml里至少要有两个依赖Servlet API和数据库驱动。Servlet API在部署时由容器提供所以用provided作用域数据库驱动要打包进WAR用默认的compile作用域。为了避免版本带来的干扰建议直接用你手头Servlet容器对应的Servlet API版本。项目结构大致如下src/main/java下面放Servlet、Service、DAO类。src/main/webapp下面放WEB-INF/web.xml即使使用注解配置保留web.xml在迁移部署时会更灵活。数据库连接信息放在配置类或者配置文件中不要硬编码在每个类里。5.2 DAO、Service、Servlet三层的代码长这样我不想贴一个假大空的项目就按一个最简单的“用户名加密码登录”逻辑来讲。先是最底层的数据库访问public class UserDao { private final DataSource dataSource; public UserDao(DataSource dataSource) { this.dataSource dataSource; } public User findByUsername(String username) throws SQLException { String sql SELECT id, username, password_hash, nickname FROM t_user WHERE username ?; try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { return User.fromResultSet(rs); } } } return null; } }然后是Service层。登录逻辑要清晰先按用户名查用户再校验密码。密码校验不是简单的字符串equals实际项目里一定是对比哈希值public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao userDao; } public User login(String username, String password) { if (username null || username.trim().isEmpty() || password null || password.isEmpty()) { return null; } User user userDao.findByUsername(username.trim()); if (user null || !user.verifyPassword(password)) { return null; } return user; } }最后是Servlet层只做HTTP参数处理和JSON响应WebServlet(/user/login) public class UserLoginServlet extends HttpServlet { private UserService userService; Override public void init() throws ServletException { userService new UserService(new UserDao(Db.getDataSource())); } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); User user userService.login(username, password); resp.setContentType(application/json;charsetUTF-8); if (user null) { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); resp.getWriter().write({\success\:false}); return; } resp.getWriter().write({\success\:true,\nickname\:\ user.getNickname() \}); } }注意我在Servlet里覆写了init()方法在这里初始化Service。init()在整个Servlet生命周期里只执行一次比放在doPost里每次创建对象要更合理。Service和DAO都是无状态对象可以在多个线程间安全复用。5.3 跑起来之后先验证这三件事项目部署到Servlet容器后不要着急看业务结果。先用三个最小请求把链路验证通。第一验证驱动和连接池。启动时如果连接池能成功创建说明数据库地址、账号、密码都没问题。如果启动不报错但第一个请求报“Connection refused”或者“Access denied”问题多半在数据库的用户授权和网络可达性。第二验证参数编码。POST请求如果没有在读取参数前执行setCharacterEncoding(UTF-8)中文用户名很容易变成乱码。这个设置必须在getParameter()之前调用顺序反了这次请求的编码就救不回来了。第三验证SQL执行。可以用数据库日志或者连接池的SQL日志看PreparedStatement最终执行的SQL参数到底是什么。不要把日志打在大字段上但对于这种登录查询打印参数能很快定位是业务传参出错还是SQL条件写错。6. 高频故障排查从驱动加载到连接泄漏6.1 ClassNotFoundException和No suitable driver新手第一次跑Servlet数据库访问最常见的报错就是ClassNotFoundException: com.mysql.cj.jdbc.Driver。这个异常说明数据库驱动类在JVM的classpath里找不到。排查顺序肯定是先看WAR包或classes目录里有没有驱动jar。使用Maven时最常见的原因是把驱动的依赖作用域写成了provided。这样驱动jar不会打包到WEB-INF/lib运行时自然找不到。另一个容易忽略的问题是多个应用共用Servlet容器的lib目录容器类加载器虽然能看到公共jar但不同应用之间互相不感知还是应该把驱动jar放到自己应用的WEB-INF/lib下避免部署混淆。No suitable driver found则是另一个性质的问题。驱动类其实存在但加载时发现URL格式不匹配。比如数据库URL前缀写错驱动就认为自己不该处理这个连接。这种问题靠看全异常信息最好排查提示里通常会列出当前驱动支持的URL格式。6.2 连接池超时消失到哪里查连接池最典型的故障表现是应用启动正常前几次请求也正常过一段时间后突然所有请求都卡住最终抛出“获取连接超时”。这个问题的元凶大概率是连接泄漏。排查时先看连接池监控确认活跃连接数是否持续上涨直到达到最大值。如果是就从代码里找哪些地方获取了Connection但没有close。常见漏网之鱼包括只关了ResultSet和Statement忘了关Connection在某个分支里return跳过了finally的关闭DAO方法内部catch了异常但不走finally块。try-with-resources能解决一大部分问题但它只对实现了AutoCloseable的资源有效。如果你把Connection传给Service层处理DAO层就不要再负责关闭谁开启连接谁负责关闭这条约定要定死。否则两层都以为对方会关最后谁都关不干净。6.3 SQL注入不是理论问题很多人在本地写测试用例时感觉SQL注入离自己很远因为测试数据里没人构造恶意字符串。但真实系统在线之后扫描工具和攻击流量很快就来了。只要有一个接口用了字符串拼接SQL攻击者不需要知道你数据库结构通过报错信息就能逐步探测出表名和字段名。PreparedStatement是底线不是可选项。凡是SQL里可能出现外部输入的一律用?占位。如果遇到动态排序字段、动态表名这类PreparedStatement不好直接处理的情况也要用白名单映射绝不允许把原始字符串拼进SQL。除了SQL注入还要注意Response输出时的注入。登录接口返回的nickname如果来自数据库而数据库里被写入了/script之类的标签前端拿到后直接拼接HTML就可能被XSS。数据库访问只是安全链路的一环但确实是从源头过滤用户输入的重要位置。6.4 事务悄悄自动提交有时候业务代码看起来逻辑完整但数据就是不一致。比如一个转账功能扣钱成功加钱失败最后两边都没了或者只扣了一半。问题往往出在事务边界。Connection默认的autoCommit是true也就是说每执行一条SQL就立即提交。要让多条SQL组成一个事务必须先setAutoCommit(false)然后在所有SQL执行成功后commit()任何一步失败都要rollback()。如果项目分层之后DAO方法自己从连接池取连接事务控制就会失效。因为两次取到的Connection大概率不是同一条第一条SQL已经提交第二条SQL失败时只能回滚它自己的连接前面提交的就没法撤销了。所以事务必须有一个“跨多个DAO操作”的边界这个边界放在Service层是最自然的。7. 深层优化查询性能与安全基线7.1 批量操作为什么能快这么多增删改场景下如果用循环逐条执行SQL每一条都是一次网络往返。批量操作可以把多条SQL一次性发送给数据库网络开销大幅降低。PreparedStatement支持批量String sql INSERT INTO t_user (username, password_hash, nickname) VALUES (?, ?, ?); try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (User user : userList) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPasswordHash()); ps.setString(3, user.getNickname()); ps.addBatch(); } ps.executeBatch(); }使用批处理时要注意单批数据量不要太大。一次塞十万条内存和数据库端都会吃不消。常见的做法是每500条或1000条提交一次既能减少往返次数又不会让批过大导致资源问题。7.2 每页20条却把全表捞出来的问题分页查询在Servlet数据库访问里经常写错。很多人图省事先把所有数据查出来再在Java里截取需要的部分。用户量少的时候看不出来一旦表里有几万条数据每次请求都要传输大量无用数据内存和网络双双被拉满。正确做法是在SQL层面做分页。关系数据库大多支持limit语法比如SELECT id, username, nickname FROM t_user ORDER BY id LIMIT ?, ?;第一页从0开始每页20条传给PreparedStatement的参数就是0, 20。第二页是20, 20。如果业务需要跳转大页码还需要考虑深分页的优化比如基于上一次查询的最大ID做条件过滤而不是直接offset到一个很大的偏移量。7.3 密码处理、输入校验和最小权限登录查询里有一个很不该出现的写法直接用SQL比较明文密码。比如WHERE username ? AND password ?。这种写法不仅是注入风险问题更严重的是密码以明文形式出现在SQL执行路径里日志一旦开启就可能把密码完整记录下来。正确做法是数据库里保存密码哈希登录时先按用户名查出用户再用哈希校验。校验计算放在Java侧完成。哈希算法要选带盐的慢哈希而不是MD5、SHA-1这类一次计算就能算出来的摘要算法。数据库账号的权限也要控制。一个Web应用连数据库时不要用最高权限账户。常规操作只需要对业务库的增删改查权限连DDL权限都不该给。哪怕某一层代码被攻破攻击者也拿不到改表结构、删库的权限。数据库访问永远要遵循“最小权限原则”。最后一个容易忽略的点是连接池的配置。不要在代码里硬编码数据库IP、账号、密码也不要提交到版本控制里。用环境变量或独立的配置文件来维护这样换环境时不需要改代码也不至于让任何人都能从源码里看到生产库密码。Servlet数据库访问这件事表面上只是几行JDBC代码往深了走却牵扯连接管理、事务边界、分层设计、性能优化和攻击防护。每个坑我都踩过写出来就是希望你能少折腾几个晚上。先把基础链路走通再把连接池和分层结构补齐剩下的优化和加固都是顺手的事。