Java Web数据库系统开发实例:JDBC连接池与事务避坑
简介:面向Java Web数据库开发初学者与进阶者的配套源码与教学视频包,围绕Servlet、JSP、JDBC及MVC设计模式展开,涵盖Tomcat应用服务器部署、SQL数据定义与查询、事务处理、存储过程及触发器,以及Hibernate、MyBatis等ORM框架的整合应用,可帮助读者从Java语法与面向对象基础过渡到完整Web数据库系统的构建、部署与调试。压缩包共884个文件、约222.5MB,内含163个java源文件、140个jsp页面、84个js脚本、181个class编译文件、29个jar依赖和17个sql脚本,并附48个avi教学视频,含系统演示、共通Servlet、报表打印及首个JSP应用部署等录屏,便于对照代码理解Servlet生命周期、JSP内置对象和数据库访问流程。已有470人学习,适合通过运行示例排查并发访问、SQL查询优化、用户认证授权与Ajax异步通信等问题,积累可复用的项目结构、分层组织方式与排错思路,也适合复盘MVC分层与请求响应流程。
1. 一套能跑起来的 Java Web 数据库实例,比十篇零散教程管用
很多人学 Java Web 卡在同一个位置:登录页能写,表单能提交,数据往 MySQL 里一落就开始出玄学问题——连接拿不到、事务不回滚、中文变问号、分页一翻就空。这套 Java Web 数据库系统应用开发与实例,本质是把“Servlet 接请求 → Service 组装业务 → DAO 执行 SQL → 连接池归还连接”这条链路完整铺开,让你看到真实工程里每一层长什么样,而不只是零散的语法示例。它适合已经会写 Java、能看懂简单 JSP,但还没独立做完一个带数据库增删改查系统的人;也适合工作里要接手老项目、需要快速摸清 JDBC 与连接池套路的从业者。下面按能复现的顺序拆:先把环境对齐跑通,再顺着源码看数据怎么流,最后说清哪些地方一改就容易出问题。
2. 环境对齐:源码跑不起来的三个真实原因
拿到一套 Java Web 数据库实例,第一反应往往是双击 IDE 里的运行按钮。血泪经验是,这类带数据库的老项目十个里有六个第一次跑不起来,原因通常不在代码本身,而在环境对不齐。版本、驱动、连接串这三样只要有一处错位,报错信息就会指向完全无关的地方,白折腾半天。下面三节按“先看什么、再改什么、最后怎么验证”的顺序走,每一步都能自己确认结果,不靠猜。
2.1 目录结构和依赖先摸清楚
先别急着运行,把工程目录摊开看一眼。典型的 Java Web 数据库实例工程结构大致如下,不同年份的模板会有差异,但关键位置是固定的:
JavaWebDbDemo/ ├── src/ │ └── com/example/ │ ├── dao/ # 数据访问层,SQL 基本都在这里 │ ├── service/ # 业务层,事务边界一般画在这一层 │ ├── servlet/ # 控制器,接收请求、转发页面 │ ├── entity/ # 实体类,和表字段一一对应 │ └── util/ # 连接池工具类,重点看这里 ├── WebContent/ # 老工程叫 WebContent,新工程叫 webapp │ ├── WEB-INF/ │ │ ├── lib/ # 第三方 jar,缺一个就 ClassNotFound │ │ └── web.xml # Servlet 映射、过滤器、欢迎页 │ └── jsp/ # 页面文件 └── sql/ └── db_init.sql # 建库建表脚本,先执行它WEB-INF/lib和sql这两个目录是判断工程能否跑通的第一线索:前者决定编译期和运行期的类能不能找到,后者决定数据库里有没有对应的表。常见做法是先数一遍 lib 下的 jar,把清单列出来,再对照代码里的 import 逐项确认。下面这张表是这类工程里出现频率最高的几个 jar,缺哪个就会在哪个环节报错:
| jar 包 | 作用 | 缺失时的典型报错 |
|---|---|---|
| mysql-connector-java | MySQL JDBC 驱动 | No suitable driver found |
| druid 或 commons-dbcp | 数据库连接池 | Cannot create PoolableConnectionFactory |
| jstl 与 standard | JSP 标签库 | 标签不解析,原样输出到页面 |
| servlet-api | 编译期接口 | 编译报错,运行期由容器提供 |
| commons-fileupload | 文件上传 | 上传功能 500 |
注意servlet-api这类容器自带的包,scope 要设成 provided,打进 war 包里反而会和应用服务器里的版本打架,出现类加载冲突。我在一个模拟项目里就遇到过:本地跑得好好的,部署到测试机就抛ClassCastException,最后查到是 war 包里多带了一份 servlet-api。
2.2 JDK、Tomcat、MySQL 版本对齐
版本是这类老工程最主要的失败来源。一套写着javax.servlet的代码,扔进 Tomcat 10 里必然 404 或 500,因为从 Tomcat 10 开始包名整体迁到了jakarta.servlet,两套命名空间不兼容。同样,用 JDK 17 编译 JDK 8 时代写的代码,也可能因为模块化移除某些内置包而报错。下面这张矩阵是我一般会先写在纸上、贴在屏幕边的对照表:
| 组件 | 老工程常见版本 | 新环境常见版本 | 冲突表现 |
|---|---|---|---|
| JDK | 8 | 11 / 17 | 编译通过但运行缺包 |
| Tomcat | 8.5 / 9 | 10 / 11 | javax 包全部找不到 |
| MySQL | 5.7 | 8.0 | 驱动类名、时区报错 |
| 驱动 jar | 5.1.x | 8.0.x | Public Key Retrieval 异常 |
判断方法很直接:翻web.xml的版本声明和代码里的 import 语句,出现javax.servlet就选 Tomcat 9 及以下,出现jakarta.servlet就选 Tomcat 10 及以上,不要混。MySQL 这边,8.0 的驱动类名是com.mysql.cj.jdbc.Driver,5.1 的是com.mysql.jdbc.Driver,两者在连接串参数上也有差别,8.0 默认要求显式声明时区,否则连接阶段就抛异常。
2.3 建库导表与连接串配置
环境版本确定之后,先把数据库准备好。建库脚本一般放在sql目录,执行前先确认字符集,这一步省掉后面全是乱码:
-- 建库时显式指定字符集,避免后续中文写入变问号 CREATE DATABASE demo_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE demo_db; -- 示例业务表,主键自增,字段类型和实体类保持一致 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, gender TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段脚本做了两件事:库和表都显式声明utf8mb4,保证中文和特殊符号能存进去;表引擎用 InnoDB,因为只有 InnoDB 支持事务,后面讲回滚才有意义。字段类型要和实体类对得上,gender用TINYINT而不是CHAR,实体里就写Integer,别用String硬转,否则ResultSet.getInt会抛类型转换异常。
建完表再改连接配置。这类工程一般把连接参数放在jdbc.properties或db.properties里:
# MySQL 8.0 驱动写法,类名带 cj jdbc.driver=com.mysql.cj.jdbc.Driver # 时区必须写,否则 8.0 驱动直接拒绝连接 jdbc.url=jdbc:mysql://127.0.0.1:3306/demo_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=your_password # 连接池基础参数,先小后大,方便观察 jdbc.initialSize=2 jdbc.maxActive=10 jdbc.maxWait=3000连接串里几个参数值得单独说:characterEncoding=utf8决定客户端与服务端协商的编码,和建库字符集要一致;serverTimezone是 8.0 驱动强制要求的,不写会报The server time zone value is unrecognized;allowPublicKeyRetrieval=true只在 8.0 默认认证插件下需要,本地开发开着方便,生产环境要按实际认证方式收紧。参数改完先别启动整个工程,写个十几行的 main 方法单独测连接,通了再往下走,能省掉一大半排查时间。
3. JDBC 到 DAO:源码里的数据访问链路拆成四段看
工程跑起来之后,很多人就停在“能登录、能列表”这一步,不知道代码里哪一段在干什么。这套实例的价值在于分层比较完整,顺着一次查询请求往下看,能看到连接获取、语句预编译、结果集映射、资源释放四个独立环节。把这四段拆开理解,改任何一张表都不至于牵一发动全身。
3.1 连接获取:DriverManager 直连和连接池差在哪
老的示例代码里经常能看到DriverManager.getConnection直接拿连接,写法简单,但每次调用都是一次完整的 TCP 握手加认证,请求一多就顶不住。工程里更常见的是连接池,初始化时建好一批连接放在池子里,用完归还而不是关闭。两种写法的差别不在语法,而在生命周期管理:
// 方式一:直连,每次调用都新建物理连接,仅适合本地调试 public static Connection getDirectConnection() throws Exception { Class.forName("com.mysql.cj.jdbc.Driver"); return DriverManager.getConnection(URL, USER, PWD); } // 方式二:Druid 连接池,全局只初始化一次 public class DbUtil { private static DataSource dataSource; static { try { Properties props = new Properties(); // 配置文件放在 classpath 下,打包后依然能读到 props.load(DbUtil.class.getClassLoader() .getResourceAsStream("jdbc.properties")); dataSource = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } // 业务代码只拿连接,不关心池子内部 public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }第一段是调试用的直连,Class.forName在 JDBC 4.0 之后其实可以省略,但老工程保留着也无妨。第二段用静态块初始化数据源,保证整个应用生命周期内只建一个池;DruidDataSourceFactory会读取initialSize、maxActive、maxWait这些参数,maxWait单位是毫秒,超过就抛获取超时。这里有个容易忽略的点:池化连接调用close()并不是真的关闭物理连接,而是归还,所以 DAO 里照常写close()是对的,但绝不能因为“反正有池子”就不关,池子会被慢慢占满。
3.2 PreparedStatement 的参数绑定与结果集映射
连接拿到之后就是执行 SQL。实例里增删改查基本都是PreparedStatement,它的两个作用要分清楚:一是预编译,同一条 SQL 反复执行时效率更高;二是参数绑定,值不拼进 SQL 字符串,从根上堵住注入。看一段插入和一段查询:
// 插入:占位符从 1 开始编号,顺序不能错 public int insert(Student s) throws SQLException { String sql = "INSERT INTO student(sno, name, gender) VALUES(?, ?, ?)"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, s.getSno()); ps.setString(2, s.getName()); ps.setInt(3, s.getGender()); return ps.executeUpdate(); // 返回受影响行数 } } // 查询:列名要和表字段严格对应,大小写不敏感但拼错就报错 public List<Student> findAll() throws SQLException { String sql = "SELECT id, sno, name, gender, create_time FROM student"; List<Student> list = new ArrayList<>(); try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Student s = new Student(); s.setId(rs.getInt("id")); s.setSno(rs.getString("sno")); s.setName(rs.getString("name")); s.setGender(rs.getInt("gender")); s.setCreateTime(rs.getTimestamp("create_time")); list.add(s); } } return list; }setString(1, ...)里的数字是占位符序号,不是字段序号,写成 0 会直接抛越界。try-with-resources保证三个资源按声明的逆序关闭,ResultSet先关、PreparedStatement再关、Connection最后归还池子,顺序错了在某些驱动下会报“结果集已关闭”。结果集映射这一块,getInt遇到数据库里的NULL会返回 0 而不是抛异常,需要判断是否真的为空时得配合rs.wasNull(),这个坑在统计类查询里特别常见。
3.3 DAO 分层与事务边界该画在哪
分层的目的不是好看,是让“一次业务操作要么全成、要么全败”这件事有地方落。实例里的典型做法是:DAO 只负责单条 SQL,Service 负责组合多条 DAO 调用并控制事务。事务边界画错,回滚就成了摆设:
// Service 层:两条 SQL 必须在同一个连接、同一个事务里 public boolean transferScore(int fromId, int toId, int score) { Connection conn = null; try { conn = DbUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,事务开始 StudentDao dao = new StudentDao(conn); dao.deduct(fromId, score); // 第一条 SQL dao.add(toId, score); // 第二条 SQL conn.commit(); // 两条都成功才提交 return true; } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { /* 记录日志 */ } } return false; } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ex) { } } } }关键在StudentDao的构造函数接收外部传入的Connection,而不是在自己内部重新getConnection()。如果 DAO 各自去池子里拿连接,两条 SQL 就落在两个不同的事务里,第一条成功第二条失败时,回滚只能撤掉第二条,数据就脏了。finally里恢复autoCommit再归还,是为了避免这个连接被下一次请求拿到时还带着未提交状态,池化环境里这是实打实的血泪教训。
4. Servlet + JSP 闭环:一次表单提交要穿过哪些层
数据访问层理顺之后,往上就是请求入口和页面渲染。这部分代码看起来零碎,但套路固定:Servlet 收参数、调 Service、存数据到 request、转发 JSP;JSP 只负责取值展示,不写业务逻辑。理解这条链路,改字段、加校验、换页面都有明确落点。
4.1 请求参数接收与编码统一
中文乱码是这条链路上出现频率最高的问题,而且往往只改一处不管用。编码要在三个位置同时对上:请求体、响应体、数据库连接。实例里一般用过滤器统一处理,比在每个 Servlet 里写一行setCharacterEncoding更可靠:
// 编码过滤器,映射到 /*,对所有请求生效 public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { // 必须在读取任何参数之前设置,否则已经解析过的参数改不回来 req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); } }过滤器注册在web.xml里,url-pattern写/*,顺序要排在业务 Servlet 前面。setCharacterEncoding只对 POST 请求体有效,GET 请求的参数编码取决于容器配置,Tomcat 8 之后默认按 UTF-8 解析 URI,老版本要在server.xml的 Connector 上加URIEncoding="UTF-8"。这三处只要有一处没对上,页面上就会出现“张三”变“å¼ ä¸‰”的经典画面。
4.2 增删改查落地:一个学员表的完整例子
看一个 Servlet 怎么把表单数据接到 Service 再跳到列表页。代码不长,但每一行的位置都有讲究:
@WebServlet("/student") public class StudentServlet extends HttpServlet { private final StudentService service = new StudentService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("add".equals(action)) { Student s = new Student(); // 参数名要和表单 name 属性完全一致 s.setSno(req.getParameter("sno")); s.setName(req.getParameter("name")); // 单选按钮没选中时返回 null,这里给默认值 String gender = req.getParameter("gender"); s.setGender(gender == null ? 0 : Integer.parseInt(gender)); boolean ok = service.add(s); // 重定向而不是转发,避免刷新重复提交 resp.sendRedirect(req.getContextPath() + "/student?action=list"); } } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("list".equals(action) || action == null) { req.setAttribute("students", service.findAll()); // 转发到 WEB-INF 下的页面,外部无法直接访问 req.getRequestDispatcher("/WEB-INF/jsp/studentList.jsp") .forward(req, resp); } } }doPost处理写入后走sendRedirect,浏览器地址栏变成列表页,刷新不会重复插入,这是“提交后重定向”的标准做法;doGet里用forward,request 作用域里的数据能带到 JSP。JSP 放在WEB-INF下是硬性习惯,这个目录对外不可见,只能由 Servlet 转发进入,防止有人直接敲 URL 绕过参数校验。
页面文件用 JSTL 遍历,不写 Java 脚本片段:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table> <c:forEach items="${students}" var="s"> <tr> <td>${s.sno}</td> <td>${s.name}</td> <td>${s.gender == 1 ? '男' : '女'}</td> </tr> </c:forEach> </table>${students}取的是 request 里的属性名,和 Servlet 里setAttribute的字符串必须一字不差。c:forEach的var是循环变量,作用域只在标签体内。EL 表达式遇到空集合会输出空白而不是报错,所以列表为空时页面看起来“什么都没发生”,调试时要先确认 Service 返回的 list 是不是 size 为 0。
4.3 分页查询与结果集处理
列表页数据一多就得翻页。分页的写法有两种,一种是查全量再在内存里截取,数据量小的时候能用,上了万级就明显卡顿;实例里用的是数据库分页,靠LIMIT完成:
// 分页查询:pageNo 从 1 开始,pageSize 固定 10 public List<Student> findByPage(int pageNo, int pageSize) throws SQLException { String sql = "SELECT id, sno, name, gender FROM student LIMIT ?, ?"; List<Student> list = new ArrayList<>(); try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { // 第一个参数是偏移量,第二个是条数,顺序不能反 ps.setInt(1, (pageNo - 1) * pageSize); ps.setInt(2, pageSize); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Student s = new Student(); s.setId(rs.getInt("id")); s.setSno(rs.getString("sno")); s.setName(rs.getString("name")); list.add(s); } } } return list; }LIMIT的两个参数分别是偏移量和条数,(pageNo - 1) * pageSize这一步最容易写错,写成pageNo * pageSize就会漏掉第一页。总数查询单独走一条SELECT COUNT(*),两个结果一起放进分页对象里传给 JSP。MySQL 的LIMIT在偏移量很大时性能会下降,几十万行以后常见做法是改成基于主键的游标分页,WHERE id > ? ORDER BY id LIMIT ?,这一点在这套实例里没有展开,但改造成本不高。
5. 避坑排查:这套源码最容易翻车的五个地方
代码能跑通只是开始,改起来不出事才算真的看懂。下面五条都是这类 Java Web 数据库实例里反复出现的故障,每条按“现象、原因、解决”写,遇到时直接对照。
5.1 连接池耗尽:程序没报错,但页面一直转圈
现象是运行一段时间后所有请求都卡住,日志里偶尔出现wait millis 3000, active 10。原因基本是某条分支上Connection没有关闭,池子里的连接被借空,后续请求全在maxWait那里排队。解决分两步:先把maxActive临时调大确认是泄漏而不是并发真的高;再逐个检查 DAO,凡是getConnection的地方都必须出现在try-with-resources或finally里。我一般会在连接池上加一个removeAbandoned开关做兜底,但它只是补救,不能替代正确的关闭。
顺带说一个变体:有人在 DAO 里关了Connection,又在 Service 的finally里再关一次,池化实现下第二次close通常无害,但如果中间做过commit,重复关闭在某些版本会抛异常,日志里一堆红色但业务其实成功了,容易误判。
5.2 事务不回滚:报错信息很清楚,数据还是写进去了
现象是第二条 SQL 抛异常,页面提示失败,刷新一看第一条的数据已经落库。原因通常有三种:autoCommit没关,两条 SQL 各自独立提交;DAO 内部自己getConnection,和 Service 拿的不是同一个连接;异常被catch住之后没有重新抛出,Service 以为成功就commit了。解决方式是把连接从 Service 传到 DAO,setAutoCommit(false)之后所有操作共用它,catch块里必须rollback,并且不要在catch里把异常吞掉。
还有一种隐蔽情况:表引擎是 MyISAM,rollback执行了但没有任何效果。建表时加ENGINE=InnoDB,这一点在第 2 章的建表脚本里已经写死,改表时别忘了带上。
5.3 中文乱码:三处编码必须同时对上
现象是页面显示问号、数据库里存进去是问号、或者查询出来是问号,三种情况对应三个不同位置。原因是请求编码、响应编码、连接串编码、库表字符集这几处只要有任意一处是ISO-8859-1或utf8与utf8mb4混用,就会出现。解决顺序是:先确认库和表都是utf8mb4,再确认连接串带characterEncoding=utf8,最后确认过滤器在读取参数之前设置了setCharacterEncoding("UTF-8")。
一个常被忽略的点是 emoji 和部分特殊符号,utf8三字节存不下,必须utf8mb4。如果历史表已经是utf8,改字符集要用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4,直接改建库语句对已有表无效。
5.4 SQL 拼接:功能能用,但注入风险一直存在
现象是代码里到处"WHERE name = '" + name + "'",正常输入没问题,一旦有人在输入框里带上引号和条件,查询结果就超出预期。原因是字符串拼接把用户输入当成了 SQL 语法的一部分。解决方式统一改成PreparedStatement占位符,包括LIKE和IN这两种不太好写的场景:LIKE用CONCAT('%', ?, '%'),IN按列表长度动态生成问号再逐个绑定。
排序字段是个例外,ORDER BY ?绑定的是值不是列名,绑了也不生效。这种只能白名单校验,把允许的列名放进一个Set里,不在集合内就拒绝,别图省事直接拼。
5.5 驱动类名、时区与 SSL 参数
现象是启动就抛No suitable driver found或者The server time zone value is unrecognized,配置看着都对。原因是驱动 jar 版本和连接串写法不匹配:5.1 驱动配 8.0 的 URL 参数、8.0 驱动用旧类名、URL 里漏了serverTimezone。解决是先确认 jar 版本,再按版本选类名和参数,8.0 统一写com.mysql.cj.jdbc.Driver,URL 带上时区,本地开发加useSSL=false省掉证书告警。
此外allowPublicKeyRetrieval=true只在特定认证方式下需要,生产环境按实际账号的认证插件决定,不要无脑复制。改完连接串先单独跑一个连接测试类,比启动整个工程再翻日志快得多。
6. 二次改造:把示例表换成自己的业务表要动哪几处
示例跑通之后,真正的需求往往是把student换成自己的表。这类工程改动点固定,按顺序走一遍就不会漏:
| 顺序 | 改动位置 | 检查点 |
|---|---|---|
| 1 | 建表脚本 | 引擎 InnoDB、字符集 utf8mb4、主键自增 |
| 2 | 实体类 | 字段类型与列类型对应,日期用 Timestamp |
| 3 | DAO 的 SQL | 列名逐字核对,占位符顺序与绑定一致 |
| 4 | Service 事务 | 多表操作必须共用同一个 Connection |
| 5 | Servlet 参数名 | 与表单 name 属性完全一致 |
| 6 | JSP 取值 | EL 属性名与 setAttribute 一致 |
最容易出问题的是第 3 步和第 5 步。列名拼错时,ResultSet抛的是Column 'xxx' not found,报错里不会告诉你正确列名是什么,只能回去看表结构;参数名对不上时更隐蔽,getParameter返回null,插入时字段变成空值,页面不报错但数据是缺的。我一般的做法是改完 DAO 先写一个 main 方法,单独跑一遍增删改查,四条 SQL 都通过再接页面,能省掉前后端来回猜的时间。
验证环节别只看“能插入”。把这几项跑一遍:插入一条带中文和特殊符号的记录,确认库里存的是原字符;提交一个不存在的 ID 做删除,确认返回受影响行数为 0 而不是抛异常;把分页的pageNo传成 0 或负数,确认有边界处理;断开数据库再点一次查询,确认错误页不是一片空白。这四条过了,改造基本就算落地。
从那以后我每接一套带数据库的老工程,都先把版本矩阵和连接串这两张纸写出来贴在屏幕边上,再动第一行代码——省下来的排查时间,比看十篇教程都值。这套源码和配套视频如果需要对照着跑,下载包里一并带上了。希望帮到你。
本文还有配套的精品资源,点击获取