1. 项目背景与双技术栈选型的真实逻辑
1.1 这个项目要解决的到底是什么问题
做旅游类的电商项目,最常遇到的尴尬局面是:商城功能本身不难,难在"用户来了之后,怎么让他愿意留下来逛、愿意下单"。宁波这种旅游城市的消费场景很典型——游客到宁波,可能只知道天一阁、老外滩、鼓楼这几个地标,但到了景区附近,周边有哪些值得买的伴手礼、哪家民宿评价好、哪个时段的餐饮优惠券值得领,这些信息是散的。游客没有耐心去逐家翻,商城如果只是机械地罗列SPU,用户体验就是货架,不是推荐。
所以这个项目定下来时,核心目标就一句话:基于用户的浏览和消费行为,把"宁波旅游"场景下的周边商品和本地生活服务,以个性化推荐的形式呈现在用户面前。这里面牵扯出来的技术问题有三层:
- 用户行为数据是海量且持续产生的,浏览日志、收藏记录、下单数据,一天能积累几十万条甚至上百万条,这超出了普通单机MySQL能轻松扛住的分析范畴。
- 推荐计算不能跟用户请求抢在线资源。如果同步算推荐结果,接口响应会慢到不可接受。
- 商城业务本身是实时事务,订单、库存、支付这些环节要求强一致性,不能因为推荐侧的异步任务影响交易链路。
换句话说,这个项目从设计上就是"一个系统里同时存在两种性格完全不同的任务":一边是实时在线事务OLTP,一边是离线批量分析OLAP和批量计算。SpringBoot和Hadoop这对组合,正好各自负责合适的半边。
1.2 为什么是SpringBoot+Hadoop,而不是一套全栈搞定
很多人问我,单机能不能做推荐?当然能,几百个用户、几千条数据,用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的推荐接口先查Redis,Redis没有再去MySQL兜底,组装数据后返回。
整个链路最核心的原则就是:实时请求链路尽量短,离线计算链路尽量深。用户点进来,30毫秒以内必须拿到推荐结果;而推荐结果本身,可以允许昨天晚上计算出来的,不要求每秒钟都更新。
2.3 模块边界与开发顺序建议
模块边界在项目初期就定好:
recommend-boot:SpringBoot主服务,含商品、订单、用户、推荐位接入。recommend-etl:Hive 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 | 按天分区的原始日志目录 |
| 2 | Hive清洗 + 用户行为表 | user_behavior_fact(用户ID、物品ID、行为时间、行为类型、位置标签) |
| 3 | Spark相似度计算 + 候选生成 | item_sim_result、user_recall_list,按天分区 |
| 4 | 结果导入MySQL和Redis | t_recommendation_result全量表,Redis当日热数据 |
| 5 | 商城接口读取推荐位 | 用户端可见的个性化推荐 |
步骤2里有一个很关键的设计:清洗时不仅去重,还要做时间窗口裁剪。用户购物行为是有时效性的,三个月前的浏览行为,对今天的推荐来说可能完全是噪音。我在Hive SQL里对行为数据的截止时间做了过滤,只保留最近30天的有效行为,这样不仅结果更合理,而且Spark计算数据量也大幅下降,跑批很快。
步骤4导MySQL时,我用的批量插入而不是逐条插入,因为推荐结果全量表更新时可能是几十万条记录,逐条插入性能扛不住。批量插入时注意mybatis的rewriteBatchedStatements=true这个配置,开启后插入速度能提升一个量级。
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结果里存了"相似商品ID",MySQL的推荐表里存了rec_reason字段,用户在页面上看到的"因为你浏览了【宁波海鲜干货礼盒】,向你推荐【红膏炝蟹】"就是从这来的。理由这个东西看起来简单,但对点击率的影响非常大,同样的位置、同样的商品,带推荐理由的点击率高出一大截。
4.3 推荐位接口设计细节与缓存告警
我最终推荐位接口出参的结构是这样的,固定好格式,前端完全不用关心推荐引擎内部的事。
public class RecommendResponse { private List<RecommendItemVO> 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> <groupId>org.apache.hadoop</groupId> <artifactId>hadoop-client</artifactId> <version>3.3.4</version> <exclusions> <exclusion> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>*</artifactId> </exclusion> <exclusion> <groupId>com.google.guava</groupId> <artifactId>guava</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.mapfiles=true和hive.merge.size.per.task=268435456(256MB)。 - 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_MIN=512 export HADOOP_HEAPSIZE_MAX=1024 export HADOOP_OPTS="-Xms512m -Xmx1024m"同时调小了YARN的容器内存参数,在yarn-site.xml里设置:
<property> <name>yarn.nodemanager.resource.memory-mb</name> <value>2048</value> </property> <property> <name>yarn.scheduler.minimum-allocation-mb</name> <value>256</value> </property> <property> <name>yarn.scheduler.maximum-allocation-mb</name> <value>1024</value> </property>这样配置之后,本地伪分布式模式跑小规模推荐计算不再卡死了。如果条件允许,我更建议用Docker跑Hadoop集群,内存和网络隔离都比直接把服务铺在开发机上干净得多。现在好多开发者直接拉Hadoop的Docker镜像,一个脚本起三个容器,配置统一,还不会弄脏宿主机环境,属于性价比很高的方案。
收尾的一点经验
从这个项目里的个人体会来说,最值得反复琢磨的还是那句话:再好的技术选型,也要服务于业务链路本身。Hadoop在这个项目里不是摆设,它确实在日志存储、离线分析、推荐计算这几块承担了不可替代的重活;SpringBoot角色同样清晰,它把所有在线事务处理得干净利落。两者各管一段,用数据链路打通,而不是在代码层强行耦合。
最后再分享一个小技巧。如果你也想做类似的推荐商城项目,可以从"先用规则推荐跑通全链路、再逐步替换成协同过滤"这个节奏走。规则推荐虽然简单,但它能让整个数据链路先运转起来,你才能拿到真实的行为数据,后面做模型才有原料。一个连底表数据质量都保证不了的项目,算法再高级也是空中楼阁。先把链路跑直,再把算法做准,这个顺序永远不会错。