资讯动态

Java+Vue构建外卖互助平台:协同过滤推荐系统从0到1实战

发布时间:2026/10/17 14:22:18 来源:尧图企业网站定制
1. 从凑一顿饭到互助外卖平台这个需求到底是怎么来的先别急着聊技术我想从最原始的痛点说起。这个项目最早并不是冲着协同过滤推荐去的而是为了解决一个非常具体、非常生活化的问题一个人点外卖经常凑不够起送价或者赶上晚高峰配送费贵得离谱又或是临时有事走不开需要一个顺路的人帮忙带一份。这种情况在现在的社区、园区、高校宿舍区里太常见了。给人帮忙带东西本来就是邻里之间天然存在的互助需求只是过去靠微信群喊话、打电话效率极低而且信息一刷就没了。于是我们决定做一个外卖商城 互助平台——用户既可以像普通外卖 App 一样浏览菜品下单也可以发布求助单让别人帮忙捎带同时整个商城以协同过滤算法为内核让每个用户看到的菜品排序和互助任务都是懂他的个性化列表。为什么选协同过滤而不是简单的按销量排序因为外卖场景和一般电商不一样用户的决策非常受时间、地点、口味偏好影响。同样是辣子鸡丁喜欢重口味的用户会反复下单不吃辣的则永远划过。按销量排固然没错但天花板很低。我们希望用户一打开首页看到的前几名是真正符合他口味的菜而不是全网爆款。这个目标靠协同过滤是合理的路径尤其是基于物品的协同过滤ItemCF它利用物品 A 和物品 B 经常被同一批人买来建立关联然后再根据用户的历史行为推荐关联商品。我后面会具体讲。这个项目适合谁参考如果你是正在做毕业设计、团队项目或个人作品集的 Java 开发或者你想给电商项目加一个推荐引擎但不知道从哪下手这篇分享应该能帮你少走不少弯路。我会把架构选择、算法落地的关键代码、互助流程的设计、前端展示的细节以及上线后踩过的几个大坑都讲一遍。项目最后跑起来的效果不谈多大但作为一个能展示推荐系统、多人互助、完整交易链路的全栈项目它是很能打的。1.1 最初的问题场景项目启动前我们大概访谈了身边二三十个日常点外卖的人问题很集中起送价不够想吃的店起送 30 元但一个人点一个菜也就 18 元加配送费不划算配送时间不可控高峰期等四五十分钟是常事临时不方便下雨天、加班、生病不想动需要有人帮忙拿外卖或顺路买想帮忙但没渠道确实有不少人愿意在顺路的时候帮邻居或同事带个饭顺便赚一点跑腿费或攒点社区积分。于是产品定位很快就清晰了在正常外卖商城之上叠加一个人帮人的互助层。普通外卖订单走商家、骑手、用户的标准链路互助订单则走用户发布求助单 - 其他用户接单 - 购买并送达 - 双方确认 - 互相评价的链路。一套体系两条履约路径。这个定位直接影响了技术架构——我们的后端在完成标准商品、购物车、订单模块之外还必须有一个高可用的互助任务撮合模块推荐系统也要把互助行为纳入用户行为数据而不只是关注买了什么菜。1.2 为什么把推荐算法放进互助平台很多人会问互助平台要推荐算法干嘛不是有互助大厅按时间排序就行了吗其实不是。互助平台的信息流比外卖列表更容易过载每个人发布的需求五花八门帮带一杯咖啡和拼一份炸鸡完全不是一类如果没有个性化分发大厅里刷两眼就划走了真正想帮忙的人看不到匹配自己的任务。协同过滤在这里干了两件事给用户推荐菜品——基于用户的历史订单、浏览、收藏行为算出他可能喜欢的菜提升普通外卖链路的转化率给用户推送互助任务——根据你经常帮 A 同学带饭这类互动记录系统能推断出你和 A 的口味偏好接近进而把他未来发布的求助单优先推给你。把这两件事同时纳入同一个用户行为矩阵里实际上我们构建的是一个“泛消费 社交互动”的联合推荐系统。后面第三节我会重点讲算法怎么落地第四节会讲互助模块和算法怎么互相喂数据。2. Java Vue 前后端架构拆解这块项目的地基2.1 后端技术栈选择与理由后端我是用 Java 的 Spring Boot 做的单体应用但内部按模块拆得很清楚。选择单体的原因很简单项目核心是推荐算法和互助撮合业务复杂度还没到必须微服务的程度单体架构部署简单、联调快一个人或小团队能 hold 住。Spring Boot 2.7 Maven稳定社区资料多遇到问题好搜MyBatis-Plus数据层用国产增强插件CRUD 写得少分页也省事MySQL 8.0业务数据主存储Redis做缓存、分布式锁、推荐热榜的临时存储XXL-Job 或 Spring Task跑离线推荐任务我最后用的 Spring 自带 Scheduled 加 xxl-job 两套配合本地环境用 Spring Task生产环境抽到 XXL-Job。有一点要提醒推荐算法模块不要直接塞进 Controller 里。我给推荐算法单独开了一个recommend包里面又拆成similarity相似度计算、matrix矩阵构建、rank排序过滤三个子包。这样即使后面你想把算法部分替换成其他方案也不会牵动业务代码。2.2 数据模型设计要点外卖商城那些常规表——用户表、商家表、菜品表、订单表、购物车表我就不逐字段说了重点说几组跟本项目特色相关的表设计。用户行为表user_behavior。这张表是协同过滤的数据基础我们按行为类型记录CREATE TABLE user_behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, item_id BIGINT NOT NULL COMMENT 菜品或互助任务ID, behavior_type TINYINT NOT NULL COMMENT 1浏览 2收藏 3加购 4下单 5互助完成, behavior_weight DECIMAL(4,2) DEFAULT 1.00, create_time DATETIME NOT NULL, INDEX idx_user_time (user_id, create_time), INDEX idx_item (item_id) ) COMMENT 用户行为流水表;互助任务表help_order。它比普通订单多几个关键的字段直接决定业务流程走向字段说明order_status待接单 / 已接单 / 已完成 / 已取消publish_user_id发布求助的人accept_user_id接单的人task_type帮买 / 帮带 / 拼单reward_points完成后发布方赠送给接单方的积分location_range可接单位置范围JSON 或经纬度评分/反馈表rating_feedback。普通订单完成后可以打分互助完成后双方也可以互评。这里的数据会回写到协同过滤的评分矩阵中。2.3 前端技术选型与页面规划前端用了Vue 3 Vite Pinia Vue Router组件库一开始选 Element Plus后来发现用户主要在手机上用就换了 Vant 4。说实话如果一开始就定位移动端 H5直接上 Vant 更省事Element Plus 的组件在触屏上交互感差不少。页面规划大概分五个模块首页推荐流菜品卡片 今日互助混合流菜品详情/商家页点餐入口购物车与结算普通外卖下单链路互助大厅滚动列表展示求助单个人中心订单、发布记录、评分、信用分。Vue 这块我的经验是页面好画状态难管。购物车数据、用户地理位置、未读互助消息这些跨页面共享的状态一定要在 Pinia 里统一管理否则后期会出现同一个订单在不同页面金额对不上的诡异 bug。3. 协同过滤算法落地从行为日志到个性化菜品推荐这是整个项目含金量最高的部分我单独拿出来讲。3.1 构建用户-物品隐式反馈矩阵协同过滤第一步永远是数据。显式评分五星打分在绝大部分电商里是稀疏得可怜的数据不能只靠它。我们做了一个隐式反馈权重表把用户各种动作折成虚拟分值行为权重说明浏览菜品1.0弱信号防刷量要限流收藏2.0较明确偏好加购3.0购买意图强下单完成5.0最可信信号互助任务完成4.0体现了跨用户的兴趣关联矩阵的存储没有用二维数组暴力铺开因为用户数和菜品数一上来就是几万乘几十万的规模稀疏矩阵直接内存爆炸。我用的是稀疏行向量存储即MapLong, MapLong, Double按用户维度组织在 Java 里本质是嵌套哈希表。后面算相似度时只遍历非零项即可。3.2 相似度计算与两种过滤方式的选择核心公式是余弦相似度similarity(A, B) (A·B 的非零交集权重积) / (|A|的模 × |B|的模)当两个用户/两个物品只是杰卡德交集大小不同时余弦能把评分尺度差异也考虑进去比简单交集计数更合理。在 ItemCF 和 UserCF 的选择上我做了明确取舍UserCF用户找用户适合新闻、社交内容因为兴趣漂移快需要实时找相似用户。短板是物品上新时根本推不出去因为新物品没有任何用户行为ItemCF物品找物品适合电商、外卖因为物品是稳定的口味关联不容易变。用户只要在 A 菜品上有点击就能通过和 A 常一起被购买的 B、C 物品做推荐。我们以ItemCF 为主在互助任务推荐上用UserCF 做辅助——你会发现帮你买过同一家奶茶的人下次发布的求助单也大概率是你想带的。两者并不冲突混合打分即可。3.3 推荐生成的完整流程与 Java 实现片段整个推荐生成分两个阶段离线阶段定时任务每天凌晨跑一次构建物品相似度矩阵并且只保留每个物品 Top 50 相似物品避免矩阵爆炸在线阶段用户请求推荐时把该用户最近 30 天有正向行为的物品取出来查这些物品的相似物品按加权打分生成候选集再过滤掉已下单、已下架、超出配送范围的最后排序返回。核心代码有点像这样// 对目标用户做 ItemCF 推荐 public ListRecommendItem recommend(Long userId, Integer topN) { // 1. 取用户近期正反馈物品 ListLong userItems behaviorMapper.selectPositiveItems(userId, 30); MapLong, Double scoreMap new HashMap(); for (Long itemId : userItems) { // 2. 取出与 itemId 最相似的 TopN 物品 ListSimilarItem simItems itemSimilarityService.getTopSimilar(itemId, 50); double baseScore getUserBehaviorWeight(userId, itemId); for (SimilarItem sim : simItems) { scoreMap.merge( sim.getTargetItemId(), baseScore * sim.getSimilarity(), Double::sum ); } } // 3. 过滤用户已经买过/加购过的以及下架商品 ListLong filtered filterInvalidItems(scoreMap.keySet(), userId); // 4. 按分数从大到小排序 return filtered.stream() .sorted((a, b) - Double.compare(scoreMap.get(b), scoreMap.get(a))) .limit(topN) .map(this::toRecommendItem) .collect(Collectors.toList()); }注意一个小细节scoreMap.merge里用的累加方式其实是 ItemCF 里加权的简化版。严格来说更标准的打分公式是“对每个相似物品累加 用户与历史物品的兴趣分 × 物品间相似度”上面的baseScore就是用户对历史物品的虚拟评分这样已经能体现常用物品权重越高推荐结果越偏向他最在意的口味。相似度矩阵离线构建的核心逻辑// 计算物品 i 和已存在的候选物品 j 的余弦相似度 double cosineSimilarity(MapLong, Double iVector, MapLong, Double jVector) { SetLong commonUsers new HashSet(iVector.keySet()); commonUsers.retainAll(jVector.keySet()); double dot 0.0; for (Long uid : commonUsers) { dot iVector.get(uid) * jVector.get(uid); } double normI norm(iVector); double normJ norm(jVector); return normI 0 || normJ 0 ? 0.0 : dot / (normI * normJ); }生产环境一定要把commonUsers提前控制在合理范围。可以先用热门物品粗筛一遍只有共同购买人数大于某个阈值比如 ≥3时才计算相似度这一步能砍掉大量噪声。3.4 冷启动问题的缓解方案冷启动是推荐系统里绕不开的难题新用户没有行为数据算法直接失灵。我们的方案是多路召回兜底新用户冷启动按当前时间段的销量排行 当前地理位置附近的热门菜品推荐。比如午饭时段推荐周边销量最高的 10 个菜而不是强行走协同过滤新菜品冷启动新品如果没有任何行为就根据分类口味标签找相似老品。我们给菜品维护了category_id、flavor_tags字段如 微辣、重辣、清淡新品直接计算标签相似度做替代推荐互助任务的冷启动求助单发布后先按地理位置 领域标签广播给周边 1 公里以内的活跃用户等到行为积累后再进入协同过滤池。上线几个月后复盘冷启动兜底方案贡献了大约 35% 的曝光点击证明这部分不能省。4. 互助模块的设计与履约流程这事没看起来那么简单互助听起来很美真做成产品细节非常多。我按业务规则、并发控制、算法联动三个角度来讲。4.1 互助任务的业务规则一个求助单的生命周期是这样跑的发布 - 审核 - 大厅展示 - 有人接单 - 履行中 - 确认完成 - 双方互评 - 积分结算当用户在互助大厅看到一条帮带楼下瑞幸生椰拿铁的求助单可以点接单然后根据求助单上的指定地址去购买完成后拍照上传凭证发布方确认收货系统自动把代购费 奖励积分转入接单方账户。设计的时候有个特别容易忽略的点互助任务要不要有价格上限我们一开始没有限制结果有人发布帮忙代买一台冰箱之类的离谱需求接单的人根本没有履约能力。后来加了限制互助任务仅限餐饮、饮品、轻小商品金额上限比如 200 元超过的走普通外卖或跑腿平台。这样把业务边界画清楚产品才不会被少数极端需求拖垮。4.2 接单过程并发控制与防刷互助大厅里一条热门求助单比如谁帮我带一份公司楼下某店的牛肉面可能在发布后几十秒内被好几个人同时看到并点击接单。如果不做控制就会出现一单多人抢、重复购买的乱象。我们用了 Redis 分布式锁String lockKey help:accept: helpOrderId; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, userId, 5, TimeUnit.SECONDS); if (!locked) { throw new BizException(手慢了该求助单已被接走); } try { // 更新订单状态并校验发布者和接单者不能是同一人 helpOrderService.accept(helpOrderId, userId); } finally { redisTemplate.delete(lockKey); }锁过期时间设 5 秒因为正常更新订单状态的操作远用不了这么久万一事务卡住也不至于锁死所有人。除了锁还添加了状态校验只有order_status 0待接单的求助单才允许接单而且数据库更新语句要带WHERE order_status 0这是防误操作的兜底。防刷方面我们比较常规每天限制最多发布 5 条求助单接单后未完成率超过 20% 的用户限制接单 3 天同一 IP 或同一设备短时间内频繁接单触发风控。4.3 互助行为怎么反过来喂给推荐算法这一节是项目的点睛之笔。互助行为不应该只是独立业务它实际上是一类高质量的信任行为。当用户 A 接了用户 B 的求助单并完成购买我们视为一次用户间正向关联如果多次完成同一类任务比如 A 总是帮人带咖啡我们会把这种行为回写到user_behavior表里item_id 记成具体的菜品/商家。这样用户 B 未来在商城浏览时很可能发现系统给他推了你常帮助的人喜欢的那家咖啡店。这比普通协同过滤更有人情味用户体验上是一个记忆点。实现上互助完成的回调里除了更新订单状态还会往行为流水表写两条数据behaviorRecordService.save(userId, itemId, Constants.BEHAVIOR_HELP_COMPLETE, 4.0); behaviorRecordService.save(targetUserId, itemId, Constants.BEHAVIOR_IMPLICIT_VIEW, 1.0);一条是接单方的行为另一条是发布方对目标物品的隐式浏览这样算法就能自然学到关联了。5. Vue 前端推荐展示的细节列表容易做转化率难做前端这块很多人觉得只是把后端接口返回的数据渲染出来。真正做下来你会发现推荐算法再好前端展示差点意思转化率照样惨。我们迭代了好几版最后总结出几个关键细节。5.1 推荐流页面的设计思路首页推荐流本质上是一个信息流页面。我的做法是菜品推荐区 互助任务区混合展示按 4:1 的比例间隔插入。菜品推荐区用为你推荐卡片卡片头部显示菜品图和评分中部是名称、价格、销量底部是一行小字因为你常点 XX推荐这道菜。这一行是后期加上的加完后点击率涨了快两成说明给出推荐理由能显著提升用户信任。互助任务区则采用列表卡片关键信息直接外露任务类型、金额、取货位置、发布时时长。用户不用点进去就知道要不要接减少操作成本。骨架屏一定要做。推荐流是异步接口如果上来一片空白用户直接退出。我们用 Vant 的Skeleton组件做了两屏占位加载完成后平滑切入真实内容体验会好很多。5.2 状态管理与后端接口前端状态管理用 Pinia仓库拆成四个userStore用户信息、登录态、信用分cartStore购物车数据购物车在互助和普通下单链路都要用且退货、改价逻辑复杂拆出来单独管理recommendStore首页推荐流的展示数据、分页游标、下拉刷新状态helpStore互助大厅的任务列表、自己发布的求助单状态。推荐接口的设计要注意响应结构。我没有把菜品列表和互助任务列表拆成两个接口让前端自己合并而是后端直接返回一个混合列表{ code: 0, data: { feed: [ { type: dish, item: { id: 101, name: 椒麻鸡饭 } }, { type: help, item: { id: 88, title: 帮带楼下奶茶 } } ], page: 2, hasMore: true } }这样前端渲染逻辑非常单一也方便之后调整推荐流比例不用动前端代码。5.3 从推荐了但不想点到推荐了就点的调整第一次上线时我们的推荐流效果很差差到什么程度曝光点击率不到 1%。后来复盘出来三个问题图片加载太慢。菜品图没做 WebP 压缩首屏渲染卡顿用户早滑走了。压缩后体积降了 60%点击率明显回升价格不够直观。推荐卡片里价格是灰色小字用户扫一眼看不到重点。后来把价格放大置顶并加了满减标签点击率提升明显缺少时间偏好。午饭时间推荐的全是宵夜类菜品用户当然不会点。后来会传给后端当前时段参数让推荐算法按早/午/晚/夜宵四个时段分别维护相似度矩阵。这三点都是细节但每个都直接影响结果。我的体会是推荐系统不是算法调完就完了前端和算法必须一起迭代。6. 上线实测和踩坑记录推荐不准、响应慢、抢单乱最后这部分是我觉得最有价值的部分。全是实际跑出来才发现的坑希望能帮后来人避开。6.1 推荐跑偏为什么 ItemCF 在冷启动期像在乱推项目上线第一天我们的推荐流就出了洋相——给一个点了三次轻食沙拉的女生推了三份炸鸡。原因是数据量太小只有几十个用户的行为记录物品相似度矩阵过于稀疏两两之间的共同购买用户数普遍只有 1导致噪声完全压过真实信号。当时那个矩阵构建出来的相似度几乎等价于这俩菜被同一个人买过一次毫无统计意义。这个问题我们通过两点解决提高共同评分最小阈值从共同购买人数 ≥1提高到 ≥3宁可少推荐几个也不要乱推荐先用规则推荐顶一阵冷启动期内用品类匹配 口味标签的规则兜底等行为数据积累到一定体量再切换协同过滤。这个教训让我明白推荐系统上线前必须设一个数据冷却期别指望算法第一天就出奇迹。6.2 相似度矩阵计算性能问题离线计算物品两两相似度最粗暴的写法是双重循环所有物品复杂度 O(n²)。我们当时菜品表里约 5000 个有效菜品算下来 2500 万次计算虽然不算多但如果每个都要计算余弦相似度并比较共同用户实际耗时非常惊人。优化办法有三个效果都很明显倒排索引剪枝先构建物品 - 用户列表的倒排索引相似度只需要在任意两个共享过至少一个用户的物品之间算瞬间砍掉大量无关联对限制相似物品长度每行相似度只保留 Top 50排序后截断后续在线查询只读这 50 条结果缓存相似度矩阵结果序列化到 Redis设置 24 小时过期避免每次重启服务重算。实测峰值计算时间从原来的 40 多分钟压到 7 分钟左右完全能接受。6.3 互助接单并发问题排查分布式锁加上了按理说应该没问题但测试环境压测时还是出现了同一求助单被两个人同时接走的情况。查了很久最终发现是锁失效导致一个请求拿到了锁但更新操作耗时超过 5 秒锁自动过期另一个请求又拿锁进去了。这个坑其实很典型分布式锁 长事务 的组合很容易出问题。解决思路锁的时间放到 10 秒但更新操作控制在毫秒级最重要的兜底数据库更新时仍然使用乐观锁校验状态即使 Redis 锁失效SQL 也只能成功一条。UPDATE help_order SET order_status 1, accept_user_id #{userId} WHERE id #{orderId} AND order_status 0影响行数为 0说明已经被抢走就直接提示用户手慢了。6.4 一批经验总结与迭代方向做完整套项目我的整体感受是协同过滤算法本身不难难的是把数据收集、计算、存储、前端展示、业务异常全都串起来。一个推荐系统能工作60% 的功夫在线下数据治理和线上降级预案上。后续迭代方向我们列了几个尝试用Slope One 算法做更轻量的近似推荐减少矩阵维护成本引入用户画像标签把口味、价位、配送距离偏好做成显式特征与协同过滤做特征交叉互助推荐可以再细一点比如同一个小区、同一个办公楼的人互助匹配优先级更高考虑接入实时计算引擎把用户刚看了什么实时加入推荐信号而不是只等离线任务。做这个项目踩过的坑不少但真把推荐算法、互助业务、前端表现三者打通的那一刻成就感是实打实的。如果你也在做类似的项目建议从最小的闭环开始先跑通用户行为采集 - ItemCF 推荐 - 前端展示再去叠加互助模块和复杂规则。地基稳了上面盖什么楼都不用怕。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑