SpringBoot+Vue+MyBatis+MySQL实战:构建无人值守仓库管理系统

发布时间:2026/9/24 12:52:09
SpringBoot+Vue+MyBatis+MySQL实战:构建无人值守仓库管理系统 做这个系统起因其实挺朴素仓库管理里最耗人的环节恰恰是最不该耗人的环节。人工扫码、手工记账、口头沟通库位、月底对账对到怀疑人生这套动作在中小型仓库里每天都在重复。我当时的想法很直接——能不能用SpringBootVueMyBatisMySQL这四件套搭一个能自动处理入库、出库、库存预警和盘点的“无人值守”管理系统把人的工作量压到最低。项目做下来大半年代码重构了三轮踩过的坑比写过的接口还多所以这篇东西不是课程设计式的堆功能演示而是把整个系统从技术选型、表设计、后端核心代码、前端页面到上线部署的完整链路拆开讲包括为什么这么做、哪些地方容易翻车、生产环境怎么改。这套系统适合谁看一类是正在做Java课程设计或者毕业设计的同学可以直接当项目骨架另一类是公司里想搞仓储数字化的开发尤其是不想出人查库、想靠系统做自动决策的小团队。全文涉及的关键技术点我都标注了难易程度新手照步骤能跑起来老手可以直接看第四章的问题排查和第五章的并发库存处理。1. 整体设计与技术选型1.1 为什么还是SpringBootVueMyBatisMySQL这套组合这个组合被议论了很久总有人觉得没新意但真正做项目的人会明白选技术栈不是选秀是选稳定性和团队可维护性。SpringBoot负责把后端繁琐的配置全部自动搞定不用再写一堆XML配置文件内嵌Tomcat也能让部署变得极其简单Vue做前端SPA应用组件化开发让后台管理页面这种“表单表格弹窗”高度重复的界面写起来很快MyBatis则让SQL完全掌握在开发者手里仓储系统里到处都是复杂的多表联查和动态条件查询用MyBatis的XML映射比JPA那种自动生成SQL的方式更可控MySQL就更不用说了中小型仓库管理系统在数据量几十万级的场景下完全够用而且维护成本低招人容易。版本选择上我用了Spring Boot 2.7.x而不是3.x。原因很实际3.x强制要求JDK17和Jakarta命名空间很多老依赖还没完全跟上2.7版本在2025年依然是企业里最稳的过渡版本。JDK用1.8或者11都行推荐11G1垃圾回收器在服务端的表现比8好。前端Vue用2.6ElementUI还是Vue3Vite如果是新项目我更推荐Vue3ViteElementPlusVite的冷启动速度和热更新体验比webpack好太多而且Vue3的组合式API写业务逻辑确实比Options API舒服。但这个决定会影响后续所有代码结构所以下文我会把两种写法的关键差异标出来。1.2 无人仓库的业务场景到底长什么样所谓“无人”不是真的一个活人没有而是把“人跑腿”变成“系统调度”。整个系统要覆盖的核心场景有三块入库场景货物到仓扫码枪或RFID读写器读到货物信息后自动生成入库单系统根据商品体积、重量和当前库位占用情况自动推荐存放库位货物放到库位后库位传感器上报确认库存实时更新。出库场景订单系统下发拣货指令系统生成出库单和拣货任务AGV小车或者传送带把对应货架送到拣货口人工只做最终确认或者由机械臂完成库存扣减低于阈值自动触发采购预警。盘点与预警场景系统定时任务自动发起盘点库位摄像头重量传感器比对数据盘点差异自动生成异常记录库存上下限、临期商品、呆滞库存全部由规则引擎自动标记。这套系统里没有“一个人在Excel里改库存”的操作所有库存变动都要有单据和任务作为凭证每一笔流水都可追溯。这也是我在做表设计时最看重的一点——所有表都必须带着“来源单号”和“操作人”字段哪怕操作人是系统本身。1.3 系统模块划分与整体架构整个系统划分为七个模块系统管理用户、角色、菜单、基础数据商品、仓库、库位、供应商、入库管理入库单、质检、上架、出库管理出库单、拣货任务、复核、库存管理实时库存、流水、盘点、预警、报表统计出入库趋势、库存周转率、滞销榜、设备接入扫码枪、称重仪、RFID模拟接口。架构分层上就是经典的三层结构前端Vue通过Axios调后端RESTful接口后端Controller层只做参数校验和结果封装Service层处理所有业务逻辑和事务DAO层用MyBatis操作MySQL。但无人仓库有一点特殊就是多了一个“任务调度层”——比如入库单审核通过后Service会往任务表里插入一条“搬运任务”同时调用设备服务接口下发指令这个流程我会在第三章用代码完整演示。2. 数据库设计与核心表结构2.1 核心表的设计思路仓库管理系统的核心是库存但库存不是一张表就能搞定的它是一套数据流转链路的结果。我总共设计了20多张表其中七张是整个系统的命脉用户表、商品表、库位表、库存表、入库单表、出库单表、库存流水表。下面把最关键的几张表拿出来说。商品表product字段设计上要注意的点sku_code要做唯一索引这是被扫码枪和系统内部反复查询的字段spec存储规格unit存储单位这两个字段在库存统计里经常被group by所以要避免使用text类型default_loc_id表示默认库位这样可以减少每次入库时的库位推荐计算量。库位表location要记录仓库的分区信息字段包括warehouse_id、loc_code、loc_type存储区/拣货区/暂存区、status空闲/占用/锁定、max_weight和max_volume。做无人仓库一定要把库位的物理容量数字化否则系统推荐库位时完全没依据。库存表stock是重点我设计了三个字段用来做并发控制quantity可用库存、frozen_quantity冻结库存、version乐观锁版本号。为什么要有冻结库存因为出库订单创建后必须先锁定这部分库存防止其他并发请求把它抢走等拣货完成再真正扣减。这个设计银行系统用得最多仓储系统也是一样的道理。入库单表inbound_order和出库单表outbound_order是业务单据一单对应多个商品明细所以拆了主表和明细表。主表存单号、类型、状态、供应商/客户、创建时间、审核时间明细表存商品、数量、库位、质检结果。记住一个原则单据状态必须用数字字典0草稿、1已审核、2执行中、3已完成、4已取消不要用字符串状态数据库比较和索引效率都更好。库存流水表stock_flow设计成只追加不改动每条记录记录before_qty和after_qty的变化以及对应业务单号。有这张表整个系统的库存追溯、审计、报表统计都有数据支撑。有人问流水表会不会太大——不用怕按月分区或者定期归档就好但前提是必须得有。2.2 库存并发安全的设计细节库存表必然面临并发扣减这个问题在无人仓库场景里更突出——AGV和人工同时操作、多订单同时创建都是并发的来源。我的解决方案分三层第一层是数据库层面的行锁扣减库存的SQL必须写成原子操作UPDATE stock SET quantity quantity - #{num}, version version 1 WHERE product_id #{pid} AND location_id #{lid} AND quantity #{num}。这条SQL的关键在于把“查询再扣减”合并成一步靠where条件里的quantity num保证不会扣成负数。第二层是乐观锁更新前先查version更新时把version作为条件带上如果影响行数为0说明被别人改过了需要重试或者提示失败。这在业务代码里用MyBatis的Update语句配合POJO字段即可实现我会在3.4节给完整示例。第三层是事务隔离级别。默认的MySQL隔离级别是REPEATABLE_READ在仓储场景下其实够用但要注意一个大坑同一事务中先查询再更新可能会因为快照读导致看不到最新数据。所以在关键业务里查询库存必须用SELECT ... FOR UPDATE走当前读或者直接把这个查询放到更新语句的条件里去。这也是很多新手写的库存系统在并发测试下一超卖就崩的根源。2.3 常用SQL与统计报表的实现报表相关的SQL需要注意性能。拿“每日出入库趋势”举例如果直接对出入库明细表按天做count和sum数据量大时非常慢。我的做法是维护一张daily_summary日报表每天凌晨由定时任务把前一天的数据聚合好报表查询直接查这张表。比如聚合SQLINSERT INTO daily_summary (stat_date, product_id, inbound_qty, outbound_qty, stock_qty) SELECT CURDATE() - INTERVAL 1 DAY, product_id, SUM(CASE WHEN type 1 THEN num ELSE 0 END), SUM(CASE WHEN type 2 THEN num ELSE 0 END), 0 FROM stock_flow WHERE create_time CURDATE() - INTERVAL 1 DAY AND create_time CURDATE() GROUP BY product_id;这种“明细入库、汇总出报表”的模式做过后台系统的人都懂但它真的是仓储统计里最值得先做的设计。对于库存盘点推荐使用“盘点单差异对比”方式盘点时读取库位传感器的实际数量写入盘点表然后与库存表做全连接对比差异部分自动生成stock_check_diff记录。SQL核心逻辑是SELECT s.product_id, s.quantity AS system_qty, c.qty AS check_qty, c.qty - s.quantity AS diff_qty FROM stock s LEFT JOIN stock_check_detail c ON s.product_id c.product_id AND s.location_id c.location_id WHERE c.check_id #{checkId} AND c.qty ! s.quantity;3. 后端SpringBoot核心功能实现3.1 项目结构与Maven依赖配置后端项目用Maven管理结构上采用标准的包分包模式controller、service、mapper、entity、common、config、task、util。common里放统一返回结果类R、异常处理类GlobalExceptionHandler、常量类config放跨域配置、拦截器注册、定时任务配置task放定时盘点、库存预警扫描。POM文件里最核心的依赖就几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency注意mysql-connector-java在Spring Boot 2.7里不需要写version但如果你的Spring Boot版本比较老一定要显式指定8.0以上的MySQL驱动版本否则连不上MySQL8。MyBatis的mapper文件扫描用MapperScan(com.warehouse.mapper)注解放在启动类上即可。3.2 无人值守场景下的登录鉴权设计无人仓库系统没有人工柜台所以登录鉴权不能依赖传统的Session我用JWT做无状态鉴权。用户登录成功后后端生成一个包含用户ID、角色编码、过期时间的token返回给前端前端存在localStorage里每次请求放在请求头Authorization: Bearer token。后端用一个拦截器拦截所有/api/**请求放行/api/auth/login其他请求都校验token。拦截器里有个容易踩的坑Spring Boot默认静态资源也被拦截所以排除路径一定要把/static/**、/favicon.ico加进去。另外token过期和签名错误要区分处理过期返回40101签名错误返回40102前端才能分别跳登录页和报错提示。JWT的生成代码String token Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(role, user.getRoleCode()) .setExpiration(new Date(System.currentTimeMillis() 86400000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();3.3 入库出库的核心业务流程实现入库流程的Service层代码是最值得复用的部分完整逻辑是创建入库单草稿 - 审核通过 - 系统自动分配库位 - 设备上报确认 - 更新库存 - 写库存流水。我摘一段库位分配逻辑的伪代码Transactional(rollbackFor Exception.class) public void inboundConfirm(InboundOrderDetailVO vo) { // 1. 校验入库单状态 InboundOrder order inboundOrderMapper.selectById(vo.getOrderId()); if (order null || order.getStatus() ! 1) { throw new BizException(入库单不存在或状态不允许确认); } // 2. 遍历明细尝试锁定库位 for (InboundOrderItem item : vo.getItems()) { Location loc locationMapper.selectAvailable( item.getProductId(), item.getNeedVolume(), item.getNeedWeight()); if (loc null) { throw new BizException(可用库位不足商品 item.getProductId()); } // 3. 更新库存原子操作乐观锁 int rows stockMapper.increaseStock(item.getProductId(), loc.getId(), item.getNum(), System.currentTimeMillis()); if (rows 0) { throw new BizException(库存更新失败请重试); } // 4. 写流水 stockFlowMapper.insert(StockFlow.buildInboundFlow(...)); } // 5. 更新入库单状态为已完成 inboundOrderMapper.updateStatus(vo.getOrderId(), 3); }这段代码有三个关键点事务注解必须加否则中间任何一步失败都会导致库存不一致库位锁定和库存更新在同一次事务里完成避免出现“库位分配了但库存没加上”的中间状态如果并发高可以在第二步对库位记录加SELECT ... FOR UPDATE防止多个入库单同时锁定同一个库位。出库流程比入库多一个冻结库存的环节创建出库单时先查库存充足然后冻结相应数量生成拣货任务拣货完成确认后扣减冻结库存、增加出库流水。如果订单取消则执行“释放冻结”操作把frozen_quantity减回去。3.4 MyBatis配置与分页插件PageHelper的正确用法MyBatis的配置主要集中在application.yml里。常用的配置项我建议这样写mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.warehouse.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case一定要开启数据库字段的user_name才能自动映射到Java属性的userName否则你会发现查询结果全是null。log-impl在生产环境要换成slf4j或者直接关闭不要像我一样上线后才发现控制台疯狂打印SQL日志文件一晚上好几个G。PageHelper分页插件是MyBatis生态里最常用的分页方案用法非常简单PageHelper.startPage(pageNum, pageSize); ListStockVO list stockMapper.selectStockPage(queryVO); PageInfoStockVO pageInfo new PageInfo(list);但有几个使用规范必须严格遵守PageHelper.startPage只能作用于紧接着的一条SQL查询如果你在startPage和查询之间执行了其他查询分页就失效了或者把别的查询给分页了多表联查时PageHelper会生成SELECT COUNT(0)来查总数如果SQL里有复杂的union或者group by自动count可能会出错这时候就手动写count查询用PageHelper.startPage(pageNum, pageSize, false)禁用自动count再手动SELECT COUNT(1) FROM (...) t。另外在启用了MyBatis二级缓存的项目里PageHelper的Page对象不建议被缓存避免返回的分页数据带着上一页的total解决方案就是设置pagehelper.support-methods-argumentsfalse控制好缓存边界。3.5 MyBatis缓存机制与踩坑记录MyBatis的缓存分成一级缓存和二级缓存。一级缓存默认开启作用范围是同一个SqlSession就是说同一个事务里连续查两次相同SQL第二次直接走缓存。这个特性在日常开发中是好事但在无人仓库的多设备并发场景下容易掩盖问题——一个SqlSession里先查库存是100然后另一个事务把库存改成90你再查还是100因为走了一级缓存。所以库存查询不能依赖一级缓存我的做法是在Mapper查询中显式加flushCachetrue或者在每次库存变更后调用sqlSession.clearCache()。二级缓存是Mapper级别的跨SqlSession共享。看起来能提升查询性能但仓储系统的库存数据变化极其频繁开启二级缓存后很容易出现脏读同一个商品一个事务更新了库存另一个事务的二级缓存里还是旧值。所以我的结论很明确库存表和流水表绝不开启二级缓存只有那些几乎不变的字典表、仓库表、库位表非状态字段可以开收益才明显。如果需要开启在Mapper XML里加上cache evictionLRU flushInterval60000 size1024 readOnlytrue/3.6 全局异常处理与统一返回结构前后端分离的项目统一返回结构是必须的。我定义了一个泛型类R 包含code、msg和data三个字段所有Controller的返回值都是R类型。VO类如下Data public class RT { private int code; private String msg; private T data; public static T RT ok(T data) { ... } public static T RT fail(int code, String msg) { ... } }全局异常处理用RestControllerAdvice加ExceptionHandler搭配。业务异常BizException返回code500msg为具体业务提示参数校验异常返回code400把第一条错误信息拼出来兜底的Exception返回code500和“系统繁忙”的信息同时log.error打印完整堆栈方便排查。3.7 AOP实现操作日志无人仓库要求所有库存操作都能审计如果每个Service方法里手动写日志插入代码代码会烂成一锅粥。我用Spring AOP做了一个操作日志切面自定义一个OpLog注解标注在需要记录日志的Service方法上然后用切面拦截方法执行解析注解里的模块名和操作类型再通过SpEL表达式解析方法参数里的单号、商品ID等信息插入操作日志表。切面的核心代码Aspect Component public class OpLogAspect { Around(annotation(opLog)) public Object around(ProceedingJoinPoint point, OpLog opLog) throws Throwable { long begin System.currentTimeMillis(); Object result point.proceed(); // 解析参数、记录日志... return result; } }这样做的好处是业务代码零侵入而且日志的采集逻辑在后面可以随时替换成MQ异步写入不影响主链路。4. 前端Vue核心实现与页面联动4.1 项目初始化与环境配置前端我采用Vue3 Vite ElementPlus ECharts Axios的组合如果你用Vue2逻辑上也是类似的只是API写法略有差异。Node版本建议16以上Vite对Node版本有硬性要求低于14会被直接拒绝启动。创建项目npm create vitelatest warehouse-web -- --template vue cd warehouse-web npm install npm install element-plus axios echarts vue-router4 pinia这里要注意Vue Router版本Vue3必须用vue-router4Vue2用vue-router3版本不对会导致路由完全无法工作。ElementPlus需要按需引入还是全量引入小团队开发建议全量引入省心如果对打包体积有要求再用unplugin-vue-components做按需自动导入。4.2 路由设计与参数传递后台管理系统的路由不建议全部写在静态路由表里而是根据用户权限动态生成。我的做法是登录后拿到用户角色和菜单列表前端用router.addRoute()动态添加路由这样不同角色的人看到的菜单不一样路由权限也天然隔离。列表页跳详情页传参是前端最常用的场景仓库管理里的出入库单查询尤其依赖这个。我推荐用query参数因为刷新页面后数据不丢router.push({ path: /inbound/detail, query: { orderId: row.orderId } });在详情页接收const route useRoute(); const orderId route.query.orderId;如果不希望参数暴露在URL上也可以用Pinia存一份当前选中的对象但刷新后会丢失需要二次查询。这个取舍要看业务对刷新状态的容忍度。4.3 Axios请求封装与鉴权拦截Axios必须封装不然每个页面写一遍token和错误处理会让人崩溃。我封装了一个request.js核心是拦截器service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] Bearer token; } return config; }); service.interceptors.response.use( response { const res response.data; if (res.code 200) { return res.data; } if (res.code 40101) { // token过期跳转登录页 router.push(/login); return Promise.reject(new Error(登录已过期)); } ElMessage.error(res.msg || 系统错误); return Promise.reject(new Error(res.msg)); }, error { ElMessage.error(网络异常请稍后重试); return Promise.reject(error); } );这里有几个细节值得注意响应拦截器里判断业务code而不是HTTP状态码因为后端无论业务成败HTTP都是200token过期不能只弹错误要跳登录页并清掉localStorage里的用户信息文件导出类的请求responseType是blob需要单独处理不能在统一拦截器里按JSON解。4.4 ECharts大屏与库存看板可视化无人仓库最直观的展示就是大屏看板我用ECharts做库存总览和出入库趋势。ECharts用到Vue里最推荐的方式是封装一个BaseChart组件接收option作为props内部负责实例化和resize监听template div refchartRef :style{ height: height px }/div /template script setup import * as echarts from echarts; const props defineProps({ option: { type: Object, required: true }, height: { type: Number, default: 350 } }); const chartRef ref(null); let chart null; onMounted(() { chart echarts.init(chartRef.value); chart.setOption(props.option); window.addEventListener(resize, chart.resize); }); watch(() props.option, (val) { chart.setOption(val); }, { deep: true }); onBeforeUnmount(() { window.removeEventListener(resize, chart.resize); chart.dispose(); }); /script大屏页面的数据来源就是第三章提到的日报表和实时库存接口用setInterval每30秒拉一次刷新时不要整页刷新只更新ECharts的option这样能看到库存数字自己跳动演示效果非常好。4.5 动态菜单与按钮权限控制按钮级别的权限控制前端需要一个自定义指令v-permission。登录后把权限码列表存到Pinia里指令解析元素绑定的权限码如果在列表里就保留不在就删除元素const permission { mounted(el, binding) { const required binding.value; const has usePermissionStore().hasPermission(required); if (!has) { el.parentNode.removeChild(el); } } };后端在接口层面也要做同样的校验前端隐藏只是体验优化真正的安全防线在后端。按钮权限码建议和后端接口权限码统一例如stock:edit对应库存修改接口两边用同一套字符串管理成本最低。5. 部署联调与生产环境优化5.1 前后端联调与跨域处理开发阶段前端Vite默认跑在5173端口后端SpringBoot跑在8080必然有跨域问题。处理方式有两种一是在后端配置CorsFilter全局放行二是用Vite的proxy代理配置把/api开头的请求转发到后端。我更推荐第二种因为生产环境部署时前后端同域开发环境只有Vite这一层需要转发改动最小。Vite配置server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端代码里所有请求都写相对路径/api/xxx不要写http://localhost:8080/api/xxx否则一到生产环境就要全局搜索替换。5.2 打包部署Nginx与SpringBoot jar后端打包就一条命令mvn clean package -DskipTests启动时建议指定JVM参数nohup java -Xms512m -Xmx1024m -jar warehouse-server.jar --spring.profiles.activeprod /dev/null 21 前端打包npm run build会把dist目录生成出来把dist目录放到Nginx的html目录下Nginx配置如下server { listen 80; server_name your-domain.com; root /data/web/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 解决Vue Router history模式刷新404问题 location / { try_files $uri $uri/ /index.html; } }location /api/后面的proxy_pass要不要带路径是个知名的细节坑——proxy_pass http://127.0.0.1:8080;不带斜杠会把/api/xxx原样转发如果写成proxy_pass http://127.0.0.1:8080/;就会把/api前缀去掉。两者选哪种取决于后端接口是否有/api前缀的context-path。5.3 生产环境的MySQL配置与索引优化生产环境数据库的优化比代码优化更重要。字符集必须用utf8mb4而不是utf8不然存emoji或者生僻字直接报错。核心表的字符集在建表时指定CREATE TABLE stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, location_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 0, frozen_quantity INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_product_location (product_id, location_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci;索引的黄金法则是WHERE条件里的字段要建索引ORDER BY的字段要建索引GROUP BY的字段要建索引但索引不是越多越好每个索引都会拖慢写入速度。mybatis的执行慢有一个常见原因where条件里的字段类型和表字段类型不一致导致索引失效。比如表字段是varchar传参却是LongMySQL隐式转换后索引直接用不上全表扫描肯定慢。6. 常见问题与排查技巧实录6.1 高频问题避坑速查表问题现象根本原因解决方案查询结果全是null没开map-underscore-to-camel-case配置里开启驼峰映射分页数据不对/分页失效startPage和查询之间插入了其他SQLstartPage紧跟目标查询修改库存后查出来还是旧值MyBatis一级缓存/二级缓存库存表关闭二级缓存必要时flushCache并发扣减出现负库存查询后再更新的非原子操作用UPDATE stock SET quantityquantity-#{num} WHERE quantity #{num}前端请求跨域报错前后端端口不一致Vite配置proxy或者后端CorsFilter刷新404Vue Router history模式没配置Nginx配置try_filesmybatis Update执行特别慢索引失效/锁等待EXPLAIN分析SQL检查字段类型是否一致打包后前端找不到接口请求URL写死了localhost统一用相对路径/api6.2 实战过程中的心得与经验数据库字段设计一定要保守。业务字段全部用decimal而不是double来存数量尤其是重量和体积double的精度问题在累计统计时会让你抓狂。主键全部用BIGINT自增不需要纠结分布式ID——中小仓库系统的QPS远没到必须上雪花算法的程度。事务切面要注意自调用问题。同类中一个方法调用另一个带Transactional注解的方法事务是不生效的因为Spring的AOP代理基于代理对象内部的this调用绕过了代理。如果出现“事务没生效”的诡异问题优先怀疑这个。排查MyBatis执行慢问题最快的方式是打开慢SQL日志。MySQL配置里开启慢查询SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;然后在日志目录看哪些SQL超过1秒用EXPLAIN看执行计划没用到索引就盯着索引建执行计划看不懂就先看type字段全是ALL说明在扫全表。做无人仓库调度逻辑时设备上报的请求和业务请求是两条线程容易出现“设备先上报确认业务单据还没准备好”的问题。我的方案是加了一张task_queue表设备上报的消息先落到这张表里由定时任务轮询处理业务状态流转完整后再触发库存更新。这样虽然增加了一点延迟但把数据一致性的风险消除了。末尾再分享一个小技巧写库存扣减相关代码前先写好并发测试脚本。用JMeter或者简单的CompletableFuture模拟并发请求多线程下跑1000次扣减如果结果不为0说明代码有并发漏洞。这个测试在功能开发阶段做成本最低等上线后再发现就是事故。这套系统我在写完扣减逻辑后跑了三轮并发测试修了四个问题最有价值的一轮就是并发压测发现的超卖隐患大家在参考这套代码时千万别跳过我标注的并发处理部分。