资讯动态

Java超市管理系统实战:SQL Server库存扣减与事务控制

发布时间:2026/10/13 12:31:34 来源:尧图企业网站定制
简介本资源为基于Java与SQL Server的超市管理系统项目源码面向具备一定Java Web基础、需要完成课程设计或企业级练手项目的开发者。系统围绕商品管理、库存控制、销售记录、会员管理与报表生成等模块展开后端采用JDBC、Servlet/JSP、Spring与MyBatis构建MVC架构数据库端涵盖表结构设计、索引优化、存储过程与触发器等实践要点可帮助读者理解业务逻辑与数据交互的完整链路。压缩包为rar格式大小约2.16MB文件总数暂未提供具体文件类型明细亦暂无数据但整体体量轻便便于快速导入与二次开发。目前已有488人学习下载适合作为超市管理类项目的参考实现用于梳理模块划分、数据库设计与框架整合思路也可在此基础上扩展功能或替换技术栈进行练习。1. 从一次库存对不上账说起这套 Java 超市管理系统到底能干什么去年帮一个做社区连锁便利店的朋友看系统他遇到的问题是前台收银显示库存还有 12 瓶某品牌矿泉水仓库实际盘点只有 3 瓶中间 9 瓶的差异查了三天没查出来。后来发现是退货流程里少写了一条库存回滚的 SQL退货单生成了但库存表没加回去。这种问题在中小超市里非常典型——收银、库存、采购、会员各跑各的数据对不上是常态。这套 Java 超市管理系统配 SQL Server 的资源核心就是解决这类问题。它把商品管理、库存出入库、收银结算、会员积分、供应商采购这几条线用一套数据库串起来前端用 JavaSwing 或 JavaFX 桌面端居多也有 Web 版后端数据落在 SQL Server 上。适合谁用一是计算机专业做课程设计或毕业设计的学生二是中小超市想自己搭一套轻量管理系统的开发者三是想拿一个完整 CRUD 事务 报表项目练手的 Java 初学者。它不是什么高并发分布式架构别指望扛住双十一级别的流量。但如果你需要一套能跑通「进货→上架→销售→退货→库存回滚→报表统计」完整闭环的代码并且数据库用的是 SQL Server 而不是 MySQL这套资源的参考价值就很直接了。下面我从数据库设计、核心模块实现、部署踩坑几个角度把它拆开讲清楚。2. 数据库表结构与 SQL Server 建库先把地基打对2.1 核心表设计思路与字段说明超市管理系统的数据库设计难点不在表多而在表之间的约束关系和事务边界。我一般会把表分成四组基础资料表商品、分类、供应商、会员、业务流水表销售单、销售明细、退货单、采购单、库存表库存主表、库存流水、系统表用户、角色、日志。先看商品表和库存表的关系。很多新手会把库存数量直接放在商品表里当一个字段比如product.stock。这样做在单仓库、无并发场景下能跑但一旦涉及采购入库、销售出库、退货回滚、盘点调整多个入口同时改库存就会出现超卖或数据覆盖。正确做法是拆成两张表product存商品静态信息inventory存每个商品在每个仓库的动态数量再用inventory_log记录每一次变动。-- 商品表存静态信息 CREATE TABLE product ( product_id INT IDENTITY(1,1) PRIMARY KEY, barcode VARCHAR(32) NOT NULL UNIQUE, -- 条形码收银扫码用 product_name NVARCHAR(100) NOT NULL, category_id INT NOT NULL, supplier_id INT NULL, purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0, -- 进价 sale_price DECIMAL(10,2) NOT NULL DEFAULT 0, -- 售价 unit NVARCHAR(10) DEFAULT N件, status TINYINT DEFAULT 1, -- 1上架 0下架 create_time DATETIME DEFAULT GETDATE() ); -- 库存表存动态数量按商品仓库维度 CREATE TABLE inventory ( inventory_id INT IDENTITY(1,1) PRIMARY KEY, product_id INT NOT NULL, warehouse_id INT NOT NULL DEFAULT 1, quantity INT NOT NULL DEFAULT 0, warn_threshold INT DEFAULT 10, -- 库存预警阈值 update_time DATETIME DEFAULT GETDATE(), CONSTRAINT uk_prod_warehouse UNIQUE (product_id, warehouse_id) ); -- 库存流水表每一次变动都留痕方便对账 CREATE TABLE inventory_log ( log_id INT IDENTITY(1,1) PRIMARY KEY, product_id INT NOT NULL, change_type TINYINT NOT NULL, -- 1采购入库 2销售出库 3退货入库 4盘点调整 change_qty INT NOT NULL, -- 正数增加负数减少 before_qty INT NOT NULL, after_qty INT NOT NULL, ref_order VARCHAR(32) NULL, -- 关联单号 operator NVARCHAR(50), create_time DATETIME DEFAULT GETDATE() );这里有几个参数值得说清楚。barcode加了 UNIQUE 约束因为收银扫码必须保证一码一商品重复条码会导致扫出来不知道是哪个。inventory表上的uk_prod_warehouse联合唯一约束是关键它保证同一个商品在同一个仓库只有一条记录避免出现两条库存记录各记一半的情况。inventory_log里的before_qty和after_qty是血泪经验——只记变动量对账时根本还原不出当时的状态把变动前后都记下来出问题一查流水就清楚。2.2 建库脚本与初始化数据SQL Server 建库和 MySQL 有个明显区别SQL Server 默认不区分大小写取决于排序规则但标识列用IDENTITY而不是AUTO_INCREMENT字符串拼接用而不是CONCAT虽然新版本也支持。下面这段脚本可以直接在 SSMS 里执行。-- 建库指定排序规则支持中文 CREATE DATABASE SupermarketDB COLLATE Chinese_PRC_CI_AS; GO USE SupermarketDB; GO -- 分类表 CREATE TABLE category ( category_id INT IDENTITY(1,1) PRIMARY KEY, category_name NVARCHAR(50) NOT NULL, parent_id INT DEFAULT 0 ); -- 供应商表 CREATE TABLE supplier ( supplier_id INT IDENTITY(1,1) PRIMARY KEY, supplier_name NVARCHAR(100) NOT NULL, contact NVARCHAR(50), phone VARCHAR(20), address NVARCHAR(200) ); -- 会员表 CREATE TABLE member ( member_id INT IDENTITY(1,1) PRIMARY KEY, card_no VARCHAR(20) NOT NULL UNIQUE, member_name NVARCHAR(50), phone VARCHAR(20), points INT DEFAULT 0, -- 积分 balance DECIMAL(10,2) DEFAULT 0, -- 储值余额 create_time DATETIME DEFAULT GETDATE() ); -- 销售主表 CREATE TABLE sale_order ( order_id INT IDENTITY(1,1) PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, member_id INT NULL, total_amount DECIMAL(10,2) NOT NULL, pay_amount DECIMAL(10,2) NOT NULL, pay_type TINYINT DEFAULT 1, -- 1现金 2扫码 3会员余额 cashier NVARCHAR(50), sale_time DATETIME DEFAULT GETDATE() ); -- 销售明细表 CREATE TABLE sale_detail ( detail_id INT IDENTITY(1,1) PRIMARY KEY, order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, unit_price DECIMAL(10,2) NOT NULL, subtotal DECIMAL(10,2) NOT NULL );初始化数据里分类和供应商先各插几条商品数据建议用脚本批量生成手工插几百条不现实。会员表可以先造几个测试卡号。注意sale_order的order_no用 UNIQUE 约束生成规则常见做法是「日期 流水号」比如20250101-0001避免并发时单号重复。提示SQL Server 的GETDATE()返回服务器时间如果应用服务器和数据库服务器不在同一台机器且时区不同会出现单据时间偏差。生产环境建议统一用数据库时间或者应用层传入时间参数。3. Java 端核心模块实现收银、库存扣减与事务控制3.1 收银结算流程与 JDBC 事务写法收银是整个系统里事务要求最高的环节。一笔销售涉及三张表的写入sale_order插一条主单sale_detail插 N 条明细inventory扣减对应商品库存inventory_log记流水。这四步必须在一个事务里任何一步失败全部回滚否则就会出现「单生成了但库存没扣」或者「库存扣了但单没生成」的脏数据。用原生 JDBC 写事务核心是关掉自动提交手动 commit/rollback。下面这段代码是收银结算的骨架。public boolean checkout(SaleOrder order, ListSaleDetail details) { Connection conn null; PreparedStatement psOrder null; PreparedStatement psDetail null; PreparedStatement psStock null; PreparedStatement psLog null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交开启事务 // 1. 插入销售主单 String orderSql INSERT INTO sale_order(order_no, member_id, total_amount, pay_amount, pay_type, cashier) VALUES(?,?,?,?,?,?); psOrder conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS); psOrder.setString(1, order.getOrderNo()); psOrder.setObject(2, order.getMemberId()); psOrder.setBigDecimal(3, order.getTotalAmount()); psOrder.setBigDecimal(4, order.getPayAmount()); psOrder.setInt(5, order.getPayType()); psOrder.setString(6, order.getCashier()); psOrder.executeUpdate(); // 拿到自增主键供明细表关联 ResultSet rs psOrder.getGeneratedKeys(); int orderId 0; if (rs.next()) orderId rs.getInt(1); // 2. 批量插入明细 扣库存 记流水 String detailSql INSERT INTO sale_detail(order_id, product_id, quantity, unit_price, subtotal) VALUES(?,?,?,?,?); String stockSql UPDATE inventory SET quantity quantity - ?, update_time GETDATE() WHERE product_id ? AND quantity ?; String logSql INSERT INTO inventory_log(product_id, change_type, change_qty, before_qty, after_qty, ref_order, operator) VALUES(?,2,?,?,?,?,?); psDetail conn.prepareStatement(detailSql); psStock conn.prepareStatement(stockSql); psLog conn.prepareStatement(logSql); for (SaleDetail d : details) { // 明细 psDetail.setInt(1, orderId); psDetail.setInt(2, d.getProductId()); psDetail.setInt(3, d.getQuantity()); psDetail.setBigDecimal(4, d.getUnitPrice()); psDetail.setBigDecimal(5, d.getSubtotal()); psDetail.addBatch(); // 扣库存WHERE 里带 quantity ? 防止超卖 psStock.setInt(1, d.getQuantity()); psStock.setInt(2, d.getProductId()); psStock.setInt(3, d.getQuantity()); int affected psStock.executeUpdate(); if (affected 0) { throw new RuntimeException(库存不足商品ID d.getProductId()); } } psDetail.executeBatch(); conn.commit(); // 全部成功才提交 return true; } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { DBUtil.close(conn, psOrder, null); DBUtil.close(null, psDetail, null); DBUtil.close(null, psStock, null); DBUtil.close(null, psLog, null); } }这段代码里最关键的一行是扣库存 SQL 的WHERE product_id ? AND quantity ?。把库存充足判断放在 UPDATE 的 WHERE 条件里而不是先 SELECT 查库存再判断再 UPDATE是为了避免并发场景下的超卖。两个收银台同时卖最后一件商品如果先查后改两边都查到库存为 1都认为够结果扣成 -1。用quantity ?做条件更新数据库层面保证只有一个 UPDATE 能成功另一个 affected 返回 0直接抛异常回滚。Statement.RETURN_GENERATED_KEYS是拿自增主键的标准写法SQL Server 配合 JDBC 驱动可以正常工作。addBatch()和executeBatch()用于明细批量插入减少网络往返。注意setObject(2, order.getMemberId())这里用 setObject 而不是 setInt是因为会员 ID 可能为 null非会员消费setInt 传 null 会报错。3.2 库存回滚与退货逻辑退货是库存回滚的典型场景也是前面提到的「库存对不上账」的高发区。退货流程要处理三件事生成退货单、把库存加回去、记库存流水。如果会员用积分或余额支付还要退积分退余额。这里只讲库存部分。public boolean returnGoods(String orderNo, int productId, int returnQty, String operator) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 查原库存 String querySql SELECT quantity FROM inventory WHERE product_id ?; PreparedStatement psQuery conn.prepareStatement(querySql); psQuery.setInt(1, productId); ResultSet rs psQuery.executeQuery(); int beforeQty 0; if (rs.next()) beforeQty rs.getInt(quantity); // 2. 库存加回 String updateSql UPDATE inventory SET quantity quantity ?, update_time GETDATE() WHERE product_id ?; PreparedStatement psUpdate conn.prepareStatement(updateSql); psUpdate.setInt(1, returnQty); psUpdate.setInt(2, productId); psUpdate.executeUpdate(); // 3. 记流水change_type3 表示退货入库 String logSql INSERT INTO inventory_log(product_id, change_type, change_qty, before_qty, after_qty, ref_order, operator) VALUES(?,3,?,?,?,?,?); PreparedStatement psLog conn.prepareStatement(logSql); psLog.setInt(1, productId); psLog.setInt(2, returnQty); psLog.setInt(3, beforeQty); psLog.setInt(4, beforeQty returnQty); psLog.setString(5, orderNo); psLog.setString(6, operator); psLog.executeUpdate(); conn.commit(); return true; } catch (Exception e) { try { if (conn ! null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } return false; } finally { DBUtil.close(conn, null, null); } }退货逻辑比销售简单但有个容易翻车的点before_qty的查询和UPDATE之间如果不在同一事务里或者隔离级别不够可能读到过期数据。这里因为整个方法在一个事务里且 SQL Server 默认的 READ COMMITTED 隔离级别下同一事务内的读会看到自己未提交的写所以before_qty和实际加回前的值是一致的。但如果把查询和更新拆到两个方法、两个事务里流水里的before_qty就可能对不上。注意退货数量要做校验不能超过原单该商品的购买数量。这个校验建议在业务层做同时数据库层可以用触发器或检查约束兜底但触发器调试麻烦我一般倾向业务层校验 流水留痕出问题查流水。3.3 库存预警与报表查询库存预警是个实用功能逻辑很简单查inventory表里quantity warn_threshold的记录关联商品名展示。SQL 如下。SELECT p.product_name, p.barcode, i.quantity, i.warn_threshold FROM inventory i JOIN product p ON i.product_id p.product_id WHERE i.quantity i.warn_threshold AND p.status 1 ORDER BY i.quantity ASC;报表方面日销售汇总、商品销售排行、毛利统计是三个最常用的。日销售汇总按日期分组统计销售额和单数SELECT CONVERT(VARCHAR(10), sale_time, 120) AS sale_date, COUNT(*) AS order_count, SUM(pay_amount) AS total_sales FROM sale_order WHERE sale_time ? AND sale_time ? GROUP BY CONVERT(VARCHAR(10), sale_time, 120) ORDER BY sale_date;CONVERT(VARCHAR(10), sale_time, 120)是 SQL Server 里截取日期部分的常用写法120 样式返回yyyy-mm-dd hh:mi:ss截前 10 位就是日期。商品销售排行用sale_detail关联product按销量倒序。毛利统计需要sale_detail关联product拿进价用(unit_price - purchase_price) * quantity算单条毛利再汇总。这些查询在数据量上万后要加索引sale_order.sale_time和sale_detail.order_id上建非聚集索引否则报表会越跑越慢。4. 部署与连接配置SQL Server 和 Java 环境怎么对上4.1 JDBC 驱动配置与连接字符串Java 连 SQL Server驱动包是mssql-jdbc。注意版本选择JDK 8 用mssql-jdbc-8.x.jre8.jarJDK 11 及以上用mssql-jdbc-9.x.jre11.jar或更高。放错版本会报UnsupportedClassVersionError这个坑很常见。连接字符串的写法直接影响能不能连上// 方式一SQL Server 身份验证推荐配置简单 String url jdbc:sqlserver://localhost:1433; databaseNameSupermarketDB; usersa; passwordYourPassword; encryptfalse; trustServerCertificatetrue;; // 方式二Windows 集成身份验证需加载 dll跨平台麻烦 // String url jdbc:sqlserver://localhost:1433;databaseNameSupermarketDB; // integratedSecuritytrue;;encryptfalse和trustServerCertificatetrue这两个参数在新版驱动里必须显式加上否则默认开启加密本地自签证书会导致连接失败报PKIX path building failed。这是新手最常卡住的地方之一。生产环境该开加密还是要开本地开发图省事可以关掉。连接池方面小系统用 HikariCP 就够了配置几个核心参数jdbcUrljdbc:sqlserver://localhost:1433;databaseNameSupermarketDB;encryptfalse;trustServerCertificatetrue usernamesa passwordYourPassword maximumPoolSize10 minimumIdle2 connectionTimeout30000 idleTimeout600000maximumPoolSize设 10 对中小超市足够设太大反而增加数据库连接开销。connectionTimeout30 秒超过就抛异常避免请求一直挂着。4.2 常见连接失败排查连接失败基本集中在四类TCP/IP 没启用、端口不对、防火墙拦截、身份验证模式不对。SQL Server 安装后默认可能没开 TCP/IP需要在「SQL Server 配置管理器」里把实例的 TCP/IP 协议启用并确认端口是 1433。如果改过端口连接字符串里要加port自定义端口。身份验证模式要在安装时选「混合模式」或者装完后在 SSMS 里改服务器属性允许 SQL Server 身份验证。只用 Windows 身份验证的话sa账号是连不上的。防火墙方面Windows 防火墙默认可能拦 1433 入站需要加规则放行。提示如果报The TCP/IP connection to the host has failed先别急着改代码用 telnet 测一下telnet localhost 1433通不通一目了然。不通就是服务端配置问题通不了再查驱动和连接字符串。5. 避坑与常见问题那些让我返工三次的细节5.1 坑一库存扣成负数现象并发收银时某商品库存显示为 -2实际已经没货了还在卖。原因扣库存用的是「先 SELECT 查库存Java 里判断够不够再 UPDATE 扣减」。两个线程同时查到库存为 1都判断够都执行扣减结果扣成 -1。解决把库存判断放进 UPDATE 的 WHERE 条件里UPDATE inventory SET quantity quantity - ? WHERE product_id ? AND quantity ?根据 affected rows 判断是否成功。数据库的行锁保证同一行只有一个 UPDATE 能拿到锁另一个 affected 返回 0业务层抛异常回滚。5.2 坑二中文乱码现象商品名、会员名存进数据库变成问号或乱码。原因三个环节都可能出问题——数据库排序规则不是中文、JDBC 连接没指定字符集、Java 源文件编码不对。解决建库时指定COLLATE Chinese_PRC_CI_AS字段类型用NVARCHAR而不是VARCHARJDBC 连接字符串加sendStringParametersAsUnicodetrue默认就是 true但显式写上保险Java 编译时加-encoding UTF-8。三处都对了才不会乱码。5.3 坑三事务没回滚导致脏数据现象收银时某商品库存不足抛了异常但销售主单已经插进去了明细和库存没动出现「有单无货」的脏数据。原因conn.setAutoCommit(false)之后异常分支里没有调conn.rollback()或者 rollback 写在了 catch 外面异常直接抛出没走到。解决把 rollback 放在 catch 块里并且用 try-catch 包住 rollback 本身防止 rollback 再抛异常。finally 里只负责关连接不负责回滚。另外连接池返回的连接可能带着上次的 autoCommit 状态从池里拿连接后第一件事就是setAutoCommit(false)别假设默认值。5.4 坑四日期格式和时区现象报表按日期分组跨天的那笔单被算到了前一天或后一天。原因Java 的java.util.Date和 SQL Server 的DATETIME在时区处理上不一致或者应用服务器时区和数据库服务器时区不同。解决统一用数据库时间GETDATE()生成单据时间Java 端只传业务参数不传时间。报表查询的日期范围用参数传入格式用yyyy-MM-ddSQL 里用 起始日期 AND 次日日期的半开区间避免BETWEEN在边界上的歧义。5.5 坑五自增主键获取失败现象插入主单后拿不到 orderId明细表关联不上报外键或空指针。原因prepareStatement时没加Statement.RETURN_GENERATED_KEYS或者 SQL Server 驱动版本太老不支持。解决conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)执行后用getGeneratedKeys()拿结果集。如果还是拿不到检查驱动版本8.x 以上都支持。实在不行用SELECT IDENTITY或SCOPE_IDENTITY()在同一个连接里查但要注意IDENTITY在触发器场景下会拿到触发器插入的 ID用SCOPE_IDENTITY()更安全。6. 进阶技巧用存储过程把报表查询压到数据库层报表查询如果全在 Java 里拼 SQL数据量一大网络传输和 Java 内存都吃不消。我一般会把复杂的汇总查询写成存储过程Java 只负责调返回结果集直接映射。以日销售汇总为例CREATE PROCEDURE sp_daily_sales_report start_date DATETIME, end_date DATETIME AS BEGIN SET NOCOUNT ON; -- 日销售汇总 SELECT CONVERT(VARCHAR(10), sale_time, 120) AS sale_date, COUNT(*) AS order_count, SUM(pay_amount) AS total_sales, AVG(pay_amount) AS avg_order_amount FROM sale_order WHERE sale_time start_date AND sale_time end_date GROUP BY CONVERT(VARCHAR(10), sale_time, 120) ORDER BY sale_date; -- 商品销售 TOP 10 SELECT TOP 10 p.product_name, SUM(d.quantity) AS total_qty, SUM(d.subtotal) AS total_amount FROM sale_detail d JOIN product p ON d.product_id p.product_id JOIN sale_order o ON d.order_id o.order_id WHERE o.sale_time start_date AND o.sale_time end_date GROUP BY p.product_name ORDER BY total_qty DESC; ENDJava 端调用public void printDailyReport(Date start, Date end) { String sql {call sp_daily_sales_report(?, ?)}; try (Connection conn DBUtil.getConnection(); CallableStatement cs conn.prepareCall(sql)) { cs.setTimestamp(1, new Timestamp(start.getTime())); cs.setTimestamp(2, new Timestamp(end.getTime())); boolean hasResult cs.execute(); while (hasResult) { ResultSet rs cs.getResultSet(); while (rs.next()) { // 映射结果第一段是日汇总第二段是 TOP10 System.out.println(rs.getString(1) | rs.getInt(2)); } hasResult cs.getMoreResults(); } } catch (SQLException e) { e.printStackTrace(); } }存储过程的好处是把计算放在数据库层减少数据传输量而且执行计划可以缓存复用。SET NOCOUNT ON关掉行数统计消息避免 JDBC 驱动把消息当结果集处理导致getMoreResults行为异常。getMoreResults()配合while循环可以依次拿到多个结果集这是调用返回多结果集存储过程的标准写法。参数方面start_date和end_date用DATETIME类型Java 端用setTimestamp传。注意存储过程里的日期比较用半开区间 start_date AND end_date这样跨月跨年都不会漏单或重复。还有一个技巧是给报表查询加WITH (NOLOCK)提示。超市系统报表查询和收银写入可能同时发生加 NOLOCK 允许脏读报表看到的数据可能不是最新但能避免报表查询阻塞收银写入。中小超市对报表实时性要求不高这个取舍是划算的。不过 NOLOCK 有风险可能读到未提交的回滚数据用不用看业务容忍度。从那以后我每次做涉及库存和金额的系统都强制走一遍「事务边界检查 并发扣减测试 流水对账」这三步宁可前期多花两小时也不想后期花三天查账。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑