ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Servlet漂流瓶工程实战:JDBC连接池与事务并发控制全解析

Servlet漂流瓶工程实战:JDBC连接池与事务并发控制全解析 简介以「拾起海中的漂流瓶」为主题的Java Web学习资源瞄准Servlet与MySQL技术栈面向具备Java语法基础、却还不清楚如何用Servlet接收请求以及如何通过MySQL完成数据持久化的读者也适合正在准备Java Web课程设计或期末复习的初学者。资源被封装为rar压缩包单包大小约3.43MB下载后可完整保留在本地解压即用整体较为轻量。目前已有180人浏览学习资源规模不是重点关键是场景设置贴近实际Web开发中的小业务闭环。内容围绕漂流瓶的投放、漂流与拾取展开能够帮助读者把Servlet的请求接收与响应返回、HttpSession的会话跟踪、MySQL的数据读写与查询处理等内容整合到同一个小项目中过程中还会自然涉及JDBC参数绑定、结果集遍历、资源释放等易错细节便于形成从页面请求到数据库落地的完整认知。对于希望在课余或备考阶段用一个小型Java Web项目来提升项目感、并减少在零散教程中筛选时间的学习者而言这份材料具备直接跟做与对照参考价值。1. 一个 Servlet 漂流瓶工程为什么值得你把代码翻出来某次线上反馈一个给新手练手的 Servlet 漂流瓶项目在 Tomcat 重启后所有瓶子都打不开了。排查后发现罪不在业务代码而是 MySQL 连接串里的时区参数和连接池回收策略没有对齐——恰恰是很多人学 Servlet 时最不在乎的一层。这个项目用纯 Servlet 3.x MySQL 5.7/8.0 把“扔瓶子、捞瓶子、我的瓶子”这条闭环走完JDBC 连接池、事务、会话状态都装在一个不超过 2000 行的工程里。对三年以上开发者它最大的价值是能把 Servlet 生命周期、请求链路和数据库资源管理放在同一视野里复盘对刚入门的人它是从“会写接口”到“能上线”的分水岭。2. 数据模型与 JDBC 连接池表结构、DAO 设计与 DBCP 参数2.1 漂流瓶表结构为什么必须显式设计 status两张核心表user 和 bottle。bottle 表是这个项目的业务表字段设计直接决定了“捞瓶子”能不能在并发下安全执行。CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE bottle ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, content VARCHAR(512) NOT NULL COMMENT 漂流瓶内容, type TINYINT NOT NULL DEFAULT 0 COMMENT 0匿名 1实名, owner_id INT NOT NULL, receiver_id INT DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0漂浮 1已捞起 2已删除, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_status_id (status, id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;status 字段是整个表设计的核心。如果不做软删除用户删掉自己的瓶子时直接物理 DELETE那么正在遍历随机池的另一个请求可能读到已不存在的 id然后在主键关联时扑空。更麻烦的是“已捞起”和“漂浮中”是同一张表里的两种状态没有状态位就只能额外建一张 relation 表来记录捞取关系查询路径立刻翻倍。这里的做法是用 status0/1/2表达生命周期配合复合索引 (status, id)让“取随机瓶子”的扫描范围被限定在 status0 的数据块内。字符集用 utf8mb4 而不是 utf8是因为漂流瓶内容完全可能包含 emoji 或生僻字utf8 在 MySQL 里是 utf8mb3 的别名存放 4 字节字符时会出现 Incorrect string value 报错。密码字段也不要先写成 32 位至少按 SHA-256 十六进制串的 64 位预留避免后面做注册功能时要回头改表。2.2 连接池参数DBCP 的 6 个关键参数Servlet 项目最常见的错误是每个请求都 Class.forName DriverManager.getConnection()。连接建立本身要经过 TCP 握手、MySQL 认证、线程创建每次 HttpServlet 的 service 周期内少说浪费 20ms 以上并且数据库连接数是有限的Tomcat 默认 200 线程同时进来连接数当场被打穿。常见做法是引入 DBCP2 或 HikariCP用连接池复用物理连接。db.properties 配置如下driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/drifter?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/ShanghaiuseSSLfalse usernameroot passwordyour_password initialSize5 maxTotal20 maxIdle10 minIdle5 maxWaitMillis3000 validationQuerySELECT 1DBUtil.java 做一个静态的 DataSource 单例避免每次请求重复解析配置public class DBUtil { private static DataSource dataSource; static { try (InputStream in DBUtil.class.getClassLoader().getResourceAsStream(db.properties)) { Properties p new Properties(); p.load(in); dataSource BasicDataSourceFactory.createDataSource(p); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }classLoader.getResourceAsStream 会把 classpath 下的 db.properties 读进来部署后改配置不用动代码。BasicDataSourceFactory.createDataSource 接收 Properties底层按 key 反射到 setter所以属性名必须和 DBCP 的 Bean 属性完全一致写错其中一个比如把 maxActive 写进 DBCP2 的配置里启动时不报错运行期才会暴露连接不上。下面这几个参数值得单独抠出来对一遍参数值含义与坑initialSize5容器启动时预建连接设 0 则首次请求才建立连接冷启动变慢maxTotal20最大活动连接Tomcat 线程数大于此值时超出的请求必须等待maxIdle10空闲上限超过这个数的空闲连接会被回收避免被 MySQL wait_timeout 静默关闭minIdle5低于这个数时池会主动补连接减少毛刺maxWaitMillis3000拿不到连接就抛异常而不是无限等该值频繁打满说明需要扩容或优化 SQLvalidationQuerySELECT 1连接归还前探测存活MySQL 8 驱动对空闲超 8 小时的连接会报 Communications link failure这里能提前挡住注意 MySQL 8 的驱动类名是 com.mysql.cj.jdbc.Driver老项目里的 com.mysql.jdbc.Driver 在 mysql-connector-j 8.x 里已标记过时。serverTimezoneAsia/Shanghai 必须显式写否则驱动拿系统默认时区跟 JDBC 的时间戳转换时会出现 8 小时偏差。2.3 DAO 层与随机捞瓶子的 SQL 误区DAO 是 Servlet 和表之间的桥。常见败笔是把 SQL 直接写在 Servlet 的 doPost 里导致换表结构要翻 controller。这里拆出 BottleDAO三个方法对应三种操作扔insert、捞事务内 select update、列表按 owner_id 查。public class BottleDAO { public int insertBottle(String content, int type, int ownerId) throws SQLException { String sql INSERT INTO bottle(content, type, owner_id) VALUES(?,?,?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, content); ps.setInt(2, type); ps.setInt(3, ownerId); return ps.executeUpdate(); } } public long selectRandomFloatingId(Connection conn, int excludeOwnerId) throws SQLException { String sql SELECT id FROM bottle WHERE status 0 AND owner_id ? ORDER BY RAND() LIMIT 1; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, excludeOwnerId); try (ResultSet rs ps.executeQuery()) { return rs.next() ? rs.getLong(1) : -1L; } } } public int markPicked(Connection conn, long bottleId, int receiverId) throws SQLException { String sql UPDATE bottle SET status 1, receiver_id ? WHERE id ? AND status 0; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, receiverId); ps.setLong(2, bottleId); return ps.executeUpdate(); } } }这里最值得解释的是 selectRandomFloatingId 为什么把 Connection 作为参数传进来而不是自己在方法内部 getConnection。这是为了让 DAO 层方法参与外层 Servlet 声明的同一事务保证“随机选一个 把它标记为已捞”是原子的。ORDER BY RAND() 在索引命中范围里是性能损耗点它要对结果集全部排序再取一数据量到 10 万级别就能看到慢查询日志。教学项目可以保留生产环境中我一般把随机性转移到应用层先估算 id 区间再用一个随机偏移量往两侧扩散查找。用 UPDATE 的返回影响行数做并发判断还能覆盖两个请求同时捞到同一瓶子的场景——只有一条 update 成功另一条因为 status 已经改成 1 影响行数为 0直接 rollback 返回空结果。3. 扔瓶与捞瓶Servlet 请求链路、事务与响应编码3.1 从 WebServlet 到 DispatcherServlet轻量 Servlet 的职责边界写完之后你会发现这个项目的核心是一个超轻的前端控制器。Spring MVC 的 DispatcherServlet 本质也是一个 HttpServlet只不过把 URL 映射、参数绑定、拦截器、视图解析这些工作揽到了自己身上原生 Servlet 只做三件事接请求、做业务、回响应。下面用一个 ThrowBottleServlet 看三个最容易出错的决策点。WebServlet(name throwBottle, urlPatterns /bottle/throw) public class ThrowBottleServlet extends HttpServlet { private final BottleDAO bottleDAO new BottleDAO(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); HttpSession session req.getSession(false); Integer userId session null ? null : (Integer) session.getAttribute(userId); if (userId null) { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } String content req.getParameter(content); String typeParam req.getParameter(type); if (content null || content.trim().isEmpty() || content.length() 512) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); return; } int type; try { type Integer.parseInt(typeParam null ? 0 : typeParam); } catch (NumberFormatException e) { type 0; } try { bottleDAO.insertBottle(content.trim(), type, userId); resp.setStatus(HttpServletResponse.SC_OK); } catch (SQLException e) { resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } }三个决策点分别对应第 3、8、20 行。第一req.setCharacterEncoding(UTF-8)必须在读取任何参数之前调用放在 getParameter 之后就没有意义这是几乎所有中文乱码的根因。第二req.getSession(false)表示没有会话就返回 null避免攻击者随便带一个 JSESSIONID 就触发容器创建会话对象登录态判定应该走这个而不是无参的req.getSession()。第三请求内容先做长度与空值校验再交给 DAOSQL 注入的基本防线靠 PreparedStatement 占位符完成不要拼字符串。doGet 里建议只做页面导航转发到 throw.jsp把写操作留在 doPost保持 GET 请求的幂等性。这也是面试里常被追问的“转发和重定向的区别”在工程上的落点业务失败时用重定向回到表单页避免刷新浏览器时重复提交表单。3.2 捞瓶子的并发控制update 影响行数才是锁捞瓶子的动作天然存在竞争瓶库里只剩一个瓶子两个用户同时点击“捞起”。逻辑拆开是三步——随机选一个、查内容、标记为已捞。如果不做并发控制两个请求可能在第一步拿到同一个 id随后各自 update 成功导致一个瓶子被两个人领走。下面的方法把三步收进同一个事务。public Bottle pickUpBottle(int receiverId) throws SQLException { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); long bottleId selectRandomFloatingId(conn, receiverId); if (bottleId -1L) { conn.rollback(); return null; } int affected markPicked(conn, bottleId, receiverId); if (affected 0) { conn.rollback(); return null; } Bottle bottle selectById(conn, bottleId); conn.commit(); return bottle; } catch (Exception e) { if (conn ! null) conn.rollback(); throw e; } finally { if (conn ! null) conn.close(); } }关键不是 conn.setAutoCommit(false)而是 markPicked 里那条带AND status 0的 UPDATE。InnoDB 在更新主键记录时会自动加行锁先更新的人把 status 改成 1后更新的人等待行锁释放后再执行发现找不到 status0 的记录影响行数是 0于是 rollback 返回 null。就算两个请求同时 SELECT 到同一个 id最后只有一个人能拿到瓶子。selectRandomFloatingId 里 ORDER BY RAND() 产生的随机性在锁竞争下依然有效但对大表select 前套子查询会把大量行锁引进事务生产上建议在应用层生成随机 id 再WHERE id ? AND status0 LIMIT 1取第一条锁范围会小一个数量级。conn.close() 在 finally 里调用归还的是连接池里的连接不是真的断开连接但事务状态已经因为在异常分支执行 rollback 而复位。3.3 响应编码与资源释放顺序如果给前端的响应包含中文必须同时设置内容类型和字符集顺序也有讲究resp.setContentType(application/json;charsetUTF-8); resp.setCharacterEncoding(UTF-8); try (PrintWriter out resp.getWriter()) { out.write({\code\:0,\msg\:\ok\}); }setContentType 里带 charset 时容器会用它覆盖后续的字符编码设置。setCharacterEncoding 需要在 getWriter 之前调用因为写入流一旦建立编码器就已经确定再改就来不及了。上面的写法合并成一句也可以但把 setCharacterEncoding 单独写出来是为了强调它和 request 侧的 setCharacterEncoding 是一对一个是请求解码、一个是响应编码。资源释放顺序按 JDBC 对象的依赖方向来先 ResultSet、再 Statement、最后 Connection。用 try-with-resources 时声明顺序是 Connection、PreparedStatement、ResultSet关闭顺序自动按逆序执行正好符合这个原则。凡是看到只关了 Connection 没关 Statement 的代码要么是二次封装要么迟早把数据库连接池耗尽——连接虽归还Statement 关联的结果集还在内存里占用资源。4. 会话状态与常见异常从 HttpSession 到“无法加载主类”4.1 HttpSession 登录态与“不敢捞自己的瓶子”规则业务上有个硬性规则用户不能捞到自己刚扔出去的瓶子。靠请求参数传 userId 是完全不安全的攻击者在浏览器里直接把 userId 改成别人的 ID 就能伪装身份。正确来源是 HttpSession 里维护的登录态。public static boolean canPick(Long bottleOwnerId, HttpSession session) { Integer loginUserId (Integer) session.getAttribute(userId); return loginUserId ! null !loginUserId.equals(bottleOwnerId); }实现上可以更保守直接在捞取的 SQL 里加AND owner_id ?把排除条件下沉到数据库这样即使 Servlet 层忘了判断数据层也兜底。Session 的默认过期时间是 Tomcat 的 30 分钟web.xml 里可以按业务节奏调整session-config session-timeout30/session-timeout /session-config单位是分钟0 表示永不过期但漂流瓶这类弱社交场景不建议长期不过期服务端存的是 Session 对象大量休眠会话会堆在 JVM 里成为 GC 负担。还要留意同一个用户从登录、扔瓶、捞瓶三个请求的 JSESSIONID 必须一致。浏览器跨站点携带 Cookie 的行为被限制时前端要检查是否把 fetch 的credentials字段设置成了include否则抓包看到请求发出去了Session 里永远是 null。这是很多前后端分离项目在联调时才暴露的坑后端把 HttpSession 当未登录前端百思不得其解。4.2 常见异常速查从 ClassNotFoundException 到“找不到或无法加载主类”Servlet 项目报错场景高度集中用一张表把高频问题对应起来报错常见根因处理建议java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver驱动 JAR 没有打到 WEB-INF/lib或 classpath 没含 jar确认构建产物WEB-INF/lib/mysql-connector-j-8.x.jar存在不要只在 IDE 里 Run 过就以为服务器上也能跑错误: 找不到或无法加载主类 org.apache.catalina.startup.Bootstrap用java -jar xxx.jar去启动纯 Servlet 项目Servlet 项目不是 Spring Boot 可执行 jar必须由 Tomcat 的bin/startup.sh启动或使用 IDE 配置的 Tomcat Serverjava.sql.SQLException: Communications link failureMySQL 连接串缺时区/SSL 参数或连接被服务器空闲回收加serverTimezoneAsia/ShanghaiuseSSLfalse打开连接池的 validationQueryAccess denied for user rootlocalhost密码与库权限不匹配单独建最小权限账号不要拿 root 跑业务The server time zone value ??? is unrecognizedMySQL 8 时区未配置连接串加serverTimezoneAsia/Shanghai或执行SET GLOBAL time_zone 08:00“错误: 找不到或无法加载主类”是 java 命令层面的问题常发生在两个地方。一是刚配好 java 环境变量在命令行里用java -cp手搓运行写错了 classpath 绝对路径二是把这类工程的启动入口理解成 Spring Boot 的主类其实纯 Servlet 项目根本没有 main 函数HttpServlet 容器项目由 Tomcat 调度你只需要把 war 或 exploded 目录发布到 webapps 就行。快速自检环境是否正常依次执行java -version、javac -version、echo $JAVA_HOME如果能输出 JDK 版本和安装路径就说明环境变量配置没问题剩下的方向是构建工具是否把依赖打进了产物。4.3 中文乱码的“三段式”排查乱码在这个项目里有三个可能出现的位置请求参数、数据库回写、响应内容。排查时先定位是哪个环节再下判断。数据库侧连接串里必须有useUnicodetruecharacterEncodingUTF-8缺了它PreparedStatement 传进去的中文被 MySQL 按 latin1 解析存进表的就是问号。表与库的字符集也必须是 utf8mb4光改连接串不行。请求侧req.setCharacterEncoding(UTF-8)必须在 getParameter 之前Tomcat 8 及以上版本对 URL 中 QueryString 默认按 UTF-8 解码但 POST 表单 Body 的编码还是要靠这行代码指定。响应侧就是 3.3 里说的 setContentType 和 getWriter 的调用顺序。原生 Servlet 里没有全局拦截器统一做这件事通常自己写一个 CharacterEncodingFilter 实现 javax.servlet.Filterinit 参数里接收 encodingdoFilter 里先调用 request.setCharacterEncoding再 filterChain.doFilter 放行。Spring MVC 项目里 DispatcherServlet 前面的 CharacterEncodingFilter 就是做同一件事。如果你在 Java 学习路线上刚越过 Servlet 阶段这个 filter 是理解“编码过滤器”概念的最好切入点它在 Servlet 之前执行正好覆盖所有请求路径也顺便解释了“为什么 Servlet 乱码而 Spring Boot 不乱码”的疑问。5. 用 curl 与 JMeter 验证漂流瓶接口把并发问题堵在测试端5.1 用 curl 模拟用户回归全链路没有前端页面时curl 是验证接口最快的工具。用-c保存服务端返回的 Cookie用-b带上就能模拟连续会话curl -c cookie.txt -X POST \ -d usernamealicepassword123456 \ http://localhost:8080/bottle/login curl -b cookie.txt -c cookie.txt -X POST \ -d contenthello-from-alicetype1 \ http://localhost:8080/bottle/throw第一行把登录后的 JSESSIONID 写进 cookie.txt第二行带着同一个会话去扔瓶子。验证时重点看三点返回有没有中文乱码、HTTP 状态码对不对、重复执行两次 throw 后捞瓶子是否能返回 alice 刚才扔的内容。执行curl -v还能看到请求头与响应头的完整链路比如 Set-Cookie 是否带上 HttpOnly这些细节和浏览器里的表现完全一致。5.2 100 并发捞瓶子观测连接池排队JMeter 里新建线程组线程数 100、Ramp-Up 1 秒循环 1 次HTTP 请求路径填/bottle/pick不要漏掉 Cookie 管理器否则全部请求都是未登录状态。跑完后关注两个数据错误率超过 0说明发生了异常分支可能是瓶库空了也可能是连接池超时聚合报告里的吞吐量如果明显低于单用户时的值说明瓶颈在 maxWaitMillis 或 MySQL 的行锁等待。可以做一个对照组把 maxTotal 从 20 改成 200重新压一轮对比平均响应时间的变化量。多数情况下响应变快不是连接数变多而是减少了线程排队但 MySQL 并发写锁会让这个结论在数据量大时反转——所以永远要带着数据下结论不要只看直觉。5.3 让验证结果反哺参数配置压测结束后看一眼两个指标连接池获取连接的平均耗时以及数据库层SHOW GLOBAL STATUS LIKE Lock%里的等待计数。如果获取连接的 avg 接近 50ms说明 maxWaitMillis 每多等 1ms 都在浪费用户时间建议把 maxTotal 提到 50 并缩短最大等待如果 Lock 等待计数涨幅很大说明行锁竞争才是主因加连接数只会更糟方向应该是减少每个事务里锁定行的数量——把随机 id 生成放到应用层让事务只包含那一条 UPDATE。最后用EXPLAIN SELECT id FROM bottle WHERE status0 AND owner_id? ORDER BY RAND() LIMIT 1确认索引有没有生效再把 general_log 关闭回归两轮观察并记录这个 SQL 实际触发行锁的数量级。这组参数直接改 db.properties 里的 maxTotal 和 maxWaitMillis 就能复用不用再动一行 Java 代码。本文还有配套的精品资源点击获取
返回列表