拓冰建站拓冰建站
首页 / 资讯中心 / 正文

JavaWeb超市收银系统源码解析:Servlet、事务与并发库存扣减

简介一份基于 JavaWeb 的超市收银系统完整源码面向计算机相关专业学生和初级 Java 开发者适合用于课程设计、毕业设计或系统学习经典 JavaWeb 分层项目结构与开发流程。系统覆盖商品管理、订单管理、用户管理与销售统计等核心模块包含商品增删改查、库存预警、多种支付方式、多角色权限控制等功能可支撑小型超市的日常收银、库存追踪和运营数据统计。压缩包共含 147 个文件以 60 个 Java 源文件、29 个 JSP 页面、22 个映射/配置文件、16 个 CSS 样式和 7 个 JS 脚本为主另附 SQL 初始化脚本与 Maven 工程配置整体大小仅 1.61MB目录按后端逻辑、页面视图和静态资源分类便于查找与部署。已有 97 人学习下载。借助该源码可获得可运行的前后端代码、数据库脚本和清晰的目录组织思路既能直接完成演示也适合二次开发或对照研读帮助深入理解用户会话、权限校验及数据持久化等 JavaWeb 关键环节。1. 拿到这份 JavaWeb 超市收银系统源码之后先看什么超市收银系统在 JavaWeb 项目里属于麻雀小但五脏全的类型。标题里带 JavaWeb 而不是 Spring Boot意味着技术栈通常落在 Servlet 3.0、JSP、JDBC 或 MyBatis 这个区间这也正是很多教学项目与面试八股的分水岭。对从业者而言这类源码真正的价值不在登录注册那几行代码而在三件事购物车生命周期管理、订单落库时的事务边界、并发结账时库存扣减。这三个点分别对应 HttpSession 的使用边界、Service 层事务粒度、数据库锁与隔离级别。拿到源码包先别急着启动 Tomcat先看 web.xml 里的 url-pattern 和数据库脚本里有没有外键这两个文件基本能判断整套代码是能跑通教学演示还是能扛住真实门店的收银高峰期。2. 技术栈与数据库设计超市收银系统的表结构与连接池参数2.1 为什么收银系统值得用 Servlet JSP 而不是直接上 Spring Boot常见做法是课程设计或毕业设计用 Servlet JSP JDBC或 MyBatis来完成这套系统。原因很实际Servlet 是 JavaWeb 的基础设施HttpServletRequest、HttpServletResponse、HttpSession 这些对象在 Spring MVC 里虽然被封装了一层但请求生命周期和会话管理的基本语义仍然来自 Servlet 规范。超市收银系统天然适合拿来验证这些概念每个收银台的操作就是一个 HTTP 请求从加购到支付是几次请求之间的状态保持这恰好是 Session 的典型场景。如果要改造成 Spring Boot 风格对应关系大致是WebServlet 变成 RestController 或 Controllerweb.xml 里的 servlet-mapping 变成 RequestMappingJDBC 的 Connection 由 MyBatis 的 SqlSession 接管。但有一个点不能直接平移就是过滤器链的顺序。Spring Boot 里 FilterRegistrationBean 的 order 属性控制 Filter 的执行顺序而传统写法是在 web.xml 里从上到下排列权限过滤器和编码过滤器谁在前谁在后直接决定 Session 能不能在中文参数被解析之前取到用户上下文。这个顺序问题在源码里经常被忽略运行期表现为乱码或者 Session 意外失效。2.2 商品表、订单表、订单明细表的字段类型与约束超市收银系统最核心的是三张表product商品表、orders订单主表、order_item订单明细表。注意订单主表不要命名为 orderorder 在 SQL 里是 ORDER BY 的关键字很多建表脚本在这里踩坑。下面是可在 MySQL 5.7 / 8.0 执行的最小建表脚本CREATE TABLE product ( id INT AUTO_INCREMENT PRIMARY KEY, barcode VARCHAR(32) UNIQUE NOT NULL COMMENT 条码收银台扫码用, name VARCHAR(128) NOT NULL COMMENT 商品名称, price DECIMAL(10,2) NOT NULL COMMENT 零售单价保留两位小数, stock INT NOT NULL DEFAULT 0 COMMENT 当前库存收银时校验, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 业务订单号, total_amount DECIMAL(10,2) NOT NULL COMMENT 实收金额, cashier_id INT NOT NULL COMMENT 收银员用户ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-已创建 1-已支付 2-已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, product_id INT NOT NULL, product_name VARCHAR(128) NOT NULL COMMENT 冗余商品名快照防止历史小票随商品改名而变, unit_price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL, KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两个容易写废的点。第一price 字段用 DECIMAL(10,2) 而不是 double。double 是浮点类型0.1 0.2 的二进制误差在金额累加多次后会变成 0.30000000000000004如果订单明细多实收金额会出现分以下的多余数字。DECIMAL 是定点数在数据库端按整数方式存储累加不会丢精度。第二order_item 冗余 product_name 是刻意设计不是重复存储。超市改商品名是常态但历史订单小票上的商品名不能跟着变所以下单那一刻的快照必须写进明细表。这和数据仓库里的缓慢变化维度SCD Type 2思路一致。stock 字段用 INT 也值得说一句。有些教材把库存设计成 DECIMAL理由是部分商品按斤卖但收银系统里按件和按重是两种不同的业务模式。按重商品通常走电子秤估重后传克数上来克数在 Java 端向下取整为 INT 再写库。库存出现负数的高危数据大多来自并发扣减第四章专门处理。2.3 连接池参数Druid 配置与收银高峰期的关系JavaWeb 阶段最常见的连接池配置是 DBCP 或 C3P0近年新写的项目用 Druid。DBCP 的默认行为在 maxTotal 打满时直接抛 SQLException而超市收银的高峰期通常集中在下班后的两个小时内收银台 10 台、连接池 20 个连接瞬间被占满是现实情况。下面以 Druid 为例给出推荐配置driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/supermarket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai usernameroot passwordyour_password initialSize5 minIdle5 maxActive20 maxWait3000 testWhileIdletrue timeBetweenEvictionRunsMillis60000 removeAbandonedtrue removeAbandonedTimeout180maxWait 设 3000 毫秒意思是高峰期拿不到连接时不无限等3 秒后抛异常让收银员重新扫码而不是一直转圈。removeAbandonedTimeout 设 180 秒防止某台收银终端在结账中途断网连接没归还导致池子被占满。testWhileIdle 表示空闲连接每 60 秒探测一次这个配置配合 MySQL 的 wait_timeout能避免凌晨首单报连接已关闭的经典问题。参数调优后的经验值列在下面这张表参数Druid 默认值推荐值对应收银故障maxActive820晚高峰连接不够报 CannotGetJdbcConnectionmaxWait-1无限等待3000前端页面卡死用户重复点按钮removeAbandonedTimeout300180连接泄漏导致池子慢慢耗尽testWhileIdletruetrue空闲超时后首笔交易连不上库提示晚高峰如果连接池调整后还是打满优先检查 SQL 有没有走索引。orders 表虽然对 order_no 建了唯一索引但很多源码的统计报表按 create_time 范围扫这种 SQL 一个高峰能扫出几十万行。3. 收银主流程实现从扫码加购到结账提交的代码骨架收银系统的主链路是收银员扫码或输入条码 → 商品进入购物车 → 顾客确认 → 收银台结账 → 库存扣减 → 订单落库 → 打印小票。这一章按顺序把每一步的落法写清楚这也是 JavaWeb 项目完整案例里最值得复刻的一段业务主链路。3.1 购物车用 HttpSession 还是数据库表收银场景的答案很明确超市收银系统很少用数据库表存购物车原因是购物车是临时态生命周期就是顾客从排队到结账这一段通常不超过十分钟。用 HttpSession 存购物车天然绑定到收银台的登录用户结账完成后 removeAttribute 即可不需要建表也不需要清理任务。只有线上商城场景购物车要跨设备、跨 session 保持时才值得建 cart 表。这里要留意一个边界Session 对象的序列化。如果购物车对象要支持 Tomcat 重启后恢复需要实现 Serializable 接口并把商品对象里所有字段做成可序列化类型。但在收银场景一般不建议开 Tomcat 持久化 Session因为收银台是强一致场景重启前未完成的订单应让收银员确认后重建购物车而不是静默恢复一个可能已失效的购物车。购物车的数据结构常见做法是 LinkedHashMap 而不是 ArrayList这样重复扫码能合并数量SuppressWarnings(unchecked) private void addToCart(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session request.getSession(); // CART 里存的是 LinkedHashMapkey 是商品 ID 的字符串形式 MapString, CartItem cart (MapString, CartItem) session.getAttribute(CART); if (cart null) { cart new LinkedHashMap(); session.setAttribute(CART, cart); } int productId Integer.parseInt(request.getParameter(productId)); int quantity Integer.parseInt( request.getParameter(quantity) null ? 1 : request.getParameter(quantity)); ProductDao dao new ProductDao(); Product p dao.findById(productId); CartItem existing cart.get(String.valueOf(p.getId())); if (existing ! null) { existing.setQuantity(existing.getQuantity() quantity); } else { // CartItem 内持有 Product 引用和数量不持有多个 Product 副本 cart.put(String.valueOf(p.getId()), new CartItem(p, quantity)); } response.sendRedirect(request.getContextPath() /cart.jsp); }这段代码有几个可以较真的细节。第一Map 的 key 用 productId 的字符串而不是 Product 对象因为对象做 key 必须重写 hashCode 和 equals稍不注意就变成同一商品两条记录。第二每次加购查一次数据库是收银场景可接受的扫码频率远低于线上秒杀但把 SQL 优化成SELECT name, price FROM product WHERE id ?的列子集能减少 InnoDB 聚簇索引页的加载量。第三SuppressWarnings(unchecked)是 Servlet 旧代码里最常见的黄色警告来源它不影响运行不要为了消警告而改动强转逻辑。3.2 下单结账的 Service 方法事务边界和返回值设计提交订单时扣库存、插订单、插明细三个操作必须在一个事务里。如果不使用 Spring 的 Transactional常见做法是手写一个基于 ThreadLocal 的 TransactionManager让每个线程持有自己那条连接事务边界跨越多个 DAO 调用仍然生效public class TransactionManager { private static final ThreadLocalConnection HOLDER new ThreadLocal(); public static Connection getConnection() throws SQLException { Connection conn HOLDER.get(); if (conn null) { conn DataSourceUtils.getDataSource().getConnection(); // 关键关闭自动提交否则每个 DAO 各自 commit事务形同虚设 conn.setAutoCommit(false); HOLDER.set(conn); } return conn; } public static void commit() throws SQLException { Connection conn HOLDER.get(); if (conn ! null) { conn.commit(); conn.close(); HOLDER.remove(); } } public static void rollback() throws SQLException { Connection conn HOLDER.get(); if (conn ! null) { conn.rollback(); conn.close(); HOLDER.remove(); } } }OrderService 里的执行顺序是查库存 → 扣库存 → 插订单 → 插明细任一环节抛异常就在 catch 里调 rollback。这里最常见的新手错误是忘掉setAutoCommit(false)导致每个 DAO 方法内自动提交库存扣了但订单没插进去对账时发现钱收了货没少。Service 方法返回类型也要注意。很多源码返回 boolean下单失败就返回 false页面弹一句系统错误。这在收银场景里等于让收银员猜谜。生产上更合适的是返回一个 Result 对象public class Result { private int code; // 200-成功 400-业务失败 500-系统异常 private String message; private Object data; // 省略构造器与 getter / setter }库存不足时返回 400 商品可口可乐 500ml 库存不足当前剩余 3 件收银台页面直接展示这段 message。收银员在高峰期没时间看日志失败原因必须直接摆在屏幕上。3.3 收银台页面的金额计算与 JSP 小脚本的取舍金额计算放后端还是前端收银系统的答案是一律放后端。前端用 JS 算金额会出现浮点精度和舍入差异后端保留两位小数用BigDecimal.valueOf(item.getUnitPrice()).multiply(BigDecimal.valueOf(item.getQuantity())).setScale(2, RoundingMode.HALF_UP)。Servlet 算好小计和合计后放进 request attributeJSP 只做展示。JSP 里% %小脚本尽量不用。看到满屏小脚本的源码基本可以判断写法停留在 Servlet 2.5 时代。替代方案是 JSTL ELc:forEach varitem items${cartItems} tr td${item.product.name}/td td${item.quantity}/td td${item.product.price}/td td${item.subtotal}/td /tr /c:forEach这里把${cart.values()}换成${cartItems}是故意的。EL 2.2 之前不能直接调用 values() 方法老 Tomcat 会报 PropertyNotFoundException。比较稳的写法是 Servlet 里把ListCartItem放进 request再在 JSP 里迭代这个 List既绕开 EL 版本差异也避免在页面上直接暴露 Session 内部结构。4. 并发收银与数据一致性超卖、订单重复和一行 UPDATE 的取舍这是超市收银系统含金量最高的一部分。并发收银是多个 POS 机同时提交结账请求同一商品库存是一行数据三个柜台同时卖最后一瓶水库存只能变成 0不能变成 -2。这一章的方案不依赖任何中间件纯靠数据库特性就能落地。4.1 乐观锁 UPDATE 条件改写一条 SQL 解决库存不足与并发竞态最常见的错误写法是先 SELECT stock判断 stock 0再 UPDATE。这是典型的 check-then-act 竞态两个并发请求同时读到 stock1都通过判断然后各自 UPDATE库存变成 -1。正确做法是把判断条件写进 UPDATE 的 WHERE 子句UPDATE product SET stock stock - ? WHERE id ? AND stock ?;这条 SQL 返回的受影响行数就是判据。在 DAO 层用int rows productDao.deductStock(productId, quantity)Service 层判断 rows 是否为 0int rows productDao.deductStock(order.getProductId(), order.getQuantity()); if (rows 0) { return Result.fail(400, 库存已被其他收银台扣光请重新扫码); } // 继续插订单主表和明细 // ... TransactionManager.commit(); return Result.success();一个容易忽略的细节是affected rows 为 0 并不能区分库存不足和商品 ID 不存在。如果前端传来一个已被删除的商品 ID同样会拿到 rows0。所以严格一点的做法是先查一次商品是否存在但查完到 UPDATE 之间仍有竞态窗口最终还是要靠这条带条件的 UPDATE 兜底。4.2 悲观锁 SELECT ... FOR UPDATE 什么时候值得用乐观锁适合冲突不多的场景但超市收银的晚高峰会出现多个收银台抢同一件商品。让一个事务在读取商品行时直接把行锁住能杜绝业务层的重试逻辑代价是并发吞吐下降。典型写法是在事务内执行SELECT stock FROM product WHERE id ? FOR UPDATE;拿到锁后其他事务对该行的 UPDATE 都会排队直到当前事务提交或回滚。这条语句的适用边界是事务足够短一般控制在秒级以内且冲突频繁到乐观锁重试成本高于阻塞成本。收银台扫码、确认、提交这个交互在收银员手里可能停留几秒而事务只在点击结账那一下开启所以悲观锁更符合收银场景。注意FOR UPDATE 必须搭配事务才能生效在 autocommittrue 的连接上执行等于没锁。如果发现两个收银台仍然能同时扣同一商品库存优先检查连接池配置和 TransactionManager 的 setAutoCommit 是否被误关。4.3 订单号生成时间戳加随机数为什么会撞怎么改很多毕业设计源码的订单号是System.currentTimeMillis() new Random().nextInt(1000)。这个方案在单机多线程下撞车的概率比想象中高Random 在多线程下种子可能重复时间戳是毫秒级粒度同一毫秒内两个请求掷出同一个随机数并不罕见。订单号的唯一索引一撞整个结账事务回滚顾客重新排队。JavaWeb 阶段可落地的替代方案是用数据库自增 ID 拼时间戳String orderNo String.format(%s%07d, new SimpleDateFormat(yyyyMMddHHmmss).format(new Date()), orderId);实现方式可以是先插入订单主表status 为创建中拿到自增主键 id再 UPDATE order_no。这比分布式 UUID 方案更简单且订单号可读便于收银员在电话报单时念出来。缺点是自增 ID 在跨库扩容时会冲突但单库收银系统里它完全够用。4.4 事务隔离级别MySQL 默认 REPEATABLE_READ 对收银的影响MySQL InnoDB 默认隔离级别是 REPEATABLE_READ这与 Oracle 的 READ_COMMITTED 不同。对收银系统的利好是一个事务内重复读取同一行商品价格结果一致符合结账期间价格不能变的业务预期。需要避免的配置是把隔离级别降成 READ_UNCOMMITTED极端情况下会读到其他事务未提交的库存变化表现为明明下单了但订单详情里查不到金额。连接池层面如果显式配置了defaultTransactionIsolation建议写成TRANSACTION_REPEATABLE_READ避免驱动或数据源的默认值不一致。这个参数在 Druid 的配置文件里不是常见项但一旦配错排查成本很高。5. 打包部署与验证收银系统从 war 包到并发测试的最后一公里收银系统代码写完后部署和验证是决定源码能否实际开张的最后一步。这一章给出一条可复现的路径。5.1 Maven 构建 war 包Servlet 3.0 注解扫描与 provided 依赖如果源码使用 WebServlet 注解而没有 web.xmlMaven 打包时默认会报web.xml is missing。需要在 pom.xml 里配置plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-war-plugin/artifactId version3.4.0/version configuration failOnMissingWebXmlfalse/failOnMissingWebXml /configuration /plugin另一项必须检查的是 servlet-api 依赖的 scope。Tomcat 7 自带 Servlet 3.0 API应用里如果再打包一份 servlet-api类加载时可能出现同名类冲突报 LinkageErrordependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version3.1.0/version scopeprovided/scope /dependencyscope 为 provided 表示编译期可用但不进 war 包。把 war 丢进 Tomcat 的 webapps 目录后会自动展开部署启动日志里如果看到Deployment of web application archive字样就说明上下文注册成功。5.2 用 jstack 定位死锁用日志定位扣减失败部署完成后并发验证至少要做两件事并发下单测试和库存最终值核对。最简单的方式是写一个 main 方法起 20 个线程同时调 OrderService.createOrder最后查数据库 product.stock 是否与预期一致。如果出现死锁JDK 自带的 jstack 是最直接的诊断工具jstack tomcat_pid /tmp/thread_dump.txt在导出文件里搜索Found one Java-level deadlock输出会精确打印两个线程互相等待的锁和代码行号。收银系统里常见的死锁模式是两个事务各自锁了不同的表后再互等解决方法是统一加锁顺序比如所有 Service 方法里约定先锁 product 再插 orders。日志配置上至少把 OrderServiceImpl 所在的包设为 DEBUG每次扣减打印 order_no、扣减行数、剩余库存。不要依赖 System.out.printlnTomcat 的 stdout 日志没有级别控制高峰期会拖慢写入。5.3 用两条 SQL 直接模拟行锁验证事务配置是否生效最后给一个连业务代码都不用跑就能验证事务配置的技巧。打开两个 MySQL 会话-- 会话 A START TRANSACTION; UPDATE product SET stock stock - 1 WHERE id 1 AND stock 1; -- 此时不提交保持事务打开 -- 会话 B START TRANSACTION; UPDATE product SET stock stock - 1 WHERE id 1 AND stock 1; -- 会话 B 会阻塞在这里直到会话 A commit 或 rollback如果会话 B 没有阻塞而是立刻返回 0说明连接或客户端处于自动提交模式事务边界根本没生效。这个测试暴露的往往是 TransactionManager 里漏了setAutoCommit(false)或连接池配置覆盖了事务设置。比起翻几百行 Java 代码两条 SQL 就能定位到问题方向。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门