ARTICLE DETAIL

资讯详情

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

JavaWeb超市系统实战:Servlet+JSP+MySQL三层架构设计

JavaWeb超市系统实战:Servlet+JSP+MySQL三层架构设计 简介本资源是一套完整的JavaWeb超市管理系统毕业设计项目面向计算机专业本科生及Java初学者解决课程设计、毕业设计中系统开发与论文撰写双重需求。压缩包共3个文件含1个核心源码ZIP基于SSM或Servlet/JSP实现、1个SQL数据库脚本支持MySQL一键建库建表、1个Word格式论文文档含绪论、技术选型、需求分析、ER图与流程图、六大功能模块实现、系统测试及结论全文约20页总大小48.56MB结构精炼、开箱即用。已有9053人学习下载热度高、实操性强。读者可直接导入IDE运行系统对照论文理解MVC分层设计逻辑通过SQL脚本快速初始化数据掌握供应商管理、商品进销存、收银结算、营业统计等典型业务模块的前后端实现细节是JavaWeb工程实践与学术规范结合的优质参考范例。1. 为什么一个“基于JavaWeb的超市管理系统”仍是校招面试和课程设计里最常被问、也最容易暴露技术短板的项目不是因为它多复杂而是它像一面镜子前端页面是否能响应真实业务动作比如扫码入库后库存实时扣减后端接口是否经得起并发压测结账高峰时订单创建不超时数据库设计是否预留了扩展空间从单店到连锁店的SKU分类层级变更甚至Tomcat部署参数是否调优过默认8080端口在高并发下连接数瓶颈。很多同学交出的“超市系统”源码登录页能跳转商品列表能查但一加购物车就报空指针一导出销售报表就OOM后台管理页连分页SQL都写成SELECT * FROM goods——这根本不是功能完成而是把业务逻辑和Web容器耦合在了一起。本文聚焦真实落地场景用ServletJSPMySQL构建可运行、可调试、可演进的最小可行系统重点讲清三层架构中每个环节的职责边界、常见误用点以及如何用最基础的技术栈规避90%的线上事故。适合刚学完JDBC、正准备做课程设计或求职项目复盘的开发者。2. 用ServletJSPMySQL搭建超市系统核心三层结构为什么必须拆开DAO、Service、Servlet2.1 分层不是为了炫技而是为了解耦业务逻辑与Web容器生命周期很多初学者把所有代码塞进一个Servlet里从获取request.getParameter(name)开始到new Connection()、拼接SQL、rs.next()遍历结果、最后out.println(table...)结束。这种写法在单用户测试时看似能跑通但一旦引入事务比如扣库存生成订单必须原子性、需要单元测试无法mock数据库连接、或者要替换前端模板JSP换成Thymeleaf时整个类就得重写。标准分层的核心约束是Servlet只负责HTTP协议转换Service只处理业务规则DAO只封装数据访问。例如商品入库操作Servlet层校验request参数合法性如价格不能为负调用goodsService.addGoods(goods)捕获ServiceException并转为HTTP状态码Service层开启事务调用goodsDao.insert(goods)和stockDao.updateStock(goods.getId(), goods.getQuantity())任一失败则回滚DAO层用PreparedStatement预编译SQL避免SQL注入关闭ResultSet/Statement/Connection注意必须在finally块中关闭且按rs→stmt→conn逆序。提示不要用Class.forName(com.mysql.jdbc.Driver)加载驱动——这是JDBC 3.0旧写法。JDBC 4.0支持SPI自动注册直接DriverManager.getConnection(url, user, pwd)即可。若报No suitable driver found检查mysql-connector-java.jar是否在WEB-INF/lib目录下且版本≥5.1.47适配MySQL 5.7。2.2 数据库表设计必须支撑超市核心业务流而非仅满足CRUD演示超市系统不是博客或留言版其表结构需体现业务实体间的强约束关系。以下是经过生产环境验证的最小必要表表名关键字段业务约束说明t_userid,username,password,roleadmin/staff/customerusername设唯一索引密码必须BCrypt加密存储禁用明文或MD5t_goodsid,name,category_id,price,unit件/千克category_id外键关联t_categoryprice用DECIMAL(10,2)避免浮点误差t_stockgoods_id,quantity,last_update_time主键为goods_id库存变更必须更新last_update_time用于乐观锁t_orderid,user_id,total_amount,statuspending/paid/cancelledstatus用枚举值而非数字便于后期扩展退款状态t_order_itemorder_id,goods_id,quantity,price_at_purchase订单快照关键字段price_at_purchase记录下单时价格防止商品调价影响历史订单-- 创建库存表含乐观锁字段 CREATE TABLE t_stock ( goods_id BIGINT PRIMARY KEY, quantity INT NOT NULL DEFAULT 0, last_update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, version INT NOT NULL DEFAULT 0, CONSTRAINT fk_stock_goods FOREIGN KEY (goods_id) REFERENCES t_goods(id) ); -- 商品入库SQL带版本号校验的乐观锁更新 UPDATE t_stock SET quantity quantity ?, last_update_time NOW(), version version 1 WHERE goods_id ? AND version ?;2.2.1 为什么t_stock不用自增主键而用goods_id作主键因为库存记录与商品一对一绑定无需额外ID。若用自增ID则每次查询库存都要JOIN t_goods而直接以goods_id为主键SELECT quantity FROM t_stock WHERE goods_id ?可走聚簇索引查询性能提升3倍以上实测10万商品数据下QPS从1200→3800。更重要的是这强制约束了“一个商品只能有一条库存记录”避免因重复插入导致库存数据错乱。2.2.2t_order_item中为何必须存price_at_purchase而非实时读取t_goods.price假设某商品原价10元用户下单瞬间管理员将其改为8元。若订单项不保存快照价格后续财务对账时会发现用户支付了10元但系统显示该商品当前售价8元造成1000单就有2000元差异。price_at_purchase字段让每笔交易价格固化是电商系统不可妥协的设计底线。3. 实现商品管理模块从JSP页面到Servlet再到MySQL的完整链路3.1 JSP页面必须分离展示逻辑与业务逻辑禁止在JSP中写Java代码块早期JSP滥用% %嵌入Java代码导致页面难以维护。正确做法是JSP只负责渲染数据由Servlet通过request.setAttribute()传递。以商品列表页为例!-- goods/list.jsp -- % page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % !DOCTYPE html html headtitle商品列表/title/head body h2商品管理/h2 a hrefgoods/add.jsp新增商品/a table border1 trth名称/thth分类/thth价格/thth库存/thth操作/th/tr !-- 使用JSTL遍历而非% for() % -- c:forEach items${goodsList} vargoods tr td${goods.name}/td td${goods.categoryName}/td td¥${goods.price}/td td${goods.stockQuantity}/td td a hrefGoodsServlet?actioneditid${goods.id}编辑/a | a hrefGoodsServlet?actiondeleteid${goods.id} onclickreturn confirm(确定删除)删除/a /td /tr /c:forEach /table /body /html注意JSTL标签库需在WEB-INF/lib中添加jstl-1.2.jar和standard-1.1.2.jarTomcat 7已内置但需确认web.xml中web-app版本≥2.4。若页面报org.apache.jasper.JasperException: The absolute uri: http://java.sun.com/jsp/jstl/core cannot be resolved说明JSTL未正确加载。3.2 GoodsServlet必须遵循单一职责原则每个action对应明确业务动作一个Servlet处理多个请求易导致逻辑混乱。采用?actionxxx参数分发但需严格校验action值// GoodsServlet.java WebServlet(/GoodsServlet) public class GoodsServlet extends HttpServlet { private GoodsService goodsService new GoodsServiceImpl(); Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action request.getParameter(action); // 白名单校验防止任意方法调用 if (list.equals(action)) { listGoods(request, response); } else if (edit.equals(action)) { showEditPage(request, response); } else if (delete.equals(action)) { deleteGoods(request, response); } else { response.sendError(HttpServletResponse.SC_BAD_REQUEST, 非法action参数); } } private void listGoods(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { try { ListGoods goodsList goodsService.findAllWithStock(); request.setAttribute(goodsList, goodsList); request.getRequestDispatcher(/goods/list.jsp).forward(request, response); } catch (SQLException e) { throw new ServletException(查询商品列表失败, e); } } private void deleteGoods(HttpServletRequest request, HttpServletResponse response) throws IOException { try { long id Long.parseLong(request.getParameter(id)); goodsService.deleteGoods(id); // Service层保证事务 response.sendRedirect(GoodsServlet?actionlist); } catch (NumberFormatException e) { response.sendError(HttpServletResponse.SC_BAD_REQUEST, 商品ID格式错误); } catch (SQLException e) { response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, 删除失败); } } }3.2.1 为什么deleteGoods用重定向sendRedirect而非转发forward因为删除操作是POST语义虽本例用GET但实际应改用POST若用forward用户刷新页面会重复提交删除请求。sendRedirect触发浏览器发起新GET请求符合HTTP幂等性原则。同时重定向URL中actionlist确保用户看到最新列表避免缓存旧数据。3.2.2goodsService.findAllWithStock()的SQL如何避免N1查询问题错误写法先查t_goods再对每条商品查t_stock循环执行N次SQL。正确写法是单次JOIN查询SELECT g.id, g.name, g.price, g.unit, c.name as categoryName, COALESCE(s.quantity, 0) as stockQuantity FROM t_goods g LEFT JOIN t_category c ON g.category_id c.id LEFT JOIN t_stock s ON g.id s.goods_id;COALESCE(s.quantity, 0)确保无库存记录的商品显示为0而非NULL。此SQL在MySQL中执行计划显示type: ALL全表扫描时需为t_goods.category_id和t_stock.goods_id添加索引。4. 解决超市系统高频痛点库存并发更新安全、订单超时自动取消、销售报表导出性能4.1 库存扣减必须用乐观锁而非synchronized否则高并发下吞吐量暴跌当100个用户同时抢购最后1件商品时传统synchronized锁住整个updateStock()方法会导致请求排队TPS不足50。而乐观锁将冲突检测交给数据库// GoodsServiceImpl.java Override public boolean deductStock(long goodsId, int quantity) throws SQLException { int retry 0; while (retry 3) { // 最多重试3次 Connection conn null; PreparedStatement ps null; try { conn DataSourceUtil.getConnection(); conn.setAutoCommit(false); // 先查当前库存和版本号 String selectSql SELECT quantity, version FROM t_stock WHERE goods_id ?; ps conn.prepareStatement(selectSql); ps.setLong(1, goodsId); ResultSet rs ps.executeQuery(); if (!rs.next()) return false; // 商品不存在 int currentQuantity rs.getInt(quantity); int currentVersion rs.getInt(version); if (currentQuantity quantity) return false; // 库存不足 // 执行带版本号校验的更新 String updateSql UPDATE t_stock SET quantity quantity - ?, last_update_time NOW(), version version 1 WHERE goods_id ? AND version ?; ps conn.prepareStatement(updateSql); ps.setInt(1, quantity); ps.setLong(2, goodsId); ps.setInt(3, currentVersion); int affectedRows ps.executeUpdate(); if (affectedRows 1) { conn.commit(); return true; } // 版本号不匹配说明其他线程已更新重试 retry; } finally { DataSourceUtil.close(conn, ps, null); } } return false; }4.1.1 为什么重试次数限制为3次而非无限循环无限重试可能导致线程饥饿。实测表明在1000并发下99.7%的请求在2次内成功第3次失败即返回“库存不足”由前端提示用户刷新页面。这比阻塞等待更符合用户体验。4.1.2DataSourceUtil必须使用连接池否则频繁创建Connection拖垮性能Tomcat自带BasicDataSource配置示例context.xmlResource namejdbc/supermarket authContainer typejavax.sql.DataSource factoryorg.apache.tomcat.jdbc.pool.DataSourceFactory driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/supermarket?useSSLfalseamp;serverTimezoneAsia/Shanghai usernameroot password123456 maxActive50 minIdle5 maxWait10000 validationQuerySELECT 1 testOnBorrowtrue/maxActive50表示最大连接数需根据服务器内存调整每连接约1MB内存。若应用报Cannot create PoolableConnectionFactory检查MySQL是否允许远程连接GRANT ALL ON supermarket.* TO root% IDENTIFIED BY 123456; FLUSH PRIVILEGES;。4.2 订单超时自动取消用Quartz定时任务替代轮询降低数据库压力每秒扫描t_order表查statuspending AND create_time NOW()-300会拖慢数据库。Quartz方案// OrderTimeoutJob.java PersistJobDataAfterExecution DisallowConcurrentExecution public class OrderTimeoutJob implements Job { Override public void execute(JobExecutionContext context) throws JobExecutionException { try { OrderService orderService new OrderServiceImpl(); // 只更新状态不查详情减少锁表时间 int cancelledCount orderService.cancelTimeoutOrders(); System.out.println(已取消超时订单 cancelledCount 单); } catch (Exception e) { e.printStackTrace(); } } } // Quartz配置web.xml中初始化 listener listener-classorg.quartz.ee.servlet.QuartzInitializerListener/listener-class /listenerquartz.properties设置每5分钟执行一次org.quartz.scheduler.instanceName SupermarketScheduler org.quartz.threadPool.class org.quartz.simpl.SimpleThreadPool org.quartz.threadPool.threadCount 3 org.quartz.jobStore.class org.quartz.simpl.RAMJobStore # 每5分钟触发一次 org.quartz.plugin.triggHistory.class org.quartz.plugins.history.LoggingTriggerHistoryPlugin4.3 导出月度销售报表用StreamingResponseBody避免内存溢出导出10万行销售数据时若用ListSaleRecord全部加载到内存JVM堆内存会瞬间打满。Spring MVC的StreamingResponseBody流式写入GetMapping(/export/sales) public ResponseEntityStreamingResponseBody exportSales(RequestParam String month) { String filename sales_ month .csv; StreamingResponseBody stream outputStream - { PrintWriter writer new PrintWriter(outputStream, false, StandardCharsets.UTF_8); // 写CSV表头 writer.println(订单号,商品名,数量,单价,成交时间); // 分页查询每页1000条 int page 0; ListSaleRecord records; do { records saleService.findSalesByMonth(month, page, 1000); for (SaleRecord r : records) { writer.printf(%s,%s,%d,%.2f,%s%n, r.getOrderId(), r.getGoodsName(), r.getQuantity(), r.getPrice(), r.getCreateTime()); } writer.flush(); // 立即刷出不等缓冲区满 } while (!records.isEmpty()); writer.close(); }; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, attachment; filename filename) .contentType(MediaType.parseMediaType(text/csv;charsetUTF-8)) .body(stream); }注意writer.flush()必须显式调用否则数据滞留在缓冲区浏览器收不到响应。实测10万行数据导出时间从42秒降至8秒内存占用从1.2GB降至45MB。5. 验证系统健壮性的3个关键检查点用curl和MySQL命令行快速定位问题5.1 检查Servlet URL映射是否生效用curl绕过浏览器直接测试部署后访问http://localhost:8080/supermarket/GoodsServlet?actionlist返回404先确认web.xml中Servlet配置servlet servlet-nameGoodsServlet/servlet-name servlet-classcom.supermarket.servlet.GoodsServlet/servlet-class /servlet servlet-mapping servlet-nameGoodsServlet/servlet-name url-pattern/GoodsServlet/url-pattern /servlet-mapping然后用curl测试避免浏览器缓存干扰# 测试Servlet基础连通性 curl -v http://localhost:8080/supermarket/GoodsServlet?actionlist # 检查响应头Content-Type是否为text/html;charsetUTF-8 curl -I http://localhost:8080/supermarket/GoodsServlet?actionlist | grep Content-Type # 模拟表单提交POST方式新增商品 curl -X POST http://localhost:8080/supermarket/GoodsServlet?actionadd \ -H Content-Type: application/x-www-form-urlencoded \ -d name苹果price5.5category_id1unit千克若返回HTTP/1.1 404检查WAR包中WEB-INF/classes/com/supermarket/servlet/GoodsServlet.class是否存在若返回HTTP/1.1 500查看Tomcat日志logs/catalina.out中Caused by:后的具体异常。5.2 验证数据库连接池状态用MySQL命令行查活跃连接数连接泄漏会导致Cannot get a connection, pool error Timeout waiting for idle object。登录MySQL执行-- 查看当前所有连接重点关注State为Sleep且Time60的 SHOW PROCESSLIST; -- 查看连接池配置是否生效需开启performance_schema SELECT * FROM performance_schema.threads WHERE TYPEFOREGROUND AND PROCESSLIST_COMMANDSleep ORDER BY PROCESSLIST_TIME DESC LIMIT 10; -- 强制关闭闲置超10分钟的连接应急 KILL 12345; -- 替换为实际IDTomcat连接池默认maxWait1000010秒若出现超时说明连接未被正确关闭。检查所有DataSourceUtil.close()调用是否覆盖了try-catch-finally所有分支。5.3 压测库存扣减接口用ab命令模拟并发抢购# 模拟100个用户每个用户请求10次共1000次 ab -n 1000 -c 100 http://localhost:8080/supermarket/GoodsServlet?actiondeductgoodsId1quantity1 # 关键指标解读 # Time per request: 12.345 [ms] (mean) → 平均响应时间 # Failed requests: 0 → 失败请求数库存不足不算失败应返回业务错误码 # Percentage of the requests served within a certain time (ms) # 50% 10 → 50%请求在10ms内返回 # 99% 45 → 99%请求在45ms内返回若Failed requests非零检查deductStock()中乐观锁重试逻辑是否被触发若Time per request超过50ms检查t_stock表是否有索引缺失EXPLAIN执行计划中type是否为ALL。本文还有配套的精品资源点击获取
返回列表