商店进销存管理系统设计:从库存建模到JSP+Servlet并发事务实现 简介面向数据库课程设计学习者这是一份Word文档格式的商店进销存管理系统完整文档压缩包内仅有1个文件整体大小约609KB已有156人学习。文档以中小型商店的进货、销售、库存管理为业务场景完整覆盖需求分析、实体-联系图设计、关系模式转换、数据字典构建以及数据库实现等核心环节。内容从市场调查入手点明系统聚焦大型软件复杂昂贵、操作维护不便的空白进而设计商品类别、供货商、业务员、商品、仓库等实体以及所属、供应、库存、入出库、转库等联系并借助触发器和规则实现库存数量自动更新与单位约束。文档同时给出局部与全局实体-联系图并标注各关系模式的主码、外码有助于理解数据库设计从概念模型到逻辑模型再到物理实现的完整映射。文档附有商品类别表、供货商表、库存表、入库表、出库表、转库表等九张数据字典表以及建表、插入数据等数据库脚本读者既可据此掌握课程设计的完整流程也可参考表结构、主外键设置与触发器写法直接用于日常小型进销存系统开发。1. 商店进销存管理系统先分清「凭证」「库存」和「流水」「商店进销存管理系统」这个题很多课程设计容易做成「小卖部收银系统」商品增删改查、收银台下单、当日销售额看起来什么都有但真去盘点时库存永远对不上。原因在于进销存的核心不是「录单」而是三件事相互印证进——采购入库销——销售出库存——当前账面库存。系统要回答的是这个月进了多少、出了多少、账上还剩多少并且每一笔变动都能追溯到原始单据。题目的完整落地常用 JSPServletJDBC 这一套经典技术栈来完成正好也是很多学校课程设计指定的方向。下面从数据模型、分层实现、事务边界到盘点调整把整套系统的关键点拆开讲清楚。这套方案适合正在做进销存课程设计的人参考也适合第一次做管理类系统的后端开发拿来对照自己的设计。2. 进销存管理系统的表结构从单据流到库存表的字段设计2.1 为什么库存要「实时表」和「流水表」两套账初学者最常见的建表方式是在商品表里放一个stock字段卖一单执行一次UPDATE goods SET stock stock - 1。这个写法最直接但最大的问题是不可追溯。一个月后盘点发现账实差了两件商品你分不清是入库那天少录了、销售那天多扣了还是有人改了数据。原因很简单只记录了「结果」没记录「过程」。所以做进销存管理系统库存部分我一般拆成两张表配合使用。一张inventory只保存实时库存快照对应界面上看到的「当前库存数」另一张stock_flow保存每一次变动的明细包括变动前数量、变动后数量、变动类型、关联单据号。前者负责高效查询与销售校验后者负责事后审计与差异分析。这两张表的职责差异可以用下表概括。表名记录内容使用场景写入时机inventory每个商品当前库存量商品列表、销售时校验库存、补货提醒入库、出库、盘点调整成功后stock_flow每次变动的明细流水库存台账、盘点对账、异常追溯与 inventory 同事务写入这里有个容易踩的坑这两张表必须在一个数据库事务里同时写入不能先更新inventory再单独写流水否则一旦第二步失败就会出现「库存变了而流水缺失」的状态后续对账时永远差一笔。关于事务边界的完整处理在第 4 章展开。2.2 单据表设计的三个关键字段库存不是凭空变的它必须由「单据」触发。进销存管理系统里单据指进货单和销售单每种单据都包含主表和明细表。主表存整单信息明细表存每一件商品的品种、数量、单价。这么设计是为了让一张进货单可以包含几十种商品也方便日后按单号查完整内容。单据表上有三个字段容易被忽略但对后续流程影响很大。第一个是status状态字段推荐用0 表示草稿、1 表示已审核库存只认已审核单据草稿单不允许动库存。第二个是order_no单据编号必须唯一建议用「日期流水号」的格式例如PF20250113001表示 2025 年 1 月 13 日第一张进货单这种编号在打印纸质单和手工对账时非常直观。第三个是total_amount金额合计直接存在主表里不要每次查询都去明细表做SUM数据量大了以后性能差距很明显。2.3 建表 SQL一套可直接运行的 MySQL 结构下面给出这套进销存管理系统的最小化建表结构覆盖商品、库存、库存流水、进货、销售、管理员六部分。以 MySQL 8.0 为例引擎统一使用 InnoDB字符集用 utf8mb4。CREATE DATABASE IF NOT EXISTS shop_ims DEFAULT CHARACTER SET utf8mb4; USE shop_ims; -- 商品表 CREATE TABLE goods ( id INT PRIMARY KEY AUTO_INCREMENT, goods_code VARCHAR(32) NOT NULL UNIQUE COMMENT 商品编码, goods_name VARCHAR(64) NOT NULL COMMENT 商品名称, category_id INT COMMENT 分类ID, spec VARCHAR(64) COMMENT 规格, unit VARCHAR(8) COMMENT 计量单位, purchase_price DECIMAL(10,2) COMMENT 进价, sale_price DECIMAL(10,2) COMMENT 售价, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; -- 库存表 CREATE TABLE inventory ( goods_id INT PRIMARY KEY COMMENT 与goods.id对应, quantity INT NOT NULL DEFAULT 0 COMMENT 当前库存数量, warn_line INT NOT NULL DEFAULT 10 COMMENT 库存预警线, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB; -- 库存流水表 CREATE TABLE stock_flow ( id INT PRIMARY KEY AUTO_INCREMENT, goods_id INT NOT NULL, flow_type TINYINT NOT NULL COMMENT 1采购入库 2销售出库 3盘点调整, order_no VARCHAR(32) NOT NULL COMMENT 关联单据号, change_qty INT NOT NULL COMMENT 变动数量正数为增加负数为减少, before_qty INT NOT NULL COMMENT 变动前库存, after_qty INT NOT NULL COMMENT 变动后库存, operator VARCHAR(32) COMMENT 操作人, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_goods_time (goods_id, create_time), KEY idx_order_no (order_no) ) ENGINEInnoDB; -- 进货主表 CREATE TABLE purchase_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, supplier_name VARCHAR(64) COMMENT 供应商名称, total_amount DECIMAL(10,2) DEFAULT 0, status TINYINT DEFAULT 0 COMMENT 0草稿 1已审核, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, audit_time DATETIME COMMENT 审核时间 ) ENGINEInnoDB; -- 进货明细表 CREATE TABLE purchase_detail ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, goods_id INT NOT NULL, purchase_qty INT NOT NULL COMMENT 进货数量, purchase_price DECIMAL(10,2) NOT NULL, KEY idx_order (order_id), KEY idx_goods (goods_id) ) ENGINEInnoDB; -- 销售主表 CREATE TABLE sale_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, customer_name VARCHAR(64) COMMENT 客户名, total_amount DECIMAL(10,2) DEFAULT 0, status TINYINT DEFAULT 0 COMMENT 0草稿 1已审核, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, audit_time DATETIME ) ENGINEInnoDB; -- 销售明细表 CREATE TABLE sale_detail ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, goods_id INT NOT NULL, sale_qty INT NOT NULL COMMENT 销售数量, sale_price DECIMAL(10,2) NOT NULL, KEY idx_order (order_id), KEY idx_goods (goods_id) ) ENGINEInnoDB; -- 管理员表 CREATE TABLE manager ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL ) ENGINEInnoDB;这份 SQL 里的几个细节值得专门说明。stock_flow表特意保存了before_qty和after_qty这是整个系统审计能力的基础——只要这两列数据完整任何时间点的库存状态都能重算出来。inventory表用goods_id做主键而不是单独的自增 id因为每个商品只能有一行库存记录这个约束在数据库层面就避免了同一商品出现两条库存数据。明细表上的idx_goods索引很重要因为所有对账和流水查询都是按商品 ID 来查的没有这个索引数据量过万后查询会明显变慢。2.4 外键建不建课程设计里的一个现实取舍关于外键实践中分两派。课程设计阶段我建议在purchase_detail.goods_id、sale_detail.goods_id上建外键因为演示时删除一个商品数据库能立即给出约束报错这能让评审直观看到数据的完整性设计。但真实的内部管理系统一般会去掉外键改用应用层逻辑保证一致性因为外键在高并发写入时会有额外的锁检查和性能开销而且不少项目在拆库分表后外键直接不可用。折中的做法是建表时保留外键用于静态演示在 DAO 层的删除方法里仍然手动检查关联数据两边都得到保证。3. JSPServlet 实现进销存分层、事务与三条业务链路3.1 为什么课程设计普遍选 JSPServlet 而不是直接上 Spring Boot这门课的定位决定了技术栈。不少学校的课程设计题目明确写了 JSPServletJDBC不允许直接使用 Spring Boot目的是考察对 HTTP 请求、Servlet 生命周期、数据库连接管理等基础概念的理解。即使学校没有硬性限制我也建议先做一版 JSPServlet因为 Servlet 能让你切实看到「请求到了哪个方法」「事务在哪个位置提交」「连接什么时候关闭」这些在 Spring Boot 里都被框架隐藏了。Servlet 3.0 之后可以用WebServlet注解替代web.xml配置项目结构清爽很多。下面的实现示例基于 Tomcat 9、Servlet 3.1 和 JDBC不依赖任何框架。3.2 项目分层Servlet 只管路由Service 只做事务进销存管理系统的代码如果全部写在 Servlet 里逻辑会迅速失控。常见的落地分层是下面这样的约定照这个结构建包即可。层次包名职责关键注意点实体类entity对应数据库表的 JavaBean字段类型与数据库列对齐数据访问dao单表增删改查只接受 Connection不开启事务不处理业务业务层service跨表业务、事务控制事务从这个边界开始表现层webServlet 接收参数、调 service、转发不写 SQL不做事务视图JSP只渲染数据用 EL 和 JSTL少写 Java 代码在这个结构下一次「进货单审核」的完整调用链是浏览器 POST 请求 →PurchaseAuditServlet接收order_no参数 → 调用PurchaseService.audit()→ Service 内部开启事务并调用多个 DAO 方法 → 事务提交后由 Servlet 重定向到列表页。3.3 采购入库从请求参数到库存表更新的完整链路采购入库是整个系统中第一个涉及事务的核心操作。常见的业务要求是进货单先录入为草稿确认货到无误后执行「审核」审核时才真正增加库存。这么做的原因是防止录错单后库存立刻被改掉草稿阶段还有纠正余地。审核进货单的 Service 层代码如下注意事务的边界和每一步失败时的处理路径。public class PurchaseService { public void audit(String orderNo, String operator) throws Exception { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); PurchaseOrderDao orderDao new PurchaseOrderDao(); PurchaseDetailDao detailDao new PurchaseDetailDao(); InventoryDao inventoryDao new InventoryDao(); StockFlowDao flowDao new StockFlowDao(); // 1. 校验单据存在且处于草稿状态 PurchaseOrder order orderDao.findByOrderNo(conn, orderNo); if (order null || order.getStatus() ! 0) { throw new BizException(单据不存在或已审核不能重复入库); } // 2. 逐条增加库存并写流水 ListPurchaseDetail details detailDao.findByOrderId(conn, order.getId()); for (PurchaseDetail detail : details) { int before inventoryDao.getQuantity(conn, detail.getGoodsId()); int after before detail.getPurchaseQty(); inventoryDao.updateQuantity(conn, detail.getGoodsId(), after); flowDao.insert(conn, detail.getGoodsId(), 1, orderNo, detail.getPurchaseQty(), before, after, operator); } // 3. 更新单据状态 orderDao.updateStatus(conn, orderNo, 1); conn.commit(); // 所有步骤成功后统一提交 } catch (Exception e) { DBUtil.rollback(conn); throw e; } finally { DBUtil.close(conn); } } }代码里的关键点在于第 2 步先查before再计算after然后更新库存并插入流水这四步在同一事务里完成所以不会出现不一致。第 1 步的状态校验不是可选项它保证了同一张进货单不会被重复点两次「审核」导致库存翻倍。conn.setAutoCommit(false)之后的每一条 SQL 都在同一个数据库事务内commit前任何一步抛异常都会走rollback回滚全部操作。3.4 销售出库一行 SQL 完成「检查库存 扣减」销售出库比采购入库更容易出问题因为它涉及「库存够不够」的判断。初学者最容易写出的代码是先SELECT quantity FROM inventory WHERE goods_id ?在 Java 里判断如果库存大于等于购买数量再执行UPDATE。这个写法在单用户下没问题但两个人同时买最后一件商品时两个请求都读到了库存 1都通过了判断都会执行扣减最终库存变成 -1。正确做法是把判断下推到数据库用一条条件 UPDATE 同时完成检查和扣减。public int decrease(Connection conn, int goodsId, int qty) throws SQLException { String sql UPDATE inventory SET quantity quantity - ? WHERE goods_id ? AND quantity ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, qty); ps.setInt(2, goodsId); ps.setInt(3, qty); return ps.executeUpdate(); } }这段代码的核心是WHERE quantity ?这个条件。如果库存不足UPDATE 影响的行数是 0Service 层拿到返回值后就知道扣减失败抛异常让整单回滚。注意这里是在 SQL 里直接让数据库字段quantity - ?而不是先 SELECT 出来在 Java 里减完再 UPDATE 回去这样做从根源上避免了「读取到修改」之间的时间差。Service 层对应的事务封装参照进货单审核但多一步判断返回值int rows inventoryDao.decrease(conn, goodsId, saleQty); if (rows 0) { throw new BizException(商品库存不足当前操作已回滚); }3.5 库存查询页Servlet 传参 JSP 渲染商品列表与库存查询是进销存管理系统的门面代码模式高度一致Servlet 接收搜索关键词和页码调用 DAO 查询把结果放进 request转发给 JSP。下面给出数据访问层的模糊查询方法。public ListGoods search(Connection conn, String keyword, int offset, int limit) throws SQLException { String sql SELECT g.*, i.quantity FROM goods g LEFT JOIN inventory i ON g.id i.goods_id WHERE g.goods_name LIKE ? OR g.goods_code LIKE ? LIMIT ?, ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, % keyword %); ps.setString(2, % keyword %); ps.setInt(3, offset); ps.setInt(4, limit); // 结果集映射省略 } }查询使用 LEFT JOIN 而不是普通 JOIN才能把尚未建立库存记录的商品也查出来LIMIT 参数直接拼进 PreparedStatement 不做字符串拼接避免注入问题。JSP 端用 JSTL 循环渲染列表其中库存列可以直接判断是否低于预警线c:forEach items${goodsList} varg tr td${g.goodsCode}/td td${g.goodsName}/td td${g.quantity}/td td c:if test${g.quantity g.warnLine} span stylecolor:red库存不足/span /c:if /td /tr /c:forEach${g.warnLine}能直接用要求 Goods 实体里必须有warnLine属性并且提供了 getter。JSP 里不要写% %脚本片段全部数据由 Servlet 预先准备好JSP 只负责展示。3.6 必写的两个 FilterUTF-8 编码和登录拦截JSP 提交中文表单后出现乱码是课程设计里最高频的报错原因几乎都是request.setCharacterEncoding没有在所有 POST 请求前执行。标准解法是写一个编码过滤器而不是在每个 Servlet 里重复调用。WebFilter(/*) public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); chain.doFilter(req, resp); } }登录拦截过滤器与编码过滤器并存作用是未登录用户不能访问业务页面。拦截范围只设置/*但放行登录相关路径避免登录页本身被拦截造成死循环。WebFilter(/*) public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; String uri request.getRequestURI(); if (uri.contains(/login) || uri.contains(.css) || uri.contains(.js)) { chain.doFilter(req, resp); return; } Object loginUser request.getSession().getAttribute(loginUser); if (loginUser null) { request.getRequestDispatcher(/login.jsp).forward(request, resp); return; } chain.doFilter(req, resp); } }编码过滤器必须放在登录过滤器之前执行。Filter 的执行顺序按类名或 web.xml 中配置的filter-mapping顺序决定实际部署中建议在 web.xml 里显式配置两个过滤器及顺序避免依赖类名排序这种隐式行为。4. 库存扣减的事务边界并发下如何不扣成负数4.1 丢失更新是怎么发生的上一章用条件 UPDATE 解决了「先查再改」的问题但这只是并发安全的第一步。进销存系统真正的考验是在多人同时收银时不断有请求对同一行库存记录做减法。假设商品 A 当前库存为 1两个收银台同时提交销售单。如果代码是「SELECT 库存 → Java 判断 → UPDATE 库存」两个请求同时读到库存 1分别判断通过先后执行SET quantity 1 - 1最后一次更新把库存写成 0 而不是 -1看起来没出负数但实际成交了 2 件商品账面却只减了 1 件。这就是丢失更新比负库存更隐蔽。条件 UPDATE 解决了这个窗口期因为数据库的行锁让第二个请求的 UPDATE 必须等第一个提交后才能执行而执行时WHERE quantity ?重新读到的是第一个请求扣减后的值于是第二个请求返回 0 行库存不足被正确拦截。4.2 三种并发控制方案的选型对比对于进销存管理系统库存扣减有几种常见方案各自适用场景不同。方案核心写法优点缺点适用场景条件 UPDATEUPDATE ... SET quantityquantity-? WHERE goods_id? AND quantity?简单锁粒度小无法获得被扣前的库存值一般课设首选SELECT FOR UPDATE先锁定该商品库存行再读取可以读取并锁定灵活性高锁范围大需注意释放顺序复杂的库存计算逻辑乐观锁版本号UPDATE ... SET quantity?, versionversion1 WHERE id? AND version?无行锁适合读多写少冲突后需要重试代码复杂高并发且冲突少我实际做这个题目会优先采用条件 UPDATE。原因有两个进销存管理系统的并发峰值远比互联网应用低行锁代价可以接受同时它不需要重试机制实现简单排查方便。乐观锁适合场景是库存操作极度频繁、冲突率高的系统但对课程设计来说属于过度设计。4.3 事务边界必须放在 Service 层事务放在哪一层决定了错误回滚的范围。正确做法是让 Service 方法成为事务的最小边界DAO 的方法各自独立但都不开事务。原因是一次进货审核涉及订单校验、库存更新、流水写入、状态变更四步这四步要么全部成功要么全部失败。如果把事务开在 DAO 层比如InventoryDao自己setAutoCommit(false)然后在方法内 commit会导致两个问题第一同一业务中前一个 DAO 已经提交后面 DAO 失败时已提交的部分无法回滚第二DAO 被 Service 调用时连接已经被 DAO 自己提交Service 的事务控制彻底失效。如果把事务开在 Servlet 层则事务持有时间过长从参数解析到业务处理整个过程都占着数据库连接连接池压力变大。所以事务边界放在 Service 是这两者之间的最优解。4.4 复现并验证两个会话模拟并发扣减验证并发安全不需要写完整的多线程测试代码直接在 MySQL 中打开两个命令行会话就能模拟。先造一条库存为 1 的记录然后会话 A 执行条件 UPDATE 但不提交-- 会话 A START TRANSACTION; UPDATE inventory SET quantity quantity - 1 WHERE goods_id 1 AND quantity 1; -- 此时不写 COMMIT让事务保持打开接着在会话 B 中执行同样的扣减语句。此时会话 B 的 UPDATE 会阻塞等待而不是立即返回。因为 InnoDB 的行锁机制让会话 B 必须等会话 A 提交或回滚。这时在会话 A 执行COMMIT会话 B 会立刻返回「0 行受影响」说明库存不足没有执行扣减。如果你把 WHERE 条件换成不带quantity 1的版本再重复上面的操作会话 B 的 UPDATE 就会成功执行库存变成 -1。这正是条件 UPDATE 存在的意义。这个实验也间接验证了事务的原子性一条 SQL 里同时完成了「检查」和「扣减」中间没有其他会话能插进来。4.5 事务内的循环更新与连接管理细节采购入库审核的事务里一个 for 循环对同一连接执行了多条 UPDATE。这里面有两个容易被忽视的细节。第一循环内不能每执行一条就close掉 PreparedStatement应该复用同一个 Connection并在循环结束后统一关闭——释放连接意味着事务被隐式提交整个事务边界就断了。第二事务期间不要做任何远程调用或长耗时操作比如发送提醒短信、调用外部查询这会延长行锁的持有时间其他收银台的扣减请求会一直排队体感就是系统变卡。如果必须集成外部服务应放在事务提交之后执行。5. 把库存账做平盘点调整单与台账对账5.1 盘点发现的差异不能直接改库存数字无论系统写得多严谨实际经营中库存账面数量和实物总会因为漏录、错录、报损等原因产生差异。这时最忌讳的操作是「在库存表上直接 EDIT 一下把数字改对」这种做法会让之前的流水彻底失去参考价值。规范的做法是建立一张盘点调整单以调整单的形式记录「实际数量是多少和账面差多少」再通过调整单审核逻辑更新库存。对应的库存表更新要沿用第 3 章的条件 UPDATE 模式但这里不需要库存下限判断目标是把库存直接改成盘点数。Service 里设置流水的 flow_type 为 3before_qty 填写账面数after_qty 填写盘点实存数change_qty 为二者差值。这样以后查流水时每一笔调整都有单据支撑审计时能明确区分「正常销售导致的减少」和「盘点报损导致的减少」——这也是建表时在stock_flow里设计flow_type字段的最终目的。5.2 用流水台账反推库存的验证方法库存台账对账的常见做法是以stock_flow表为准按时间范围汇总每种商品的变动量然后用期初库存加减变动量计算期末库存再与inventory表的当前值比较。这个查询就是一张对账 SQLSELECT goods_id, SUM(CASE WHEN flow_type 1 THEN change_qty ELSE 0 END) AS purchase_in, SUM(CASE WHEN flow_type 2 THEN change_qty ELSE 0 END) AS sale_out, SUM(change_qty) AS net_change FROM stock_flow WHERE create_time 2025-01-01 00:00:00 GROUP BY goods_id;把net_change累加到期初库存上如果结果与inventory一致说明系统内部是自洽的如果不一致则说明存在某笔操作直接修改了库存而未写流水需要回到第 5.1 节的流程做整改。这套验证方法适用于任何规模也是验收系统时最具说服力的演示素材。最后的落点其实很简单进销存管理系统的价值不止在于录单和算账而是能从流水里随时回答「账是怎么变成现在这个样子的」值得用这个思路约束每一处库存写操作。本文还有配套的精品资源点击获取