我们仓库去年上线了一套基于 SpringBoot Vue 的智能无人仓库管理系统技术栈是 SpringBoot MyBatis MySQL前端是 Vue标题里那个“2025最新”其实不算夸张这套组合到今天依然是中小型仓库管理系统里性价比最高的搭配。这个项目我从需求梳理、数据库设计、前后端联调一直跟到上线部署中间踩了不少坑也总结了一些实战经验。这篇文章就把整个系统的拆解过程、核心模块的实现思路、以及那些文档里不会写的细节一次性讲清楚适合正在做类似管理系统的开发者参考尤其是想用 SpringBoot Vue MyBatis MySQL 这套技术栈落地一个完整项目的朋友。1. 从“人找货”到“货找人”无人仓库的业务模型怎么拆做无人仓库系统第一步不是写代码而是把业务模型彻底想清楚。传统仓库管理是“人找货”库管员拿着单据去货架找商品、核对数量、手工记账无人仓库的核心逻辑是“货找人”货物通过自动化设备入库、上架、出库系统负责记录每一件货的位置和状态人类只在异常情况下介入。1.1 仓库管理系统到底在管什么一个可落地的无人仓库系统本质上围绕四类核心数据在运转单据、货品、货位、库存。单据是业务的发起方包括入库单、出库单、盘点单、调拨单货品是仓库管理的基本对象货位是物理空间的数字化映射库存则是货品与货位之间的关系快照。系统里所有功能归根结底都是在维护这四类数据的一致性。在设计无人仓库的业务模型时我建议先画一张业务流程图把整个链路走一遍入库到货登记 → 质量检验 → 分配货位 → 上架确认 → 库存增加出库订单审核 → 库存锁定 → 生成拣货任务 → 设备取货 → 出库确认 → 库存扣减盘点创建盘点单 → 锁定货位 → 循环计数 → 差异核对 → 调整库存这个流程里最容易出问题的环节是“分配货位”和“库存锁定”。无人仓库的货位分配不能像人工仓库那样靠经验找个空位就放必须通过策略计算比如按商品品类分区、按出入库频率决定货位优先级、按保质期启用先进先出规则。库存锁定则直接关系到超卖问题设计不好会在高并发出库时出现数据不一致。1.2 三类核心单据的状态流转单据状态是整个系统的骨骼一定要在设计初期就把状态枚举定完整不然后续改状态机的成本会非常高。我们系统里的单据状态分为三层单据层状态、任务层状态、明细层状态。单据层状态用一条简单的链路草稿 → 已提交 → 处理中 → 已完成 → 已取消。任务层状态跟着设备执行进度走待执行 → 已下发 → 执行中 → 已完成 → 执行异常。明细层状态则精确到每一行货品待分配 → 已分配 → 已拣货 → 已复核 → 已上架或已出库。这里分享一个实际经验状态枚举要用字符串而不是数字。虽然数据库里存数字更节省空间但调试时看到status3完全没有可读性每次都要翻代码。用PENDING、PROCESSING、COMPLETED这样的字符串日志里一眼就能看清单据走到哪一步了排查线上问题能省一半时间。当然如果单表数据量达到亿级别字符串索引占空间的劣势才值得纳入考量中小规模系统完全不需要纠结这个问题。2. 数据库设计是地基库存模型、流水账与并发扣减仓库管理系统的数据库设计可以说是整个项目的灵魂。很多新手一上来就建一张库存表字段就是sku_id、quantity、location_id然后出库就UPDATE stock SET quantity quantity - 1这种做法在 demo 里跑跑没问题一上线就会发现各种数据对不上的问题。2.1 拆开“库存表说了算”的思维误区库存数据不是简单的一条数而是由三层数据构成可用的即时库存、被锁定的预占库存、在途的出入库库存。如果只用一张库存表表达这三层含义并发场景下根本算不清楚。我们最终的库存表设计是拆成三张核心表加一张流水表stock存实时库存字段包括sku_id、location_id、available_qty、locked_qty、total_qtystock_flow每次入库、出库、盘点调整都写一条流水记录flow_type区分INBOUND、OUTBOUND、LOCK、UNLOCK、ADJUSTinbound_order/inbound_order_item入库单主表和明细表outbound_order/outbound_order_item出库单主表和明细表核心设计原则是库存表的任何变更都必须能在流水表中找到对应的原始凭证。有一次财务和我们仓库对账怀疑系统库存和实物差了一百多个结果通过stock_flow一查发现是压测时生成的测试单据没有清理干净半小时就定位到了问题。如果没有流水表这种问题只能对着 Excel 手工核对到天亮。2.2 货品信息和货位信息的实体建模货品信息表sku除了常规的品名、条码、规格之外有一个字段很容易忽略存储属性标识。比如某商品需要冷藏、某商品属于危险品、某商品是贵重品这些属性直接决定了货位分配策略。我们把存储属性设计成字符串标签的形式例如COLD、FRAGILE、VALUABLE用逗号分隔存储在storage_attrs字段里货位表location也有allowed_attrs字段做匹配条件。分货位时的 SQL 就多了一个关联条件只有货位的允许属性包含了商品的存储属性时该货位才可以分配。货位表还有一个关键字段是status代表货位是空闲、占用还是锁定。无人仓库的货位状态必须实时刷新因为自动设备执行完任务后如果货位状态不更新下一个任务的分配逻辑就会失效。这里我建议货位状态的变化直接和任务状态绑定由任务回调统一更新而不是让业务代码在多个地方各自维护。2.3 MySQL 事务与锁库存扣减到底该怎么写库存扣减是仓库系统并发最高的操作。多台设备同时出库如果 SQL 写不对很容易出现超卖或库存变成负数。我们的核心扣减语句是这样的UPDATE stock SET available_qty available_qty - #{outQty} WHERE sku_id #{skuId} AND location_id #{locationId} AND available_qty #{outQty}这条语句的精髓在于把“判断库存是否充足”和“扣减库存”合成一条语句用数据库行锁保证并发安全。执行后判断影响行数如果影响行数为 0说明库存不足或货位不匹配业务层抛出异常回滚整个事务。这里千万别写成先SELECT查库存再UPDATE扣减两条语句之间会有时间差高并发下一定会出问题。事务隔离级别我们用的是READ_COMMITTED配合上面的原子更新语句足够了。就算有人把隔离级别调成REPEATABLE_READ只要扣减走的是UPDATE语句本身InnoDB 的行锁机制依然能保证正确性。关于锁还有一个实战细节多行更新时比如一个出库单要扣减多个货位的库存务必按固定顺序如按location_id升序逐条执行更新否则高并发下多个事务互相等待对方的锁可能造成死锁。我们上线初期就栽过一次跟头两个出库单同时要扣减同样的两个货位A 单先扣了货位1再等货位2B 单先扣了货位2再等货位1双双锁死。调整成统一排序后问题再没发生过。3. 后端骨架SpringBoot MyBatis 的核心实现细节后端我用的 SpringBoot 3.x 版本搭配 JDK 17MyBatis 用的是 mybatis-spring-boot-starter 3.x。这套组合在 2025 年已经非常成熟网上资源也多遇到问题基本上搜得到答案。3.1 工程分层与包结构项目用标准的 Maven 多模块结构但模块划分不是按技术层controller、service、dao而是按业务域划分这样更符合实际开发节奏warehouse-system ├── warehouse-common // 通用工具、统一返回结果、异常定义 ├── warehouse-inbound // 入库域 ├── warehouse-outbound // 出库域 ├── warehouse-inventory // 库存域 ├── warehouse-task // 任务调度域 └── warehouse-system // 系统管理用户、角色、权限每个业务域内部再按controller、service、mapper分层。这种划分方式的好处是多人协作时每个人负责一个域代码冲突少业务边界清晰。如果按照 controller/service/mapper 横向分层一旦项目大了改一个入库功能可能要同时动几个模块协作起来很痛苦。3.2 MyBatis 的 XML 映射与动态 SQLMyBatis 在这个项目里承担数据访问层的工作。我的经验是简单查询用注解复杂查询和动态 SQL 务必写在 XML 里。XML 的好处是 SQL 和 Java 代码分离DBA 评审 SQL 时直接看 XML 文件夹就行不用翻 Java 代码。另外 XML 里写动态 SQL条件变更时不需要重新编译 Java 代码热部署就能生效。举个例子入库单列表查询的接口需要支持按单号、状态、供应商、入库时间范围组合筛选XML 里的写法大致是select idselectInboundOrderPage resultTypecom.warehouse.inbound.vo.InboundOrderVO SELECT io.id, io.order_no, io.supplier_id, io.status, io.created_at, COUNT(ioi.id) AS item_count FROM inbound_order io LEFT JOIN inbound_order_item ioi ON ioi.order_id io.id where if testorderNo ! null and orderNo ! AND io.order_no LIKE CONCAT(%, #{orderNo}, %) /if if teststatus ! null and status ! AND io.status #{status} /if if testsupplierId ! null AND io.supplier_id #{supplierId} /if if teststartTime ! null AND io.created_at gt; #{startTime} /if if testendTime ! null AND io.created_at lt; #{endTime} /if /where GROUP BY io.id, io.order_no, io.supplier_id, io.status, io.created_at ORDER BY io.created_at DESC /select这里要注意gt;和lt;XML 里不能直接写大于小于号必须转义否则解析 XML 会直接报错。我第一次写的时候忘记了启动项目就报The content of elements must consist of well-formed character data排查半天才想起来是这个原因。动态 SQL 的where标签还有个隐藏好处如果所有条件都不成立它会自动去掉WHERE关键字生成全表查询的 SQL不会出现语法错误。3.3 缓存设计Redis 扛热数据MySQL 落账本仓库系统的数据访问特点是库存查询的 QPS 极高但是单据写入的 QPS 相对可控。所以我们的策略是热点数据走 Redis 缓存核心账本数据以 MySQL 为准。Redis 里主要缓存了四类数据库存实时数key 设计为stock:{skuId}:{locationId}value 直接存可用库存和锁定库存的 JSON货位状态key 为location:status:{locationId}基础字典货品信息、供应商信息、用户信息这类数据几乎不变缓存时间设长分布式锁库存扣减的防重入锁key 为lock:stock:{skuId}:{locationId}缓存更新的策略我们用的是“先更新数据库再删除缓存”而不是“先更新缓存再写数据库”。因为如果先写缓存再写数据库一旦数据库写入失败缓存里就是脏数据而且很难发现。先更新数据库再删缓存即使删缓存失败了最多就是多查一次数据库数据最终还是一致的。这里有个经典问题缓存删除失败怎么办线上环境偶尔会遇到 Redis 连接超时导致缓存没删掉下一次请求读到的还是旧数据。我们的兜底方案是一个简单的延时双删public void updateStock(Long skuId, Long locationId, int newQty) { stockMapper.updateQuantity(skuId, locationId, newQty); redisTemplate.delete(stock: skuId : locationId); // 延迟500毫秒再删一次兜底处理并发读请求 scheduledExecutor.schedule(() - { redisTemplate.delete(stock: skuId : locationId); }, 500, TimeUnit.MILLISECONDS); }这里有个坑删除缓存后到第二次删除之间如果有读请求恰好把旧值加载回缓存就会导致缓存里长期是脏数据。不过结合业务场景来看仓库系统库存数据的写入频率远低于读取频率这个风险发生的概率很低而且即使发生了最多就是数据短暂不准确下一次写入操作又能纠正过来。如果要求严格可以引入版本号机制写入时递增版本号读取时对比缓存版本号和数据库版本号不一致就重新加载。另外还要提防缓存穿透库存查询的 key 不含随机因子如果恶意用一个不存在的 skuId 反复查询会直接打到 MySQL 上影响数据库性能。我们的方案是如果数据库查询结果为 null也往 Redis 里写一个空值的短过期缓存比如 30 秒防止恶意请求穿透。关于 MyBatis 缓存默认配置下我们直接禁用了二级缓存。原因很简单库存数据是强一致性的任何缓存延迟都可能导致业务判断失误。Redis 已经承担了缓存职责MyBatis 的本地缓存反而会造成数据不一致的隐患不如不用。这一点在我的项目里是硬性规定所有需要缓存的查询只走 RedisMyBatis 缓存一律关闭。3.4 分页插件的用法与踩坑记录列表查询的分页是用 PageHelper 做的这个插件非常方便用法也简单PageHelper.startPage(pageNum, pageSize); ListInboundOrderVO list inboundOrderMapper.selectInboundOrderPage(queryVO); PageInfoInboundOrderVO pageInfo new PageInfo(list);但 PageHelper 有它的脾气使用不当会踩坑。最大的坑是它只对紧跟其后的第一条查询语句生效。如果你在执行PageHelper.startPage()之后又执行了另一条查询分页就会作用到那条无关的查询上。我们有个同事在 service 层调用了两个查询分页参数被第二个查询消耗掉了第一个查询返回了全量数据接口直接卡死。更隐蔽的坑是子查询会被分页插件误伤。如果你的查询 SQL 里包含子查询PageHelper 解析 SQL 时可能把分页条件加到错误的层级导致查询结果异常。这就是为什么我们的分页查询 SQL 尽量保持单层结构不对复杂嵌套查询使用 PageHelper而是在 XML 里手写LIMIT #{offset}, #{pageSize}虽然多写几行代码但可控性高得多。另外PageHelper 的 count 查询和实际数据查询是两条 SQL如果数据量特别大count 查询会拖慢接口响应。我们的优化是列表接口的数据量控制在一万条以内超过一万条强制走条件筛选不让用户翻到那么深的页码。这样 count 查询的代价就可控了。4. 前端交互Vue3 Element Plus 从列表到可视化大屏前端用的是 Vue3 Element Plus Vite这套组合在管理后台场景下开发效率非常高。Vue3 的 Composition API 配合script setup语法写业务代码比 Vue2 的 Options API 清爽太多了。4.1 组件拆分与状态管理前端的核心页面有仪表盘大屏、入库管理、出库管理、库存查询、盘点管理、货位管理、系统设置。我们按页面维度拆组件每个页面一个目录下面按功能拆子组件。库房地图是最难写的组件之一。要在一个平面图上展示所有货位每个货位根据状态显示不同颜色绿色是空闲红色是占用黄色是锁定灰色是禁用。我们的方案是用 CSS Grid 布局渲染货位网格每个格子是一个按钮点击弹出该货位的库存明细弹窗。货位状态的数据源是后端提供的 WebSocket 推送设备执行完任务后后端通过 WebSocket 广播货位状态变化前端实时更新格子颜色。这个交互做好后整个库房的运行状态一眼就能看全比纯表格体验好很多。Vue 的状态管理用的 Pinia比 Vuex 简洁不少。需要共享的状态只有两个当前用户信息和系统配置的暗色模式。像货位这种高频变化的数据没有放进 Pinia因为各个页面只需要在打开时读取快照即可不需要全局实时同步。过度使用全局状态是 Vue 项目的通病性能问题就藏在无意义的状态更新里。4.2 用户权限与路由控制用户权限用的是 RBAC 模型后端菜单表存了每个角色的可见菜单前端登录后拿到菜单列表动态生成路由。const asyncRoutes []; const modules import.meta.glob(../views/**/*.vue); menuList.forEach(menu { if (menu.type 2) { asyncRoutes.push({ path: menu.path, name: menu.name, component: modules[../views/${menu.component}.vue] }); } }); router.addRoute(asyncRoutes);这里实测有一个坑比较值得分享router.addRoute添加的路由如果刷新页面会全部丢失因为刷新后 JS 重新执行动态路由需要重新添加。所以必须在路由守卫里判断如果当前路由不存在但用户已登录就重新加载菜单并重新跳转。否则刷新一个不在静态路由表里的页面会直接 404。这个问题的解决方案是这样的router.beforeEach(async (to, from, next) { const token localStorage.getItem(token); if (token !userStore.hasLoadedMenus) { await userStore.fetchMenuList(); await userStore.generateRoutes(); next({ ...to, replace: true }); } else { next(); } });这个路由守卫里有个细节生成完路由后一定要用next({ ...to, replace: true })重新进入一次目标路由因为第一次进入时目标路由还不存在直接放行会匹配到 404 页面。4.3 大屏展示不是炫技关键数据和异常告警无人仓库的仪表盘大屏核心价值是让管理者在十秒内看懂仓库运行状态。我们的大屏左边是入库趋势折线图中间是库房地图右边是出库任务实时列表和设备状态面板。用 ECharts 画折线图的时候有一个 RT 级别的坑容器初始化时如果宽度为 0图表会渲染失败。尤其是大屏页面用了 CSS 动画或者 Tabs 组件隐藏的 Tab 初次渲染时容器宽度是 0。解决办法是监听容器尺寸变化或者等元素挂载完成后再初始化onMounted(() { nextTick(() { chart echarts.init(document.getElementById(inboundTrend)); chart.setOption(option); }); });大屏的数据更新用轮询而不是 WebSocket因为大屏上的数据是统计型数据不需要毫秒级实时每 10 秒拉一次就够了。WebSocket 连接数量有限全部业务都走长连接会把连接数占满只有货位状态这种强实时数据才值得用 WebSocket。设备异常报警是大屏的特色功能。当出库任务下发后一定时间内没有收到完成回调后端会将该任务标记为异常大屏通过 WebSocket 推送一条红色告警卡片同时蜂鸣器响起。这个功能上线后帮我们发现过货架传感器失灵、视觉识别误判等好多次问题如果没有实时告警很多故障要等盘点时才会暴露出来。5. “智能”到底落在哪视觉识别、策略调度与异常兜底“智能无人仓库”这个词被很多项目当成噱头但实际上能落地的智能化功能就那么几类自动化设备的接入与控制、基于规则的货位分配策略、数据驱动的运营优化。我们的系统主要做了货位分配策略和任务调度以及在设备层的视觉识别对接。5.1 设备接入从传感器到系统指令无人仓库的设备对接是系统里最复杂也是最有技术含量的部分。我们的仓库里有几种基础设备扫码枪RFID 读头、AGV 小车、自动分拣线、智能货架每个货位有指示灯和压力传感器。设备对接的方式统一走 HTTP 回调加上 WebSocket 推送。设备执行完任务后通过回调接口通知系统更新状态。这里有一个设计要点设备回调必须做幂等处理。设备网络不稳定时同一条回调可能会被发送两次甚至多次。我们的做法是回调接口带上任务号先查询任务状态是否已经是终态是就直接返回成功不再重复处理。另外设备的执行结果不能完全信任。比如 AGV 小车报告“已从货位 A 取货”但实际可能取了货位 B 的货。所以我们在出库流程里增加了一个复核环节出库前再次扫描货物标签比对系统记录与实物信息。这个步骤智能程度虽然不高但能避免大量由于设备错误导致的库存问题。5.2 货位分配策略让货物自己找到合适的位置货位分配是整个系统智能化的核心体现。我们没有用机器学习或者复杂的优化算法而是用规则引擎 评分算法实现的。每个候选货位根据一系列维度打分得分最高的获得分配public int scoreLocation(Location loc, Sku sku, InboundOrderItem item) { int score 0; // 同类商品靠近存放提升拣货效率 if (loc.getCategory().equals(sku.getCategory())) score 30; // 优先分配到已有同种商品库存的货位减少货位碎片化 if (loc.hasSku(sku.getSkuId())) score 20; // 高频商品优先分配到离出库口近的货位 if (sku.getPickFrequency() 100 loc.getDistanceFromExit() 5) score 25; // 同批次的货物尽量集中存放方便先进先出 if (loc.getBatchNo().equals(item.getBatchNo())) score 10; return score; }这种规则算法可解释性强业务人员看代码就能理解为什么某个货位会被选中方便调整权重参数。我们的实际运行数据显示这套策略让出库拣货的平均路径长度比之前的人工经验分配缩短了约 18%。更进一步的优化可以做“热力图”统计每个 sku 的出库频率把高频商品放在离出库口最近的中层货位低频商品放在高处或深处。这项工作只需要在 sku 表增加一个pick_frequency字段定期用 SQL 从流水表里跑一个统计更新任务即可。5.3 异常兜底报警与人工介入无人仓库必须设计好异常兜底机制因为无人化程度越高系统异常的影响也就越大。所有设备的关键节点都设置了超时检测任务下发后超过 30 秒没有收到设备确认自动触发重发超过 5 分钟没有完成自动标记异常并通知运维人员。另一个兜底措施是人工介入的“应急模式”。当某一台 AGV 故障时系统自动将该区域的货位锁定同时把任务重新分配给其他空闲设备。如果全部设备都故障整个仓库会进入“应急人工模式”PDA 上显示所有货位和货物的位置图工作人员可以拿扫码枪手动找货所有操作仍然会被记录到流水表里。我还特别设计了一个“异常操作日志”功能专门记录设备异常、人工介入、状态跳转等非常规操作。有一次盘点发现库存差异追查原因时发现是有一次人工介入改错了一个货位的绑定关系。这种日志在运营过程中非常重要设计系统时千万别忽略。6. 部署与踩坑从开发到上线的实战记录代码写完了不等于项目完成了部署上线才是真正考验的开始。这一节把我印象最深的几个坑和调优经验记录下来希望能帮大家少走弯路。6.1 环境准备与配置服务器用的是 LinuxMySQL 8.0Redis 6.2JDK 17。项目用 Docker Compose 编排部署核心配置如下version: 3 services: mysql: image: mysql:8.0 restart: always environment: MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD} MYSQL_DATABASE: warehouse volumes: - ./mysql-data:/var/lib/mysql ports: - 3306:3306 command: --character-set-serverutf8mb4 --collation-serverutf8mb4_unicode_ci app: build: . restart: always depends_on: - mysql - redis ports: - 8080:8080 environment: SPRING_PROFILES_ACTIVE: prod部署时遇到的第一个坑就是环境变量必须走配置中心或者application-prod.yml外部化不能打包进 jar 里。我们有一次打包时忘记排除application-dev.yml里的测试环境数据库配置导致正式环境首次启动直接连到了开发库把开发数据混到了生产环境最后花了两天时间才清理干净。从那以后生产环境的所有密码和连接串全部走环境变量注入jar 包里只保留带占位符的配置模板。6.2 上线至今遇到的典型问题与处理记录第一个典型问题数据库连接池被打满。系统上线初期早高峰时经常收到连接池超时告警。排查发现是部分接口忘记关闭数据库连接导致的泄漏。HikariCP 的默认配置maximum-pool-size是 10根本扛不住业务量。调整后配置如下spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000同时把所有 mapper 的查询都检查了一遍用 try-with-resources 确保 SqlSession 正确关闭。调整后连接池稳定了很多再也不报警了。第二个典型问题Linux 服务器上 MySQL 启动失败报错ERROR 2002 (HY000): Cant connect to local MySQL server through socket /tmp/mysql.sock。这个报错我印象特别深原因是 MySQL 的 socket 文件路径配置不对或者 MySQL 服务根本没启动成功。排查过程先检查 MySQL 进程是否在运行如果不在查看错误日志/var/log/mysql/error.log。我们当时遇到的问题是数据目录的属主权限不对MySQL 无法写入。解决办法很简单chown -R mysql:mysql /var/lib/mysql systemctl restart mysql这个问题在部署文档里几乎看不到但是至少有一半的人在 Linux 上首次部署 MySQL 时会撞上。第三个典型问题Spring Boot 版本和 MyBatis 版本不兼容。老版本的mybatis-spring-boot-starter只支持到 Spring Boot 2.x如果直接用最新的 Spring Boot 3.x启动时可能会因为javax和jakarta包名不一致报错。解决方法是升级到 mybatis-spring-boot-starter 3.0 以上的版本同时注意项目里的javax.servlet引用需要替换为jakarta.servlet。这个坑在引入任何 Spring Boot 3.x 依赖时都要留意不光是 MyBatis。6.3 性能调优的几个方向数据库层面的优化第一件事就是给核心表的查询字段加索引。我们的经验是stock表的(sku_id, location_id)建联合唯一索引stock_flow表的查询条件基本是order_id或者sku_id flow_type created_at所以建了对应组合索引inbound_order和outbound_order表的order_no建唯一索引因为业务上单号本身就是唯一的。还有一个优化点是 MySQL 的sort_buffer_size参数。之前出库单列表做ORDER BY created_at DESC时单表数据量达到五十万行左右排序性能明显下降。调大sort_buffer_size到 2MB 之后排序效率明显提升。当然这个参数不能无脑调大会占用更多内存需要根据服务器实际情况权衡。MySQL 慢查询日志也是必开的。上线后我们每周拉一次慢查询日志分析前十条最耗时的 SQL逐一优化。这个过程持续了一个月把几个隐藏的性能瓶颈全揪了出来最典型的一个是盘点单的统计查询因为关联了 5 张表还没走对索引一次查询耗时 8 秒优化后降到 100 毫秒以内。最后一个优化点是前端静态资源的部署。我们把构建后的dist包直接扔到 Nginx 下开启 gzip 压缩同时给静态资源设置七天缓存首次加载速度从 3 秒降到了 1 秒以内。这个优化基本零成本效果却非常明显。我一直觉得仓库管理系统这种业务型项目技术栈的先进程度不是第一位的能不能把库存数据算准、把业务流程走通、把异常情况兜住才是核心。SpringBoot Vue MyBatis MySQL 这套组合看起来“不够新”但它的生态成熟度、问题排查资源的丰富度、团队招聘的容易程度都是很多新框架比不了的。如果你正准备做一个类似的管理系统我建议不要把精力浪费在不成熟的技术选型上而是把更多时间花在业务模型的梳理和数据一致性的保障上。把这篇文章里提到的那些细节想清楚你的系统离真正稳定上线就不远了。