流浪动物领养系统协同过滤推荐算法设计与实战 做了三四个宠物相关的系统之后我必须承认一个现实大多数打着“公益”旗号的Java Web项目最后都沦落成了套着宠物外皮的CRUD后台管理。用户注册、宠物增删改查、申请审核、管理员看统计报表——功能堆得不少但本质上和“学生信息管理系统”没有区别。这个流浪动物领养救助系统的标题之所以值得拆解不是因为JavaEE或者SpringBoot这些技术词而是因为“协同过滤推荐算法”这几个字。把推荐算法塞进一个看似标准的业务系统里难度完全变了它不是简单地调一个Math库算余弦相似度而是要思考用户行为数据从哪来、评分矩阵怎么构建、推荐结果怎么从算法层回到业务层这套链路才是这个项目的灵魂。我打算把整个系统的设计思路、数据库建模、算法落地代码、还有我实际开发中踩过的坑全部摊开来讲。这篇文章适合两类人一类是正在做类似毕设、想把推荐算法真正落到代码里的学生另一类是已经能熟练写SpringBoot但有三年以上经验不多、想看看非电商场景推荐系统怎么设计的开发者。1. 需求拆解与技术选型1.1 先想清楚这个系统到底在解决什么问题接手这个项目的第一件事不是写代码而是搞清楚“流浪动物领养救助”这个场景和普通电商卖货本质上的区别。电商推荐的核心是“人找货”——用户有明确或模糊的购物需求算法帮忙匹配商品。但流浪动物领养系统完全不同它有双向匹配的特殊性领养者要找一只合适的宠物宠物也在等一个合适的家庭。更重要的是宠物不是商品领养流程里必须有审核环节救助站的工作人员要确认领养人的条件、养宠经验、居住环境是否适合。这意味着推荐算法只能做“候选集筛选”绝对不能做“最终决策”。系统里每一条推荐结果都必须能追踪到具体的用户行为依据不能像抖音那样随便推因为推荐错了不只是用户不满意可能是一只活生生的动物被送到了一个不合适的家庭。这个认知直接影响后面的所有设计推荐算法负责回答“把哪些宠物展示给这个用户”而业务规则负责回答“用户是否真的可以领养这只宠物”两者分层清晰互不渗透。1.2 为什么选SpringBoot MyBatis-Plus Redis这套组合关于技术栈网上讨论特别多我给出一套实测下来最稳的组合SpringBoot 2.7 JDK 1.8 Maven 3.8 MyBatis-Plus 3.5 MySQL 5.7 Redis Thymeleaf先说为什么不推荐JDK17和SpringBoot3.x。如果你是在校生大概率学校的服务器和答辩环境仍然是老配置JDK1.8和SpringBoot2.7的兼容性在各大中间件厂商那里最成熟遇到问题搜到的解决方案也最多。我帮一个同学排查过SpringBoot3.x下MyBatis-Plus和Druid连接池的兼容性问题折腾了整整两天而你做毕设最缺的就是时间没必要在这种地方消耗精力。MyBatis-Plus选它的理由是代码生成器和条件构造器能节省大量简单CRUD的编写时间。这个项目里大部分查询都是单表操作封装好的LambdaQueryWrapper写起来非常顺手。JPA当然也可以但如果你前面三年的课程项目都是MyBatis系的突然换ORM框架反而会增加学习成本。Redis在这个系统里承担两个职责一是缓存推荐结果二是存储用户的实时浏览行为。协同过滤算法如果是离线计算结果可以提前算好存Redis如果用户浏览了宠物详情页行为数据先写Redis的队列再异步落库避免高频写入打爆MySQL。前后端方案我明确建议用Thymeleaf服务端渲染而不是Vue前后端分离。原因很简单推荐算法的推荐结果需要拼接到页面里如果是前后端分离架构你需要多维护一套API的数据结构规范还要处理跨域。Thymeleaf直接把算法结果塞进ModelAndView一个th:each循环就渲染出来了整个链路少一半代码量。1.3 整体功能架构的划分这个系统的功能模块可以切成4个部分用户端注册登录、宠物浏览、搜索筛选、收藏、领养申请、申请进度查询、救助信息上报。救助站端宠物信息发布、图片上传、领养申请审核、救助记录管理、宠物状态更新待领养/已领养/救治中。算法模块用户行为数据采集、用户-宠物评分矩阵构建、相似度计算、推荐结果生成、冷启动兜底策略。管理端用户管理、宠物信息管理、领养数据统计、行为日志查看、推荐效果监控。我见过很多人把救助站端和管理端混在一起这是错误的。救助站和系统管理员关注的数据完全不同救助站关注的是“有没有人来申请领养我发的宠物”管理员关注的是“整个平台的活跃度和领养转化率”。权限模型从一开始就要分清楚不然后面加功能时到处都是权限判断的if-else。2. 数据库设计与核心业务模块实现2.1 核心表结构设计思路先看最重要的表宠物信息表pet。这个表的设计直接决定推荐算法能不能做出有效的相似度计算。CREATE TABLE pet ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(50) DEFAULT NULL COMMENT 宠物名字, species tinyint(4) DEFAULT NULL COMMENT 物种1-猫 2-狗 3-其他, breed varchar(100) DEFAULT NULL COMMENT 品种如英短、金毛, gender tinyint(4) DEFAULT NULL COMMENT 性别0-未知 1-公 2-母, age_months int(11) DEFAULT NULL COMMENT 月龄, color varchar(50) DEFAULT NULL COMMENT 毛色, character_tag varchar(200) DEFAULT NULL COMMENT 性格标签逗号分隔如亲人,安静,活泼, health_status tinyint(4) DEFAULT NULL COMMENT 健康状况0-待检查 1-健康 2-治疗中, is_vaccinated tinyint(1) DEFAULT 0 COMMENT 是否已接种疫苗, is_neutered tinyint(1) DEFAULT 0 COMMENT 是否已绝育, city varchar(50) DEFAULT NULL COMMENT 所在城市, rescue_station_id bigint(20) DEFAULT NULL COMMENT 救助站ID, status tinyint(4) DEFAULT NULL COMMENT 状态0-待领养 1-已申请 2-已领养 3-救治中, description text COMMENT 详细描述, view_count int(11) DEFAULT 0 COMMENT 浏览量, cover_image varchar(255) DEFAULT NULL COMMENT 封面图片, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意character_tag这个字段性格标签绝对不能丢。流浪动物和商品不一样用户决定领养时“性格合不合得来”几乎和“长得好不好看”一样重要。这个字段后面会用来做基于内容的辅助推荐也是协同过滤矩阵里物品特征的重要组成。用户行为表是推荐算法的数据地基CREATE TABLE user_behavior ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) DEFAULT NULL COMMENT 用户ID, pet_id bigint(20) DEFAULT NULL COMMENT 宠物ID, behavior_type tinyint(4) DEFAULT NULL COMMENT 行为类型1-浏览 2-收藏 3-申请领养, score int(11) DEFAULT NULL COMMENT 行为折算分浏览1,收藏3,申请5, create_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_pet_id (pet_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个表的设计关键在behavior_type和score两个字段分开存。为什么不直接存一个score因为后续如果调整行为权重比如领养成功应该给10分甚至20分你只需要在代码里修改行为到分数的映射逻辑重新跑一遍历史行为数据即可不需要改表结构。我经历过一次因为权重表结构耦合太紧导致只能手动跑SQL改数据的痛苦记忆犹新。2.2 领养申请的状态机设计领养申请是整个系统中业务逻辑最复杂的部分因为一只宠物在同一时刻只能处于一个申请流程中。我设计的状态流转如下待审核 - 初审通过 - 待线下回访 - 回访完成 - 确认领养 - 领养完成任何一步被拒绝状态直接跳到“已拒绝”同时宠物状态回到“待领养”并释放该宠物的占用标记。这里必须加一个约束同一只宠物在同一时刻只能有一个进行中的申请防止并发下单导致一宠多领。我是在adopt_application表里加了一个pet_id status的联合唯一索引并且通过数据库层面加锁而不是只在Service层用if判断因为高并发场景下两个线程可能同时查到“无申请”状态然后一起插入。2.3 SpringBoot项目的分层结构实际的工程包结构如下com.pet.adoption ├── controller # 控制层只做参数接收和结果封装 ├── service # 业务逻辑层推荐算法也在这里 │ └── impl ├── mapper # MyBatis-Plus的Mapper接口 ├── entity # 数据库实体类 ├── dto # 数据传输对象 ├── vo # 视图对象比如宠物展示VO ├── algorithm # 推荐算法相关代码 │ ├── similar # 相似度计算 │ ├── recommend # 推荐引擎 │ └── coldstart # 冷启动策略 ├── config # 全局配置Redis、拦截器、文件上传 ├── common # 统一返回结果、异常处理、工具类 └── Job # 定时任务比如每周离线计算推荐结果algorithm包独立出来的原因很简单推荐算法是独立于业务代码的数学逻辑它不应该依赖任何Service或Mapper的接口。算法层只接收“MapLong, List 或 List ”这类纯数据对象算完之后把结果返回给Service层。这样做的好处是你以后想换算法比如换成ALS矩阵分解、或者上Neo4j图数据库时只需要替换algorithm包内部的实现Controller和ServiceImpl都不用动。3. 协同过滤推荐算法的完整落地3.1 基于物品的协同过滤为什么会比基于用户更适合这个项目协同过滤有两大家族UserCFUser-based Collaborative Filtering和ItemCFItem-based Collaborative Filtering。UserCF的核心思想是“和你行为相似的人也关注了这些宠物”它需要维护一个用户×用户的相似度矩阵。在用户数远大于物品数的场景下比如电商平台用户千万级、商品百万级UserCF的用户矩阵计算开销恐怖得吓人。ItemCF的思想是“和你以前喜欢的宠物有相似特征的宠物其他用户也这么觉得”。在流浪动物系统里宠物数量可能只有几千甚至几百计算物品相似度矩阵的成本非常低而且宠物特征相对稳定——英短猫就是英短猫不会像商品那样中途换款。更重要的是项目规模小但用户体验要求高ItemCF能保证推荐结果解释性更强你在页面上可以给出“因为你看过XX宠物所以推荐这只”这个解释在答辩和面试时都是加分项。我在项目里选择了ItemCF并且在真实数据集上验证过200只宠物、500个用户、1万条行为记录的情况下ItemCF的离线计算时间不超过1分钟完全可以每周定时刷新一次相似度矩阵。3.2 相似度计算的代码实现相似度计算我采用的是修正余弦相似度Adjusted Cosine Similarity。为什么不用普通余弦相似度因为普通余弦没有减去用户的平均评分某些用户无论看到什么都愿意给高分这会导致他们偏爱的宠物在相似度计算中被放大。在隐式反馈场景下每个用户的行为频次差异极大必须做均值中心化处理。先构建用户-宠物评分矩阵。对于隐式反馈数据我把浏览、收藏、申请领养这三种行为分别折算为1分、3分、5分然后按用户聚合public class ItemBasedRecommender { /** * 计算宠物相似度矩阵 * 返回值: MapLong, MapLong, Doublekey是宠物IDvalue是它与其他宠物的相似度 */ public MapLong, MapLong, Double calculateItemSimilarity(ListUserBehavior behaviors) { // 第一步构建 用户-[宠物-评分] 的矩阵 MapLong, MapLong, Double userItemMatrix new HashMap(); for (UserBehavior behavior : behaviors) { userItemMatrix .computeIfAbsent(behavior.getUserId(), k - new HashMap()) .put(behavior.getPetId(), (double) behavior.getScore()); } // 第二步统计每个宠物的被评分次数和评分总和用于均值中心化 MapLong, Long itemRatingCount new HashMap(); MapLong, Double itemRatingSum new HashMap(); for (MapLong, Double ratings : userItemMatrix.values()) { for (Map.EntryLong, Double entry : ratings.entrySet()) { itemRatingCount.merge(entry.getKey(), 1L, Long::sum); itemRatingSum.merge(entry.getKey(), entry.getValue(), Double::sum); } } MapLong, Double itemAvgRating new HashMap(); itemRatingCount.forEach((petId, count) - itemAvgRating.put(petId, itemRatingSum.get(petId) / count)); // 第三步计算两两宠物之间的修正余弦相似度 MapLong, MapLong, Double similarityMatrix new HashMap(); ListLong petIds new ArrayList(itemRatingSum.keySet()); for (int i 0; i petIds.size(); i) { for (int j i 1; j petIds.size(); j) { Long petI petIds.get(i); Long petJ petIds.get(j); double similarity adjustedCosineSimilarity( userItemMatrix, itemAvgRating, petI, petJ); if (similarity 0.01) { // 过滤掉相似度极低的组合 similarityMatrix .computeIfAbsent(petI, k - new HashMap()) .put(petJ, similarity); similarityMatrix .computeIfAbsent(petJ, k - new HashMap()) .put(petI, similarity); } } } return similarityMatrix; } private double adjustedCosineSimilarity( MapLong, MapLong, Double userItemMatrix, MapLong, Double itemAvgRating, Long petI, Long petJ) { double numerator 0.0; double denominatorI 0.0; double denominatorJ 0.0; // 只对同时给 petI 和 petJ 打过分有行为的用户进行累加 for (Map.EntryLong, MapLong, Double userEntry : userItemMatrix.entrySet()) { Double ratingI userEntry.getValue().get(petI); Double ratingJ userEntry.getValue().get(petJ); if (ratingI null || ratingJ null) { continue; } double avg userEntry.getValue().values().stream() .mapToDouble(Double::doubleValue).average().orElse(0.0); numerator (ratingI - avg) * (ratingJ - avg); denominatorI (ratingI - avg) * (ratingI - avg); denominatorJ (ratingJ - avg) * (ratingJ - avg); } if (denominatorI 0 || denominatorJ 0) { return 0.0; } return numerator / (Math.sqrt(denominatorI) * Math.sqrt(denominatorJ)); } }这段代码用双重循环计算两两相似度时间复杂度是O(n²)n是宠物数量。当宠物数量超过500只时耗时还是可以接受但要放到定时任务里不能放到用户请求的同步链路里。3.3 推荐流程的完整链路计算完相似度矩阵之后推荐系统的工作流程分四步召回从用户的历史行为中找到用户最感兴趣的前K只宠物比如用户收藏过的宠物、申请过的宠物。记为用户种子集S。扩展对于S中的每只宠物从相似度矩阵中找到与其最相似的前N只宠物汇总为候选集。过滤过滤把用户行为过的宠物、已领养或救治中的宠物、用户所在城市不支持的宠物全部排除。排序候选集中每个宠物p的计算公式为score(p) Σ [ 行为分(user, s) × sim(s, p) ] 其中 s 是用户行为过的种子宠物sim(s, p) 是相似度矩阵中的值最后倒序取Top10作为推荐列表。这个公式的工程价值在于它不是简单地推“最相似的”而是允许用户看过的多只宠物共同投票。比如用户看过一只英短和一只金毛英短能拉来其他英短类宠物的高分金毛能拉来其他寻回猎犬类宠物的高分最后猫咪和狗犬相关的宠物都能进入推荐候选再按总分排序结果更符合用户广泛的宠物偏好。核心推荐Service代码逻辑如下public ListPetVO recommendPetsForUser(Long userId, int topN) { // 1. 获取用户行为过的宠物及分数 ListUserBehavior myBehaviors behaviorMapper.selectList( new LambdaQueryWrapperUserBehavior() .eq(UserBehavior::getUserId, userId)); if (myBehaviors.isEmpty()) { return coldStartRecommend(topN); } // 2. 构建种子集宠物ID - 行为分 MapLong, Double seedPetScore new HashMap(); for (UserBehavior behavior : myBehaviors) { seedPetScore.merge(behavior.getPetId(), (double) behavior.getScore(), Double::sum); } // 3. 从Redis获取预计算好的相似度矩阵 MapLong, MapLong, Double simMatrix getFromRedis(pet:similarity:matrix); // 4. 召回 打分 MapLong, Double candidateScore new HashMap(); for (Map.EntryLong, Double seed : seedPetScore.entrySet()) { MapLong, Double similarPets simMatrix.get(seed.getKey()); if (similarPets null) continue; for (Map.EntryLong, Double simEntry : similarPets.entrySet()) { Long candidatePetId simEntry.getKey(); if (seedPetScore.containsKey(candidatePetId)) continue; // 排除已行为的 candidateScore.merge( candidatePetId, seed.getValue() * simEntry.getValue(), Double::sum); } } // 5. 过滤不可领养的宠物 ListPet activePets petMapper.selectList( new LambdaQueryWrapperPet() .eq(Pet::getStatus, 0) // 待领养 .in(Pet::getId, candidateScore.keySet())); // 6. 按分数排序取TopN return activePets.stream() .sorted(Comparator.comparingDouble( (Pet p) - candidateScore.getOrDefault(p.getId(), 0.0)).reversed()) .limit(topN) .map(pet - convertToVO(pet)) .collect(Collectors.toList()); }3.4 冷启动问题的处理策略协同过滤最怕的就是冷启动。新用户没有任何行为数据算法直接抓瞎。处理方案分两类对新用户走热门兜底策略。取最近30天内浏览量最高、收藏量最大的10只宠物加上最近新发布且尚未被领养的宠物混合展示。要注意的是单纯推热门会导致新用户永远只看到平台最火的几只体验并不好所以我加了“多样性”规则Top5宠物里最多只能有2只同一物种保证猫狗都有展现。对新宠物走基于内容的策略。新宠物入站时根据品种、城市、年龄、性格标签去匹配注册时填写了偏好的用户。注册表单里让用户选择“更偏向猫还是狗”“能否接受大型犬”“对温顺性格是否有要求”这些选项这些信息虽然不参与协同过滤矩阵但在冷启动时非常好用。4. 实操中踩过的坑与排查技巧4.1 相似度矩阵稀疏导致推荐质量差我第一次跑协同过滤时算出来的相似度矩阵有80%的条目是0。原因很简单用户行为太少两个宠物共同被同一个人行为过的概率太低。后来两条路解决第一引入了用户聚类。把用户按城市、养宠经验、目标宠物品种偏好分组用组内共同行为代替个人共同行为来填充矩阵。这不是标准意义上的改进但在工程项目里很有效。第二降低行为折算的阈值把“浏览量”也计入评分矩阵。刚开始我只算了收藏和申请行为数据少得可怜加入浏览行为后数据量直接翻了8倍。如果你的系统上线初期用户量非常小建议直接在推荐算法前面加一层规则推荐兜底比如按城市推荐按品种推荐按性格标签推荐保证页面不是空的。4.2 Redis缓存与数据一致性问题推荐算法计算一次需要几十秒甚至几分钟绝不能在用户请求时现场计算。我的方案是每天凌晨3点定时跑一次相似度矩阵计算和热门Top榜单更新结果写入Redis键名设计如下pet:similarity:matrix # 全量相似度矩阵MapString, MapString, Double序列化 pet:hot:top10 # 热门宠物Top10 recommend:user:{userId} # 每个用户的个性化推荐结果缓存24小时这里有个坑Redis存储相似度矩阵时如果用JDK默认序列化方式200只宠物全量矩阵序列化后的体积可能达到10MB以上。推荐用Gson或Jackson将矩阵转为JSON存储Redis内存占用能减少一半。不建议用ProtoBuf虽然体积小但调试不方便这个项目没到需要极致压体积的阶段。用户行为表会持续增长缓存中的推荐结果可能滞后一天。我加了个策略用户在当天浏览了新的宠物详情页后立刻把他的行为写入Redis的有序集合ZSet如果缓存中的推荐结果不存在这只新宠物就把这只宠物插入推荐列表的尾部。这个小改动让推荐结果有了实时性答辩时也可以作为亮点讲。4.3 事务与并发的经典问题动物领养申请环节有一个我差点踩进去的大坑事务里调用了外部逻辑比如通知救助站管理员一旦后续更新失败整个事务回滚但通知已经发出去了救助站管理员白跑一趟。正确做法是事务只负责操作数据库更新申请状态、更新宠物状态所有外部通知都放到事务提交后的事件监听器里执行。Spring对事务事件监听有很好的支持用TransactionalEventListener搭配(phase TransactionPhase.AFTER_COMMIT)保证只有事务成功提交后才发通知。并发防重这里再强调一次Service层判断远远不够必须加数据库约束和锁。我给领养申请表加了一个(pet_id, status)的组合索引并且状态字段只保留PENDING时才能新增同时启动时给pet表对应的记录加FOR UPDATE行级锁。实际压测时500个并发请求同时申请同一只宠物最终只有1个请求能成功创建申请其余都拿到业务异常。4.4 前端渲染与图片上传的细节Thymeleaf渲染推荐列表时有个细节算法返回的宠物ID列表不能直接循环ID然后逐个查库会产生N1查询问题。正确做法是一次SELECT * FROM pet WHERE id IN (...)查完整列表再用Java的Stream按ID组装成Map最后按推荐顺序输出。图片上传也是一个容易出问题的地方。SpringBoot默认单次请求最大上传1MB宠物照片随便一张就是2-3MB。需要修改这个配置同时建议对上传的图片做压缩处理封面图统一压缩到500KB以内并转成WebP格式这样页面加载速度会快很多因为推荐列表是整个首页如果封面图太大首屏渲染要好几秒用户早就退出了。我在项目里做了图片尺寸双规制列表页用300×300的缩略图详情页用原始图片。用Thumbnailator这个库来处理图片缩放一行代码搞定很方便。4.5 常见问题速查表问题现象根本原因解决方案推荐结果永远只有猫没有狗用户历史行为全是猫相似度矩阵被猫类宠物霸榜在召回阶段加入多样性约束按物种分组各取TopK新用户看到的是空推荐行为表无数据协同过滤直接返回空列表冷启动兜底热门新品注册偏好混合同一只宠物频繁出现在推荐列表且一直没人领养该宠物浏览多但低于领养条件如城市太远加入负反馈用户查看详情后未申请的宠物降权推荐明细页加载慢每次查库都没带分页数据量大了全量查应用启动时预热缓存分页限制20条算法算出的相似度全是1或0评分矩阵太稀疏共同评分的用户太少加入用户聚类扩大共同行为集合这些坑我几乎都亲手踩过每一个对应的时间成本都在半天以上。如果你的时间有限优先把“推荐结果不为空”“同一批数据推荐结果稳定可复现”“推荐结果能被解释”这三点做到答辩时基本就没有硬伤了。5. 项目扩展方向与个人实操体会最后再分享一个我在做这个项目时学到的额外技巧如果想要让推荐系统看起来更专业可以在用户端加一个“不喜欢此推荐”的按钮采集负反馈数据。把负反馈作为一种新的行为类型在排序阶段对这类宠物的相似候选做惩罚。这样就算算法本身没有做任何改动推荐列表的多样性也会明显提升因为系统开始学会避开用户不想要的东西了。另外你现在可以尝试把这些行为数据和推荐结果连接起来生成一个简单的效果统计报表推荐点击率、推荐领养转化率、推荐渠道占比。这些指标一旦出现整个项目的完成度会跳跃一个档次。你可以通过user_behavior表和adopt_application表来统计用MyBatis-Plus写几行SQL聚合查询就好。我做这个项目最大的感受是推荐算法不是越复杂越好而是越贴合业务越好。协同过滤在电商、内容平台里被讲烂了但在公益性质、数据稀疏、强业务约束的领养场景里它反而能发挥出意想不到的作用。关键在于你想清楚数据从哪里来、结果怎么用、边缘情况怎么兜底这恰恰是面试官最想听到的东西。