十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

SpringBoot校园外卖平台实战:从订单设计到部署上线

SpringBoot校园外卖平台实战:从订单设计到部署上线 1. 项目概述与背景分析1.1 为什么做校园外卖平台校园外卖这个场景我前前后后接触过几个类似项目说实话它比普通电商系统更有意思。学生群体集中、订单峰值明显、配送范围固定、支付习惯统一这几个特点决定了它非常适合用来做完整的Java Web实战项目。你不需要像做大型电商那样考虑海量商品SKU和复杂推荐算法但用户端、商家端、骑手端、管理后台四条业务线一个都跑不掉订单状态流转、支付回调、配送调度、权限管理这些核心模块全部都能覆盖到。我见过不少同学一上来就闷头写代码结果写到订单模块就卡住了因为配送状态和支付状态之间的联动关系没想清楚。所以这篇博文我从整体设计开始讲把我在实际开发中用到的方案、踩过的坑、提炼出的经验都记录下来。如果你正准备用SpringBoot做校园外卖平台或者正在构思类似的交易类系统这篇文章应该能帮你少走不少弯路。1.2 技术选型与版本选择先说我最终选型的结果后面逐个解释为什么这么选组件选型说明开发框架SpringBoot 2.7.x稳定版本兼容JDK8生态成熟持久层MyBatis-Plus单表CRUD不用写SQL复杂查询自己控制数据库MySQL 8.0存业务数据事务支持完善缓存Redis 5.x存验证码、Session会话、热搜数据权限认证Spring Security JWT无状态登录适合前后端分离接口文档Knife4j在线调试方便前端对接不扯皮文件存储MinIO上传菜品图片、店铺证照前端框架Vue 3 Element Plus管理后台端纯前后端分离构建工具Maven依赖管理多模块项目标配这里需要特别提醒一下版本问题最近很多人在SpringBoot版本上踩坑。如果是4.0的版本你会发现Spring Security的配置方式变了AOP的自动配置类也不一样了网上很多教程里的写法直接跑不通。我建议做项目还是选2.7.x这个区间教程多、问题答案好搜、同事踩过的坑你都能搜到解决方案。等到核心功能都跑通了再考虑升级也不迟。1.3 项目适合谁参考这个项目的受众其实很宽我大致分了三类人。第一类是计算机专业的毕业生需要做一个能写进简历里的完整项目校园外卖这个题材比图书管理系统有辨识度得多面试官问业务场景也容易展开。第二类是自学Java想转行的朋友需要一套从零到一的完整项目链路来串联零散的知识点你会发现原来注解、事务、缓存这些东西在真实项目里是这么配合工作的。第三类是在校生想自己搞点技术创业的虽然创业成功率不高但通过这个系统把需求调研、系统设计、开发部署整个流程走一遍收获还是很大的。2. 整体设计与技术原理拆解2.1 核心需求与角色权限分析校园外卖平台最核心的需求就一句话解决学生吃饭痛点。具体拆开来看它涉及四种角色每种角色的关注点完全不同。学生用户最关心的是下单流程顺不顺畅。从打开应用看到附近商家、浏览菜品、加购物车、提交订单、支付、等待配送、收到餐品再到评价和售后这条链路是系统的主干线。设计时必须保证每一步的状态都对得上不能让用户支付成功了订单还停在待支付状态。商家端关注的是接单和出餐效率。商家要能随时上下架菜品、修改库存、接收新订单、更新订单状态接单、制作中、配送中、已完成。这里要特别考虑消息的实时性不能靠用户手动刷新页面要主动推送新订单通知。骑手端核心任务是接单和配送。骑手要能看到可抢的订单列表抢单后进入配送流程更新配送状态。这里涉及一个常见问题订单分配策略。我这版做的是手动抢单以后可以扩展成自动派单按骑手位置和负载情况分配。管理后台负责基础数据维护和运营管理审核商家入驻、管理用户账号、处理投诉、统计分析订单数据。这块功能不多但必须有不然整个平台没有监管入口。我用一张表来归纳权限矩阵模块学生商家骑手管理员浏览菜品登录即可---下单支付自己订单---菜品管理-自己店铺-全部订单处理自己订单接到的订单接到的订单全部用户管理---全部数据统计个人消费店铺营收个人收入平台整体2.2 数据库设计与表结构规划数据库设计是整个项目的基石表结构一开始没设计好后面改起来非常痛苦。我第一个版本就吃了这个亏订单表和订单明细表没分开结果统计商家月销量的时候SQL写到你怀疑人生。第二次重构时我老老实实按标准范式来设计核心表有这些用户表user字段id、用户名、密码BCrypt加密存储、手机号、头像、角色student/seller/rider/admin、微信openid、创建时间、状态。密码加密这个点我要多说一句千万不要存明文用Spring Security自带的BCryptPasswordEncoder同一密码每次加密结果都不同安全性比MD5高一个量级。商家表seller字段id、用户ID关联user表、店铺名称、店铺LOGO、营业时间、联系电话、店铺地址、公告、评分、月销量、营业状态。这里有个小设计点评分和月销量不要每次都实时算可以在订单完成时异步累加否则商家列表页的查询会越来越慢。菜品表dish字段id、商家ID、分类ID、菜品名称、图片、描述、原价、现价、月销量、库存、状态上架/下架。菜品表要建立商家ID和分类ID的联合索引这是查询频率最高的维度。分类表category字段id、商家ID、分类名称、排序。每个商家的分类是独立的所以商家ID必填。购物车表cart字段id、用户ID、商家ID、菜品ID、数量、创建时间。购物车设计上要注意一点同一个用户的购物车只能有一个商家的菜品如果添加另一家商家的菜品要提示用户是否清空原购物车。这是业务规则层面的约束代码里要处理。订单表orders字段id、订单号、用户ID、商家ID、骑手ID、总金额、订单状态、支付状态、配送地址、联系人、联系电话、备注、下单时间、支付时间、接单时间、送达时间。订单状态我用整数枚举1待支付、2待接单、3待配送商家已接单、4配送中、5已完成、6已取消。订单明细表order_detail字段id、订单ID、菜品ID、菜品名称、菜品图片、价格、数量、小计。订单明细里冗余了菜品名称和图片当时有同学问为什么不直接关联菜品表后来订单菜品改了价格、换了图片历史订单却要保留当时的信息这个冗余就体现出价值了。地址表address字段id、用户ID、联系人、手机号、详细地址、是否默认。学生一般就是宿舍楼栋号但字段设计上还是要做成通用地址格式以后推广到校外也能用。配送表delivery字段id、订单ID、骑手ID、取餐时间、送达时间、配送状态、备注。配送表可以理解成订单的附属表主要是为了以后做骑手绩效考核预留的数据空间。数据库设计这里我再强调一个经验所有的时间字段统一用datetime不要用timestamp否则2038年问题迟早找上你所有金额字段统一用decimal(10,2)不要用float和double二进制浮点数在金额计算上会出精度问题这不是玄学是IEEE 754标准决定的。2.3 SpringBoot核心机制通俗解读SpringBoot之所以能成为Java后端的事实标准核心原因是它把Spring的繁杂配置变成了“约定大于配置”。但很多初学者只知道会用注解不知道背后是怎么回事我这里用大白话讲清楚两个最关键的机制。自动装配原理是SpringBoot的看家本领。你引入一个spring-boot-starter-web依赖不需要配置TomcatSpringBoot会自动帮你创建内嵌的Tomcat你引入spring-boot-starter-data-redis自动帮你创建RedisTemplate的Bean你引入mybatis-plus-boot-starter自动帮你创建SqlSessionFactory。这套魔术的核心是EnableAutoConfiguration注解它会去读取META-INF/spring.factories或META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件里列出的所有自动配置类然后通过ConditionalOnClass、ConditionalOnMissingBean这些条件注解按当前项目的classpath和Bean情况决定要不要执行自动配置。说白了就是你jar包里有什么我就自动帮你配什么你自己写了Bean我就用你写的。依赖注入与Bean生命周期这块面试和实际开发都绕不开。Spring容器启动时大致经历这么个过程扫描ComponentScan指定的包路径下的类识别Component、Service、Repository、Controller这些注解的类生成BeanDefinition然后根据BeanDefinition创建实例、填充属性、执行Aware回调、调用BeanPostProcessor的前后处理方法、执行初始化方法PostConstruct最后把Bean放入单例池。你平时用的Autowired就是通过BeanPostProcessor里的AutowiredAnnotationBeanPostProcessor来实现的值注入。理解这个流程之后你才能解释为什么构造器注入比字段注入推荐——构造器注入天然支持不可变对象还能在编码期发现循环依赖。2.4 前后端分离架构与数据交互校园外卖平台采用前后端分离架构前端Vue运行在Nginx里开发环境是Node服务后端SpringBoot跑在Tomcat里通过RESTful API通信。这种架构的好处是互不干扰前端同学只管页面交互后端同学只管数据接口两边通过约定的接口文档联调即可。数据交互我统一用JSON格式约定返回结构{ code: 200, message: success, data: { token: xxxxx.yyyyy.zzzzz, userInfo: { id: 10001, username: 张三 } } }code是业务状态码200代表成功其他如401代表未登录、403代表无权限、500代表服务异常。这种统一返回结构的好处是前端可以写一个axios拦截器统一处理错误码跳转登录页、弹出错误提示不用每个请求都做一遍判断代码量省很多。3. 核心功能实现与关键代码3.1 用户认证与JWT权限控制校园外卖平台的认证模块我用了Spring Security JWT的组合方案。为什么不用传统的Session方案因为前后端分离架构下Session要处理跨域Cookie、要解决集群Session共享问题麻烦不断。JWT把用户信息加密编码到一个字符串里前端存储后每次请求带着后端验签通过就认为是有效登录。这就像你去健身房办卡人家不记你名字只认你手上的手环手环有效就行。JWT的结构是三段字符串Header.Payload.Signature。Header指定加密算法Payload放用户核心信息id、用户名、角色、过期时间Signature用服务端密钥对前两段做HMACSHA256或RSA签名。所以JWT不能被篡改但能被解码查看内容敏感信息别往Payload里放。我的实现思路是这样的写一个JwtUtil工具类负责生成Token和解析Token写一个JwtAuthenticationFilter继承OncePerRequestFilter在Spring Security过滤器链的UsernamePasswordAuthenticationFilter之前执行核心逻辑是从请求头Authorization取出Token解析校验成功后把用户信息UserDetails塞进SecurityContextHolder就相当于告诉Security框架这个人登录过了。再配合SecurityConfig里配置的permitAll放行登录注册接口、antMatchers(/api/admin/**).hasRole(ADMIN)这种角色控制权限体系就完整了。生成Token的核心代码public String generateToken(LoginUser user) { MapString, Object claims new HashMap(); claims.put(userId, user.getId()); claims.put(role, user.getRole()); // 设置过期时间2小时 return Jwts.builder() .setClaims(claims) .setSubject(user.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 7200000L)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }过滤器校验代码String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims JwtUtil.parseToken(token); LoginUser loginUser userService.getLoginUser(claims.get(userId)); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { // Token无效不用处理Security会拦截后续请求 } }这里有个细节坑跨域配置的CORS过滤器必须放在Security过滤器之前不然前端带Origin的预检请求OPTIONS直接就被Security拦截了联调时会一直报跨域错误。我在项目里是写了一个CorsFilter用Order(Ordered.HIGHEST_PRECEDENCE)标注最高优先级。3.2 用户端下单流程完整实现下单流程是外卖系统的命脉环节业务逻辑最复杂也最容易出错。整个流程包含校验购物车、计算金额、创建订单、扣减库存、清空购物车、对接支付。每一步都要保证原子性否则用户支付成功但订单创建失败售后麻烦就大了。我的服务层代码结构大概是这样的Transactional(rollbackFor Exception.class) public OrderVO createOrder(CreateOrderDTO dto) { // 1. 校验用户地址 Address address addressMapper.selectById(dto.getAddressId()); if (address null || !address.getUserId().equals(currentUserId())) { throw new BizException(收货地址不存在); } // 2. 查询购物车列表 ListCart cartList cartMapper.selectList( new LambdaQueryWrapperCart().eq(Cart::getUserId, currentUserId())); if (CollectionUtils.isEmpty(cartList)) { throw new BizException(购物车为空); } // 3. 计算总金额并且分布式锁锁定店铺库存 String lockKey lock:dish_stock_ cartList.get(0).getDishId(); redisLock.lock(lockKey, 10, TimeUnit.SECONDS); BigDecimal totalAmount BigDecimal.ZERO; for (Cart cart : cartList) { Dish dish dishMapper.selectById(cart.getDishId()); if (dish.getStatus() ! 1) { throw new BizException(菜品已下架 dish.getName()); } if (dish.getStock() cart.getQuantity()) { throw new BizException(库存不足 dish.getName()); } totalAmount totalAmount.add(dish.getPrice().multiply(BigDecimal.valueOf(cart.getQuantity()))); // 扣减库存 dishMapper.deductStock(cart.getDishId(), cart.getQuantity()); } redisLock.unlock(lockKey); // 4. 生成订单号和订单记录 String orderNo generateOrderNo(); // 5. 清空购物车 cartMapper.delete(new LambdaQueryWrapperCart().eq(Cart::getUserId, currentUserId())); // 6. 构建返回对象 return OrderVO.builder() .orderNo(orderNo) .totalAmount(totalAmount) .build(); }createOrder方法加了Transactional保证订单、扣库存、清购物车在同一个事务里任何一个步骤失败都会回滚。分布式锁这里我先用Redis实现防止并发下单导致超卖。虽然校园外卖平台并发量远不如电商大促但写代码的时候养成这个习惯以后项目上量了不会慌。这里有个特别隐蔽的坑扣减库存的SQL必须写条件stock 数量而不是先查出来在Java里判断。你想想两个线程同时读到stock1都判断库存充足然后都执行扣减如果不加条件库存就会变成-1。正确写法是UPDATE dish SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity};如果影响行数为0说明扣减失败抛出库存不足异常事务回滚。订单号生成我用的是时间戳加随机数再加用户ID后四位实际项目里可以换成雪花算法保证全局唯一即可。3.3 商家端接单与销量统计实现商家端的核心是接单流程和营业数据看板。接单流程我用了Redis的发布订阅功能。商家登录后前端通过WebSocket连接到后端后端在商家登录时建立一个客户端连接再订阅一个频道频道名按商家ID区分形如order:notify:123。用户下单成功后发布一条消息到这个频道商家WebSocket就能实时收到新订单提醒。这套方案比商家轮询数据库高效得多也比引入消息中间件轻量很多适合校园外卖这种规模。WebSocket配置核心代码Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 客户端订阅前缀服务端推送 registry.enableSimpleBroker(/topic, /queue); // 客户端发送前缀 registry.setApplicationDestinationPrefixes(/app); } }Java代码里往指定频道推送Autowired private SimpMessagingTemplate messagingTemplate; public void notifyNewOrder(Order order) { messagingTemplate.convertAndSend( /queue/order_ order.getSellerId(), JSON.toJSONString(order) ); }前端Vue里用SockJS STOMP订阅stompClient.subscribe(/queue/order_ sellerId, (message) { const order JSON.parse(message.body); // 弹出新订单提醒播放提示音 ElNotification({ title: 新订单, message: 你有新的订单待处理 }); loadOrderList(); });销量统计我建议不要实时查订单表而是维护一张商家统计表订单完成时异步更新月销量、总销量、评分数据。这样商家端看板打开时只查一条统计记录毫秒级返回。做法是在事务提交后发布一个事件事件监听器里做统计更新用TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT)确保事务成功后再更新。3.4 管理后台与数据看板管理后台是四端里最容易忽视但最体现系统完整度的部分。我实现了商家审核、用户管理、订单管理、投诉处理、数据统计五个模块。审核功能本质就是状态机流转商家提交入驻申请 - 管理员审核通过/驳回 - 商家可以登录店铺后台。状态字段我放在商家表里用status区分0待审核、1营业中、2已关闭。数据看板这块我做了三个维度的统计时间维度今日/近7天/近30天、业务维度订单量、GMV、新增用户、客单价、排行维度热销菜品TOP10、销量领先商家TOP10。SQL写起来很简单联表查询Group By就行但要注意加时间段索引不然数据量大了全表扫描会让你想哭。热销菜品排行查询select d.name, sum(od.quantity) as total_sales from order_detail od left join dish d on od.dish_id d.id left join orders o on od.order_id o.id where o.status 5 and o.pay_time between #{startTime} and #{endTime} group by d.id, d.name order by total_sales desc limit 103.5 Redis缓存与异步消息处理Redis在这个项目里承担了三个职责缓存热点数据、存储验证码和Token黑名单、实现分布式锁和发布订阅。我先说缓存这块最典型的场景是商家列表和菜品列表。校园外卖的SKU少则几百多则几千完全不值当每请求都查数据库。我的方案是首次请求查MySQL然后把结果序列化为JSON存入Redis设置过期时间10分钟。后续请求直接读Redis命中率可达80%以上性能提升明显。这里有个反直觉的坑要提醒加了缓存之后商家改了菜品信息用户端看到的还是旧数据。我的解决方案是写一个RedisUtil在商家修改菜品、上下架操作后调用del(key)删除对应缓存下次查询自动回源数据库并重建缓存。这种简单粗暴的“删缓存保一致”策略在小项目里比Canal监听Binlog和双删策略更实用。下单高并发场景下我还在用户提交订单时做了幂等处理。方案是在Redis里用SETNX命令记录订单唯一键比如user:order:10001:{timestamp}如果SETNX返回失败说明上次提交还没处理完直接返回“请勿重复提交”。这个处理能有效避免用户手抖连点两次下单导致重复订单。boolean success stringRedisTemplate.opsForValue() .setIfAbsent(order:submit: userId : dishId, 1, 5, TimeUnit.SECONDS); if (!success) { throw new BizException(操作太频繁请稍后重试); }3.6 文件上传与大文件处理方案校园外卖平台涉及文件上传的地方商家入驻要传营业执照、菜品要传图片、用户要传头像。传统做法是把图片存到应用服务器本地目录这在小项目里没问题但应用重启或者多实例部署时文件就丢了所以我的方案是MinIO对象存储。MinIO部署很简单Docker一行命令就能拉起来docker run -p 9000:9000 -p 9001:9001 \ -e MINIO_ROOT_USERadmin \ -e MINIO_ROOT_PASSWORDadmin123456 \ -v /data/minio:/data \ minio/minio server /data --console-address :9001然后在SpringBoot里集成MinIO的Java SDK封装一个MinioServicepublic String upload(MultipartFile file, String bucketName) { String objectName uploads/ UUID.randomUUID().toString().replace(-, ) getExtName(file.getOriginalFilename()); minioClient.putObject(PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return minioConfig.getEndpoint() / bucketName / objectName; }这里有两个容易踩的坑。第一个是文件大小限制SpringBoot默认Spring MVC只接受1MB以内的上传超了直接抛MaxUploadSizeExceededException需要在application.yml里显式设置spring: servlet: multipart: max-file-size: 20MB max-request-size: 50MB第二个是静态资源映射和防盗链策略MinIO默认生成的URL是实时的我在前后端分离场景下用的是预签名URL设定7天有效期有效防止外链盗图既安全又省配置。4. 开发环境搭建与部署上线4.1 手把手搭建开发环境项目开发前要先把环境弄得干干净净不然后续排查问题时不知道是环境问题还是代码问题白浪费时间。我建议按这个顺序来第一步安装JDK 8和配置JAVA_HOME环境变量。这一步网上教程太多我不展开只提醒一点安装后命令行执行java -version确认版本避免IDE内置JDK和系统JDK混用。IDEA里创建SpringBoot项目时如果你用的是2023年后的版本默认会让你选Spring Initializr的在线地址建议换成阿里云的镜像地址https://start.aliyun.com不然从国外服务器拉取元数据会慢到怀疑人生有时候还会超时。第二步安装MySQL并初始化数据库。MySQL 8.0安装好以后用Navicat或命令行创建数据库create database if not exists campus_order default character set utf8mb4 collate utf8mb4_general_ci;。utf8mb4必须用因为学生备注里可能会输入emoji表情普通utf8存不下报错Incorrect string value的时候你就知道这个坑了。然后执行schema.sql初始化表结构数据脚本不需要提前执行后面有开关可以测试数据。第三步启动Redis和MinIO。Redis的Windows版本不太好装建议直接用Docker Desktop跑Linux容器一行代码搞定。MinIO也建议用Docker方式我已经在上面给过部署命令。第四步用IDEA导入项目。后端项目用Maven构建导入后等依赖下载完成右侧Maven面板能看到模块结构。如果一直卡在downloading状态多半是Maven的中央仓库地址问题找到settings.xml把镜像地址改成阿里云的mirror idaliyun/id mirrorOfcentral/mirrorOf namealiyun maven mirror/name urlhttps://maven.aliyun.com/repository/public/url /mirror第五步修改application.yml配置你自己的数据库账号密码、Redis地址、MinIO密钥。我封装了一个Config工具类用ConfigurationProperties(prefix minio)读取自定义参数这样配置项不会散落在代码里。第六步启动Application类看到SpringBoot启动日志里出现Tomcat started on port(s): 8080就说明后端起来了。然后启动前端Vue项目npm install安装依赖后npm run dev浏览器访问localhost:5173端口看你vite配置能打开登录页说明前后端联通正常。4.2 Nginx反向代理与前端部署开发完成后前端项目执行npm run build会在dist目录生成纯静态文件。部署时我不建议直接扔给Tomcat而是交给Nginx托管静态资源同时把/api/开头的请求反向代理到后端的8080端口。这样做的好处是前端资源和后端服务完全隔离、Nginx可以配请求超时时间、可以开启gzip压缩降低传输体积、可以配置跨域不死在前端代码里。Nginx核心配置片段server { listen 80; server_name campus-order.example.com; # 前端静态资源 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # 解决Vue路由history模式刷新404问题 } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 60s; } }try_files这行太关键了Vue Router开history模式时用户刷新页面会直接请求一个不存在的路径没有这行配置就会404。用hash模式则没有这个问题但URL里会带个#号不太好看。4.3 Docker镜像构建与云端部署后端部署我推荐用Docker。Docker打包SpringBoot项目的本质就是把JRE和Jar包一起装进一个Linux容器里让应用跑在一个干净隔离的环境里随便复制到哪台Linux服务器都能运行。Dockerfile怎么写我直接给一个可用的版本FROM openjdk:8-jdk-alpine LABEL maintaineryour-emailexample.com ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone WORKDIR /app COPY target/campus-order.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -Xms256m, -Xmx512m, -jar, app.jar]构建命令和执行mvn clean package -DskipTests docker build -t campus-order:v1.0 . docker run -d --name campus-order \ -p 8080:8080 \ -e DB_HOST你的数据库IP \ -e REDIS_HOST你的Redis IP \ -v /data/logs:/app/logs \ campus-order:v1.0这里环境变量不在Dockerfile里写死而是运行时传参这样同一个镜像可以部署到开发、测试、生产多个环境这就是12-Factor应用推荐的做法。还有一个经验线上服务器的内存分配别超过物理内存的70%JVM参数Xmx设512m够用业务上量后再通过调整配置扩容。日志一定要挂载到宿主机目录不然容器一删日志全没了排查线上问题全靠日志日志丢了等于现场被破坏。4.4 配置文件管理技巧SpringBoot的多环境配置管理我按照application.yml、application-dev.yml、application-prod.yml的方式拆分。主配置放通用项环境配置放差异项。打包的时候通过启动参数指定环境java -jar app.jar --spring.profiles.activeprod有个很常见的坑是application.yml里数据库密码直接明文放在Git仓库如果项目开源或者仓库不小心被别人看到数据库就是裸奔的。这个问题可以通过Jasypt组件来解决把密码加密成一串密文配置里写ENC(加密串)项目启动时用密钥解密。Jasypt集成SpringBoot很简单引入依赖后在配置类上加EnableEncryptableProperties然后在配置文件中指定解密密钥jasypt: encryptor: password: ${JASYPT_PASSWORD:default_secret}密钥通过环境变量传入不进入代码仓库这样即使代码泄露数据库密码也不会泄露。这个安全规范建议所有带数据库连接的SpringBoot项目都遵守。5. 高频问题排查与避坑指南5.1 启动失败端口占用与依赖冲突我调试项目时遇到的最多问题就是启动报错。端口被占用很好排查IDEA控制台会直接提示Port 8080 was already in use执行netstat -ano | findstr 8080Windows或lsof -i :8080Linux/macOS找到占用进程然后结束掉就行。如果非要保留当前占用的服务可以在启动命令里指定--server.port8081或者改application.yml里的server.port。依赖冲突是另一个高发问题。SpringBoot的依赖管理和业务依赖版本冲突表现在启动时Caused by: java.lang.NoSuchMethodError或者ClassNotFoundException几乎可以确定是版本冲突。排查思路是执行mvn dependency:tree查看依赖树找到冲突的依赖通过排除或调整版本解决dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId exclusions exclusion groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId /exclusion /exclusions /dependency5.2 MyBatis-Plus与MyBatis共存问题有些教程会用原生MyBatis有些用MyBatis-Plus如果两个都引入会产生SqlSessionFactory重复创建的问题启动时yaml配置就会冲突。这里我给出建议新项目直接选MyBatis-Plus它兼容MyBatis的绝大部分功能还能让你少写大量单表CRUD的XML。如果项目必须同时用需要在MybatisPlusConfig里手动指定SqlSessionFactoryBean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean sessionFactory new MybatisSqlSessionFactoryBean(); sessionFactory.setDataSource(dataSource); // 关键设置MyBatis的配置器避免两者冲突 sessionFactory.setConfiguration(new org.apache.ibatis.session.Configuration()); return sessionFactory.getObject(); }5.3 IDEA中application.yml不自动提示这个问题问我的人特别多。IDEA里新建SpringBoot项目后application.yml文件输入spring.时没有自动提示通常是因为IDEA没有识别到Spring Boot的配置元数据。解决办法File - Project Structure - Facets添加Spring facet如果Facets里没有Spring选项说明缺少spring-boot-configuration-processor依赖在pom里加上dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-configuration-processor/artifactId optionaltrue/optional /dependency加完后重新加载Maven项目手动提示就出来了。5.4 循环依赖的成因与两种解决思路SpringBoot中循环依赖这个问题非常经典场景是两个或多个Bean互相引用比如订单服务依赖通知服务通知服务又依赖订单服务。SpringBoot 2.6版本开始默认禁止循环依赖启动就会直接报错The dependencies of some of the beans in the application context form a cycle解决方案有两种。第一种是重构代码把互相依赖的逻辑拆开使用事件机制解耦。我在订单模块就是用Spring的事件发布器订单完成后发布OrderCompletedEvent通知服务监听事件避免两个服务直接循环引用。第二种是适配器模式把其中一个依赖通过Lazy延迟加载Component public class OrderService { Autowired Lazy private NotifyService notifyService; }Lazy的意思是第一次调用时才创建代理对象这样就打断了循环链路的初始化顺序。但我更推荐第一种方案循环依赖本质是设计不良的信号通过事件驱动的方式从根上消除它代码更清晰也契合Spring的Recommended Practices。5.5 事务失效的7种常见场景Spring事务失效这个问题面试考察频率极高而且写了两年代码的人都未必趟过这些坑。我结合实战经验列出高频场景和避坑方案方法没有被public修饰Spring AOP基于代理实现代理只能拦截public方法private方法调用是被编译器内联的不走代理事务失效。同类内部方法调用A方法调用本类的B方法B有Transactional注解但实际上是this.B()调用绕过代理对象。解决方法是注入自身或拆分Service。异常被catch吞掉事务方法里的业务代码捕获异常不抛出事务回滚策略无法感知异常白写了事务。异常类型不是RuntimeExceptionSpring默认回滚RuntimeException和Error受检异常默认不回滚。通过Transactional(rollbackFor Exception.class)强制所有异常回滚。数据库引擎不支持事务MyISAM引擎不支持InnoDB才支持事务。建表时注意引擎。事务方法开在启动类的Bean或配置类方法上SpringBoot扫描不到不生效。多线程调用事务方法事务是绑定在当前线程的数据库连接上的新开的子线程不参与事务。比如父线程更新了数据没提交子线程去查这个数据是查不到的。我记得有一次生产环境上线后用户在支付成功页面看到异常查了半天原因最后发现事务方法里第一行调用了Thread.sleep(5000)导致数据库连接被占用时间过长数据库连接池被打满查询全部阻塞。所以事务方法里一定不要做耗时操作网络请求、文件写入、外部RPC调用都要放到事务外面。5.6 前端联调踩坑跨域与Token刷新前后端联调跨域问题几乎是必踩的。开发环境最简单的方案是在Vue的vite.config.js里配置代理server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端请求/api/login会转发到后端8080端口浏览器看到的是同源请求不会触发CORS。生产环境由Nginx反向代理解决效果也一样。Token过期的处理也是联调中经常出问题的地方。我的方案是前端axios响应拦截器检测到code401时清空本地存储的Token跳转到登录页同时做一个静默刷新逻辑在用户操作频繁时提前用refreshToken换取新Token。这个方案比较复杂我的简化版是先不做refreshToken把Token过期时间设置长一点比如7天学生用户重新登录的成本很低。6. 性能优化与单元测试实战6.1 慢查询优化与索引设计系统做到后期用户量上来后慢SQL会逐渐成为主要瓶颈。校园外卖平台虽然并发量不算极端但是订单表、订单明细表的数据量增长很快商家列表页的查询涉及多张表关联和排序如果不加索引很快会见到“查询接口耗时超过3秒”这种日志。我总结的索引设计原则第一WHERE条件里最频繁使用的字段建索引第二ORDER BY的字段尽量也进索引第三索引列要选择区分度高的列比如订单状态只有1-6这种低区分度字段建了索引也没有用MySQL优化器会放弃索引走全表扫描第四联合索引要遵守最左前缀原则。订单表最常用查询是“某商家的待接单订单”索引就是(seller_id, status)联合索引查询效率提升了5倍多。菜品表最常用“某商家的上架菜品”联合索引(dish_seller_id, status)。调试SQL时在MySQL命令行执行EXPLAIN SELECT...看type列如果是ALL表示全表扫描必须优化如果是ref或者range说明合理使用索引如果是const说明是主键查询最优。还有个经验SQL里避免SELECT *只查需要的列MySQL传输数据量小解析时间也短。6.2 SpringBoot单元测试最佳实践单元测试是很多自学项目最容易跳过的环节但如果你想把项目写进简历面试官几乎必问“你怎么保证代码质量”这块不能糊弄。我的做法是Controller、Service、Mapper三层分别测试重点测Service层的业务逻辑和事务回滚。先引入测试依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependencyService层测试示例验证下单成功后库存扣减SpringBootTest Transactional // 测试结束自动回滚不会污染数据库 class OrderServiceTest { Autowired private OrderService orderService; Test void testCreateOrderSuccess() { CreateOrderDTO dto new CreateOrderDTO(); dto.setAddressId(1L); dto.setUserId(10001L); OrderVO orderVO orderService.createOrder(dto); Assertions.assertNotNull(orderVO.getOrderNo()); Assertions.assertEquals(new BigDecimal(38.80), orderVO.getTotalAmount()); } }这里有个很重要的设计测试的数据库和生产数据库必须隔离我新建一个campus_order_test数据库application-test.yml里配置测试库地址和Redis地址。运行测试时指定--spring.profiles.activetest。我建议优先测试核心业务方法的下单幂等性、库存扣减不超卖、取消订单状态流转换。这些用例就是你的系统最核心的保险锁以后改代码有没有破坏原有逻辑一跑测试全知道。6.3 系统安全加固与常见漏洞防范校园外卖系统涉及真实用户数据和支付流程安全问题不能疏忽。我从代码层面做了几个关键加固。SQL注入防范MyBatis的#{}写法会使用PreparedStatement参数化不会注入但${}字符串拼接直接拼SQL语句就危险了。项目规范定死所有动态SQL一律用#{}禁止${}拼接任何用户输入。XSS攻击防范用户在店铺公告、菜品描述等文本域输入的内容可能包含
返回列表