ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零搭建SpringBoot+Vue美食分享平台:全栈实战与部署指南

从零搭建SpringBoot+Vue美食分享平台:全栈实战与部署指南

抓了十几套模板后,我决定自己从零写一个美食分享平台

先说结论:真正把一个 SpringBoot + Vue 的美食分享平台从零落地的过程,远比你看任何教程、套任何开源项目模板都要折腾。我最初也想找一个现成的 starter 项目改改就行,但翻了十几套模板后,要么是登录逻辑太简陋没法支撑多角色,要么是图片上传只做了本地临时方案、线上根本没法玩,要么代码架构乱到连@Autowired都随手往实体类里塞。折腾几天后我决定不偷懒了,自己从头把整个平台写一遍。

这篇文章我会完整记录这次从需求拆解、技术选型、数据库设计,到后端接口实现、Vue 前端落地,再到部署上线的全过程。每个关键步骤我都会讲清楚"为什么这么做",而不只是给你贴代码。如果你正准备做一个前后端分离的实战项目,或者想把美食分享这个方向做成自己的作品集项目,这篇文章可以直接当路线图来用。

平台最终能做到什么程度?三句话概括:用户能注册登录、浏览菜品、发布带图菜谱,能点赞、收藏、评论、关注作者;作者能看到自己发布内容的数据变化;管理员有一个轻量后台做分类管理和内容审核。技术侧是 SpringBoot 提供 RESTful API,Vue 负责页面交互,MySQL 存业务数据,Redis 做热点缓存和登录态刷新,Nginx 做部署层的静态资源服务和反向代理。功能规模不贪大,但是一条完整业务链路从头到尾是通的。

1. 需求拆解:先想清楚平台是给谁用、要解决什么问题

动手写代码之前,我花了大概一个晚上把需求理了一遍。这个过程特别容易被忽略——很多人上来就建表写接口,做到一半才发现要么多做了没人用的功能,要么漏了核心闭环。美食分享平台这种项目,名字听着简单,但"分享"背后其实牵扯出三条主链:内容的产生、内容的消费、内容生产者之间的连接。

1.1 三类核心角色决定了权限边界

我把平台用户分成三类:普通浏览者、注册用户、管理员。浏览者能看首页、菜品详情、搜索,但一旦想点赞、收藏、评论、发布,就必须登录。注册用户是所有互动行为的主体,可以发布菜品、管理自己的作品、关注其他作者。管理员的权限则聚焦在内容安全和分类维护上,不需要参与美食内容的创作。

这个角色划分直接影响后端的技术选型——你需要一套能区分"是否需要登录"和"登录后是谁"的鉴权机制,所以我最终选择了 JWT 配合 Spring 拦截器来实现,而不是传统的 Session 方案。前后端分离的项目用 Session 会有跨域场景下的 cookie 处理问题,JWT 无状态、天然适配分布式部署,后续想加网关做鉴权也比较顺。

1.2 功能清单按闭环划分

我给自己列了一个最小可用功能集,原则是:每个功能都必须能在业务链路里找到存在理由,不做摆设。

  • 账号体系:注册、登录、退出、个人信息查看与编辑
  • 内容展示:首页推荐流、分类浏览、菜品详情、搜索
  • 内容生产:发布菜品(标题、封面、分类、食材、步骤说明、多图)、编辑已发布内容
  • 互动体系:点赞、取消点赞、收藏、取消收藏、评论、评论删除、关注与取关
  • 数据统计:用户主页的发布数、获赞数、收藏数、粉丝数
  • 后台管理:分类管理、用户状态管理、内容审核与下架

我特意没有做私信和聊天功能,因为那会引入 WebSocket、消息持久化一套复杂度,对当前阶段的价值不算大。先保证核心链路完整,搭建的扩展点再留出来更重要。

1.3 信息架构和页面跳转关系

从一个普通用户的视角,我把核心的页面路径画了一遍(放心,不用画图工具也能说明白):

用户打开首页 → 看到推荐菜品信息流 → 点击某个菜品进到详情页 → 看到食材表、步骤说明、作者信息 → 登录后可以点赞/收藏/评论 → 点作者头像进个人主页 → 看到作者发布的历史菜品和获赞统计 → 决定是否关注作者。

发布者视角则是:个人中心 → 发布新菜品 → 填写表单、上传图片 → 发布成功回到菜品详情页 → 过一会儿在个人主页看到浏览量变化。

这套信息架构直接决定了数据库有哪几张表、前端有哪些路由、接口有哪些。我自己的经验是:先画这个"用户走查图",再去写接口,你只会越写越顺,不会前面写了后面推翻。

2. 技术选型:SpringBoot 与 Vue 的搭配逻辑,以及我踩过的坑

选 SpringBoot + Vue 这对组合,一定层面是被 Java 生态的稳定性和 Vue 的上手曲线共同驱动的。但真正做起来,你会发现单纯"选用框架"只是第一步,落到版本选择、配套工具链时,坑其实不少。

2.1 后端选型:SpringBoot 3.x 还是 2.x

我首选的 SpringBoot 版本是 3.2.x,因为它自带 Jakarta EE 规范,底层是 Spring Framework 6,对 JDK 17 的支持也更成熟。但这里我必须提示一个场景:如果你本地的 JDK 还是 8,或者你打算用的各类第三方 SDK(如某些服务商的存储 SDK)长期只维护 JDK8 版本,那勇闯 SpringBoot 3 反而会让自己难受。

我最后用的是 SpringBoot 3.2.5 + JDK 17。持久层框架选择 MyBatis-Plus 3.5.x 而不是 JPA,原因是 MyBatis-Plus 的单表 CRUD 封装足够省事,分页插件也好用,而且如果你团队的思维习惯是写 SQL、控制 SQL,它要比 Hibernate 的抽象更直观。JPA 在动态查询场景下写 Criteria 确实啰嗦。

配套组件我选了这些:

  • 数据库连接池:HikariCP(SpringBoot 默认,不用改)
  • 缓存:Redis 6.x,用 Spring Data Redis 的 StringRedisTemplate
  • 鉴权:JWT + 自研拦截器(不引入 spring-security,因为这个项目的权限模型比较轻,引入 security 反而多了大量配置成本和概念负担)
  • 接口文档:knife4j 基于 OpenAPI 3
  • 参数校验:spring-boot-starter-validation

有一点要特别提醒:MyBatis-Plus 3.5.x 和 SpringBoot 3 结合时,记得检查插件版本和mybatis-plus-spring-boot3-starter这个 artifactId 是带boot3后缀的,直接依赖旧版的mybatis-plus-boot-starter会出现各种扫描不到 mapper 的玄学问题。

2.2 前端选型:Vue 3 + Vite + Pinia

前端我选了 Vue 3.4 + Vite 5 + Pinia,而不是 Vue 2 生态。原因很直接:如果我今天从零开始学一个会持续用两三年的技术栈,没有理由去选一条快进入维护末期的路。Vite 的 dev server 启动和 HMR 热更新体验比 Webpack 好太多,配代理也简单,对本地联调是实打实的效率提升。

UI 组件库用的 Element Plus。这个方案对做管理后台和中后台系统很友好,表单、表格、弹窗这一类组件都有现成的。页面路由用 vue-router 4,HTTP 请求统一走 axios,状态管理用 Pinia(相比 Vuex 4 类型推导更好、写起来也更少样板代码)。

我需要强调一个前端工程化的细节:不要把所有接口请求函数都堆在某个.vue单文件内部。我会建议按模块拆成独立的api文件,每个文件对应一个后端 Controller 的领域(比如dish.js、user.js、comment.js),这样页面只负责交互渲染,数据获取逻辑可以复用。这个习惯越早养越好,页面一多,差距就很明显。

2.3 本地开发联调环境的坑

前后端分离开发时,跨域是绕不开的。我的做法是前端 Vite 配置 dev server 的 proxy,把/api前缀的请求代理到http://localhost:8080,这样前端代码里请求的 baseURL 就写成/api,浏览器不会出现跨域报错。后端方面则配置一个宽松的 CORS 过滤器,允许本地开发时从http://localhost:5173访问,生产环境有 Nginx 同源代理兜底,CORS 反而可以不放开。

这个步骤看起来琐碎,但我见过太多人栽在这里:前端报错跨域、后端已经配了@CrossOrigin结果还是报,其实是因为代理和后端 CORS 两层机制互相打架。我的经验是:同一时刻只让一种机制生效。本地开发用 Vite 代理,后端 CORS 只做兜底;生产环境全部交给 Nginx。

3. 数据库设计:从用户到菜品评论的表结构规划

数据库设计是整个项目中"一次想清楚、后期最省心"的环节。我花了比写接口还多的时间建表,因为表结构一旦定了,后面改起来是动筋骨的事。下面我把核心表逐一过一遍,并说明设计意图。

3.1 用户表与账户安全字段

用户表user是所有业务的起点,设计时不能只放用户名密码:

  • id:自增主键
  • username:用户名,唯一索引
  • password:BCrypt 加密后的密码哈希
  • avatar:头像图片 URL
  • bio:个人简介
  • role:用户角色(USER/ADMIN)
  • status:账号状态(ACTIVE/DISABLED),后台可以禁用违规账号
  • create_time、update_time:公共时间字段

密码存储我直接采用 Spring Security 里的BCryptPasswordEncoder(单独引入,不引入整套 security)来做哈希。BCrypt 内置随机盐,相同密码每次哈希结果不同,存储安全上有保障,即使数据库泄露,反向破解成本也高不少。另一个细节是用户名唯一索引的设置——不光是业务上不允许重复用户名,从数据库层面约束重复也能防止并发注册时出现脏数据。

3.2 菜品表与扩展信息表

菜品表dish是内容消费的核心:

  • id、user_id:作者 ID,外键逻辑关联
  • title:标题
  • description:简要描述
  • cover_image:封面图 URL
  • category_id:分类 ID
  • difficulty:难度(简单/中等/困难,用字符串枚举或数字标识)
  • cook_time:预计耗时
  • servings:建议食用人数
  • status:内容状态(DRAFT/PUBLISHED/OFFLINE)
  • view_count、like_count、favorite_count、comment_count:四个统计字段
  • create_time、update_time

这里我要说一个设计上的取舍:为什么把统计字段冗余在菜品表里,而不每次实时count?因为我清楚这个平台的读多写少,首页列表一次性展示了十几道菜,如果每道菜都实时 count 点赞、收藏、评论,那一次列表请求可能触发几十条 SQL。用冗余字段记录,展示层直接用,互动发生时用事务同步更新,这是用空间换时间的典型做法。代价是更新逻辑需要谨慎管理,比如重复点赞/取消点赞时,要保证计数只增减一次。

食材和步骤如果都存在主表里,字段会变成 JSON 存一大堆,既不好做校验,也对后期扩展不友好。所以我拆了两张表:

  • dish_step:步骤表,字段有id、dish_id、step_order、content、step_image
  • dish_ingredient:食材表,有id、dish_id、name、amount、sort_order

食材和步骤其实也可以用 JSON 字段一条数据顶掉,但拆成子表在后台审核、单步骤操作、后续想统计"常用食材"时都更灵活。我宁可在插入时多写几条 SQL 包在事务里,也不愿意把整个菜谱塞成一个大 JSON 字段。

3.3 互动表:点赞、收藏、评论与关注

互动的本质是关系数据,所以都用单独的表存储,关键在唯一索引。

点赞表dish_like:字段为id、user_id、dish_id、create_time,并建立user_id + dish_id的联合唯一索引。这个索引直接保证了一个用户对同一道菜只能点赞一次,数据库层面帮我们挡掉了并发重复操作,后端代码在新增记录前可以先查后插,但即使并发漏过了,数据库的约束也会把重复数据拦下来。

收藏表dish_favorite与点赞表结构类似。评论表comment则要稍微复杂一点:

  • id、dish_id、user_id、content
  • parent_id:父评论 ID,用于实现楼中楼回复,为0表示一级评论
  • reply_user_id:被回复的用户 ID,可选
  • create_time

我只支持了一级评论加回复的场景,没有做无限层级嵌套,因为无限嵌套的展示对用户来说并不一定友好,实现和维护成本却显著增加。我个人判断这个取舍是合理的,也符合很多内容产品的实际交互模型。

关注表user_follow也要说下:字段为id、follower_id、followee_id、create_time,联合唯一索引建立在follower_id + followee_id上。我要注意的一点是关注关系是单向的,A 关注 B 不等于 B 关注 A,所以表里用 follower/followee 两列来表达方向,别把语义搞反了。

3.4 分类表与标签的设计取舍

美食平台的分类应当浅而直接,我设计了category表:id、name、sort_order、icon。分类不搞父子层级,因为美食分享场景里的用户心智就是"川菜、粤菜、烘焙、甜品、家常菜"这样的平铺分类,层级过深反而让用户多一次点击。分类管理放在后台,管理员可以增删改。

我曾经考虑过引入标签tag系统来增加内容的交叉维度(比如"低卡""一人食""空气炸锅"),但这会给菜品发布页增加一道二次维护标签的成本。初期我先砍掉了标签系统,把分类做到足够。如果你后期想做个性化推荐,加一个dish_tag关系表并不困难,但要现在上线就能用,我会劝你不要把标签系统当作 MVP 的一部分。

4. 后端接口落地:从用户登录到菜品发布的完整链路

数据库设计完,接下来就是后端编码。我习惯按"先搭骨架,再逐领域填充"的方式来,也就是先把基础设施(通用返回结构、异常处理、JWT 认证)搞定,再写用户模块,再写业务模块,最后再做管理端接口。

4.1 通用返回结构与全局异常处理

前后端联调最心烦的事之一就是返回的数据格式不一致。深呼吸,排序是:这个接口返回{code: 0, data: ...},那个接口返回{success: true, result: ...},前端写起来想骂人。

所以我第一步就定义一个通用返回体R<T>:包含code(业务码)、message(提示信息)、data(数据),并提供静态方法R.ok(data)和R.fail(code, msg)。所有 Controller 方法都是返回R<T>,这样前端的 axios 响应拦截器就可以集中处理code,不用每个页面各自判断。

全局异常处理我用@RestControllerAdvice统一接管:业务异常BusinessException、参数校验异常MethodArgumentNotValidException、兜底的Exception。好处是 Controller 里业务逻辑不用每处都 try-catch 包裹,代码干净一大截,而且任何未知异常都会返回一个结构化 JSON,前端不会看到默认的错误页。

4.2 JWT 登录态:从登录接口到拦截器

登录接口我用/api/auth/login,接收用户名和密码,校验通过后返回一个 token。token 的生成我用了 jjwt 库(0.11.5 版本),负载里放userId和username,设置 24 小时过期时间。签发时使用一个配置在配置文件里的密钥,生产环境会通过环境变量注入,不会硬编码进源码。

JWT 的验证靠一个AuthInterceptor实现。它做三件事:

  • 从请求头Authorization: Bearer xxx里取 token
  • 解析并校验签名与过期时间
  • 把解析出来的userId放到ThreadLocal或直接放到 request attribute 里,供后续业务代码使用

这里有个细节非常值得注意:preHandle返回false时请求就中断了,所以要区分"哪些接口需要登录、哪些不需要"。我维护了一个白名单列表,包含登录注册和公开的浏览接口,白名单外的路径都需要有效 token。拦截器不能只做部分路径匹配就完事,还要考虑 SpringBoot 静态资源路径,否则前端的静态文件也会被拦截怼回去。

JWT 的痛点也有——无法主动让其失效。如果用户改了密码或者管理员封禁了账号,已签发的 token 在过期前仍然有效。我的初步方案是在用户表加一个tokenVersion字段,登录时把版本号放进 token 里,后续拦截器查库校验版本号是否一致,不一致就拒绝。这个方案每次请求都查一次库是有成本的,但对当前项目量级完全可行,而且给了我们主动下线用户的抓手。

4.3 发布菜品:多表事务与封面图处理

发布菜品是后端最典型的"多表写操作"场景,涉及:插入dish主记录、批量插入食材步骤、记录菜品图片。这三步必须在一个事务里,要么全成,要么全败,不能出现主记录有了、步骤却丢了的情况。

我的 Controller 接收一个DishCreateRequestDTO,包含标题、描述、封面图 URL、分类、难度、食材列表、步骤列表。Service 层方法标注@Transactional,先插dish拿到自增主键,再循环插入dish_ingredient和dish_step,图片的 URL 在前端上传接口返回后已经存好,这里只把 URL 字符串落库。

图片上传怎么做?我先做的方案是:前端把图片文件用multipart/form-dataPOST 到/api/upload/image,后端接收后校验文件类型(只允许 jpg/png/webp)和大小(限制在 5MB 内),然后把图片转存到服务器本地指定目录,返回一个静态可访问的 URL。这个方案在单机部署下是最简单的,不需要额外付费服务。生产环境我把这个目录放到 Nginx 直接托管的静态目录下,访问图片的路径由 Nginx 直接返回文件,不走 Java 应用,缓解应用服务器的 IO 压力。

上传接口必须校验登录态,不然就是别人往你服务器扔文件的漏洞。我在拦截器白名单里没有放/api/upload/**,就是有意让所有上传操作必须带着 token 才能访问。

4.4 原生点赞/收藏接口的幂等设计

点赞接口/api/dish/{id}/like的第一次实现我写得很单纯,就是先查记录是否存在,不存在就插入,存在就提示"已经点过了"。但把并发场景想进去后,我意识到这个判断会有问题——两个请求同时进来,都可能查到"不存在",然后都去插入,虽然数据库唯一索引会拦住一个,但被拦住的那个到底是报错还是跳过,逻辑不好看。

更稳妥的做法是:直接插入,依赖数据库唯一索引去重,在捕获到DuplicateKeyException时说明用户已经点过赞,不做任何处理直接返回成功。同时用事务来维护dish表上的like_count:新增点赞时+1,取消点赞时-1。这个语义上更干净,也不容易写乱。

同理,收藏的favorite_count维护也是一样的思路。cancel 操作要先确认记录存在,避免用户点了取消、结果把一个不存在的记录-1,把计数搞成负数。我在 Service 里专门处理了这种"先查后改"的防御逻辑,并且对-1的操作增加了结果校验,防止出现负数污染展示数据。

4.5 评论与关注接口的边界问题

评论接口相对简单,核心校验是内容不能为空且长度限制在 500 字内,同时评论的菜品必须存在且不是草稿状态。我通过 DTO 校验注解就完成了非空和长度控制,Controller 里只做业务判断。

关注接口有一个真正容易踩的坑——不能自己关注自己。我当时写的第一版就漏了这个判断,结果自己关注自己之后,个人主页的粉丝数多了一个,用户在 UI 上怎么看都不对。修正就是把followerId和followeeId相同的情况直接抛业务异常,提示语"不能关注自己"。

关注和取关的另外一点是异步或者非异步的问题。对当前量级,我直接同步处理没问题,但如果后期数据大了,关注列表、粉丝列表的查询要记得分页并及时利用索引,follower_id和followee_id上都建议建独立索引。

5. 前端页面实现:Vue3 的项目骨架与关键交互

后端接口准备得差不多后,前端实际搭建我反而觉得更考验细节。这里的老话题是:页面交互要给用户明确的反馈,加载状态、空数据状态、错误状态一个都不能少,否则功能能用,体验却很"原生态"。

5.1 前端项目目录结构

我搭的项目结构大致是这样的:

  • src/api:按领域拆分的请求函数
  • src/router:路由配置与导航守卫
  • src/store:Pinia 状态管理
  • src/views:页面组件
  • src/components:可复用组件
  • src/utils:工具函数(axios 实例、token 存取等)

src/utils/request.js里创建一个 axios 实例,配置baseURL: '/api',设置请求拦截器从 localStorage 取 token 塞进Authorizationheader,响应拦截器统一处理code,遇到 401 则清除 token 并跳转登录页。这个 axios 封装是整个前端和所有后端接口对话的入口,它的质量决定了前端联调时的心情。

Pinia 里我主要维护两个 store:userStore保存当前登录用户信息和登录状态,appStore保存全局布局信息如侧边栏折叠状态。userStore会在应用启动时尝试调用/api/auth/profile拿到用户信息,若有 token 则实现"登录态自动恢复",刷新页面后用户依然是登录状态。

5.2 首页信息流与卡片设计

首页是信息流展示,我从后端请求GET /api/dish/page?pageNum=1&pageSize=12,拿到分页数据后渲染成卡片网格。每张卡片展示封面图、标题、分类标签、难度和预计耗时。

这个页面有四个需要前端注意的点:

一是图片加载优化。封面图如果不做任何处理,大图全尺寸加载会严重影响首页速度。我对上传的图片做了一套前端预压缩逻辑:前端在上传前用 canvas 把图片最长边压到 1200px,质量系数设为 0.8,这样一张手机拍出来的 3MB 照片能压到 300KB 左右,上传快,加载也快。

二是分页加载。我采用"加载更多"按钮而不是无限滚动。无限滚动在移动端体验好,但在桌面端容易让用户迷失,而且实现复杂度更高。加载更多配合一个loading状态和hasMore判断,逻辑非常直白。

三是骨架屏。Element Plus 的el-skeleton组件可以替代传统的 loading 转圈,在图片没加载出来时先占位,视觉上平滑很多。实现不难,但对用户体验的提升很直观。

四是路由跳转。卡片点击跳转到/dish/:id详情页,跳转前应该把该菜品的 ID 传给详情接口。如果用户是从首页点进来的,详情页组件挂载后,从当前路由参数取 ID,再调用GET /api/dish/{id}/detail获取详情。这个流程简单,但要注意一个细节:详情页如果复用了同一个组件实例(比如从详情页跳到相似菜品的详情页),需要用 watch 监听路由参数变化来重新请求数据,否则页面会显示上一个菜品的信息,这个 bug 我在开发时真的踩过。

5.3 菜品详情页:发布者视角与互动面板

详情页是用户停留最久的页面。它的信息层级我安排为:封面大图 + 标题 + 作者信息卡 + 描述 + 食材表 + 分步骤说明 + 评论区。

作者信息卡需要展示作者头像、用户名、粉丝数,以及"关注"和"私信"按钮——我暂时只做了关注按钮。核心交互是:页面加载后,如果当前用户已登录,前端调用接口查询"我是否已关注该作者"、"我是否已点赞/收藏该菜品",用返回值控制按钮的展示状态。如果未登录,则点击这些按钮会引导跳转到登录页。

这里有一个体验细节:点赞和收藏按钮的状态切换要尽量即时。用户的点击触发了后端接口,在接口返回期间前端就可以先做一次"乐观更新"(按钮变红、计数 +1),接口失败再回滚。因为我接口的幂等设计已经保证了服务端语义,所以乐观更新是安全的。如果每次点击都傻等接口返回再改 UI,在慢网络下用户会感觉按钮"不跟手"。

评论区我做了简单的一级评论和回复。提交评论后,直接把返回的新评论对象插入评论列表头部,而不是重新拉一遍整个列表,这样既省流量又不会有闪屏。删除评论则需要校验当前用户是否为评论作者或菜品作者,否则请求会返回无权限错误。

5.4 发布菜品页面:多图片上传与表单校验

发布页是操作复杂度最高的表单页。字段有:标题、描述、分类下拉、难度、预计耗时、封面图、食材列表(动态增删行)、步骤列表(动态增删行,每个步骤可选配图)。

封面图我单独用一个el-upload组件,限制只能传一张,上传成功后把返回的 URL 存进表单对象。食材和步骤是动态行,我用el-table加一个"添加行"按钮来控制。每一行是一个对象,提交前前端先校验所有行必填项是否都填了,不能出现用户加了一行空的食材就提交。

表单校验的另一个注意点:分类必须在系统分类列表里合法,不能传入随机数字。前端用一个 API 在页面加载时获取所有分类列表,渲染成下拉选项,这样用户不可能选出非法分类。当后端管理员新增分类后,前端刷新页面会自动拿到新分类。

发布成功的跳转逻辑一定要处理好。我在提交按钮的 loading 结束后,会调用router.push('/dish/' + res.data.id)直接跳到新发布的菜品详情页。如果跳到个人中心或首页,用户往往会困惑"我的内容发到哪去了",跳到详情页反而是最直观的反馈。

5.5 路由权限与导航守卫

前端路由的权限控制是"体验上的安全边界"。我用 vue-router 的全局前置守卫做登录校验:

  • meta: { requiresAuth: true }的路由(发布页、个人中心、后台管理)在访问时检查是否有 token
  • 无 token 则跳转登录页,并带上redirect参数,登录成功后可以跳回原目标页
  • 后台管理的路由还要额外检查用户角色是否为ADMIN,这里需要调用用户信息接口判断

为什么说这只是"体验上的安全边界"?因为真正的安全控制永远在后端 Interceptor 上。前端的路由守卫只是避免让未登录用户进入一个会报 401 的页面,不能只依赖它来做权限保护。

6. 首页接口性能优化与搜索:从 Redis 缓存到索引调优

平台的基本功能够跑通之后,我开始关注性能。美食分享平台的流量峰值通常出现在饭前时段,用户会集中刷首页和详情页,如果没有缓存和索引,数据库要扛的压力并不小。我在这个阶段做了三块优化。

6.1 Redis 缓存菜品列表与热点详情

首页推荐流的查询逻辑是:查询dish表中状态为PUBLISHED的记录,按创建时间倒序分页,关联分类名、作者名。一开始这个查询每被访问一次都会 JOIN 两张表,用户量一大,响应时间会明显上涨。

我引入 Redis 的策略是:首页第一页数据(按创建时间倒序的最新 12 条)缓存到 Redis,key 设为dish:index:latest:1:12,过期时间 10 分钟。为什么只缓存第一页?因为后续翻页用户会越来越少,为了一两页用户把全量数据都缓存起来,命中率不高。10 分钟过期是因为内容发布频率不算爆炸式,过期后下次请求自然回源刷新即可。

详情页的缓存更关键。我对菜品基本信息(含食材、步骤)做了缓存,key 为dish:detail:{id},在菜品被修改或删除时手动删除对应缓存,保证一致性。浏览量view_count的更新我没有走实时写库:每次详情请求只把缓存里的视图计数值加一,再定期或者在下一次写操作时把值刷回数据库。这样数据库不会被高频的浏览请求打爆。

需要注意的是缓存的过期策略要和业务场景匹配。菜品详情缓存如果设太长(比如 1 天),用户修改菜品后看到的还是旧内容,体验会很奇怪。我设置了 30 分钟过期,配合修改时主动删缓存,基本能保证新鲜度。

6.2 数据库索引的设计与验证

建索引这个事我放在数据量有感知后才做了 review。核心的几个索引:

  • dish表:status和create_time的联合索引,用于首页列表过滤和排序
  • dish_like和dish_favorite表:user_id + dish_id的联合唯一索引,已在建表时规划
  • comment表:dish_id + create_time的联合索引,用于评论区按时间排序
  • user_follow表:follower_id和followee_id分别建索引,用于查关注列表和粉丝列表

你可以用EXPLAIN SELECT ...去看看 SQL 的执行计划,如果看到type是ALL(全表扫描)而表数据量已经不小,就说明要加索引了。我给列表查询都用了分页,索引要覆盖过滤字段和排序字段才能避免filesort,这在数据量大时其实是性能关键。

6.3 全文搜索:MySQL LIKE 够不够

搜索功能我起初是直接LIKE '%关键字%'查标题和描述。在几百条数据时毫无压力,但在万级数据量、用户高频搜索时性能会变得难看,因为%关键字%无法使用普通索引。而且中文分词的搜索需求,LIKE也解决不了"搜低卡"希望"减脂餐"也命中的这种语义问题。

我的项目方案是:第一阶段先接上 MySQL 的LIKE查询,因为实现简单、不需要额外引入服务,对当前平台的搜索频率完全够用。但我预留了接口层的抽象,后续如果搜索复杂化,可以直接在服务层替换为 Elasticsearch 或 OpenSearch 而不影响上层逻辑。这个升级路线在架构上是最稳妥的,既不会延迟上线,也不给未来挖坑。

7. 构建部署与运维:从 Nginx 到 Docker Compose 的一键上线方案

项目的最后一步是部署。我这次直接用一台云服务器,生产环境结构是:

  • Nginx 监听 80 端口,托管前端dist静态文件和上传图片目录
  • SpringBoot 应用以 Jar 包方式跑在 8080 端口
  • MySQL 8 跑在 3306,Redis 跑在 6379

我用 Docker Compose 编排了 MySQL、Redis、Java 应用这三个容器,Nginx 放在宿主机直接以系统服务方式管理。Docker Compose 的好处是环境可复现,下次迁移或重装服务器时,不需要一个人肉记录装环境的过程。

7.1 前端构建与 Nginx 配置要点

前端构建命令是npm run build,产物在dist目录,我将它上传到服务器/var/www/food-share。Nginx 配置核心逻辑:

  • location /指向dist目录,尝试查找对应文件,找不到就回退到index.html,这是 Vue Router history 模式的标配
  • location /api/反向代理到http://127.0.0.1:8080
  • location /uploads/直接 alias 到上传图片的本地目录

我第一次部署时把location /的 try_files 写错了,try_files $uri $uri/ /index.html;这行没加,结果一刷新路由页面就 404。Vue 的 history 模式刷新时,Nginx 会尝试找对应的真实文件路径,找不到后必须回退到 index.html 让前端路由接管。这个配置即使做不到过目不忘,也要知道它的存在,不然上线当天就很容易踩到。

7.2 Docker Compose 编排与 SpringBoot 环境变量

SpringBoot 的application.yml里,数据库地址、Redis 地址、JWT 密钥等敏感信息我没有写在正式配置里,而是通过环境变量注入:

docker-compose.yml里给app容器配置environment字段,比如:

version: "3.8" services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: food_share volumes: - mysql-data:/var/lib/mysql ports: - "3306:3306" redis: image: redis:7-alpine ports: - "6379:6379" app: build: context: . dockerfile: Dockerfile environment: DB_HOST: mysql DB_PORT: "3306" DB_NAME: food_share DB_USER: root DB_PASSWORD: ${MYSQL_ROOT_PASSWORD} REDIS_HOST: redis REDIS_PORT: "6379" JWT_SECRET: ${JWT_SECRET} ports: - "8080:8080" depends_on: - mysql - redis

注意 Compose 里的DB_HOST用的是服务名mysql,不是127.0.0.1,因为容器之间通信走的是 Compose 内部网络。如果写错为localhost,Java 应用在容器里访问到的 localhost 是容器自己,连不上数据库。这个坑我当年第一次用 Docker 部署时真真切切踩过,排查了很久。

7.3 服务器安全与日常运维建议

部署完成后,几个运维层面的建议:

  • 云服务器安全组只开放 80 和 443,数据库 3306 和 Redis 6379 端口不对公网开放
  • MySQL 本身不建议用 root 连业务库,我给应用创建了独立账号,只授权food_share库的增删改查
  • JWT 密钥上线后使用长度至少 32 位的随机字符串,不要复用开发阶段的密钥
  • 日志方面,我配置了 SpringBoot 日志输出到文件并按天滚动,logback-spring.xml里设置maxHistory保留 7 天。排查问题时没有日志才是最绝望的事
  • 数据库备份:配置了每天凌晨 3 点的mysqldump定时任务,备份文件保留最近 7 天,同步到另一个存储位置

最后还有一个建议:上线后别忘了做一轮"用户视角验收"。我会把自己当用户,从头走一遍从注册到发布菜品、再到被别人点赞关注的全流程,主动挑那些一开始写代码时根本没注意的体验问题。比如某个操作缺少 loading 提示、某次点击没有友好报错、某个页面刷新后状态丢失。这些细节往往比多写几个接口更能决定一个平台有没有"能用的感觉"。

我用 Vue 搭建前端时,最满意的不是某个炫酷动画,而是所有页面在正常操作和异常操作下都有清晰的反馈链路。后端也是一样,所有接口的返回结构一致,前端处理数据的成本降到最低。这套项目从数据库建模到上线部署,整个过程大概花了两到三周业余时间,节奏上不算快,但每一步都走得很踏实。

如果你也打算做一个类似的美食分享平台,我真心建议你从需求闭环出发,而不是急着找模板。模板给你的是"别人已经想清楚的解决方案",你跳过了思考过程,也就会错过真正值得学习的地方。自己从零搭一遍之后,以后再看到任何 SpringBoot + Vue 项目,你看到的不再是一堆文件,而是一条完整的业务链路和无数个决策点。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进