简介:这份Java超市管理系统是一套基于Eclipse与MySQL的完整课程设计/毕业设计资源,适合Java初学者、计算机专业学生及需要快速搭建进销存项目的开发者。压缩包共378个文件,包含56个JSP页面、49个Java源文件、98个class编译文件、SQL数据库脚本、XML配置文件及前端CSS/JS等,覆盖商品销售、采购管理、数据统计、管理员与收银员双角色权限等核心模块,包体仅10.37MB,结构紧凑便于导入学习。目前已有2384人学习下载,可用作数据库设计与Servlet开发参考,也可在此基础上扩展报表、优化界面或对接真实零售业务流程,帮助理解JavaWeb项目从分层编码到部署运行的完整路径。
1. 一个 eclipse+MySQL 的 Java 超市管理系统,听起来像课程设计,实际是练 JDBC 功底的硬核项目
你有没有见过那种“学习资料”里躺着的 Java 超市管理系统?用 Eclipse 写界面,用 MySQL 存数据,没有 Spring Boot,没有 MyBatis,看起来土掉渣,但它恰恰是理解 Java 数据库编程最扎实的起点。这个标题背后要解决的是“一个 Java 应用如何连上 MySQL,做商品进销存和订单结算”,常见于课程设计、毕业设计或者小团队内部工具。它能覆盖 JDBC 连接、DAO 分层、事务控制、库存扣减这些核心技能,而且不用引入一堆依赖,适合新手一步步跟,熟手用来回顾底层原理也够味。别小看这个项目,真把它做到能跑、不出乱码、不丢数据,你已经比很多只会拷贝 Spring Boot demo 的人强了。
为什么这么说?因为它的难点不在“功能多”,而在“细节密”。连接串拼错一个参数就报错,日期时区不对就崩,库存并发扣减更是让人头疼。接下来我会把整个项目从环境搭建到数据建模,再到排错经验,按实际做过的路径讲一遍,你照着敲完,就能交出一份“老师挑不出毛病”的作业,或者一套能直接拿去用的内部小系统。
2. 技术选型与环境搭建:为什么现在还会用 eclipse+MySQL 做管理系统
2.1 选型理由:这个组合适合什么场景,不适合什么场景
先说结论:eclipse+MySQL 的组合适合“一个人 3 天内做完并讲清楚原理”的小型管理信息系统,尤其适合 Java 初学者理解 JDBC。它不适合高并发、大规模部署、复杂权限控制的企业级系统,那种场景你会被繁琐的纯 JDBC 代码写死。但恰恰是这种“繁琐”,逼迫你把 SQL、事务、连接管理这些基本功练扎实。
很多人一上来就上 Spring Boot + MyBatis Plus,反而不知道一条 SQL 到底是怎么发到 MySQL 再返回来的。而用 eclipse 写 Java 项目,用 MySQL 存储,纯 JDBC 的每一步都是显式的:加载驱动、获取连接、执行语句、处理结果集、关闭资源。你能看到全流程,也能在翻车时快速定位是驱动问题、URL 问题还是 SQL 问题。这也是为什么很多高校和培训班的项目实战仍然保留这个组合。
此外,Eclipse 是免费开源的 IDE,对老电脑友好;MySQL 是使用率最高的开源数据库,招聘要求里也几乎不会避开它。用这个组合做完一个超市管理系统,你会顺手掌握 eclipse 导入项目、配置 build path、关联数据库插件这一套操作,这些技能在你以后换 IDEA、换框架时照样复用。
2.2 环境安装:JDK、Eclipse、MySQL 的版本搭配和最小安装步骤
先说版本搭配。JDK 用 8 或 11 都行,Eclipse 用 2022 之后的版本即可,MySQL 用 5.7 或 8.0。这里我建议直接上 MySQL 8.0,因为 5.7 已经进入生命周期后期,而且 8.0 的驱动和连接串在教程里更常见。
如果你是新装环境,按这个顺序来:
- 安装 JDK,配置 JAVA_HOME 和 PATH。
- 下载 Eclipse IDE for Java Developers,解压即用。
- 安装 MySQL Server,选择 Server Only,执行默认配置。
- 下载并配置 JDBC 驱动 jar。这里有个关键点,往下看。
对于 JDK,我一般用 Temurin 的安装包,安装后打开命令行输入java -version看到版本号就说明成功。对于 MySQL,安装时设置 root 密码,最好用一个简单好记的,比如root或123456,本机学习没问题。但注意,如果你的电脑已经装了 MySQL,并且还启用了服务,后续项目连接时要注意端口冲突,常见的坑是 3306 被占用,我们后面还会提到。
至于 Eclipse,新手容易在“安装插件”上浪费时间。实际上写 JDBC 项目不需要任何额外插件,只需要把 MySQL 驱动 jar 放到项目的 classpath 里。常见的做法是:在项目右键 -> Build Path -> Configure Build Path -> Add External JARs,选择驱动 jar 文件。这一步不做好,代码里写再完美的Class.forName也没用。
提示:MySQL 8.0 的驱动类名是
com.mysql.cj.jdbc.Driver,连接 URL 中必须带serverTimezone参数,否则会报时区异常。
2.3 第一个 JDBC 连接程序:让 Java 连上 MySQL 的最小代码
很多人用 eclipse 建项目后,上来就写业务代码,结果第一步就卡在数据库连接上。下面这是一个最小可运行的连接测试代码,我建议你单独建一个类,先把数据库连通了再开发业务。
import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBConnectionTest { public static void main(String[] args) { // JDBC URL 格式:jdbc:mysql://主机:端口/数据库名?参数 String url = "jdbc:mysql://localhost:3306/supermarket?useSSL=false&serverTimezone=Asia/Shanghai"; String user = "root"; String password = "123456"; Connection conn = null; try { // 1. 加载驱动(MySQL 8.0 之后可省略,但保留更稳妥) Class.forName("com.mysql.cj.jdbc.Driver"); // 2. 获取连接 conn = DriverManager.getConnection(url, user, password); // 3. 验证连接是否成功 if (conn != null) { System.out.println("数据库连接成功!"); } } catch (ClassNotFoundException e) { System.out.println("找不到驱动类,请检查 jar 是否导入"); e.printStackTrace(); } catch (SQLException e) { System.out.println("连接失败,请检查用户名密码和 URL"); e.printStackTrace(); } finally { // 4. 关闭连接 try { if (conn != null) { conn.close(); } } catch (SQLException e) { e.printStackTrace(); } } } }代码里最重要的参数是 URL。jdbc:mysql://localhost:3306/supermarket前半段表示数据库地址,supermarket是你的数据库名(必须先在 MySQL 里建好)。useSSL=false是避免本机连接时的警告;serverTimezone=Asia/Shanghai是 MySQL 8.0 必须要加的,不然你会看到“The server time zone value '�й���ʱ��' is unrecognized”这种让人崩溃的报错。
驱动加载的Class.forName在 JDK 高版本其实可以省略,因为 DriverManager 能自动发现 jar 里的驱动。但保留它有一个好处:如果你连驱动类都找不到,它会在第一步就报ClassNotFoundException,而不是在获取连接时才报,这样排查问题更快。这就是为什么我建议新手别删掉这一行。
还有一个容易被忽略的细节:finally块里关闭资源。有人觉得程序就结束无所谓,但如果你后面写的是 servlet 或长期运行的工具,不关连接会把连接池耗尽。养成每个连接用完后都关闭的习惯,血泪经验都是从忘了关闭连接开始的。
3. 超市管理系统的核心设计:数据库建模与 JDBC 代码分层
3.1 数据库设计:商品、库存、订单、用户四张核心表
超市管理系统虽小,但必须有清晰的数据模型。用 eclipse+MySQL 做这个项目,数据库设计往往是评分占比最高的部分,同时也是后患最多的部分。我建议最少四张表:user存登录账号,product存商品基本信息,stock存库存数量,order存销售订单;再加一张order_item存订单明细,一共五张也完全合理。
先看表结构设计:
| 表名 | 字段 | 类型 | 说明 |
|---|---|---|---|
| user | id, username, password, role | INT, VARCHAR, VARCHAR, VARCHAR | 用户表,role 区分管理员和收银员 |
| product | id, barcode, name, spec, price, unit | INT, VARCHAR, VARCHAR, VARCHAR, DECIMAL(10,2), VARCHAR | 商品表,barcode 最好设唯一索引 |
| stock | id, product_id, quantity, min_quantity | INT, INT, INT, INT | 库存表,product_id 外键索引 |
| orders | id, order_no, user_id, total_amount, create_time | INT, VARCHAR, INT, DECIMAL(10,2), DATETIME | 订单主表 |
| order_item | id, order_id, product_id, price, quantity | INT, INT, INT, DECIMAL(10,2), INT | 订单明细表 |
注意,order是 MySQL 的保留字,建表时必须用反引号包起来,或者直接叫orders。这里我用orders避免踩坑。还有unit也是保留字,但它不是 MySQL 的关键字,现代版本可以用,不过为了安全最好改成unit_name。
下面是建表 SQL,可以直接在 MySQL Workbench 里执行:
CREATE DATABASE IF NOT EXISTS supermarket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE supermarket; CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, role VARCHAR(20) DEFAULT 'admin' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE product ( id INT AUTO_INCREMENT PRIMARY KEY, barcode VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL, spec VARCHAR(50), price DECIMAL(10,2) NOT NULL, unit_name VARCHAR(10) DEFAULT '个', UNIQUE KEY idx_barcode (barcode) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE stock ( id INT AUTO_INCREMENT PRIMARY KEY, product_id INT NOT NULL, quantity INT DEFAULT 0, min_quantity INT DEFAULT 10, FOREIGN KEY (product_id) REFERENCES product(id) ON DELETE CASCADE, UNIQUE KEY uk_product (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, total_amount DECIMAL(10,2) DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE order_item ( id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, product_id INT NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, FOREIGN KEY (product_id) REFERENCES product(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段 SQL 里几个参数值得说清楚。ENGINE=InnoDB必须用,InnoDB 支持事务,这是我们后面做扣库存时回滚的前提;如果默认是 MyISAM,事务就失效了。DEFAULT CHARSET=utf8mb4是因为 MySQL 8.0 默认就是它,但显式写出来可以防止某些旧环境下使用 latin1 导致的中文乱码。UNIQUE KEY idx_barcode保证同一个条码不能出现两次,避免收银时数据歧义。FOREIGN KEY外键在这里不是必须的,但加上可以让作业评分看到你学过约束。
我建议你先执行这段 SQL,然后用一条简单的 INSERT 插入测试数据,确认能用 SELECT 查出来,再开始写 Java 代码。不要一上来就写代码,很多人的项目最后翻车,都是因为数据库表结构被反复改动,代码跟着返工。
3.2 代码分层:实体类、DAO、Service、UI 怎么拆
很多初学者的“超市管理系统”是一坨代码:把 SQL 写在窗口类里,一个方法里既做查询又做界面弹窗。这种代码跑起来没问题,但没法维护。更合理的分层是这样:
entity:实体类,对应数据库表dao:数据访问对象,只负责 SQL 操作service:业务逻辑层,处理事务和判断ui:窗口组件,只负责展示和接收输入
这样的分层有什么实际好处?你换界面的时候不碰 SQL,你换数据库方言的时候不碰界面。对于 eclipse+MySQL 这个级别,不用引入 Spring,手动 new 对象就能实现控制反转,但结构上足够清晰。
以商品管理为例,先定义一个商品实体类:
package entity; import java.math.BigDecimal; public class Product { private int id; private String barcode; private String name; private String spec; private BigDecimal price; private String unitName; // 每个字段都要 getter 和 setter public int getId() { return id; } public void setId(int id) { this.id = id; } public String getBarcode() { return barcode; } public void setBarcode(String barcode) { this.barcode = barcode; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getSpec() { return spec; } public void setSpec(String spec) { this.spec = spec; } public BigDecimal getPrice() { return price; } public void setPrice(BigDecimal price) { this.price = price; } public String getUnitName() { return unitName; } public void setUnitName(String unitName) { this.unitName = unitName; } }然后写一个 DAO 类,只负责对product表的增删改查:
package dao; import java.math.BigDecimal; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import entity.Product; public class ProductDao { private String url = "jdbc:mysql://localhost:3306/supermarket?useSSL=false&serverTimezone=Asia/Shanghai"; private String user = "root"; private String password = "123456"; public boolean insert(Product p) { String sql = "INSERT INTO product (barcode, name, spec, price, unit_name) VALUES (?,?,?,?,?)"; try (Connection conn = DriverManager.getConnection(url, user, password); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, p.getBarcode()); ps.setString(2, p.getName()); ps.setString(3, p.getSpec()); ps.setBigDecimal(4, p.getPrice()); ps.setString(5, p.getUnitName()); return ps.executeUpdate() > 0; } catch (SQLException e) { e.printStackTrace(); return false; } } public Product findByBarcode(String barcode) { String sql = "SELECT * FROM product WHERE barcode = ?"; try (Connection conn = DriverManager.getConnection(url, user, password); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, barcode); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { Product p = new Product(); p.setId(rs.getInt("id")); p.setBarcode(rs.getString("barcode")); p.setName(rs.getString("name")); p.setSpec(rs.getString("spec")); p.setPrice(rs.getBigDecimal("price")); p.setUnitName(rs.getString("unit_name")); return p; } } } catch (SQLException e) { e.printStackTrace(); } return null; } public boolean updatePrice(int id, BigDecimal price) { String sql = "UPDATE product SET price = ? WHERE id = ?"; try (Connection conn = DriverManager.getConnection(url, user, password); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setBigDecimal(1, price); ps.setInt(2, id); return ps.executeUpdate() > 0; } catch (SQLException e) { e.printStackTrace(); return false; } } }这里用到了 try-with-resources,eclipse 会自动提示你Connection和PreparedStatement已经实现了AutoCloseable,所以 try 块结束后会自动关闭资源,不用手写 finally。这是 JDK 7 以后推荐的写法,比手动关闭更不容易漏。参数方面,setBigDecimal就是把 Java 里BigDecimal映射到数据库的DECIMAL,价格计算必须用这个类型,不能图省事用double,否则在小数计算时会得到 19.99 变成 20.000000000000004 这种尴尬结果。
注意看findByBarcode用了PreparedStatement而不是Statement。这是关键习惯:第一,它会在 MySQL 服务端预编译,相同结构的 SQL 第二次执行更快;第二,参数用占位符?绑定,能堵住 SQL 注入。你可能觉得自己做的内部系统没人攻击,但作业检查时有没有用 PreparedStatement 是一条明确的扣分点,面试时也几乎必问。
3.3 业务核心:销售下单与库存扣减的事务控制
超市管理系统的核心是“卖一件货,库存就要减一,同时生成订单”。如果只是简单地把 INSERT 订单和 UPDATE 库存分开执行,就会出现一个灾难:订单写进去了,但库存没减;或者反过来,库存减了订单没生成。解决这个问题必须用事务。
先看业务逻辑代码,这里我用一个SaleService来模拟收银台一键下单:
package service; import java.math.BigDecimal; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Random; public class SaleService { private String url = "jdbc:mysql://localhost:3306/supermarket?useSSL=false&serverTimezone=Asia/Shanghai"; private String user = "root"; private String password = "123456"; public boolean saleOrder(int productId, int quantity, int userId) { Connection conn = null; try { conn = DriverManager.getConnection(url, user, password); // 关键:脏关闭自动提交,事务开始 conn.setAutoCommit(false); String orderNo = "SO" + System.currentTimeMillis() + new Random().nextInt(1000); // 1. 插入订单主表 String sqlOrder = "INSERT INTO orders (order_no, user_id, total_amount) VALUES (?,?,?)"; PreparedStatement psOrder = conn.prepareStatement(sqlOrder, PreparedStatement.RETURN_GENERATED_KEYS); psOrder.setString(1, orderNo); psOrder.setInt(2, userId); psOrder.setBigDecimal(3, new BigDecimal("0")); // 先占位,后面再算 int r1 = psOrder.executeUpdate(); int orderId = 0; try (ResultSet rs = psOrder.getGeneratedKeys()) { if (rs.next()) orderId = rs.getInt(1); } psOrder.close(); // 2. 查询商品价格 String sqlPrice = "SELECT price FROM product WHERE id = ?"; PreparedStatement psPrice = conn.prepareStatement(sqlPrice); psPrice.setInt(1, productId); ResultSet rsPrice = psPrice.executeQuery(); BigDecimal price = BigDecimal.ZERO; if (rsPrice.next()) { price = rsPrice.getBigDecimal("price"); } rsPrice.close(); psPrice.close(); // 3. 插入明细 String sqlItem = "INSERT INTO order_item (order_id, product_id, price, quantity) VALUES (?,?,?,?)"; PreparedStatement psItem = conn.prepareStatement(sqlItem); psItem.setInt(1, orderId); psItem.setInt(2, productId); psItem.setBigDecimal(3, price); psItem.setInt(4, quantity); int r2 = psItem.executeUpdate(); psItem.close(); // 4. 扣减库存 String sqlStock = "UPDATE stock SET quantity = quantity - ? WHERE product_id = ?"; PreparedStatement psStock = conn.prepareStatement(sqlStock); psStock.setInt(1, quantity); psStock.setInt(2, productId); int r3 = psStock.executeUpdate(); psStock.close(); // 5. 更新订单总额 BigDecimal total = price.multiply(new BigDecimal(quantity)); String sqlTotal = "UPDATE orders SET total_amount = ? WHERE id = ?"; PreparedStatement psTotal = conn.prepareStatement(sqlTotal); psTotal.setBigDecimal(1, total); psTotal.setInt(2, orderId); psTotal.executeUpdate(); psTotal.close(); // 全部成功才提交 conn.commit(); return r1 > 0 && r2 > 0 && r3 > 0; } catch (SQLException e) { e.printStackTrace(); // 出错回滚,不留半截数据 try { if (conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } return false; } finally { try { if (conn != null) { conn.setAutoCommit(true); // 恢复默认 conn.close(); } } catch (SQLException e) { e.printStackTrace(); } } } }这里重点是conn.setAutoCommit(false),从这一行开始,后面所有 SQL 都暂不生效,直到conn.commit()一次性生效。如果在任何步骤抛异常,catch块里的conn.rollback()能把前面已执行的 INSERT 和 UPDATE 全部撤销。这就是事务的原子性。
有人会问,order_no为什么要拼时间戳和随机数?因为订单号一般要保证唯一,数据库里也做了唯一索引。如果直接用一个自增 id 当订单号,别人一眼能看出你的业务量,而且不好看。这里生成的SO + 时间戳 + 随机数基本不会重复,但你在实际做时要注意:如果多个线程同时调用,Random.next()可能会碰撞,所以更稳妥的方法是使用UUID.randomUUID().toString().replace("-", "")的前几位,或者直接用数据库自带的主键。
getGeneratedKeys也是常用技巧,它可以拿到自增主键的值,方便把order_id插到明细表里。如果你在 MySQL 里没有定义 AUTO_INCREMENT,这一步会返回空结果,对应的orderId就是 0,后续明细表外键就错了。所以建表时的自增主键很重要。
4. eclipse+MySQL 项目常见坑:5 个我差点把电脑砸了的报错与解决办法
4.1 eclipse 里“找不到或无法加载主类 org.apache.catalina.startup.Bootstrap”
这是很多人的噩梦,尤其当你把项目从网上导入时,明明看着是 Java 项目,一运行却报这个错。现象是控制台提示“错误: 找不到或无法加载主类 org.apache.catalina.startup.Bootstrap”,但你的代码里根本没有这个类。
原因通常是:你导入的项目原本是 Dynamic Web Project,依赖了 Tomcat 运行时,而当前工作区的 Tomcat 服务器没有配置或者版本不匹配,eclipse 在清理/重新编译后无法找到启动类。另一个常见原因是 build path 中 classpath 损坏,或项目根目录不对。
解决办法分两步:先检查项目是不是 Java 项目,右键项目 -> Properties -> Project Facets,取消勾选比如 Dynamic Web Module 或者重新指定 Tomcat 运行时;如果你只是做控制台项目,最简单的是新建一个普通 Java 项目,把源码和 jar 手动拷贝进去,而不是直接 import 旧项目。另一个方法是把当前 Tomcat 从 Server 视图里删掉重新添加,并且检查 Java Build Path 中的 Libraries 里有没有把 servlet-api.jar 包含进来。这个报错和 MySQL 无关,但它特别容易在“网上下载的 javaweb 项目”里出现,所以单独列出来。
4.2 MySQL 连接时“Public Key Retrieval is not allowed”
这是一个很新又很烦人的报错,MySQL 8.0 默认使用 caching_sha2_password 认证,JDBC 驱动第一次连接时需要在非 SSL 情况下获取公钥,但默认 URL 参数没允许。现象是一连数据库就抛“Public Key Retrieval is not allowed”,但你在命令行用相同用户密码又能登录。
原因是在 JDBC URL 里缺少allowPublicKeyRetrieval=true。解决办法很简单,把连接串改成:
jdbc:mysql://localhost:3306/supermarket?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true这里的allowPublicKeyRetrieval是允许客户端向服务器请求公钥,在开发环境完全可以开。如果你对安全性有执念,可以改用 SSL,但本机调试没必要。注意,这个参数只对 MySQL 8.0 有效,5.7 不会报这个错,所以如果你是用 5.7 的驱动连 8.0 的库,也会出现奇奇怪怪的兼容问题。我建议你直接下载 mysql-connector-j 8.0.33 之类的通用驱动,避免老驱动连新版库。
4.3 保存中文变乱码,查询结果全是“?”
乱码问题在中文项目里几乎是必踩的坑。现象有多种:界面上输入“苹果”存入 MySQL 变成“??”;直接查询表数据显示正常但 Java 控制台输出乱码;或者反过来。原因通常是三层编码不一致:数据库表字符集、JDBC 连接字符集、Java 文件本身的编码。
我的做法是统一三层全部使用 UTF-8。第一步,建表时指定 DEFAULT CHARSET=utf8mb4,前面已经做了。第二步,在 JDBC URL 里加characterEncoding=utf8,比如:
jdbc:mysql://localhost:3306/supermarket?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8第三步,在 eclipse 里设置项目编码,右键项目 -> Properties -> Resource -> Text file encoding,改成 UTF-8。第四步,如果还有什么地方不对,在连接后执行SET NAMES utf8mb4强制指定客户端字符集。记住,乱码不是一处错的,是链路中每一处都要设为 UTF-8,缺一个环节就乱。
4.4 UPDATE 或 DELETE 操作返回受影响行数为 0,但好像没生效
有同学反馈:“我明明执行了 UPDATE product SET price=19.99 WHERE id=1,返回 1,但再查还是原价。”这时先检查事务是否提交。如果在代码里手动调用了conn.setAutoCommit(false),但执行完executeUpdate()后没有调用conn.commit(),那么当前连接下自己查可能看不到结果,因为默认隔离级别下未提交变更对其他连接不可见。另一个更隐蔽的原因是:你操作之后没有关闭连接,而同一个连接又是从连接池里复用的,把未提交的事务带到下次操作,产生幻觉。
解决方法是执行更新后立刻conn.commit(),或者干脆不要在这条语句之前关掉自动提交。还有一个类似情况:executeUpdate()返回值代表受影响的行数,如果你的数据库里数据本身没变化,比如你把价格从 19.99 改成 19.99,MySQL 返回的是 0(匹配行数不为 0,但受影响行数因值未变为 0)。这不是 bug,但如果你的代码用返回值判断“是否更新成功”,就会误判。正确做法是使用executeUpdate()返回者 + 检查 WHERE 条件是否符合预期,而不是依赖返回值 > 0。
4.5 库存卖到负数,销售单却照常生成
这个坑不是报错,但比报错更严重。现象是高并发下多个线程同时卖同一件商品,库存被扣到负数,订单照样生成。原因是我的 UPDATE 语句是UPDATE stock SET quantity = quantity - ? WHERE product_id = ?,但没有带库存足够的条件。
解决办法是在 SQL 层面加一个约束:
UPDATE stock SET quantity = quantity - ? WHERE product_id = ? AND quantity >= ?这条语句利用 MySQL 行级锁和条件判断,当库存不足时,匹配行为 0,executeUpdate()返回 0,代码里就能拿到“库存不足”的信号。然后在 Java 里判断返回值,如果为 0 就回滚并提示前台。这个方法比先 SELECT 再判断再 UPDATE 更可靠,因为两个请求同时读到库存 5,先执行 UPDATE 的会先拿到锁,后执行的会因为条件不满足直接返回 0,不会出现都读到 5 然后都减到 4 的“幻影扣减”。
如果你嫌这个条件写法可读性差,也可以改成先 SELECT 库存,然后带版本号更新(乐观锁),但这对于简单的课程项目有点过度设计。用条件 UPDATE 已经能解决单机并发问题。注意,如果不开启事务,条件 UPDATE 也是每个命令自成一个事务,最终还是可能把两条并发更新串行化,所以事务依然要保留。
5. 最后的一公里:从“能跑通”到“能答辩”——验证技巧与两个保命习惯
项目做完不是终点,你要能验证它没有隐性问题,也要能在别人问你“为什么这么写”时答得上来。我强烈建议你给自己写一个“自毁测试”,什么意思?故意在销售下单时把库存改成 0,看看系统会不会报“库存不足”而不是生成空订单;再故意把一条 SQL 写错,观察日志里有没有回滚。这套验证比多写十个功能都值钱。
另一个实用技巧是给 DAO 层加上带参数调整的查询方法。比如按商品名称模糊查询,这个功能在超市管理里几乎必用,代码也很简单:
public List<Product> searchByName(String keyword) { String sql = "SELECT * FROM product WHERE name LIKE ?"; try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); try (ResultSet rs = ps.executeQuery()) { List<Product> list = new ArrayList<Product>(); while (rs.next()) { list.add(mapRow(rs)); } return list; } } catch (SQLException e) { e.printStackTrace(); return new ArrayList<Product>(); } }这段代码里把%拼进参数而不是拼进 SQL,既是一个好习惯,也能让你顺手复习LIKE的模糊配匹。如果你用的是Statement直接拼接字符串,遇到名字里带单引号会立刻报错,这就是 SQL 注入的入门场景。
再有一个保命习惯:不要用new BigDecimal(String)之外的方式初始化金额。如果你在代码里写new BigDecimal(19.99),你会得到一个近似小数,这是新手最容易忽视的黑匣子。正确做法是new BigDecimal("19.99")。我当年就因为用 double 算价格,导致系统里出现 19.990000000000002,查到凌晨才找到根因。后来我给自己定了个规矩:凡是涉及钱的地方,一律用 String 构造函数初始化 BigDecimal。
最后,把项目里所有硬编码的数据库连接串抽到一个单独的工具类DbUtil里,这是你未来迁移连接池、改配置的唯一入口。我做这个项目时一开始图省事,每个 DAO 都抄了一份连接参数,后来要换成带连接池的连接方式时,改了二十个文件。从那以后我养成了先写工具类的习惯。
这个 eclipse+MySQL 的超市管理系统,说小也小,说深也深。你如果老老实实把它从建库到事务写完,再去掉几个坑,你会发现后面学 MyBatis 也好,学 Spring 也罢,那些连不上数据库、乱码、事务不回滚的问题,都会很容易举一反三。希望帮到你。
本文还有配套的精品资源,点击获取