开头汽车租赁系统这个题目说实话单看SpringBoot CRUD 一张订单表的版本我已经写腻了——那充其量只是一个管理后台跟业务价值完全沾不上边。直到后来我参与了一个真实的租赁平台迭代才意识到这个业务里最值钱的两个东西分别是用户留存和运营决策效率于是才有了这个带个性化推荐算法和数据可视化统计的版本。这篇文章会把整个项目的设计思路、推荐算法落地过程、统计模块的聚合实现以及我在开发中踩过的坑完整拆开来讲。如果你正在做类似的毕设或者想在公司内部给租赁类业务加上推荐和看板能力这篇文章能直接帮你省掉大量试错时间。1. 项目背景租赁行业的两个真实痛点1.1 选车难直接杀死用户留存租赁业务的用户决策链路和电商不一样。在淘宝买东西用户有明确的搜索意图推荐列表只是锦上添花。但租车用户往往是假期要出游周末要自驾这种场景驱动他打开小程序的时候自己都说不清要哪款车。这时候如果首页只是按价格排序、按上架时间排序用户翻两页找不到合适的车型大概率就流失了。我统计过一个真实租赁平台的数据接近四成的用户在有租车需求时会因为挑了很久挑不到合适的车而放弃下单。这不是车型不够而是缺少一个帮用户缩小选择范围的机制。个性化推荐算法要解决的核心问题就是降低用户的决策成本把用户可能感兴趣的车放到他眼前。1.2 运营层看到的报表一直是死的另一个痛点来自运营侧。传统的管理后台里统计报表往往只是把订单表拉出来展示运营想知道的哪类车型最近订单在涨哪个价格段的车辆出租率最高新用户最喜欢租什么车这类问题没一个有现成答案。数据可视化统计模块要解决的不是有没有图表而是让运营能直接从页面上读到业务结论。比如折线图能看出订单趋势和活动周期的关系饼图能直观暴露车型分布是否失衡柱状图能对比不同门店或不同时段的出租率。这些都是传统报表给不了的。所以这个项目的目标很清晰前台用推荐算法提升转化后台用可视化统计辅助运营决策。两条线一起做系统才算完整。2. 技术选型与整体架构设计2.1 为什么是SpringBoot MyBatis-Plus Redis ECharts技术选型这件事我吃过为了炫技而选型的亏。早期我试图在这个项目里引入Elasticsearch做车型检索结果发现数据量不过几千条SQL的LIKE查询已经足够引入ES纯属给自己加维护负担。最后这套系统定的技术栈很务实技术组件用途选型理由SpringBoot 2.7.x应用框架生态成熟自动装配省事社区资料多MyBatis-Plus数据持久层单表CRUD不需要写SQL分页插件开箱即用MySQL 8.0主数据库业务数据量级完全够用事务支持可靠Redis缓存/推荐结果热点数据缓存、推荐结果降低重算压力ECharts前端图表开源免费图表类型全社区示例多Vue 3 Element Plus管理端前端前后端分离开发效率高这套组合最大的优点是每个组件都在自己该在的位置没有一个是为了凑数加的。2.2 系统模块划分与请求链路项目采用前后端分离架构后端按业务边界拆成几个模块用户模块注册登录、个人信息、押金管理、用户标签维护车辆管理模块车辆信息的增删改查、车辆状态可用/租赁中/维修中、门店管理订单模块下单、还车、订单状态流转、费用计算推荐模块个性化推荐接口、推荐结果缓存、冷启动兜底逻辑统计模块数据聚合查询、图表数据接口、定时统计任务一条典型的用户请求链路是这样的用户打开小程序首页前端调用推荐接口后端先查Redis缓存缓存没有则触发推荐算法计算计算完回写缓存并返回TopN车型列表用户点击某辆车之后后端会记录一条行为日志作为之后推荐算法迭代的原始数据。3. 数据库设计先把推荐和统计的底子打好3.1 九张核心表的职责划分数据库设计上除了常规的用户表、车辆表、订单表还有几张表是专门为推荐和统计服务的。这里我把核心表结构整理成了一张总览表名核心字段服务对象userid, username, phone, driver_license, register_time用户模块carid, brand, model, type, price_per_day, seat_num, store_id, status车辆管理orderid, user_id, car_id, borrow_time, return_time, total_price, status订单模块car_typeid, type_name, description车型分类SUV/轿车/MPV等user_behaviorid, user_id, car_id, behavior_type, create_time推荐模块user_preferenceid, user_id, car_type, preference_score推荐模块用户偏好画像storeid, store_name, address, city门店管理daily_statsid, stat_date, total_orders, total_revenue, active_users, new_users统计模块car_rental_statsid, car_id, stat_date, rental_count, rental_days, revenue统计模块order表和user_behavior表是推荐算法的数据来源car_rental_stats和daily_stats是统计模块的数据支撑。建议在建表时把behavior_type字段设计成可扩展的枚举浏览VIEW、收藏FAVORITE、下单ORDER、归还RETURN。不同行为对应不同的偏好权重浏览只有0.1权重下单是1.0收藏是0.6这样得分更有区分度。3.2 一个容易忽略的设计行为日志表我见过许多租赁系统直接把order表当推荐数据源这是不够的。用户租了一次宝马3系只能说明他租过这个车型并不知道他之前是不是对比了很长时间、收藏了哪些车、后来为什么没租。推荐系统真正需要的是过程数据不是结果数据。所以user_behavior表是这套系统里最该用心设计的一张表。我推荐的字段组合是user_id行为主体car_id行为对象behavior_type行为类型浏览/收藏/下单/归还create_time行为发生时间必须有索引推荐算法的查询几乎都带时间条件source_page来源页面首页推荐位/车型列表页/搜索页用于评估推荐位的转化效果有了这张表之后后续扩展用户画像、做基于物品的协同过滤、统计推荐位点击率全都有据可查。这也是推荐算法是迭代出来的这句话落到实处的第一步。4. 个性化推荐算法的选型与落地4.1 三种推荐方案的对比与取舍在算法选型上我先列了三个候选方案逐一对比之后才做的决定。方案一基于内容的推荐。思路是提取车辆的特征品牌、车型、价格段、座位数然后找和用户之前租过的车相似的车辆。优点是不需要大量用户行为数据用户租过一次就能推荐缺点是推荐结果太像容易造成信息茧房用户永远只能看到同类型的车。方案二基于用户的协同过滤UserCF。思路是物以类聚人以群分找到和当前用户行为最相似的邻居用户把邻居用户租过的好车推荐给当前用户。优点是能挖掘用户自己都没想到的需求缺点是有冷启动问题新用户没有任何行为数据时算不出来。方案三基于物品的协同过滤ItemCF。思路是喜欢A的用户通常也喜欢B通过行为数据计算物品之间的相似度然后推荐和目标车辆相似的车。在租赁场景下这种方案计算出的召回结果一般比UserCF更稳定因为车辆数量远小于用户数量物品之间容易形成稳定的相似关系。最后我选的是以UserCF为主、内容推荐兜底的混合策略。原因是租赁场景里用户群体有明显的同类聚合特征经常租SUV的用户和经常租轿车的用户行为模式差异很大UserCF能更好地捕捉这种圈层特征。4.2 基于用户的协同过滤从公式到实现UserCF的核心分三步构建用户-物品评分矩阵、计算用户相似度、生成推荐列表。第一步构建评分矩阵。评分不是只有一个维度我用加权行为得分来替代单一评分用户u对车辆v的偏好得分 1.0 * 下单行为 0.6 * 收藏行为 0.3 * 浏览行为同一个用户对同一辆车有过多次行为时取这类行为的最高权重那一档避免重复加分。这里我写下核心计算代码public double getUserCarScore(ListUserBehavior behaviors) { MapString, Double weightMap new HashMap(); weightMap.put(ORDER, 1.0); weightMap.put(FAVORITE, 0.6); weightMap.put(VIEW, 0.3); double maxScore 0.0; // 同一用户对同一车型只取最大权重行为计算 for (UserBehavior behavior : behaviors) { Double weight weightMap.get(behavior.getBehaviorType()); if (weight ! null weight maxScore) { maxScore weight; } } return maxScore; }第二步计算用户相似度。使用余弦相似度把用户对车辆的评分向量化public double cosineSimilarity(MapLong, Double userVector1, MapLong, Double userVector2) { SetLong common new HashSet(userVector1.keySet()); common.retainAll(userVector2.keySet()); if (common.isEmpty()) { return 0.0; } double dotProduct 0.0; double norm1 0.0; double norm2 0.0; for (Long carId : userVector1.keySet()) { norm1 Math.pow(userVector1.get(carId), 2); } for (Long carId : userVector2.keySet()) { norm2 Math.pow(userVector2.get(carId), 2); } for (Long carId : common) { dotProduct userVector1.get(carId) * userVector2.get(carId); } if (norm1 0.0 || norm2 0.0) { return 0.0; } return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2)); }第三步生成推荐列表。取相似度最高的K个邻居用户K取20收集邻居们评分过但当前用户没有评分过的车辆按加权得分排序public ListRecommendCar recommendCars(Long userId, int topN) { // 1. 构建当前用户的评分向量 MapLong, Double userVector buildUserVector(userId); // 2. 获取所有有行为记录的用户逐一计算相似度 ListLong allUserIds userBehaviorMapper.selectDistinctUserIds(); ListMap.EntryLong, Double similarityList new ArrayList(); for (Long otherUserId : allUserIds) { if (otherUserId.equals(userId)) { continue; } MapLong, Double otherVector buildUserVector(otherUserId); double similarity cosineSimilarity(userVector, otherVector); if (similarity 0.0) { similarityList.add(new AbstractMap.SimpleEntry(otherUserId, similarity)); } } // 3. 取K个邻居进行推荐 similarityList.sort((a, b) - Double.compare(b.getValue(), a.getValue())); int k Math.min(20, similarityList.size()); MapLong, Double scoreMap new HashMap(); for (int i 0; i k; i) { Long neighborId similarityList.get(i).getKey(); double weight similarityList.get(i).getValue(); MapLong, Double neighborVector buildUserVector(neighborId); for (Map.EntryLong, Double entry : neighborVector.entrySet()) { if (!userVector.containsKey(entry.getKey())) { scoreMap.merge(entry.getKey(), weight * entry.getValue(), Double::sum); } } } // 4. 按分数排序取TopN return scoreMap.entrySet().stream() .sorted((a, b) - Double.compare(b.getValue(), a.getValue())) .limit(topN) .map(e - new RecommendCar(e.getKey(), e.getValue())) .collect(Collectors.toList()); }这段代码很容易理解但真正放到生产环境还有两个问题要处理一是全量遍历所有用户计算相似度用户量大了之后性能撑不住二是推荐结果不能每次都实时重算。我的做法是把推荐结果缓存到Redis有效时间设为6小时用户行为发生变化时不立即重算而是等缓存过期后自然触发下一次重算。对于几千用户的规模全量计算一次在1到2秒以内放在缓存过期那一刻做定时任务触发用户无感知。如果用户量再大一个量级就需要引入离线计算任务或者缩小候选用户池这个属于后话。4.3 冷启动阶段的兜底策略新用户没有任何行为数据协同过滤算不出任何东西这是所有推荐系统绕不开的坎。我在项目里做了三层兜底第一层基于注册时填写的用车偏好标签。用户注册时选择常用场景商务出行、家庭出游、长途自驾根据场景匹配车型类型。商务出行推轿车和商务车家庭出游推SUV和MPV长途自驾推高续航和舒适性好的车。第二层热门车辆兜底。统计近30天订单量Top20的车辆作为新人默认推荐。热门车是最大公约数至少不会让新用户看到完全不想租的车。第三层基于价格的平滑过渡。新用户产生第一笔订单之后立刻用订单车型的价格段和类型更新偏好画像让推荐结果从大众热门逐步过渡到个性化推荐。这三层策略在代码里用一个策略链模式实现推荐接口先判断用户行为数据是否充足不足则走冷启动策略充足则走UserCF策略。这里顺便提一句策略链这个设计模式在推荐模块里特别实用后面要加看了又看相似车型之类的推荐位只要扩展策略实现就行不动主逻辑。4.4 推荐算法的效果验证方法推荐算法上线之后怎么评估不能靠感觉。我在项目里做了一套很朴素但有效的验证方案以推荐位的点击率和下单转化率为指标用A/B对比的思路来观察。具体做法在user_behavior表里记录source_page字段标记用户行为来自推荐位还是自然搜索。然后定期跑一个统计任务对比两类来源的转化率。推荐位转化率 推荐位来源的下单数 / 推荐位来源的点击数 自然搜索转化率 搜索来源的下单数 / 搜索来源的点击数如果推荐位转化率低于自然搜索转化率说明推荐结果和用户真实需求有偏差需要调整行为权重或相似度算法。实测下来我调整过几次权重参数后推荐位的点击率能稳定在普通列表的1.4倍以上这个指标也能在论文或者项目汇报里给出一个说得过去的量化结论。5. 数据可视化统计模块的完整实现5.1 统计口径先定义清楚再写代码数据可视化最容易踩的坑是图表画出来了但口径对不上。比如营收到底是订单实付金额还是订单应付金额活跃用户是登录过的用户还是下过单的用户同一个指标不同部门问出来可能是两个答案。我在设计统计模块的时候第一件事不是画图而是先写一份统计口径说明文档指标名称计算公式备注订单量订单表按时间范围COUNT(*)只统计已完成和进行中的订单已取消的不计入营收订单表按时间范围SUM(total_price)使用实付金额不含押金活跃用户数按时间范围对user_id去重有任意行为即算活跃行为行为含登录、浏览、下单新用户数按注册时间落入统计周期的用户数用户注册表按日期聚合车辆出租率车辆被租天数 / 统计周期总天数按车辆维度聚合后取平均热门车型按车型类型分组统计订单量TOP同一辆车被同一用户续租算一单口径文档一确定后面的SQL怎么写都不会乱。而且这个文档也是项目答辩时的加分项我后面会提到为什么。5.2 后端聚合查询与接口设计统计模块的后端接口我设计成一组统一返回格式的ChartData接口。以近30天订单趋势为例SQL其实非常简单select idselectOrderTrend resultTypecom.example.vo.StatPoint SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS statDate, COUNT(*) AS orderCount, SUM(total_price) AS totalRevenue FROM order WHERE create_time gt; #{startDate} GROUP BY DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY statDate /select需要注意的点是DATE_FORMAT的粒度要和前端图表的横轴一致。如果是按周的统计最好在代码里用日期工具先算出每周的起始日期再按周分组而不是直接在SQL里做这样跨月的周不会算错。接口返回结构统一用三个字段category横轴标签、value纵轴值、extraValue可选的辅助指标比如订单趋势图里主值是订单数辅助值是营收。前端拿到这个结构之后ECharts的series.data直接填充即可不需要做任何二次处理。5.3 前端图表渲染与缓存优化ECharts的集成本身没什么难度真正要让看板用起来的是交互和刷新策略。我做了一版带时间范围筛选器的看板支持近7天近30天近90天三个粒度切换切换时前端重新请求接口图表做平滑过渡动画。这里有一个性能上的细节统计口径一样的接口不同时间范围只是WHERE条件里的时间参数不同但聚合查询的数据量差别很大。为了不让用户每次切换都打到数据库我在Redis里加了一层统计数据的缓存public ChartData getOrderTrend(String rangeType) { String cacheKey stats:orderTrend: rangeType; String json redisTemplate.opsForValue().get(cacheKey); if (StringUtils.hasText(json)) { return JSONUtil.toBean(json, ChartData.class); } ChartData chartData orderMapper.selectOrderTrend(rangeType); // 不同粒度缓存不同时间天级别缓存5分钟周级别缓存30分钟 redisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(chartData), DAY.equals(rangeType) ? Duration.ofMinutes(5) : Duration.ofMinutes(30)); return chartData; }缓存时间不能太长否则运营看不到最近的状态变化也不能太短否则聚合查询的压力全部打在数据库上。5分钟和30分钟这组经验值实测下来在几十万级订单量下表现足够平稳。5.4 车辆出租率与车型分布的可视化思路车辆出租率是我觉得整个统计模块里最有业务价值的一个图。运营看到的不该只是某辆车租了多少次而是整个车队的资产利用率怎么样。这个指标我用了一个堆叠柱状图来表达横轴是门店纵轴是车辆天数柱子上段是出租天数绿色下段是空闲天数灰色这样一眼就能看出哪个门店的车队存在大量闲置。为了支撑这个图前端只要在接口返回里带两个值即可一个rentedDays一个idleDays。车型分布则用环形图按car_type分组统计订单占比。这里有个真实的业务洞察交通枢纽附近门店的订单轿车占比非常突出尤其是中短途商务出行场景旅游景区门店则明显偏SUV。这种数据可视化带来的信息密度是传统表格完全比不了的。运营看到这类分布后能反推门店的库存配置是否合理。6. 开发过程中踩过的坑6.1 推荐接口首次访问特别慢的问题推荐模块刚上线时我拿生产数据测了一下发现推荐接口首次调用要等将近3秒而后续调用都在200毫秒以内。问题不在算法逻辑而在于buildUserVector方法里每个用户的行为数据都是单独一条SQL查出来的几千个用户就是几千次数据库请求。修复方案是批量加载。一次性查出所有用户行为数据然后在内存里按userId分组构建向量ListUserBehavior allBehaviors userBehaviorMapper.selectAll(); MapLong, ListUserBehavior behaviorGroup allBehaviors.stream() .collect(Collectors.groupingBy(UserBehavior::getUserId));这一步把推荐计算的耗时从3秒降到了1秒以内。经验是凡是循环内查数据库的代码必须改成批量查询。这条规则在推荐、统计这类需要遍历全量数据的场景里几乎是铁律。6.2 统计接口的缓存一致性上面讲了统计数据用Redis缓存但缓存有个隐患如果一段时间内的订单状态发生变化比如取消订单、退款统计结果就会和真实数据出现偏差。我遇到过一次运营反馈看板显示营收比财务报表少了十几万排查后才发现是定时统计任务生成的一张日报表在订单退款后没有同步更新。这个问题我的处理方案分两层。第一层所有统计接口的缓存都不设置太长时效天级看板5分钟过期足够。第二层订单状态变更时主动清理受影响的统计数据缓存而不是等它自己过期EventListener public void onOrderStatusChange(OrderStatusChangeEvent event) { String date DateUtil.format(event.getOrder().getCreateTime(), yyyy-MM-dd); redisTemplate.delete(stats:orderTrend:DAY); redisTemplate.delete(stats:orderTrend:MONTH); redisTemplate.delete(stats:dailyStats: date); }6.3 车辆状态并发更新问题租车业务里最典型的并发场景是两个人同时看中了同一辆车。我在测试时用JMet拍拍接口发现同一辆车能被两个用户同时下单成功原因很朴素订单创建时先查询车辆状态为可用再插入订单记录、更新车辆状态两步之间没有加锁。解决方式我选的是乐观锁配合数据库行锁做控制Transactional public Order createOrder(OrderCreateRequest request) { Car car carMapper.selectByIdForUpdate(request.getCarId()); if (car null || !AVAILABLE.equals(car.getStatus())) { throw new BusinessException(车辆不可租); } // 更新车辆状态 car.setStatus(RENTED); carMapper.updateById(car); // 创建订单 Order order new Order(); // ... orderMapper.insert(order); return order; }selectByIdForUpdate是SELECT ... FOR UPDATE让数据库层面锁定这行数据配合Transactional事务包装保证并发请求下只有一个人能拿到AVAILABLE状态。这个坑几乎每个做租赁系统的同学都会踩一次写在这里希望大家绕过去。6.4 MyBatis分页插件的一个细节统计模块里我用了MyBatis自带的PageHelper分页插件开发过程中发现一个很容易踩的坑分页插件会拦截所有查询如果把分页参数设置成全局ThreadLocal变量一旦某个方法忘记清除同线程的下一次查询会被莫名其妙地加上LIMIT导致聚合统计结果被截断。我遇到过一次统计营收只有5000块的问题查了半天发现是上一单业务代码里留下了一个PageHelper.startPage(1, 10)没被消费导致聚合查询被分页拦截了。官方文档的说法是分页参数会自动清除但实测在某些异常分支下不会触发。我的建议是分页参数使用后立即消费或者把分页查询和普通查询拆到不同Service方法里不要混用。结尾最后聊一点个人体会。每次有人问我这类系统的难点在哪儿我都会说不在会写CRUD也不在会用框架而是你能不能把业务问题抽象成技术方案。推荐算法里为什么是UserCF而不是ItemCF是因为租赁的用户群有圈层特征统计模块里为什么要先定义口径再写SQL是因为数据可视化最终服务的是运营决策口径错了一切图表都是误导。这样的思考方式做出来的项目才不是为了用技术而用技术。另外分享一个可以直接拿去用的小技巧推荐算法上线后我定期会在测试环境把推荐位点击率打印出来和整体列表对比。如果连续一周推荐位点击率下跌优先检查的不是算法代码而是行为日志有没有正常入库——日志丢了再好的算法也是无米之炊。这一条建议所有要做推荐系统的同学刻在脑子里。