基于JSP+Servlet+MySQL的超市管理系统开发实战教程
2026/9/14 5:53:03 网站建设 项目流程

简介:这是一套基于 JSP 的超市管理系统,采用 B/S 模式设计,适用于 Java Web 课程设计、毕业设计或初学者实战练习。系统在 Myeclipse 8.5 环境下开发,数据库为 MySQL 5.0,可部署于 Tomcat 7.0,完整演示了从数据库设计到前端页面的开发流程。功能上覆盖人事管理、销售管理、进货管理、库存管理和客户管理五大模块,包括职工信息与供货商信息的增删改查、销售盘点、进货记录维护、商品信息及库存更新、用户密码修改等,逻辑清晰,便于二次开发。压缩包约 1.06MB,内含完整 Java 源代码、数据库脚本 jspsupermarket.sql 以及数据库配置文件 DBO.java,部署时只需导入数据库并配置连接即可。目前已有 1023 人学习下载,对于希望系统掌握 JSP+Servlet+MySQL 整合开发的读者来说,是一份实用且完整的参考项目。

1. 一套 JSP 超市管理系统,为什么值得自己写一遍

小型超市的日常经营里,商品入库、货架补货、前台收银、库存清点相互咬合,一个环节出错就容易出现“账上还有货、货架已空”。用 JSP + Servlet + MySQL 把这套流程做成 web 结构的管理系统,是 Java 课程与实训项目的经典选题,也是理解 B/S 架构最直接的入手点:MyEclipse 负责集成开发,MySQL 存数据,JSP 出页面,Java 写业务,合起来就是典型的三层 web 工程。

对新手来说,这套系统的价值不在“新”,而在完整:JDBC 连接、Servlet 生命周期、JSP 编译过程、SQL 事务,这些面试常考的基础,亲手趟一遍才记得住。存量 JSP 系统在现网中依然不少,能读懂这类老项目对维护和改造同样有用。下面从环境搭起,一路讲到部署。

2. MyEclipse 搭建 JSP web 工程:版本配对与第一个页面

2.1 版本配对:JDK、Tomcat、MySQL 怎么选

做这个项目默认不走 Maven,而是 MyEclipse 里最传统的 Web Project 方式,依赖 jar 直接放 WEB-INF/lib。这种做法的好处是每个包都看得见,也最贴合老教材的讲解路径,排错时不会被构建工具遮蔽问题。

版本上我一般这样配,兼容性最稳:

组件推荐版本说明
JDK1.8与 Tomcat 8.5、MyEclipse 内置编译器都兼容
Tomcat8.5.x对应 Servlet 3.1 / JSP 2.3,足够本项目使用
MySQL5.7 或 8.08.0 需换 mysql-connector-j 8.x,并加 serverTimezone
MyEclipse2017 及以上自带 Server 插件,创建工程时直接选 Java EE 版本

几个配错就起不来的组合要记住:JDK 17 配 Tomcat 7 基本直接报错;MySQL 8.0 还用 mysql-connector-java 5.1.47 会报 "Public Key Retrieval is not allowed",解决方法是把连接 URL 加上 allowPublicKeyRetrieval=true 并保持 useSSL=false;JRE 选了 1.7 而 Tomcat 需要 1.8,启动日志里会出现 UnsupportedClassVersionError。MySQL 的安装配置本身不复杂,root 口令别设太复杂,字符集在建库语句里统一控制,这部分在下一章展开。

2.2 Web 工程目录结构:src 与 WebRoot 各放什么

在 MyEclipse 里新建 Web Project,工程名叫 SupermarketMS,生成的骨架是这样的:

SupermarketMS/ ├── src/com/supermarket/ │ ├── entity/ # 商品、订单等实体类 │ ├── dao/ # JDBC 数据访问层 │ ├── servlet/ # 控制器 │ └── util/ # 数据库连接工具 └── WebRoot/ ├── WEB-INF/ │ ├── web.xml # 部署描述符 │ └── lib/ # mysql-connector-java.jar 等 ├── css/ ├── js/ ├── login.jsp └── index.jsp

src 下的 Java 类编译后进 WEB-INF/classes,WebRoot 下的 jsp、css、js 原样发布到 Tomcat 的 webapps。这是典型的 B/S web 结构:浏览器发请求到 Tomcat,JSP 和 Servlet 处理逻辑,MySQL 返回数据。浏览器访问的 URL 根路径是工程名,比如 http://localhost:8080/SupermarketMS/login.jsp,这个路径关系决定了整个请求走向。

2.3 在 MyEclipse 里绑定 Tomcat 并运行

IDE 里的配置分两步。第一步绑定运行时:Window → Preferences → Servers → Runtime Environments → Add,选 Apache Tomcat v8.5,指向本地解压目录,JRE 选已装的 JDK 1.8。第二步部署:右键工程 → MyEclipse → Add Deployment,Server 选刚建的 Tomcat,Deploy Type 选 Exploded Archive,这样改动 jsp 后刷新页面就能生效,不用反复打 war 包。

完成后点 Servers 面板的启动按钮,控制台看到 "Server startup in xxx ms" 就说明起来了。注意如果 8080 被占用,要改 Tomcat 安装目录 conf/server.xml 里的三处端口:8005 是关闭端口,8080 是 HTTP 端口,8009 是 AJP 端口,三处不能有重复,否则启动时报端口被占用。

提示:MyEclipse 绑定 JRE 时务必选 JDK 而不是单独的 JRE,否则 JSP 第一次编译时会因为找不到 javac 报 500 错误,这个问题排查起来很隐蔽。

2.4 第一个能跑的 JSP 页面

在 WebRoot 下建 index.jsp,内容可以非常少,目的是验证“JSP → Java → Class”这条编译链路是否通:

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>超市管理系统</title> </head> <body> <h1>系统启动成功</h1> <p>当前时间:<%= new java.util.Date() %></p> </body> </html>

页面里那行 <%%> 是 JSP 表达式,会被翻译成 Servlet 里的 out.print 语句。第一次访问这个页面时,Tomcat 的 Jasper 引擎会先把 jsp 翻译成 java 源文件,再编译成 class 加载,所以第一次响应会慢一点。为了让系统一启动就进登录页而不是看到目录列表,还要在 web.xml 里声明欢迎页:

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <display-name>SupermarketMS</display-name> <welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> </web-app>

web.xml 里 servlet 声明与映射的写法在第 4 章会用到,欢迎页只是第一步。到这里工程骨架已经跑起来了,下一章解决数据层的问题。

3. MySQL 数据库建模与 JDBC 连接层设计

3.1 五张核心表:商品、供应商、员工、订单、明细

超市管理系统的数据模型围绕“进销存”展开,最少需要五张表。设计时把握一个原则:订单头只存与本次销售整体相关的信息,订单明细存每一件商品的单价和数量,这样一次购物车结算对应一条订单头、多条明细,符合第三范式,也方便后期做销售报表。

表名关键字段职责
productbarcode, name, category, sell_price, stock, min_stock, supplier_id商品主档与库存数量
suppliername, contact, phone供应商档案
employeeusername, password, real_name, role登录账号与权限位
sale_orderorder_no, employee_id, total_amount, create_time销售订单头
sale_order_itemorder_id, product_id, quantity, price销售明细

实际项目里还会加 inventory_log 库存流水表、shop 门店表,但最小可用集合就是上面这五张。库存数量冗余在 product 表里,查询快,但每次买卖都要用事务保证它和订单明细的一致性,这是第 4 章的重点。外键约束在课堂项目里可以省,生产系统建议加上,至少要把 sale_order_item.order_id 指向 sale_order.id 的约束落下。

3.2 建表 SQL 与字段设计要点

以下是 MySQL 5.7/8.0 都能直接执行的建库建表脚本:

CREATE DATABASE supermarket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE supermarket; CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, barcode VARCHAR(20) NOT NULL UNIQUE COMMENT '商品条码', name VARCHAR(50) NOT NULL COMMENT '商品名称', category VARCHAR(20) NOT NULL DEFAULT '未分类', sell_price DECIMAL(10,2) NOT NULL COMMENT '售价', stock INT NOT NULL DEFAULT 0 COMMENT '当前库存', min_stock INT NOT NULL DEFAULT 10 COMMENT '库存预警阈值', supplier_id INT, KEY idx_category (category) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; CREATE TABLE sale_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(30) NOT NULL UNIQUE COMMENT '订单号', employee_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售订单头'; CREATE TABLE sale_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT '成交单价', KEY idx_order_id (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售明细';

字段设计上有几个细节值得注意。金额一律用 DECIMAL 而不是 float/double,后者会有精度误差,结账时多一分少一分很难查。order_no 用时间戳加随机串生成,例如 "SO20250101120000123",避免走主键自增暴露日单量。库存数量默认值给 0,插入时由入库流程控制,绝不能允许 NULL,否则后面做加减法时 NULL 参与运算结果全是 NULL,库存就永远不对了。

product 表里已经顺手建了 idx_category,sale_order 和 sale_order_item 也各建了一个二级索引,这是针对“按分类筛选商品”和“按订单关联明细”这两个高频查询建的。索引不是越多越好,写多读少的表只保留主键即可,这一点到 4.4 还会展开。

3.3 手写 DBUtil 连接工具类

不引入 Spring、不用连接池框架之前,先手写一个最朴素的 DBUtil,把驱动加载和连接获取集中在一处:

package com.supermarket.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/supermarket" + "?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement st, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException e) { } } if (st != null) { try { st.close(); } catch (SQLException e) { } } if (conn != null) { try { conn.close(); } catch (SQLException e) { } } } }

URL 里的 useSSL=false 是为了省去证书告警,serverTimezone 是 MySQL 8.0 驱动必填的参数,5.7 加上也不会报错,不写会抛 "The server time zone value" 异常。驱动类名用的是 com.mysql.cj.jdbc.Driver,这是 Connector/J 8.x 的写法;如果用老驱动,类名是 com.mysql.jdbc.Driver,两者不能混用。用户名密码按本机实际修改,不要把生产库口令写死在代码里,后面会提一种外置配置的思路。

DAO 层的调用模式是“谁打开连接谁负责关闭”,每个方法内部用 try-finally 或 try-with-resources 保证连接归还。这里 close 方法对关闭异常不做处理,属于课堂项目可接受的范围;线上系统至少要把关闭失败的异常记到日志里,否则排查连接泄漏时会无从下手。

3.4 连接池:从 DriverManager 到 DBCP 的演进

商品管理页把列表、查询、编辑分散在多个 DAO 方法里,每个请求都走一遍“打开连接→执行 SQL→关闭连接”,会产生大量握手开销。对一个内部管理系统,单机并发二三十人就容易出现数据库连接耗尽,这时要把 DBUtil 换成 DBCP 连接池。

DBCP 的最小配置可以放在 src 下的 dbcp.properties 里:

driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/supermarket?characterEncoding=utf8&serverTimezone=Asia/Shanghai username=root password=123456 initialSize=5 maxTotal=20 maxIdle=10 maxWaitMillis=3000

使用 DBCP 需要在 WEB-INF/lib 里放上 commons-dbcp2 和 commons-pool2 两个 jar。maxWaitMillis 是排队等待连接的毫秒数,超过就抛异常,而不是无限等下去,这个参数在数据库真出问题时能避免请求无限堆积。连接池的意义在于复用物理连接,把连接的创建和销毁成本摊到多次请求上。改造方式是让 DBUtil.getConnection() 从 DataSource 拿连接,DAO 层代码一行都不用改——这是后面遇到性能瓶颈时最先要做的优化,比加索引来得更快。

4. JSP + Servlet + Java 实现商品、销售、库存模块

4.1 分层思路:JSP 只出页面,Servlet 只做转发

继续用经典的三层:JSP 负责展示和表单收集,Servlet 负责接收参数、调 DAO、按结果选页面转发,DAO 负责 SQL。这个结构下 JSP 页面里不写业务判断,不连数据库,只有 JSTL 标签和 EL 表达式。这样分工的意义在于,改了页面样式不会碰到 Java 代码,改了库存算法也不需要前端同事来 review JSP——说白了,这个项目里的“前端”就是 JSP 本身。

Servlet 在 web.xml 里的映射方式如下,url-pattern 写成 /product 而不是 *.do,这是现在 Servlet 3.0 之后推荐的扩展名无关写法:

<servlet> <servlet-name>ProductServlet</servlet-name> <servlet-class>com.supermarket.servlet.ProductServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>ProductServlet</servlet-name> <url-pattern>/product</url-pattern> </servlet-mapping>

一个 Servlet 对应多个动作时,按 action 参数分发,请求路径大致长这样:/product?action=list&page=2。本系统的 URL 动作表如下:

请求路径动作对应模块
/product?action=list分页查询商品商品管理
/product?action=addOrEdit新增或修改商品管理
/product?action=delete删除商品商品管理
/sale?action=checkout收银结算销售模块
/inventory?action=warn库存预警列表库存模块

4.2 商品管理:PreparedStatement 分页与增删改

商品列表最常见的需求是分页,MySQL 用 LIMIT 实现,注意计算偏移量时页码从 1 开始要减一,这个 Off-by-one 错误几乎每个新手都会踩一次。DAO 层实现如下:

public List<Product> findByPage(int page, int pageSize) throws SQLException { List<Product> list = new ArrayList<>(); String sql = "SELECT id, name, category, sell_price, stock " + "FROM product ORDER BY id LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, (page - 1) * pageSize); ps.setInt(2, pageSize); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Product p = new Product(); p.setId(rs.getInt("id")); p.setName(rs.getString("name")); p.setCategory(rs.getString("category")); p.setSellPrice(rs.getBigDecimal("sell_price")); p.setStock(rs.getInt("stock")); list.add(p); } } } return list; }

这里用 PreparedStatement 而不是 Statement,有两个理由:一是 ? 占位符把参数和 SQL 语句分开,从根上避免了拼接字符串带来的 SQL 注入风险;二是相同结构的 SQL 在 MySQL 里会走预编译缓存,重复执行时解析开销更小。setInt 的第一个参数是从 1 开始的下标,不是 0,写错会直接报参数越界。

对应的 JSP 页面用 JSTL 输出,页面顶部记得引入标签库:

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <c:forEach var="p" items="${page.list}"> <tr> <td>${p.id}</td> <td>${p.name}</td> <td>${p.sellPrice}</td> <td>${p.stock}</td> <td><a href="product?action=edit&id=${p.id}">编辑</a></td> </tr> </c:forEach>

如果 MyEclipse 自带的 JSTL 实现版本较旧,uri 用 http://java.sun.com/jsp/jstl/core;新容器上也可以换成 jakarta 命名空间,具体看 Servlet 版本。${page.list} 是 EL 表达式,它自动调用 page 对象的 getList() 方法,所以 PageBean 里必须有对应的 getter,否则页面上什么都渲染不出来且不报错。

提示:JSTL 需要 jstl.jar 和 standard.jar 两个包同时在 WEB-INF/lib 下,只放一个会出现 "can not find taglib" 的 500 错误。

4.3 销售收银:事务与行锁保证库存不超卖

销售模块是整套系统里最容易出数据错误的地方。收银流程要同时做四件事:校验库存、扣减库存、写订单头、写订单明细,任何一步失败都应该整体回滚,否则会出现“钱收了、货没扣”或者“货扣了、库存变成负数”的脏数据。事务控制在 Service 或 Servlet 层统一管理连接,核心代码如下:

Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); // 1. 锁定该商品库存行,防止两个收银台并发扣减 String lockSql = "SELECT stock FROM product WHERE id = ? FOR UPDATE"; try (PreparedStatement ps = conn.prepareStatement(lockSql)) { ps.setInt(1, productId); try (ResultSet rs = ps.executeQuery()) { if (rs.next() && rs.getInt("stock") < quantity) { throw new RuntimeException("库存不足"); } } } // 2. 扣减库存,WHERE 里再判断一次库存,双保险 String updateSql = "UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?"; try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, quantity); ps.setInt(2, productId); ps.setInt(3, quantity); int rows = ps.executeUpdate(); if (rows == 0) { throw new RuntimeException("库存不足,扣减失败"); } } // 3. 插入订单头,用 RETURN_GENERATED_KEYS 取回自增主键 // 4. 循环插入订单明细,累加 total_amount conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); DBUtil.close(conn, null, null); }

SELECT ... FOR UPDATE 是 InnoDB 的行级锁,两个收银台同时结算同一件商品时,第二个请求会阻塞在这条语句上,直到前一个事务提交或回滚。第 2 步在 UPDATE 的 WHERE 里再加一次 stock >= ? 条件,属于双保险——即使忘了加锁,这条语句本身也能拦截库存不足的写操作。取回订单自增主键时要用 connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS),插入后通过 rs.getLong(1) 拿到订单头 id,再把它作为外键写明细;千万别用“插入后再查 max(id)”这种方式,并发下可能拿到别人的订单号。

这段逻辑也是面试里高频考察的内容,事务隔离级别、脏读、锁范围都能串起来问,属于 java 八股里必须能默写的那类题。想再省事的做法是把整个结账流程封装成 MySQL 存储过程,Java 端只调 CALL,但存储过程把业务规则藏进了数据库,新同事接手时理解成本更高,小团队不建议这么做。

4.4 库存预警与索引优化

库存模块工作量不大,但查询质量最能体现水平。预警列表要查“当前库存小于等于阈值”的商品,按缺货程度排序:

SELECT id, name, category, stock, min_stock FROM product WHERE stock <= min_stock ORDER BY (stock - min_stock) ASC;

这条 SQL 的坑在于 ORDER BY 里的表达式无法命中普通的单列索引,即使对 stock 建了索引,优化器也得把符合条件的数据取出来计算再排序。商品表几千行时完全没问题,但到了百万级就要调整策略:把预警阈值判断放到定时任务里,扫描结果落到一张 low_stock_report 表,页面只查这张结果表。定时任务用 Quartz 做每日凌晨扫描即可,JSP 页面本身不承担这个计算量。

索引方面,结合本系统实际查询,两个索引最值得建:

CREATE INDEX idx_product_category ON product(category); CREATE INDEX idx_sale_order_time ON sale_order(create_time);

分类索引服务商品管理页的按分类筛选,时间索引服务销售报表的日/月汇总。反例是不加思考地对 barcode 建前缀索引——条码本身有唯一索引就够了,再多就是浪费磁盘和写入时间。判断索引是否生效,用 EXPLAIN SELECT ... 看 type 列,从 const、ref 退化成 ALL 就要警惕全表扫描了。

5. JSP 部署与踩坑:编译产物、中文乱码与 Tomcat 上线

5.1 JSP 编译后的 class 文件到底存在哪

JSP 不是直接执行的,Tomcat 的 Jasper 引擎在第一次访问时把它翻译成 java 源文件再编译成 class,产物默认放在 Tomcat 安装目录的 work 下,路径规律是:

{TOMCAT_HOME}/work/Catalina/localhost/{工程名}/org/apache/jsp/

目录里能看到 index_jsp.java 和 index_jsp.class,对应 WebRoot 下的 index.jsp。看过这两个文件就明白 JSP 本质就是 Servlet。如果改了 JSP 但页面不更新,优先删整个 work/Catalina 目录再重启,而不是怀疑浏览器缓存——在 MyEclipse 里跑 Tomcat 时,只有运行配置勾选了自动构建,改动 jsp 才会真正触发重新翻译和编译。

5.2 中文乱码的三处检查点

超市系统里商品名、供应商联系人全是中文,乱码基本出现在三个位置,按顺序排查。第一处是 JSP 页面本身,page 指令里 pageEncoding、contentType 两处都要是 UTF-8,并且也要一致。第二处是 Servlet 接收参数,在调用 getParameter 之前先执行 request.setCharacterEncoding("UTF-8"),放在读取参数之后就不生效了。第三处是数据库连接,JDBC URL 里带 characterEncoding=utf8,且建库时用了 utf8mb4。三处都对了,中文从页面到数据库来回就不会乱。

5.3 导出 WAR 部署与静态资源前置

系统在 MyEclipse 里跑通后,交付时不打开 IDE 启动,而是导出标准 WAR:右键工程 → Export → WAR file,放到独立 Tomcat 的 webapps 目录下,启动后自动解压。数据库初始化脚本导成 sql 文件,执行顺序是建库、建表、插测试数据。

访问量上来之后,常见改造是把 Tomcat 放到 nginx 后面,静态资源交给 nginx 直接返回。由于 JSP 必须经 Tomcat 编译,nginx 只代理动态请求,把 jsp 和 Servlet 路径转发给 Tomcat 的 8080 端口,同时让 nginx 直接返回 css、js、图片目录。多 web 系统部署时,只改 nginx 的 server_name 或 location 映射到不同端口即可,Tomcat 本身不需要做多虚拟机配置。最后用 curl -I 验证响应头,再开两个浏览器窗口对同一件库存不足的商品发起收银请求,事务代码在并发下还能保证库存不为负,这套系统就可以交出去了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询