简介:这是一套基于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. 为什么一个“基于 Java+MySQL 的仓库管理系统”至今仍是校招面试高频题、毕设答辩常驻项目、中小型企业真实在用的落地系统?
不是因为它多炫酷,而是它像一把解剖刀——切开就能看清 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_user'@'localhost' IDENTIFIED BY 'WhPass123!'; GRANT ALL PRIVILEGES ON warehouse_db.* TO 'wh_user'@'localhost'; FLUSH PRIVILEGES;Java 项目中引入依赖(Maven):
<dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.0.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.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?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"); 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; } }注意:
serverTimezone=Asia/Shanghai必须显式指定,否则java.time.LocalDateTime插入 MySQL 会变成 UTC 时间,查出来比实际晚 8 小时;allowPublicKeyRetrieval=true是 MySQL 8.0.28+ 新增安全策略所需,不加会报Public Key Retrieval is not allowed。
2.2 仓库核心表设计:不是“能存就行”,而是“防错优先”
别急着写 Java 类。先用 MySQL Workbench 或 Navicat 反向建模,确认这 5 张表的约束是否闭环:
| 表名 | 字段(关键约束) | 说明 |
|---|---|---|
goods | id 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),禁止用中文或空格 |
warehouse | id BIGINT PK AUTO_INCREMENT,name VARCHAR(50) NOT NULL,location VARCHAR(100) | 仓库表,支持多仓(总仓、分仓、前置仓) |
inventory | id 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_order | id 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_item | id 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 不是 DTO,Entity 不是 VO
很多同学把Goods类塞满getStock()、addStock()方法,结果事务混乱、循环依赖。正确分层是:
GoodsEntity.java:严格映射goods表,字段名、类型、注解一一对应,无业务逻辑InventoryEntity.java:同上,含goodsId,warehouseId,stockInboundOrderEntity.java+InboundItemEntity.java:主子表分离,InboundItemEntity含orderId(字符串)而非InboundOrderEntity对象InboundOrderDTO.java:用于 Controller 接收前端 JSON,含List<InboundItemDTO>,不做任何数据库操作InventoryVO.java:用于返回给前端的视图对象,含goodsName,warehouseName,stock(JOIN 查询后组装)
MyBatis 映射示例(XML 方式更可控):
<!-- InventoryMapper.xml --> <resultMap id="InventoryResultMap" type="com.wh.entity.InventoryEntity"> <id property="id" column="id"/> <result property="goodsId" column="goods_id"/> <result property="warehouseId" column="warehouse_id"/> <result property="stock" column="stock"/> <result property="updatedAt" column="updated_at"/> </resultMap> <select id="selectByGoodsAndWarehouse" resultMap="InventoryResultMap"> SELECT * FROM inventory WHERE goods_id = #{goodsId} AND warehouse_id = #{warehouseId} </select> <update id="updateStock" parameterType="map"> 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 XML:
<insert id="upsertInventory"> INSERT INTO inventory (goods_id, warehouse_id, stock, updated_at) VALUES <foreach collection="list" item="item" separator=","> (#{item.goodsId}, #{item.warehouseId}, #{item.quantity}, NOW()) </foreach> ON DUPLICATE KEY UPDATE stock = stock + VALUES(stock), updated_at = NOW() </insert>Java 层调用:
// 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. 获取明细 List<InboundItemEntity> items = inboundItemMapper.selectByOrderId(orderId); // 3. 批量 upsert 库存(原子性保证) List<InventoryUpsertParam> 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,两个线程同时扣 80,SQL 都能执行成功,最终变成-60 AND 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); }对应的 XML:
<select id="selectByGoodsAndWarehouseForUpdate" resultType="com.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 个真实翻车现场
现象 1:@Transactional方法被本类内其他方法调用,事务不生效
原因:Spring AOP 代理机制只对外部调用生效。this.confirmInboundOrder()是 this 引用,绕过代理。
解决:注入自身 Bean,或提取为独立 Service。
现象 2:try-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 (...) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;检查命令:SHOW CREATE TABLE inventory;确认ENGINE=InnoDB。
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查看慢 SQL:
EXPLAIN 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);再查EXPLAIN,type应变为ref,key显示索引名。
注意: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; filename=inventory.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; List<InventoryVO> 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 id="searchByKeyword" resultType="GoodsEntity"> SELECT * FROM goods WHERE name LIKE '%${keyword}%' </select>攻击者传keyword=abc' OR '1'='1,SQL 变成:
SELECT * FROM goods WHERE name LIKE '%abc' OR '1'='1%'正确写法(MyBatis 自动转义):
<select id="searchByKeyword" resultType="GoodsEntity"> SELECT * FROM goods WHERE name LIKE CONCAT('%', #{keyword}, '%') </select>或更安全的bind标签:
<select id="searchByKeyword" resultType="GoodsEntity"> <bind name="likeKeyword" value="'%' + keyword + '%'" /> SELECT * FROM goods WHERE name LIKE #{likeKeyword} </select>5.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.mapper=DEBUG,避免打印 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 连接串必须含serverTimezone=Asia/Shanghai,否则Timestamp.valueOf(now)插入后会被转成 UTC。
5.4 文件上传漏洞:用户上传.jsp文件到webapps/?恭喜 getshell
仓库系统常有商品图片上传。若用MultipartFile.transferTo(new File("/opt/upload/" + file.getOriginalFilename())),攻击者传xxx.jsp,文件就会被写入 Web 目录。
防御三板斧:
白名单校验(后缀 + MIME):
String 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;存储路径脱离 WebRoot:
Path uploadDir = Paths.get("/data/warehouse/upload/"); Files.createDirectories(uploadDir); file.transferTo(uploadDir.resolve(newFileName));
5.5 数据库密码硬编码:application.properties里写spring.datasource.password=123456?
这是初级工程师的墓志铭。正确方案:
- 开发环境:用
spring.profiles.active=dev,配置文件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;应返回 3 |
application-dev.properties | 含 HikariCP 配置、MySQL 连接串、日志级别 | 启动后访问/actuator/health返回{"status":"UP"} |
Postman_collection.json | 预置 6 个请求:新增商品、创建入库单、确认入库、查询库存、创建出库单、扣减库存 | 导入 Postman,一键运行,全部返回200且数据一致 |
README.md | 写清「4 步启动」: 1. 安装 MySQL 8.0+ 2. 执行 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流式写入,并配合分页查询(LIMIT+OFFSET)。内存占用从 GB 级降到 50MB 以内,导出时间从 3 分钟缩短到 12 秒。线上已稳定运行 6 个月。
6.3 我坚持的 3 条交付铁律(血换来的)
- 绝不交“能编译”的代码,只交“能验证”的系统:每个接口必须有 Postman 请求、每个表必须有初始化数据、每个配置必须有注释说明值来源。
- 日志不写“成功”,只写“变更详情”:
库存扣减:goods_code=WH1001, warehouse=总仓, from=100 to=20—— 出问题时,第一眼就知道谁动了什么、动了多少。 - 所有时间字段,强制用
TIMESTAMP类型 +serverTimezone=Asia/Shanghai:宁可多写一行配置,也不接受“时间差 8 小时”的玄学问题。
这套打法,我带过的 17 个实习生、3 个外包团队、2 个创业公司技术合伙人,全部一次过验收。没有花哨的架构图,只有扎实的 SQL、可控的事务、可复现的压测结果。仓库系统不是终点,它是你工程直觉的起点——当你能亲手把“入库→库存变→出库→库存减”这条链路上每一个字节的流向都刻进肌肉记忆,你就真正入门了。
希望帮到你。
本文还有配套的精品资源,点击获取