news 2026/10/6 16:24:58

美术馆预约系统实战:分时预约与高并发防超卖设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
美术馆预约系统实战:分时预约与高并发防超卖设计

简介:这是一套面向高校计算机相关专业毕业设计的「美术馆预约系统」完整项目源码,适合正在准备毕设、需要参考完整业务系统实现的学生与开发者。项目围绕美术馆的预约、展览、票务与后台管理展开,涵盖用户注册登录、并发预约防超卖、在线支付、预约确认与取消、消息通知、后台数据分析以及响应式布局等模块,可帮助读者理解从需求分析到系统测试的完整开发流程。资源包共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 扣减成功后,如果落库有任何异常,一律回补并让用户重试,绝不“先记着后面补”。这个习惯让我少加了很多班。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 16:23:09

企业微信群机器人接收API数据:连趣云自动化推送实战

1. 核心场景&#xff1a;为什么要把API数据送进企业微信群 1.1 从人工盯数据到“数据找人” 先说一个我自己真实经历过的场景。以前负责一套电商中台的时候&#xff0c;每天上班第一件事是打开电脑&#xff0c;把后台管理页挨个过一遍&#xff1a;今天支付订单有没有异常、库存…

作者头像 李华
网站建设 2026/10/6 16:19:20

用提示词工程与Python把怪点子变成《降世神通》跑团模组

如果你是一张《降世神通&#xff1a;传奇》&#xff08;Avatar Legends&#xff09;桌面角色扮演游戏的主持人&#xff08;GM&#xff09;&#xff0c;某次开团前你收到玩家发来的一句话&#xff1a; “Can Norra STOP 1999 Honda Civic Avatar Legends” 这句话没有标点、没…

作者头像 李华
网站建设 2026/10/6 16:18:37

机器学习检测恶意URL:SVM与n-gram特征工程实战

简介&#xff1a;这是一份面向计算机相关专业课程设计、期末大作业与毕业设计的机器学习实践项目&#xff0c;聚焦恶意URL检测场景&#xff0c;包含改进后的完整源码与项目说明。压缩包共15个文件&#xff0c;主体为3个Python工程脚本&#xff08;数据预处理、模型训练与检测调…

作者头像 李华
网站建设 2026/10/6 16:15:42

遥感图像识别四模型协同流水线:kNN+SVM+CNN+LSTM

简介&#xff1a;本资源是一套面向遥感图像识别初学者与算法实践者的机器学习与深度学习综合实验方案&#xff0c;聚焦kNN、SVM、CNN及LSTM四类主流模型在WHU-RS19遥感数据集上的系统性对比实现。资源共34个文件&#xff0c;涵盖6个核心Python脚本&#xff08;含数据预处理、模…

作者头像 李华
网站建设 2026/10/6 16:11:50

python五大数据容器切片 基础内容

python五大数据容器 | 切片TOC 在啃Python五大数据容器的时候&#xff0c;我发现切片算是新手最容易混淆的知识点之一。很多同学刚学会下标取值&#xff0c;一碰到切片的起始、终止、步长就乱了&#xff0c;尤其反向切片很容易踩空。 切片本质上是有序容器专属的截取工具&…

作者头像 李华
网站建设 2026/10/6 16:11:49

字符设备驱动(一)

字符设备驱动&#xff08;一&#xff09;目录前言我们前两天提到了模块的编写&#xff0c;知道了模块是我们驱动代码的承载物&#xff0c;那么设备又是什么东西呢&#xff0c;顾名思义&#xff0c;它是我们在内核注册的对于硬件的一种抽象&#xff0c;如果把模块比作流水线&…

作者头像 李华