做了几年Java全栈,前后端分离的项目也带过不少,但真正从零开始完整走一遍“SpringBoot + Vue3 + MyBatis + MySQL”的手机商城系统,还是最近这版欢迪迈手机商城项目让我觉得最值得复盘。手机商城这个品类比普通电商多了不少细节:不同颜色、不同存储版本组合成的SKU、库存校验、规格切换、购物车到订单的状态流转,每一处都不是“增删改查”四个字能糊弄过去的。这篇文章不是教程的说明书复述,而是把我在开发过程中真正踩过的坑、想明白的原理、反复调整过的设计决策写出来,希望对正在做同类项目或准备系统学习前后端分离的同学有用。
1. 为什么这套系统要做前后端分离:一次请求的完整旅行
1.1 一条数据从浏览器到数据库,中间经历了什么
很多刚接触全栈的同学会把“前后端分离”理解成“前端写页面、后端写接口”,这个理解不算错,但太粗了。欢迪迈手机商城的前端是Vue3工程,跑在Vite开发服务器上,后端是SpringBoot应用,跑在独立的Tomcat端口,两者通过HTTP接口通信,前端页面不直接访问数据库,后端也完全不关心页面长什么样。
拿“用户浏览手机列表”这个最普通的功能举例,完整链路是这样的:用户在Vue组件里触发请求,Axios把配置好的BaseURL拼上路径,通常是/api/product/page这样的形式,开发环境下经过Vite的proxy代理转给后端;后端先经过SpringMVC的DispatcherServlet,进入我们配置的拦截器校验Token,通过之后路由到ProductController;Controller调用Service,Service调用Mapper接口,Mapper的XML文件里写好的SQL去MySQL查数据;查询结果一层层包装成统一返回结构R对象,序列化成JSON回到前端;前端拿到数据后更新响应式变量,页面渲染出商品卡片列表。
这个链路里最容易出错的地方不是某一个环节,而是环节之间的“契约”。前端以为后端返回的是data.list,后端返的字段叫records,页面就白屏;后端把价格字段设计成String类型,前端要做数学运算,就会踩到类型转换的坑。我在这套手机商城里定了一个规矩:所有接口的返回格式统一,code表示状态,message放提示信息,data里放业务数据,分页数据固定用total和list这两个字段名。这个约定比任何技术选型都重要,前后端分离的本质其实是“契约先行”。
1.2 SpringBoot + Vue3 + MyBatis这套组合,取舍在哪里
技术选型上,我见过很多人纠结要不要用更重的框架,比如后端上SpringCloud、前端上全套后台管理模板、持久层直接上MyBatis-Plus。欢迪迈手机商城这个项目定位明确:单体应用、前后端分离、要能跑通完整业务闭环,所以后端用纯SpringBoot就够了,不需要分布式那套东西。
SpringBoot的优势是“约定大于配置”,一个spring-boot-starter-web搞定Web环境,内置Tomcat让部署变得极简,一个java -jar就能跑起来。对于商城系统这种场景,它的生态成熟度是最重要的,市面上能找到的踩坑资料基本都覆盖了,遇到问题搜一下比什么都强。
Vue3这边,我选择了组合式API的写法,配合<script setup>语法糖,代码量比选项式API少很多,逻辑组织也更清晰。比如商品详情页里既有规格选择逻辑、又有加入购物车逻辑、还有价格计算逻辑,如果用选项式API,这些逻辑会分散在data、methods、watch里,维护起来很割裂。用组合式API,我可以把“规格选择”封装成一个useSkuSelector函数,每个页面只关心自己需要的逻辑块。
MyBatis这块,很多人问为什么不用MyBatis-Plus。我的回答是:商城系统的SQL很多是动态查询和复杂的多表关联,MyBatis-Plus虽然能省去简单的单表CRUD,但实际业务里我更想要对SQL的完全控制。比如商品列表页要根据品牌、价格区间、排序方式动态拼接SQL,还需要处理<if>标签里的空值判断,手写XML比框架封装的LambdaQueryWrapper更直观。而且对于想提升功底的同学来说,手写SQL本身就是必修课,MyBatis的XML文件是学习SQL优化最好的练习场。
1.3 工程目录怎么切分,才能让前后端各干各的又不脱节
欢迪迈手机商城在目录结构上从一开始就做了严格区分:仓库根目录下分frontend和backend两个独立工程,互不嵌套,各有各的依赖管理和配置文件。前端目录按src/api、src/views、src/components、src/store、src/router分层,后端按controller、service、mapper、entity、config分包。
这里有个实际开发中的经验:前后端一定要各配一套启动文档和接口文档。别觉得麻烦,一个人开发的时候确实无所谓,但如果未来有新同学接手,或者你自己隔了两个月再回来看,没有文档真的会一头雾水。我给这套系统写过一个简单的接口文档,用表格记录每个接口的请求方式、路径、参数、返回示例,前后端联调的时候打开对照着看,效率至少提升一倍。
配置方面,后端的application.yml里分了三套环境:dev、prod,用spring.profiles.active切换。前端则通过.env.development和.env.production管理环境变量,VITE_API_BASE_URL指向不同的后端地址。这里最容易踩的坑是前端把后端地址写死成localhost:8080,结果部署到服务器上之后所有接口都404,因为部署环境的域名变了。环境变量分离这件事,从项目第一天就要做好。
2. 手机这个品类的数据库设计:SPU、SKU和规格参数的建模思路
2.1 手机商城和普通商城的最大区别,藏在商品模型里
普通商品,比如一件T恤,数据库里可能一行商品记录就够了:商品名、价格、库存、图片。手机不行。一台手机有品牌、型号、颜色、运行内存、存储空间五个维度,用户选“黑色+8GB+256GB”和“蓝色+12GB+512GB”,是两个不同的库存单位,价格也可能不同。这就引出了电商系统里非常经典的一对概念:SPU和SKU。
SPU(Standard Product Unit)是标准化产品单元,可以理解成“手机的型号本身”,比如某品牌旗舰机,它不区分颜色和版本;SKU(Stock Keeping Unit)是库存保有单位,是用户真正能下单购买的最小库存单元,比如“某品牌旗舰机-黑色-8GB-256GB”就是一个SKU。数据库中SPU和SKU是典型的一对多关系:一个SPU下有多个SKU,每个SKU有独立的库存、价格、图片和规格属性组合。
我在设计欢迪迈手机商城的商品表时,第一版只建了一张product表,把所有规格用逗号拼接存在一个spec字段里,这样做的代码写起来特别快,但很快就暴露问题了:用户下单选规格时没法精确锁定库存,后台管理要改某个颜色版本的库存就得解析字符串,更别提按规格维度做数据统计了。我后来推倒重来,才把SPU和SKU彻底分开。
2.2 商品模块的表结构:从SPU表到SKU库存表怎么拆
这套系统里商品相关的表一共五张,我用一个表格把它们的关系说清楚。
| 表名 | 职责 | 关键字段 |
|---|---|---|
| brand | 品牌表 | id、品牌名称、LOGO图片 |
| category | 分类表 | id、父级分类id、分类名称、层级 |
| product | SPU商品表 | id、品牌id、分类id、商品名称、主图、详情富文本、上下架状态 |
| product_spec_item | 规格项表 | id、商品id、规格名称(颜色/版本/内存)、规格值列表 |
| sku | SKU表 | id、商品id、规格组合快照、价格、原价、库存、SKU图片 |
规格项表看起来有点绕,我解释一下设计逻辑。每个手机商品有三个规格维度:颜色、内存、存储空间,每个维度有不同的值,比如颜色有黑色、白色、蓝色,内存有8GB、12GB,存储有128GB、256GB。product_spec_item表的spec_values字段用JSON存储该维度下的所有可选值,比如["黑色","白色","蓝色"]。SKU表里的spec_snapshot字段则记录这个SKU具体是哪种组合,比如{"颜色":"黑色","内存":"8GB","存储":"256GB"}。
这样设计的好处是通用性强:将来加一个“充电套装版本”的规格维度,只需要在规格项表里加一行,SKU表动态扩展组合即可,不用改表结构。前端详情页渲染规格选择器的时候,也是先读规格项表获取可选维度,再把当前SKU的组合快照和用户选择做匹配,找出匹配到哪个SKU、显示对应价格和库存。
2.3 购物车和订单表:为什么购物车可以不实时扣库存
购物车表结构相对简单,核心字段是用户id、SKU id、购买数量、勾选状态、加入时间。购物车的最关键设计决策是:它只是一个“意向收集器”,不承担库存校验和价格锁定职责。用户在购物车页面看到的价格和库存只是当前时刻的展示值,真正下单时后端必须重新校验SKU库存和价格。这样设计的原因是购物车数据可能长期存放,用户一个月前加入购物车的商品现在可能已经缺货或调价,如果购物车数据作为下单依据,会带来严重的超卖和价格偏差风险。
订单表的设计则要更谨慎,因为订单是交易闭环的核心。订单主表记录订单编号、用户id、总金额、订单状态、收货地址快照、创建时间;订单明细表记录每个SKU的商品名称、购买时单价、购买数量、规格快照。这里有个关键点:订单里记录的商品信息必须是“快照”,不能关联查询SKU表的当前值。原因很简单,商品可能会改名、改价、甚至下架,但用户的订单凭证必须永远保留下单那一刻的真实信息。
订单状态我用一个整数字段status表示,配合状态字典解释。常用的状态机是:待支付(0)→ 待发货(1)→ 已发货(2)→ 已完成(3),另外有已取消(-1)。支付回调后状态从0变1,发货操作后从1变2,确认收货后从2变3。这里要特别注意,状态流转不能只靠前端按钮控制,后端每个状态变更接口都要校验“当前状态是否允许跳转到目标状态”,比如已取消的订单不能再发货,已完成的订单不能重复取消。
2.4 建索引和事务设计的经验:哪些索引看似合理其实是浪费
数据库设计阶段我踩过一个自认为“很懂”的坑:给所有外键字段都建了索引,结果数据量还没起来,写入速度先受到了影响。后来看慢查询日志才发现,有些字段在查询条件里几乎用不到,索引纯粹是摆设。
在欢迪迈手机商城里,我梳理了几条真正有效的索引规则。第一,订单表的用户id和订单状态组合索引必须建,因为“查某用户的订单列表并按状态筛选”是最常用的查询;第二,SKU表的商品id索引必建,因为商品详情页要一次性查该商品下所有SKU;第三,商品表的品牌id和分类id索引要根据实际查询频率决定,如果后台管理页面经常按品牌和分类组合筛选,那就建组合索引(brand_id, category_id),如果分开筛选更多,就各建单列索引;第四,购物车表的用户id索引必建,但不需要和SKU id建组合索引,因为购物车列表查询条件基本只有用户id一个维度。
事务设计方面,下单接口是整个系统里最需要谨慎的地方。下单动作涉及的操作包括:校验库存、创建订单主表记录、插入订单明细、扣减SKU库存、清空购物车对应项,这五步必须在一个事务内完成,任何一步失败都要回滚。我用@Transactional注解标注在Service方法上,并通过抛出RuntimeException触发回滚。这个过程中最容易出错的是“内部方法调用导致事务失效”,因为Spring的事务是基于AOP代理实现的,同类内部直接this.method()调用不会经过代理对象,注解就失效了。解决办法是事务方法放在独立的Service类中,或者通过注入自身代理来调用,最稳妥的做法是把事务边界放在入口Service的方法上,内部方法不加注解、只抛异常。
3. 后端从登录到下单:JWT、SQL拼接与库存扣减的完整闭环
3.1 JWT登录鉴权:拦截器放行哪些路径,必须想清楚
用户注册登录是商城系统的第一个业务闭环。注册接口接收手机号和密码,后端校验手机号格式和是否已注册,密码用BCrypt加密后写入用户表;登录接口校验密码成功后,生成JWT(JSON Web Token)返回给前端,前端存到本地存储里,后续每个请求在请求头带上Authorization: Bearer <token>。
JWT本身由三部分组成:Header、Payload、Signature。Header声明加密算法,Payload放用户id、用户名、过期时间等自定义声明,Signature用后端配置的密钥对前两部分签名。后端接收到Token后,用同一个密钥验证签名是否有效、过期时间是否已过,然后从Payload里取出用户id,放入ThreadLocal中供后续业务逻辑使用。
拦截器里有一个必须仔细思考的配置:哪些路径需要登录,哪些路径放行。欢迪迈手机商城的放行规则是:登录注册接口、商品列表查询接口、商品详情接口、首页轮播图接口、公共图片资源路径全部放行;购物车查询、添加购物车、提交订单、订单列表等接口必须校验登录。这里容易犯的错误是把所有接口都拦截,结果前端页面连商品都刷不出来;反过来,如果把购物车相关接口也放行了,那用户未登录也能操作购物车,逻辑上会很混乱。我最终的方案是写一个白名单数组,在拦截器的preHandle方法里先判断请求路径是否在白名单中,如果是就直接返回true。
3.2 商品列表查询:动态SQL怎么拼才安全又灵活
商品列表是用户打开商城看到的第一个核心页面,通常带筛选条件:品牌、价格区间、排序方式、分页。手写SQL的时候,MyBatis的XML给了很大的灵活性,但也给了犯错的空间。
<select id="selectProductPage" resultType="com.example.entity.ProductVO"> SELECT p.id, p.name, p.main_image, p.min_price, b.name AS brand_name FROM product p LEFT JOIN brand b ON p.brand_id = b.id <where> <if test="brandId != null"> AND p.brand_id = #{brandId} </if> <if test="minPrice != null"> AND p.min_price >= #{minPrice} </if> <if test="maxPrice != null"> AND p.min_price <= #{maxPrice} </if> <if test="keyword != null and keyword != ''"> AND (p.name LIKE CONCAT('%', #{keyword}, '%')) </if> </where> <choose> <when test="sort == 'priceAsc'"> ORDER BY p.min_price ASC </when> <when test="sort == 'priceDesc'"> ORDER BY p.min_price DESC </when> <otherwise> ORDER BY p.id DESC </otherwise> </choose> LIMIT #{offset}, #{pageSize} </select><where>标签的作用是去掉第一个条件前的AND,很省心;<choose>实现多分支排序逻辑;价格比较用XML转义符>=和<=是因为XML里不能直接写>、<符号。这里我特别强调一点:排序字段绝对不能通过字符串拼接进SQL,比如前端传个sort=name; DROP TABLE product,如果直接拼进SQL就是灾难。正确做法是用<choose>限定排序字段的白名单值,前端怎么传都只能落到指定的几个字段上。
商品详情页是另一个比较复杂的地方。一个页面需要同时返回基础信息和SKU列表,基础信息来自product表,SKU列表来自sku表按商品id查询。我写了一个selectProductDetail方法,单独处理多表关联和字段映射,比在Controller里查两次再手动组装更清晰。Vue端拿到数据后,需要把SKU列表存进响应式变量,规格选择器根据SKU的高亮状态判断某个规格组合是否可选,这些都要求接口返回的结构保持稳定,所以在VO类里我直接建了ProductDetailVO,字段按前端页面的渲染需要来命名。
3.3 从购物车到订单:库存扣减必须用条件更新的原子性
下单接口是这套系统里最核心、也最容易出问题的部分。用户在购物车勾选若干商品,点击“去结算”后,前端把勾选的购物车项id列表传给后端,后端重新从数据库查这些购物车项的完整信息,逐个校验SKU库存,计算总价,然后创建订单。
库存扣减我用的是“条件更新”的方式,SQL写得很讲究:
UPDATE sku SET stock = stock - #{quantity} WHERE id = #{skuId} AND stock >= #{quantity}这条SQL的巧妙之处在于,AND stock >= #{quantity}条件确保了只有在库存充足时才会执行扣减。如果本次更新影响的行数为0,说明要么SKU不存在、要么库存不足,后端捕获到这个结果后就抛出库存不足的异常,触发事务回滚。这种方式天然具备原子性,不需要额外加锁。
为什么不先SELECT stock再UPDATE stock?因为两层操作之间可能被其他请求穿插,造成超卖。悲观锁和乐观锁我也对比过:悲观锁SELECT ... FOR UPDATE在并发高时会降低吞吐,乐观锁需要额外维护版本号字段,而条件更新用一行SQL既保证了正确性又足够轻量,是这个场景下最合适的选择。
3.4 事务回滚的几个“阴沟”:异常被吞掉、类内自调用
下单过程中我遇到过两次事务不回滚的问题,都是很隐蔽的原因。第一次是异常被代码块吞掉了:Service方法里我用try-catch把业务逻辑包起来,捕获到异常后打印了一行日志,然后return了。结果库存确实扣了,但订单没创建成功,事务管理器根本没有收到异常信号,回滚自然不会发生。后来我把try-catch去掉,只在最外层Controller用全局异常处理器处理,事务就恢复正常了。
第二次是事务方法的类内自调用。我在OrderServiceImpl里写了一个私有方法createOrder,把下单的核心逻辑放进去,然后在公开方法里直接调用this.createOrder(...),@Transactional注解加在私有方法上。结果事务压根没生效,因为Spring的AOP代理拦截不到私有方法,也拦截不到类内部直接调用。解决办法是把需要事务的代码单独拆成另一个Service类,或者用注入自身代理的方式调用,我最终选了前者,逻辑也更清晰。
金融级的事务正确性,最终靠的是“把所有写操作放进一个方法、任何异常直接抛出、事务边界明确”这三条朴素的规则。这个经验在后来的订单支付回调、商品批量上下架等场景里都复用上了。
4. Vue3前端工程:Pinia状态、路由守卫和手机规格切换的实战细节
4.1 Vite工程搭建的目录分层和管理规则
前端工程我用Vite初始化,选Vue3 + TypeScript的组合。TypeScript在中小型项目里可能显得有点繁琐,但商城系统的API请求参数和返回数据结构都比较固定,类型定义反而是一种文档。我把类型声明集中在src/types目录下,包括User、Product、Sku、CartItem、Order等接口定义,和后端的实体类一一对应。
src/api目录下按业务模块拆文件:auth.ts管登录注册、product.ts管商品、cart.ts管购物车、order.ts管订单。每个文件导出一个或多个API函数,内部调用封装好的request实例。这样做的好处是页面组件完全不关心请求细节,只需要调用对应的API函数并处理返回的Promise即可。
src/router里配置路由表,路由路径和页面组件的对应关系要清晰。登录页/login、注册页/register、首页/、商品分类页/category、商品详情页/product/:id、购物车页/cart、订单页/order。路由懒加载用() => import('@/views/xxx.vue'),首屏不会加载全部页面代码,性能更好。
4.2 Pinia状态管理与Axios拦截器:登录态失效要统一处理
Pinia是Vue3官方推荐的状态管理库,比Vuex更简洁。我在欢迪迈手机商城里建了两个Store:userStore存用户信息、Token、登录状态,cartStore存购物车数量和购物车数据。
userStore的核心是login和logout两个动作。登录成功后调userStore.setUserInfo(userInfo),把用户数据存进Pinia,同时写入localStorage持久化。刷新页面时,Pinia内存中的数据会丢失,所以在App.vue的onMounted生命周期里做一次恢复:先从localStorage读Token和用户信息,再调后端接口刷新用户数据。
Axios拦截器是前端工程里最容易体现专业度的地方。请求拦截器统一做三件事:把Token加到请求头、开启加载状态、对请求参数做基础处理。响应拦截器统一处理三件事:解构后端的code字段判断业务结果、遇到401状态码清除本地登录态并跳转登录页、遇到网络错误或超时显示统一提示。
这里有个细节我要多说一句:后端返回的code字段和HTTP状态码是两个层面的东西。HTTP 200只代表网络请求成功,不代表业务成功;业务失败时我设计的返回结构是{"code": 500, "message": "库存不足", "data": null},但HTTP状态码依然是200。响应拦截器里判断业务成功与否的唯一依据就是code,不能写“res.status === 200就代表成功”这种逻辑。
4.3 路由守卫的角色:未登录用户别想溜进购物车
商城系统的路由权限控制用Vue Router的全局前置守卫实现。router.beforeEach回调里拿到目标路由的meta信息,判断是否需要登录权限,再结合userStore里的登录态做跳转。
router.beforeEach((to, from, next) => { const userStore = useUserStore() const requiresAuth = to.matched.some(record => record.meta.requiresAuth) const token = userStore.token || localStorage.getItem('token') if (requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else if (token && (to.path === '/login' || to.path === '/register')) { next('/') } else { next() } })登录后要跳回来源页,这个细节很贴心。用户在商品详情页点“立即购买”被重定向到登录页,登录成功后如果没有redirect参数,会回到首页,用户得重新找一个商品,体验很糟糕。现在登录页的onSubmit方法里判断route.query.redirect是否存在,存在就跳回去,不存在才跳首页。
4.4 手机详情页的规格切换逻辑:从选中的规格值匹配SKU
手机详情页的规格选择是前端交互里最复杂的部分,市面上大多数商城都是这么做的。我的实现思路是:接口返回SKU列表后,前端按规格维度分组建一个可选项集合,用户点某个颜色,已选集合里就多一个{"颜色":"黑色"},然后遍历所有SKU,筛选出所有规格属性都匹配的SKU,显示这个SKU的价格和库存。
核心代码大致是:
const selectedSpec = ref<Record<string, string>>({}) const matchedSku = computed(() => { return skuList.value.find(sku => { const snapshot = sku.specSnapshot return Object.entries(selectedSpec.value).every( ([key, value]) => snapshot[key] === value ) }) })这里的逻辑看起来简单,但实际要考虑两个问题。第一,用户只选了颜色、没选内存和存储时,怎么处理?我的做法是:只要未选满所有维度,就显示“请选择完整规格”,不允许加入购物车;已选维度对应的其他可选值要做禁用标记,避免用户选择无效组合。第二,每个规格维度的顺序必须固定,我按数据库里product_spec_item表的排序字段控制,前端渲染时也按这个顺序遍历。
购物车的数量累计要放在cartStore里,避免每个页面重复计算。加入购物车成功后,调用cartStore.fetchCartCount()更新角标数字,这个数字要显示在导航栏的购物车图标上,全局可见。
5. 联调、部署和优化:跨域、Nginx代理和真实环境里的坑
5.1 开发环境的跨域问题:Vite代理比什么都好使
前后端分离开发时,前端跑在5173端口,后端跑在8080端口,浏览器直接请求http://localhost:8080/api/product/page会被CORS策略拦截。大多数人第一时间想到的是在后端加@CrossOrigin注解或配置CorsFilter,这个方法可行,但我更推荐在前端Vite配置代理。
Vite的server.proxy配置里,把/api开头的请求代理到后端的8080端口:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端请求/api/product/page时,Vite开发服务器会把请求转发到http://localhost:8080/api/product/page,浏览器视角下请求是同源的,就不会触发跨域。生产环境则通过Nginx反向代理,把/api路径转发到后端服务的地址,下面讲部署时会提到。
5.2 前后端联调最容易踩的五个字段不一致
联调阶段的痛苦大多来自字段名对不上,我在欢迪迈手机商城项目里整理了五个高频问题,提前写进接口文档能少加很多班。
| 问题 | 前端习惯 | 后端习惯 | 统一结果 |
|---|---|---|---|
| 用户id字段名 | userId | id | 统一为userId |
| 商品主图字段名 | mainImage | img | 统一为mainImage |
| 时间格式 | 2024-01-01 12:00:00 | 2024-01-01T12:00:00 | 统一为字符串yyyy-MM-dd HH:mm:ss |
| 布尔值 | true/false | 1/0 | 统一为true/false |
| 金额类型 | number | BigDecimal | 统一序列化为number字符串,前端做精度处理 |
时间格式的坑最隐蔽,Jackson默认序列化LocalDateTime会带T,前端显示“2024-01-01T12:00:00”特别难看。我在application.yml里配置了全局时间格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8金额类型的问题也是老生常谈,BigDecimal序列化成JSON后可能变成科学计数法,我统一在后端转成字符串返回,前端用decimal.js做计算时再转回数值。
5.3 生产环境部署:Nginx替前端挡一层,顺手解决前端路由刷新404
部署方案我选择了最简单的单机部署:前端打包成静态文件,放在Nginx的web目录下;后端打成jar包,在服务器上用systemd守护进程托管;MySQL单独部署在同一台机器上。
前端构建命令是npm run build,产物在dist目录。把dist里的文件上传到服务器Nginx的/var/www/mobile-shop目录,然后配置Nginx的server块:
server { listen 80; server_name your-domain.com; root /var/www/mobile-shop; 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; } location / { try_files $uri $uri/ /index.html; } }try_files $uri $uri/ /index.html这一行是SPA部署的灵魂。Vue Router默认用history模式,路由路径是/product/3这种真实的URL,如果用户直接刷新这个页面,Nginx会去服务器找/product/3对应的文件,找不到就返回404。try_files把所有找不到的路径都回退到index.html,由前端路由接管,刷新问题就解决了。
还没完,location /api反向代理照样存在跨域的概念,只是现在代理方从Vite变成了Nginx。前端构建时VITE_API_BASE_URL要设置成空字符串或者/api,让所有请求都走相对路径,由Nginx分发,这样就不会出现前端把http://localhost:8080写进生产包里的低级错误。
后端jar包启动时也踩过内存不足的坑,默认的java -jar app.jar会去申请系统全部可用内存,小服务器上容易把内存撑爆。我在systemd配置文件的ExecStart里加了JVM参数:
ExecStart=/usr/bin/java -Xms256m -Xmx512m -jar /opt/mobile-shop/app.jar限制最大堆内存为512MB,实际运行下来非常稳定。图片来源方面,我先把商品图片上传到服务器某个目录,Nginx加一个/images的静态资源映射,MySQL里只存相对路径,这样图片不会挤占后端服务的带宽。
5.4 上线之后的简单性能优化与后续可扩展方向
系统跑起来之后,我做了三个低成本但收益明显的优化。第一个是MySQL的慢查询日志,开启后发现商品列表页的分页SQL在大偏移量时变慢,因为LIMIT 10000, 20会先扫描前面10000行再丢弃,我改用“延迟关联”优化:先只查主键id的分页,再关联查询完整数据,响应时间从几百毫秒降到几十毫秒。
第二个是图片懒加载。首页的商品列表一次性渲染20个卡片,每个卡片都有高清主图,用户首屏根本来不及看全部。我用v-lazy指令让图片进入视口才加载,白屏时间短了很多,移动端流量也省了。
第三个是把验证码从同步改为异步。早期注册登录页的图形验证码是后端同步生成再返回Base64,后来发现高并发下图片生成的线程很占用CPU,我改成预先生成验证码并缓存在本地临时存储,接口直接取,瓶颈少了一块。
后续如果想继续演进,方向其实很清晰:引入Redis做缓存(商品详情缓存、验证码存储、登录态存储),引入消息队列处理订单超过30分钟未支付的自动关闭,再用Elasticsearch替换MySQL里的商品模糊搜索。但我个人的建议是,先把当前系统的SQL逻辑和事务边界优化好,别在不成熟的阶段盲目引入分布式组件,很多小商城用单机MySQL + Redis就能撑住日均几千单,过度设计才是真正的浪费。
最后再说一个实际开发中的感受:像欢迪迈手机商城这类项目,真正的难点从来不是单个技术栈怎么用,而是当你把SpringBoot、Vue3、MyBatis、MySQL串在一起时,数据如何在每一层安全、准确地流转。这个项目让我把数据库建模、接口设计、事务管理、前端状态管理这些知识真正串成了一个整体。如果你也在写类似的商城系统,我建议先把SPU/SKU模型和下单库存扣减这两块吃透,它们比任何花哨功能都值得花时间。