SpringBoot+Vue+MyBatis+MySQL实战:构建无人值守仓库管理系统
2026/9/24 12:51:48 网站建设 项目流程

做这个系统,起因其实挺朴素:仓库管理里最耗人的环节,恰恰是最不该耗人的环节。人工扫码、手工记账、口头沟通库位、月底对账对到怀疑人生,这套动作在中小型仓库里每天都在重复。我当时的想法很直接——能不能用SpringBoot+Vue+MyBatis+MySQL这四件套,搭一个能自动处理入库、出库、库存预警和盘点的“无人值守”管理系统,把人的工作量压到最低。项目做下来大半年,代码重构了三轮,踩过的坑比写过的接口还多,所以这篇东西不是课程设计式的堆功能演示,而是把整个系统从技术选型、表设计、后端核心代码、前端页面到上线部署的完整链路拆开讲,包括为什么这么做、哪些地方容易翻车、生产环境怎么改。

这套系统适合谁看?一类是正在做Java课程设计或者毕业设计的同学,可以直接当项目骨架;另一类是公司里想搞仓储数字化的开发,尤其是不想出人查库、想靠系统做自动决策的小团队。全文涉及的关键技术点我都标注了难易程度,新手照步骤能跑起来,老手可以直接看第四章的问题排查和第五章的并发库存处理。

1. 整体设计与技术选型

1.1 为什么还是SpringBoot+Vue+MyBatis+MySQL这套组合

这个组合被议论了很久,总有人觉得没新意,但真正做项目的人会明白:选技术栈不是选秀,是选稳定性和团队可维护性。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都行,推荐11,G1垃圾回收器在服务端的表现比8好。前端Vue用2.6+ElementUI还是Vue3+Vite?如果是新项目,我更推荐Vue3+Vite+ElementPlus,Vite的冷启动速度和热更新体验比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日报表,每天凌晨由定时任务把前一天的数据聚合好,报表查询直接查这张表。比如聚合SQL:

INSERT 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.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.StdOutImpl

map-underscore-to-camel-case一定要开启,数据库字段的user_name才能自动映射到Java属性的userName,否则你会发现查询结果全是null。log-impl在生产环境要换成slf4j或者直接关闭,不要像我一样上线后才发现控制台疯狂打印SQL,日志文件一晚上好几个G。

PageHelper分页插件是MyBatis生态里最常用的分页方案,用法非常简单:

PageHelper.startPage(pageNum, pageSize); List<StockVO> list = stockMapper.selectStockPage(queryVO); PageInfo<StockVO> 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-arguments=false,控制好缓存边界。

3.5 MyBatis缓存机制与踩坑记录

MyBatis的缓存分成一级缓存和二级缓存。一级缓存默认开启,作用范围是同一个SqlSession,就是说同一个事务里连续查两次相同SQL,第二次直接走缓存。这个特性在日常开发中是好事,但在无人仓库的多设备并发场景下容易掩盖问题——一个SqlSession里先查库存是100,然后另一个事务把库存改成90,你再查还是100,因为走了一级缓存。所以库存查询不能依赖一级缓存,我的做法是在Mapper查询中显式加flushCache="true",或者在每次库存变更后调用sqlSession.clearCache()

二级缓存是Mapper级别的,跨SqlSession共享。看起来能提升查询性能,但仓储系统的库存数据变化极其频繁,开启二级缓存后很容易出现脏读:同一个商品,一个事务更新了库存,另一个事务的二级缓存里还是旧值。所以我的结论很明确:库存表和流水表绝不开启二级缓存;只有那些几乎不变的字典表、仓库表、库位表(非状态字段)可以开,收益才明显。如果需要开启,在Mapper XML里加上:

<cache eviction="LRU" flushInterval="60000" size="1024" readOnly="true"/>

3.6 全局异常处理与统一返回结构

前后端分离的项目,统一返回结构是必须的。我定义了一个泛型类R ,包含code、msg和data三个字段,所有Controller的返回值都是R类型。VO类如下:

@Data public class R<T> { private int code; private String msg; private T data; public static <T> R<T> ok(T data) { ... } public static <T> R<T> fail(int code, String msg) { ... } }

全局异常处理用@RestControllerAdvice@ExceptionHandler搭配。业务异常BizException返回code=500,msg为具体业务提示;参数校验异常返回code=400,把第一条错误信息拼出来;兜底的Exception返回code=500和“系统繁忙”的信息,同时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 vite@latest warehouse-web -- --template vue cd warehouse-web npm install npm install element-plus axios echarts vue-router@4 pinia

这里要注意Vue Router版本,Vue3必须用vue-router@4,Vue2用vue-router@3,版本不对会导致路由完全无法工作。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都是200;token过期不能只弹错误,要跳登录页并清掉localStorage里的用户信息;文件导出类的请求responseType是blob,需要单独处理,不能在统一拦截器里按JSON解。

4.4 ECharts大屏与库存看板可视化

无人仓库最直观的展示就是大屏看板,我用ECharts做库存总览和出入库趋势。ECharts用到Vue里,最推荐的方式是封装一个BaseChart组件,接收option作为props,内部负责实例化和resize监听:

<template> <div ref="chartRef" :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.active=prod > /dev/null 2>&1 &

前端打包:

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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

索引的黄金法则是:WHERE条件里的字段要建索引,ORDER BY的字段要建索引,GROUP BY的字段要建索引,但索引不是越多越好,每个索引都会拖慢写入速度。mybatis的执行慢有一个常见原因:where条件里的字段类型和表字段类型不一致,导致索引失效。比如表字段是varchar,传参却是Long,MySQL隐式转换后索引直接用不上,全表扫描肯定慢。

6. 常见问题与排查技巧实录

6.1 高频问题避坑速查表

问题现象根本原因解决方案
查询结果全是null没开map-underscore-to-camel-case配置里开启驼峰映射
分页数据不对/分页失效startPage和查询之间插入了其他SQLstartPage紧跟目标查询
修改库存后查出来还是旧值MyBatis一级缓存/二级缓存库存表关闭二级缓存,必要时flushCache
并发扣减出现负库存查询后再更新的非原子操作用UPDATE stock SET quantity=quantity-#{num} WHERE quantity >= #{num}
前端请求跨域报错前后端端口不一致Vite配置proxy或者后端CorsFilter
刷新404Vue Router history模式没配置Nginx配置try_files
mybatis @Update执行特别慢索引失效/锁等待EXPLAIN分析SQL,检查字段类型是否一致
打包后前端找不到接口请求URL写死了localhost统一用相对路径/api

6.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,说明代码有并发漏洞。这个测试在功能开发阶段做成本最低,等上线后再发现就是事故。这套系统我在写完扣减逻辑后跑了三轮并发测试,修了四个问题,最有价值的一轮就是并发压测发现的超卖隐患,大家在参考这套代码时千万别跳过我标注的并发处理部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询