ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot+Vue前后端分离商城实战:数据库设计、订单并发与部署避坑指南

SpringBoot+Vue前后端分离商城实战:数据库设计、订单并发与部署避坑指南 做这个项目的时候我最初的想法特别简单需要一个能完整跑通的电商系统作为技术练习于是就有了“米家商城”这个名字。项目严格来说是两块一块是面向消费者的商城前端一块是运营人员使用的后台管理端也就是项目代号里的“abo管理系统”Administrative Back Office管理后台。整套系统基于SpringBoot Vue Java MySQL MyBatis实现前后端分离后端提供RESTful接口前端负责页面展示和交互。对于刚接触JavaWeb完整流程的同学来说这个项目的价值在于它把SpringBoot配置简化、MyBatis持久层操作、Vue组件化开发以及数据库建模这些知识点串成了一条线。1. 项目整体架构与技术选型1.1 为什么是SpringBoot Vue网上关于Java后端框架的选择早几年还在争论SSM和SpringBoot最近几年基本没什么争议了。SpringBoot能火起来核心原因是它把Spring全家桶的XML配置压缩成了自动配置一个main方法就能启动Web服务。做商城这种CRUD密集型项目SpringBoot的起步依赖和自动配置能省掉大量重复工作比如内嵌Tomcat、DataSource自动配置、事务管理器自动装配开箱即用的体验对新手特别友好。Vue这边项目用的是Vue2不是因为它比Vue3更优秀而是当时的组件生态和团队成员更熟悉Vue的组件化对商城这种模块多、复用性强的场景非常合适商品卡片、分页器、订单状态标签、导航栏这类UI都可以抽成组件配合props和事件就能在不同页面复用。SpringBoot和Vue组合后团队可以并行开发后端先定义好接口文档和数据库表结构前端用mock数据同步开搞最后联调接口。这种开发模式在中小型项目里效率提升非常明显。这套架构还有一个好处是部署灵活。后端最终打成可执行jar包前端打包成静态文件扔到nginx里不需要一台服务器同时跑Tomcat和前端静态服务。接口和页面分离后后期如果要扩展小程序端或App端只需要把前端页面换成uni-app或者React Native后端接口完全复用。所以这个选型不只是因为主流更是因为它能覆盖从单体应用到前后端分离的演进路径对毕设或者个人项目来说性价比很高。1.2 米家商城和abo管理系统的功能边界整个项目按用户角色拆成两个端。米家商城面向普通用户功能包括注册登录、首页商品展示、分类浏览、关键字搜索、商品详情、购物车管理、订单结算、模拟支付、个人中心查看订单状态。abo管理后台面向管理员功能包括管理员登录、控制台数据统计、商品分类管理、商品上下架、订单审核与发货、用户禁用与解封、系统参数配置。两个端共用同一个后端服务通过接口路径前缀区分前台接口统一以 /api/user/ 开头后台接口以 /api/admin/ 开头后端拦截器根据路径判断是否需要权限避免用一个巨大的if-else去拆功能。“abo”这个名字其实是项目立项初期的内部代号取自Administrative Back Office的首字母直观理解就是办公室后台。对外展示时可能不太容易猜出来所以我在项目文档里专门加了个注释。功能设计上后台管理并不需要跟前台页面一一对应比如商品模块在前台是搜索和浏览在后台是增删改查和上下架订单模块在前台是提交和查看在后台是发货和售后处理。做需求拆分的时候我建议先把每个端口的角色和操作列成一张矩阵再开始建表和写接口能少走不少弯路。1.3 技术栈细节与版本选择数据库用的MySQL版本是8.0.x。MySQL 8.0相比5.7在性能、窗口函数、JSON类型上都有提升但使用中也有坑后面会专门讲。驱动必须写com.mysql.cj.jdbc.Driver并且在连接参数里加serverTimezoneAsia/Shanghai。持久层框架我选了MyBatis而不是MyBatis-Plus因为想锻炼原生SQL能力实际开发中MyBatis对复杂查询的控制力更强比如多表关联、动态条件、批量插入都能通过手写SQL解决。MyBatis-Plus的代码生成和条件构造器确实方便但那个是从ORM角度思考问题一旦SQL复杂反而要绕弯子。这个项目到了后期我也引入了PageHelper做分页只是为了节省写limit的时间。前端工具链用了Vue CLI创建项目Node版本固定在14.x因为高版本Node在安装旧依赖时容易报OpenSSL错误。后端JDK用的1.8SpringBoot版本选的2.3.x这个组合兼容性非常稳。构建工具方面后端用Maven管理依赖前端用npm。整套环境搭建下来最花时间的其实是前端依赖下载建议使用国内npm镜像能省去大量等待时间。项目初期我把前后端仓库分开管理后来为了交代码方便又合并到一个仓库分别放在server和web两个目录下各自维护独立的package.json或pom.xml。2. 数据库设计与核心表结构2.1 从“用户-商品-订单”三个主维度建模做商城系统数据库设计是第一步也是最容易返工的一步。我的做法是先把业务对象列出来再画ER图最后才落到建表SQL。核心对象是用户、商品、分类、订单、订单明细、购物车、收货地址。用户表user字段包括id、username、password、nickname、avatar、phone、email、status、创建时间和更新时间。password字段存的是BCrypt加密后的散列值长度要留到60以上。商品表product字段包括产品编码、标题、副标题、主图、轮播图、价格、原价、库存、销量、分类id、上下架状态、详情富文本。这里必须强调价格和库存用decimal和int不要用float否则金额比较会出问题。分类表category用parent_id做自关联支持无限级分类实际商城用两级就够了。订单表orders保存订单编号、用户id、总金额、支付状态、收货地址快照、订单状态。订单明细order_item保存商品快照包括商品标题、图片、价格、数量防止商品改价或下架后历史订单数据错乱。建表时我统一加了created_at和updated_at两个字段用datetime类型应用层写入。物理外键我没有用只在实际字段上建了普通索引。原因有两个一是物理外键在插入、更新时会额外触发约束检查高并发下影响性能二是后期如果要分库分表物理外键几乎无法迁移。所以表之间的关系靠Service层保证冗余数据可以通过MySQL的JOIN查询来关联逻辑清晰扩展性也好。2.2 购物车与库存扣减为什么单独建表购物车单独建cart表字段包括用户id、商品id、数量、选中状态。很多人会把购物车存RedisRedis性能确实好但考虑到这是教材级项目要求“JavaMySQL”能完整跑通所以用表持久化。这样用户换设备也能同步购物车后期如果要做“降价提醒”或“购物车数量角标”查表也很方便。购物车表设计了联合唯一索引(user_id, product_id)这样同一个商品重复加入时执行INSERT ON DUPLICATE KEY UPDATE直接累加数量不用先查再更新。表结构虽然简单但这一步考虑了幂等后续就不用担心插入重复记录。库存字段直接放在product表的stock字段下单时在事务里先执行SELECT stock FROM product WHERE id ? AND stock ? FOR UPDATE把商品行锁住再执行UPDATE product SET stock stock - ? WHERE id ? AND stock ?。这里特意用了带条件的更新保证并发下库存扣减不会变成负数。行级锁会牺牲一点并发量但对中小型商城完全够用远比悲观锁一把锁锁全表靠谱。如果以后商品数量变大可以再把库存拆到单独库表或Redis预扣方案但一开始没必要上那么复杂的架构。2.3 订单状态机与数据一致性订单状态是商城系统的灵魂我设计的订单状态码如下0待支付、1待发货、2已发货、3已完成、4已取消、5已退款。为了排查问题订单表还保存了订单备注字段另外建了一张订单操作日志表记录谁在什么时间把订单从什么状态改成了什么状态。状态流转不允许跳级比如支付后必须从0切到1发货后从1切到2取消只能发生在0。这个规则在Service层控制更新订单SQL强制带上当前状态影响行数等于1才说明操作成功否则就提示“订单状态已更新请刷新页面”。这种简单方案比引入状态机引擎更容易理解也满足项目需要。订单和库存还有一个联动问题下单时要预扣库存实际支付后占用库存锁定如果超时未支付则关闭订单并释放库存。我用Spring的Scheduled注解写了一个定时任务每30分钟扫描一次创建时间超过30分钟且状态为0的订单先关闭订单再恢复库存。这里要注意定时任务要加分布式锁否则多实例部署时会多个任务同时跑造成库存恢复两次。我在项目里只部署了一个实例所以用synchronized加签名就够了但写文档时把这个风险写清楚了。3. 后端关键功能设计与实现3.1 RESTful接口设计与统一返回结构后端接口设计坚持RESTful风格资源用名词复数操作交给HTTP方法。比如GET /api/user/products是查询商品列表POST /api/user/orders是创建订单PUT /api/admin/products/{id}是修改商品。所有接口返回统一结果对象Result这个对象只有三个字段code、message、data。code为200表示成功401表示未登录403表示无权限500表示服务器异常。前端axios响应拦截器看到code不等于200时统一弹出提示不需要每个页面写重复错误处理。接口入参校验用spring-boot-starter-validation创建订单时收货地址不能为空、数量必须大于0都通过DTO上的NotNull和Min注解解决校验失败由全局异常处理器返回对应提示。为了接口文档规范我引入过Springfox生成Swagger文档后来觉得页面太重就改成在代码里写固定的接口注释。如果只是个人项目用Postman的Collections也能管理接口但Swagger能自动导入参数、导出在线文档方便答辩和交接。要注意的是Swagger在SpringBoot 2.6以上有路径匹配策略冲突会启动报错需要加spring.mvc.pathmatch.matching-strategyant_path_matcher。这个坑我遇到的时候排查了很久写在这里提醒一下。3.2 MyBatis动态SQL实战多条件搜索与批量更新MyBatis最强大的能力就是动态SQL。商品列表页有三个搜索条件分类id、关键字、上下架状态。ProductMapper.xml里用where标签配合if标签拼条件关键字用LIKE CONCAT(%, #{keyword}, %)注意不要直接写LIKE %${keyword}%那会被SQL注入。下面是我常用的写法select idselectProductList resultMapProductResultMap SELECT p.*, c.name AS category_name FROM product p LEFT JOIN category c ON p.category_id c.id where if testcategoryId ! null AND p.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (p.title LIKE CONCAT(%, #{keyword}, %) OR p.subtitle LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND p.status #{status} /if /where ORDER BY p.id DESC /select这段代码只列出两个条件实际开发中别忘记拼接排序和limit。分页我用PageHelperPageHelper.startPage(pageNum, pageSize)必须紧跟目标select语句中间不能夹其他数据库操作否则分页可能会作用到错误的SQL上。批量更新商品状态时我用 标签遍历id集合比如UPDATE product SET status #{status} WHERE id IN (...)。这里要注意MySQL默认允许一次更新的包大小限制如果id集合太大建议分段批量执行。3.3 登录鉴权JWT BCrypt前后端分离后Session方案不好扩展我用了JWT。用户登录成功后后端生成token里面带上用户id和过期时间用HMAC签名前端把token存在localStorage每次请求在Authorization头里带上。后端写一个拦截器解析token从Header里拿到凭证验签和过期校验通过后把用户信息存进ThreadLocal供Controller直接取。ThreadLocal用完要记得remove否则线程池复用时会串数据。密码存储一定不要用MD5MD5加盐能防彩虹表但BCryptPasswordEncoder每次加密会产生随机盐散列值抗暴力破解更强是我现在的首选。登录接口还需要图形验证码项目里接了个简单的验证码工具生成一个base64图片和一个uuid前端把uuid和用户输入的验证码一起提交能防止接口被脚本刷。JWT也有坑token一旦签发没办法主动失效除非引入黑名单机制。这个项目用户量不大所以token有效期设置成7天管理员token设置成30分钟到期后需要重新登录。后续如果要做敏感操作比如改密码可以强制前端更换token或者在后端维护一个token版本号这些都属于额外扩展写毕设文档时可以提一句体现思考深度。3.4 文件上传图片资源的本地存储方案商品图片上传是后台必有的功能。我用的方案很简单前端用el-upload组件action指向后端的/api/admin/upload接口后端用MultipartFile接收校验文件类型和大小保存在服务器本地的/upload目录文件名用UUID重命名防止冲突。保存成功后回传完整URL比如http://服务器IP:8080/upload/xxx.jpg。然后为上传目录配置一个MvcResourceHandler让SpringBoot把/upload/**映射到本地磁盘路径等于实现了简易图床。为什么不建议把图片存数据库二进制字段既浪费性能又难维护存路径就够了图片文件本身走静态资源服务。这里还有几个经验上传接口要限制文件类型我用白名单校验图片后缀文件大小上限在application.yml里配置multipart.max-file-size默认只有1MB不调大一点图片很容易上传失败本地目录要定期清理否则磁盘会被无效图片占满。4. 前端Vue项目搭建与页面实现4.1 用Vue CLI初始化项目与目录规范前端工程我用Vue CLI创建命令是vue create mall-web创建时选择Router和Vuex。Node版本需要注意太高版本可能和旧版node-sass冲突我固定Node 14。创建完成后的目录结构src/api专门放接口调用src/router放路由配置src/store放Vuex状态src/views放页面组件src/components放公共组件。封装请求工具时我在axios实例里设置了baseURL、超时时间以及请求拦截器请求拦截器从store或localStorage取token响应拦截器统一处理业务code如果code为401就跳转登录页。API模块按后端接口对应拆分比如product.js里放getProductList、getProductDetail等方法页面只需要引入API方法不直接调用axios这样后端接口地址调整时只需改一个文件。目录规范看起来是小事但项目写到后面就知道没有规范很容易在一堆组件里迷路。我的习惯是所有页面组件文件名和路由路径保持一致公共组件都带上通用前缀比如ProductCard.vue、PaginationBar.vue。后端接口的定义也按模块归到同一个文件里这样调试接口时只需要打开一个js文件就能看到这个模块所有可用的请求方法。4.2 Vue Router路由守卫与Vuex状态管理商城有两种角色普通用户和管理员。前台需要判断是否登录才能进入购物车和订单页面后台需要判断是否管理员才能访问。路由配置里给后台路由加meta{ requiresAuth: true, role: admin }然后在beforeEach守卫里做判断代码逻辑大致是如果目标路由需要登录且没有token跳转登录页如果是后台路由且当前用户角色不是管理员跳转403页面。Vuex主要存用户信息、token、购物车数量。购物车数量在用户登录后拉取一次加入购物车时更新state再调用后端接口持久化。后台菜单数据从数据库动态加载也存到Vuex里刷新页面后重新拉取。这里有个非常经典的坑页面刷新后Vuex会被清空。我在main.js里调用一个初始化方法先从localStorage恢复用户基本信息再请求一次用户详情接口同时根据路由权限判断当前页面是否可访问。否则用户刷新后台页面时路由守卫拿不到用户信息会被误判为未登录结果就是反复跳登录页体验很差。做权限控制的时候前后端都要做前端控制按钮显隐后端控制接口访问两者缺一不可。4.3 商城核心页面的交互逻辑首页是商城门面。轮播图用第三方组件swiper商品列表用flex布局的卡片组件每个卡片点击跳转详情页。商品详情页图片可以放大规格选择用radio分组实现价格随规格变化。加入购物车按钮要处理两种情况未登录先跳登录页登录后调用cart接口。购物车页面支持修改数量、单选全选、删除结算时把选中的商品传给订单确认页。订单确认页包含收货地址选择、配送方式选择、订单金额汇总提交订单按钮要做防重复点击点击后禁用并显示loading防止用户连续提交产生多笔订单。这里我特意在订单号生成规则里拼接了毫秒级时间戳和随机数保证订单号唯一。订单支付是模拟的我在支付页设计了一个“确认支付”按钮点击后直接把订单状态从0改成1同时清空购物车已下单商品。如果是真实支付这里要对接微信或支付宝流程会复杂很多但对于毕设项目模拟支付足够展示整个闭环了。个人中心页展示订单列表按状态分类展示用户可以取消订单、确认收货、申请退款。申请退款这里我留下了扩展点后台退货审核页面可以按订单号搜索通过后更新订单状态为5。4.4 abo管理后台的表格与表单设计后台管理系统CRUD居多。商品管理页面用的是el-table展示商品列表操作列有编辑、上架、下架、删除。搜索区放在表格上方点击查询时重新加载数据。编辑功能用el-dialog弹窗包裹表单新增和编辑共用同一个弹窗通过title和字段初始化来区分。表单校验规则用el-form的rules比如商品标题必填、价格必须大于0、库存必须是整数。分类管理用element-ui的el-tree支持增删改和拖拽排序树节点的增删要注意同步刷新分类下拉框。订单管理页用状态筛选标签点击发货时弹出物流单号输入框提交后调用后台接口更新状态。后台的整体布局参考了vue-element-admin的风格左侧菜单从路由自动生成顶部加面包屑导航和用户下拉菜单。表格和表单虽然看起来机械但有几个细节值得注意列表页的删除操作必须二次确认不然误删很麻烦表格加载状态用v-loading指令避免用户以为是卡死上传图片时el-upload的on-success回调要把返回的URL存到表单字段里再把预览图赋值给图片组件的value。这些交互细节决定了管理系统好不好用很多毕设项目功能都实现了但体验很生硬就是因为少了这些细节。5. 前后端联调与部署上线5.1 跨域问题的三种解决方式前后端分离联调第一天大概率遇到跨域。我踩过的坑是接口能从浏览器地址栏访问但页面请求就报CORS。后来在vue.config.js里配了devServer.proxy把/api代理到http://localhost:8080开发环境不用后端配跨域。代理配置大概是这样module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }部署到服务器时用nginx做反向代理location /api/ { proxy_pass http://127.0.0.1:8080; }。如果后端也要支持跨域就写一个CorsFilter配置allowedOriginPatterns和allowedMethods。三个方案优先级nginx最稳定开发代理最方便后端CorsFilter最灵活。实际项目我建议前端代理加nginx配合使用后端不一定要开跨域开关偶尔开一下也要注意allowedOrigins别写成*否则携带cookie时会有问题。5.2 SpringBoot打包部署与Nginx配置后端打包用mvn clean package生成可执行jar。服务器上装JDK8用nohup java -jar mall-server.jar --spring.profiles.activeprod后台运行。前端打包用npm run build生成dist目录把它放到nginx的html目录或指定root。nginx配置要点location / 指向dist目录和index.htmllocation /api/ 代理到后端history路由需要try_files $uri $uri/ /index.html否则刷新页面就404。这个坑我印象深刻第一次部署时把路由从hash改成了history刷新二级页面直接白屏加上nginx配置后才好。Nginx配置片段供参考location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /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; }部署时还要注意端口放行和防火墙。如果后端用8080端口前端用80端口就得保证云服务器安全组规则里80和8080都能访问。我用的是CentOS 7.6装nginx用yum install nginx就行配置文件默认在/etc/nginx/nginx.conf修改完记得nginx -t检查语法再reload。5.3 MySQL数据迁移与初始化脚本上线前准备一份init.sql里面包含建库、建表和初始数据。因为本地MySQL版本是5.7服务器是8.0导入时要注意utf8mb4字符集设置避免中文乱码。application-prod.yml里配置数据库连接url中加useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai。数据迁移我用的是mysqldump -u root -p mall mall.sql然后在服务器上source mall.sql。如果遇到外键冲突检查表导出顺序或临时SET FOREIGN_KEY_CHECKS0。上线后建议定期备份至少mysqldump做全量备份配合binlog做增量。数据备份这件事我一开始完全没在意直到有一次误删了一张表才追悔莫及从那次后每次改动数据库前都先导出一份sql。数据库初始化脚本最好包含测试数据不然前端联调时列表空空看不出效果。我准备了两个分类和十来个商品图片用的是公共占位图。初始管理员账号是admin密码是123456但上线后必须改成强密码我在文档里专门标注了这个风险点。6. 常见问题与避坑指南6.1 MyBatis字段映射与TypeHandler数据库字段用下划线Java属性用驼峰如果没开启mapUnderscoreToCamelCase查出来的字段全是null。我在application.yml里配置mybatis.configuration.map-underscore-to-camel-casetrue。另一个容易踩的是枚举类型Java里的订单状态如果定义成枚举查询和插入都需要TypeHandler来处理。MyBatis的默认枚举处理器可以存name或者ordinal但项目里要显示状态说明所以我自定义了OrderStatusTypeHandler在setNonNullParameter和getNullableResult两个方法里做转换。自定义TypeHandler的工作流程其实很简单insert时把Java枚举转成数据库intselect时把int转回枚举关键是注册到XML的resultType上。网上有大量关于TypeHandler的图解但我个人觉得实际写一遍比看图更快代码量很小却能解决字段语义化的大问题。6.2 MySQL 5.7与8.0的差异坑连接驱动是最大差异。MySQL 5.7用com.mysql.jdbc.Driver8.0用com.mysql.cj.jdbc.Driver依赖版本换错启动就会报ClassNotFound。8.0还默认要求设置时区我在url后面加了serverTimezoneAsia/Shanghai。密码认证方式8.0默认caching_sha2_password如果Java连接报“Unable to load authentication plugin”需要创建用户时指定mysql_native_password。另外MySQL 8.0的GROUP BY更严格查询字段必须在group by或聚合函数里老SQL直接报错。这些坑在部署阶段特别常见因为本地开发库和服务器生产库版本不一致所以上线前一定要确认两边MySQL版本避免生产环境跑不起来或者SQL执行结果不一样。如果使用MySQL 8.0的JSON字段MyBatis的resultMap映射也要适配不能直接用普通字符串类型需要自定义TypeHandler读取JSON。这个项目暂时没用到但我加了一个备注字段做JSON格式的规格参数扩展点以后可以无缝升级。6.3 Vue项目的白屏与404问题排查前端白屏时先看控制台有没有报错如果只是空白多半是路由模式问题。开发环境用history模式刷新某个具体路由会404生产环境nginx没配try_files也会404。还有一种白屏是代码编译错误Vue运行时错误会在控制台提示这时要看具体报错组件。如果build后的dist文件夹里index.html引用的JS路径是绝对路径部署到子目录会找不到需要把vue.config.js的publicPath改成./或相对路径。另外组件循环依赖也可能导致白屏用Vue Devtools检查依赖关系会快很多。我调试的时候习惯在main.js入口先打印一个全局配置信息确认前端代码是新的避免缓存导致看到的还是旧版本。6.4 并发下单与库存扣除的实践刚开始做订单时没考虑并发压测时发现100个并发请求能把库存扣成负数。后来我用select for update锁定商品行再执行更新库存。但是要注意for update必须在事务里执行不能锁定后抛异常否则连接会一直被占用。为了更稳我还在订单表创建了唯一索引order_no防止重复生成订单。每隔半小时做一次未支付订单超时关闭扫描创建时间超过30分钟且状态为0的订单先关闭订单再释放库存。这个定时任务用Spring的Scheduled注解实现生产环境建议改成分布式任务避免多实例重复执行。如果要用乐观锁可以在product表加version字段更新库存时set version version 1 where version #{version}只要更新行数为0就重试这种方案适合并发不是特别高的场景。实际压测下来100个并发用户同时买同一个商品在select for update方案下库存最终扣减数量正确但接口响应时间会有轻微波动因为排队等待行锁。这个结果完全可以接受。如果未来量大了可以按商品维度做Redis预减库存再异步同步数据库那是另一个层次的问题了。这个项目做完以后我个人最大的体会是一个商城系统看起来功能简单真正动手做的时候数据库设计和并发控制才是最花时间的。后端接口、前端页面这些都可以靠熟练度堆出来但像订单状态、库存扣减这种业务规则一旦设计错误后面补丁只会越打越多。如果你正在做类似的SpringBootVue商城项目我建议先把订单和库存这两块想清楚再写代码数据库字段可以冗余一些但状态流转的逻辑一定要理清。另外部署上线时提前把MySQL版本和nginx配置确认好能少熬几个夜。最后分享一个小技巧所有配置信息包括数据库密码、密钥、文件路径都放到application-prod.yml里本地用application-dev.yml不要把生产数据库密码提交到代码仓库这种教训我认为值得每个开发者早点记住。
返回列表