Java超市管理系统与SQL Server实战:库存扣减、事务并发与日结对账
简介这份资源是面向Java初学者与课程设计开发者的超市管理系统项目源码基于Java语言结合SQL Server数据库实现适合作为毕业设计、课程实训或企业级入门练手参考。系统覆盖商品管理、库存控制、销售记录、会员管理与报表生成等核心模块技术栈涉及JDBC连接、Servlet与JSP构建MVC架构并引入Spring与MyBatis简化开发与持久层操作数据库层面包含表结构设计、索引优化、存储过程与触发器等实践要点。压缩包为rar格式大小约2.16MB文件总数暂未提供具体文件类型明细暂无数据但整体体量轻便便于快速下载与本地部署。目前已有488人学习关注读者可从中获取完整的业务模块划分思路、数据库表设计参考以及Java与SQL Server交互的实现范例适合对照梳理系统开发流程与排错逻辑。1. 从一张收银小票说起Java 超市管理系统配 SQL Server 到底在解决什么很多人第一次接触「Java 超市管理系统 SQL Server」这个组合是在课程设计或者小型商超的数字化改造需求里。表面上看它就是个增删改查的练手项目但真正落地到一家日均几百单、几千个 SKU 的门店里问题立刻变得具体收银台扫码后库存要实时扣减促销价和会员价要按规则算对交接班要对得上账退货还得把库存加回去同时留痕。这些动作背后是一套事务边界清晰、并发可控的数据层而 SQL Server 在这里承担的就是那个「账本」角色——它的事务隔离、行锁、存储过程和触发器恰好能覆盖零售场景里最怕出错的几个环节。这篇文章面向两类人一类是刚学完 Java Web 想找个完整项目练手的开发者另一类是要给小型门店做一套能真正跑起来的收银库存系统的工程师。我会从表结构设计讲到 JDBC 连接池配置再到并发扣库存的坑最后落到一个能直接抄的库存扣减方案。全程用 SQL Server 作为数据库Java 作为后端语言不绕弯子。2. 表结构先立住商品、库存、订单三张核心表怎么设计才不返工做超市管理系统最容易翻车的地方不是代码写得多漂亮而是表结构一开始就没想清楚。我见过太多人把商品信息和库存数量塞在同一张表里结果一到盘点就对不上账。下面这套设计是我在几个小型零售项目里反复用过的核心思路是商品档案和库存流水分离订单和订单明细分离所有涉及金额的字段用 DECIMAL 而不是 FLOAT。2.1 商品表与库存表为什么要拆开商品表存的是「这个东西是什么」库存表存的是「这个东西现在有多少、放在哪」。拆开的好处是一个商品可以在多个货位有库存盘点时只动库存表不影响商品档案促销改价也不会锁住库存行。-- 商品档案表只存静态属性不存数量 CREATE TABLE Product ( ProductId INT IDENTITY(1,1) PRIMARY KEY, Barcode VARCHAR(32) NOT NULL UNIQUE, -- 条码收银扫码用 ProductName NVARCHAR(100) NOT NULL, CategoryId INT NOT NULL, UnitPrice DECIMAL(10,2) NOT NULL, -- 标准售价 MemberPrice DECIMAL(10,2) NULL, -- 会员价可为空 IsActive BIT NOT NULL DEFAULT 1, CreatedAt DATETIME NOT NULL DEFAULT GETDATE() ); -- 库存表一个商品一个货位一条记录数量单独维护 CREATE TABLE Inventory ( InventoryId INT IDENTITY(1,1) PRIMARY KEY, ProductId INT NOT NULL, LocationCode VARCHAR(16) NOT NULL DEFAULT DEFAULT, Quantity INT NOT NULL DEFAULT 0, SafetyStock INT NOT NULL DEFAULT 10, -- 安全库存低于此值预警 UpdatedAt DATETIME NOT NULL DEFAULT GETDATE(), CONSTRAINT FK_Inventory_Product FOREIGN KEY (ProductId) REFERENCES Product(ProductId), CONSTRAINT UQ_Inventory_Product_Location UNIQUE (ProductId, LocationCode) );逻辑说明Product 表的 Barcode 加了唯一约束因为收银扫码必须能唯一定位到一个商品。Inventory 表用 (ProductId, LocationCode) 做联合唯一保证同一个商品在同一个货位只有一条库存记录避免出现两条记录各写各的数量导致对不上。SafetyStock 字段是给补货预警用的低于这个值就触发提醒。参数说明UnitPrice 和 MemberPrice 用 DECIMAL(10,2)10 位总长度 2 位小数足够覆盖零售价格。Quantity 用 INT 而不是 DECIMAL因为超市商品按件卖不存在半件库存生鲜称重场景另建表处理。2.2 订单主表与明细表的字段取舍订单表最容易犯的错是把商品信息冗余进去导致商品改名后历史订单显示混乱。正确做法是订单明细只存 ProductId 和下单时的快照价格商品名称通过 JOIN 查但价格必须快照。-- 订单主表 CREATE TABLE SalesOrder ( OrderId BIGINT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(32) NOT NULL UNIQUE, -- 业务单号如 20250101-0001 CashierId INT NOT NULL, MemberId INT NULL, TotalAmount DECIMAL(12,2) NOT NULL DEFAULT 0, DiscountAmount DECIMAL(12,2) NOT NULL DEFAULT 0, PayAmount DECIMAL(12,2) NOT NULL DEFAULT 0, PayMethod TINYINT NOT NULL, -- 1现金 2扫码 3会员卡 OrderStatus TINYINT NOT NULL DEFAULT 1, -- 1正常 2退货 CreatedAt DATETIME NOT NULL DEFAULT GETDATE() ); -- 订单明细表价格快照是关键 CREATE TABLE SalesOrderItem ( ItemId BIGINT IDENTITY(1,1) PRIMARY KEY, OrderId BIGINT NOT NULL, ProductId INT NOT NULL, Quantity INT NOT NULL, UnitPrice DECIMAL(10,2) NOT NULL, -- 下单时的实际成交单价 Subtotal DECIMAL(12,2) NOT NULL, CONSTRAINT FK_Item_Order FOREIGN KEY (OrderId) REFERENCES SalesOrder(OrderId) );逻辑说明SalesOrderItem 里的 UnitPrice 是下单那一刻的实际成交价不是 Product 表里的当前价。这样即使后续调价历史订单金额也不会变。OrderNo 用业务单号而不是自增主键对外方便收银员口头核对。参数说明OrderId 用 BIGINT因为订单量大的门店一天可能几千单INT 上限 21 亿虽然够用很久但 BIGINT 更稳妥。PayMethod 和 OrderStatus 用 TINYINT 枚举比 VARCHAR 省空间且查询快。2.3 建索引的三个位置和两个不要索引不是越多越好。超市系统里最影响性能的查询是「按条码找商品」和「按时间段查订单」所以索引要精准打在这两个地方。-- 必须建的索引 CREATE INDEX IX_Product_Barcode ON Product(Barcode); -- 扫码查询 CREATE INDEX IX_Order_CreatedAt ON SalesOrder(CreatedAt); -- 日结查询 CREATE INDEX IX_Item_OrderId ON SalesOrderItem(OrderId); -- 订单明细关联 -- 不要建的两个索引 -- 不要给 Product.IsActive 单独建索引区分度太低 -- 不要给 Inventory.Quantity 建索引频繁更新会导致索引维护开销逻辑说明Barcode 上的索引让扫码查询走索引查找而不是全表扫描。CreatedAt 索引让「查今天所有订单」这类日结查询快速定位。OrderId 索引加速订单和明细的 JOIN。IsActive 只有 0 和 1 两个值索引区分度极低查询优化器通常不会用。Quantity 字段更新频繁每次扣库存都要维护索引得不偿失。3. Java 侧连 SQL ServerJDBC 配置、连接池和事务边界表建好了接下来是 Java 怎么连上去。这一步的坑主要集中在驱动版本、连接池参数和事务提交时机上。我见过最典型的问题是收银扣库存和写订单在两个独立事务里结果订单写成功了库存没扣或者反过来。3.1 JDBC 驱动引入与连接字符串写法SQL Server 的 JDBC 驱动是 mssql-jdbcMaven 里引进来就行。连接字符串有几个参数必须显式设置否则默认行为可能不符合预期。!-- pom.xml 中引入 SQL Server JDBC 驱动 -- dependency groupIdcom.microsoft.sqlserver/groupId artifactIdmssql-jdbc/artifactId version12.4.2.jre11/version /dependency// 连接字符串几个关键参数说明 String url jdbc:sqlserver://localhost:1433; databaseNameSupermarketDB; encrypttrue; // 启用加密避免明文传输 trustServerCertificatetrue; // 本地开发跳过证书验证生产环境应配正式证书 loginTimeout10; // 登录超时 10 秒避免卡死 socketTimeout30000; // 套接字超时 30 秒 String user sa; String password YourStrongPassword;逻辑说明encrypttrue 在较新版本的驱动里是默认值但显式写出来更清晰。trustServerCertificatetrue 只建议在本地开发用生产环境应该配置正式证书否则加密形同虚设。loginTimeout 和 socketTimeout 是防止网络异常时线程一直挂着的后悔药。参数说明端口 1433 是 SQL Server 默认端口如果改了要对应调整。databaseName 指定要连的库。socketTimeout 设 30 秒是因为收银场景下查询通常很快超过 30 秒基本是异常早点抛错比一直等好。3.2 HikariCP 连接池参数怎么调连接池用 HikariCP它是目前 Java 生态里性能和稳定性都比较好的选择。参数不能照抄网上的模板要根据门店收银台的并发量来算。HikariConfig config new HikariConfig(); config.setJdbcUrl(url); config.setUsername(user); config.setPassword(password); config.setMaximumPoolSize(10); // 收银台一般 2-4 个10 个足够 config.setMinimumIdle(2); // 保持 2 个空闲连接避免每次新建 config.setConnectionTimeout(3000); // 获取连接超时 3 秒 config.setIdleTimeout(600000); // 空闲 10 分钟回收 config.setMaxLifetime(1800000); // 连接最长存活 30 分钟 config.setPoolName(SupermarketPool); HikariDataSource dataSource new HikariDataSource(config);逻辑说明maximumPoolSize 设 10 是因为小型超市同时收银的终端通常不超过 4 台加上后台查询和报表10 个连接足够。设太大反而会增加 SQL Server 的线程切换开销。connectionTimeout 设 3 秒收银员等 3 秒还没拿到连接就该报错重试了。参数说明maxLifetime 设 30 分钟是为了避免连接被数据库端或网络中间设备断开后变成死连接。idleTimeout 设 10 分钟回收空闲连接释放资源。minimumIdle 设 2 保证随时有可用连接不用每次现建。3.3 一个事务里完成扣库存和写订单这是整个系统最核心的一段代码。扣库存和写订单必须在同一个事务里要么都成功要么都回滚。public void checkout(SalesOrder order, ListSalesOrderItem items) throws SQLException { Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); // 关闭自动提交开启事务 conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); // 1. 写入订单主表 String orderSql INSERT INTO SalesOrder (OrderNo, CashierId, TotalAmount, PayAmount, PayMethod) VALUES (?, ?, ?, ?, ?); try (PreparedStatement ps conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, order.getOrderNo()); ps.setInt(2, order.getCashierId()); ps.setBigDecimal(3, order.getTotalAmount()); ps.setBigDecimal(4, order.getPayAmount()); ps.setByte(5, order.getPayMethod()); ps.executeUpdate(); // 获取自增主键用于明细关联 try (ResultSet rs ps.getGeneratedKeys()) { if (rs.next()) order.setOrderId(rs.getLong(1)); } } // 2. 逐条扣库存并写明细 String stockSql UPDATE Inventory SET Quantity Quantity - ?, UpdatedAt GETDATE() WHERE ProductId ? AND Quantity ?; String itemSql INSERT INTO SalesOrderItem (OrderId, ProductId, Quantity, UnitPrice, Subtotal) VALUES (?, ?, ?, ?, ?); try (PreparedStatement stockPs conn.prepareStatement(stockSql); PreparedStatement itemPs conn.prepareStatement(itemSql)) { for (SalesOrderItem item : items) { // 扣库存WHERE 条件里带 Quantity ? 防止超卖 stockPs.setInt(1, item.getQuantity()); stockPs.setInt(2, item.getProductId()); stockPs.setInt(3, item.getQuantity()); int affected stockPs.executeUpdate(); if (affected 0) { throw new SQLException(库存不足商品ID: item.getProductId()); } // 写明细 itemPs.setLong(1, order.getOrderId()); itemPs.setInt(2, item.getProductId()); itemPs.setInt(3, item.getQuantity()); itemPs.setBigDecimal(4, item.getUnitPrice()); itemPs.setBigDecimal(5, item.getSubtotal()); itemPs.addBatch(); } itemPs.executeBatch(); } conn.commit(); // 全部成功才提交 } catch (SQLException e) { if (conn ! null) conn.rollback(); // 任何一步失败整体回滚 throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } }逻辑说明setAutoCommit(false) 开启事务所有操作在 commit 之前都不生效。扣库存的 UPDATE 语句里 WHERE 条件带了 Quantity ?这是防超卖的关键——如果库存不够affected 会是 0直接抛异常触发回滚。明细用 addBatch 批量提交减少网络往返。参数说明TRANSACTION_READ_COMMITTED 是 SQL Server 默认隔离级别能防止脏读且不会像 REPEATABLE_READ 那样持有大量锁。RETURN_GENERATED_KEYS 用来拿订单自增 IDSQL Server 需要驱动支持mssql-jdbc 是支持的。4. 并发扣库存的坑从超卖到死锁的排查记录这一章专门讲踩过的坑。超市系统在高峰期比如晚上六点到八点多个收银台同时操作并发问题会集中暴露。下面四条是我实际遇到并解决过的。4.1 现象两个收银台同时卖最后一件商品库存变成 -1原因最初的代码是先 SELECT 查库存Java 里判断够不够再 UPDATE 扣减。两个线程同时查到库存为 1都判断通过然后各自扣 1结果变成 -1。解决把判断逻辑放进 UPDATE 的 WHERE 条件里用UPDATE Inventory SET Quantity Quantity - ? WHERE ProductId ? AND Quantity ?靠数据库的行锁保证原子性。affected 为 0 就说明库存不够直接抛异常回滚。这个改法不需要显式加锁SQL Server 在执行 UPDATE 时会对目标行加排他锁第二个事务会等待第一个提交后再执行。4.2 现象日结时报表查询卡住收银台也跟着卡原因报表查询用了SELECT * FROM SalesOrder WHERE CreatedAt 2025-01-01没有索引导致全表扫描扫描时持有共享锁阻塞了收银台的插入操作。解决给 CreatedAt 建索引并且报表查询加WITH (NOLOCK)提示SQL Server 特有允许脏读避免阻塞写入。但要注意 NOLOCK 可能读到未提交数据只适合报表这种对实时性要求不高的场景收银扣库存绝对不能用。-- 报表查询用 NOLOCK收银查询不能用 SELECT OrderNo, PayAmount, CreatedAt FROM SalesOrder WITH (NOLOCK) WHERE CreatedAt 2025-01-01 AND CreatedAt 2025-01-02;4.3 现象批量退货时出现死锁两个事务互相等待原因退货操作要更新 Inventory 和插入退货记录两个并发退货事务如果更新 Inventory 的顺序不一致一个先更新商品 A 再更新商品 B另一个反过来就会形成死锁。解决统一更新顺序所有涉及多商品的操作都按 ProductId 升序排列后再执行。这样两个事务的加锁顺序一致不会互相等待。另外把退货事务的隔离级别降到 READ_COMMITTED减少锁持有时间。// 按 ProductId 排序后再处理避免死锁 items.sort(Comparator.comparingInt(SalesOrderItem::getProductId)); for (SalesOrderItem item : items) { // 按序更新库存 }4.4 现象连接池耗尽收银台报「获取连接超时」原因有一段代码在循环里每次 getConnection() 但没有 close()连接泄漏。跑一段时间后连接池 10 个连接全被占满。解决所有 Connection、PreparedStatement、ResultSet 都用 try-with-resources 包裹确保异常时也能释放。另外在 HikariCP 配置里加config.setLeakDetectionThreshold(60000)超过 60 秒没归还的连接会打印警告日志方便定位泄漏点。提示连接泄漏是生产环境最常见的问题之一上线前一定要开泄漏检测哪怕日志多一点也比半夜被叫起来强。5. 收银小票打印与日结对账两个容易被忽略的落地细节系统能跑通之后真正决定它能不能在门店用起来的往往是打印和对账这两个「边缘」功能。收银小票打印涉及字符编码和排版日结对账涉及金额精度和差异处理这两个地方翻车收银员会直接弃用系统。5.1 小票打印的编码与排版处理Java 调打印机通常用 ESC/POS 指令集通过串口或 USB 发送字节流。中文小票最容易出的问题是乱码根源是编码没对上。// 小票打印核心GBK 编码 ESC/POS 指令 public void printReceipt(SalesOrder order, ListSalesOrderItem items) throws IOException { ByteArrayOutputStream bos new ByteArrayOutputStream(); // 初始化打印机 bos.write(new byte[]{0x1B, 0x40}); // 设置中文模式GBK bos.write(new byte[]{0x1C, 0x26}); // 打印标题居中 bos.write(new byte[]{0x1B, 0x61, 0x01}); bos.write(超市收银小票\n.getBytes(GBK)); bos.write(new byte[]{0x1B, 0x61, 0x00}); // 左对齐 // 打印明细 for (SalesOrderItem item : items) { String line String.format(%-12s x%d %8.2f\n, item.getProductName(), item.getQuantity(), item.getSubtotal()); bos.write(line.getBytes(GBK)); } // 切纸 bos.write(new byte[]{0x1D, 0x56, 0x42, 0x00}); // 发送到打印机此处省略具体端口写入 sendToPrinter(bos.toByteArray()); }逻辑说明0x1B 0x40 是初始化指令清空缓冲区。0x1C 0x26 是进入中文模式。0x1B 0x61 0x01 是居中对齐0x00 是左对齐。0x1D 0x56 0x42 0x00 是切纸指令。所有中文必须用 GBK 编码因为大多数热敏打印机的中文字库是 GBK 的用 UTF-8 会乱码。参数说明String.format 里的 %-12s 表示左对齐占 12 个字符宽度x%d 是数量%8.2f 是金额右对齐占 8 位保留 2 位小数。这些宽度要根据实际小票纸宽度通常 58mm 或 80mm调整。5.2 日结对账的金额精度与差异排查日结的核心是当天所有订单的 PayAmount 之和应该等于收银员实际收到的现金加扫码金额。差异通常来自三个地方退款没冲减、折扣计算精度、以及并发下的金额覆盖。-- 日结汇总查询按支付方式分组统计 SELECT PayMethod, COUNT(*) AS OrderCount, SUM(TotalAmount) AS TotalSales, SUM(DiscountAmount) AS TotalDiscount, SUM(PayAmount) AS TotalReceived FROM SalesOrder WHERE CreatedAt StartDate AND CreatedAt EndDate AND OrderStatus 1 -- 只统计正常订单退货单独查 GROUP BY PayMethod;逻辑说明按 PayMethod 分组分别统计现金、扫码、会员卡的应收金额。OrderStatus 1 过滤掉退货订单退货的金额冲减要单独处理。SUM 用 DECIMAL 类型避免浮点误差。参数说明StartDate 和 EndDate 是参数化查询的占位符用 PreparedStatement 传入避免 SQL 注入。日期范围用左闭右开避免边界重复统计。对账差异排查步骤先查当天是否有未完成的挂单再查退货订单是否已冲减最后核对折扣计算是否用了四舍五入。我一般会在日结报表里加一列「理论金额」和「实收金额」的差值超过 0.01 就标红提醒人工核查。注意金额字段一律用 DECIMAL不要用 DOUBLE。DOUBLE 在累加时会产生 0.0000001 级别的误差几百单累加后可能差出几毛钱对账时很难解释。6. 从能跑到好用库存预警与批量导入的两个进阶技巧系统上线只是开始真正让门店愿意持续用的是那些能减少人工操作的细节。这里分享两个我后来加上的功能库存预警和商品批量导入。它们不复杂但能显著降低店员的日常工作量。6.1 用 SQL Server 作业做库存预警库存预警不需要在 Java 里写定时任务SQL Server 自带的作业调度就能做。建一个存储过程每天早晚各跑一次把低于安全库存的商品查出来写进预警表。-- 库存预警存储过程 CREATE PROCEDURE sp_CheckStockWarning AS BEGIN SET NOCOUNT ON; -- 清空旧预警 DELETE FROM StockWarning WHERE CreatedAt DATEADD(DAY, -7, GETDATE()); -- 插入新预警库存低于安全库存的商品 INSERT INTO StockWarning (ProductId, ProductName, CurrentQty, SafetyStock, CreatedAt) SELECT p.ProductId, p.ProductName, i.Quantity, i.SafetyStock, GETDATE() FROM Inventory i INNER JOIN Product p ON i.ProductId p.ProductId WHERE i.Quantity i.SafetyStock AND p.IsActive 1; END;逻辑说明先清理 7 天前的旧预警避免表无限增长。然后关联 Product 和 Inventory找出库存低于安全库存且商品仍在售的记录。IsActive 1 过滤掉已下架商品避免误报。参数说明DATEADD(DAY, -7, GETDATE()) 是保留最近 7 天预警这个天数可以根据门店补货周期调整。如果补货周期是 3 天改成 -3 就行。在 SQL Server Management Studio 里建一个作业每天 8 点和 18 点各执行一次这个存储过程。Java 侧只需要查 StockWarning 表展示给店员就行不用自己维护定时逻辑。6.2 商品批量导入的 CSV 解析与事务控制新店开业或者换季时商品档案需要批量导入。用 CSV 文件导入比一条条手工录入快得多但要注意编码和事务控制。// CSV 批量导入商品整批成功或整批回滚 public int importProducts(InputStream csvStream) throws Exception { ListProduct products new ArrayList(); try (BufferedReader reader new BufferedReader( new InputStreamReader(csvStream, StandardCharsets.UTF_8))) { String line; boolean isHeader true; while ((line reader.readLine()) ! null) { if (isHeader) { isHeader false; continue; } // 跳过表头 String[] cols line.split(,); if (cols.length 5) continue; // 列数不足跳过 Product p new Product(); p.setBarcode(cols[0].trim()); p.setProductName(cols[1].trim()); p.setCategoryId(Integer.parseInt(cols[2].trim())); p.setUnitPrice(new BigDecimal(cols[3].trim())); p.setMemberPrice(cols[4].trim().isEmpty() ? null : new BigDecimal(cols[4].trim())); products.add(p); } } // 批量插入整批一个事务 Connection conn dataSource.getConnection(); try { conn.setAutoCommit(false); String sql INSERT INTO Product (Barcode, ProductName, CategoryId, UnitPrice, MemberPrice) VALUES (?, ?, ?, ?, ?); try (PreparedStatement ps conn.prepareStatement(sql)) { for (Product p : products) { ps.setString(1, p.getBarcode()); ps.setString(2, p.getProductName()); ps.setInt(3, p.getCategoryId()); ps.setBigDecimal(4, p.getUnitPrice()); ps.setBigDecimal(5, p.getMemberPrice()); ps.addBatch(); } ps.executeBatch(); } conn.commit(); return products.size(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); } }逻辑说明CSV 用 UTF-8 读取避免中文乱码。跳过表头行列数不足的行直接跳过而不是报错容错性更好。整批插入放在一个事务里任何一条失败全部回滚避免导入一半留下脏数据。参数说明split(,) 是最简单的 CSV 解析如果商品名里包含逗号会出问题生产环境建议用 OpenCSV 之类的库。addBatch 配合 executeBatch 减少网络往返几千条商品导入能在几秒内完成。我自己的习惯是每次给门店加新功能前先问收银员「这个操作你一天要做几次」。如果超过 20 次就值得做成批量或者自动化。库存预警和批量导入就是这么加进来的后来店员反馈说比之前手工盘点省了至少一个小时。希望帮到你。本文还有配套的精品资源点击获取