
简介基于Spark的餐饮平台菜品智能分析与推荐系统包含完整可运行的Java源码和配套数据库这是个人毕业设计项目答辩评审分达到98分所有代码均经过调试测试下载导入即可运行。资源共49个文件主体为17个Java源码文件和8个XML配置文件辅以3个JSP动态页面、6个CSS及5个JS前端组件同时提供SQL建表脚本、CSV与JSON格式的用户菜品评分数据以及项目说明文档整体压缩包仅2.05MB。项目以Spark为计算引擎围绕餐饮平台用户评分数据展开智能分析和菜品推荐涉及数据解析、特征处理、推荐模型训练与结果展示等关键环节目录模块划分清晰方便按需阅读与二次开发。这套源码特别适合计算机、通信、人工智能、自动化等专业学生作为期末课程设计、课程大作业或毕业设计参考也能帮助Spark初学者快速实践完整项目流程。当前已有254人学习浏览综合来看是一份高质量的可复用项目。1. 为什么餐饮平台需要Spark来做菜品推荐这不是一个普通的CRUD项目餐饮平台的菜品推荐和电商推荐有个本质区别电商推荐的是标品今天推的iPhone和明天推的iPhone是同一个东西但菜品有强烈的时段性、季节性和组合性。中午推重油重辣的川菜和晚上推清淡的粤菜转化率完全不同。更麻烦的是用户对菜品的偏好往往不是“喜欢什么菜”而是“这顿饭想吃什么类型”——这种短时意图在用户历史行为里很难直接体现。如果只用MySQL的SQL做统计推荐光算用户偏好相似度就够呛更别说处理订单明细表动辄几百万行的关联规则挖掘。这个基于Spark的餐饮平台菜品智能分析推荐系统核心就是把海量订单数据、用户行为数据和菜品属性数据统一扔进Spark做分布式计算用ALS协同过滤产出“猜你喜欢”用FP-Growth挖掘“一起点”的套餐组合再落到数据库供业务端查询。适合正在做大数据课程设计、Java/Python后端想补Spark实战经验、或者准备面试中大厂数据岗位的人。它解决的不是“能登录能下单”这种CRUD问题而是“数据量上来之后推荐逻辑怎么不崩、结果怎么解释得通”的问题。2. 把系统拆开看Spark到底在链路里干了哪些活2.1 技术选型为什么推荐引擎非要Spark而不是纯JVM内存计算先建立一个整体认知。这类系统的数据流通常是订单库MySQL→ 数据采集Sqoop/Canal→ 数据清洗Spark SQL→ 特征工程Spark DataFrame→ 模型训练Spark MLlib ALS→ 结果入库MySQL/Redis→ API查询。关键问题在于为什么中间这一段不能换成Spring Boot里直接跑一个Mahout或者自己写余弦相似度我的观点是当菜品数量超过5000、用户量超过1万、订单明细超过几十万行的时候单机内存计算有两个硬伤。第一相似度矩阵是O(n²)量级1万个用户算用户相似度矩阵就是1亿个元素堆内存根本扛不住。第二关联规则挖掘如果用Apriori在单机跑每轮扫描全量订单数据做候选集计数几十万行订单跑起来是以小时计的。Spark的核心优势是把中间结果放进RDD/DataFrame用惰性求值和血缘关系做容错不是把数据一次性载入内存而是按stage切分、按partition并行——你的数据可以放在HDFS或本地磁盘上计算时按需加载这才是它处理“跑不动”问题的原因。另外MLlib里的ALS做了分布式矩阵分解把评分矩阵拆分到多个executor上并行迭代。虽然理论原理是“最小二乘法交替优化”但工程实现上Spark已经把矩阵分块和梯度同步封装好了你要做的只是调rank、iterations、lambda这几个参数。2.2 数据链路搭建从MySQL到Spark的最小可用流程先假设你已经有一张订单表、一张菜品表、一张用户表。订单明细在MySQL里大概是这个结构CREATE TABLE order_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL COMMENT 用户ID, dish_id INT NOT NULL COMMENT 菜品ID, category_id INT COMMENT 菜品分类ID, rating TINYINT DEFAULT 5 COMMENT 评分用户未评时给默认分, order_time DATETIME NOT NULL COMMENT 下单时间, amount DECIMAL(8,2) COMMENT 实付金额 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;从这张表生成Spark需要的训练数据常见做法是先用Sqoop全量抽到HDFS再用Spark SQL处理。但如果你只是做课程设计或者单机跑通可以跳过HDFS直接用Spark读取MySQL——前提是JDBC驱动丢进Spark的jars目录并且driver和executor都能访问到驱动类。val jdbcUrl jdbc:mysql://localhost:3306/food_db?useSSLfalseserverTimezoneAsia/Shanghai val df spark.read .format(jdbc) .option(url, jdbcUrl) .option(dbtable, order_detail) .option(user, root) .option(password, your_password) .option(partitionColumn, id) .option(lowerBound, 1) .option(upperBound, 1000000) .option(numPartitions, 8) .load()这段代码里最关键的是partitionColumn参数。如果不指定Spark会用单线程读MySQL全表几十万行数据读出来的耗时足够你泡杯咖啡。指定了分区列之后Spark会按lowerBound到upperBound的范围把查询拆成多个并行SQL去执行每个executor各读一段主键范围。这里有一个前提id必须接近连续如果删除操作过多导致id空洞严重分区会不均匀某些task读得多、某些读得少。建议先用SELECT MIN(id), MAX(id) FROM order_detail确认边界再填参数。读取之后做基础清洗去重、过滤掉金额为负的异常数据、把order_time拆出小时字段以便后续做时段分析。val cleanDF df .filter($amount 0) .dropDuplicates(user_id, dish_id, order_time) .withColumn(hour, hour($order_time)) .withColumn(date, to_date($order_time))dropDuplicates必须带上order_time字段——同一个用户一天内点两次同一道菜是正常的去掉时间后按user_id和dish_id去重会把真实的重复偏好抹掉影响后面ALS对偏好强度的判断。我见过有人只按两个字段去重结果周一点的用户被过滤掉模型冷启动更严重。清洗后的数据可以写回一份Parquet作为临时特征表也可以直接塞给MLlib。注意Spark MLlib的ALS接口吃的是RDD[Rating]不是DataFrame但ML库里面ALS.train的封装已经帮你做了转换你要关心的只是把数据映射成 (userId, dishId, rating) 三元组。2.3 评分不是拍脑袋把“点过”变成“喜欢”的三种建模方式很多拿这个项目做课程设计的人最纠结的地方是订单表里根本没有rating字段ALS怎么跑这里有个重要的工程常识推荐系统里所谓的“评分”不一定是用户明着打的星星而是你从行为数据里构造出来的隐式反馈。做法有三种。第一种最直接用户每下一个单就记5分退单记1分这个方式优点是简单缺点是完全没区分度——大部分活跃用户的菜品全都一个分。第二种是用消费频次做加权score 1.0 log(1 order_count)再按用户维度做归一化。第三种是引入金额因子score log(1 order_count) * log(1 total_amount)贵的菜天然得分高但这会偏袒高客单菜品看业务目标决定。val ratingDF cleanDF .groupBy(user_id, dish_id) .agg( count(*).as(order_count), sum(amount).as(total_amount) ) .withColumn(rating, when($order_count 3, 5.0) .when($order_count 2, 4.0) .otherwise(3.0) ) .select(user_id, dish_id, rating)这个方案是把频次映射到1到5的档位简单直接、可解释性强适合做课程设计的答辩展示。如果你想让模型效果更好可以引入时间衰减近7天内的订单评分权重是1.030天前的权重降到0.5对应真实用户的偏好漂移——毕竟三个月前爱吃酸菜鱼不代表现在还爱吃。这里要特别提醒ALS是协同过滤它的输入是“用户×菜品”的稀疏评分矩阵。如果直接拿这个ratingDF去训练user_id和dish_id是自增Integer倒还好但在真实业务里它们可能是UUID。ALS要求ID连续编码否则矩阵维度爆炸。解决办法是在训练前做一次StringIndexer或者直接用DataFrame API里的ALS.setUserCol(user_id).setItemCol(dish_id)让它内部自己编码。下一章进入模型调参那里有几个非常折磨人的细节。3. 基于Spark MLlib实现ALS推荐模型从参数到结果落库3.1 训练脚本一份能直接改改就用的ALS核心代码根据我的经验很多人在Spark上跑ALS第一次都会遇到“任务跑完了但没有推荐结果”的尴尬——因为预处理时把误差处理错了。直接给一份能跑的训练代码基于Spark 2.4/3.xScala版。import org.apache.spark.ml.recommendation.ALS import org.apache.spark.sql.SparkSession import org.apache.spark.sql.functions._ val spark SparkSession.builder() .appName(DishRecommendation) .master(local[*]) // 本地调试用集群环境删除这行并指定deploy-mode .config(spark.sql.shuffle.partitions, 8) .config(spark.default.parallelism, 8) .getOrCreate() val ratings spark.read.parquet(/path/to/ratingDF.parquet) // ALS要求列名默认是user/item/rating可以改名但推荐直接用下面这种隐式做法 val als new ALS() .setMaxIter(12) .setRegParam(0.08) .setRank(12) .setUserCol(user_id) .setItemCol(dish_id) .setRatingCol(rating) .setColdStartStrategy(drop) // 隐式反馈模式如果评分是构造出来的频次类数据打开这个选项 // .setImplicitPrefs(true) // .setAlpha(40.0) val Array(train, test) ratings.randomSplit(Array(0.8, 0.2), seed 42L) val model als.fit(train) // 为每个用户生成Top-N推荐 val userRecs model.recommendForAllUsers(10) // 保存模型 model.write.overwrite().save(/tmp/als_model) // 模型评估根均方误差 val predictions model.transform(test) val evaluator new org.apache.spark.ml.evaluation.RegressionEvaluator() .setMetricName(rmse) .setLabelCol(rating) .setPredictionCol(prediction) val rmse evaluator.evaluate(predictions) println(sRoot-mean-square error $rmse)代码里setColdStartStrategy(drop)是必须写的如果你漏了transform测试集时遇到训练集没出现过的用户或菜品ALS会给你产生NaN预测值评估指标直接变NaN还会影响后面写库。设置为drop的意思是“遇到冷启动用户/菜就丢弃预测结果”宁缺毋滥。randomSplit(Array(0.8, 0.2), seed 42L)这行有个细节必须固定seed否则每次跑模型评估结果都不一致。做课程设计答辩的时候同一个代码跑两次RMSE差0.1评审老师问起来你根本解释不了。3.2 参数怎么调rank、iterations、regParam的实战经验ALS这三个核心参数每个都有它自己的脾气我直接说结论和理由。rank是潜在因子的数量决定了矩阵分解的复杂度。rank太小——比如4到6模型表达力不足推荐结果会很“大众脸”每个人拿到的Top10高度趋同。rank太大——比如50以上过拟合严重训练集的RMSE很好看测试集的RMSE飙高而且训练时间大幅上升。我一般从12开始试如果测试集RMSE下降不明显就停。对于几千道菜的中型数据12到20这个区间基本够用。iterations是ALS交替迭代的上限Spark默认是10但这个值不是越大越好。ALS每轮迭代会做一次用户矩阵和物品矩阵的交替更新10轮之后平方误差的下降幅度已经很缓。设成20以上纯粹增加训练时间RMSE改善不足1%。我第一次跑的时候设了50等了一个多小时结果和15轮的几乎一样属于典型的“参数强迫症”。regParam是正则化系数控制对过拟合的惩罚。常见区间是0.01到0.1。设太大会把模型压得太保守推荐结果趋向平均打分设太小则模型记住了训练集中的每个噪声点。我习惯先用0.1起步然后0.01、0.05、0.08各跑一遍挑RMSE最小的。有一个很多教程不会告诉你的点ALS训练前必须对特征列做标准化——不对ALS其实不需要标准化数值特征因为它的输入就是离散ID加评分值。真正需要关注的是评分分布。如果你的评分构造得全是5分ALS学不出任何区分度这属于特征工程的问题不是模型参数的问题。我自己的调试顺序永远是先看评分分布再调参数不要一上来就GridSearch。3.3 关联规则挖掘FP-Growth挖出“点酸菜鱼的人还点了什么”ALS告诉你“这个用户可能喜欢哪些菜”但没有告诉你“这桌人点什么菜搭配最合理”。后者是FP-Growth的活。import org.apache.spark.ml.fpm.FPGrowth // 输入格式每个订单作为一个数组订单内的菜品ID列表 val orderItems cleanDF .groupBy(order_id) .agg(collect_list(dish_id).as(items)) val fpgrowth new FPGrowth() .setItemsCol(items) .setMinSupport(0.02) // 至少2%的订单出现 .setMinConfidence(0.3) // 条件概率下限 val fpgModel fpgrowth.fit(orderItems) // 查看关联规则左边是“前提菜品”右边是“推荐搭配” fpgModel.associationRules.show(false) // 查看每个菜品的频繁项集 fpgModel.freqItemsets.show(false)这里的minSupport和minConfidence是关联规则效果的生命线。支持度太低了比如0.001会出现大量只出现过几次的菜品组合规则库几千条全是噪声。支持度太高了比如0.2高频菜会霸榜——米饭出现在所有规则里毫无意义。餐饮数据的经验是minSupport取0.01到0.03起步minConfidence取0.3到0.5之间具体看你数据量。FP-Growth在Spark里的实现已经做了FPTree的分布式剪枝单机内存压力比Apriori小很多但它的输入是每个订单的菜品ID列表这就要求订单ID在原始表里必须存在。我见过有人拿清洗后的明细表直接喂FP-Growth结果每个“订单”只有一道菜根本挖不出任何规则。记得先做聚合。训练完ALS和FP-Growth之后整个Spark侧的计算就结束了要把推荐结果写回给业务系统用。这一步也是坑最多的。3.4 结果落库List[Rating]怎么优雅地写回MySQLALS输出的recommendForAllUsers结果是DataFrameschema是user_id, recommendations其中recommendations是一个数组[struct(dish_id, rating)]。不能直接拿这玩意儿写MySQL得先展开成行。val output userRecs .select( $user_id, explode($recommendations).as(rec) ) .select( $user_id, $rec.dish_id.as(dish_id), $rec.rating.as(pred_score) ) .repartition(4) // 控制写入并行度避免MySQL连接被打爆 output.write .mode(SaveMode.Overwrite) .jdbc(jdbcUrl, user_dish_rec, prop)explode展开数组之后每个用户一行变成每个用户每个推荐菜品一行否则JDBC写入时无法把数组结构映射成MySQL的字段。repartition(4)很关键——如果你有100个executor同时向MySQL写半小时超时很正常。把写入并行度控制在4到8MySQL用连接池扛住单连接多批次插入是这里最常见的调优手段。写入之后的MySQL表结构一般是CREATE TABLE user_dish_rec ( user_id INT NOT NULL, dish_id INT NOT NULL, pred_score DOUBLE NOT NULL, rec_type TINYINT DEFAULT 1 COMMENT 1-ALS推荐, 2-关联规则, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, dish_id, rec_type), KEY idx_dish (dish_id) );主键加rec_type是因为同一个用户既会从ALS拿到推荐也会从FP-Growth拿到套餐搭配两者业务语义不同不能互相覆盖。只保留user_id和dish_id做主键的话第二次写库会把第一批数据冲掉。4. 菜品智能分析从“用户想要什么”到“什么菜该上架”4.1 分析维度拆解销量趋势、时段分布、品类结构、价格带推荐系统只是这个项目的其中一条线“菜品智能分析”是另一条核心线——而且往往是答辩时更能体现功力的部分。原因在于推荐模型是黑盒你能展示的只有RMSE和几个推荐例子而分析模块每张图、每个指标都可以拿出来讲业务结论。常见做法是把分析拆成四个维度。销量趋势看的是菜品按周/按月的销量变化用来识别“上升菜”和“衰退菜”。时段分布看的是早中晚三个时段各菜品的销量占比午市主推套餐和夜市主推烧烤就是从这里来的。品类结构算每个分类下的菜品数量、销量贡献、销售额贡献能够发现“某分类SKU很多但销量聚集在一两个爆品”。价格带分析是用分位数把菜品分成5档看每个价格带产生的GMV比例这个数据可以直接决定菜单排版——主推菜放在哪个价格位置这里有一个波士顿矩阵的变形用法做四象限分类。4.2 用Spark SQL一把梭写一条端到端分析SQL推荐用Spark SQL做分析而不是DataFrame API原因是分析逻辑经常要改SQL的迭代成本最低。-- 菜品时段销量排名找出每个时段的TOP10 WITH time_slot AS ( SELECT dish_id, CASE WHEN hour(order_time) BETWEEN 6 AND 10 THEN 早餐 WHEN hour(order_time) BETWEEN 11 AND 14 THEN 午餐 WHEN hour(order_time) BETWEEN 17 AND 21 THEN 晚餐 ELSE 夜宵 END AS slot, COUNT(*) AS order_cnt FROM order_detail GROUP BY dish_id, slot ), ranked AS ( SELECT dish_id, slot, order_cnt, ROW_NUMBER() OVER (PARTITION BY slot ORDER BY order_cnt DESC) AS rn FROM time_slot ) SELECT * FROM ranked WHERE rn 10;这段SQL里有几个分析场景常用的套路。WITH子句把清洗和聚合分开第一层算每个时段每个菜品的单量第二层用窗口函数按时段分组排名。窗口函数在Spark 2.4以上支持得很完善但要注意如果数据量很大PARTITION BY slot ORDER BY order_cnt DESC会产生全排序需要给spark.sql.shuffle.partitions设置足够大的值否则几十万行数据在200个默认分区下会有数据倾斜。另一个常用的分析是菜品的“成长性”识别。用近4周的销量算环比增长率配合总销量做一个四象限高销量高增长是“明星菜”高销量负增长是“现金牛”需要维持低销量高增长是“潜力菜”需要给曝光位低销量负增长是“瘦狗菜”可以考虑下架。WITH weekly AS ( SELECT dish_id, WEEK(order_time) AS wk, COUNT(*) AS cnt FROM order_detail WHERE order_time DATE_SUB(CURRENT_DATE(), 28) GROUP BY dish_id, WEEK(order_time) ), pivot_data AS ( SELECT dish_id, SUM(CASE WHEN wk WEEK(CURRENT_DATE()) THEN cnt ELSE 0 END) AS this_week, SUM(CASE WHEN wk WEEK(CURRENT_DATE()) - 1 THEN cnt ELSE 0 END) AS last_week, SUM(CASE WHEN wk WEEK(CURRENT_DATE()) - 3 THEN cnt ELSE 0 END) AS week_base FROM weekly GROUP BY dish_id ) SELECT dish_id, this_week, ROUND((this_week - last_week) / last_week * 100, 2) AS mom_growth, ROUND((this_week - week_base) / week_base * 100, 2) AS vs_3w_ago FROM pivot_data WHERE last_week 0 -- 防止除零 ORDER BY mom_growth DESC;这里有个边界要提一下WEEK()函数在不同数据库里返回的区间不同MySQL里默认是周天为起点。如果你直接用SQL丢到Spark的JDBC数据源里Spark会下推执行——但如果你把这段SQL写在Spark SQL里WEEK的语义由Spark决定可能出现跨年边界的周数和MySQL不一致。稳妥的做法是把数据先拉到Spark再算不要让Spark SQL和MySQL的方言混着来。4.3 数据可视化分析结果怎么变成后端能查的接口分析结果最终要展示到前端或者大屏。常见做法是把汇总结果写回一张dish_analysis_report表后端API直接查这张表就够不需要实时跑Spark。这个过程等价于“预计算”。CREATE TABLE dish_analysis_report ( report_date DATE NOT NULL, dish_id INT NOT NULL, slot VARCHAR(8), order_cnt INT, revenue DECIMAL(10,2), mom_growth DECIMAL(5,2), rank_no INT, PRIMARY KEY (report_date, dish_id, slot) );Spark写完这张表后后端接口就变成一条简单SQLSELECT * FROM dish_analysis_report WHERE report_date ? ORDER BY rank_no LIMIT 10。这样做的好处是Spark集群不需要对前端请求实时响应只需要每天固定的时间跑一次批任务写完后业务侧随便查。课程设计如果时间紧这一步可以只做Spark写MySQL和提供查询接口不需要单独做可视化页面因为“查询报表”的过程本身已经证明了链路是通的。5. 从评分矩阵到真实推荐的避坑指南数据倾斜、冷启动与评价指标5.1 数据倾斜某道热门菜的订单多了整个任务卡死现象Spark跑ALS训练或者FP-Growth时日志显示18个task在几秒内完成剩下2个task跑了20分钟还在转。如果你点开Spark UI看stage里的task耗时分布会发现一个刺眼的“长尾”——这就是数据倾斜。原因餐饮数据的天然特性导致热点极高。宫保鸡丁一天的订单量是冷门菜的几百倍按照dish_id分组做聚合时所有宫保鸡丁的数据全部落到同一个partition这个task的压力远超其他partition而且桶内的shuffle数据量过大还可能触发磁盘溢写。解决第一个手段是加盐。对dish_id做concat一个随机后缀把热点key打散到多个分区聚合完成后再去掉后缀合并。第二个手段是调整spark.sql.adaptive.coalescePartitions.enabled打开自适应查询执行让Spark在shuffle之后动态合并小分区。第三个手段是针对ALS本身可以尝试setBlockSize调高矩阵分块让矩阵乘法更均匀地分配到各个executor。提示加盐方案只适合GROUP BY类聚合不能直接用在ALS的user/item列上。如果你对user_id加盐ALS的矩阵分解结构会被破坏推荐结果变成随机噪声。加盐请严格限定在特征聚合和数据分析场景。5.2 冷启动新用户没有任何订单ALS直接返回空列表现象用recommendForAllUsers(10)跑完数据库里有某个新注册用户的记录但user_dish_rec表没有这个用户的任何推荐数据SQL查询返回空数组。原因ALS是协同过滤训练集里根本没有这个用户的历史行为模型学不出这个用户的隐向量。setColdStartStrategy(drop)是丢弃NaN预测直接连推荐列表都不生成。解决分两层。第一层是策略兜底——在推荐API里设置规则如果查不到ALS结果回退到全局热门榜按销量、评分、新上架加权的Top10。第二层是模型层——对新用户可以走“相似用户推荐”的旁路用用户注册时填写的口味偏好、所在地区等属性做基于内容的粗筛再通过物品的协同过滤补一轮。课程设计阶段第一层的热门榜兜底已经够用重点是把回退逻辑写清楚接口不报错、返回结构一致。5.3 评估指标失真RMSE低了不代表推荐效果好了现象RMSE从0.9降到0.7很开心但打开推荐列表一看全是“米饭”“可乐”这类用户肯定会下单的东西没有任何个性化。原因餐饮场景的评分矩阵太稀疏且偏斜。大部分订单都是高频基础菜品ALS只要学会预测这些基础菜的打分就能把RMSE压得很低个性化菜品的预测误差对RMSE的贡献被稀释了。解决别只盯RMSE。加一个业务侧评估指标——推荐命中率。具体做法是用训练好的模型给每个用户推荐Top10然后看这10道菜在测试集的订单里出现了几道。强于RMSE的阈值判断直接看推荐列表里有没有用户真实下单的菜。更专业一点可以计算PrecisionK和RecallKval hits userRecs .join(test.groupBy(user_id).agg(collect_set(dish_id).as(ordered)), Seq(user_id)) .select( $user_id, $recommendations, $ordered, size(array_intersect( $recommendations.dish_id, $ordered )).as(hit_cnt) )array_intersect在Spark SQL里有现成实现注意它的参数是两个数组不是两个struct数组。所以要先从recommendations里取出dish_id组成数组再参与运算。5.4 中文编码菜品名称在Spark里显示成问号或乱码现象Spark SQL查询菜品表返回的菜名全是???或者写入MySQL后变成ææ¡è±这类字符。原因Spark读取MySQL时JDBC连接串没指定useUnicodetruecharacterEncodingutf8或者MySQL表本身是latin1Spark侧按utf8读了转换出错。如果是从HDFS上读CSV还有可能是CSV没带UTF-8 BOM。解决JDBC URL加上useUnicodetruecharacterEncodingUTF-8。读CSV时显式指定编码.option(encoding, UTF-8)。写回MySQL时确保表的charset是utf8mb4连接串再加rewriteBatchedStatementstrue——这个参数不只是性能优化在批量写入时对中文编码的容错也比逐条插入好。6. 从离线到在线把推荐结果变成实时可用的技巧如果你只做到“Spark每天跑一次、结果写MySQL”这个系统已经能应付课程设计和大部分业务演示了。但真正的餐饮平台推荐系统还有一个差距用户晚上7点打开App正是饭点这时你给他推昨天凌晨跑出来的结果可能已经过时了——他想吃的是现在这个时段的东西。一个可行的升级路径是引入Redis做在线缓存。Spark批处理的结果仍然每天全量写入MySQL但API层接收到用户请求后先查Redis里有没有这个用户当天的推荐列表没有就查MySQL并回填Redis设置过期时间为2小时或者覆盖到下一个饭点。这样ALS的离线计算结果加上Redis的快速获取就是业界所称的“离线召回在线缓存”不需要引入Flink和Kafka也能获得接近实时的体验。另一个技巧是用Spark JobServer或者简单的spark-submit --class调度脚本把训练任务包装成可传参的jar包。你不需要把整套系统改成微服务只需要保证MySQL有新的订单数据 → 定时调度Spark跑批 → 重算ALS和FP-Growth → 更新Redis。三步之间用shell脚本或者crontab串起来就得到了一套可持续运行的闭环。做验证时我习惯每次全量重算后做一次“双写对比”一半用户走旧推荐结果一半用户走新推荐结果跑一周看两边的下单转化率差异。虽然线下RMSE和命中率都已经测过但这个线上AB实验能兑现所有模型层面的假设。如果新手阶段不具备AB平台条件至少要在代码里埋点记录“推荐曝光→点击→下单”的日志表哪怕不用Spark分析它将来做任何模型迭代都需要这份数据。我最后想分享一个个人习惯做这个项目时我会把每天Spark任务的执行日志保留下来包括每个stage的耗时、shuffle读写量、失败重试次数。遇到性能问题先翻日志再动代码比一上来就改参数靠谱得多。推荐系统这个方向最难的不是模型公式而是数据质量管理和评价体系——顺着这套代码把这两个基本功练熟了Spark上的其他数据分析项目基本都能平趟。希望帮到你。本文还有配套的精品资源点击获取