马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
SpringBoot+Vue做救援物资管理系统,入库单并发把库存搞成负数了
入库单并发把库存搞成负数了
这个救援物资管理系统上线第二周就出了个离谱的bug:同一张入库单被两个人同时点了审核,结果库存被扣了两次,有个物资的库存直接变成了-50。我当时查日志查了半天才发现,审核接口没做并发控制,两个请求几乎同时进来,都读到了库存100,都执行了"审核通过→更新库存",最后库存变成了100+100+100?不对,是入库应该加库存,怎么会负数?哦,那张单其实是借用归还,入库类型选的是"借用",审核时要先扣借用记录再还库存,两个请求都扣了借用记录但只还了一次,就负了。反正就是经典的并发更新问题,没加锁也没乐观锁。
后来加了@Transactional事务、库存表加了version字段做乐观锁、审核接口加了分布式锁,才彻底解决。这个项目前后做了一个月,踩的坑不少,这篇文章就把架构、数据库设计、核心代码和踩坑经历整理出来。
图1 前后端分离架构(Vue前端通过Axios调Spring Boot的RESTful API,MyBatis操作MySQL) 系统整体架构和技术栈
这个系统是典型的前后端分离架构。前端用Vue 2 + Element UI + Axios,跑在8080端口;后端用Spring Boot 2.3 + MyBatis + MySQL 5.7,跑在8081端口;前后端通过RESTful API交互,用JWT做认证授权。部署的时候前端build出静态文件扔Nginx,后端打成jar包用java -jar跑,Nginx反向代理/api前缀到后端。
功能模块分六大块:系统管理(用户、角色、菜单、字典)、业务管理(物资来源、供应商)、物资管理(物资资料、物资类别、物资入库、物资发放、物资库存、物资流向)、其他管理(公告、留言)、日志管理(操作日志、登录日志)、统计报表。入库流程是"填写→审核→入库"三步骤,入库类型分捐赠、下拨、采购、借用四种,不同类型的审核逻辑和库存处理不一样。
数据库设计
图2 数据库ER图(核心是物资、入库单、出库单、库存四张表的关联) 数据库表有二十多张,核心的几张说一下。物资表product存物资基础信息(名称、规格、单位、分类、图片、预警值);入库单表in_stock存入库主表(单号、类型、来源、状态、操作员、描述),入库明细表in_stock_item存入库明细(入库单ID、物资ID、数量、单价);出库单out_stock和out_stock_item类似;库存表stock存每个物资的当前库存(物资ID、数量、version乐观锁字段)。
建表SQL核心部分:
CREATE TABLE `stock` (
`id` bigint NOT NULL AUTO_INCREMENT,
`product_id` bigint NOT NULL COMMENT '物资ID',
`quantity` int NOT NULL DEFAULT 0 COMMENT '库存数量',
`version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product` (`product_id`)
) ENGINE=InnoDB COMMENT='物资库存表';
CREATE TABLE `in_stock` (
`id` bigint NOT NULL AUTO_INCREMENT,
`in_stock_no` varchar(64) NOT NULL COMMENT '入库单号',
`type` tinyint NOT NULL COMMENT '1捐赠 2下拨 3采购 4借用',
`source_id` bigint DEFAULT NULL,
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0草稿 1待审核 2已入库 3已驳回',
`operator` varchar(32) DEFAULT NULL,
`remark` varchar(500) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_no` (`in_stock_no`)
) ENGINE=InnoDB COMMENT='物资入库单';
这里有个设计要点:入库单和出库单都用独立的明细表,不要直接改库存表。库存表只存当前数量,所有变动都通过入库/出库单记录,这样可以追溯、可以对账。库存表加version字段是后来出了并发bug之后加上的,最开始没有。
后端分层和入库审核流程
后端是标准的三层架构:Controller接收请求、Service处理业务逻辑、Mapper操作数据库。入库审核的流程是:校验入库单状态是否为"待审核"→遍历明细逐条更新库存→修改入库单状态为"已入库"→记录操作日志。整个流程必须在一个事务里,任何一步失败都回滚。
最开始的代码是这样的(有bug的版本):
// 有bug的版本:没有事务,没有并发控制
public void audit(Long inStockId) {
InStock inStock = inStockMapper.selectById(inStockId);
if (inStock.getStatus() != 1) {
throw new BusinessException("入库单状态不正确");
}
List<InStockItem> items = inStockItemMapper.selectByInStockId(inStockId);
for (InStockItem item : items) {
// 直接更新库存,没有锁
stockMapper.addQuantity(item.getProductId(), item.getQuantity());
}
inStock.setStatus(2);
inStockMapper.updateById(inStock);
}
这个版本的问题:一是没加@Transactional,循环里如果中间某条更新失败,前面已经更新的库存不会回滚;二是stockMapper.addQuantity是直接UPDATE stock SET quantity=quantity+?,虽然SQL层面是原子的,但入库单状态校验和库存更新之间有时间差,并发请求会重复审核。
并发问题和解决方案
并发bug的根因是"检查再执行"(check-then-act)不是原子操作:两个请求都读到status=1,都通过校验,都执行了库存更新。解决方案有三种,我最后用了第一种加第二种的组合。
第一种是乐观锁。库存表加version字段,更新时带WHERE version=?,更新成功才继续,失败就重试。Mapper SQL:
<!-- 乐观锁增加库存 -->
<update id="addQuantityWithVersion">
UPDATE stock
SET quantity = quantity + #{quantity},
version = version + 1
WHERE product_id = #{productId}
AND version = #{version}
</update>
第二种是数据库行锁(悲观锁)。审核入库单时,用SELECT ... FOR UPDATE锁住入库单行,其他事务必须等这个事务提交才能读,从源头避免并发审核。Service代码:
@Transactional(rollbackFor = Exception.class)
public void audit(Long inStockId) {
// 悲观锁:锁住入库单行,防止并发审核
InStock inStock = inStockMapper.selectByIdForUpdate(inStockId);
if (inStock.getStatus() != 1) {
throw new BusinessException("入库单状态不正确,可能已被处理");
}
List<InStockItem> items = inStockItemMapper.selectByInStockId(inStockId);
for (InStockItem item : items) {
Stock stock = stockMapper.selectByProductId(item.getProductId());
int rows = stockMapper.addQuantityWithVersion(
item.getProductId(), item.getQuantity(), stock.getVersion());
if (rows == 0) {
throw new BusinessException("库存更新失败,请重试");
}
}
inStock.setStatus(2);
inStock.setAuditTime(new Date());
inStockMapper.updateById(inStock);
logService.record("审核入库单", inStockId);
}
这里有个细节:SELECT FOR UPDATE要在事务里才生效,所以方法必须加@Transactional。而且锁的是入库单这一行,不是库存表,因为并发场景是"同一张单被重复审核",不是"同一个物资被不同单同时操作"。如果是后者,才需要锁库存行。
第三种是分布式锁(Redis的SETNX)。如果后端部署了多个节点,数据库行锁只能保证单节点内的并发,跨节点需要分布式锁。我这个项目是单节点部署,所以没用到,但代码里留了接口,以后扩容可以加。
踩坑:最开始加了@Transactional但方法是private的,Spring的AOP代理不生效,事务根本没起作用。后来才想起来,@Transactional只能加在public方法上,而且要通过代理调用,同类内部调用也不生效。这个坑经典但很多人踩。
前端Vue实现
图3 库存管理界面(Element UI的Table+Pagination,左侧菜单用el-menu) 前端用Vue 2 + Element UI,页面组件化。入库单页面分三个步骤:填写(el-form表单+物资明细动态增删)、审核(列表展示待审核单,点击审核弹窗确认)、入库(已入库列表,支持导出Excel)。路由用vue-router,权限控制在路由守卫里根据用户角色的菜单列表动态生成。
入库表单的核心是物资明细的动态增删,用el-table的inline编辑或者弹窗选择物资。我用的是弹窗选择物资后加到明细表,明细表每行可以改数量。提交时前端校验必填项,然后调后端接口。Axios请求封装了拦截器,请求头带token,响应拦截器统一处理401(跳转登录)和业务错误码。
// 提交入库单
submitForm() {
this.$refs.form.validate(valid => {
if (!valid) return
const data = {
type: this.form.type,
sourceId: this.form.sourceId,
remark: this.form.remark,
items: this.items.map(it => ({
productId: it.productId,
quantity: it.quantity,
price: it.price
}))
}
this.$http.post('/api/in-stock', data).then(res => {
this.$message.success('提交成功,等待审核')
this.$router.push('/inStockList')
})
})
}
前端踩过的坑:一是跨域,前端8080后端8081,浏览器报CORS错误,后端加了CorsConfig配置allowedOrigins就好了;二是el-table的动态数据不刷新,因为Vue2对数组索引修改不响应,要用this.$set或者splice;三是文件上传,物资图片上传用el-upload,action写后端接口,headers里带token,on-success回调里拿返回的URL。
权限管理:RBAC模型
权限用的是RBAC(基于角色的访问控制),用户-角色-权限三张表加关联表。用户登录后返回token和权限列表,前端根据权限列表动态生成菜单和按钮,后端用Shiro的注解@RequiresPermissions做接口级别的权限控制。
比如入库审核接口需要"inStock:audit"权限:
@RequiresPermissions("inStock:audit")
@PostMapping("/api/in-stock/{id}/audit")
public Result audit(@PathVariable Long id) {
inStockService.audit(id);
return Result.success();
}
Shiro的配置要注意:自定义Realm里doGetAuthorizationInfo方法返回用户的权限集合,而且要加缓存,否则每次请求都查数据库。还有@RequiresPermissions注解要生效,必须在ShiroConfig里配置DefaultAdvisorAutoProxyCreator和AuthorizationAttributeSourceAdvisor,少一个注解都不生效。我最开始漏配了,注解写了跟没写一样,谁都能调审核接口,后来加了配置才好。
部署和其他踩坑
部署的时候也踩了几个坑。一是前端build后资源路径404,因为Vue Router用了history模式,Nginx要配置try_files $uri $uri/ /index.html,否则刷新页面就404。二是后端jar包用nohup启动后关闭终端进程就没了,要用nohup java -jar app.jar > app.log 2>&1 &,或者用systemd管理。三是MySQL的时区问题,连接URL要加serverTimezone=Asia/Shanghai,否则时间差8小时。
还有一个性能问题:物资库存列表页要关联查物资名称、分类名称,最开始用了循环查(N+1查询),数据多了页面加载要3秒。后来改成MyBatis的一对多关联查询,一次SQL查出来,速度降到200毫秒。N+1问题是MyBatis新手最容易犯的,要养成看SQL日志的习惯。
文件上传也踩过坑:最开始把上传的图片存在项目jar包同级目录,结果重新部署后图片全没了。后来改成存在服务器固定目录/data/upload/,Nginx配置静态资源映射,上传和访问都走这个目录,跟应用解耦。
还有些问题没搞定
这个系统作为毕业设计原型够用了,但离生产环境还有差距,几个问题没完全解决:
一是多节点部署的分布式锁。现在是单节点,数据库行锁够用,但如果要做高可用部署多实例,必须加Redis分布式锁,还要考虑锁的过期时间和续期(Redisson的看门狗机制),这个还没做。
二是库存预警的实时推送。现在库存低于预警值只是在列表里标红,用户不刷新页面看不到。想做WebSocket实时推送,但Spring Boot的WebSocket跟Shiro的session管理有点冲突,调试了几次没搞定,暂时搁置。
三是数据权限。现在只能做到功能权限(能不能看某个菜单),做不到数据权限(比如某个仓库管理员只能看自己仓库的物资)。数据权限需要在SQL层面加过滤条件,跟MyBatis插件结合,实现复杂度高一些。
四是导入导出的大数据量。现在导出Excel用的是EasyExcel,几千条没问题,但如果几万条以上会内存溢出,需要改成分批查询+SXSSF流式写入,还没优化。
写在最后
做这个项目最大的感受是:CRUD谁都会写,但并发、事务、权限这些"非功能性"的东西才是区分新手和老手的关键。最开始我觉得不就是个增删改查系统吗,做起来才发现,一个入库审核涉及状态机、事务隔离、并发控制、权限校验、日志记录,每一块都有坑。
前后端分离开发效率高,但跨域、认证、部署这些问题比传统的JSP项目多。Vue的响应式原理、Spring的事务传播机制、MyBatis的关联查询,这些底层原理不搞清楚,出了bug根本不知道怎么查。
如果你也在做类似的管理系统或者毕业设计,希望我的这些经历能帮你少走点弯路。代码写得糙的地方欢迎指正,技术这行,踩坑踩多了自然就稳了。
下一个项目想试试用Spring Cloud微服务重构,把物资、库存、用户拆成独立服务,到时候如果踩了分布式事务的新坑,再写出来分享。
|