
简介这是一份面向Java初学者与课程设计学习者的超市商品管理系统完整项目源码基于JavaFX构建图形界面采用MVC设计模式与面向对象思想实现商品、库存等模块管理数据以文件格式化方式持久化保存适合作为Java课程设计参考或OOP与界面设计练手案例。压缩包共46个文件约847KB包含8个java源文件、13个class编译文件、6个fxml界面布局、14个png图片素材以及fxbuild构建配置、项目配置与说明文档源码、资源与编译产物分层清晰便于直接导入Eclipse运行与二次修改。目前已有1410人学习下载。通过该项目可掌握JavaFX控件与CSS样式定制、MVC分层组织、文件读写存储等实用技能并理解从模型到视图的完整交互流程对巩固面向对象编程与GUI开发有较高实践价值。1. 超市商品管理系统从课程设计到能扛住真实收银台的那一步很多 Java 课程设计里都躺着一个超市商品管理系统功能列表看着挺全登录、商品增删改查、库存预警、订单结算。但真把它放到一台收银机上跑一天问题就来了——两个收银员同时卖同一件商品库存扣成了负数收银中途断电订单只写了一半商品条码扫进去价格还是昨天的。这些不是玄学是并发、事务和缓存一致性没处理好。这个标题背后真正要解决的是用 Java 把一套商品管理从「能跑通」推到「敢在真实场景里用」。适合正在做课程设计想拿高分的学生也适合刚入行想补一个完整业务闭环的 Java 工程师。下面按数据建模、并发扣减、事务边界、缓存与条码、避坑、进阶验证六块拆开讲每一步都能直接抄。2. 商品与库存的数据建模表结构定错后面全白干2.1 为什么商品表和库存表要拆开新手最容易犯的错是把商品名称、价格、库存数量全塞进一张product表。看起来省事但商品基础信息名称、条码、分类、进价变更频率极低而库存数量在收银高峰期每秒都在变。两者混在一起每次扣库存都要锁整行商品信息修改也会被库存更新阻塞。常见做法是拆成三张表product存基础信息inventory存库存与版本号stock_record存每一次出入库流水。这样库存扣减只锁inventory行商品信息可以走缓存互不干扰。-- 商品基础表变更少适合缓存 CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, barcode VARCHAR(32) NOT NULL UNIQUE COMMENT 条码收银扫描入口, name VARCHAR(128) NOT NULL, category_id BIGINT NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT 售价必须用DECIMAL, cost_price DECIMAL(10,2) NOT NULL COMMENT 进价, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 库存表高频更新带版本号做乐观锁 CREATE TABLE inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL UNIQUE, quantity INT NOT NULL DEFAULT 0 COMMENT 当前库存, warn_threshold INT NOT NULL DEFAULT 10 COMMENT 预警阈值, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;价格字段用DECIMAL(10,2)而不是FLOAT这是血泪经验。浮点数在累加结算时会出现0.1 0.2 0.30000000000000004这种结果收银小票金额对不上财务对账直接翻车。barcode加唯一索引是因为收银枪可能因为网络抖动重复提交同一条码唯一约束能在数据库层兜底。2.2 库存扣减的三种写法与选型库存扣减是整个系统最容易出并发问题的地方。常见有三种写法先查后改、UPDATE原子扣减、乐观锁版本控制。先查后改在单机低并发下没问题但两个线程同时查到库存 1都判断够扣最后扣成 -1。UPDATE inventory SET quantity quantity - 1 WHERE product_id ? AND quantity 1这种原子写法靠数据库行锁保证不会超卖是中小超市最稳的选择。乐观锁适合库存扣减逻辑复杂、需要先读出来做业务判断的场景但失败要重试。// 原子扣减一条SQL完成判断与扣减返回影响行数 Mapper public interface InventoryMapper { Update(UPDATE inventory SET quantity quantity - #{num}, version version 1 WHERE product_id #{productId} AND quantity #{num}) int deductStock(Param(productId) Long productId, Param(num) int num); } // Service层影响行数为0说明库存不足或并发冲突 Transactional(rollbackFor Exception.class) public void sell(Long productId, int num) { int affected inventoryMapper.deductStock(productId, num); if (affected 0) { throw new BizException(库存不足或商品已下架); } // 写流水、生成订单明细... }deductStock返回的是受影响行数为 0 就抛异常触发回滚。这里Transactional的rollbackFor Exception.class必须写因为 Spring 默认只对RuntimeException回滚业务里抛的受检异常如果不指定事务不会回滚库存扣了订单没生成数据就烂了。参数num是本次购买数量productId是商品 ID两个参数都走预编译占位符避免 SQL 注入。3. 收银结算的事务边界一次下单到底该包多大3.1 事务里不要做远程调用和耗时操作收银结算的典型流程是校验商品状态、扣库存、写订单主表、写订单明细、扣会员积分、打印小票。很多同学图省事把打印小票、调用第三方支付、发短信通知全塞进一个Transactional方法里。结果就是数据库连接被长时间占用高峰期连接池打满整个系统卡死。正确做法是把事务边界收窄到只包含数据库写操作打印和通知放到事务提交后异步执行。Transactional(rollbackFor Exception.class) public OrderVO checkout(CheckoutDTO dto) { // 1. 校验并锁定商品只读可放事务外但为一致性放里面 ListProduct products productMapper.selectByIds(dto.getProductIds()); // 2. 逐项扣库存任一失败抛异常整体回滚 for (CheckoutItem item : dto.getItems()) { int affected inventoryMapper.deductStock(item.getProductId(), item.getNum()); if (affected 0) { throw new BizException(商品[ item.getProductId() ]库存不足); } } // 3. 写订单主表与明细 Order order buildOrder(dto); orderMapper.insert(order); orderItemMapper.batchInsert(order.getId(), dto.getItems()); return toVO(order); } // 事务提交后再打印小票、发通知 public void checkoutAndPrint(CheckoutDTO dto) { OrderVO vo self.checkout(dto); // self是代理对象保证事务生效 printService.asyncPrint(vo); // 异步失败不影响订单 }注意self.checkout(dto)这里用的是注入自身的代理对象而不是this.checkout()。因为Transactional靠 AOP 代理生效同类内部方法直接调用会绕过代理事务注解形同虚设这是 Java 面试八股文里高频问到的自调用失效问题实际项目里踩一次就记住了。CheckoutDTO里包含商品列表、会员 ID、支付方式OrderVO是返回给前端的订单视图对象。3.2 订单号生成与幂等收银台网络抖动时前端可能重复提交同一笔结算请求。如果没有幂等控制会生成两笔订单、扣两次库存。常见做法是前端生成一个requestIdUUID后端用 Redis 做setnx占位key 为order:req:{requestId}过期时间 30 秒。第一次请求占位成功继续处理重复请求直接返回已生成的订单。订单号本身建议用「日期 收银机编号 自增序列」的格式不要用 UUID 做主键因为 InnoDB 聚簇索引下随机主键会导致页分裂插入性能下降。public OrderVO checkoutWithIdempotent(CheckoutDTO dto) { String key order:req: dto.getRequestId(); Boolean first redisTemplate.opsForValue().setIfAbsent(key, 1, 30, TimeUnit.SECONDS); if (Boolean.FALSE.equals(first)) { // 重复请求查已生成订单返回 return orderMapper.selectByRequestId(dto.getRequestId()); } try { return self.checkout(dto); } catch (Exception e) { redisTemplate.delete(key); // 失败释放允许重试 throw e; } }setIfAbsent对应 Redis 的SETNX30是过期秒数防止请求处理失败后 key 永久占位导致后续正常请求被拒。失败时主动删除 key给用户重试的机会。这个幂等方案在单机 Redis 下够用如果 Redis 做了集群要注意 key 落在同一分片上否则setnx不保证原子性。4. 商品缓存与条码扫描让收银枪扫得快、扫得准4.1 商品信息缓存与更新策略收银时每次扫码都查数据库高峰期数据库压力大。商品基础信息变更少适合放本地缓存或 Redis。常见做法是启动时全量加载到本地ConcurrentHashMap同时订阅 Redis 的发布订阅频道商品信息变更时广播刷新。本地缓存的好处是零网络开销扫码响应在毫秒级坏处是多收银机之间有一致性延迟通常可接受因为商品改名改价不是秒级敏感操作。Component public class ProductCache { private final MapString, Product cache new ConcurrentHashMap(); // 启动加载 PostConstruct public void init() { productMapper.selectAllOnShelf().forEach(p - cache.put(p.getBarcode(), p)); } // 条码查询缓存未命中回源并写入 public Product getByBarcode(String barcode) { Product p cache.get(barcode); if (p null) { p productMapper.selectByBarcode(barcode); if (p ! null) { cache.put(barcode, p); } } return p; } // 商品变更时刷新 public void refresh(Long productId) { Product p productMapper.selectById(productId); if (p null || p.getStatus() 0) { cache.values().removeIf(item - item.getId().equals(productId)); } else { cache.put(p.getBarcode(), p); } } }ConcurrentHashMap保证多线程读写安全removeIf处理下架商品。缓存未命中回源时要注意如果数据库也没有不要缓存 null否则后续新增同条码商品会被旧 null 挡住。参数barcode是收银枪扫入的字符串通常 8 到 13 位数字做缓存 key 前建议trim()去掉扫描器可能带上的空白字符。4.2 条码扫描的输入处理与防重复收银枪本质是一个键盘输入设备扫一次会快速输入一串字符然后回车。如果收银员手快连续扫两次或者条码有污损导致扫描器重复触发就会重复加购。处理方式是在前端加一个 300 毫秒的防抖同时后端在购物车项上做条码去重合并数量。另外条码可能有多种格式EAN-13、UPC-A、Code128扫描器一般可配置前缀后缀建议在系统里统一去掉前后缀再匹配。// 前端防抖300ms内重复条码只处理一次 let lastBarcode ; let lastTime 0; function onScan(barcode) { const now Date.now(); if (barcode lastBarcode now - lastTime 300) { return; // 重复扫描忽略 } lastBarcode barcode; lastTime now; addToCart(barcode); }lastBarcode记录上次条码lastTime记录时间戳300 毫秒是经验值太短挡不住重复太长会影响连续扫不同商品的效率。这个逻辑放在前端只是第一道防线后端addToCart里仍要按条码合并数量不能信任前端。5. 避坑与排查那些让系统半夜报警的细节5.1 库存扣成负数现象促销活动开始后某商品库存显示 -3超卖了。原因扣减逻辑用了先查后改两个线程同时查到库存 1都判断够扣。解决改成UPDATE ... WHERE quantity num原子扣减并在inventory表加CHECK (quantity 0)约束兜底数据库层直接拒绝负库存写入。5.2 事务不回滚导致订单与库存不一致现象下单失败提示库存不足但库存已经扣了。原因业务异常是受检异常Transactional默认不回滚。解决统一写Transactional(rollbackFor Exception.class)并且不要在事务方法里 catch 异常后不抛出吞掉异常等于告诉 Spring 一切正常。5.3 缓存与数据库不一致现象后台改了商品价格收银台扫码还是旧价。原因本地缓存没有收到刷新通知或者刷新时只更新了 Redis 没更新本地。解决商品变更走统一入口先更新数据库再删缓存再发广播让各收银机刷新本地缓存。不要先删缓存再更新数据库并发下会读到旧值回填。5.4 订单号重复现象两笔不同订单号一样主键冲突。原因订单号用了时间戳到秒同一秒内多台收银机同时下单。解决订单号加收银机编号和毫秒级时间戳或者用数据库自增序列统一分配。不要用System.currentTimeMillis()单独做订单号。5.5 收银高峰连接池耗尽现象高峰期系统卡顿日志报Connection is not available。原因事务里做了打印、发短信等耗时操作连接被长时间占用。解决事务只包数据库写操作打印通知异步化连接池最大连接数按收银机数量乘以 2 配置并设置合理的超时时间。6. 进阶验证用压测和日志把系统底牌翻出来系统写完不是终点得知道它在多少并发下会出问题。我一般用 JMeter 或 wrk 对结算接口做压测重点看三个指标TPS、库存扣减的最终一致性、订单号是否有重复。压测时准备 100 个商品、每个库存 1000模拟 200 个并发用户各买 1 件跑完后核对inventory.quantity是否等于 1000 减去成功订单数。如果对不上说明扣减逻辑有并发漏洞。# 用 wrk 对结算接口压测-t 线程数 -c 连接数 -d 持续时间 wrk -t4 -c200 -d30s --latency \ -s checkout.lua \ http://localhost:8080/api/checkoutcheckout.lua里构造带requestId的 POST 请求体每个请求用不同requestId模拟真实用户。-t4是 4 个压测线程-c200是 200 个并发连接-d30s跑 30 秒。跑完后看Latency分布如果 P99 超过 500 毫秒就要检查是不是事务太大或者缓存没命中。另一个验证手段是打开 MySQL 的慢查询日志把long_query_time设为 0.1 秒跑一轮收银流程看有没有意外的全表扫描。库存扣减的UPDATE必须走product_id唯一索引订单明细的batchInsert要控制单次批量大小在 500 条以内太大容易触发max_allowed_packet限制。-- 开启慢查询日志 SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 0.1; -- 查看慢查询 SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 20;最后说一个我自己的习惯每次改完库存扣减或事务边界一定会在本地用两个线程各扣 100 次库存跑完检查最终数量是否为 0。这个土办法帮我拦下过至少三次乐观锁重试写错的问题。系统能不能上收银台不看功能列表多长看的是并发扣减和事务回滚这两条线有没有焊死。希望帮到你。本文还有配套的精品资源点击获取