1. 美食分享平台项目:为什么我会选 Spring Boot 来做
聊到个人博客、内容社区这类项目,我见过太多人一上来就选很重的方案:微服务先拆四个服务、数据库直接上分库分表、消息队列先挂上。结果往往是开发周期拖到三个月,连用户登录都还没做完。这次做美食分享平台,我一开始就定了个调子:项目定位是社区型内容平台,不是互联网大厂的高并发系统,单体 Spring Boot 就是最务实的答案。
当初这个项目立项时,需求其实非常明确:用户能发图文混排的美食菜谱,能按分类找菜,能收藏、点赞、评论,最好还能关注喜欢的美食作者。说实话,这些功能听起来不少,但仔细拆开之后,都是经典的 CRUD + 关联查询 + 排序分页,没有任何一个需要分布式事务或者大数据量处理。用微服务架构去做这种项目,等于拿航空母舰去跑小区门口的快递三轮车线路,治理成本比业务本身还高。
Spring Boot 在这里的价值不只是"约定优于配置"那几个字。它真正帮我省时间的地方在于:
- 自动配置机制:引入
spring-boot-starter-web就拿到了内嵌 Tomcat 和完整的 MVC 能力,引入spring-boot-starter-data-jpa就有了数据访问层,我只需要写业务代码,不用花两天去拼 ssh 时代的 XML 配置文件。 - 起步依赖:Redis、MyBatis、验证框架、Swagger,全都是一行依赖的事,版本冲突也由 Spring Boot 的 BOM 统一管理。
- 生态成熟:菜谱图片上传、全文搜索、定时任务这些需求,在 Spring Boot 生态里都有非常成熟的落地方案,不用自己造轮子。
- 部署友好:最终打出一个可执行 jar 包,扔到服务器上就能跑,后续如果要上 Docker 也很顺。
搜"springboot"相关热词的时候,我看到很多人在纠结 Spring Boot 版本选取、项目结构规范这些东西。我的经验是:这类内容型项目,别选太新的版本,选稳定版。当时我用的 Spring Boot 2.7.x,不是最新的,但它兼容性最好,遇到坑时网上的解决方案最多。等我把核心功能全部跑通、部署上线、压测完成之后,再去考虑升不升级,这才是合理的节奏。
下面我把整个项目的解剖过程写出来,从功能规划到表设计,从前端接口到部署上线,全部是真实跑过的路径,适合也想做类似内容社区项目的同学参考。
2. 平台功能模块拆解:用户能干什么,系统要撑住什么
2.1 前台用户端:围绕"发现和分享美食"设计功能路径
美食分享平台的核心是内容。我先把用户的典型行为路径画出来——内容生产、内容消费、互动反馈。这三条路径撑起来了整个站点的功能骨架。
内容生产路径:用户注册登录后,可以发布菜谱。菜谱不是简单的文字+图片,我拆成了几个独立字段:标题、封面图、食材清单、步骤说明、分类归属、自定义标签。食材清单我用 JSON 格式存储,因为每道菜的食材数量和单位都不同,关系表反而啰嗦;步骤说明则是按顺序排列的文本列表。发布之后,系统自动生成一条动态推送到"美食广场",同时同步到作者的"个人主页"。
内容消费路径:首页美食广场按时间流展示最新内容和热点内容;分类页按八大菜系加"烘焙""轻食""家常菜"等场景分类;搜索框支持按菜名、食材、作者三种维度查找。用户点进详情页,能看到完整菜谱、作者信息、相关推荐。
互动反馈路径:收藏、点赞、评论、关注。这些功能单独看都很简单,但合在一起就是社区活跃度的发动机。我在详情页放了"收藏数、点赞数、评论数、浏览数"四个指标,用户能直观感受到内容的热度,作者也有动力持续更新。
2.2 后台管理端:内容审核和数据统计一个都不能少
作为平台方,后台管理是所有内容社区项目的刚需。我做了两个大块:
- 内容管理:菜谱审核(新发布的菜谱先进入待审核状态,管理员可以一键通过或驳回)、分类管理(增删改查分类)、标签管理、热门推荐位设置、置顶内容管理。
- 用户管理:用户列表、状态管理(封号/解封)、作者认证标记。后台首页放了核心指标:今日新增用户、今日发布菜谱数、总菜谱数、总用户数,让运营者一眼看到平台状态。
这里要特别提醒一点:内容审核不要不做,也不要过度做。我见过很多个人项目完全没有审核环节,结果被人灌了一堆垃圾内容还毫不知情。美食平台虽然内容风险低,但图片资源如果被人当图床用,服务器带宽分分钟被打爆。所以我设计了"先发后审"——用户发布后立刻可见,但管理员后台能看到所有内容并按需处理,这样既不伤害用户体验,又能守住底线。
2.3 系统支撑能力:文件存储、权限控制、定时任务
除了业务功能,还有几个横切面能力是必须提前规划的:
- 认证授权:用户端用 JWT 做无状态认证,管理员端走独立校验逻辑,两个角色权限分开。
- 图片处理:用户上传的美食图片,系统要压缩出多尺寸版本(缩略图、列表图、原图),并且做格式统一。
- 定时任务:每日热点榜计算、数据统计报表、失效 token 清理、未登录访客的临时数据回收。
- 搜索索引:菜谱的标题、食材、标签需要建立高效检索能力。
这些能力虽然不直接在前台页面上展示,但它们是平台能不能撑住的底层地基。我在做技术选型的时候,盯着这几个横切面能力反复对比,最终确定了下面的技术栈。
3. 技术选型对比:为什么是 MyBatis-Plus 而不是 JPA,为什么 Redis 不能省
技术选型这一步,我花了不少时间,最终确定的核心技术栈是:Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Redis + JWT + MinIO。下面逐个说理由,这些理由都是我在实际编码过程中真实体会到的。
MyBatis-Plus 和 Spring Data JPA 之争。说实话,JPA 的自动建表和一二级缓存机制很适合快速开发,但在做内容社区这种大量自定义查询、多表关联、动态条件分页的场景下,JPA 的复杂查询写起来非常憋屈——不是不能写,而是要么拼接 Specification,要么写 JPQL,调试成本高。MyBatis-Plus 的好处是:简单的单表 CRUD 直接继承BaseMapper就用,复杂的多表查询可以用 XML 自己写 SQL,而且它内置了分页插件,对"列表页+条件筛选"这种需求简直是量身定做。美食广场需要支持"按分类、按标签、按关键词、按时间范围"组合筛选,这种 SQL 很容易写,但用 JPA 能把自己绕晕。另外,MyBatis-Plus 的逻辑删除、自动填充(创建时间、更新时间)也帮我省掉了非常多的样板代码。
MySQL 8.0 的选型。从 5.7 切到 8.0,最直接的感受是:窗口函数真好用。比如"某人发布的所有菜谱中,按收藏数排名",一条 SQL 就能算出来,不需要复杂子查询。8.0 的默认字符集是utf8mb4,对 emoji 支持不需要额外配置——美食分享平台上用户评论里出现 emoji 的概率极高,这个细节不能忽略。
Redis 不是锦上添花,而是必需品。我有三个核心使用场景:点赞数和浏览数的秒级更新(先写 Redis 再异步落库);热门菜谱榜单的实时计算;用户 Session 级别的临时状态。另外,首页的"今日热点"如果每次请求都查 MySQL,高峰期压力很大,我用 Redis 缓存了热点列表,每 5 分钟刷新一次,QPS 从几百降到几十。
MinIO 和云存储的选择。很多教程喜欢直接用七牛云/阿里云 OSS,但在内网部署或个人服务器场景下,MinIO 是完全兼容 S3 API 的开源对象存储,装在服务器上就能提供图片上传下载服务。我选择 MinIO 的理由是可控:不依赖外部厂商,环境隔离、数据私有,以后想迁云也很方便,因为 API 是标准化的。菜谱图片统一走 MinIO,配合 Nginx 做静态资源代理,实际体验下来很稳定。
做技术选型对比时,我整理过一个表格,大家可以直接抄:
| 技术组件 | 选型结果 | 选型理由 |
|---|---|---|
| Web 框架 | Spring Boot 2.7 | 生态成熟、自动配置、稳定版 |
| ORM | MyBatis-Plus | 复杂查询灵活、内置分页、较少样板代码 |
| 数据库 | MySQL 8.0 | 窗口函数、utf8mb4、社区资源多 |
| 缓存 | Redis | 热点榜单、计数、异步落库 |
| 搜索 | MySQL LIKE + 全文索引 | 中小体量够用,上 ES 是后面的事 |
| 文件存储 | MinIO | S3 协议、私有化部署、易迁移 |
| 认证 | JWT | 无状态、易扩展 |
| 接口文档 | Knife4j | 在线调试方便,前后端协作神器 |
| 构建 | Maven | 上手门槛低,和 Jenkins/Docker 都好集成 |
有个东西我没上——搜索引擎 Elasticsearch。我知道很多内容平台都用 ES 做搜索,但说实话,在数据量没有到几十万条的时候,MySQL 的LIKE模糊查询配合ngram全文索引,性能完全够用,而且省掉了一套独立的集群运维成本。我在搜索设计里做了个折中:菜名和食材字段建了FULLTEXT索引,用MATCH...AGAINST做评分排序,数据量过 10 万条再考虑迁 ES。
4. 数据库设计:从用户表到菜谱详情,12 张核心表的设计思路
4.1 用户、内容、互动三大域建模
数据库设计是我在整个项目里最谨慎的一部分。表结构一旦定了,后面改起来痛苦无比。我按"用户域、内容域、互动域"三个域来建模,一共设计了 12 张核心表。
用户域:
user:用户主表,username、password(BCrypt 加密)、nickname、avatar、bio、role(USER/ADMIN)、status、create_time。user_follow:关注关系表,user_id、follow_user_id。关注关系用单独的表而不在用户表里存"关注数",是因为关注数是个频繁更新的聚合值,放主表里会锁行,单独建表后关注数用 Redis 统计即可。
内容域:
category:分类表,用于菜谱分类(如川菜、粤菜、烘焙、轻食)。recipe:菜谱主表,这是整个平台的核心表。字段包括:title、cover_image、user_id(作者)、category_id、description、ingredients(JSON 数组,存食材清单)、status(PENDING待审核/PUBLISHED已发布/REJECTED已驳回)、view_count、like_count、favorite_count、comment_count、create_time、update_time。recipe_step:菜谱步骤表,recipe_id 关联菜谱、step_no 步骤序号、content 步骤文本、image_url 步骤图。步骤拆成子表而不是拼成一个长文本,好处是前端可以按步骤渲染出清晰的流程卡片,后续如果要做"只看步骤"的打印版也很方便。tag和recipe_tag:标签表和菜谱标签关联表,多对多关系。
互动域:
recipe_like:点赞记录表,recipe_id、user_id、create_time。recipe_favorite:收藏记录表,recipe_id、user_id、create_time。comment:评论表,recipe_id、user_id、content、parent_id(支持楼中楼)。recipe_view:浏览记录表,recipe_id、user_id(未登录为 null)、view_time。
4.2 索引、冗余字段和一些取舍
建表的时候有几条是我反复强调的原则:
读多写少的数据,加冗余字段不是坏味道。菜谱表里的
like_count、favorite_count、comment_count这些计数,虽然可以通过 count 查询实时算出来,但列表页一次要展示 20 条菜谱,每条都去 count 一下,数据库直接被打垮至于吗?直接实时 count 必然导致性能灾难,所以我在recipe主表里冗余了这几个计数字段。计数更新通过 Redis 异步做,保证最终一致。所有关联字段都建索引。这是最基础也最容易漏的——
recipe.user_id、recipe.category_id、recipe_step.recipe_id、recipe_tag.recipe_id、recipe_tag.tag_id、comment.recipe_id这些外键字段全部建索引。很多新手只给主键加索引,等数据量大了分页变慢才发现问题,那时候加索引可能已经要锁表了。JSON 字段要克制。食材清单我用 JSON 存储,是因为食材这个对象在业务上没有独立的查询维度,不太需要"按食材搜菜谱"以外的场景。但如果你觉得以后要"按某个食材筛选所有菜谱"这种功能,那还是老老实实拆出
ingredient子表,否则 SQL 里查 JSON 字段会非常痛苦。我的项目里搜索食材用的是全文索引,数据存在 JSON 字段里也能被全文索引覆盖到,所以够用。
最终的数据模型长这样:
user ├── recipe (user 发布 recipe) │ ├── recipe_step (recipe 的步骤) │ ├── recipe_tag (recipe 和 tag 关联) │ └── category (recipe 属于分类) ├── recipe_like (用户给菜谱点赞) ├── recipe_favorite (用户收藏菜谱) ├── comment (用户评论菜谱) └── recipe_view (用户浏览菜谱)这套模型跑下来的感受是:整体够用、扩展性均衡。将来要加"菜谱合集(合集里包含多道菜)"或"活动页(固定一批菜谱参与活动)",只需要在合集表和菜谱之间加一张关联表,不需要动现有表结构。
5. 后端核心实现:认证、上传、搜索、点赞,四个最难啃的点
5.1 JWT 认证和用户权限:Spring Security 配置别硬抄
Spring Security 是所有 Spring Boot 项目里最容易让人翻车的地方,因为网上的教程版本差异极大。我这次统一了思路:用 Spring Security + JWT 做无状态认证,自定义 Filter 完成 token 校验,不碰 OAuth2 那套重东西。
核心逻辑是这样的:
- 用户登录成功,后端生成 JWT 返回给前端,前端存到 localStorage。
- 前端每次请求在 Header 里带
Authorization: Bearer <token>。 - 后端自定义一个
JwtAuthenticationFilter,继承OncePerRequestFilter,在过滤链里解析 token、校验签名、把用户信息塞进SecurityContext。 - 请求进入 Controller 时,通过
@AuthenticationPrincipal拿到当前用户。
这里有个容易踩的坑:很多人直接把 Security 配置类从网上抄下来,结果发现WebSecurityConfigurerAdapter在 Spring Boot 2.7 里还能用,在 3.x 里直接废弃了。虽然我用的 2.7 还能用,但建议新项目直接使用SecurityFilterChain的 Bean 配置方式,避免后续升级痛苦。
我贴一下配置的关键代码,大家可以参考这个思路:
@Configuration @EnableWebSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/api/recipes/list", "/api/recipes/detail/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(ex -> ex .authenticationEntryPoint((req, res, e) -> { res.setStatus(401); res.setContentType("application/json;charset=UTF-8"); res.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}"); }) ); return http.build(); } }注意几个细节:/api/auth/**和公开的菜谱列表接口要放行;后台管理接口单独用hasRole("ADMIN")保护;未登录的拦截要返回 JSON,不能跳转 login 页面,因为这是前后端分离项目。JWT token 的有效期我设成 7 天,用户端每次请求时刷新一次过期时间,管理员台 token 有效期设成 2 小时,权限边界越严越好。
5.2 图片上传:MinIO 集成与压缩处理
美食分享平台的核心资产就是图片,一个精致的菜谱封面或步骤图,能直接影响用户的浏览意愿。图片上传这个功能我踩过不少坑,总结下来最关键的是三点:限制大小、压缩处理、安全校验。
首先,MinIO 的集成非常简单。引入依赖后,配置一个MinioClient的 Bean,上传时调用putObject就完成:
@Service public class FileStorageService { private final MinioClient minioClient; public FileStorageService(MinioClient minioClient) { this.minioClient = minioClient; } public String upload(MultipartFile file, String bucket, String objectName) { try { // 检查 bucket 是否已创建 boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build()); if (!exists) { minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); } // 上传文件 minioClient.putObject(PutObjectArgs.builder() .bucket(bucket) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); // 拼接访问路径 return "/api/files/" + bucket + "/" + objectName; } catch (Exception e) { throw new RuntimeException("文件上传失败", e); } } }但直接存原图有两个问题:带宽浪费和加载速度慢。所以我开发时做了三件事:
- 限制上传文件类型,只接受
jpg、jpeg、png、webp,前端和后端双重校验。 - 按需求压缩:封面图统一压缩到宽度 800px;步骤图压缩到宽度 600px;头像压缩到 200x200。
- 缩略图单独生成,列表页加载缩略图,详情页加载原图。
这里我推荐一个工具:Thumbnailator,几行代码就能完成图片缩放和格式转换:
BufferedImage image = ImageIO.read(file.getInputStream()); Thumbnails.of(image) .width(800) .outputFormat("jpg") .outputQuality(0.85) .toOutputStream(outputStream);实测下来,一张 5MB 的相机原图,压缩后大概 300KB 左右,加载速度提升非常明显。还有一个细节:图片文件名不能用用户上传的原始文件名,因为客户端传的文件名可能包含中文、特殊字符甚至路径注入攻击。我用UUID + 时间戳 + 后缀重新生成对象名,从根上杜绝安全问题。
5.3 搜索功能:MySQL FULLTEXT + 标题/食材/标签加权
做搜索功能之前,我认真考虑过要不要上 Elasticsearch,最后决定先在 MySQL 上做。上面说了,目前的数据量根本不值得引入一套 ES,而且 MySQL 8.0 的全文索引对中文支持已经不错了,处理中文需要配置ngram分词插件,但比我预想的简单——只要建表时指定全文索引,查询时用MATCH...AGAINST即可。
具体做法:
CREATE FULLTEXT INDEX ft_recipe_search ON recipe(title, description, ingredients) WITH PARSER ngram;查询的时候,我用加权搜索让结果更符合"美食平台"的用户预期——标题命中的权重最高,食材命中的次之,描述的权重最低。实现方式是写一条动态 SQL,在 MyBatis-Plus XML 里拼接不同字段的MATCH条件,score 相加后排序。
除了全文搜索,我还额外做了标签预筛选。热搜词里大家搜"炸鸡"或"家常菜",近义词和场景词不好用关键词索引直接覆盖,我把标签作为一种结构化的筛选条件挂到了搜搜接口上——用户搜索后,外层再按标签和时间做二次过滤。这样即使用户输入的关键词比较泛,也能通过标签精确锚定。
如果大家以后真的要把搜索换成 ES,核心索引结构大概这样:recipe_index存菜谱 id、标题、食谱描述、食材列表、标签列表,从 MySQL 同步过去,查询的时候走 ES,然后拿recipeId回表查详情。这个演进路径很清晰,但现阶段还是老老实实用 MySQL。
5.4 点赞、收藏和计数:先写 Redis 再异步落库
点赞/收藏/浏览数这类高频写操作,如果每次都直接写 MySQL,数据库的锁竞争和磁盘 IO 会很密集。我采用的模式是Redis 计数 + 延迟异步落库。
具体逻辑:
- 用户点收藏,前端调
POST /api/recipe/{id}/favorite。 - 后端先把
userId写入 Redis 的 Set 集合favorite:recipe:{id}。 - 接着把计数递增到 Redis 的 Hash 里,然后返回"收藏成功"给前端。
- 每隔一段时间(比如 5 分钟),一个定时任务把 Redis 里的增量同步回 MySQL 的
favorite_count字段。
这套模式好处很明显:用户侧响应极快,数据库压力平摊。但坏处也同样明显:万一 Redis 数据丢了,计数会不准确。不过对美食分享平台这种业务来说,计数偏差个位数完全无感知,这属于典型的可用性和一致性的权衡。我的兜底方案是:每晚凌晨跑一次全量统计任务,把每道菜的真实点赞收藏数刷回 MySQL,修正白天可能产生的偏差。
有个并发细节要注意:不可重复点赞。用户点赞之前,要判断Redis Set里有没有这个 userId,没有才执行SADD,这道判断保证了一个用户对一个内容最多只能点一次赞。用关系型数据库做同样的事情需要在user_id + recipe_id上建唯一索引,两条路都可以,Redis 的做法更快,也不产生随机的数据库索引竞争。
6. 前后端对接:接口设计规范、跨域和文件访问代理
6.1 RESTful 接口约定和统一返回结构
前后端分离的项目,接口设计直接影响开发效率和联调体验。我一开始就定下规范:统一返回对象Result<T>,无论成功失败,响应 JSON 结构都一致。
public class Result<T> { private int code; // 200 成功,400 参数错误,401 未登录,500 服务异常 private String msg; private T data; }接口路径按资源命名:
POST /api/auth/register:注册POST /api/auth/login:登录GET /api/recipes/list?page=1&size=10&categoryId=xx&tagId=xx&keyword=xx:菜谱列表GET /api/recipes/detail/{id}:菜谱详情POST /api/recipes:发菜谱POST /api/recipes/{id}/like:点赞POST /api/recipes/{id}/favorite:收藏GET /api/users/{id}/profile:用户主页GET /api/users/{id}/followers:粉丝列表
我特别建议在开发阶段就接上Knife4j(Swagger 的增强版)。它能在浏览器里直接测试所有接口,对调试 JWT 这类带认证的接口非常方便——在文档页面填好 token,点一下就能调用,比用 Postman 手动拼参数效率高不少。
6.2 跨域处理与文件访问映射
前后端分离部署时,跨域问题是首要拦路虎。前端跑在http://localhost:5173(Vite 默认端口),后端跑在http://localhost:8080,前端直接发 Ajax 请求会被浏览器拦截。我的处理方式是后端配置 CORS:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意:allowCredentials(true)时allowedOrigins("*")在allowedOriginPatterns("*")是可以配合的。实际生产环境建议把allowedOriginPatterns配成真实前端域名,不要用*或字符串匹配,减少恶意站点的跨域请求。
另外,MinIO 里存的文件不能直接暴露给前端,否则会出现另一个跨域问题,而且 MinIO 的外网访问端口如果直接开放也有安全隐患。我的方案是通过 Nginx 反代/api/files/**路径到 MinIO 端口,前端看到的 URL 永远是后端域名:
location /api/files/ { proxy_pass http://127.0.0.1:9000/; proxy_set_header Host $host; }这样图片的访问路径和 API 域名保持一致,后端在生成访问 URL 时也不需要硬编码 MinIO 的内网端口。
7. 部署上线与性能调优:宝塔面板 + Docker,以及压测后的三个教训
7.1 服务器部署方案
搜索热词里有不少"宝塔docker部署springboot"和"springboot 阿里云构建地址",可见大家对部署环节的关注度非常高。我的部署方案很简单:一台 2C4G 云服务器 + 宝塔面板 + Docker Compose。主要跑四个容器:MySQL、Redis、MinIO、后端 jar 包。前端打包成静态文件后丢到 Nginx 的html目录,反代后端 API。
Docker Compose 文件的核心片段:
version: "3.8" services: mysql: image: mysql:8.0 container_name: food-mysql environment: MYSQL_ROOT_PASSWORD: yourpassword MYSQL_DATABASE: food_share ports: - "3306:3306" volumes: - ./mysql-data:/var/lib/mysql redis: image: redis:7-alpine container_name: food-redis ports: - "6379:6379" minio: image: minio/minio container_name: food-minio command: server /data --console-address ":9001" ports: - "9000:9000" - "9001:9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin123 volumes: - ./minio-data:/data backend: build: . container_name: food-backend depends_on: - mysql - redis - minio ports: - "8080:8080"用 Docker 部署的好处是环境隔离、迁移方便。如果想精简,也可以不用 Docker,直接在宝塔面板里安装 MySQL、Redis、MinIO,后端 jar 用系统服务进程守护,效果差不多,看个人习惯。
7.2 压测后做的三个优化
项目上线前,我用 JMeter 做了基础压测,模拟 100 个并发用户持续操作 10 分钟。发现的问题和优化办法如下:
第一个教训:首页热点接口响应时间从 50ms 涨到 1200ms,原因是每次请求都在实时计算热度值。问题定位后,我做了俩改动:热点数据 Redis 缓存(5 分钟过期),热度值从"浏览量+收藏量3+点赞量2"的加权积分中取值,计算结果直接存 Redis。优化后接口 P99 稳定在 80ms 左右。
第二个教训:图片懒加载要配占位图,否则首屏图片加载数量过多。菜谱列表页一页 20 道菜,每道菜一张封面缩略图,如果用户网速一般,页面会白屏很久。我把封面图全部换成 WebP 格式,加载时先显示后端返回的低清占位图,目标图加载完再替换,体感改善非常明显。
第三个教训:MySQL 连接池别用默认配置。默认的connectionTimeout=30000、maximumPoolSize=10在高并发下不够用。我调成了maximum-pool-size: 30,minimum-idle: 5,connection-timeout: 5000(防止长时间排队)。同时在application.yml里开启了 MySQL 的连接验证和空闲回收,避免长时间空闲连接被数据库服务端断开后,应用还拿着死连接去访问。
还有一点,网上搜"springboot定时任务"经常能搜到@Scheduled的写法,我在项目里确实也用了这个注解做每日榜单刷新。不过要提醒一下:@Scheduled只适合单实例部署。如果以后项目扩到两台机器同时跑后端,定时任务会被触发两次,这时候就要引入分布式锁或独立的调度服务了。单体阶段用@Scheduled完全合理,但要心里有数这是后续架构升级时要处理的点。
8. 整个项目做完后,我最想分享的几个心得
项目从立项到上线稳定运行,前前后后折腾了三周多。如果让我重新做一遍,有几个决策我会坚持,有几个时间会被我省下来。
坚持用 MyBatis-Plus 而不是 JPA。不是因为 JPA 不好,而是内容型社区项目的查询绝大多数是定制化的列表查询,MyBatis-Plus 在这种场景下写 SQL 的直观性和可控性远超 JPA。团队协作也更简单,新同事接手时看 XML 里的 SQL 就能立刻理解业务逻辑。
坚持把用户认证和权限前置设计。我见过太多项目先做业务后补安全,最后业务代码里到处散落着判断当前用户的逻辑。我在第一个迭代就把 JWT 认证、角色校验、接口权限全部搭好,后面的所有业务开发都在这个安全框架内进行,省掉了大量返工。
坚持做接口文档而不是口头约定。Knife4j 在开发期和联调期的价值被严重低估了。前后端并行开发时,后端把接口定义好,注解写清楚,前端根本不用追着问参数,直接在文档页面看示例、调试接口。这个习惯保持到项目后期,能让协作效率提升一半。
从热搜词反复出现的"springboot项目结构""springboot配置""springboot 自定义自动配置"来看,现在很多初学者还在跟配置搏斗。我建议这类同学不要沉迷于从零手写配置,先跑通一个完整的业务闭环,再回头琢磨底层原理。我这款美食分享平台做完,最大的成长不在于学会某个框架的 API,而在于理解了"一个内容平台的完整生命周期是怎样的"——这个理解,比具体的技术栈选择更值钱。