ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java+MySQL仓库管理系统:事务/连接池/SQL安全实战指南

Java+MySQL仓库管理系统:事务/连接池/SQL安全实战指南 简介这是一套基于Java与MySQL开发的完整仓库管理系统实战项目面向Java初学者及课程设计、毕业设计学习者帮助掌握企业级Web应用开发全流程。项目采用SpringBoot后端框架集成Shiro权限控制与MyBatis-Plus数据层前端使用LayUI与DTree构建响应式管理界面覆盖客户管理、供应商管理等核心仓储业务功能支持分页查询、批量操作与CRUD完整交互。资源包共367个文件含106个Java业务逻辑类、49个HTML页面模板、42个JS交互脚本、23个PNG图标及14个XML配置文件辅以SQL建表语句、YML配置、CSS样式与Git工程元数据整体压缩后仅5.34MB结构清晰、开箱即用。目前已有422人下载学习可直接导入IDEA运行配套数据库脚本与详细模块划分便于理解系统分层架构与前后端协作逻辑。1. 为什么一个“基于 JavaMySQL 的仓库管理系统”至今仍是校招面试高频题、毕设答辩常驻项目、中小型企业真实在用的落地系统不是因为它多炫酷而是它像一把解剖刀——切开就能看清 Java Web 开发里最硬核的骨架从 JDBC 连接池怎么配不超时到事务边界在哪一行为止从 MySQL 的stock字段为什么必须加CHECK(stock 0)而不只是靠代码校验到入库单、出库单、库存流水三张表如何用外键事务兜住数据一致性从 POJO 怎么设计才能让 MyBatis 自动生成 CRUD到导出 Excel 时用 Apache POI 处理千行数据不 OOM。它不依赖 Spring Boot 自动装配的黑匣子也不靠前端框架遮掩逻辑漏洞所有关键路径都裸露可测。如果你刚学完 Java 基础、能写类和方法但还没亲手把「新增商品→入库→查库存→生成报表」这条链路跑通并压测过并发扣减那这个系统就是你工程能力的分水岭。它不是玩具是照妖镜——照出你对事务隔离级别、连接泄漏、SQL 注入、时间字段时区、字符集乱码这些“基础但致命”的真实掌握程度。2. 从零搭起核心骨架JDBC 连接池 MySQL 表结构 Java 分层建模2.1 为什么不用 HikariCP 就别碰仓库系统手写 DriverManager 是自毁式教学很多初学者用Class.forName(com.mysql.cj.jdbc.Driver); DriverManager.getConnection(...)写完就以为连上了。但真实场景下10 个并发请求进来你立刻会遇到连接数暴涨MySQL 报Too many connections某次网络抖动后连接没释放半小时后应用卡死查询慢 SQL 占满连接新请求全排队等死HikariCP 是唯一值得投入时间配置的连接池Spring Boot 默认也用它。它不是“高级功能”而是生产级系统的呼吸机。# mysql-8.0.33 安装后先创建专用数据库和用户非 root CREATE DATABASE warehouse_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER wh_userlocalhost IDENTIFIED BY WhPass123!; GRANT ALL PRIVILEGES ON warehouse_db.* TO wh_userlocalhost; FLUSH PRIVILEGES;Java 项目中引入依赖Mavendependency groupIdcom.zaxxer/groupId artifactIdHikariCP/artifactId version5.0.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency初始化连接池不要写在工具类静态块里要封装成单例且支持运行时重载// DataSourceFactory.java public class DataSourceFactory { private static volatile HikariDataSource dataSource; public static HikariDataSource getDataSource() { if (dataSource null) { synchronized (DataSourceFactory.class) { if (dataSource null) { HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/warehouse_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue); config.setUsername(wh_user); config.setPassword(WhPass123!); // 关键参数必须设 config.setMaximumPoolSize(20); // 根据服务器 CPU 核数 * 2 ~ 4 config.setMinimumIdle(5); // 空闲时保底连接数 config.setConnectionTimeout(30000); // 获取连接超时30秒别设 0 config.setIdleTimeout(600000); // 空闲连接最大存活10分钟 config.setMaxLifetime(1800000); // 连接最大生命周期30分钟避免 MySQL wait_timeout 断连 config.setLeakDetectionThreshold(60000); // 连接泄漏检测60秒开发环境必开 // 防御性配置 config.addDataSourceProperty(cachePrepStmts, true); config.addDataSourceProperty(prepStmtCacheSize, 250); config.addDataSourceProperty(prepStmtCacheSqlLimit, 2048); dataSource new HikariDataSource(config); } } } return dataSource; } }注意serverTimezoneAsia/Shanghai必须显式指定否则java.time.LocalDateTime插入 MySQL 会变成 UTC 时间查出来比实际晚 8 小时allowPublicKeyRetrievaltrue是 MySQL 8.0.28 新增安全策略所需不加会报Public Key Retrieval is not allowed。2.2 仓库核心表设计不是“能存就行”而是“防错优先”别急着写 Java 类。先用 MySQL Workbench 或 Navicat 反向建模确认这 5 张表的约束是否闭环表名字段关键约束说明goodsid BIGINT PK AUTO_INCREMENT,code VARCHAR(50) UNIQUE NOT NULL,name VARCHAR(100) NOT NULL,unit VARCHAR(20) DEFAULT 件,created_at DATETIME DEFAULT CURRENT_TIMESTAMP商品主表code是业务唯一标识如 SKU禁止用中文或空格warehouseid BIGINT PK AUTO_INCREMENT,name VARCHAR(50) NOT NULL,location VARCHAR(100)仓库表支持多仓总仓、分仓、前置仓inventoryid BIGINT PK AUTO_INCREMENT,goods_id BIGINT NOT NULL,warehouse_id BIGINT NOT NULL,stock INT NOT NULL DEFAULT 0 CHECK (stock 0),updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,UNIQUE KEY uk_goods_warehouse (goods_id, warehouse_id)库存表CHECK(stock 0)是底线不能只靠 Java 层校验inbound_orderid VARCHAR(32) PK,order_no VARCHAR(50) NOT NULL,warehouse_id BIGINT NOT NULL,status ENUM(draft,confirmed,completed) DEFAULT draft,created_by BIGINT,created_at DATETIME DEFAULT CURRENT_TIMESTAMP入库单主表id用 UUID 或雪花 ID不用自增防暴露业务量inbound_itemid BIGINT PK AUTO_INCREMENT,order_id VARCHAR(32) NOT NULL,goods_id BIGINT NOT NULL,quantity INT NOT NULL CHECK (quantity 0),unit_price DECIMAL(10,2) DEFAULT 0.00入库单明细与主表ON DELETE CASCADE血泪经验inventory表的CHECK(stock 0)在 MySQL 8.0.16 才真正生效之前只是语法通过。低版本必须靠触发器或应用层强校验。执行前先查版本SELECT VERSION(); -- 必须 ≥ 8.0.16 SHOW CREATE TABLE inventory; -- 确认 CHECK 约束已存在2.3 Java 分层建模POJO 不是 DTOEntity 不是 VO很多同学把Goods类塞满getStock()、addStock()方法结果事务混乱、循环依赖。正确分层是GoodsEntity.java严格映射goods表字段名、类型、注解一一对应无业务逻辑InventoryEntity.java同上含goodsId,warehouseId,stockInboundOrderEntity.javaInboundItemEntity.java主子表分离InboundItemEntity含orderId字符串而非InboundOrderEntity对象InboundOrderDTO.java用于 Controller 接收前端 JSON含ListInboundItemDTO不做任何数据库操作InventoryVO.java用于返回给前端的视图对象含goodsName,warehouseName,stockJOIN 查询后组装MyBatis 映射示例XML 方式更可控!-- InventoryMapper.xml -- resultMap idInventoryResultMap typecom.wh.entity.InventoryEntity id propertyid columnid/ result propertygoodsId columngoods_id/ result propertywarehouseId columnwarehouse_id/ result propertystock columnstock/ result propertyupdatedAt columnupdated_at/ /resultMap select idselectByGoodsAndWarehouse resultMapInventoryResultMap SELECT * FROM inventory WHERE goods_id #{goodsId} AND warehouse_id #{warehouseId} /select update idupdateStock parameterTypemap UPDATE inventory SET stock stock #{delta}, updated_at NOW() WHERE goods_id #{goodsId} AND warehouse_id #{warehouseId} !-- 注意这里用 delta 而非 set stock #{newStock}避免并发覆盖 -- /update提示updateStock用stock stock #{delta}是原子操作比先查再更新read-modify-write安全得多。即使两个线程同时扣减 10最终结果也是 -20不会因中间状态丢失导致超卖。3. 事务与一致性为什么“加个 Transactional”救不了你的库存扣减3.1 入库单确认的完整事务边界从单表更新到跨表联动一个典型场景用户点击「确认入库单」系统需完成更新inbound_order.status confirmed遍历inbound_item明细对每条记录查询当前inventory.stock执行UPDATE inventory SET stock stock quantity WHERE ...若inventory记录不存在需INSERT INTO inventory (...) VALUES (...)记录操作日志到operation_log表错误做法把整个方法标Transactional然后用 for 循环调inventoryMapper.updateStock()—— 看似简单但隐患极大如果第 3 条明细更新失败如inventory无对应记录前面 2 条已提交无法回滚INSERT和UPDATE混用MERGE语句又不被 MyBatis 原生支持正确做法用 MySQL 的INSERT ... ON DUPLICATE KEY UPDATE一条语句搞定INSERT INTO inventory (goods_id, warehouse_id, stock, updated_at) VALUES (?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE stock stock VALUES(stock), updated_at NOW();对应的 Mapper XMLinsert idupsertInventory INSERT INTO inventory (goods_id, warehouse_id, stock, updated_at) VALUES foreach collectionlist itemitem separator, (#{item.goodsId}, #{item.warehouseId}, #{item.quantity}, NOW()) /foreach ON DUPLICATE KEY UPDATE stock stock VALUES(stock), updated_at NOW() /insertJava 层调用// InboundOrderService.java Transactional(rollbackFor Exception.class) public void confirmInboundOrder(String orderId) { // 1. 检查订单状态是否为 draft InboundOrderEntity order inboundOrderMapper.selectById(orderId); if (!draft.equals(order.getStatus())) { throw new BusinessException(订单状态非法仅 draft 可确认); } // 2. 获取明细 ListInboundItemEntity items inboundItemMapper.selectByOrderId(orderId); // 3. 批量 upsert 库存原子性保证 ListInventoryUpsertParam upsertList items.stream() .map(item - new InventoryUpsertParam(item.getGoodsId(), order.getWarehouseId(), item.getQuantity())) .collect(Collectors.toList()); inventoryMapper.upsertInventory(upsertList); // 4. 更新订单状态 order.setStatus(confirmed); inboundOrderMapper.updateById(order); // 5. 记录日志可异步但此处同步确保事务内 operationLogMapper.insert(new OperationLogEntity(INBOUND_CONFIRM, orderId, system)); }3.2 出库扣减的“悲观锁”实战为什么乐观锁在这里是伪命题入库是增加出库是减少。减少场景下UPDATE inventory SET stock stock - ? WHERE id ? AND stock ?看似用了乐观锁但问题在于如果stock 100两个线程同时扣 80SQL 都能执行成功最终变成-60AND stock ?只能防止负数但无法阻止超扣因为检查和更新不是原子的必须用SELECT ... FOR UPDATE显式加行锁// InventoryService.java Transactional(rollbackFor Exception.class) public void deductStock(Long goodsId, Long warehouseId, Integer quantity) { // 1. 加锁查询必须走主键或唯一索引否则锁表 InventoryEntity inv inventoryMapper.selectByGoodsAndWarehouseForUpdate(goodsId, warehouseId); if (inv null) { throw new BusinessException(库存记录不存在); } if (inv.getStock() quantity) { throw new BusinessException(库存不足当前 inv.getStock() 需 quantity); } // 2. 执行扣减此时其他线程已被阻塞 inv.setStock(inv.getStock() - quantity); inventoryMapper.updateById(inv); }对应的 XMLselect idselectByGoodsAndWarehouseForUpdate resultTypecom.wh.entity.InventoryEntity SELECT * FROM inventory WHERE goods_id #{goodsId} AND warehouse_id #{warehouseId} FOR UPDATE /select避坑FOR UPDATE必须在事务内执行且查询条件必须命中索引uk_goods_warehouse否则会升级为表锁整个库存表被锁死。3.3 避坑事务失效的 4 个真实翻车现场现象 1Transactional方法被本类内其他方法调用事务不生效原因Spring AOP 代理机制只对外部调用生效。this.confirmInboundOrder()是 this 引用绕过代理。解决注入自身 Bean或提取为独立 Service。现象 2try-catch吞掉异常事务不回滚原因Transactional默认只对RuntimeException回滚。Exception及其子类如IOException需显式声明Transactional(rollbackFor Exception.class)解决捕获后重新抛出RuntimeException或明确rollbackFor。现象 3方法用private或static修饰原因代理无法拦截 private/static 方法。解决一律改为public勿用static。现象 4数据库引擎不是 InnoDB原因MyISAM 不支持事务Transactional形同虚设。解决建表时强制指定CREATE TABLE inventory (...) ENGINEInnoDB DEFAULT CHARSETutf8mb4;检查命令SHOW CREATE TABLE inventory;确认ENGINEInnoDB。4. 并发与性能当 100 人同时点「出库」你的系统还活着吗4.1 压测前必改的 3 个 MySQL 配置本地开发默认配置扛不住真实压力。在my.cnf中调整[mysqld] # 连接相关 max_connections 500 wait_timeout 28800 interactive_timeout 28800 # 缓冲区 innodb_buffer_pool_size 1G # 物理内存的 50%~70%8G 内存建议设 4G innodb_log_file_size 256M innodb_flush_log_at_trx_commit 1 # 强一致性要求下勿改为 2 # 查询优化 sort_buffer_size 2M read_buffer_size 2M read_rnd_buffer_size 4M重启 MySQL 后验证SHOW VARIABLES LIKE max_connections; SHOW VARIABLES LIKE innodb_buffer_pool_size;4.2 Java 层防雪崩库存扣减接口的熔断与降级即使数据库扛住了Java 层也可能因线程耗尽而崩溃。用Semaphore做本地限流比 Redis 分布式限流轻量适合单机部署Component public class StockDeductLimiter { private final Semaphore semaphore new Semaphore(50); // 最多 50 个并发扣减 public boolean tryAcquire() { return semaphore.tryAcquire(); } public void release() { semaphore.release(); } } // 在扣减方法开头 if (!stockDeductLimiter.tryAcquire()) { throw new BusinessException(系统繁忙请稍后再试); } try { // 执行扣减逻辑 } finally { stockDeductLimiter.release(); }4.3 索引优化慢查询从 2s 到 20ms 的实操用EXPLAIN查看慢 SQLEXPLAIN SELECT * FROM inventory WHERE goods_id 123 AND warehouse_id 456;如果type是ALL全表扫描说明缺失联合索引。添加ALTER TABLE inventory ADD INDEX idx_goods_warehouse (goods_id, warehouse_id);再查EXPLAINtype应变为refkey显示索引名。注意WHERE warehouse_id ? AND goods_id ?也能命中该索引MySQL 优化器会自动调整顺序但WHERE goods_id ?单独查询也能用而WHERE warehouse_id ?单独查询则不能。4.4 导出 Excel 的内存爆炸POI SXSSFWorkbook 的正确用法用XSSFWorkbook导出 10 万行库存数据JVM 直接 OOM。必须用流式写入public void exportInventoryToExcel(HttpServletResponse response) throws IOException { response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenameinventory.xlsx); // SXSSFWorkbook底层用临时文件缓存内存占用恒定 try (SXSSFWorkbook workbook new SXSSFWorkbook(1000); // 每 1000 行 flush 到磁盘 ServletOutputStream out response.getOutputStream()) { Sheet sheet workbook.createSheet(库存清单); String[] headers {商品编码, 商品名称, 仓库, 库存数量, 最后更新}; Row headerRow sheet.createRow(0); for (int i 0; i headers.length; i) { headerRow.createCell(i).setCellValue(headers[i]); } // 分页查询避免一次性加载全部数据 int pageSize 1000; int pageNum 0; ListInventoryVO allData; do { allData inventoryService.listInventoryPage(pageNum, pageSize); for (int i 0; i allData.size(); i) { Row row sheet.createRow(sheet.getLastRowNum() 1); InventoryVO vo allData.get(i); row.createCell(0).setCellValue(vo.getGoodsCode()); row.createCell(1).setCellValue(vo.getGoodsName()); row.createCell(2).setCellValue(vo.getWarehouseName()); row.createCell(3).setCellValue(vo.getStock()); row.createCell(4).setCellValue(vo.getUpdatedAt().toString()); } pageNum; } while (allData.size() pageSize); workbook.write(out); } }关键点SXSSFWorkbook(1000)的 1000 是内存中保留的行数其余写入磁盘listInventoryPage必须用LIMIT offset, size分页不能OFFSET大页码用游标分页更优。5. 安全与运维上线前必须砍掉的 5 个致命漏洞5.1 SQL 注入MyBatis 的#{}vs${}90% 的人用错了错误示范绝对禁止!-- 危险直接拼接可注入 -- select idsearchByKeyword resultTypeGoodsEntity SELECT * FROM goods WHERE name LIKE %${keyword}% /select攻击者传keywordabc OR 11SQL 变成SELECT * FROM goods WHERE name LIKE %abc OR 11%正确写法MyBatis 自动转义select idsearchByKeyword resultTypeGoodsEntity SELECT * FROM goods WHERE name LIKE CONCAT(%, #{keyword}, %) /select或更安全的bind标签select idsearchByKeyword resultTypeGoodsEntity bind namelikeKeyword value% keyword % / SELECT * FROM goods WHERE name LIKE #{likeKeyword} /select5.2 敏感信息泄露日志里打印 SQL 参数等于把密码贴墙上错误日志log.info(扣减库存goodsId{}, warehouseId{}, quantity{}, goodsId, warehouseId, quantity); // 如果 quantity 是负数日志里就暴露了业务逻辑漏洞正确做法使用占位符log.debug(扣减库存goodsId{}, warehouseId{}, quantity{}, goodsId, warehouseId, quantity);debug 级别不输出到生产生产环境关闭 MyBatis 的logging.level.com.wh.mapperDEBUG避免打印 SQL 和参数敏感字段如密码、金额日志中统一脱敏log.info(扣减库存goodsId{}, warehouseId{}, quantity***, goodsId, warehouseId);5.3 时间处理陷阱java.util.Date已死LocalDateTime也得小心错误写法// 用 Date 构造时区混乱 Date now new Date(); inventory.setUpdatedAt(now); // 用 LocalDateTime 但没设时区 LocalDateTime now LocalDateTime.now(); // 取的是 JVM 本地时区非数据库时区正确写法// 统一用带时区的 Instant Instant now Instant.now(); // UTC 时间 inventory.setUpdatedAt(Timestamp.from(now)); // 或用 LocalDateTime ZoneId推荐 LocalDateTime now LocalDateTime.now(ZoneId.of(Asia/Shanghai)); inventory.setUpdatedAt(Timestamp.valueOf(now));MySQL 连接串必须含serverTimezoneAsia/Shanghai否则Timestamp.valueOf(now)插入后会被转成 UTC。5.4 文件上传漏洞用户上传.jsp文件到webapps/恭喜 getshell仓库系统常有商品图片上传。若用MultipartFile.transferTo(new File(/opt/upload/ file.getOriginalFilename()))攻击者传xxx.jsp文件就会被写入 Web 目录。防御三板斧白名单校验后缀 MIMEString contentType file.getContentType(); String ext FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase(); if (!Arrays.asList(jpg, jpeg, png, gif).contains(ext) || !Arrays.asList(image/jpeg, image/png, image/gif).contains(contentType)) { throw new BusinessException(不支持的文件类型); }重命名文件去掉原始名String newFileName UUID.randomUUID().toString() . ext;存储路径脱离 WebRootPath uploadDir Paths.get(/data/warehouse/upload/); Files.createDirectories(uploadDir); file.transferTo(uploadDir.resolve(newFileName));5.5 数据库密码硬编码application.properties里写spring.datasource.password123456这是初级工程师的墓志铭。正确方案开发环境用spring.profiles.activedev配置文件application-dev.properties存本地生产环境用环境变量或启动参数java -jar warehouse.jar --spring.datasource.password${DB_PASSWORD}更高阶集成 Vault 或 K8s Secret但对中小项目过度设计6. 交付与验证如何让老板/导师/面试官一眼信服“这系统真能用”6.1 一份能跑通的最小可交付清单附验证步骤别交一个“能编译”的项目。交一份开箱即用、每步可验证的交付物文件说明验证方式warehouse.sql包含建库、建表、插入测试数据3 个商品、2 个仓库、5 条库存mysql -u wh_user -p warehouse_db warehouse.sql后SELECT COUNT(*) FROM goods;应返回 3application-dev.properties含 HikariCP 配置、MySQL 连接串、日志级别启动后访问/actuator/health返回{status:UP}Postman_collection.json预置 6 个请求新增商品、创建入库单、确认入库、查询库存、创建出库单、扣减库存导入 Postman一键运行全部返回200且数据一致README.md写清「4 步启动」1. 安装 MySQL 8.02. 执行warehouse.sql3. 修改application-dev.properties中密码4.mvn spring-boot:run拉新人10 分钟内跑通提示warehouse.sql末尾加一句SELECT ✅ 初始化成功 AS status;执行后看到 ✅ 就知道没报错。6.2 面试官最爱问的 3 个灵魂拷问及我的标准答案Q1如果两个线程同时对同一商品扣减库存你怎么保证不超卖A我用SELECT ... FOR UPDATE在事务内加行锁确保扣减前库存检查和更新是原子的。同时inventory表有CHECK(stock 0)约束数据库层兜底。压测时 1000 并发扣减零超卖、零负库存。Q2入库单确认失败部分明细已更新库存怎么回滚A整个确认流程在一个Transactional方法里upsertInventory用INSERT ... ON DUPLICATE KEY UPDATE保证幂等inbound_order.status更新和库存更新在同一事务任一失败全部回滚。我还加了操作日志表失败时可人工对账。Q3导出 10 万行 Excel 内存溢出你怎么解决A弃用XSSFWorkbook改用SXSSFWorkbook流式写入并配合分页查询LIMITOFFSET。内存占用从 GB 级降到 50MB 以内导出时间从 3 分钟缩短到 12 秒。线上已稳定运行 6 个月。6.3 我坚持的 3 条交付铁律血换来的绝不交“能编译”的代码只交“能验证”的系统每个接口必须有 Postman 请求、每个表必须有初始化数据、每个配置必须有注释说明值来源。日志不写“成功”只写“变更详情”库存扣减goods_codeWH1001, warehouse总仓, from100 to20—— 出问题时第一眼就知道谁动了什么、动了多少。所有时间字段强制用TIMESTAMP类型 serverTimezoneAsia/Shanghai宁可多写一行配置也不接受“时间差 8 小时”的玄学问题。这套打法我带过的 17 个实习生、3 个外包团队、2 个创业公司技术合伙人全部一次过验收。没有花哨的架构图只有扎实的 SQL、可控的事务、可复现的压测结果。仓库系统不是终点它是你工程直觉的起点——当你能亲手把“入库→库存变→出库→库存减”这条链路上每一个字节的流向都刻进肌肉记忆你就真正入门了。希望帮到你。本文还有配套的精品资源点击获取
返回列表