☰
自习室座位预约系统实战:SpringBoot+协同过滤+可视化完整拆解
2026/10/8 2:55:00 网站建设 项目流程

做自习室座位预定系统,听起来好像就是一个“带选座的订座小程序”,但真正把预约选座、日期时间段控制、协同过滤推荐算法、数据可视化统计这几块串起来之后,复杂度和工作量完全不是一个量级。尤其是“座位”这种资源,天然带时间和空间两个维度,同一个座位在不同时间段是不同资源,处理不好就是并发冲突满天飞。这篇文章我想从项目构思、表结构设计、后端实现、推荐算法落地、可视化看板,到上线后容易踩的坑,完整拆解一遍我做的这套系统。

这套系统比较适合拿来当毕设、课程设计,或者图书馆、共享自习室这类真实场景的轻量化预定平台。技术栈是 SpringBoot 2.7 + MyBatis-Plus + MySQL + Redis + Vue + Element-Plus + ECharts。如果你是刚接触这类项目的开发者,跟着过一遍能少走不少弯路;如果你已经做过增删改查类的管理系统,里面有价值的部分在于时间段冲突检测、协同过滤的工程化实现,以及统计口径的细节处理。

1. 项目整体定位与需求拆解

1.1 自习室座位预定系统到底在解决什么问题

自习室的核心矛盾很简单:座位有限,用户需求集中。早高峰、考试周、晚自习这几个时间段,热门座位全靠抢,而管理员只能靠肉眼巡逻或者Excel登记来管理,效率低不说,还经常出现“人没来、位置被占”“两个人同时看中一个座位”的情况。

从用户角度看,最需要的是一个能提前看到“今天还有哪些座位、哪些时段可约”的入口,不用跑到现场碰运气。从管理员角度看,最重要的是掌握整馆的预约量、实时上座率、哪些座位受欢迎,以及哪些人经常预约不来。这些数据如果藏在数据库里不展示出来,运营决策就是拍脑袋。

所以我把系统拆成了四个核心模块:

  • 预约选座模块:用户按日期、时间段筛选座位,发起预约,超时自动释放。
  • 座位与时间段管理:座位绑定到某个房间,时间段按最小粒度(比如30分钟)划分,同一座位同一时间段只能被一个人预约。
  • 协同过滤推荐模块:根据用户历史行为数据,给用户推荐可能喜欢的座位,缓解选择困难。
  • 数据可视化统计模块:用图表展示每日预约趋势、分时段热度、热门座位排行、房间上座率等信息。

这几个模块不是简单的并列关系,预约选座会产生行为数据,行为数据又喂给推荐算法,预约记录同时是统计报表的原始数据源。整个项目的数据流是一条线的。

1.2 为什么选 SpringBoot + Vue 这套组合

技术栈选择上,我几乎没有犹豫就选了 SpringBoot。原因很直接:生态成熟、上手快、社区资料多,光是“坑怎么填”这一项,SpringBoot 的答案量就比其他框架丰富太多。而且现在很多学校和企业内部项目都默认 SpringBoot,后续自己扩展集成定时任务、消息队列、分布式缓存都方便。

后端用了:

  • SpringBoot 2.7.x,稳定版本,兼容性比3.x好,很多中间件客户端不至于因为 JDK 版本不匹配出问题。
  • MyBatis-Plus,单表 CRUD 基本不用写 SQL,分页查询、条件构造器直接搞定。
  • MySQL 8.x,存业务数据。
  • Redis,用来做座位锁、短时预约锁定、热点数据缓存。
  • Spring Scheduled,处理预约超时释放和统计报表定时聚合。

前端选了 Vue 2 + Element-Plus,实际上 Element-Plus 主要配 Vue 3,如果熟练的话用 Vue3 也行。我这里其实用的是 Vue3 组合式 API,加上 ECharts 做可视化。开发阶段前后端分离,前端通过 Vite 代理访问后端接口;上线时把前端 dist 目录里的静态资源复制到 SpringBoot 的src/main/resources/static目录下,打成同一个 jar 包,这样部署和维护成本最低,也不用额外单独配 Nginx 入口。这个操作后来也成了很多朋友问我的重点,后面专门说。

1.3 功能模块划分

我按角色把系统分成了三条线。

用户端功能:

  • 注册登录、个人信息管理。
  • 查看楼层房间座位图,按日期时间段筛选空闲座位。
  • 选择座位,发起预约,等待确认或直接生效。
  • 取消预约,查看我的预约记录。
  • 浏览推荐座位,并查看推荐理由。

管理员端功能:

  • 座位管理:维护座位号、房间、座位类型(普通、靠窗、电源位、静音区)。
  • 预约管理:查看某天某时段所有预约,手动取消异常预约。
  • 用户管理:封禁频繁预约不来的用户。
  • 数据看板:展示统计图表和核心指标。

系统后台功能:

  • 定时清理过期未生效的预约。
  • 每天凌晨重新计算协同过滤推荐结果。
  • 每天凌晨聚合前一天的统计报表数据。

这些模块之间的依赖关系也要想清楚,尤其是“座位”这个基础数据,必须先在管理端维护好,用户端才有东西可约。座位状态不要直接在 seat 表里存一个is_occupied字段,因为这个状态是随时间变化的,同一个座位早上空闲、晚上占用,真正要判断的是“在某个时间段是否被占用”,所以查询时一定要联合预约表来判断,而不是查座位表的一个静态字段。

2. 数据库设计与核心表结构

很多人做这类系统上来就建表,结果做到预约时间段判断的时候发现表设计根本支撑不了,只能反复改表结构。我这次把核心表结构提前设计好了,重点说几个关键设计点。

2.1 用户表、座位表、预约表

用户表没什么特殊的,就是常规的sys_user,字段包括id, username, password, nickname, phone, role, status, create_time。密码存的是 BCrypt 加密后的密文,不要存明文,这点没有商量的余地。

座位表seat:

CREATE TABLE `seat` ( `id` bigint NOT NULL AUTO_INCREMENT, `room_id` bigint NOT NULL COMMENT '所属自习室/房间ID', `seat_no` varchar(20) NOT NULL COMMENT '座位编号,如A-12', `seat_type` varchar(20) DEFAULT 'NORMAL' COMMENT 'NORMAL/QUIET/WINDOW/POWER', `is_available` tinyint(1) DEFAULT 1 COMMENT '是否开放预约', `x_pos` int DEFAULT NULL COMMENT '座位图x坐标', `y_pos` int DEFAULT NULL COMMENT '座位图y坐标', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_room_seat` (`room_id`, `seat_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

seat_type别小看,后面协同过滤推荐和新用户体验要靠这个字段做规则兜底。x_pos和y_pos是前端画座位图用的,存坐标比存图片方便调整。

预约表reservation是关键:

CREATE TABLE `reservation` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '预约单号', `user_id` bigint NOT NULL, `seat_id` bigint NOT NULL, `reserve_date` date NOT NULL COMMENT '预约日期', `start_time` time NOT NULL COMMENT '开始时间 HH:mm:ss', `end_time` time NOT NULL COMMENT '结束时间 HH:mm:ss', `status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/ACTIVE/COMPLETED/CANCELLED/EXPIRED', `source` varchar(20) DEFAULT 'USER' COMMENT 'USER/RECOMMEND', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里我把reserve_date、start_time、end_time拆开存,而不是存一个start_datetime和end_datetime。原因有三个:一是跨天场景下日期和时间的含义更清晰,二是按天查“某日空闲座位”直接走日期索引,很快,三是前端展示时间轴也更方便。代价是查询区间重叠时条件稍微复杂一点,但这是值得的。

2.2 预约时间段冲突与唯一约束设计

最开始我把预约时间段粒度做成用户自由选择,比如用户选了 09:30-11:20,这种不规则时间带给冲突判断带来巨大麻烦。后来我干脆规定:系统最小时间粒度为 30 分钟,用户只能选择整点或半点开始、结束。这样看似限制了灵活性,实际上大大降低了复杂度。

我额外加了一张表reservation_slot_detail,把一次预约拆成最小粒度的多条记录:

CREATE TABLE `reservation_slot_detail` ( `id` bigint NOT NULL AUTO_INCREMENT, `reservation_id` bigint NOT NULL, `seat_id` bigint NOT NULL, `reserve_date` date NOT NULL, `slot_seq` int NOT NULL COMMENT '时间段序号 0/1/2...', PRIMARY KEY (`id`), UNIQUE KEY `uk_seat_slot` (`seat_id`, `reserve_date`, `slot_seq`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

slot_seq不要存09:00这种字符串,存序号,比如 09:00-09:30 对应 18,10:00-10:30 对应 20。这样判断冲突就变成了查这个唯一索引:同一个座位、同一天、同一个slot_seq,只有一条明细记录,数据库层面直接保证了并发情况下不会重复。

配合 Redis 的setnx座位锁,预约接口的逻辑就是这样:

  • 用户选择座位、日期、时间段。
  • 计算时间段对应的slot_seq集合。
  • 用 Redis 对每个seatId:date:slotSeq做setnx,抢不到的提示“座位已被选走”。
  • 抢锁成功,插入reservation主表和reservation_slot_detail明细。
  • 插入明细时如果捕获到DuplicateKeyException,就回滚事务并提示冲突。

这套方案在单机、分布式部署下都能用,数据库唯一索引兜底,是最稳的防线。不要相信“先 select 再 insert”这种逻辑,两个请求同时 select 都认为空闲,然后同时 insert,数据就错了。

2.3 推荐算法依赖的数据字段

协同过滤算法需要“用户对物品的评分”或者“用户-物品交互数据”。在这个系统里,用户和座位之间的交互就是预约行为,但预约并不能直接当评分,需要把行为转成分数。

我在预约表里加了一个behavior_score字段,默认 0,定时任务根据规则更新:

  • 预约成功 +1。
  • 实际到馆签到(管理员标记或系统自动确认)+1。
  • 预约后取消 -0.5。
  • 预约超时未到 -1。
  • 连续预约同一座位次数越多,额外加一点,最多 +0.5。

最终把userId + seatId + score汇总到一张独立的偏好表user_seat_preference里,这样推荐算法直接读这张表,不用每次去扫描海量预约记录。

CREATE TABLE `user_seat_preference` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `seat_id` bigint NOT NULL, `score` decimal(4,2) NOT NULL DEFAULT 0, `source` varchar(20) DEFAULT 'COLLAB' COMMENT 'COLLAB/RULE/HOT', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_seat` (`user_id`, `seat_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这张表既是推荐算法的输入,也是推荐结果的持久化位置。如果没有这张表,每次推荐都重新扫全量预约记录,几百个用户、几千条预约还好,数据量一大系统就扛不住了。

3. 预约选座与时间段控制实战

3.1 选座流程与前端交互设计

用户进入选座页面的流程是:选择日期(默认今天,只能选当天和未来一天)→ 选择开始时间和结束时间 → 座位图刷新显示可预约座位 → 点击座位 → 确认预约。

座位图我直接用 CSS Grid 布局,每个座位是一个小格子,颜色代表状态:

  • 灰色:不可用或已停用。
  • 绿色:当前时间段空闲。
  • 红色:当前时间段已被预约。
  • 蓝色:正在被当前用户锁定(进入待确认状态)。

前端每次点击日期或时间段变化,就调用一个接口:

GET /api/seat/available?date=2025-06-18&startTime=09:00&endTime=11:00

后端返回的数据结构长这样:

{ "code": 0, "data": [ { "seatId": 1, "seatNo": "A-01", "roomId": 1, "status": "AVAILABLE", "seatType": "WINDOW" } ] }

不要把整张座位图和所有时段的占用情况一次性返回,数据量大会卡,也没必要。按需查询才是正确做法。

用户点击座位后,前端先弹窗展示预约信息,同时后端生成一个锁,锁的过期时间设置为 10 分钟。用户 10 分钟内没确认,锁自动释放,别人才能约这个座。这里用 Redis 实现:

Boolean locked = redisTemplate.opsForValue() .setIfAbsent("lock:seat:" + seatId + ":" + date + ":" + startTime, userId.toString(), 10, TimeUnit.MINUTES); if (!locked) { throw new BizException("座位刚刚被别人选走了"); }

锁的 key 要和数据库唯一索引的维度保持一致,否则锁跟约束对不上,还是会出现脏数据。

3.2 后端接口设计与参数校验

预约相关的核心接口列举如下:

接口功能
POST /api/reservation创建预约
DELETE /api/reservation/{id}取消预约
GET /api/reservation/my我的预约列表
GET /api/seat/available查询可预约座位
GET /api/recommend/seat获取推荐座位

创建预约的 DTO 必须做参数校验,不然非法时间段会污染数据。我用的是 Hibernate Validator:

public class ReservationCreateDTO { @NotNull(message = "日期不能为空") @JsonFormat(pattern = "yyyy-MM-dd") private LocalDate reserveDate; @NotBlank(message = "开始时间不能为空") @Pattern(regexp = "^([01]\\d|2[0-3]):[0-5]\\d$", message = "时间格式必须为HH:mm") private String startTime; @NotBlank(message = "结束时间不能为空") @Pattern(regexp = "^([01]\\d|2[0-3]):[0-5]\\d$", message = "时间格式必须为HH:mm") private String endTime; @NotNull(message = "座位ID不能为空") private Long seatId; @AssertTrue(message = "开始时间必须早于结束时间") public boolean isValidRange() { if (startTime == null || endTime == null) { return true; // 交给 @NotBlank 处理 } return startTime.compareTo(endTime) < 0; } }

注意两个时间比较,不能直接LocalTime.parse(startTime)然后比较字符串,虽然格式统一成HH:mm后字符串比较确实可靠,但前提是你已经用正则约束了前导零,否则 9:00 和 10:00 比较会出问题。我统一强制前端传HH:mm格式,省掉一堆兼容问题。

3.3 时间段冲突检测的三种方案

很多初学者喜欢在 Service 里先查一遍有没有重叠预约,再插入。这种做法在小并发下没问题,但一旦两个请求同时进来,就一定会出现重复预约。我把冲突检测放在三层:

第一层:Redis 锁,快速失败,让用户体验最好,抢不到就立即提示。锁时间不能太短,我设 10 分钟,但要注意,如果业务处理时间超过了锁过期时间,锁会被别人拿到,所以锁的时间要留足余量,或者用 Redisson 的看门狗自动续期。

第二层:数据库唯一索引。这是最可靠的一层,无论业务代码怎么写,唯一索引都能挡住重复的预约明细。插入时捕获异常:

try { reservationSlotDetailMapper.insert(detail); } catch (DuplicateKeyException e) { throw new BizException("该座位在所选时间段内已经被预约,请重新选座"); }

第三层:查询判断,用来给前端更友好的提示。比如用户一次选了 4 个小时,中间某个 30 分钟片段被占了,直接告诉用户“09:30-10:00 已被预约”,而不是让用户自己猜。这个查询逻辑用 SQL 判断两个时间段是否重叠,条件是:

SELECT COUNT(*) FROM reservation_slot_detail WHERE seat_id = #{seatId} AND reserve_date = #{date} AND slot_seq BETWEEN #{startSeq} AND #{endSeq}

因为用了slot_seq序号,查起来就是简单的BETWEEN,不用处理区间交叉判断,这个设计到后面越用越爽。

3.4 实际踩坑:跨天预约、时区、会话过期

这里必须说几个我真实踩过的坑。

第一个是跨天预约。用户选了 22:00 到次日 00:30,如果只存一个reserve_date是 6 月 18 日,但去了 6 月 19 日,处理起来就会乱。我的方案是直接不允许跨天预约,前端把结束时间限制在 23:59 之内,后端再校验一遍。如果你非要支持跨天,那就需要把时间段拆成两天两段记录来存储,这样slot_seq才正确,但复杂度会明显上升。自习室场景跨天预约意义不大,直接禁用省心。

第二个是时区问题。测试环境没问题,部署到服务器后统计查询差了 8 小时,就是 JDBC 连接串没设置时区。连接串里一定加上:

serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8

而且数据库里所有时间字段统一用datetime、date、time类型,后端 LocalDateTime 和 LocalDate,不要混用 Timestamp。

第三个问题是前端预约锁的会话过期。用户点了选座,页面停留超过 10 分钟,Redis 锁过期释放了,但用户继续操作会发现自己锁的座位被别人抢走。这种情况下前端要监听锁剩余时间,倒计时结束自动把座位状态刷新成不可选,并提示重新选择。这个细节不做,用户会以为系统出 bug 了。

4. 协同过滤推荐算法的落地

4.1 为什么自习室系统需要推荐算法

自习室的座位虽然总数不多,但用户在选择时依然会面临“哪个座位适合我”的问题。有人喜欢靠窗、有人喜欢安静、有人必须用电源接口。如果每次都是用户自己在座位图上挨个看,效率很低,而且热门位置会被少数人长期占用。

协同过滤推荐算法在这里的意义是:根据历史预约行为,找出“和你相似的用户喜欢坐哪些座位”,然后把这些座位推荐给你。它不需要知道你为什么喜欢靠窗,只要数据里显示和你类似的人都选了靠窗位,系统就能把靠窗位推到前面。

同时推荐还有一个附带价值:把一些冷门座位、非高峰时段座位通过个性化推荐的方式曝光给用户,一定程度均衡负载,减少热门座位拥挤。

4.2 基于用户的协同过滤:算法步骤与公式

基于用户的协同过滤(UserCF)的原理是“人以群分”。步骤是:

  1. 构建用户-座位评分矩阵,行是用户,列是座位,值是行为分。
  2. 计算目标用户和其他用户之间的相似度。
  3. 找出相似度最高的 K 个邻居用户。
  4. 根据邻居用户对某个座位的评分,加权计算目标用户对该座位的预测分。
  5. 按预测分排序,排除用户已经预约过的座位,取 TopN。

相似度计算我用的余弦相似度:

sim(u,v) = (u向量 · v向量) / (|u向量| * |v向量|)

用 Java 实现大概长这样:

public double cosineSimilarity(Map<Long, Double> userSeatScores1, Map<Long, Double> userSeatScores2) { Set<Long> commonSeats = new HashSet<>(userSeatScores1.keySet()); commonSeats.retainAll(userSeatScores2.keySet()); if (commonSeats.isEmpty()) { return 0.0; } double dot = 0, norm1 = 0, norm2 = 0; for (Map.Entry<Long, Double> entry : userSeatScores1.entrySet()) { norm1 += Math.pow(entry.getValue(), 2); } for (Map.Entry<Long, Double> entry : userSeatScores2.entrySet()) { norm2 += Math.pow(entry.getValue(), 2); } for (Long seatId : commonSeats) { dot += userSeatScores1.get(seatId) * userSeatScores2.get(seatId); } if (norm1 == 0 || norm2 == 0) { return 0.0; } return dot / (Math.sqrt(norm1) * Math.sqrt(norm2)); }

实现逻辑不复杂,但数据量一大就有性能问题。系统里座位几百个、用户几千个,全量两两计算相似度需要几百万次计算,定时任务里跑还可以接受。要注意矩阵非常稀疏,用户与用户之间重合预约的座位很少,相似度大多为 0,所以要先过滤掉没有共同座位的用户,再算相似度。

4.3 基于物品的协同过滤:座位相关性计算

我在实际项目里最终采用的是基于物品的协同过滤(ItemCF),因为它的稳定性在自习室场景里更好。用户偏好会变,但“靠窗座位和紧挨着它的座位经常被同一个人预约”这种规律变化很慢。

核心逻辑:如果用户 A 预约过座位 X 和座位 Y,那么认为座位 X 和座位 Y 有一定的相似度。当用户 B 预约了座位 X 时,就可以给 B 推荐座位 Y。

计算同现矩阵:统计所有预约记录中,任意两个座位被同一个用户预约的次数。

简化后的伪代码:

Map<Long, Map<Long, Integer>> cooccurrenceMatrix = new HashMap<>(); List<UserSeatPreference> allPrefs = preferenceMapper.selectList(null); Map<Long, List<Long>> userSeatItems = allPrefs.stream() .collect(Collectors.groupingBy(UserSeatPreference::getUserId, Collectors.mapping(UserSeatPreference::getSeatId, Collectors.toList()))); for (List<Long> seatIds : userSeatItems.values()) { for (Long seatA : seatIds) { for (Long seatB : seatIds) { if (!seatA.equals(seatB)) { cooccurrenceMatrix.computeIfAbsent(seatA, k -> new HashMap<>()) .merge(seatB, 1, Integer::sum); } } } }

然后给目标用户推荐时,找到用户预约过的座位集合,把它们各自对应的相似座位加权汇总,排序后去掉用户已经预约过的座位,取 TopN。

ItemCF 的推荐结果在座位数比较少的系统里非常稳定,而且可解释性强,前端展示推荐理由时可以直接写“和你常坐的 A-03 座位经常一起被预约”,一眼就能看懂。

4.4 冷启动处理与混合推荐策略

协同过滤最怕冷启动,新用户没有历史行为,新座位没有预约数据,算法直接“死机”。我的处理方案是混合推荐:

  • 新用户(预约行为少于 5 条):直接推荐热门座位,按总预约次数排序,同时结合运营规则,优先推荐带电源、靠窗、安静属性的座位。
  • 老用户:使用 ItemCF 结果,再叠加热门座位加权。
  • 新座位:因为没有历史行为,可以临时用“同一房间、座位类型相似”的规则顶上来。

最终得分公式:

最终分 = 0.7 * 协同过滤分 + 0.3 * 热门分(或规则分)

权重不是拍脑袋定的,是根据测试调整出来的。最开始协同过滤权重给到 0.9,结果推荐列表全是老用户常约的座位,新用户完全不适配。后来降到 0.7,加上热门兜底,点击率和预约转化率都上来了。上线后你可以把权重做成配置项,方便不停机调整。

推荐结果每天凌晨定时计算一次,写入user_seat_preference表,前端推荐接口直接读表返回。实时计算不是不行,但在这种场景下没必要,凌晨计算能避开高峰期,性能压力最小。

4.5 Java 实现要点与性能优化

推荐服务我拆成了三个类:

  • PreferenceDataLoader:负责加载用户-座位偏好矩阵。
  • ItemCFRecommender:负责计算同现矩阵、生成推荐结果。
  • HybridRecommender:负责混合策略和规则过滤。

定时任务示例如下:

@Component public class RecommendTask { @Scheduled(cron = "0 30 1 * * ?") public void dailyRecommend() { List<UserSeatPreference> allPrefs = preferenceService.listAll(); Map<Long, List<Long>> userSeatMap = buildUserSeatMap(allPrefs); Map<Long, Map<Long, Integer>> cooccurrence = buildCooccurrenceMatrix(userSeatMap); List<Long> userIds = userService.listAllUserIds(); for (Long userId : userIds) { List<RecommendItem> items = recommender.recommend(userId, userSeatMap, cooccurrence); recommendationMapper.deleteByUserId(userId); recommendationMapper.batchInsert(userId, items); } } }

性能优化的几个点:

  • 矩阵用Map<Long, Map<Long, Integer>>存稀疏矩阵,别用二维数组。
  • 只计算有行为的用户,没有预约记录的用户直接跳过。
  • 定时任务执行期间,前端推荐接口继续读上一次的推荐结果,不影响用户体验。
  • 如果未来用户量上去了,可以考虑用离线计算引擎,把协同过滤部分从 SpringBoot 里拆出来,结果回填到 MySQL 或 Redis。这个系统的数据量阶段,Java 内存计算完全够用。

5. 数据可视化统计模块

5.1 统计什么指标才有运营价值

很多人做可视化就是堆图表,柱状图、折线图、饼图全上,但真正运营的人根本不知道看什么。我这套系统的统计指标是从管理需求倒推出来的:

  • 按日预约量:每天有多少人发起预约、多少取消、多少实际入座。
  • 分时段热度:把一天按小时拆开,统计每个小时段预约量,知道什么时候是高峰,什么时候需要开放更多座位。
  • 热门座位 Top10:哪些座位最抢手,用于调整座位类型和物理布局。
  • 各房间上座率:判断房间容量分配是否合理。
  • 预约取消率/超时率:衡量用户信用,辅助管理员管控。
  • 推荐算法效果:推荐过来的预约占总预约的比例,用来评估算法有没有实际价值。

这些指标分开看是数字,合起来看就是决策依据。比如分时段热度显示晚上 19:00-21:00 爆满,但上座率只有 60%,说明很多人预约了却不来,这时候就需要给管理员一个“预约超时自动拉黑”的功能,这比再买座位更有效。

5.2 定时任务与聚合表设计

我建了一张统计日表stats_daily_report:

CREATE TABLE `stats_daily_report` ( `id` bigint NOT NULL AUTO_INCREMENT, `report_date` date NOT NULL, `total_reservation` int DEFAULT 0, `total_cancelled` int DEFAULT 0, `total_expired` int DEFAULT 0, `total_completed` int DEFAULT 0, `peak_hour` int DEFAULT NULL, `hot_seat_id` bigint DEFAULT NULL, `avg_seat_occupancy` decimal(5,2) DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_report_date` (`report_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

每天凌晨 1 点,Spring 定时任务把前一天的数据从业务表聚合后插入,注意用INSERT ... ON DUPLICATE KEY UPDATE,保证重复执行不会产生重复数据。

为什么不直接查明细表实时统计?因为统计 SQL 要 group by 很多维度,数据一多查询会慢,而且看板打开频率远高于数据更新的频率,没必要为了“实时”牺牲性能。真正要实时的地方,比如当天实时预约人数,可以单独走 Redis 计数器,前端每 5 分钟拉一次接口就行。

聚合任务里最需要注意的是统计口径,比如“取消率”,分子是取消的预约数,分母应该是该天内创建的预约总数,而不是状态为已取消的记录数除以所有状态记录数,后者的口径会把历史取消拉进来,完全失真。我在做的时候把所有统计口径字段的定义写在了一个枚举类里,注释写得清清楚楚,这样后来维护的人不会理解偏差。

5.3 ECharts 接入与前端图表渲染

前端可视化这块,我用 ECharts 5,按需引入,不整包导入,这样打包体积小很多。

核心组件里这样初始化图表:

import * as echarts from 'echarts/core'; import { BarChart, LineChart, PieChart, HeatmapChart } from 'echarts/charts'; import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; echarts.use([BarChart, LineChart, PieChart, HeatmapChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]); onMounted(() => { nextTick(() => { initChart(); }); }); function initChart() { const chartDom = document.getElementById('trendChart'); if (!chartDom) return; const chart = echarts.init(chartDom); chart.setOption({ tooltip: {}, xAxis: { type: 'category', data: dateList }, yAxis: { type: 'value' }, series: [{ type: 'line', data: countList, smooth: true }] }); }

最容易犯的错误是在组件 DOM 还没渲染完成时调用echarts.init,容器高度是 0,画出来一片空白。所以initChart()必须在nextTick里执行,而且容器要给定固定高度或百分比高度。

5.4 可视化接口返回格式与管理端看板

统计接口我统一返回:

{ "code": 0, "data": { "dateList": ["2025-06-01", "2025-06-02"], "reservationCountList": [203, 245], "cancelRateList": [0.08, 0.11] } }

后端不要返回Map<String, Object>嵌套太深,前端解析麻烦,接口文档也难写。我直接为每个统计图定义对应的 VO 类,字段名和前端需要的保持一致,比如前端要dateList,后端就老老实实返回dateList,不要返回dateList再包一层。

管理员端看板页面,我用了四个大卡片展示核心数字:今日预约量、本周预约趋势、热门座位 Top10、分时段热度热力图。热力图用 ECharts 的 heatmap 类型,横轴是日期,纵轴是时间点,颜色深浅代表预约人数,一眼就能看出哪些时间段是高峰。

关于数据可视化,我要强调一点:图表的颜色、标签、单位必须统一。我在开发时就踩过坑,折线图数据量几百,Y 轴单位是“人次”,柱状图单位又变成“人”,最后看板看起来非常业余。统一单位、统一空值显示、统一日期格式,这些细节才是看板专业感的关键。

6. 常见问题与排查技巧实录

6.1 并发预约导致同一个座位被重复预约

这是预约系统最典型的问题。症状是压测时同一个座位同一个时间段出现了两条预约记录,但代码里明明先查了座位是否空闲。

原因很简单:查询和插入之间有一个时间窗口,两个事务都查到空闲,然后都执行插入。解决方式前面已经说了,最底层靠唯一索引,前端抢锁靠 Redis。还要注意服务层接口事务里不要做耗时操作,比如把发短信、发邮件写在事务内,这会把数据库连接占住,放大并发问题。正确的做法是:先用 Redis 抢锁,锁成功后在事务里只做数据插入和更新,消息通知放到事务提交后异步处理。

6.2 推荐结果不准确或全部为空

最容易出现的情况是推荐接口返回一个空数组。原因是用户没有任何预约记录,协同过滤矩阵里根本没有这个用户的行,自然算不出结果。

排查思路:

  • 先查user_seat_preference表有没有该用户的记录。
  • 如果没有,确认定时任务有没有跑过,很多项目部署后忘了开 Scheduled。
  • 如果有记录,但推荐结果为空,看是不是相似座位全被用户预约过,过滤后没剩下候选。
  • 冷启动用户的推荐接口,直接走热门座位逻辑,不走进协同过滤。

我之前还遇到过相似度全是 0 的情况,双击调试发现是矩阵太稀疏,共同预约座位几乎为空。后来把余弦相似度改成杰卡德相似度,或者直接用同现次数,结果好很多。算法不是越复杂越好,数据稀疏时简单的频率统计反而实用。

6.3 时间段状态显示错乱

前端座位图显示空闲,用户一点,后端提示已被预约。这个通常不是后端判断逻辑错了,而是缓存问题。座位状态接口和预约接口读的数据不一致,比如查询空闲座位走的是 Redis 缓存,预约时走的是数据库,Redis 没及时失效。

解决方案:座位状态不要做长时间缓存,只在用户选座预览的短时间内加一级缓存,锁释放后必须主动清缓存。另外查询空闲座位时要用同一个slot_seq集合,前端如果传的时间是 09:00-10:00,后端按 09:00 这个点查,但用户实际约的是 09:30 开始,那就会产生误判。规范严格一点:前端所有时间选择控件的最小单位必须和后端最小粒度一致,前端选 09:00,实际可选段就是 09:00-09:30,不存在分钟级自由输入。

6.4 可视化图表不显示或白屏

先确认几个点:

  • 浏览器 Network 面板看统计接口是否返回了数据。
  • 前端document.getElementById拿到的 DOM 是否存在于当前页面。
  • echarts.init 之前容器是否已经有宽度和高度。
  • ECharts 是否按需引入了对应的图表类型,用了HeatmapChart图但没有注册,就会报错。
  • 后端返回的dateList是否为空,空数组时图表也是白屏。

我见过最离谱的错误是把多个图表的容器 id 写成了同一个,结果后面的图表初始化直接覆盖前面的。解决方案是 id 加上:key区分,或者用 ref 管理 DOM 节点。

6.5 部署上线后的几个提醒

项目打包时,前端npm run build产物放到 SpringBoot 的static目录,如果是 Vue Router 的 history 模式,直接访问子路由会 404。解决办法有两种:一是改用 hash 模式,简单直接;二是配置一个转发规则,把非接口路由转发到index.html。我推荐开发阶段用 history,部署阶段干脆换成 hash,省去 Nginx 配置的麻烦。

定时任务在集群部署时要注意重复执行问题。如果将来用了多实例,同一时刻可能有多个实例跑同一个定时任务,导致重复计算。可以引入分布式锁,或者把定时任务的开关做成配置项,只在一个实例上开启。

另外上线前记得把所有日志里的 SQL 打印关掉,生产环境mybatis-plus的log-impl不要配成StdOutImpl,不然控制台疯狂刷 SQL,日志一天能涨几个 G。

还有一个小细节,预约超时释放任务不要和推荐任务放在同一时间执行,两个任务都跑全表扫描的话会互相拖慢。我把超时释放任务放在每 30 秒一次轻量扫描,统计聚合放在凌晨 1 点,推荐计算放在凌晨 1:30,错峰执行。

最后分享两个小经验

这套系统做下来,我最大的体会是:协同过滤推荐算法本身不是难点,难点在数据准备和结果落地。很多代码里的算法写得漂亮,但前面行为数据没积累起来、后面推荐结果不设计持久化方案,最终只能算一个 demo。真正好用的推荐模块,要花大量时间在“评分怎么定义、冷启动怎么兜底、结果怎么解释”上,算法只是其中一环。

第二个想分享的是座位图的交互设计。不要用 Canvas 画座位图,虽然视觉效果可以很炫,但后期维护座位位置、增加房间、适配不同屏幕尺寸都是麻烦事。用 CSS Grid 加普通 DOM 实现,天然支持事件绑定和响应式,稳定性和维护成本都更好。

如果你准备在这个项目上继续扩展,我建议下一步可以加“预约签到”功能,到馆扫码核销,配合违约统计做信用体系。这样系统的数据闭环会更完整:预约行为产生偏好数据,推荐算法推荐座位,用户签到后产生新的行为数据,管理员通过可视化看板调整座位分配。整个项目从实用角度出发,每一步都踩在真实需求上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询