基于Spring Boot的美食推荐系统实战:协同过滤与部署全解析
发布时间:2026/9/25 3:45:44来源:尧图网络
民以食为天但吃什么这个问题每天都要消耗大量决策时间。2023年我做了一个基于Spring Boot的美食推荐系统初衷很简单不想再让用户面对几百道菜翻来翻去无从下手而是根据每个人的口味偏好、历史行为直接给出你可能想吃这个的答案。整个项目从需求拆解、表结构设计到推荐算法落地、服务器部署踩了不少坑也沉淀了一些实实在在的经验。这篇就来完整复盘一下这个系统的开发全过程适合正在做Java毕设、或者想独立搭建一个包含推荐逻辑的Web系统的同学参考。1. 需求分析先行这个美食推荐系统到底要解决什么问题很多人一上来就写代码结果做着做着发现功能堆了一堆用户却根本不知道怎么用。我在动手之前先把系统的核心问题理清楚了。1.1 用户的核心痛点普通用户在餐饮场景下的痛点非常集中信息过载菜品种类太多不知道选什么口味差异有人吃辣、有人忌甜、有人不吃香菜统一的列表根本无法满足个性化需求决策疲劳每次都要从零开始翻菜单缺乏连续性和记忆性所以推荐系统的价值不是做一个展示列表而是通过用户的历史行为评分、收藏、浏览、下单建立用户画像再基于画像预测用户对未接触过菜品的偏好程度。1.2 功能模块拆分我最终确定了两端四模块的结构端模块核心功能用户端菜品浏览分类筛选、关键词搜索、菜品详情展示用户端个性化推荐首页推荐、相似菜品推荐、热门菜品排行用户端互动中心菜品评分、收藏夹、浏览历史记录管理端后台管理菜品CRUD、分类管理、用户管理、推荐参数配置管理端不是配角它是整个系统的信息源头。菜品数据、分类数据、推荐开关、热门榜单权重都由后台控制这样前端不需要改一行代码就能动态更新业务内容。1.3 选型思考为什么用Spring Boot做推荐系统推荐系统听起来像是Python、Spark这些大数据技术的专属领域但在实际校园项目和个人项目中Spring Boot依然是性价比最高的选择。原因有三上手门槛低一个Spring Boot应用就能同时承载Web接口、业务逻辑和推荐计算无需额外引入大数据组件生态完整MyBatis-Plus、Redis、JWT这些配套组件极其成熟CRUD和鉴权半小时就能搭起来部署省心单体应用打个jar包就能跑不需要去配集群、配Hadoop同一个大家常问的问题是那推荐算法用Java怎么写协同过滤的计算本质上就是矩阵操作和相似度排序数据量在万级以内时Java代码配合数据库索引完全够用而且不需要用户去额外装Python运行环境。2. Spring Boot版本选型与项目骨架搭建2.7.18是2023年最稳的答案这个项目命名为springboot126美食推荐系统2023年份限定让很多人纠结于到底用哪个Boot版本。我的答案很明确Spring Boot 2.7.18。2.1 为什么不用Spring Boot 3.x当时Spring Boot 3.0已经发布但我在评估之后放弃了主要卡在三点JDK版本门槛3.x强制要求Java 17很多同学的开发机和服务器还是JDK 8换版本的成本很高第三方兼容性一些老的MyBatis、分页插件、代码生成器在Jakarta命名空间迁移之后需要升级实际踩坑远比你想象的深学习资料错位当时网上大量的教程、踩坑笔记都基于2.x用3.x遇到问题连个参考都找不到2.7.18是2.x系列的最后一个维护版本修复了大量已知问题稳定性非常可靠。对毕业设计和中小型项目来说稳比新重要得多。2.2 项目骨架与基础依赖项目结构和依赖我直接给出参考这是实测可用的组合food-recommend-system/ ├── src/main/java/com/example/food/ │ ├── controller/ # 控制层 │ ├── service/ # 业务层 │ ├── mapper/ # MyBatis-Plus Mapper接口 │ ├── entity/ # 实体类 │ ├── config/ # 配置类跨域、拦截器、Redis │ ├── common/ # 统一返回结果、异常处理 │ └── recommend/ # 推荐算法核心逻辑 ├── src/main/resources/ │ ├── mapper/ # XML文件 │ ├── application.yml │ └── sql/ # 初始化建表脚本 └── pom.xmlpom.xml里的核心依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies !-- Web -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis-Plus -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency !-- MySQL -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- Redis -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- JWT -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency !-- Lombok -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里有一个非常容易忽略的问题mysql-connector-java在Boot 2.7.x里的坐标是带版本号的如果写成com.mysql:mysql-connector-j也可以但版本必须显式指定否则会因为驱动版本不匹配导致连接报错。2.3 统一返回结构与全局异常处理接口返回格式如果不统一前后端联调的时候会非常痛苦。我定义了一个ResultT泛型类Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.code code; result.message message; return result; } }所有Controller统一返回Result前端只需要判断code 200即可。全局异常处理用RestControllerAdvice拦截业务异常和参数校验异常避免异常堆栈直接暴露给前端。3. 数据库设计从用户画像到推荐结果的完整数据链路推荐系统对数据表的依赖比普通CRUD系统重得多。普通系统可能只要用户表、菜品表、订单表就够了但推荐系统必须有行为数据表否则算法就是空中楼阁。3.1 核心表结构我设计了七张核心表-- 用户表 CREATE TABLE user ( id int NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT 密码(MD5/BCrypt), nickname varchar(50) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像URL, gender tinyint DEFAULT 0 COMMENT 性别: 0未知 1男 2女, create_time datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 菜品表 CREATE TABLE dish ( id int NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL COMMENT 菜品名称, category_id int NOT NULL COMMENT 分类ID, image varchar(255) DEFAULT NULL COMMENT 菜品图片, description text COMMENT 菜品描述, price decimal(10,2) DEFAULT 0 COMMENT 参考价格, spicy_level tinyint DEFAULT 0 COMMENT 辣度: 0不辣 1微辣 2中辣 3重辣, tags varchar(255) DEFAULT NULL COMMENT 标签,逗号分隔: 川菜,下饭,火锅, heat int DEFAULT 0 COMMENT 热度值(按点击/收藏/评分累计), status tinyint DEFAULT 1 COMMENT 状态: 0下架 1上架, create_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品表; -- 菜品分类表 CREATE TABLE category ( id int NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 分类名: 川菜/粤菜/甜品等, sort int DEFAULT 0 COMMENT 排序权重, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品分类表; -- 用户评分表 CREATE TABLE rating ( id int NOT NULL AUTO_INCREMENT, user_id int NOT NULL, dish_id int NOT NULL, score tinyint NOT NULL COMMENT 评分1-5, create_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_user (user_id), KEY idx_dish (dish_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT评分表; -- 收藏表 CREATE TABLE favorite ( id int NOT NULL AUTO_INCREMENT, user_id int NOT NULL, dish_id int NOT NULL, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_user_dish (user_id, dish_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT收藏表; -- 浏览历史表 CREATE TABLE history ( id int NOT NULL AUTO_INCREMENT, user_id int NOT NULL, dish_id int NOT NULL, view_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_user_time (user_id, view_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT浏览历史表;3.2 行为数据为什么必须单独建表很多同学把评分、收藏直接做成菜品表的冗余字段比如在dish表里加一个average_score。这样做的问题在于推荐算法需要的是用户-物品评分矩阵不是一个平均值。用户的个体偏好是相对值有人打4分已经算高有人打3分都嫌差。只有保留每条原始评分记录算法才能计算出相似用户群体从而做协同过滤。3.3 标签设计里藏着的推荐挖掘点tags字段我用逗号分隔比如川菜,下饭,麻辣。标签的价值在于做基于内容的推荐——当用户对某道菜评分较高时提取它的标签再去找拥有相同标签的其他菜品。为了提升查询效率我还单独建了一张标签统计缓存Redis hash用HINCRBY累加用户偏好标签的次数。3.4 数据初始化系统没有数据是无法演示推荐效果的。我建议在sql/init_data.sql里预置15个分类、200道菜品、10个测试用户以及足够的评分/收藏/历史记录。数据量太少时协同过滤矩阵会非常稀疏推荐出来的结果会很奇怪。这一步千万不要省否则前端页面空荡荡推荐结果也没法看。4. 推荐算法落地在Spring Boot里手写协同过滤与标签匹配这应该是全系统最有技术含量、也最容易翻车的地方。我采用的是协同过滤为主、标签匹配为辅的混合推荐策略下面拆开讲。4.1 思路对比基于用户协同过滤(UserCF) vs 基于物品协同过滤(ItemCF)维度UserCFItemCF核心逻辑找到与我相似的用户推荐他们喜欢的找到与我喜欢的物品相似的物品适合场景新闻、美食等兴趣分散领域电商、影视等兴趣稳定领域冷启动问题新用户无行为数据时几乎失效新菜品无行为数据时几乎失效计算成本随用户量增长而暴增随物品量增长而暴增美食推荐的特点是用户的味觉偏好相对稳定而且菜品数量往往比用户数量少很多。我最终选择了以ItemCF为主、UserCF作补充并在冷启动阶段用标签匹配来兜底。4.2 基于物品的协同过滤实现核心步骤分三步构建用户-菜品评分矩阵计算菜品之间的相似度余弦相似度根据用户历史评分加权生成Top-N推荐列表第一步先把评分数据捞出来// 查询所有评分记录 ListRating allRatings ratingMapper.selectList(null); // 构建 userId - MapdishId, score 的评分矩阵 MapInteger, MapInteger, Double userDishScoreMap new HashMap(); for (Rating r : allRatings) { userDishScoreMap .computeIfAbsent(r.getUserId(), k - new HashMap()) .put(r.getDishId(), (double) r.getScore()); }第二步计算菜品相似度。余弦相似度公式sim(A, B) Σ(共同评分的用户 (ui * vi)) / (sqrt(Σ ui²) * sqrt(Σ vi²))转换成Java代码public double cosineSimilarity(MapInteger, Double dishAScores, MapInteger, Double dishBScores) { // 找到共同评分的用户 SetInteger commonUsers new HashSet(dishAScores.keySet()); commonUsers.retainAll(dishBScores.keySet()); if (commonUsers.size() 2) { return 0.0; // 共同评分用户太少相似度不可信 } double dot 0; double normA 0; double normB 0; for (Integer userId : dishAScores.keySet()) { normA Math.pow(dishAScores.get(userId), 2); } for (Integer userId : dishBScores.keySet()) { normB Math.pow(dishBScores.get(userId), 2); } for (Integer userId : commonUsers) { dot dishAScores.get(userId) * dishBScores.get(userId); } if (normA 0 || normB 0) { return 0.0; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); }相似度计算是全系统的性能瓶颈。两个菜品两两计算的时间复杂度是O(n²)如果菜品上万内存和CPU都会吃紧。我的处理方式是把计算结果缓存到Redis菜品新增后再增量更新相似度矩阵而不是每次请求都全量重算。第三步生成推荐。对用户已经评过分的每道菜找出与它最相似的K道菜按相似度加权得分排序public ListInteger recommendByItemCF(Integer userId, int topN, int k) { // 1. 获取用户评分过的菜品 ListRating userRatings ratingMapper.selectByUserId(userId); if (userRatings.isEmpty()) { // 冷启动走标签匹配 return recommendByTags(userId, topN); } MapInteger, Double scoreMap new HashMap(); for (Rating rating : userRatings) { // 2. 找到与这道菜最相似的K道菜 ListDishSimilarity similarDishes similarityMapper.findTopK(rating.getDishId(), k); for (DishSimilarity sd : similarDishes) { if (rating.getDishId().equals(sd.getDishId())) { continue; } // 3. 加权累计用户评分 × 相似度 double score rating.getScore() * sd.getSimilarity(); scoreMap.merge(sd.getTargetDishId(), score, Double::sum); } } // 4. 排掉用户已经评过分的菜品取TopN return scoreMap.entrySet().stream() .sorted(Map.Entry.Integer, DoublecomparingByValue().reversed()) .map(Map.Entry::getKey) .filter(dishId - !userRatedDishIds.contains(dishId)) .limit(topN) .collect(Collectors.toList()); }这段代码在生产环境里还有一个优化点userRatings如果超过几十个可以只取最近评分最高的20个菜品参与计算既能降维又能保证推荐结果的时效性。4.3 冷启动问题新用户和新菜品怎么处理这是推荐系统绕不开的坎。新用户没有任何行为数据协同过滤无从下手。我的方案是新用户默认走热门推荐heat字段累积点击、收藏、评分次数按热度展示前10个。同时引导用户完成一个简单的口味选择辣度、偏好菜系这本质上是通过显式反馈快速建立用户画像。public ListInteger recommendByTags(Integer userId, int topN) { ListInteger preferredCategoryIds userTagMapper.selectPreferredCategoryIds(userId); if (preferredCategoryIds.isEmpty()) { return dishMapper.selectHotDishes(topN); // 兜底热门推荐 } return dishMapper.selectByCategoryIds(preferredCategoryIds, topN); }新菜品冷启动菜品刚上架没有评分协同过滤算不出相似度。此时给新菜品加上默认的heat初始值比如50并保证它在新品上架栏目里获得足够的曝光等积累了一定评分后再进入推荐候选池。4.4 推荐的多样性控制只按相似度取TopN容易导致结果单一比如用户喜欢吃辣推荐出来的全是川菜。我在最终排序时引入了分类多样性惩罚因子double diversityFactor 1.0; // 如果当前推荐候选与已选菜品属于同一分类降权 if (lastSelectedCategoryId ! null categoryId.equals(lastSelectedCategoryId)) { diversityFactor 0.8; } double finalScore score * diversityFactor;这里取一个简单的系数0.8实测可以在不显著损失推荐准确率的前提下让推荐列表的品类覆盖度提升30%以上。5. 前后端接口设计与用户体系JWT鉴权统一返回结构的实践推荐算法再漂亮最后还是要通过接口提供给前端。2023年的项目已经很流行前后端分离了我采用了Vue作为前端后端只负责提供RESTful API。5.1 RESTful接口规范接口路径设计遵循资源命名规范方法路径用途POST/api/user/register用户注册加密存密码POST/api/user/login用户登录返回JWT令牌GET/api/dish/page菜品分页查询分类、关键词过滤GET/api/dish/{id}菜品详情POST/api/rating提交评分POST/api/favorite收藏/取消收藏菜品GET/api/recommend/user/{userId}个性化推荐列表GET/api/recommend/similar/{dishId}相似菜品推荐GET/api/admin/dish/page管理端菜品分页管理POST/api/admin/dish新增/修改菜品这里有一个设计细节未登录用户和已登录用户的接口权限必须分开。游客可以浏览菜品、看到热门推荐但收藏、评分、个性化推荐都必须是登录后才能调用的接口。我用Spring MVC拦截器配合JWT实现登录接口放行其他接口校验Token。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BusinessException(401, 未登录); } token token.substring(7); Integer userId JwtUtil.parseToken(token); if (userId null) { throw new BusinessException(401, 登录已过期); } request.setAttribute(userId, userId); return true; } }5.2 分页与排序的细节处理菜品列表的分页我用的MyBatis-Plus的分页插件PageDishVO page new Page(current, size); LambdaQueryWrapperDish wrapper new LambdaQueryWrapper(); wrapper.eq(Dish::getStatus, 1) .like(StringUtils.hasText(keyword), Dish::getName, keyword) .eq(categoryId ! null, Dish::getCategoryId, categoryId) .orderByDesc(Dish::getHeat); dishMapper.selectPage(page, wrapper);这里有一个让人容易踩坑的点排序字段冲突。前端传sortType时如果同时按热度排序和按评分排序必须在后端做白名单映射防止SQL注入String orderColumn switch (sortType) { case heat - heat; case price_asc - price; case price_desc - price desc; default - create_time desc; };我见过有项目直接拼接前端传的字符串进orderBy被安全扫描工具拦下来的情况。排序字段一定要在后端做映射。5.3 菜品图片处理图片我用的方案是菜品图片上传后存入服务器本地目录/upload/通过虚拟路径映射对外提供访问。Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: uploadPath /); } }这里有个前端联调时很常见的报错前端上传图片时multipart请求超时因为默认最大文件大小只有1MB。需要在配置里调大spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB图片太大也影响加载速度建议在后端做一个压缩处理超过500KB的图片等比压缩后再保存。6. 部署上线与踩坑记录从Windows开发机到Linux服务器的完整流程项目开发完成后的部署环节同样是不可忽视的一环。我在这部分踩过的坑非常有代表性。6.1 环境准备生产环境我用的是一台2核4G的云服务器CentOS 7系统。部署清单如下JDK 1.8项目用Boot 2.7.18JDK 8完全够用MySQL 8.0Redis 6.x用于缓存推荐结果、热门榜Nginx 1.20反向代理前端静态资源和接口转发6.2 Docker Compose编排为了让环境可复现我用Docker Compose管理依赖组件version: 3 services: mysql: image: mysql:8.0 container_name: food-mysql environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: food_recommend TZ: Asia/Shanghai ports: - 3306:3306 volumes: - ./mysql-data:/var/lib/mysql - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_general_ci redis: image: redis:6.2 container_name: food-redis ports: - 6379:6379 app: build: . container_name: food-app depends_on: - mysql - redis ports: - 8080:8080 environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/food_recommend?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai SPRING_REDIS_HOST: redis注意TZ: Asia/Shanghai必须加。否则Docker容器默认时区是UTC时间会比北京时间早8小时推荐结果的view_time会乱掉。6.3 经典错误与排查记录错误一MySQL连接超时/Unknown database看起来是数据库名错误其实大部分情况是Docker容器启动顺序问题。MySQL容器首次启动时初始化脚本需要时间应用容器可能先启动导致连不上数据库。解决办法是用depends_on配合健康检查healthcheck: test: [CMD, mysqladmin, ping, -h, localhost] interval: 5s timeout: 5s retries: 20错误二上传的图片404少见但很坑。Linux服务器上通过Nginx转发的时候Nginx的user是nginx用户而上传目录的所有者是root导致Nginx读取不到图片。解决方式是chown -R nginx:nginx /opt/food/upload错误三推荐接口第一次请求特别慢协同过滤全量计算在菜品数据上万时会出现明显延迟。我在缓存中增加了预热逻辑项目启动时加载相似度矩阵到Redis设置12小时过期过期后异步重新计算。这样第一次请求只需要从Redis读取响应时间从3秒降到200毫秒以内。Component public class SimilarityCacheWarmer implements ApplicationRunner { Override public void run(ApplicationArguments args) { // 异步预热相似度矩阵 threadPoolTaskExecutor.execute(() - { ListDish allDishes dishMapper.selectList(null); // 计算相似度并写入Redis }); } }6.4 前端部署前端的Vue项目在本地npm run build后将dist目录上传到服务器在Nginx里配置server { listen 80; server_name your-domain.com; location / { root /opt/food/dist; index index.html; try_files $uri $uri/ /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; } location /upload/ { alias /opt/food/upload/; } }try_files那行非常重要它解决了Vue路由在history模式下刷新404的问题。没有这行用户一刷新页面就是白屏这是我见过新手最容易忽视的部署问题。写在最后的经验总结整个项目做下来我最深的体会有两点。第一推荐系统不是算法越高级越好而是越贴合数据量和业务场景越好。对于几千道菜、几百个用户的数据规模手写一个余弦相似度的ItemCF比引入Spark、Flink这些大件有效得多而且你还能真正讲清楚每行代码在做什么。这在毕业设计答辩或者项目展示时是一个极大的加分项。第二系统的完整度比单个功能的复杂度更重要。用户注册登录、权限拦截、菜品管理、评分收藏、推荐列表、后台维护、部署上线这一整套链路都跑通之后项目才真正称得上完整。很多人只盯着算法写了一个下午结果接口一调全是404反而把真正的亮点埋没了。如果你也想做一个类似的系统建议优先把数据模型和推荐算法核心逻辑跑通再逐步完善管理端和部署细节。这个项目后来我还在推荐接口里加了简单的A/B测试开关通过配置动态切换推荐策略。用一句话总结就是先把系统立起来再让推荐慢慢变聪明。
网站建设高端定制企业官网