1. 项目背景与整体技术选型
做民宿推荐系统这个项目,最开始并不是为了赶时髦硬凑大数据组件,而是真有一个很现实的痛点:民宿数据量一旦上去,单机跑的推荐脚本根本扛不住,而且业务方既要看“猜你喜欢”的推荐结果,又要看“周边热度榜”这种全局统计,还要在后台实时看订单流转和用户行为。如果只用一个普通的Web后端加MySQL,流量稍微上来,接口响应就开始飘,更别提离线训练协同过滤模型时对CPU和内存的摧残。
所以这套系统的技术选型逻辑很直接:数据分层处理,离线和实时分开走。Hadoop负责海量历史日志的离线清洗和存储,Hive负责把清洗后的数据做成结构化数仓,Spark负责在上面跑协同过滤训练和离线预测,Kafka则专门承接在线产生的用户行为事件,让实时推荐链路能转起来。整体架构可以概括为“离线批处理+实时流处理”的双通道,两个推荐算法分别在两条链路上输出结果,再通过一个统一的服务层对外提供接口。
这套方案的直接收益是:离线任务可以凌晨定时跑,白天不影响在线业务;实时链路延迟控制在秒级,用户刚点完一个民宿,几分钟后刷新页面,推荐列表就能出现相似房源。对于民宿这种“短决策周期、强地域属性、高个性化需求”的业务场景,这套架构是够用的,而且每个组件之间职责清晰,出了问题也比较好排查。
这里顺带说一句,为什么中间层一定要选Hive而不是直接拿Spark SQL替代。Hive在纯离线数仓场景下依然是性价比最高的选择,它的元数据管理、分区修剪、与Hadoop生态的契合度都经过大量生产环境验证。而且后续要做可视化报表,Hive表可以直接被BI工具连上,写SQL就能出图,比Spark临时算完再导出的方式要省事得多。
2. 双推荐算法的设计动机与核心逻辑
2.1 为什么是“双推荐算法”,而不是一个通用模型
首先明确一下“双推荐算法”在这套系统里的定义:它不是同一个模型换参数跑两遍,而是两条完全不同思路的推荐链路——基于协同过滤的离线推荐和基于物品关联热度与用户实时行为的流式推荐。
协同过滤解决的是“个性化”问题。用户A和用户B历史上浏览、收藏、预订过相似的民宿集合,那么A喜欢的新房源,B大概率也有兴趣。这是典型的UserCF思路。反过来,民宿X和民宿Y经常被同一个人收藏,那么当用户看了X之后,系统就应该推Y,这是ItemCF。离线推荐用的是ItemCF,因为民宿数量相对用户数量少得多,物品间相似度矩阵的计算量可控,而且民宿这个品类的用户兴趣变化快,物品的关联关系比用户的聚类关系稳定得多。
实时链路则解决“新鲜感”和“时效性”问题。比如台风刚过,某海滨城市的民宿价格下调,用户正好在刷那个城市,系统就要在几分钟内把降价房源推到前面。协同过滤模型不可能这么快反应,因为它依赖的是历史统计,冷启动阶段甚至连用户行为都没有,所以需要另一套基于事件驱动的实时推荐逻辑。
这两条链路最终在推荐服务层做融合。融合策略不是简单加权,而是分桶:实时推荐结果有较高置信度的场景直接覆盖,比如用户刚搜索过某个商圈;实时结果不足时,回退到离线协同过滤的结果;两边都有结果,则按照业务规则做重排,比如过滤掉用户已经预订过的房源、过滤差评率过高的房源、提价过滤等。
2.2 协同过滤的工程化细节
离线链路里最关键的是物品相似度矩阵的计算。这里选的ItemCF,相似度公式用的余弦相似度,但实现上做了两个优化。
第一个优化是用户行为加权。不能把浏览、收藏、预订当成同样的权重,否则数据会被大量无意义浏览污染。我用的权重策略是:浏览记1分,收藏记4分,下单记8分,支付成功记10分。这个权重不是拍脑袋定的,是根据业务目标反推的——系统最终要提升的是支付转化率,所以支付行为必须占主导权重。
第二个优化是相似度矩阵的稀疏处理。民宿总数如果到几十万量级,物品间两两计算相似度就是几十亿对,直接算肯定不行。所以先做了一个热门度截断:只保留90天内有过交互行为的民宿,再对每个民宿只取与之共现次数最高的Top 200个邻居计算相似度。这样矩阵规模直接缩小几个数量级,而且去掉了长尾噪声。
相似度计算在Spark里跑的时候,核心代码就几百行。数据源是Hive里的一张用户行为事实表,按用户ID做分组,把每个用户交互过的民宿列表拉出来,两两组合生成“物品对”。然后对物品对做聚合,统计共现次数和加权得分。最后算余弦相似度,把结果写成Hive表,供离线推荐查询。
这里有个很容易踩的坑:如果直接用用户ID做groupByKey,某个头部用户交互过上万套房源,单条记录就有上万个物品对,直接导致数据倾斜。解决方式是对物品对做前缀分区,也就是按照物品对中的第一个物品ID加盐分桶,让大用户的数据分散到多个分区里处理,而不是挤在一个task里。
// Spark 离线 ItemCF 相似度计算的核心流程(简化示例) val behaviorDF = spark.table("ods.user_behavior") .filter($"dt" === bizDate) .select("user_id", "item_id", "action_weight") val itemPairDF = behaviorDF .join(behaviorDF.toDF("user_id", "item_id2", "weight2"), Seq("user_id")) .filter($"item_id" < $"item_id2") .select($"item_id".as("item_a"), $"item_id2".as("item_b"), ($"action_weight" + $"weight2").as("pair_score")) val simDF = itemPairDF .groupBy("item_a", "item_b") .agg(sum("pair_score").as("co_score")) // 此处再接余弦归一化2.3 实时推荐链路的Kafka接入设计
实时链路的数据源是前端埋点的行为事件。用户浏览了哪个房源、收藏了哪个房源、搜索了什么关键词、下单了哪个房源,全部封装成JSON,经过Nginx打到Kafka的topic里。Topic按事件类型分开,比如user_view、user_favorite、user_order,分区数按流量预估设置成12个,保证Producer写入和Consumer消费的并行度。
实时推荐服务用Spark Streaming读取Kafka,窗口设在5分钟。在这个窗口内,统计每个用户的行为序列,并结合当前城市、入住日期等上下文信息,实时计算一个“热度候选集”。候选集的构成包括:该城市当前最热房源、与用户最近查看房源同商圈的房源、以及被相似用户高频收藏的房源。
这套逻辑说起来简单,但实时计算有个天然难点:窗口内的数据量不够会导致候选集太窄。所以我在实时链路里加了兜底策略——每一个用户都保留一份最近30天的行为画像在Redis里,当5分钟窗口内产生的实时信号不足时,就取画像中的历史偏好来扩展候选集。Redis的key可以采用user:profile:{userId},value是一个包含偏好商圈、偏好价格带、最近交互房源ID列表的JSON,实时任务和推荐服务都能读写这份画像。
Kafka消费者的提交方式也需要特别注意。如果用自动提交偏移量,任务重启时会丢数据;如果用处理完再手动提交,又会降低吞吐。我的做法是:先关掉自动提交,在foreachRDD里处理完业务逻辑后再提交偏移量,并且在提交前把偏移量同时写入Kafka的__consumer_offsets和一个外部存储(比如HBase),这样即使任务异常重启,也能从外部存储找回上次处理的位置。
2.4 Hive可视化:从数仓表到前端图表
Hive在系统里不只是给Spark提供训练数据,还承担了所有业务报表的底层支撑。民宿运营方最关心的几个面板:每日订单量趋势、热门城市民宿价格分布、用户来源渠道占比、收藏转化率漏斗。这些指标全部通过Hive SQL从明细表汇总到汇总表,再用可视化工具连接HiveServer2,发布成图表。
这里有一个很重要的实践建议:不要用可视化工具直接查原始明细表,一定要先建好汇总表,不然一次查询扫全表,Hive的MapReduce任务可能要跑几分钟,前端图表早就超时了。我建了三层表结构:
- ODS层:原始行为日志、订单流水,按天分区,不轻易改动;
- DWD层:清洗后的明细事实表,字段规范,枚举值统一;
- ADS层:面向应用的主题汇总表,每个指标一张表,行数控制在几千行以内。
以“城市热度排行”为例,ADS层表结构大概是:
CREATE TABLE ads_city_heat ( dt STRING COMMENT '日期分区', city STRING COMMENT '城市', pv_cnt BIGINT COMMENT '浏览人次', fav_cnt BIGINT COMMENT '收藏次数', order_cnt BIGINT COMMENT '下单数', gmv_amt DECIMAL(12,2) COMMENT '成交金额', heat_score DOUBLE COMMENT '热度分', update_time STRING ) PARTITIONED BY (dt STRING) STORED AS PARQUET;热度分计算公式为:heat_score = pv_cnt * 0.3 + fav_cnt * 0.5 + order_cnt * 1.2 + gmv_amt * 0.02。这是业务侧给出的经验公式,虽然不复杂,但能直接反映民宿运营的核心目标——既要有人看,也要有人下单。
可视化环节除了出图,还要关注一个容易被忽略的问题:HiveServer2的并发连接数。BI工具每刷新一次图表就要连接一次,如果团队里几十个人同时看板,HS2很容易被打挂。解决思路是加一个查询代理层,比如用某个开源SQL网关再接一层连接池,或者把BI工具的报表结果定时同步到MySQL/Redis里,前端只查缓存,只有缓存过期才去查Hive。
3. 系统架构分层与各组件分工
3.1 数据流向全景
整套系统的数据流向可以总结为一条主链路和一条旁路。
主链路是离线批处理链路:Nginx和业务数据库中产生的日志与订单数据,通过Sqoop和Flume定期同步到Hadoop的HDFS上。原始日志落地之后再加载到Hive的ODS层表中,然后按业务逻辑清洗到DWD层。Spark每天凌晨定时任务开始跑:先读取DWD层的行为数据,训练或更新协同过滤模型,再对全量用户生成离线推荐结果,结果写回Hive和Redis。推荐服务启动时,从Redis加载自己的推荐列表,用户请求进来直接命中。
旁路是实时流处理链路:前端埋点事件直接进Kafka,Spark Streaming从Kafka消费,经过窗口聚合和画像更新,把候选集写入Redis,实时推荐服务从Redis读取候选集,与离线结果融合后返回给前端。
两条链路的数据最终都会汇总到ADS层,供可视化大屏和运营看板使用。Hive在这里既是数仓存储,也是离线推荐和可视化统一的“数据底座”。
3.2 关键组件配置要点
组件的部署细节直接决定系统稳定性,这里列几个我在实际项目中调过的参数,供参考。
HDFS方面,NameNode的堆内存要按文件数量规模来配。如果文件数超过5000万,默认的4G堆内存肯定不够,建议至少8G以上。DataNode的存储目录不要用系统盘,优先挂载独立数据盘。数据块副本数保持默认的3,但如果你只是测试环境,可以改2,省一半空间。
Spark方面,离线训练任务的Executor内存和核心数要匹配。我这里集群是每台机器16核64G,Executor内存给了8G,核数给了4个,这样一台机器能起4个Executor,资源利用比较均衡。Shuffle分区数默认200,处理几千万级物品对数据时建议改成2000,不然每个分区数据量过大,shuffle会OOM。
Kafka方面,最重要的参数是log.retention.hours。实时链路的原始行为日志如果全部保留,磁盘很容易被打爆。我个人设的是保留24小时,因为下游实时任务当天就会消费完,更久的历史可以从Hive里找回来。另外,Kafka的message.max.bytes要调到10MB,因为民宿的详情JSON里包含多张图片URL、经纬度、设施列表,一条事件可能超过默认的1MB限制。
Hive方面,最常见的坑是小文件过多。如果每天同步日志时不提前做合并,HDFS上会堆积上百万个小文件,查询时NameNode压力巨大。我的解决方案是在Hive表定义时就设置TBLPROPERTIES ("orc.compress"="SNAPPY"),存储格式用ORC,并且在每次ETL任务后用一条INSERT OVERWRITE把当天分区重新写一遍,自动合并小文件。
3.3 Hive与Hadoop的存储规划
表的分区策略直接决定查询效率。民宿项目里,所有表都按照dt天分区,部分大表增加city二级分区。比如订单事实表,一级分区是dt,二级分区是city,这样运营方查“某天某城市的订单情况”时,Hive能够通过分区裁剪直接跳过无关数据。
另外,表的文件格式我全部用了ORC,而不是Parquet,原因在于ORC在Hive生态里的原生支持更好,尤其在做聚合查询时,ORC自带的轻量索引能减少大量IO。如果你用Parquet,Spark读写可能更顺手,但说到Hive查询性能,ORC还是稳一点。
4. 项目实操过程中的真实踩坑记录
4.1 数据倾斜:那个半夜跑不完的Spark任务
这个坑几乎每个做大数据的都遇到过。离线协同过滤第一次上线时,凌晨的定时任务跑了四个小时还没结束,后来定位到是有几个头部用户,一个人一个月交互了几万套房源,导致物品对聚合的某个key数据量爆炸,单个Executor直接算到OOM,任务卡死。
解决方式前面提到过,加盐分桶加前缀分区。我对物品ID做了取模加盐,把大key拆成多个子key分别聚合,然后再汇总。这样处理后,任务从四个小时降到四十分钟。这是整个项目里性价比最高的一次调整,没有之一。
4.2 Kafka重复消费:推荐列表为什么突然全变了
有次测试发现,Kafka消费者在重启之后,部分用户收到的实时推荐结果出现了重复项,排查下来是偏移量提交时机不对。当时代码是先写Redis再提交偏移量,但Redis写入成功的ACK和Kafka偏移量提交之间没有做事务关联,一旦进程在两者之间挂掉,重启后就会重复消费之前的窗口数据,把同样的房源再次写进Redis。
修正方式是把Redis写入做成幂等操作,重复写入只保留最新值,同时把偏移量提交改成先处理完所有写入再统一提交。以后凡是遇到Kafka相关的实时任务,我都会在代码里加上一句注释:偏移量提交之前,下游所有操作必须幂等。
4.3 Hive查询慢到怀疑人生:开了谓词下推也没用
我一度以为Hive查询慢是集群资源问题,后来发现是表结构设计有缺陷。ADS层的热度汇总表最开始没有按城市分区,每次查一个城市的热度都要扫描全表。后来把分区策略改成PARTITIONED BY (dt, city),查询从三分钟降到了十秒以内。这个案例让我明白了:Hive的查询优化,分区设计远远比SQL写法更重要。
4.4 可视化图表刷新卡死:HS2连接数被打满
某运营大促活动期间,BI报表一刷新就报错,后台日志显示HiveServer2的会话数达到上限。因为十几个运营同时开着看板,每个图表每5分钟自动刷新一次,HS2默认只能同时处理最多几百个请求,直接被拖死。
临时处理方式是提高HS2的hive.server2.thrift.max.worker.threads参数,但治本的办法还是把高频图表的结果主动推送到Redis,设置5分钟过期,BI只查Redis,不到5分钟的刷新直接返回缓存数据,HS2压力瞬间消失。
| 问题现象 | 根因 | 解决方式 |
|---|---|---|
| Spark任务凌晨跑不完 | 数据倾斜,头部用户key堆积 | 加盐分桶、前缀分区拆解大key |
| Kafka重启后重复推荐 | 偏移量提交与下游写入非原子 | 幂等写入,处理完成后统一提交偏移量 |
| Hive查询几分钟无响应 | 分区字段没有下推到查询 | ADS层表按dt+city双分区 |
| 可视化刷新连接失败 | HS2连接数被打满 | 高频图表结果缓存到Redis,过期才查HS2 |
4.5 双推荐系统评估:到底怎么算效果
推荐系统上线后不能只看“能跑”,要做效果评估。离线模型用F1和NDCG做离线评估比较容易,但业务方更关心的是线上转化率。我这里做的是A/B测试,实验组走双推荐融合链路,对照组只走离线协同过滤。
跑了两周的数据,实验组的民宿详情页点击率提升18%,收藏转化率提升12%,下单转化率提升8%。单独看好像不明显,但放在一个月几十万订单量级下,这个提升带来的GMV增长很可观。
5. 项目部署上线与运维要点
5.1 从单机到集群的迁移教训
一开始为了快速出Demo,所有组件装在一台开发机上,能跑通流程就万事大吉。但真要准备上生产,单机模式连配置都改不下去,因为Hadoop默认的副本数是3,单机只有一份数据,每次启动都会报警。所以建议如果决定做完整的民宿推荐系统,至少准备三台机器,一台做NameNode和ResourceManager节点,另外两台做DataNode和NodeManager节点,Kafka和Hive可以部署在资源相对空闲的节点上。
小规模集群可以用Ambari或者手动装,如果机器数量超过五台,建议直接用现成的HDP/CDH发行版,虽然开源版停止更新了,但稳定性比社区版要好装很多。
5.2 定时调度与监控告警
离线任务用Azkaban或Airflow调度都行。我的项目里用的是Airflow,因为DAG可以清楚表达每个任务的依赖关系。每天凌晨两点开始同步日志,三点跑ODS到DWD的清洗任务,三点半跑协同过滤训练,四点半生成离线推荐结果,五点半刷ADS层汇总表。每个任务结束之后检查日志中是否有Exception关键字,如果有就触发短信告警。
实时链路侧的监控也不可大意。Kafka的消费堆积情况必须纳入监控,消费者组lag超过阈值就告警,否则用户行为事件一直积压,实时推荐会沦为摆设。我用的是Prometheus+Grafana,Kafka的JMX exporter暴露指标,Spark Streaming任务自己上报Lag信息,看板上一目了然。
5.3 资源预估与成本权衡
如果你准备照着这个方案做生产级系统,资源预估参考如下:民宿业务日活10万、日行为日志约三千万条,每条约0.5KB,一天原始数据约15GB,保存90天的话Hadoop存储需要约1.4TB,三副本后约4TB。集群四台机器就够了,每台32GB内存、8核CPU、2TB磁盘,基本能扛住。
如果日活到了百万级,批量计算建议引入Spark On Yarn的资源队列隔离,实时任务和离线任务分开调度,不然离线任务一跑,实时推荐就卡顿。
6. 这个项目还能往哪个方向扩展
这套架构搭完之后,后续扩展方向其实很多。
第一个方向是引入更多样的用户特征。目前协同过滤用到的还是行为数据,如果能加入用户画像标签,比如出行目的(商务出差还是亲子游)、常住城市、消费水平,就可以把冷启动问题再往前推一步,新用户进入系统后不用等行为积累,直接用画像匹配相似人群。
第二个方向是引入深度学习模型。双推荐算法里,协同过滤负责召回,实时热度负责兜底,但排序层面目前还是规则重排。后续可以用一个深度排序模型(比如Wide & Deep或DeepFM)把几百个排序特征接进来,替代人工规则,推荐转化率应该还能再上一个台阶。
第三个方向是可视化层增强。目前Hive可视化主要是运营看板,后续可以加入用户行为轨迹的实时地图,展示用户从浏览到下单的完整路径。这个需要把Kafka里的地理信息拿出来,结合某个地理索引引擎做实时渲染,技术上是完全可行的。
第四个方向是把流式计算框架从Spark Streaming迁移到Flink。Flink在事件时间处理、精确一次语义上比Spark Streaming更顺手,如果以后要做分钟级的实时促销定价,Flink会更合适。但迁移成本也不小,现有链路稳定性如果没问题,可以先在旁路试验,等验证充分了再全量切换。
写在最后
做这个民宿推荐系统,最大的体会是:所谓“大数据项目”,真正的难点不在于把组件装起来,而在于搞清楚每个组件在这个业务场景里到底该承担什么角色。Hadoop不是用来炫技的,它是为了让你几十亿条历史行为数据算得动;Kafka也不是非用不可,它是因为用户行为每天都在实时产生,必须要有一个削峰填谷的管道;协同过滤算法本身都写在教科书里,但真正让它发挥价值的是权重设计、数据倾斜处理、冷启动兜底这些课外的功夫。
如果你也准备做一个类似的推荐系统,我的建议是先把数仓模型设计好,把自己业务里的行为事件定义清楚,再动手搭环境。否则组件堆得再多,数据流理不顺,一样跑不出靠谱的结果。希望这篇复盘能帮你少走几步弯路,祝顺利。