开头
汽车租赁系统这个题目,说实话单看"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 九张核心表的职责划分
数据库设计上,除了常规的用户表、车辆表、订单表,还有几张表是专门为推荐和统计服务的。这里我把核心表结构整理成了一张总览:
| 表名 | 核心字段 | 服务对象 |
|---|---|---|
user | id, username, phone, driver_license, register_time | 用户模块 |
car | id, brand, model, type, price_per_day, seat_num, store_id, status | 车辆管理 |
order | id, user_id, car_id, borrow_time, return_time, total_price, status | 订单模块 |
car_type | id, type_name, description | 车型分类(SUV/轿车/MPV等) |
user_behavior | id, user_id, car_id, behavior_type, create_time | 推荐模块 |
user_preference | id, user_id, car_type, preference_score | 推荐模块(用户偏好画像) |
store | id, store_name, address, city | 门店管理 |
daily_stats | id, stat_date, total_orders, total_revenue, active_users, new_users | 统计模块 |
car_rental_stats | id, 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(List<UserBehavior> behaviors) { Map<String, 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(Map<Long, Double> userVector1, Map<Long, Double> userVector2) { Set<Long> 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 List<RecommendCar> recommendCars(Long userId, int topN) { // 1. 构建当前用户的评分向量 Map<Long, Double> userVector = buildUserVector(userId); // 2. 获取所有有行为记录的用户,逐一计算相似度 List<Long> allUserIds = userBehaviorMapper.selectDistinctUserIds(); List<Map.Entry<Long, Double>> similarityList = new ArrayList<>(); for (Long otherUserId : allUserIds) { if (otherUserId.equals(userId)) { continue; } Map<Long, 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()); Map<Long, Double> scoreMap = new HashMap<>(); for (int i = 0; i < k; i++) { Long neighborId = similarityList.get(i).getKey(); double weight = similarityList.get(i).getValue(); Map<Long, Double> neighborVector = buildUserVector(neighborId); for (Map.Entry<Long, 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 id="selectOrderTrend" resultType="com.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 >= #{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分组构建向量:
List<UserBehavior> allBehaviors = userBehaviorMapper.selectAll(); Map<Long, List<UserBehavior>> 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,是因为数据可视化最终服务的是运营决策,口径错了一切图表都是误导。这样的思考方式,做出来的项目才不是"为了用技术而用技术"。
另外分享一个可以直接拿去用的小技巧:推荐算法上线后,我定期会在测试环境把推荐位点击率打印出来和整体列表对比。如果连续一周推荐位点击率下跌,优先检查的不是算法代码,而是行为日志有没有正常入库——日志丢了,再好的算法也是无米之炊。这一条,建议所有要做推荐系统的同学刻在脑子里。