ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于SpringBoot的自习室座位预定系统:协同过滤推荐与数据可视化实战

基于SpringBoot的自习室座位预定系统:协同过滤推荐与数据可视化实战 我做了好几个版本的自习室座位预定系统有纯JSP的老古董也有前后端分离的进阶版。最后沉淀下来的是这套基于SpringBoot的完整方案预约选座、日期时间段管理、协同过滤推荐算法、数据可视化统计四个模块全部打通。这篇文章就把我踩过的坑、取舍过的方案、核心代码和设计思路一次性讲清楚给正在做类似项目或者准备搞毕业设计的朋友一个可以直接抄作业的参考。1. 为什么做这样一个座位预定系统1.1 自习室管理的真实痛点先说一个很实际的现象。大学图书馆或者考研自习室座位永远不够用。占座靠书、靠水杯、靠一件外套每天早上开馆前门口排队一开门就百米冲刺。管理员也很头疼每天要统计哪些座位被占了、哪些座位空着靠人工巡场费力不说数据还没法沉淀。到了考试周座位利用率可能冲到90%以上但平时又经常有大片座位闲置供需严重不平衡。这些问题靠传统的人工管理根本解决不了。一个座位预定系统真正要解决的不是简简单单让用户能在网页上点一下预约而是三件事第一让座位的空闲状态实时可见用户不用跑现场就知道哪个位置能坐第二用预约数据反向指导管理比如哪些时段上座率低是不是可以调整开放时间第三通过推荐算法让热门座位和冷门座位之间实现一定程度的削峰填谷避免大家全都挤在靠窗、靠插座的位置。1.2 技术选型背后的思考技术栈看起来是老三样SpringBoot Vue ECharts加上一个协同过滤推荐算法。但选这套组合是经过考虑的。SpringBoot负责后端接口和业务逻辑它的优势不用多说自动配置、起步依赖、内嵌Tomcat开发效率极高。对于这种单体项目SpringBoot是性价比最高的选择没有之一。有人会纠结是不是要用微服务我可以负责任地说这个体量的项目上微服务纯属给自己找麻烦。你需要的是一个能快速开发、稳定运行、部署简单的框架SpringBoot完全满足。推荐算法选了协同过滤而不是基于内容的推荐理由也很直接。自习室座位这个场景里用户的偏好行为是预约了哪个座位、在哪个时间段学习这种数据天然就是用户-物品交互记录是协同过滤最喜欢的输入。如果换成基于内容推荐你需要给每个座位打标签靠窗、有插座、安静区还得给用户建画像冷启动和人工维护成本都高。协同过滤不需要这些只要有历史预约数据就能通过用户之间的相似性来预测你可能喜欢的座位。ECharts做数据可视化是因为它对后端同学最友好。纯前端图表库引入js就能用社区案例多百度开源之后更新维护也一直没停。折线图、饼图、热力图、散点图都支持API设计很简单官网的示例复制下来改改就能跑通。2. 整体设计与数据库模型2.1 前台与后台的功能切分系统按角色分成两个大端。前端是用户使用的小程序端或者网页端核心功能是注册登录、查看座位分布图、按日期和时段预约、查看自己的预约记录、取消预约。后端管理端则给管理员使用负责座位信息维护、座位状态管理、预约记录审核、数据统计查看。前后台共用同一套SpringBoot接口底层数据是一样的只是暴露的接口粒度不同。用户端接口偏向查询和下单管理端接口偏向配置和统计。这种设计的好处是将来如果想把前端换成小程序或者App只需要适配新的前端项目就行后端完全不用动。2.2 数据库核心表设计数据库是整个系统的地基表结构设计得好不好直接影响后面所有业务的复杂程度。我这里把核心表分为5张。座位表保存座位的基础信息和位置信息。关键字段包括座位编号、所属区域、是否靠窗、是否有插座、状态字段。状态字段我用的枚举值0空闲、1已预约、2使用中、3维护中、4已禁用。预约表是业务核心字段包括预约ID、用户ID、座位ID、预约日期、开始时间段、结束时间段、预约状态、创建时间。预约状态有已预约、已签到、已取消、已过期、已完成。这里有个细节需要注意预约表里存的是座位ID快照而不是座位编号字符串。因为座位信息可能会改关联ID才能保证数据可追溯。时间段表用来定义一天之内有哪些可预约的时间块。我按自习室实际运营情况划分了三个大时段上午8:00-12:00、下午14:00-18:00、晚上19:00-22:00。这个设计比按小时划分更贴近真实场景——考试周大家一坐就是半天没人按小时频繁换座位。用户行为表是为了协同过滤专门设计的。每产生一条预约记录就往这张表里插入一条行为记录字段包括用户ID、座位ID、行为类型、行为时间。行为类型区分了预约和取消取消的行为权重低一些预约成功的权重高一些。操作日志表记录管理员的关键操作比如禁用座位、开放座位、处理违约方便事后追溯。2.3 为什么单独建一张用户行为表有人会问预约表里已经有用户和座位的关系了为什么还要单独搞一张行为表这里涉及一个重要的工程思维业务数据和算法数据要解耦。预约表承担的是事务性业务逻辑它要保证数据一致性要支持状态流转的更新要应对并发。这些需求要求预约表有严格的主键、约束、索引结构非常规整。而协同过滤算法需要的输入是用户-物品的二部图关系更关注的是行为发生的次数、时间、类型权重。如果直接复用预约表一是查询时会和业务逻辑耦合二是历史取消记录和完成记录混在一起算法要先去清洗麻烦。单独建行为表之后算法模块可以独立访问这张表做离线计算也好、定时任务更新也好都不会影响到业务表的数据。这是我在做了多次迭代之后体会最深的一点算法模块的输入输出边界要清晰。3. 核心功能模块的落地实现3.1 预约选座状态驱动的完整流程预约选座是用户感知最直接的模块。用户进入选座页面看到的是按照真实物理位置绘制的座位分布图绿色表示空闲灰色表示已预约红色表示维护中。点击一个绿色座位系统弹出日期和时段选择框确认后提交预约。这个流程背后是一个典型的状态机。我定义的状态流转是座位空闲 - 用户选择座位和时段 - 系统校验日期合法、时段未冲突、座位未被占用 - 扣减座位余量、插入预约记录、座位状态改为已预约。用户签到后座位状态由已预约变为使用中。用户取消预约或者超时未签到座位状态回退到空闲。核心校验逻辑写在这段代码里。预约接口在插入预约记录之前必须做两个检查第一这个座位在这个时间段是否已经被预约第二用户在这个时间段是否已经预约了其他座位。第二个检查很多人会漏掉它防止的是同一个人同时占两个座位的刷座位行为。Transactional(rollbackFor Exception.class) public Result bookSeat(BookRequest request) { // 校验1座位在该时间段是否已被预约 Integer count bookingMapper.countConflict( request.getSeatId(), request.getBookDate(), request.getStartTime(), request.getEndTime() ); if (count 0) { return Result.error(该座位在此时段已被预约); } // 校验2用户在该时间段是否已有预约 Integer userCount bookingMapper.countUserConflict( request.getUserId(), request.getBookDate(), request.getStartTime(), request.getEndTime() ); if (userCount 0) { return Result.error(你在此时段已有预约不能重复预约); } // 插入预约记录 Booking booking new Booking(); booking.setUserId(request.getUserId()); booking.setSeatId(request.getSeatId()); booking.setBookDate(request.getBookDate()); booking.setStartTime(request.getStartTime()); booking.setEndTime(request.getEndTime()); booking.setStatus(BookingStatus.BOOKED.getValue()); bookingMapper.insert(booking); // 修改座位状态 seatMapper.updateStatus(request.getSeatId(), SeatStatus.BOOKED.getValue()); return Result.success(); }校验冲突的SQL要写成区间重叠判断不能简单用等值判断。一个时间段和其他时间段是否冲突判断条件是新预约开始时间小于已有预约结束时间并且新预约结束时间大于已有预约开始时间。这段SQL是通用的时间冲突检测逻辑在任何预约类系统里都能直接复用。SELECT COUNT(*) FROM booking WHERE seat_id #{seatId} AND book_date #{bookDate} AND status IN (0, 1) AND start_time #{endTime} AND end_time #{startTime}3.2 日期时间段设计灵活预约时间块时间段的处理是这个系统的隐藏难点。很多初学者做预约系统直接用系统当前时间加减多少分钟来生成slot这完全是错误思路。自习室的预约是以天为单位的用户选择的是某一天的一个时间段而不是从现在开始的两小时。我的做法是前端展示一个日历组件可预约日期是未来7天每个日期下面展示三个固定的时间段卡片。后端为每个日期动态生成可预约时段列表。如果某个时段该座位的预约数达到上限这个时段卡片直接置灰。预约时间段都存在时间段表里有稳定的ID。预约记录表通过book_date和time_slot_id的组合精确定位到某天某个时段。这样做的好处是统计查询变得非常简单。比如要统计某天上午时段的预约量一条group by查询就出来了不需要在业务代码里做复杂的日期转换。时间段的粒度太细也不行。我之前试过按30分钟一个slot来设计结果是用户选择起来极其繁琐预约完还要拼凑时间体验很差。后来改成了固定的大块时间段虽然灵活度降低了但符合实际使用习惯。系统是给人用的不是给算法炫技的业务合理性永远排在技术精确性前面。3.3 并发场景下座位状态一致性保障这是整个系统里我最想重点讲的部分。预约选座是最典型的并发写场景考试周早上八点开放预约同一秒钟可能有几百个人同时抢同一个靠窗座位。如果不做并发控制两个人同时查到座位空闲同时提交预约就会产生超卖。解决超卖问题业内通用方案是乐观锁。不需要引入Redis分布式锁那么重的组件光靠数据库就能解决。座位表里加一个version字段更新座位状态的SQL带上版本号条件更新影响行数为0就说明被别人改过了。UPDATE seat SET status #{newStatus}, version version 1 WHERE id #{seatId} AND status #{oldStatus} AND version #{oldVersion}实现上把座位状态的更新和预约记录的插入放在同一个数据库事务里先更新座位状态再插入预约记录。如果座位更新失败说明已经被别人抢先事务回滚预约记录不会落库。这是最简单且有效的防超卖方案实测在每秒100并发下没有出现一例重复预约。另外要注意设置事务的隔离级别。SpringBoot默认的事务隔离级别是数据库默认级别MySQL是REPEATABLE_READ。这个级别下两个并发事务同时查询空闲座位可能都查到同样的结果。所以一定要用上面那种带更新条件的SQL在更新那一刻数据库会加行锁第二个事务的更新操作会阻塞直到第一个事务提交。4. 协同过滤推荐算法的工程化落地4.1 这个场景更适合哪种协同过滤协同过滤分为基于用户的和基于物品的两种。基于用户的协同过滤是找出和你相似的其他用户把这些人喜欢但你还没用过的座位推荐给你。基于物品的协同过滤是找出和你历史上用过的座位相似的座位推荐给你。这个场景里我最终选了基于物品的协同过滤。原因有几个。自习室座位的数量是有限的一般几百个相对稳定。座位的偏好关系是相对固定的喜欢靠窗座位的人大概率一直喜欢靠窗喜欢安静角落的也一直会去角落。这种场景下物品之间的相似度计算一次可以缓存起来长期复用性价比高。而基于用户的协同过滤需要实时计算用户之间的相似度用户数量比座位数量大得多计算量更大。而且用户的兴趣会随着学习阶段变化期末和平时偏好差异很大用户相似度的稳定性差。4.2 用户-座位偏好矩阵的构建算法落地第一步不是写相似度公式而是构建用户对座位的评分矩阵。在真实系统中评分不是用户显式打的星星而是根据行为隐式计算出来的。我给不同行为定义了不同的权重。完成一次完整预约有签到记录计5分预约后取消计2分预约后未签到违约计1分。所有行为按时间衰减累加越近的行为权重越高。衰减系数取0.9意思是30天前的行为对当前偏好的影响是当前的0.9的30次方倍基本可以忽略这样推荐结果能跟上用户最近的习惯变化。准备好评分矩阵之后需要做一步数据清洗。有些座位预约次数极少或者只有个别人用过这些数据会让相似度计算产生噪声。我设定了一个过滤条件行为次数少于3次的用户参与计算时权重减半。还有长期处于维护状态的座位直接排除在推荐集合外。这些细节决定了算法算出来的结果到底能不能用。4.3 余弦相似度计算示例物品相似度用余弦相似度计算。核心思路是两个座位被同一批用户喜欢过它们的相似度就高。这里我把喜欢定义为评分超过阈值3的用户集合。以座位A和座位B为例把它们被用户评分的向量拿出来维度是全体用户数量。两个向量之间的夹角的余弦值就是相似度。值越接近1表示这两个座位在用户偏好上的重合度越高。计算代码参考如下public double calcCosineSimilarity(MapLong, Double seatAVector, MapLong, Double seatBVector) { SetLong commonUsers new HashSet(seatAVector.keySet()); commonUsers.retainAll(seatBVector.keySet()); // 没有共同用户相似度为0 if (commonUsers.isEmpty()) { return 0.0; } double dotProduct 0.0; double normA 0.0; double normB 0.0; for (Long userId : commonUsers) { double scoreA seatAVector.getOrDefault(userId, 0.0); double scoreB seatBVector.getOrDefault(userId, 0.0); dotProduct scoreA * scoreB; } for (Double score : seatAVector.values()) { normA score * score; } for (Double score : seatBVector.values()) { normB score * score; } if (normA 0.0 || normB 0.0) { return 0.0; } return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); }相似度计算的时间复杂度是O(nmp)n是座位数m是用户数p是平均行为数。几百个座位几千个用户的情况下计算量完全可控。结果计算一次之后缓存到Redis里key设计成seat_similarity:seatId存储这个座位最相似的Top20个座位ID和相似度值。缓存有效期设成1小时用户行为变化频繁的时段可以缩短到30分钟。推荐接口的响应时间实测在50毫秒以内用户完全无感知。4.4 冷启动与数据稀疏的兜底方案协同过滤最怕的就是冷启动。新用户一条预约记录都没有系统完全不知道他喜欢什么。新座位刚上线没有被任何用户用过也很难进入推荐池。我的兜底方案分成两级。第一级是热门推荐统计所有座位最近30天的预约次数倒序排列取出Top10作为默认推荐结果。这个方案在冷启动期表现不差因为自习室座位的热门度本身就遵循幂律分布10%的座位承担了大部分预约量推荐热门座位至少不会让用户觉得离谱。第二级是时段协调推荐。当用户选择了某个日期和时段之后推荐系统在这个时段内能预约的座位中选择热度适中排名在30%-70%区间的座位进行推荐。这种做法在削峰填谷上非常有效能引导用户选择平时预约量低的座位缓解热门座位的拥挤。还需要注意推荐结果的混排策略。最终展示给用户的是5个推荐座位2个基于协同过滤计算出来的相似座位2个热门兜底座位1个空闲座位。混排比例可以根据实际效果调整考试周人流量大的时候可以增加热门座位的比例因为用户更看重能不能抢到位置而不是个性化的偏好。5. 数据可视化统计的完整实现5.1 面向管理员的统计维度设计数据可视化不是为了画图而画图每个图表背后都要对应一个管理决策问题。我在设计统计模块时先列了管理员最关心的几个问题然后才决定画什么图。第一个问题是自习室的整体利用率如何对应的图表是折线图展示最近30天每天的总预约数和总座位数对比让管理员直观看到利用率的变化趋势。第二个问题是哪些时段最火爆对应的图表是柱状图按时间段统计平均预约量展示上午、下午、晚上三个时段的冷热差异。这能指导管理员调整开放时段比如上午预约量长期低于30%可以适当缩减上午开放的区域集中管理。第三个问题是哪些座位最受欢迎对应的图表是饼图或者热力图。饼图展示热门座位的预约占比热力图展示每个座位在不同时段的预约热度色块越深表示预约量越高。这张图直接指导物理空间的改造比如靠窗座位一直爆满是不是可以增加靠窗位置或者调整桌椅布局。第四个问题是用户的违约情况如何对应的图表是漏斗图展示预约总数、签到数、违约数、取消数的转化漏斗。违约率过高说明需要引入信用分机制或者预约保证金机制。统计模块的页面布局按总览-详情的层级组织。首页放四个核心指标卡片今日预约量、当前在用座位数、今日履约率、座位利用率。往下才是具体的图表区域。管理员先看数字有异常再点进对应图表查看细节这是比较顺畅的使用动线。5.2 ECharts图表的接入与配置ECharts的接入很简单在Vue项目里先安装依赖包然后在需要的组件中按需引入图表模块。折线图和柱状图只需要echarts/core里的LineChart和BarChart不要整包引入打包体积会大很多。import { ref, onMounted } from vue import * as echarts from echarts/core import { LineChart } from echarts/charts import { GridComponent, TooltipComponent, LegendComponent } from echarts/components import { CanvasRenderer } from echarts/renderers echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]) // 接口获取统计数据后初始化图表 const initUsageChart (data) { const chartDom document.getElementById(usageChart) const myChart echarts.init(chartDom) myChart.setOption({ tooltip: { trigger: axis }, legend: { data: [预约数, 总座位数] }, grid: { left: 3%, right: 4%, bottom: 3%, containLabel: true }, xAxis: { type: category, data: data.dates }, yAxis: { type: value }, series: [ { name: 预约数, type: line, data: data.bookings, smooth: true }, { name: 总座位数, type: line, data: data.totalSeats, smooth: true } ] }) // 组件卸载时销毁实例防止内存泄漏 window.addEventListener(resize, () myChart.resize()) }几个容易踩的坑点值得注意。第一图表容器必须有明确的宽度和高度否则ECharts初始化时会报错或者白屏。我给图表容器统一设置了一个min-height样式避免数据加载前容器高度塌陷。第二动态数据更新时要使用myChart.setOption而不是重新init重新init会丢失动画效果和交互状态。第三Vue组件卸载时要调用myChart.dispose()释放实例不然在单页应用里切换路由会导致内存占用越来越高。5.3 统计接口的后端SQL实现统计接口的后端实现关键不在于写复杂的业务代码而在于把SQL聚合写好。以时段热度统计为例一条group by就能搞定日期格式化成字符串再按时间段分组统计预约数量。SELECT time_slot_id, DATE_FORMAT(create_time, %Y-%m-%d) as book_date, COUNT(*) as booking_count FROM booking WHERE create_time DATE_SUB(NOW(), INTERVAL 30 DAY) AND status IN (0, 1, 3) GROUP BY time_slot_id, DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY book_date, time_slot_id座位热力图统计稍微复杂一点需要把座位和时间段做交叉。SQL上可以先把预约记录按座位和时间段聚合然后在代码里补全缺失的组合没有被预约的座位补0。这一步补全操作在Java里做就行不要执着于一条SQL查出完整的热力矩阵SQL查出的数据规整后交给前端渲染效率反而更高。统计接口的数据量如果大了比如积累了一年的数据一定要加时间范围条件。我设计接口时都要求传入startDate和endDate默认查最近30天防止用户一次查询拉出全量历史数据导致接口超时。6. 常见问题与排错经验6.1 并发下重复预约同一个座位这个问题的表现是两个用户同时提交预约系统返回都成功但数据库里出现了两条同座位同时段的预约记录。原因就是我没做座位状态更新的行锁控制之前预约记录插入和座位状态更新没有形成一个原子操作。排查方法很简单在测试环境写一个简单的并发测试脚本用线程池同时发起20个预约请求请求同一个座位同一个时段检查数据库里最终落了几条预约记录。如果大于1条那就是并发控制有漏洞。修复方案前面已经提到了就是更新座位状态时用带条件的UPDATE并判断影响行数。这里补充一点SpringBoot里用Transactional注解标注的service方法事务默认只回滚RuntimeException和Error。如果你的业务代码里手动catch了异常记得手动设置事务回滚或者直接抛出RuntimeException让事务管理器处理。6.2 推荐结果总是热门座位怎么办协同过滤算法跑了一段时间之后发现推荐结果和热门榜单基本重合个性化效果很弱。这种情况在数据量小的时候特别明显。用户行为数据少相似度计算的向量很稀疏计算出来的相似度普遍偏低排在前面的自然就变成了被预约次数最多的热门座位。处理办法有三个方向。第一提高行为权重的区分度。比如预约后实际签到的权重从5分提高到7分取消的权重降到1分让真正喜欢和随便点一下区分开。第二引入时间衰减机制让长期热门的座位热度下降让最近突然变火的座位有更多曝光。第三推荐结果和热门结果做打散处理确保5个推荐位里至少有2个不是Top10热门座位。打散策略在工程实现上简单有效我最终就是靠这个手段把个性化效果拉起来的。6.3 图表不更新、数据对不上这个问题经常出现在定时统计任务和数据库时区的配合上。数据库使用的时区是UTC而业务系统使用的是北京时间导致按天统计的数据全部偏移8个小时凌晨的数据被归到了前一天。解决方案是在数据库连接串上显式配置serverTimezoneAsia/Shanghai并且在SQL查询中使用日期函数处理时区。另一个常见问题是ECharts图表已经渲染了但调用setOption更新数据后界面没变化。检查一下是不是后端返回的数据字段名和前端取数据的地方对不上。比如后端返回avg_usage_rate前端写的是avgUsageRate这种驼峰和下划线的差异在联调中经常出现统一在接口层做字段映射不要等前端拿到数据再转换。还有一个经验和图表本身无关但确实会影响数据统计结果取消的预约和违约的预约要分开统计。如果把取消的也算进预约量上座率会被显著高估。我在统计口径上定义了三个指标预约量含取消、有效预约量签到数、违约数未签到。管理端首页展示的核心指标用的是履约率也就是有效预约量除以预约总量这个数据更真实。我在实际项目里把这些模块全部打通之后最大的感受是系统最花时间的部分其实不是算法本身而是数据治理和工程细节。推荐算法一个礼拜就能写完但把用户的隐式反馈转换成可用的评分矩阵、把并发场景下的数据一致性做扎实、把统计口径统一清楚这些才是决定系统能不能真正上线跑起来的关键。如果你也是从零开始做类似的系统建议先把预约主流程和统计模块做扎实推荐算法作为增强功能逐步迭代这个顺序能让你少走很多弯路。
返回列表