拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot与Hadoop双技术栈构建旅游商城个性化推荐系统架构实践

SpringBoot与Hadoop双技术栈构建旅游商城个性化推荐系统架构实践 1. 项目背景与双技术栈选型的真实逻辑1.1 这个项目要解决的到底是什么问题做旅游类的电商项目最常遇到的尴尬局面是商城功能本身不难难在用户来了之后怎么让他愿意留下来逛、愿意下单。宁波这种旅游城市的消费场景很典型——游客到宁波可能只知道天一阁、老外滩、鼓楼这几个地标但到了景区附近周边有哪些值得买的伴手礼、哪家民宿评价好、哪个时段的餐饮优惠券值得领这些信息是散的。游客没有耐心去逐家翻商城如果只是机械地罗列SPU用户体验就是货架不是推荐。所以这个项目定下来时核心目标就一句话基于用户的浏览和消费行为把宁波旅游场景下的周边商品和本地生活服务以个性化推荐的形式呈现在用户面前。这里面牵扯出来的技术问题有三层用户行为数据是海量且持续产生的浏览日志、收藏记录、下单数据一天能积累几十万条甚至上百万条这超出了普通单机MySQL能轻松扛住的分析范畴。推荐计算不能跟用户请求抢在线资源。如果同步算推荐结果接口响应会慢到不可接受。商城业务本身是实时事务订单、库存、支付这些环节要求强一致性不能因为推荐侧的异步任务影响交易链路。换句话说这个项目从设计上就是一个系统里同时存在两种性格完全不同的任务一边是实时在线事务OLTP一边是离线批量分析OLAP和批量计算。SpringBoot和Hadoop这对组合正好各自负责合适的半边。1.2 为什么是SpringBootHadoop而不是一套全栈搞定很多人问我单机能不能做推荐当然能几百个用户、几千条数据用SpringBoot写个内存版的协同过滤绝对不是问题。但如果这是一篇面向真实工程场景的设计复盘我必须说选型不是越复杂越好而是越匹配问题规模越好。SpringBoot的价值在于开箱即用的生态。它能快速把REST接口、MyBatis数据访问、Redis缓存、Quartz定时任务串起来而且对开发者友好团队新成员上手快。宁波旅游推荐商城这类中小型电商系统治理成本低是第一位的SpringBoot没有任何理由不用。Hadoop的角色则不是替代SpringBoot而是承担三件事HDFS提供分布式存储底座把一天几GB到几十GB的原始行为日志原样落盘不丢数据后续跑批任务直接从HDFS读原始数据不干扰线上数据库。YARN负责调度离线计算任务推荐算法中的用户-物品得分计算、相似度矩阵计算都是离线批量跑出来的YARN统一管资源。Hive做数据清洗和特征统计把非结构化的日志转成结构化特征宽表这一步非常关键后面推荐引擎直接读Hive产出的宽表比秒读原始日志要快一个数量级。很多人有一个误区觉得用了Hadoop就必须把MySQL干掉。实际上在这个项目里MySQL依然是业务系统的绝对核心——订单、商品、用户账户都在MySQL。Hadoop是数据侧和分析侧的补充二者是分工关系不是替代关系。1.3 推荐与商城之间如何协作而不是各写各的我在学校毕设和实际项目里见过很多失败的例子推荐模块和商城模块完全是两拨人写的最后对接时发现推荐接口返回的商品ID商城压根没这个商品或者推荐算完的结果不知道存哪里临时存Redis结果Redis一重启全没了。这个项目从第一天就定了协作边界。推荐模块只负责产出用户ID 商品候选列表 推荐理由 分数的推荐结果表存到MySQL的推荐结果表里同时预热一份到Redis。商城模块只负责读取推荐结果按位拼接商品详情、价格、库存信息展示给用户。这么设计有个非常大的好处推荐引擎和商城业务可以并行开发谁都不用等谁。推荐侧只要保证输出格式稳定商城侧只要保证读取逻辑稳定两边就解耦了。你在开发SpringBoot商城时甚至可以用一个Mock接口模拟推荐结果等Hadoop那边的计算作业跑通了再把数据源切过去风险小很多。2. 整体架构设计与数据流转方案2.1 五层逻辑架构拆解整个系统我从逻辑上拆成五层每一层的职责边界在动手编码之前就要画清楚不然后面改起来非常痛苦。层级职责关键技术组件接入层用户访问商城、浏览商品、下单以及行为日志采集上报Nginx、前端页面、埋点SDK、Logstash业务服务层商品管理、订单交易、用户中心、推荐位接口SpringBoot、MyBatis、MySQL、Redis离线计算层日志清洗、画像统计、相似度计算、推荐结果生成Hadoop HDFS、Hive、Spark数据调度层周期触发离线任务控制依赖关系Quartz、Crontab、Azkaban可选数据存储层原始数据、结果数据、缓存数据分域存放HDFS、MySQL、Redis这里想特别强调一下接入层的埋点设计。很多项目后来推荐效果差不是算法不行而是埋点数据根本不能用。我在这个项目里定义的埋点事件统一是userId itemId sceneId actionType timestamp这样的结构actionType枚举有view、collect、cart、order。位置信息比如用户当前是否在景区附近、距离多少也一并记录在扩展字段里。这样Hive做统计时可以直接按位置维度切分用户群这也是宁波旅游推荐区别于通用电商推荐的地方——内容要和地理位置强绑定。2.2 数据从产生到被推荐消费的完整链路数据链路是整个项目的命脉流水线理顺了系统就跑得顺。我当时画了一张非常详细的数据流向图这里用文字完整描述一下比图更详细。第一步用户在商城里产生行为SpringBoot接口把日志异步写入本地消息队列我用的是简单的内存队列加批量写生产环境建议上Kafka但项目初期不用过度设计。第二步日志后台定时把消息批量落盘到HDFS目录按日期分比如/user/tourism/logs/20250401。这一步用Flume或直接写Java调HDFS API都可以。注意一定要按日期分区不然加工Hive表的时候扫全量数据代价巨大。第三步Hive每天晚上跑ETL作业把原始日志解析、去重、清洗字段生成用户行为表user_behavior_fact和商品维度表item_info_dim分区字段是日期。第四步Spark作业读取Hive清洗后的行为数据计算物品相似度生成推荐候选集结果写回Hive推荐结果表同时把当日活跃用户的高分推荐项同步到MySQL和Redis。第五步用户打开商城App或H5页面时SpringBoot的推荐接口先查RedisRedis没有再去MySQL兜底组装数据后返回。整个链路最核心的原则就是实时请求链路尽量短离线计算链路尽量深。用户点进来30毫秒以内必须拿到推荐结果而推荐结果本身可以允许昨天晚上计算出来的不要求每秒钟都更新。2.3 模块边界与开发顺序建议模块边界在项目初期就定好recommend-bootSpringBoot主服务含商品、订单、用户、推荐位接入。recommend-etlHive SQL脚本和Spark Job负责离线处理。recommend-common公共数据结构比如行为事件的DTO、推荐结果的VO这个模块两边都要依赖必须先写。开发顺序上我的建议是先做商城基础功能因为商城是系统可见的骨架能跑通之后再做推荐引擎。反过来如果先把精力耗在算法上商城迟迟没有东西可用项目会陷入算法永远在调参业务永远是空壳的困局。先做出一个商品列表页和订单流程再去接推荐你会发现推荐结果的可视化验证也更容易做——至少你有商品数据可以算ID覆盖了吧。3. 旅游推荐引擎的核心实现细节3.1 为什么选择离线协同过滤作为主力算法推荐算法可选的方案不少基于人口统计学的冷启动推荐、基于内容的标签推荐、协同过滤、矩阵分解、深度学习排序模型。我在这个项目里选了**基于物品的协同过滤ItemCF**作为主力而不是基于用户的协同过滤UserCF也不是一上来就上深度模型。理由非常务实。商城刚开始积累数据时新用户多、行为稀疏UserCF依赖找到相似用户在稀疏数据下效果极差。ItemCF只需要用户对物品的行为不需要用户之间有多相似而且能直接给看了XX的人也看了XX这种可解释的推荐理由用户更容易接受。宁波旅游场景有一个特殊性游客的偏好会随着位置和行程摆动。今天逛人文景区明天可能去海边用ItemCF在物品层面计算相似度更贴近这种短时兴趣漂移而UserCF建模的是长期用户画像响应这种变化会慢半拍。深度模型排序比如DeepFM、双塔模型对特征工程和算力的要求起步就很高在这个体量的项目里容易陷入调了三个月参数精度涨了一个点但用户没感知的泥潭。ItemCF加简单的规则兜底已经能覆盖绝大多数需求。当然这不代表深度学习没有位置。我后期扩展的思路是ItemCF先产出一版粗排序候选集把它当作用户的特征输入进去再接一个逻辑回归或者简单的MLP做精排。但这是后话初期项目不要贪多。3.2 基于用户行为的物品相似度计算ItemCF的核心是计算物品i和物品j之间的相似度公式用的是带热度惩罚的余弦相似度。这里有一个工程上容易出错的地方必须提前说明。朴素公式为sim(i, j) |N(i) ∩ N(j)| / sqrt(|N(i)| * |N(j)|)其中N(i)表示对物品i产生过行为的用户集合。这个公式的问题在于热门商品比如宁波最网红的那家海鲜美食券几乎会被所有人浏览它的相似度会普遍偏高导致推荐结果缺少多样性用户会反复看到那几个爆款。所以我在实际实现中加入了对热门物品的惩罚因子类似亚马逊的经典做法sim(i, j) Σ(u∈N(i)∩N(j)) 1 / log(1 |N(u)|) / sqrt(|N(i)| * |N(j)|)用户的贡献权重随着他行为过的商品数量增加而降低这样那些喜欢浏览海量商品的逛客就不会主导相似度了。实际效果很显著优化后推荐结果的多样性明显提升长尾商品曝光率涨了不少。Spark实现片段大致如下// 读行为数据生成用户-物品倒排表 val userItem spark.sql( |SELECT user_id, item_id |FROM user_behavior_fact |WHERE dt 20250401 AND action_type IN (view, order) |GROUP BY user_id, item_id .stripMargin) // 共现矩阵计算 val itemSim userItem.alias(a) .join(userItem.alias(b), $a.user_id $b.user_id) .filter($a.item_id ! $b.item_id) .groupBy($a.item_id.alias(item_i), $b.item_id.alias(item_j)) .agg(countDistinct($a.user_id).alias(cooccur_users)) .repartition(200, $item_i) // 防止数据倾斜的预处理用Spark跑的核心优势是共现计算天然适合分布式join和reduce几百万条行为数分钟就能出结果。同样的计算量我以前用单机Python跑要好几个小时。3.3 相似度计算的完整作业流程与参数设计ETL作业和推荐作业在调度上是有依赖关系的我在上线时把整个流程拆成了五个步骤每一步都有明确产出物步骤任务产出1原始日志同步HDFS按天分区的原始日志目录2Hive清洗 用户行为表user_behavior_fact用户ID、物品ID、行为时间、行为类型、位置标签3Spark相似度计算 候选生成item_sim_result、user_recall_list按天分区4结果导入MySQL和Redist_recommendation_result全量表Redis当日热数据5商城接口读取推荐位用户端可见的个性化推荐步骤2里有一个很关键的设计清洗时不仅去重还要做时间窗口裁剪。用户购物行为是有时效性的三个月前的浏览行为对今天的推荐来说可能完全是噪音。我在Hive SQL里对行为数据的截止时间做了过滤只保留最近30天的有效行为这样不仅结果更合理而且Spark计算数据量也大幅下降跑批很快。步骤4导MySQL时我用的批量插入而不是逐条插入因为推荐结果全量表更新时可能是几十万条记录逐条插入性能扛不住。批量插入时注意mybatis的rewriteBatchedStatementstrue这个配置开启后插入速度能提升一个量级。3.4 冷启动和兜底策略的落地冷启动是推荐系统永远绕不开的问题。新用户没有任何行为记录你从物品相似度推什么新商品上架没有任何用户行为它永远进不了候选池怎么办我在这套系统里设计了三级兜底第一级新用户直接推荐该城市当季热销榜 位置近景区的商家。这是通用策略虽然个性化不足但对旅游场景其实够用因为游客本身对当地特色商品就有刚需。第二级用户有少量行为后改用规则推荐比如用户浏览过景区A就推荐景区A附近评分最高的三家民宿、两家餐饮券。第三级行为足够丰富时进入ItemCF的正规军。新商品的冷启动我采用的是影子推荐策略把新商品挂到同类目下点击率最高的三个老商品下面作为补充推荐位展示因为它和老商品同属一个分类用ItemCF算出来后自然能跟着老商品被推荐出去算是一种插队机制但实际测试下来效果不错新品曝光率比单独开新商品位高得多。4. 商城业务模块的SpringBoot落地4.1 商品体系与标签设计的取舍旅游周边商城里的商品天然分成两类标品实物商品和非标品本地生活服务。前者比如宁波特产的大礼包、手作年糕、文创书签后者比如民宿券、景区门票餐饮联票、老外滩酒吧优惠券。这两类商品在后端的字段模型上差异很大。我设计的商品表是SPU和SKU分离的。SPU层存通用的名称、描述、主图、分类标签SKU层存价格、库存、属性组合。对于非标品我把可用日期和适用商家作为扩展属性存到JSON字段里。这里要克制不要为了一两个非标属性就大动干戈建十几张扩展表JSON字段前期够用等业务规模上来再拆专项表。标签系统是这个项目的点睛之处。我给每个商品打了多维度标签比如人文历史海鲜美食亲子友好近地铁可预约当日出票等。这些标签一方面用户端展示的时候直接显示增强感知另一方面推荐系统的特征宽表里也会带上做基于内容的补充推荐。标签的维护成本很低运营后台一组多选而已但收益非常大。4.2 推荐结果如何低延迟喂给商城商城侧和推荐侧的数据交互是整个系统最容易出性能瓶颈的地方。我采取的是推送式而非拉取式的设计。后端推荐接口的逻辑是一条直线用户请求 /api/recommendation/{userId} → 查Redis key: rec:user:{userId} → 命中直接返回商品ID列表 → 未命中则查MySQL t_recommendation_result → 回填Redis设置过期时间 → 根据商品ID列表查询商品详情 → 组装推荐理由和排序分值返回为什么不用SpringBoot在线调用Spark或Hive接口去现算推荐因为Spark任务启动就要几秒钟用户等不起而且在线跑批占资源还会让离线任务等待资源。所以推荐的计算和读取彻底分离计算全部离线读取全部走缓存才能保证接口在30毫秒级别返回。推荐理由的文案也是动态拼的。ItemCF结果里存了相似商品IDMySQL的推荐表里存了rec_reason字段用户在页面上看到的因为你浏览了【宁波海鲜干货礼盒】向你推荐【红膏炝蟹】就是从这来的。理由这个东西看起来简单但对点击率的影响非常大同样的位置、同样的商品带推荐理由的点击率高出一大截。4.3 推荐位接口设计细节与缓存告警我最终推荐位接口出参的结构是这样的固定好格式前端完全不用关心推荐引擎内部的事。public class RecommendResponse { private ListRecommendItemVO items; private String strategyType; // ITEM_CF / HOT_SALE / LOCATION private Boolean hasMore; }strategyType这个字段非常有用。前端拿到后可以在UI上做差异化展示比如策略是LOCATION时页面顶部多显示一行基于你当前位置的推荐。同时这个字段也方便排查问题——如果用户投诉推荐的商品莫名其妙按策略类型去翻链路日志很快就能定位是算法问题还是规则问题。缓存这一块我踩过一个隐蔽的坑Redis的过期时间设了24小时结果每天上午推荐位数据是昨天的用户就会觉得这推荐怎么不变了。后来我把过期时间改成了6小时并且配合Quartz任务每天6点、12点、18点三个时间点定时刷新一次。推荐结果并不是实时变化就更好用户反而需要一定的稳定性频繁变化会让用户困惑为什么上午看到的东西下午没了。6小时是我试下来比较合适的节奏。4.4 订单与库存的常规取舍订单中心和库存管理这块我采用的方案是常规但稳妥的MySQL事务保证一致性Redis的预扣减只用于高并发秒杀场景。旅游商城的并发量级通常不会高到需要复杂分布式事务用本地事务就是最优解别把系统搞复杂。有一个经验值得分享旅游商品大多是虚拟凭证类商品门票、券码下单后不需要走实体物流所以我的订单表里没有收货地址字段而是增加了凭证码字段下单成功就生成一个唯一凭证码凭码消费。如果按实物电商的模型来做旅游商城会被地址、物流、运费模板这些字段拖累既设计过度又搞乱了业务。技术选型一定要跟着业务形态走不要先入为主地套框架。5. 项目里踩过最难缠的五个坑5.1 Windows下开发Hadoop程序的环境配置项目开发初期我用的是Windows笔记本直接在IDEA里跑SpringBoot需要连Linux服务器上的Hadoop集群。第一次写测试用例连接HDFS就报了一堆权限错误和Failed to locate the winutils executable。问题根源很简单——Hadoop在Windows本地模式下需要winutils.exe和hadoop.dll才能正常工作。解决办法是下载对应版本我用的是和集群Hadoop版本一致的的winutils放到一个目录里然后在SpringBoot启动类里直接指定System.setProperty(hadoop.home.dir, D:\\hadoop-common-bin);这里要多说一句不要把hadoop.home.dir配置到Linux环境路径上本地和服务器环境要分开配置我用Spring Profile做了环境隔离application-dev.yml和application-prod.yml分开维护避免了反复改路径的麻烦。用YARN连接生产集群的访问控制也不要为了方便直接用超级用户建一个专门的项目账号只授权项目数据目录的读写权限这样既安全又不会误删别的目录。5.2 SpringBoot直连HDFS的版本冲突SpringBoot的版本跟Hadoop客户端的依赖兼容性是我耗时最久的一个坑。一开始我用了spring-hadoop启动器结果发现这个项目已经停止维护了而且依赖版本非常古老跟SpringBoot 2.x组合时冲突一堆。后来我彻底抛弃了spring-hadoop直接在pom.xml里引入Hadoop Client依赖。这里有个关键操作Hadoop依赖传递引入了一堆老版本的基础库比如老Jackson、老Guava会和SpringBoot冲突。必须用exclusion把冲突的依赖全排除掉。我用的是这种方式dependency groupIdorg.apache.hadoop/groupId artifactIdhadoop-client/artifactId version3.3.4/version exclusions exclusion groupIdcom.fasterxml.jackson.core/groupId artifactId*/artifactId /exclusion exclusion groupIdcom.google.guava/groupId artifactIdguava/artifactId /exclusion /exclusions /dependency实际开发时只要不是用Hadoop的JSON解析和序列化功能排除掉这两类依赖基本没有影响。我在写HDFS文件读取时直接用的Hadoop的FileSystemAPI非常稳定。建议你不要在业务代码里直接跟HDFS底层交互太多封装一个HdfsStorageService工具层谁要用谁调用后面要替换成对象存储或者数据湖时只改这个Service内部实现就够了。5.3 Hive小文件与查询性能灾难Hive清洗完数据我连续跑了几天发现查询越来越慢去看HDFS目录里面躺了成千上万个几十KB的小文件。原因是我用的Spark/Hive任务默认输出并行度高每个Reduce都往HDFS写文件产生了大量小文件。小文件在NameNode里占内存不说Hive查询时每个文件都要启动一个map task扫描HDFS目录都会卡半天。解决办法有两个我两个都做了Hive SQL里开启小文件合并设置hive.merge.mapfilestrue和hive.merge.size.per.task268435456256MB。Spark写入Hive表前用coalesce()或repartition()控制分区数写入的分区数量和数据量匹配而不是默认按并行度生成几十个小文件。改完之后同样的ETL任务耗时从原来的半小时降到了十分钟以内查询响应时间更直观从原来平均十几秒降到了两秒内。小文件治理是大数据项目的日常功课不是一次性的要在调度脚本里养成固定配置的习惯。5.4 推荐结果重复与数据倾斜有一次用户反馈推荐页面怎么这几个商品永远在第一排查了一下发现推荐结果表里分数最高的几个商品重复出现了而且来自不同的策略路径——有的来自ItemCF有的来自热销兜底结果合并的时候没有做去重。我最后在推荐结果写入MySQL之前加了内存去重和分组内TopK逻辑按用户ID分组每个用户只取分数最高的前20个不重复商品ID。这个逻辑放在Spark的window函数里一行搞定df.withColumn(rn, row_number().over(Window.partitionBy(user_id).orderBy(desc(score)))) .filter($rn 20)还有一个是数据倾斜问题。我在前面对话里提到过countDistinct那步这个聚合特别容易在热门商品上出现倾斜因为少数热门商品被大量用户访问集中在极少数reduce上。解决方法是加了个随机前缀的动态分区先把物品ID加一个随机数打散中间聚合一次去掉前缀再聚合一次。牺牲一点点时间换来任务稳定不挂值得。5.5 伪分布式集群内存失控为了演示方便开发环境有时候会开一个伪分布式Hadoop跑在本地这个模式对内存极不友好。默认配置下NameNode、DataNode、ResourceManager、NodeManager几个进程全加起来轻轻松松吃掉4GB内存。我开发机只有8GB内存跑Spark作业时经常直接OOM。处理办法是显式限制每个守护进程的内存参数。在hadoop-env.sh里设置export HADOOP_HEAPSIZE_MIN512 export HADOOP_HEAPSIZE_MAX1024 export HADOOP_OPTS-Xms512m -Xmx1024m同时调小了YARN的容器内存参数在yarn-site.xml里设置property nameyarn.nodemanager.resource.memory-mb/name value2048/value /property property nameyarn.scheduler.minimum-allocation-mb/name value256/value /property property nameyarn.scheduler.maximum-allocation-mb/name value1024/value /property这样配置之后本地伪分布式模式跑小规模推荐计算不再卡死了。如果条件允许我更建议用Docker跑Hadoop集群内存和网络隔离都比直接把服务铺在开发机上干净得多。现在好多开发者直接拉Hadoop的Docker镜像一个脚本起三个容器配置统一还不会弄脏宿主机环境属于性价比很高的方案。收尾的一点经验从这个项目里的个人体会来说最值得反复琢磨的还是那句话再好的技术选型也要服务于业务链路本身。Hadoop在这个项目里不是摆设它确实在日志存储、离线分析、推荐计算这几块承担了不可替代的重活SpringBoot角色同样清晰它把所有在线事务处理得干净利落。两者各管一段用数据链路打通而不是在代码层强行耦合。最后再分享一个小技巧。如果你也想做类似的推荐商城项目可以从先用规则推荐跑通全链路、再逐步替换成协同过滤这个节奏走。规则推荐虽然简单但它能让整个数据链路先运转起来你才能拿到真实的行为数据后面做模型才有原料。一个连底表数据质量都保证不了的项目算法再高级也是空中楼阁。先把链路跑直再把算法做准这个顺序永远不会错。
返回列表