简介:这是一份基于Jsp+Tomcat+Servlet+Filter架构的超市管理系统完整源码与数据库打包,面向计算机、数学、电子信息等专业的学生,可作为课程设计、期末大作业或毕业设计的参考。项目围绕用户与账单管理等核心功能展开,通过Servlet接收请求、Filter处理过滤、JSP渲染界面,并配套数据库脚本,解压后即可部署运行。压缩包包含507个文件,主要由132个class编译类、72个jar依赖库、63个JSP页面、31个Java源文件、15个CSS样式及数据库sql文件组成,便于查看逻辑、修改样式和扩展功能。资源包大小29.9MB,体积适中。目前已有108人学习下载,适合具备一定Java Web基础、能读懂代码并希望实践综合项目的开发者,用来学习分层架构、会话控制与数据库交互,或在此基础上二次开发。
1. 到底该不该接手这个老技术栈的超市管理系统
当有人丢给你一个“基于Jsp+Tomcat+Servlet+Filter的超市管理系统源码+数据库.zip”,第一反应可能是“都2025年了还玩 JavaWeb 老一套”。但这类项目恰恰是课程设计和中小型内部系统里存活率最高的形态:依赖少、启动快、逻辑透明,一个 Tomcat 加一个 MySQL 就能跑,改起来比动辄几十个 starter 的 Spring Boot 工程要直白得多。它解决的核心诉求很具体:商品档案、分类库存、供应商、员工登录、前台收银和订单记录。适合两类人——需要交课设作业的学生,和要在一周内交付一个社区小超市管理系统的初级开发者。与其纠结换不换框架,不如先把这套组合吃透。
2. Jsp+Tomcat+Servlet+Filter 这套组合:为什么课设和中小系统还在用它
2.1 一条请求从登录到商品列表经过几个环节
老一套的请求链路一句话就能讲完:浏览器请求先进 Filter,Filter 决定放不放行;放行后到达 Servlet,Servlet 拿参数调 DAO 查数据库;最后把结果放进 request 域,转发给 JSP 渲染。整个链路上每一步都是公开的类和方法,没有框架层帮你“魔法般”完成工作,所以断点调试非常舒服。我不止一次遇到刚转 Spring Boot 的同事,一个过滤器只因为 Bean 装配顺序不对就排查半天,在 Servlet+Filter 里完全不会有这种黑匣子。
这个组合另一个被低估的点是 Filter。登录拦截、字符编码、接口访问白名单,三种最常见的基础设施都能在 web.xml 或@WebFilter注解里统一声明,不侵入任何业务代码。偶尔有人问 Filter 和 Spring 拦截器差在哪,本质差别很小——Filter 是 Servlet 规范的一部分,Tomcat 原生支持,不需要引入 Spring 就能用;拦截器依赖 SpringMVC 容器,老项目里为了一个登录校验去引全家桶,得不偿失。
值得注意的是 Filter 的执行顺序完全由 web.xml 里<filter-mapping>的声明顺序决定,声明靠前的先执行。字符编码过滤器必须排在权限过滤器之前,否则请求参数在被读了一次之后才转码,中文乱码就成了定局。这一点在源码包自带多个 Filter 时尤其要留意,也是我读任何老项目时最先看 web.xml 的原因。
2.2 解压后先别急着导入 IDE:把目录结构读一遍
拿到压缩包,我一般不会先打开 IDEA,而是先解压看结构。常见的 JSP 老项目(非 Maven)目录长这样:
supermarket/ ├── src/ │ ├── com/supermarket/ │ │ ├── entity/ # 实体类:Product、Employee、Order │ │ ├── dao/ # JDBC 数据访问 │ │ ├── servlet/ # 控制器 │ │ └── filter/ # 登录/编码过滤器 │ └── db.properties # 数据库连接参数 ├── web/ │ ├── WEB-INF/ │ │ ├── web.xml # Servlet/Filter 映射声明 │ │ └── jsp/ # 受保护的 JSP 页面 │ ├── static/ # css/js/图片 │ ├── login.jsp │ └── index.jsp └── docs/ └── supermarket.sql # 数据库脚本这个结构本身就暗示了项目的答案:web.xml 是路由中心,db.properties 是唯一要改的配置文件,JSP 放在 WEB-INF 下意味着只能通过 Servlet 转发访问。先花十分钟把 web.xml 里<servlet-mapping>和 Filter 声明顺序读一遍,比直接跑起来再猜 404 要省一个晚上。
如果你拿到的包是 Maven 结构,则多一个 pom.xml,依赖集中声明,编译打包一条mvn clean package完成;如果不是 Maven,则需要在 WEB-INF/lib 下手动放 jar 包。判断方法很简单:看根目录有没有 pom.xml。有,就按 Maven 工程导入;没有,就在 IDEA 的 Artifacts 配置里把依赖的 jar 一并打进去,这一步做漏,部署到独立 Tomcat 就会抛NoClassDefFoundError。
2.3 最小启动流程:从数据库脚本到看到登录页
跑通这类项目的最小流程是五步:启动 MySQL,导入 SQL 脚本,改 db.properties 里的账号密码,把工程部署到 Tomcat,启动后访问http://localhost:8080/supermarket/。Tomcat 可以下载解压版,不需要安装程序,前提是环境变量JAVA_HOME指向的 JDK 版本和 Tomcat 匹配。
# 以解压版 Tomcat 8.5 + MySQL 8 为例 mysql -uroot -p < docs/supermarket.sql # 修改 src/db.properties 里的 jdbc.password 后重新编译打包 # 将打包好的 supermarket.war 放入 Tomcat 的 webapps 目录 cp target/supermarket.war $CATALINA_HOME/webapps/ # 启动并观察日志,出现 "Server startup in ... ms" 即成功 $CATALINA_HOME/bin/startup.sh tail -f $CATALINA_HOME/logs/catalina.out这段命令有两个实操点:一个是用mysql < 脚本在外部导入,比在客户端里一句句执行可靠,脚本里如果有USE supermarket也不会影响;另一个是tail -f catalina.out,Tomcat 很多启动失败信息只在日志里出现,控制台窗口反而被吞掉。在 IDEA 里配置 Tomcat 也同理,关键是把 Deployment 里的 Application context 设为/supermarket,并勾选 Deploy at server startup 自动部署。
IDEA 配 Tomcat 时,很多人卡在 Application context 这一步。我一般把它设为/supermarket,这样启动后访问的就是http://localhost:8080/supermarket/login.jsp,而不是默认的根路径。还要分清 Update resources 和 Update classes and resources:改 JSP 用前者热更新,改了 Java 类就要用后者重启上下文,否则你会怀疑“为什么我改了代码没生效”。
3. 数据库设计与初始化:五张表把超市进销存串起来
3.1 这个业务模型为什么只需要五张表
超市管理系统听起来复杂,去掉营销和会员以后,核心数据只有五类:商品、商品分类、供应商、员工、销售订单。商品挂在分类和供应商下面,订单拆分出订单明细,员工管操作人。复杂连锁超市的 ERP 可能有几十张表,但一个课设或社区小超市的源码包,五张主表加两张关联表已经是完整闭环。
设计时有一条硬规矩:金额用 DECIMAL 不用 FLOAT。浮点数在购物车里累加会出现 2.899999 这种玄学结果,顾客看到价格不对会直接投诉。库存字段用 INT 并加默认值 0,查询时配合stock <= 0就能做下架判断。下面这张表给了一个可参考的最小字段集:
| 表名 | 核心字段 | 约束与说明 |
|---|---|---|
| category | id, name, remark | name 唯一,分类不做层级 |
| supplier | id, name, contact, phone | 供应商联系人 |
| product | id, category_id, supplier_id, name, price, stock, status | price DECIMAL(10,2),status 控制上下架 |
| employee | id, username, password, real_name, role | username 唯一,role 区分管理员/收银员 |
| sale_order | id, order_no, employee_id, total_amount, create_time | order_no 唯一,落单时间做索引 |
| order_item | id, order_id, product_id, quantity, price | 订单明细,一对多关联 sale_order |
这种设计的边界也很明确:不做多仓库、不打折促销、不记录会员积分。超过这个边界,这套源码就要加表,而这正是第 6 章要讲的方向。索引方面,只对高频查询字段建:product.name、sale_order.create_time和order_no。订单表每次收银都要按时间倒序查,idx_create_time是最常被用到的;索引不是越多越好,每多一个索引,INSERT 和 UPDATE 就要多维护一棵 B+ 树,小系统里两三个索引已经够用。
3.2 建库建表和初始化数据的 SQL 脚本
拿到包里的数据库脚本(通常是 supermarket.sql),如果你打算重建库,脚本主体和我下面这段等价。注意建库语句必须带字符集,否则中文注释和商品名都会变成问号:
CREATE DATABASE IF NOT EXISTS supermarket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE supermarket; CREATE TABLE category ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE, remark VARCHAR(200) ) ENGINE=InnoDB; CREATE TABLE supplier ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, contact VARCHAR(20), phone VARCHAR(20) ) ENGINE=InnoDB; CREATE TABLE product ( id INT AUTO_INCREMENT PRIMARY KEY, category_id INT NOT NULL, supplier_id INT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, status TINYINT DEFAULT 1, KEY idx_category (category_id), KEY idx_name (name), CONSTRAINT fk_product_category FOREIGN KEY (category_id) REFERENCES category(id) ) ENGINE=InnoDB; CREATE TABLE employee ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(30), role VARCHAR(20) DEFAULT 'CASHIER' ) ENGINE=InnoDB; CREATE TABLE sale_order ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(20) NOT NULL UNIQUE, employee_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_create_time (create_time), CONSTRAINT fk_order_employee FOREIGN KEY (employee_id) REFERENCES employee(id) ) ENGINE=InnoDB; CREATE TABLE order_item ( id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL, CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES sale_order(id), CONSTRAINT fk_item_product FOREIGN KEY (product_id) REFERENCES product(id) ) ENGINE=InnoDB; INSERT INTO employee (username, password, real_name, role) VALUES ('admin', 'e10adc3949ba59abbe56e057f20f883e', '系统管理员', 'ADMIN'); INSERT INTO category (name, remark) VALUES ('饮料', '含碳酸饮料与果汁'), ('零食', '膨化食品与糖果'), ('日用品', '清洁与个人护理'); INSERT INTO supplier (name, contact, phone) VALUES ('恒丰食品', '李经理', '13800000001'); INSERT INTO product (category_id, supplier_id, name, price, stock, status) VALUES (1, 1, '可乐 500ml', 3.50, 120, 1), (1, 1, '矿泉水 550ml', 2.00, 200, 1), (2, 1, '薯片 原味', 6.80, 80, 1);几个值得较真的选择:所有表都指定 ENGINE=InnoDB,因为收银台要事务下单,MyISAM 不支持行级锁;外键约束保留,它能阻止删掉正在被订单引用的商品,这是老项目里最容易漏的;admin 的密码是123456的 MD5 值,源码包大多用这种存储方式,只适合课设,上线前要换成 BCrypt。如果包里的脚本是 utf8 而不是 utf8mb4,建议先执行一条ALTER DATABASE supermarket CHARACTER SET utf8mb4,否则表情符号和生僻字会在 JSP 页面显示成乱码。插入的示例数据也别急着删,商品列表页没有数据时,很多新手会误以为自己代码写错了。
3.3 连接数据库的三个必调参数
脚本导完,决定系统能不能连上库的是 db.properties。这个文件通常是整个项目里唯一的“后悔药”,改错了启动会报Communications link failure,改对了就再也碰不到它。我给的参数模板如下:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/supermarket?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 jdbc.username=root jdbc.password=你的实际密码注意:
serverTimezone不写,MySQL 8 的驱动会拿服务器默认时区去解析 DATETIME,日期字段全部偏移 8 小时,表现为“下单时间对不上”。
参数按重要性排:useSSL=false是去掉本地连接的无谓加密握手,不加也能连但会有一大段警告日志,看着吓人;characterEncoding=utf8决定中文从 MySQL 到 JSP 全程不乱码,少一个都可能出现“???”。驱动类名也要分清:MySQL 5 是老驱动com.mysql.jdbc.Driver,MySQL 8 是com.mysql.cj.jdbc.Driver,两个写反都会启动即抛ClassNotFoundException——这种问题在搜索“tomcat启动出现 java.lang.ClassNotFoundException”时能找到一堆同款帖子。
如果连接池里配的是 maxActive=20,但系统并发只有 5 个收银员,可以放心下调到 10。连接数不是越大越好,每个连接都是 MySQL 的一个线程,配大了反而把数据库拖垮——这是我在一个会员系统上踩过的坑,连接池超限最典型的报错是Too many connections。
4. 用 Filter+Servlet+Jsp 打通登录到商品列表:核心代码与参数说明
4.1 Filter 权限拦截与放行规则
登录拦截是这类项目里最重要的 Filter。我见过不少把权限判断写在每个 Servlet 里的写法,代码重复不说,漏一个接口就裸奔。正确做法是集中在 LoginFilter 里,用一条链统一处理:
package com.supermarket.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; @WebFilter("/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 放行登录页、登录接口、静态资源与错误页 String uri = request.getRequestURI(); if (uri.endsWith("/login.jsp") || uri.endsWith("/LoginServlet") || uri.contains("/static/") || uri.contains("/error")) { chain.doFilter(req, resp); return; } // 用 getSession(false) 避免为每个请求创建无用的会话 HttpSession session = request.getSession(false); if (session != null && session.getAttribute("loginUser") != null) { chain.doFilter(req, resp); } else { response.sendRedirect(request.getContextPath() + "/login.jsp"); } } }这段代码有三个细节值得说。第一是@WebFilter("/*")配合 Servlet 3.0,省去在 web.xml 里写映射,但如果包里 web.xml 版本低于 3.0,注解不会生效,要在 web.xml 里补<filter>和<filter-mapping>。第二是放行条件的顺序,只要请求 URI 以/login.jsp结尾就放行,意味着登录页本身不需要会话,否则会死循环——被拦截跳转到登录页,登录页又要被拦。第三是getSession(false),参数为 false 时没有会话就返回 null,不会新建会话,这一点在高并发下能省不少内存,也是网上常搜的“servlet 登录拦截 session 判断”的标准写法。
4.2 Servlet 做商品分页查询的请求入口
Filter 只管放行,真正的参数解析在 Servlet 里。商品列表页最常见的需求是分页和关键字搜索,一个 ProductServlet 就能把这两件事做完:
package com.supermarket.servlet; import com.supermarket.dao.ProductDao; import com.supermarket.entity.Product; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; @WebServlet("/product/list") public class ProductServlet extends HttpServlet { private final ProductDao productDao = new ProductDao(); @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // page 参数默认为 1,直接拼进 SQL 做分页 int page = 1; String pageStr = request.getParameter("page"); if (pageStr != null && !pageStr.isEmpty()) { try { page = Integer.parseInt(pageStr); } catch (NumberFormatException e) { page = 1; // 用户手输 ?page=abc 时兜底 } } String keyword = request.getParameter("keyword"); List<Product> list = productDao.findByPage(page, 10, keyword); // 把结果放进 request 域,转发到 JSP request.setAttribute("list", list); request.setAttribute("currentPage", page); request.getRequestDispatcher("/WEB-INF/jsp/product_list.jsp") .forward(request, response); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doGet(request, response); } }两个参数层面的实践:Integer.parseInt(pageStr)在用户传?page=abc时会抛 NumberFormatException,更稳的写法是 try-catch 后再兜底成 1,用户手输 URL 是很常见的;findByPage(page, 10, keyword)里的 10 是每页条数,老项目喜欢写死在代码里,改成从常量类读取会方便后期调。转发到/WEB-INF/jsp/下的页面而不是直接重定向,是因为 WEB-INF 对浏览器不可见,用户无法绕过 Servlet 直接看 JSP 源码结构,这是安全上的一个基础细节。doPost 里直接转调 doGet,则表单搜索和链接点击共用同一套逻辑,少写一遍重复代码。
4.3 JSP 页面用 EL 和 JSTL 渲染列表
JSP 最容易被写烂的地方是嵌入大量<%Java 代码。维护过一个把 200 行 Java 写进 jsp 的项目之后,你会认同“页面里只留标签和 EL”这条铁律。商品列表页面的循环渲染用 JSTL 的<c:forEach>加 EL 表达式,十几行搞定:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <body> <table border="1"> <tr> <th>ID</th><th>商品名</th><th>分类</th><th>价格</th><th>库存</th> </tr> <c:forEach items="${list}" var="p"> <tr> <td>${p.id}</td> <td>${p.name}</td> <td>${p.categoryId}</td> <td>${p.price}</td> <td>${p.stock}</td> </tr> </c:forEach> </table> <a href="${pageContext.request.contextPath}/product/list?page=${currentPage + 1}">下一页</a> </body> </html>${p.id}这种 EL 写法会自动调用 Product 实体的 getId() 方法,并且自带 HTML 转义,商品名里出现<script>也不会被执行,这是 JSTL 胜过字符串拼接的关键理由。页脚那个“下一页”链接用${pageContext.request.contextPath}拼应用名,避免部署路径改了链接全部失效。如果你下载的包里 JSP 大量使用<%=%>输出,我一般会顺手把它们改写成 EL 表达,改动量不大,安全性提升立竿见影。如果你那个包登录后自带一个员工个人信息展示页面,同理把${sessionScope.loginUser.realName}写进 EL,而不是<%=session.getAttribute(...)%>,可读性完全不在一个层次。
5. 部署运行避坑:Tomcat 启动、JDK 版本和乱码的五个现场
5.1 Tomcat 启动闪退与端口占用
现象:双击 startup.bat 窗口一闪而过,浏览器访问 8080 无响应,点关闭按钮直接结束进程,整台机器像被“劝退”了一样。
原因:绝大多数是JAVA_HOME没配或端口被占用。闪退时 Tomcat 会把错误写在logs/catalina.out或logs/localhost.log,但窗口关闭得太快看不到内容,所以第一动作永远是看日志,不是重装 Tomcat。
解决:Windows 下在命令行里手动执行%CATALINA_HOME%\bin\catalina.bat run,错误会留在前台;端口占用用netstat -ano | findstr 8080找到 PID 后结束进程,或在conf/server.xml里把 Connector port 改成 8081 一劳永逸。记得改完删掉 Tomcat 里work/目录下的缓存,否则“改了配置不生效”的现象能骗你半天。
5.2 JDK 25 与老版本 Tomcat 不兼容
现象:Tomcat 直接起不来,日志报UnsupportedClassVersionError或ClassCastException,下载最新 JDK 反而更严重。热词里常刷到“jdk 25 tomcat 安装配置”,很多人以为越新越好。
原因:Tomcat 9 之前的版本基于旧 Servlet 规范编写,用新版 JDK 编译的 class 文件它读不了,而新版 JDK 又移除了很多老 Tomcat 依赖的 API。更关键的是 Tomcat 10 之后包名从javax.*迁到了jakarta.*,老源码的 import 全部失效,不是简单换个版本就能编译的。
解决:老项目严格按 JDK 8 + Tomcat 8.5 或 9 搭配,新一点的源码可以 JDK 11 + Tomcat 9。版本对应关系我一般按这张表记:
| JDK 版本 | 建议 Tomcat | 注意点 |
|---|---|---|
| JDK 8 | Tomcat 8.5 / 9.0 | 老源码最稳,javax.* 无痛 |
| JDK 11 | Tomcat 9.0.x | 兼容大多数老项目 |
| JDK 17 及以上 | Tomcat 10.1+ | 代码必须迁移到 jakarta.* |
排查时在命令行java -version确认版本,多个 JDK 共存的机器要把JAVA_HOME指到项目需要的那个,IDEA 的 Project SDK 和 Tomcat 运行配置里的 JRE 也分别检查一遍。这三处任何一个不一致都等于白配。
5.3 404 与 Servlet 映射路径不一致
现象:登录成功后跳转商品列表,浏览器地址栏 URL 是对的,页面却 404,控制台没有任何 Java 报错——这是最让人头大的“没报错就是不对”。
原因:先排除路径问题。Servlet 如果用的是注解@WebServlet("/product/list"),但 web.xml 里还残留一份<servlet-mapping>指向旧路径,或 JSP 里的表单 action 写成了product/list而少了前导斜杠,就会出现这种“看起来没坏但就是找不到”。
解决:统一映射来源,注解和 web.xml 二选一,我优先用注解并删掉 web.xml 里的重复声明。表单和链接里的地址一律用${pageContext.request.contextPath}开头,例如<form action="${pageContext.request.contextPath}/product/list">,这样部署名怎么改都不翻车。排查时先用浏览器开发者工具看 Network 里的请求 URL,再对照 Servlet 映射,两相对比 99% 能定位。
5.4 Tomcat Manager 上传 war 被限制 IP
现象:在 Tomcat 后台页面点 Deploy 上传 war,返回 403 Access Denied,页面上还写着默认只允许本地地址。网上搜“tomcat后台页面上传war被限制ip”能搜到一堆相同提问,这不是个例。
原因:Tomcat Manager 应用默认用 RemoteAddrValve 限制源 IP,只放行 127.0.0.1。这是保护措施,不是 bug,远程改配置改错了反而可能把后台彻底锁死,连本地都登不进去。
解决:开发环境直接在webapps/manager/META-INF/context.xml里注释掉<Valve className="org.apache.catalina.valves.RemoteAddrValve" allow="127\.\d+\.\d+\.\d+|::1|0:0:0:0:0:0:0:1" />,重启 Tomcat 后用http://localhost:8080/manager登录,账号密码在conf/tomcat-users.xml里配置 manager-gui 角色。生产环境不建议外露 Manager,更稳妥的是直接把 war 放进 webapps 目录让它自动解压部署,根本不需要开后台。
5.5 中文乱码从页面到数据库三层排查
现象:商品名称和管理员姓名在 JSP 显示成“???”,数据库里存的就是乱码,怎么改页面编码都不管用。网上常搜“jsp页面个人信息展示乱码”和“mysql数据库修改结构”,本质都是同一件事。
原因:乱码有三个可能层,缺一不可查清:请求编码、响应编码、数据库编码。最常见的是连接串没加characterEncoding=utf8,JSP 页面加了<%@ page contentType="text/html;charset=UTF-8"%>而数据库表却是 utf8,两边一凑就是乱码。
解决:按这个顺序排查——先看页面源码是不是 UTF-8,再给 Servlet 入口加request.setCharacterEncoding("UTF-8"),最后执行SHOW CREATE TABLE product确认表字符集。改表用ALTER TABLE product CONVERT TO CHARACTER SET utf8mb4,记住只改连接参数不改表结构等于白忙。请求、响应、数据库三层全部统一到 UTF-8 之后,乱码问题基本绝迹。
6. 把课设改成能用的系统:连接池、库存事务与刷新防重复
源码包能跑只是起点,离“真能在一个小超市上线”还差三样东西:连接池、库存事务、防重复提交。这三样恰好是本项目最薄弱也最值得花时间的进阶点。
第一件,把DriverManager.getConnection换成连接池。老源码每个 DAO 里都写一次连接获取,并发一高就在Too many connections上翻车。常见做法是引入 Druid 或 HikariCP,在 db.properties 里配initialSize=5、maxActive=10,DAO 层从一个统一的 DBUtil 类取连接。改动只集中在一个类里,业务代码几乎不动,收益却立竿见影。
第二件,下单扣库存必须走事务。结算时先执行带条件的 UPDATE,受影响行数为 0 说明库存不足,立即回滚并提示顾客;否则 total_amount 和明细数量对不上账:
START TRANSACTION; UPDATE product SET stock = stock - 1 WHERE id = 1001 AND stock >= 1; -- 如果受影响行数为 0,说明库存不足,立即 ROLLBACK INSERT INTO order_item (order_id, product_id, quantity, price) VALUES (20240101001, 1001, 1, 3.50); UPDATE sale_order SET total_amount = total_amount + 3.50 WHERE id = 20240101001; COMMIT;两个收银员同时买最后一件商品时,靠这条带条件的 UPDATE 配合 InnoDB 行锁就能挡住超卖,不需要业务层加 synchronized。这是并发场景里最容易被忽略的一课,也是数据库并发锁机制实实在在的用武之地。
第三件是一个很小的 JSP 习惯:表单提交后不要在服务端原样转发回页面,而是重定向到列表页。否则用户按 F5 刷新,浏览器会重复提交同一笔订单,系统里出现两条一模一样的销售记录——这就是网上常搜的“jsp页面让加载完后刷新一次”的真实场景。用 Post-Redirect-Get 模式,或者下单成功后立刻清空页面里的订单号,都能把重复提交挡在门外。
我自己每接到一个这样的包,都会先按第 2 章的目录结构把骨架读一遍,再按第 5 章的日志优先顺序排雷,最后确认连接池和事务有没有到位。这套顺序帮我挡掉过好几台“demo 能跑、上线就垮”的翻车机器。希望帮到你。
本文还有配套的精品资源,点击获取