抓了十几套模板后,我决定自己从零写一个美食分享平台
先说结论:真正把一个 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:头像图片 URLbio:个人简介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:封面图 URLcategory_id:分类 IDdifficulty:难度(简单/中等/困难,用字符串枚举或数字标识)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_imagedish_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、contentparent_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:8080location /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 项目,你看到的不再是一堆文件,而是一条完整的业务链路和无数个决策点。