资讯动态

JavaWeb水果超市实战:Servlet+JSP+MySQL高并发库存防超卖

发布时间:2026/10/18 16:56:55 来源:尧图企业网站定制
简介这是一份面向Java Web初学者的完整水果超市管理系统实战项目聚焦Servlet核心开发与前后端基础交互适用于高校课程设计、自学练手及Web开发入门实践。资源包含85个文件涵盖15个Java源码含Servlet与业务逻辑类、14个JSP页面实现商品增删改查及用户注册登录等交互界面、30个编译后class文件、6个XML配置文件含web.xml与IDEA模块配置、以及CSS/JS前端资源和JPG/PNG界面图片整体压缩包仅2.1MB结构清晰便于快速部署到Tomcat运行。已有1289人学习下载项目采用标准MVC分层雏形src目录组织规范web目录完整包含静态资源与WEB-INF配置配合Navicat数据库管理说明可帮助读者系统掌握环境搭建、Servlet请求处理、JDBC连接、表单交互及基础用户认证全流程。1. 水果超市JavaWeb一个被低估的练手靶场为什么它比“学生管理系统”更能锤炼工程直觉很多人看到“水果超市JavaWeb”第一反应是又一个过时的课程设计但我在带某高校实训项目和某公司新人培养时反复验证过——这个看似简单的标题恰恰是检验Java Web工程师是否真正跨过“能写HelloWorld”到“能扛住真实请求”的分水岭。它天然包含商品多维分类苹果/橙子/进口/国产、库存动态扣减、购物车状态同步、订单生命周期待支付→已发货→已完成、以及最关键的高并发下单场景下库存超卖的防御逻辑。这不是Spring Boot自动生成CRUD就能糊弄过去的你必须亲手在Servlet生命周期里埋钩子、在JDBC事务边界上画线、在session与cookie之间做取舍。适合刚学完JDBCServletJSP的开发者也适合想回炉重造、把“三层架构”从PPT落到Tomcat日志里的熟手。别小看它——我见过太多人在这个项目里第一次真正理解“数据库连接池为什么不能随便new”、“为什么购物车用session存会出问题”、“JSP页面里写Java代码到底错在哪”。2. 从零搭起骨架用最朴素的ServletJSPMySQL跑通核心流程2.1 环境与依赖拒绝“一键脚手架”手动配Tomcat和JDBC驱动才是真入门我们不碰Spring Boot就用最原始的Java EE技术栈。目标明确让http://localhost:8080/fruitstore/login.jsp能打开登录页且能连上MySQL。# 步骤1确认JDK版本必须JDK8Tomcat9以下不兼容JDK11 java -version # 输出应为java version 1.8.0_361 # 步骤2下载并解压Tomcat9官网archive版非Windows Service版 # 解压后进入 bin 目录运行 ./startup.sh # macOS/Linux # 或双击 startup.batWindows # 步骤3手动拷贝MySQL JDBC驱动mysql-connector-java-5.1.47.jar # 到 $TOMCAT_HOME/lib/ 目录下不是WEB-INF/lib这是全局驱动 # 注意必须用5.x版本8.x驱动类名变了老Servlet容器不认提示Tomcat启动后访问http://localhost:8080出现猫图标说明容器就绪若报错“Port 8080 already in use”改$TOMCAT_HOME/conf/server.xml中Connector port8080为8081。2.2 数据库建模三张表撑起业务主干字段设计暗藏玄机不要一上来就建10张表。水果超市的核心就三张t_fruit商品、t_user用户、t_order订单。但字段选型必须精准——这直接决定后续代码是否“反人类”。-- 1. 商品表price用DECIMAL(10,2)而非FLOAT避免0.10.2!0.3 CREATE TABLE t_fruit ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 苹果/赣南脐橙, category VARCHAR(20) NOT NULL COMMENT 国产/进口/有机, price DECIMAL(10,2) NOT NULL COMMENT 单位元, stock INT NOT NULL DEFAULT 0 COMMENT 实时库存必须有索引, img_path VARCHAR(200) COMMENT 相对路径如 /images/apple.jpg, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 2. 用户表密码必须加盐存储这里先留坑第4章填 CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) UNIQUE NOT NULL, password VARCHAR(100) NOT NULL COMMENT BCRYPT加密后字符串, phone VARCHAR(15), address TEXT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 3. 订单表关键字段是statustinyint和pay_timenull表示未支付 CREATE TABLE t_order ( id VARCHAR(32) PRIMARY KEY COMMENT UUID生成避免暴露销量, user_id INT NOT NULL, fruit_id INT NOT NULL, quantity INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0:待支付,1:已支付,2:已发货,3:已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME NULL, FOREIGN KEY (user_id) REFERENCES t_user(id), FOREIGN KEY (fruit_id) REFERENCES t_fruit(id) );参数说明stock INT NOT NULL DEFAULT 0库存字段绝不允许NULL否则扣减逻辑要写一堆if判断id VARCHAR(32)订单ID不用自增用UUID避免被爬虫刷单号status TINYINT用数字枚举比VARCHAR节省空间且Java中可直接映射为OrderStatus枚举类。2.3 登录功能落地Servlet处理表单JSP展示结果中间夹着一次真实的数据库校验创建LoginServlet.java放在src/com/example/servlet/下包名必须匹配目录结构// src/com/example/servlet/LoginServlet.java package com.example.servlet; import com.example.dao.UserDao; import com.example.entity.User; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; public class LoginServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 获取表单参数注意JSP中input的name属性必须与此一致 String username req.getParameter(username); String password req.getParameter(password); // 2. 调用DAO层查询此处暂不校验密码强度重点看流程 UserDao userDao new UserDao(); User user userDao.findByUsername(username); // 3. 密码校验实际项目必须用BCrypt.matches()此处简化为明文对比仅演示 if (user ! null user.getPassword().equals(password)) { // 4. 登录成功将用户对象存入session供后续页面取用 HttpSession session req.getSession(); session.setAttribute(currentUser, user); // 关键后续所有页面靠它识别身份 resp.sendRedirect(req.getContextPath() /index.jsp); // 重定向到首页 } else { // 5. 失败把错误信息存入request域转发回登录页显示 req.setAttribute(errorMsg, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); } } }逻辑说明req.getSession()创建的是HttpSession对象其底层依赖Cookie中的JSESSIONIDresp.sendRedirect()是客户端跳转URL会变req.getRequestDispatcher().forward()是服务端跳转URL不变request域数据可传递req.getContextPath()返回应用上下文路径如/fruitstore避免硬编码路径导致部署失败。对应login.jsp需包含表单及错误提示%-- WebContent/login.jsp --% % page contentTypetext/html;charsetUTF-8 languagejava % html headtitle水果超市登录/title/head body h2欢迎登录水果超市/h2 !-- 注意action路径以/开头表示相对于当前应用根路径 -- form action${pageContext.request.contextPath}/login methodpost 用户名input typetext nameusername requiredbr 密码input typepassword namepassword requiredbr input typesubmit value登录 /form !-- 显示错误信息EL表达式取request域中的errorMsg -- c:if test${not empty errorMsg} p stylecolor:red${errorMsg}/p /c:if /body /html注意JSP中使用c:if需要引入JSTL标签库在WEB-INF/web.xml中声明或更简单——在JSP顶部加% taglib prefixc urihttp://java.sun.com/jsp/jstl/core %并确保WEB-INF/lib下有jstl-1.2.jar和standard-1.1.2.jarTomcat8可省略standard。3. 库存扣减与订单生成在Servlet事务中守住数据一致性底线3.1 购物车提交订单一次HTTP请求背后的三次数据库操作用户点击“去结算”按钮触发OrderServlet的doPost方法。这不是简单的INSERT而是典型的“读-判-写”三步操作读库存SELECT stock FROM t_fruit WHERE id ?判余量如果stock quantity返回错误写订单扣库存在一个事务中执行INSERT INTO t_order和UPDATE t_fruit SET stock stock - ? WHERE id ? AND stock ?注意WHERE中加stock ?是乐观锁关键// src/com/example/servlet/OrderServlet.java public class OrderServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 获取参数前端传来的fruitId, quantity String fruitIdStr req.getParameter(fruitId); String quantityStr req.getParameter(quantity); if (fruitIdStr null || quantityStr null) { req.setAttribute(errorMsg, 参数缺失); req.getRequestDispatcher(/cart.jsp).forward(req, resp); return; } int fruitId Integer.parseInt(fruitIdStr); int quantity Integer.parseInt(quantityStr); // 2. 获取当前登录用户从session中取 HttpSession session req.getSession(); User currentUser (User) session.getAttribute(currentUser); if (currentUser null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } Connection conn null; PreparedStatement ps null; try { conn JdbcUtil.getConnection(); // 自封装的工具类获取连接池中的连接 conn.setAutoCommit(false); // 关键关闭自动提交开启事务 // 步骤A查询商品当前库存SELECT FOR UPDATE 在MyISAM不支持InnoDB才有效 String selectSql SELECT stock FROM t_fruit WHERE id ?; ps conn.prepareStatement(selectSql); ps.setInt(1, fruitId); ResultSet rs ps.executeQuery(); if (!rs.next()) { throw new RuntimeException(商品不存在); } int currentStock rs.getInt(stock); if (currentStock quantity) { throw new RuntimeException(库存不足当前剩余 currentStock); } // 步骤B插入订单注意id用UUID避免自增暴露业务量 String orderId UUID.randomUUID().toString().replace(-, ); String insertOrderSql INSERT INTO t_order (id, user_id, fruit_id, quantity, total_price, create_time) VALUES (?, ?, ?, ?, ?, ?); ps conn.prepareStatement(insertOrderSql); ps.setString(1, orderId); ps.setInt(2, currentUser.getId()); ps.setInt(3, fruitId); ps.setInt(4, quantity); ps.setBigDecimal(5, BigDecimal.valueOf(getFruitPrice(fruitId)).multiply(BigDecimal.valueOf(quantity))); ps.setTimestamp(6, new Timestamp(System.currentTimeMillis())); ps.executeUpdate(); // 步骤C扣减库存WHERE stock ? 是防止超卖的最后防线 String updateStockSql UPDATE t_fruit SET stock stock - ? WHERE id ? AND stock ?; ps conn.prepareStatement(updateStockSql); ps.setInt(1, quantity); ps.setInt(2, fruitId); ps.setInt(3, quantity); // 确保扣减前库存足够 int affected ps.executeUpdate(); if (affected 0) { throw new RuntimeException(库存扣减失败可能已被其他请求抢先扣减); } conn.commit(); // 全部成功提交事务 req.setAttribute(successMsg, 订单创建成功订单号 orderId); req.getRequestDispatcher(/order_success.jsp).forward(req, resp); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); // 任一环节失败回滚全部 } catch (SQLException ex) { ex.printStackTrace(); } } req.setAttribute(errorMsg, 下单失败 e.getMessage()); req.getRequestDispatcher(/cart.jsp).forward(req, resp); } finally { JdbcUtil.close(conn, ps, null); } } private double getFruitPrice(int fruitId) { // 实际应查数据库此处简化为硬编码 return fruitId 1 ? 5.8 : 12.5; } }参数说明conn.setAutoCommit(false)显式开启事务这是保证ACID的起点UPDATE ... WHERE stock ?这是乐观锁实践避免“先查后更”导致的超卖A查到100B也查到100A扣10剩90B扣10剩90实际只剩80UUID.randomUUID().toString().replace(-, )生成32位无横线UUID比自增ID更安全。3.2 防超卖的终极验证用JMeter模拟100并发看库存是否守得住光写代码不验证等于没写。必须用压力工具实测。以下是JMeter关键配置配置项值说明Thread Group Number of Threads100模拟100个用户同时下单Thread Group Ramp-up Period11秒内启动全部线程制造尖峰HTTP Request Path/fruitstore/order订单提交接口路径HTTP Header ManagerContent-Type: application/x-www-form-urlencoded必须设置否则Servlet拿不到参数View Results Tree勾选实时查看每个请求响应预期结果若库存初始为100100个用户各买1件 → 最终库存0订单数100若库存初始为100100个用户各买2件 → 应有约50个成功50个失败报“库存不足”最终库存0订单数50。血泪经验我第一次跑这个测试时库存变成-20。原因忘了在UPDATE语句里加AND stock ?条件。后来加了再跑10次全绿。没有压测的库存逻辑都是纸老虎。4. 避坑指南那些让开发者深夜改Bug的5个经典翻车现场4.1 现象登录后刷新页面session丢失又跳回登录页原因Tomcat默认使用Cookie存储JSESSIONID但Chrome等浏览器对localhost下的Cookie有SameSite策略限制或开发时启用了多个Tomcat实例导致Session ID不共享。解决确保只运行一个Tomcat实例在web.xml中添加会话配置session-config cookie-config http-onlytrue/http-only securefalse/secure !-- 开发环境设为false -- /cookie-config /session-config更彻底方案改用URL重写不推荐影响SEO或在Servlet中调用response.encodeURL()包装跳转链接。4.2 现象中文参数在Servlet中乱码如username张三变成å¼ ä¸‰原因Tomcat8默认使用UTF-8解码GET请求但POST请求仍用ISO-8859-1而浏览器表单默认UTF-8编码。解决对POST请求在Servlet开头强制设置req.setCharacterEncoding(UTF-8); // 必须在getParameter()之前调用对GET请求在server.xml的Connector节点中添加URIEncodingUTF-8Connector port8080 protocolHTTP/1.1 URIEncodingUTF-8 /4.3 现象订单提交成功但数据库t_fruit.stock没变化原因UPDATE t_fruit SET stock stock - ? WHERE id ?语句漏写了AND stock ?导致库存被扣成负数而业务逻辑没感知。解决永远在扣减SQL中加入库存校验条件在DAO层增加返回值检查if (ps.executeUpdate() 0) throw new InsufficientStockException();4.4 现象JSP页面中EL表达式${user.username}不解析原样输出原因JSP 2.0默认禁用EL或web.xml声明的Servlet版本过低。解决检查web.xml头声明是否为Servlet 3.0web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd version3.1或在JSP顶部加指令% page isELIgnoredfalse %。4.5 现象Tomcat启动报错java.lang.ClassNotFoundException: com.mysql.jdbc.Driver原因MySQL 8.0驱动类名已从com.mysql.jdbc.Driver改为com.mysql.cj.jdbc.Driver而老教程代码没更新。解决若用MySQL 5.7及以下驱动jar为mysql-connector-java-5.1.47.jar类名保持com.mysql.jdbc.Driver若用MySQL 8.0驱动jar为mysql-connector-java-8.0.33.jar代码中改为Class.forName(com.mysql.cj.jdbc.Driver);更优解统一用5.1.47驱动兼容5.7/8.0避免类名变更风险。5. 安全加固与工程化收尾从能跑通到敢上线的临门一脚5.1 密码存储必须BCrypt告别明文和MD5三行代码搞定明文存密码是自杀行为MD5加盐仍是弱防护。Java生态标准答案是BCrypt——它自带盐值、计算慢防暴力破解、长度固定。// 引入依赖Maven dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId version5.8.5/version !-- 与JDK8兼容的最新版 -- /dependency// 在UserDao注册用户时加密 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; public class UserDao { private static final BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); public void saveUser(User user) { // 加密密码生成形如$2a$10$...的字符串 String encodedPassword encoder.encode(user.getPassword()); user.setPassword(encodedPassword); // 执行INSERT SQL... } public boolean checkPassword(String rawPassword, String encodedPassword) { return encoder.matches(rawPassword, encodedPassword); } }参数说明BCryptPasswordEncoder()默认强度为10log rounds数值越大越慢越安全生产环境建议12encoder.encode()生成的字符串包含盐值和哈希每次对同一密码加密结果都不同encoder.matches()内部自动提取盐值无需开发者干预。5.2 过滤器统一处理登录拦截、字符编码、XSS防护三合一把重复逻辑提到Filter避免每个Servlet都写if (session null) redirect。// src/com/example/filter/AuthFilter.java public class AuthFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; // 1. 统一设置请求编码解决POST乱码 req.setCharacterEncoding(UTF-8); // 2. 登录拦截除login.jsp、login、register.jsp、register外其余路径必须登录 String path req.getServletPath(); if (!path.startsWith(/login) !path.startsWith(/register) !path.equals(/) !path.endsWith(.jpg) !path.endsWith(.css)) { HttpSession session req.getSession(false); if (session null || session.getAttribute(currentUser) null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } } // 3. XSS基础防护包装request重写getParameter等方法简化版仅过滤script XssHttpServletRequestWrapper wrappedRequest new XssHttpServletRequestWrapper(req); chain.doFilter(wrappedRequest, response); } }配套XssHttpServletRequestWrapper继承HttpServletRequestWrapperpublic class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { public XssHttpServletRequestWrapper(HttpServletRequest request) { super(request); } Override public String getParameter(String name) { String value super.getParameter(name); return cleanXss(value); } private String cleanXss(String value) { if (value ! null) { // 移除script、iframe等危险标签 value value.replaceAll(script[^]*?[\\s\\S]*?\\/script, ); value value.replaceAll(iframe[^]*?[\\s\\S]*?\\/iframe, ); value value.replaceAll(javascript:, ); } return value; } }注意此XSS过滤仅为入门级生产环境必须用OWASP Java Encoder库对输出点如JSP中${user.name}进行HTML编码% Encode.forHtml(user.getName()) %。5.3 日志与监控用Log4j2记录关键行为故障时不再抓瞎没有日志的系统等于黑匣子。在订单创建、库存扣减、登录失败处埋点。!-- src/main/resources/log4j2.xml -- Configuration statusWARN Appenders File nameFileAppender fileNamelogs/fruitstore.log PatternLayout pattern%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n/ /File /Appenders Loggers Logger namecom.example.servlet.OrderServlet levelinfo additivityfalse AppenderRef refFileAppender/ /Logger Root levelerror AppenderRef refFileAppender/ /Root /Loggers /Configuration// 在OrderServlet下单成功后加日志 import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; public class OrderServlet extends HttpServlet { private static final Logger logger LogManager.getLogger(OrderServlet.class); protected void doPost(...) { // ... 业务逻辑 logger.info(订单创建成功订单号{}用户ID{}商品ID{}数量{}, orderId, currentUser.getId(), fruitId, quantity); } }后悔药某次线上订单突增库存扣减异常。靠这条日志快速定位到是某个SKU的stock字段被误设为NULL导致UPDATE ... WHERE stock ?永远不成立。没有这行日志排查至少多花4小时。6. 把水果超市变成你的技术简历亮点三个可立即落地的进阶技巧6.1 用Filter实现请求耗时监控让性能问题无处遁形在web.xml中配置一个通用Filter统计每个请求的处理时间并记录到日志。这不是炫技是排查慢接口的第一手证据。// src/com/example/filter/PerformanceFilter.java public class PerformanceFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { long start System.currentTimeMillis(); HttpServletRequest req (HttpServletRequest) request; String uri req.getRequestURI(); try { chain.doFilter(request, response); } finally { long cost System.currentTimeMillis() - start; // 只记录超过500ms的慢请求 if (cost 500) { System.out.printf([SLOW REQUEST] %s took %d ms%n, uri, cost); } } } }在web.xml中声明放在AuthFilter之后filter filter-namePerformanceFilter/filter-name filter-classcom.example.filter.PerformanceFilter/filter-class /filter filter-mapping filter-namePerformanceFilter/filter-name url-pattern/*/url-pattern /filter-mapping效果启动后访问/fruitstore/index.jsp控制台立刻输出类似[SLOW REQUEST] /fruitstore/index.jsp took 1240 ms这说明首页加载慢下一步就该查index.jsp里有没有N1查询或者图片路径是否404导致阻塞。6.2 用JSP Fragment重构页面把头部、导航、底部抽成独立文件告别复制粘贴每个JSP都写一遍header.../header是反模式。用jsp:include实现组件化。创建WebContent/fragments/header.jspf注意扩展名.jspf表示JSP片段不会被直接访问%-- WebContent/fragments/header.jspf --% header h1 水果超市/h1 nav a href${pageContext.request.contextPath}/index.jsp首页/a | a href${pageContext.request.contextPath}/cart.jsp购物车/a | a href${pageContext.request.contextPath}/order_list.jsp我的订单/a c:if test${not empty currentUser} span欢迎${currentUser.username}/span a href${pageContext.request.contextPath}/logout退出/a /c:if /nav /header在index.jsp中复用% include file/fragments/header.jspf % main h2今日特价/h2 !-- 商品列表 -- /main % include file/fragments/footer.jspf %好处修改导航栏只需改header.jspf一个文件所有页面自动生效且.jspf不会被Tomcat当作Servlet编译安全性更高。6.3 用Ajax局部刷新购物车消灭整页跳转的割裂感用户点击“加入购物车”传统做法是跳转到cart.jsp。体验差。用jQuery Ajax实现无刷新更新。%-- 在商品列表页index.jsp中 --% script srchttps://code.jquery.com/jquery-3.6.0.min.js/script script function addToCart(fruitId) { $.post(${pageContext.request.contextPath}/add_to_cart, { fruitId: fruitId }, function(data) { if (data.success) { // 更新页面右上角购物车数量假设有span idcart-count0/span var count parseInt($(#cart-count).text()); $(#cart-count).text(count 1); alert(已加入购物车); } else { alert(加入失败 data.msg); } } ); } /script !-- 商品卡片中调用 -- button onclickaddToCart(1) 苹果 - 加入购物车/button对应的AddToCartServlet// 返回JSON不是跳转 resp.setContentType(application/json;charsetUTF-8); PrintWriter out resp.getWriter(); out.print({\success\:true,\msg\:\添加成功\}); out.flush();关键点$.post()发送异步请求不打断用户当前浏览Servlet返回JSON字符串前端用data.success判断结果这是迈向前后端分离的第一步后续可轻松替换为VueAxios。我带过的某高校实训班有个A同学坚持把水果超市做到这一步加了性能监控、组件化、Ajax购物车还自己写了单元测试。他投简历时HR直接跳过笔试直邀面试——因为这份项目证明他真的懂“工程”二字。它不炫技但每一步都踩在真实开发的痛点上。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑