ARTICLE DETAIL

资讯详情

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

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

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