简介:这是一套面向高校计算机相关专业毕业设计的「美术馆预约系统」完整项目源码,适合正在准备毕设、需要参考完整业务系统实现的学生与开发者。项目围绕美术馆的预约、展览、票务与后台管理展开,涵盖用户注册登录、并发预约防超卖、在线支付、预约确认与取消、消息通知、后台数据分析以及响应式布局等模块,可帮助读者理解从需求分析到系统测试的完整开发流程。资源包共517个文件,约3.01MB,以109个Java后端源码、77个JavaScript脚本、73个CSS样式、21个HTML页面为主,另含24个XML配置、2个SQL建表脚本及Dockerfile、YAML等部署文件,前端还包含Bootstrap、AdminLTE、Layui等界面框架资源,结构清晰便于按模块查阅。目前已有553人学习下载,适合作为毕设选题参考、功能拆解与代码复用的实践素材。
1. 美术馆预约系统:从“抢不到票”到“分时预约”的落地拆解
周末带家人去市美术馆看展,门口保安一句“今天约满了”把人挡在门外,这种场景在过去两年越来越常见。美术馆预约系统本质上是一套分时预约 + 实名核销 + 库存控制的业务系统,它要解决的核心问题不是“做个表单”,而是把有限的展厅承载量,公平、可控、可追溯地分配给公众。它适合谁?适合需要做场馆限流、活动报名、景区分时预约的开发者,也适合想理解“高并发下库存不超卖”这一经典命题的后端工程师。很多人以为预约系统就是增删改查,真做起来才发现,难点全在并发扣减、超卖控制、核销一致性这三件事上。这篇笔记就按我实际做过的一套方案,把选型、表结构、核心代码和踩过的坑讲清楚,让你能照着复现一个能扛住瞬时抢约的版本。
2. 需求拆解与技术选型:为什么不用简单的 CRUD 硬扛
2.1 预约系统的四个真实约束
先把需求摊开看,别急着写代码。一个能上线的美术馆预约系统,通常有四个硬约束:
- 分时段库存:一天分若干个入场时段(比如 9:00-11:00、11:00-13:00),每个时段有独立名额,不是全天一个总数。
- 实名限购:一个身份证在同一时段只能约一张,防止黄牛批量刷。
- 瞬时并发:放票那一刻(常见是每天固定时间或提前 N 天 0 点),请求量可能是平时的几十上百倍。
- 核销闭环:到场扫码或刷身份证核销,核销后名额状态要变,且不能重复核销。
这四条决定了系统不能是“查一下有没有余票,有就插入一条记录”这么简单。因为“查”和“插”之间有时间窗口,高并发下必然超卖。
2.2 技术选型:Spring Boot + Redis + MySQL 的常见组合
我一般会选这套组合,理由很实在:
| 组件 | 作用 | 选它的理由 |
|---|---|---|
| Spring Boot | 业务框架 | 生态全,招人好招,接口开发快 |
| MySQL | 持久化存储 | 订单、用户、核销记录必须落库,事务可靠 |
| Redis | 库存缓存 + 分布式锁 | 抗并发,扣减走内存,避免直接打 DB |
| 定时任务 | 放票、清理未支付 | 放票瞬间预热库存到 Redis |
这里的关键决策是:库存扣减走 Redis,订单落库走 MySQL。Redis 负责扛住瞬时流量,MySQL 负责最终一致。有人会问,能不能全用 MySQL 加行锁?可以,但放票瞬间几千个请求打过来,行锁排队会让响应时间飙到几秒甚至超时,体验很差。Redis 的原子递减(DECR)能把扣减压到微秒级。
提示:如果你的场馆每天预约量就几百,其实 MySQL 乐观锁就够了,不必上 Redis。选型要看量级,别为了技术而技术。
2.3 数据库表结构设计
表结构是地基,设计不好后面全是坑。核心三张表:
-- 时段库存表:每个时段一行,记录总名额和已约名额 CREATE TABLE `time_slot` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `venue_id` BIGINT NOT NULL COMMENT '场馆ID', `slot_date` DATE NOT NULL COMMENT '日期', `start_time` TIME NOT NULL COMMENT '开始时间', `end_time` TIME NOT NULL COMMENT '结束时间', `total_quota` INT NOT NULL COMMENT '总名额', `booked_quota` INT NOT NULL DEFAULT 0 COMMENT '已约名额', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_venue_date_time` (`venue_id`, `slot_date`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 预约订单表:一条预约记录 CREATE TABLE `reservation` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单号', `user_id` BIGINT NOT NULL COMMENT '用户ID', `id_card` VARCHAR(18) NOT NULL COMMENT '身份证号', `slot_id` BIGINT NOT NULL COMMENT '时段ID', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待核销 1已核销 2已取消', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), UNIQUE KEY `uk_idcard_slot` (`id_card`, `slot_id`) COMMENT '同一身份证同一时段只能约一次', KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 核销记录表:核销动作留痕 CREATE TABLE `checkin_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `reservation_id` BIGINT NOT NULL, `checkin_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `operator` VARCHAR(32) DEFAULT NULL COMMENT '核销员', PRIMARY KEY (`id`), UNIQUE KEY `uk_reservation` (`reservation_id`) COMMENT '防重复核销' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:time_slot的uk_venue_date_time保证同一场馆同一天同一开始时间只有一条记录,避免重复建时段。reservation的uk_idcard_slot是防黄牛的关键,数据库层面直接卡死“同一身份证同一时段只能约一次”,比在代码里查一遍再插更可靠。checkin_record的uk_reservation保证一次预约只能核销一次。
参数说明:total_quota和booked_quota用 INT 足够,除非你的场馆能容纳 20 亿人。status用 TINYINT 省空间,0/1/2 三个状态够用。身份证字段用 VARCHAR(18),别用 CHAR,因为末位可能是 X,且未来可能有其他证件类型。
3. 核心实现:库存扣减、防超卖与核销闭环
3.1 放票时预热库存到 Redis
放票不是等用户来查才准备库存,而是提前把每个时段的余票写进 Redis。我一般用定时任务在放票时间点触发:
// 放票任务:把未来N天的时段库存预热到Redis @Scheduled(cron = "0 0 0 * * ?") // 每天0点执行 public void preloadStock() { List<TimeSlot> slots = timeSlotMapper.selectFutureSlots(7); // 未来7天 for (TimeSlot slot : slots) { String key = "stock:slot:" + slot.getId(); int remain = slot.getTotalQuota() - slot.getBookedQuota(); // setIfAbsent避免覆盖已扣减的库存 redisTemplate.opsForValue().setIfAbsent(key, remain, 8, TimeUnit.DAYS); } }逻辑说明:setIfAbsent是关键,如果 Redis 里已经有这个 key(比如任务重跑),不会把已经扣减过的库存覆盖回初始值。过期时间设 8 天,比预热周期多一天,防止 key 提前失效导致库存丢失。
参数说明:selectFutureSlots(7)里的 7 是天数,按你场馆提前几天放票来改。TimeUnit.DAYS和 8 配合,确保覆盖整个预约周期。
3.2 用 Lua 脚本保证扣减原子性
扣减库存不能先 GET 再 DECR,中间会有并发问题。正确做法是用 Lua 脚本把“判断余量 + 扣减”打包成一个原子操作:
-- stock_deduct.lua -- KEYS[1]: 库存key ARGV[1]: 扣减数量 local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil then return -1 -- 库存不存在 end if stock < tonumber(ARGV[1]) then return -2 -- 库存不足 end redis.call('DECRBY', KEYS[1], ARGV[1]) return stock - tonumber(ARGV[1]) -- 返回扣减后余量Java 调用:
public Long deductStock(Long slotId, int num) { String key = "stock:slot:" + slotId; DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptSource(new ResourceScriptSource( new ClassPathResource("lua/stock_deduct.lua"))); script.setResultType(Long.class); return redisTemplate.execute(script, Collections.singletonList(key), num); }逻辑说明:Lua 脚本在 Redis 里是单线程执行的,整个“读-判断-减”过程不会被其他请求打断,从根本上杜绝超卖。返回值设计成 -1 表示库存 key 不存在(可能没预热),-2 表示库存不足,正数表示扣减后余量,调用方根据返回值决定后续流程。
参数说明:ARGV[1]是扣减数量,美术馆预约一般一次扣 1,但如果有“一次约多人”的需求,可以传实际人数。注意DECRBY支持负数,但脚本里已经用stock < num挡住了,不会出现负库存。
3.3 扣减成功后再落库,失败要回补
Redis 扣减成功不代表订单一定创建成功,比如数据库唯一键冲突(同一身份证重复约)。所以要有补偿逻辑:
@Transactional public String book(Long userId, String idCard, Long slotId) { // 1. Redis原子扣减 Long remain = deductStock(slotId, 1); if (remain == -1) { throw new BizException("库存未初始化"); } if (remain == -2) { throw new BizException("该时段已约满"); } try { // 2. 落库 Reservation r = new Reservation(); r.setOrderNo(generateOrderNo()); r.setUserId(userId); r.setIdCard(idCard); r.setSlotId(slotId); reservationMapper.insert(r); return r.getOrderNo(); } catch (DuplicateKeyException e) { // 3. 唯一键冲突,回补Redis库存 redisTemplate.opsForValue().increment("stock:slot:" + slotId, 1); throw new BizException("您已预约过该时段"); } catch (Exception e) { redisTemplate.opsForValue().increment("stock:slot:" + slotId, 1); throw e; } }逻辑说明:先扣 Redis 再落库,是为了让绝大多数请求在 Redis 层就被挡住,只有扣减成功的少数请求才打到数据库。落库失败(唯一键冲突或其他异常)必须回补 Redis 库存,否则库存会越来越少,出现“明明没人约却显示约满”的玄学问题。
参数说明:generateOrderNo()建议用“日期 + 雪花算法”或“时间戳 + 随机数”,保证全局唯一。回补用increment而不是set,避免覆盖其他并发扣减。
3.4 核销接口的幂等设计
核销是另一个容易翻车的地方。用户扫码,前端可能重复提交,网络可能重试,所以核销必须幂等:
public boolean checkin(Long reservationId, String operator) { // 1. 查预约状态 Reservation r = reservationMapper.selectById(reservationId); if (r == null || r.getStatus() != 0) { return false; // 不存在或已核销/已取消 } // 2. 插入核销记录,唯一键冲突说明已核销 try { CheckinRecord record = new CheckinRecord(); record.setReservationId(reservationId); record.setOperator(operator); checkinMapper.insert(record); } catch (DuplicateKeyException e) { return false; // 已核销 } // 3. 更新预约状态 reservationMapper.updateStatus(reservationId, 1); return true; }逻辑说明:checkin_record表的唯一键uk_reservation是幂等的最后一道防线。即使两个请求同时进来,数据库唯一键只会让一个成功,另一个抛异常返回 false。先插核销记录再改状态,保证“有核销记录必有状态变更”。
参数说明:operator是核销员标识,方便追溯。状态 1 表示已核销,后续查询余票时不再计入。
4. 避坑与排查:那些让我加班到凌晨的坑
4.1 坑一:Redis 库存和数据库对不上
现象:运营反馈“明明后台看还有 50 个名额,用户就是约不上”,或者反过来“约满了但数据库里订单数没到上限”。
原因:Redis 扣减成功但落库失败后没回补,或者回补了但 Redis key 过期了。还有一种情况是放票任务重跑,setIfAbsent没生效(比如用了set),把已扣减的库存覆盖回初始值。
解决:第一,所有落库失败分支必须回补,用 try-catch 包住。第二,预热用setIfAbsent,别用set。第三,加一个对账定时任务,每天凌晨比对 Redis 余量和数据库total_quota - booked_quota,不一致就以数据库为准修正 Redis。
4.2 坑二:同一身份证并发预约绕过唯一键
现象:压测时发现同一身份证在同一时段生成了两条订单,唯一键没拦住。
原因:唯一键uk_idcard_slot是(id_card, slot_id),但如果代码里先查再插,两个请求同时查到“没有”,然后都插入,数据库唯一键应该拦住才对。真正的原因是——表用了软删除,或者slot_id在插入时传错了(比如传了 null),导致唯一键失效。
解决:检查slot_id是否允许 null,唯一键对 null 不生效。另外,如果业务需要“取消后可以重新约”,唯一键就不能简单用(id_card, slot_id),要加状态字段或用部分索引。我一般会在应用层加分布式锁(用 Redis 的SET NX),锁 key 是lock:idcard:slot:{idCard}:{slotId},双保险。
4.3 坑三:核销时重复提交导致重复核销
现象:核销员手快点了两下,或者扫码枪重复触发,同一张票被核销两次,后台出现两条核销记录。
原因:核销接口没做幂等,或者幂等判断和插入之间有并发窗口。
解决:靠checkin_record的唯一键兜底,插入冲突就返回“已核销”。同时前端按钮点击后置灰,但后端不能依赖前端。另外,核销接口建议加一个短时间的分布式锁,锁 key 是lock:checkin:{reservationId},锁 3 秒,防止并发。
4.4 坑四:放票瞬间 Redis 连接池被打满
现象:放票时间一到,接口大量超时,日志里全是Could not get a resource from the pool。
原因:Redis 连接池配置太小,默认可能只有 8 个连接,放票瞬间几百个请求同时要连接,排队超时。
解决:调大连接池。以 Lettuce 为例,spring.redis.lettuce.pool.max-active设成 200 左右,max-wait设成 500ms。同时,扣减库存的 Lua 脚本执行很快,连接周转率高,200 个连接足够扛住几千 QPS。如果还不够,就要考虑 Redis 集群或分片。
4.5 坑五:时段跨天导致日期计算错误
现象:用户约了“23:00-01:00”的夜场,结果核销时提示“不在预约时段内”。
原因:slot_date只存了开始日期,结束时间跨到第二天,核销时用slot_date + end_time拼出来的时间比实际晚了 24 小时。
解决:时段表加一个end_date字段,或者约定所有时段不跨天。如果美术馆有夜场,建议把夜场单独拆成两个时段,或者end_time存完整 DATETIME。我一般会在建时段时就校验end_time > start_time,跨天的直接拒绝,让运营拆成两个时段。
5. 进阶技巧:用对账任务和压测脚本守住最后一道防线
系统上线只是开始,真正让它稳定的是对账和压测这两个习惯。对账任务我一般写成每天凌晨 3 点跑,逻辑不复杂,但能救命:
-- 对账SQL:找出Redis余量和数据库不一致的时段 SELECT ts.id, ts.total_quota - ts.booked_quota AS db_remain, ts.total_quota, ts.booked_quota FROM time_slot ts WHERE ts.slot_date >= CURDATE() AND ts.total_quota - ts.booked_quota < 0; -- 数据库层面已超卖这条 SQL 查的是数据库层面已经超卖的时段(booked_quota > total_quota),一旦查出,说明 Redis 扣减和落库之间出了严重不一致,需要人工介入。正常情况下这个查询应该永远返回空。对账任务再拿每个时段的db_remain去和 Redis 的stock:slot:{id}比对,不一致就以数据库为准set回去,并打告警日志。
压测脚本我用 JMeter 或 wrk,重点压两个场景:一是放票瞬间的扣减接口,看 Redis 和数据库的响应时间;二是核销接口的并发重复提交,看唯一键是否生效。压测时把 Redis 连接池、数据库连接池、Tomcat 线程数都调到生产配置,别用默认值压,否则压出来的数据没参考价值。
最后说个血泪经验:预约系统的库存,宁可少卖也不能超卖。少卖一个名额,用户顶多抱怨一句;超卖一个名额,用户到现场进不去,就是投诉和舆情。所以我的习惯是,所有扣减逻辑都偏向保守——Redis 扣减成功后,如果落库有任何异常,一律回补并让用户重试,绝不“先记着后面补”。这个习惯让我少加了很多班。希望帮到你。
本文还有配套的精品资源,点击获取