JavaWeb超市收银系统源码解析:Servlet、事务与并发库存扣减
2026/9/14 10:15:34 网站建设 项目流程

简介:一份基于 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 或 @Controller,web.xml 里的 servlet-mapping 变成 @RequestMapping,JDBC 的 Connection 由 MyBatis 的 SqlSession 接管。但有一个点不能直接平移,就是过滤器链的顺序。Spring Boot 里 FilterRegistrationBean 的 order 属性控制 Filter 的执行顺序,而传统写法是在 web.xml 里从上到下排列,权限过滤器和编码过滤器谁在前谁在后,直接决定 Session 能不能在中文参数被解析之前取到用户上下文。这个顺序问题在源码里经常被忽略,运行期表现为乱码或者 Session 意外失效。

2.2 商品表、订单表、订单明细表的字段类型与约束

超市收银系统最核心的是三张表:product(商品表)、orders(订单主表)、order_item(订单明细表)。注意订单主表不要命名为 order,order 在 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 '乐观锁版本号' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

两个容易写废的点。第一,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 为例给出推荐配置:

driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/supermarket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username=root password=your_password initialSize=5 minIdle=5 maxActive=20 maxWait=3000 testWhileIdle=true timeBetweenEvictionRunsMillis=60000 removeAbandoned=true removeAbandonedTimeout=180

maxWait 设 3000 毫秒,意思是高峰期拿不到连接时不无限等,3 秒后抛异常,让收银员重新扫码而不是一直转圈。removeAbandonedTimeout 设 180 秒,防止某台收银终端在结账中途断网,连接没归还导致池子被占满。testWhileIdle 表示空闲连接每 60 秒探测一次,这个配置配合 MySQL 的 wait_timeout,能避免"凌晨首单报连接已关闭"的经典问题。

参数调优后的经验值列在下面这张表:

参数Druid 默认值推荐值对应收银故障
maxActive820晚高峰连接不够,报 CannotGetJdbcConnection
maxWait-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 里存的是 LinkedHashMap,key 是商品 ID 的字符串形式 Map<String, CartItem> cart = (Map<String, 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 ThreadLocal<Connection> 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 attribute,JSP 只做展示。

JSP 里<% %>小脚本尽量不用。看到满屏小脚本的源码,基本可以判断写法停留在 Servlet 2.5 时代。替代方案是 JSTL + EL:

<c:forEach var="item" 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 里把List<CartItem>放进 request,再在 JSP 里迭代这个 List,既绕开 EL 版本差异,也避免在页面上直接暴露 Session 内部结构。

4. 并发收银与数据一致性:超卖、订单重复和一行 UPDATE 的取舍

这是超市收银系统含金量最高的一部分。并发收银是多个 POS 机同时提交结账请求,同一商品库存是一行数据,三个柜台同时卖最后一瓶水,库存只能变成 0,不能变成 -2。这一章的方案不依赖任何中间件,纯靠数据库特性就能落地。

4.1 乐观锁 UPDATE 条件改写:一条 SQL 解决库存不足与并发竞态

最常见的错误写法是"先 SELECT stock,判断 stock > 0,再 UPDATE"。这是典型的 check-then-act 竞态:两个并发请求同时读到 stock=1,都通过判断,然后各自 UPDATE,库存变成 -1。正确做法是把判断条件写进 UPDATE 的 WHERE 子句:

UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?;

这条 SQL 返回的受影响行数就是判据。在 DAO 层用int rows = productDao.deductStock(productId, quantity),Service 层判断 rows 是否为 0:

int rows = productDao.deductStock(order.getProductId(), order.getQuantity()); if (rows == 0) { return Result.fail(400, "库存已被其他收银台扣光,请重新扫码"); } // 继续插订单主表和明细 // ... TransactionManager.commit(); return Result.success();

一个容易忽略的细节是:affected rows 为 0 并不能区分"库存不足"和"商品 ID 不存在"。如果前端传来一个已被删除的商品 ID,同样会拿到 rows=0。所以严格一点的做法是先查一次商品是否存在,但查完到 UPDATE 之间仍有竞态窗口,最终还是要靠这条带条件的 UPDATE 兜底。

4.2 悲观锁 SELECT ... FOR UPDATE 什么时候值得用

乐观锁适合冲突不多的场景,但超市收银的晚高峰会出现多个收银台抢同一件商品。让一个事务在读取商品行时直接把行锁住,能杜绝业务层的重试逻辑,代价是并发吞吐下降。典型写法是在事务内执行:

SELECT stock FROM product WHERE id = ? FOR UPDATE;

拿到锁后,其他事务对该行的 UPDATE 都会排队,直到当前事务提交或回滚。这条语句的适用边界是:事务足够短,一般控制在秒级以内,且冲突频繁到乐观锁重试成本高于阻塞成本。收银台扫码、确认、提交这个交互在收银员手里可能停留几秒,而事务只在点击"结账"那一下开启,所以悲观锁更符合收银场景。

注意:FOR UPDATE 必须搭配事务才能生效,在 autocommit=true 的连接上执行等于没锁。如果发现两个收银台仍然能同时扣同一商品库存,优先检查连接池配置和 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.xml,Maven 打包时默认会报web.xml is missing。需要在 pom.xml 里配置:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-war-plugin</artifactId> <version>3.4.0</version> <configuration> <failOnMissingWebXml>false</failOnMissingWebXml> </configuration> </plugin>

另一项必须检查的是 servlet-api 依赖的 scope。Tomcat 7+ 自带 Servlet 3.0 API,应用里如果再打包一份 servlet-api,类加载时可能出现同名类冲突,报 LinkageError:

<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency>

scope 为 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.println,Tomcat 的 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 就能定位到问题方向。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询