首页 / 资讯中心 / 文章详情

SpringBoot+Vue+MyBatis网上超市系统:订单与库存并发控制实战

SpringBoot+Vue+MyBatis网上超市系统:订单与库存并发控制实战 ★ FEATURED ARTICLE
前阵子我把一套网上超市管理系统重新做了一遍梳理和加固。这套系统用的就是非常经典的SpringBoot Vue MyBatis MySQL组合前后端分离从商品展示、购物车、下单支付到后台的商品管理、订单管理都做了闭环。很多人一听到网上超市就觉得不过是增删改查但真正把它往企业级方向做的时候会发现里面有大量容易被忽略的细节库存怎么扣才不会超卖、订单状态怎么流转才不混乱、后台权限怎么控制才安全。这篇文章就把整个项目的设计与实现思路、核心表结构、关键代码逻辑以及我实际运行中踩过的坑完完整整分享出来适合正在做类似系统设计、或者想拿一套可运行源码来改造落地的朋友参考。1. 需求边界一个超市管理系统到底在管什么1.1 网上超市不是电商平台的缩小版很多人设计网上超市时喜欢照搬大型电商平台的功能结果项目堆得非常臃肿。实际做下来网上超市最核心的场景就是逛-选-买-管四件事用户在首页和分类页浏览商品、把商品加入购物车、提交订单并完成支付、后台管理员维护商品库存和订单状态。如果画一条业务线整个系统可以分成前台和后台两大部分。前台服务的是普通用户包括注册登录、商品检索、商品详情、购物车、下单结算、订单查询、个人资料与收货地址管理。后台服务的是运营和管理人员包括商品分类管理、商品上下架、库存调整、订单审核发货、用户管理、数据统计等。我见过不少项目把后台功能塞到前台代码里登录之后靠一个角色字段硬切界面结果维护成本极高。正确做法是前后台在工程结构上就完全分离两个独立的Vue应用两套后端模块共用同一个MySQL数据库。这样权限边界清晰接口也不容易混乱。1.2 企业级和课程设计的分水岭在哪里同样是超市系统课程设计能做到能跑通流程就交差了但企业级必须考虑三件事并发、安全、可维护性。先说并发。一个超市系统的最大并发点集中在商品详情、购物车结算、库存扣减。如果直接用先查库存如果大于0就减一这种逻辑两个人同时下单同一件商品就可能出现超卖。企业级系统必须考虑在数据库层面控制并发扣减而不是靠Java代码里的同步锁。再说安全。后台接口不能裸奔至少要做登录拦截和操作权限校验。订单金额、用户余额这些关键数据不能被客户端随意传参篡改。例如结算时应该让前端传商品ID列表后端重新从数据库读取最新价格和库存来计算总价而不是信任前端传过来的总价。最后是可维护性。数据库字段要有合理约束代码分层要清晰日志要完整。一个订单状态机改动之后能不能快速定位影响范围决定了这个系统是能长期迭代还是半年后变成遗产。1.3 功能模块的最终划分我最后敲定的功能清单如下用户端注册、登录、商品分类浏览、关键词搜索、购物车增删改、下单结算、订单列表与详情、取消订单、收货地址管理。管理端登录、商品分类管理、商品管理上下架、价格库存维护、轮播图管理、订单管理查看、发货、取消、用户管理、数据看板。公共模块图片上传、通用分页、统一异常处理、跨域配置、登录拦截器、全局日志。这套划分覆盖了超市日常运营的绝大多数动作同时把边界控制得很好。后端项目用Maven多模块或者单模块按包分层都可以我用的是单模块按业务分包package按user、goods、order、cart、admin区分每个业务包下面放controller、service、mapper、entity、dto。2. 技术选型为什么坚持用SpringBootVueMyBatis这套组合2.1 SpringBoot负责把复杂度关进门里后端框架选择SpringBoot核心原因是它的自动配置能力大大减少了传统SSH时代繁琐的XML配置。这个项目的后端只需要一个启动类然后通过application.yml把数据源、端口、MyBatis扫描路径、文件上传大小等参数配置好就能跑起一个可用的服务。SpringBoot内置的Tomcat容器也省去了单独部署Web服务器的步骤。不过自动配置也有个坑你必须清楚每个starter引入了什么依赖。我在整合MyBatis时遇到过SQLSessionFactoryBean冲突后来排查发现是同时引入了mybatis-spring-boot-starter和旧的mybatis-spring依赖重复初始化导致启动失败。这类问题在网络上很常见后面部署部分会细说。2.2 Vue是前端分离出来的最佳起步方案前端选Vue的原因很现实组件化开发效率高而且Element UI提供了现成的表格、表单、弹窗、分页组件后台管理界面几乎可以像拼积木一样快速搭建。这个项目我采用的是Vue 2.x Element UI Vue Router Vuex这套组合虽然新项目里略老但生态最成熟遇到问题基本都能搜到解决方案。网上超市用户端的核心页面包括首页商品瀑布流、商品详情页、购物车页、结算页、订单列表页。管理端则是多个表格页面加弹窗表单。这些页面的共同点是状态多、交互频繁用Vue的响应式绑定和组件复用比原生jQuery写起来清爽很多。前端项目通过npm start启动开发环境下通过代理转发请求到后端8080端口这样绕开了跨域问题。2.3 MyBatis和JPA之间我选择MyBatis的理由市面上ORM框架无非MyBatis和JPA两大派系我选择MyBatis而不是JPA最大的理由是SQL可控制性。网上超市有一个高频操作是商品列表的多条件筛选分类、价格区间、销量排序、关键字模糊搜索这些组合查询的SQL用注解方式写固然可以但一旦后期要调整查询条件MyBatis的XML文件比Java注解更好维护。另外一个关键点是库存扣减SQLMyBatis可以非常自然地写原子更新操作UPDATE goods SET stock stock - #{buyNum}, version version 1 WHERE id #{goodsId} AND stock #{buyNum}这种带条件更新的SQL在JPA里反而不那么直观需要写Modifying和Query注解。MyBatis的xml还支持动态SQL比如订单列表按订单号、下单时间、订单状态动态查询用where标签配合if条件组装SQL非常灵活。当然MyBatis也有缺点比如需要手写很多繁琐的映射表格字段一多实体类就变得很长。实际处理时我没有用代码生成器而是老老实实手写因为字段熟悉程度直接影响后续查询效率。2.4 MySQL作为数据库的适用边界MySQL能扛住中小型电商系统的大多数流量尤其配合InnoDB引擎支持事务和行级锁是订单、库存这类强一致场景的基础。这个项目里的表都使用InnoDB引擎字符集统一用utf8mb4因为商品名称、用户昵称都可能出现Emoji字符utf8存不下utf8mb4才是完整的多字节字符集。表结构设计上有几个原则主键统一使用bigint自增业务字段不允许为null的都要加not null和默认值金额字段用decimal(10,2)禁止用float和double否则会出现0.10.2这样的浮点误差需要做为查询条件的字段尽量加索引。下面这张表是我总结的核心表索引规划表名关键索引索引目的goodscategory_id, status按分类和上下架状态筛选商品goodstitle商品名称模糊搜索cartuser_id, goods_id查询某用户的购物车项ordersuser_id, order_no按用户查订单、按编号查详情order_itemorder_id关联查询订单项addressuser_id查询用户地址列表3. 数据库设计核心表结构与字段设计的完整方案3.1 用户、角色、地址三张表是系统底座用户表是系统的基础字段包括id、username、password、nickname、phone、status、create_time等。密码存储这里必须强调一下绝对不能明文存。我使用的是BCrypt加密工具Spring Security里的BCryptPasswordEncoder可以直接用加密后的字符串每次随机加盐即使两个用户密码相同密文也不同安全性高得多。角色控制我设计得很轻量没有引入完整的RBAC权限表而是用user表的role字段做区分取值USER和ADMIN。如果你要更细的权限控制可以扩展为角色表、菜单表、用户角色关联表但网上超市的管理端操作基本就是管理员一人或少数几人角色字段加控制层判断已经足够。地址表设计相对简单id、user_id、receiver_name、receiver_phone、province、city、district、detail、is_default。需要注意的是默认地址逻辑不能让用户同时存在多个默认地址所以当用户把某个地址设为默认时后端需要先把该用户的所有地址is_default置为0再把这个地址的is_default置为1。这个操作要在同一个事务里做否则会出现两个默认地址的脏数据。3.2 商品分类与商品表的设计思路商品分类表是树形结构大多数超市只有两级分类比如饮料下面有碳酸饮料果汁粮油下面有大米食用油所以设计成parent_id自关联就够用字段包括id、parent_id、name、icon、sort、status。查询分类时先把所有记录查出来在Java内存里组装成树避免使用递归SQL。商品表是整个系统的核心字段包括id、category_id、title、sub_title、main_image、detail、price、stock、sales、is_new、is_hot、status、create_time。这里status字段很关键1表示上架0表示下架用户端所有查询都必须加上status1条件避免管理员还没上架的商品出现在首页。商品详情可以单独拆表存详情图文但考虑到项目规模我直接用一个TEXT类型detail字段存富文本HTML。首页轮播图单独建一张banner表字段是id、image、target_goods_id、sort、status后台可以动态修改。3.3 购物车、订单、订单项、支付流水表购物车表字段包括id、user_id、goods_id、goods_num、selected。用户端购物车列表往往需要一次性查出来并且要关联商品表获取最新价格和上下架状态所以写连接查询比多次单表查询更合理。购物车里被勾选才能进入结算页selected字段就是干这个用的。订单表设计要谨慎一张订单关联多个商品我用orders和order_item两张表。orders表字段包括id、order_no、user_id、total_amount、pay_amount、consignee_name、consignee_phone、consignee_address、status、payment_time、delivery_time、finish_time、remark。order_item表包括id、order_id、goods_id、goods_title、goods_image、goods_price、goods_num、subtotal。订单号唯一且要有业务含义我使用时间戳加用户ID加随机数的方式生成例如SO202505101322456789001长度约20位左右同时给order_no建唯一索引防止并发下重复单号。至于支付流水因为实际对接第三方支付需要商户资质我预留payment表字段包括id、order_no、payment_no、amount、payment_type、status用来记录一个支付流程的完整流转。订单状态我按固定枚举管理0待支付、1待发货、2待收货、3已完成、4已取消。每个状态的触发动作必须明确待支付超过30分钟未支付可以取消用户主动取消管理员发货后状态变为待收货用户确认收货后完成。这个状态机看起来简单但有两点必须注意一是状态只能向后流转不允许用户把已发货订单改成待付款二是所有更新订单状态的操作都要带条件状态字段比如UPDATE orders SET status1 WHERE id? AND status0防止并发重复操作。3.4 库存并发控制字段怎么设计商品表的stock字段就是当前可售库存。在上一点里我提到了带条件的原子更新SQL那其实就依赖stock字段本身。除了扣减库存时用where stock buyNum我还在商品表加了一个version字段用来做乐观锁。每次更新库存时version1同时where条件匹配原本的version值如果更新影响行数为0说明数据已经被其他事务改过需要提示用户刷新重试。这种乐观锁方案适合库存数量不高的场景比如库存30件大量用户在秒杀场景同时抢购并发量级并不算特别大乐观锁配合数据库条件更新足以防止超卖。如果以后流量真的大到数据库撑不住再考虑引入Redis预扣库存那就是另一个项目级别的问题了。但原则是源码里必须留下能防超卖的扣减机制而不是一句以后再说。4. 核心功能实现从登录鉴权到下单结算的关键链路4.1 登录鉴权与拦截器设计登录鉴权我采用的方案是Token 拦截器没有引入Spring Security全家桶因为用不上那么重的用户权限体系。用户登录成功后后端生成一个带有效期的Token返回给前端前端存储在localStorage里。之后每次请求都在header里带上Authorization字段。拦截器在SpringBoot里通过实现HandlerInterceptor来实现preHandle方法里取出Token调用校验逻辑通过则放行并且把用户信息放入Request的attribute里方便Controller直接拿当前登录用户ID。未登录请求返回401状态码前端Router根据响应码统一跳到登录页。这里有一个容易忽略的点放行路径和拦截路径要区分开。登录、注册、首页商品、商品详情这些接口是匿名可访问的不能拦截。购物车、订单、结算、地址这些都是必须登录才能访问的。我的处理方式是定义一套公开接口清单拦截器里判断当前请求路径是否在公开清单内公开的直接放行否则校验Token。admin开头的管理端接口则需要额外的管理员角色校验避免普通用户调用后台接口。4.2 商品列表的查询与分页实现商品列表同时服务用户端和管理端但查询条件完全不同。用户端只需要查询status1且分类id匹配或关键字匹配的商品管理端则需要无条件分页列表以及可以按商品名称、分类、上下架状态组合筛选。因为这两种查询逻辑差异明显我用Mapper里两个不同方法实现而不是一个方法走天下。MyBatis分页我是用PageHelper插件实现的。分页大忌是直接在内存里list然后subList数据超过百万条时内存直接溢出。PageHelper的用法是调用PageHelper.startPage(pageNum, pageSize)紧接着执行查询查询返回的是一个Page对象其中携带了total总条数和当前页数据再封装到统一的返回结构里。商品列表返回给前端的数据不能直接暴露实体类因为实体类里有stock、status等不应让前端随意修改的字段。我定义了一个GoodsVO包含id、title、price、image、sales、stock、categoryName、status等视图字段。Controller里把实体转成VO再返回这是前后端分离接口设计中非常基础但重要的习惯。4.3 购物车加购与结算的下单事务购物车的加购接口逻辑很简单先判断商品是否存在且上架再判断当前用户购物车里是否已经有该商品有就累加数量没有就新增记录。但数量累加时需要检查不能超过商品库存这个判断在购物车阶段只做提示真正强校验在下单时。下单流程是整个系统最核心的事务。我在createOrder方法上使用Transactional注解方法内部按顺序做四件事根据用户ID和前端传的商品ID列表查出购物车中被勾选的商品记录同时关联商品表获取最新价格和库存。校验每个商品是否上架库存是否足够如果任何一个商品不满足条件直接抛出异常事务回滚。生成订单主记录和订单项记录计算商品总价插入orders表和order_item表。循环扣减每个商品的库存使用原子更新SQL如果影响行数为0则抛异常回滚。这里有一个很多初学者会踩的坑事务只加在Service方法上但Service方法内部调用另一个Service方法如果内部方法异常没有被RuntimeException触发事务不会回滚。我刚调这套系统时就因为一个自定义业务异常没有被定义为RuntimeException导致订单记录保存了、库存却没扣回去。后来我的处理是所有业务异常类都继承RuntimeException同时Service方法上明确配置rollbackFor Exception.class这样才是真正的全异常回滚。4.4 前后端联调中的接口数据格式统一前端和后端联调时最头疼的是接口返回格式不一致。我开始写的接口有的直接返回对象有的返回Map有的返回List前端拿到数据后还需要各种判断。后来我封装了一个统一结果类Result包含code、message、data三个字段所有Controller都返回Result成功了code200失败时code500或401等前端通过封装好的request.js统一处理。分页接口返回的PageResult里除了data携带当前页数据还包含total、pageNum、pageSize。前端表格组件只要把total赋给分页组件的total属性再把页码变化事件绑定到查询方法上一个标准的分页功能就完成了。这种统一规范看起来很简单但能给联调省下一大半的时间。管理端图片上传也是一个常见模块。我的方案是前端用el-upload组件选好图片通过post请求传到后端后端把图片保存到服务器指定目录并返回访问URL。为了简化部署图片不单独存OSS而是放在项目同级的上传目录再通过一个静态资源映射配置把/upload/**映射到本地目录。这样在开发环境可以通过localhost直接访问上传图片部署后也能正常访问。5. 运行部署与踩坑记录完整复现这套系统5.1 环境版本与依赖冲突我曾经把SpringBoot版本从2.3升到2.7结果MyBatis的分页插件突然失效SQL执行一直不被拦截。排查后发现是PageHelper对SpringBoot 2.7中的Spring版本兼容性问题需要升级PageHelper到1.4.2以上。另一个常见的坑是数据库连接驱动MySQL 8.0的驱动类名是com.mysql.cj.jdbc.Driver5.7是com.mysql.jdbc.Driver如果写错会启动直接报错。这里给出一份稳妥环境清单组件版本JDK1.8SpringBoot2.3.12.RELEASEMyBatis Spring Boot Starter2.1.4PageHelper1.4.2MySQL5.7或8.0均可Vue CLI4.xElement UI2.13.2如果你用的是新版SpringBoot 3.x那么JDK必须用17以上且很多starter的artifactId都有变化建议先不要盲目升级。稳定可复现比用新版本更重要。5.2 跨域与前端代理的取舍前后端分离开发时前端运行在8080端口后端运行在9090端口直接请求必然跨域。我一开始在后端配置了CorsFilter放行了所有来源结果上线后发现非浏览器工具也能随便调用接口安全性很差。后来我改了策略开发环境前端代理部署时用Nginx反代后端只在Cors配置里精确放行指定的前端地址。开发环境的proxy配置在Vue项目vue.config.js里把/api前缀转发到http://localhost:9090这样前端请求都是相对路径没有跨域问题。部署时Nginx把域名下的/路径指向前端dist目录把/api路径转发到后端服务浏览器访问始终是同一个域名后端也不需要开启CORS。这个方案既安全又干净。5.3 订单事务失效的完整排查复现有一次测试发现一个诡异现象用户下单前端提示创建成功但随后接口调用失败订单记录存在购物车商品被清空了库存却没有扣减。我查了很多地方最后定位到是事务嵌套的问题。订单Service的createOrder方法加了Transactional然后方法内部调用了同一个类的另一个方法updateStock。在Spring的默认机制里this.updateStock()是普通方法调用不会经过代理对象因此updateStock里如果抛出异常不会触发当前事务的回滚标记。但我这个场景是createOrder前面已经插入了订单数据后面updateStock失败抛异常按理说应该在createOrder的回滚范围内为什么没回滚进一步排查才明白我的异常处理逻辑把RuntimeException捕获后继续抛了一个新的SinicaRuntimeException而这个新异常没有被声明为RuntimeException的某个已知子类导致事务管理器没有识别到回滚信号。解决办法有两个一是所有业务异常统一继承RuntimeException并且不要把事务内部的受检异常吃掉二是在事务方法里只做业务操作事务边界尽量控制在最外层Service。经过这次之后我在任何事务方法内部都不再去写成不同类内部方法调用必要的时候拆分Service并注入自身代理。5.4 慢查询定位与分页优化订单列表管理端经常要查出来数据量上去之后每次查询要好几秒。我用MySQL慢查询日志定位到一条SQL发现问题在于订单表跨表join了地址和用户信息并且WHERE条件里使用了大范围的时间范围函数。优化方式是先只查询orders表本身分页查出当前页的订单ID列表再用IN查询关联补充订单项信息。这样把大join拆成了两步小查询响应时间从3000毫秒降到了200毫秒。商品搜索则是用like %关键字%前缀通配导致索引失效。对于这个项目数据量来说影响不大但如果商品表上了百万就需要考虑ElasticSearch了。我现在的处理是限制关键字最少输入两个字符同时用倒序的方式限制搜索范围保证基本可用。5.5 一次简单的并发下单压测我用JMeter做了个简单的并发测试模拟50个线程同时抢购库存为30件的商品。如果没有做任何并发控制最终订单数量超过30库存变成负数。加上原子更新SQL之后只有30个线程能成功扣减库存剩下20个线程因为影响行数为0抛出了库存不足异常订单自动回滚最终库存数正好为0。这个测试结果验证了该方案的有效性。不过也要意识到数据库乐观锁在极高频并发的场景下会有一小部分请求因重试而失败影响体验。如果以后要接秒杀活动建议把库存扣减搬到Redis中再异步落库这是下一阶段的优化方向。6. 我的一点实际经验和后续扩展思路这套系统做完之后我最深的感受是企业级不是一个技术名词而是一系列设计习惯的总和。数据库字段约束、接口返回格式统一、事务边界明确、前后端权限都校验这些细节每一个单独拎出来都很简单但全部做到位才能让系统在真实环境中稳定运行。别小看网上超市这种看起来很成熟的项目它能暴露的问题一点不比复杂的云平台少。如果你拿到这套源码准备自己改造我建议从这三步开始先把数据库脚本跑起来然后用Postman把后端接口逐个测通最后再启动前端页面联调。不要一上来就想着加功能先把核心链路摸熟再考虑增加优惠券、积分、多商户这类扩展点。往后如果要做生产级部署可以把文件上传迁移到对象存储把Redis加入会话与热点缓存再把部署方式改成Docker Compose一键拉起让整套系统真正具备上线条件。
阅读完成 · 觉得有帮助?
咨询建站