ARTICLE DETAIL

资讯详情

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

美术馆预约系统高并发架构:Redis预扣、异步落库与防刷实战

美术馆预约系统高并发架构:Redis预扣、异步落库与防刷实战 简介美术馆预约系统是一套面向艺术场馆运营方与毕业设计学习者的数字化管理资源定位于覆盖预约、展览、票务、后台管理等完整业务流程的系统设计方案。资源包含数据库脚本、Java后端源码、前端页面及样式文件并配套响应式界面与消息通知、支付集成等模块参考适合正在开展软件工程或Web开发类毕业设计、希望获取可运行项目骨架和功能拆分思路的读者。压缩包共517个文件其中Java源码约109个、页面与样式文件html/css/js合计170余个另有SQL脚本、配置文件及图片素材包体仅3.01MB结构清晰、便于快速导入部署。已有552人学习浏览可用于理解预约类系统的数据库设计、并发控制、管理员后台及安全防护等关键实现。整体资料完整度较高既能作为课程设计参考也能为美术馆或类似场馆的预约管理平台开发提供直接基础。1. 先搞明白美术馆预约系统到底在解决什么问题每次特展开票那几分钟后台请求量能冲到平时的几十倍服务器CPU报警、数据库连接池打满好不容易撑过去现场又有人拿着截图说“我明明约上了”一查是重复预约或者订单状态错乱——美术馆预约系统这一套东西表面上是做个表单让人填本质上是在做三件事限制同一时段进场人数、防止黄牛占坑、减少爽约浪费。它跟电商秒杀最大的区别是没有支付环节但有一个不能突破的物理上限那就是展厅的安全容量。所以这套系统适合谁看一类是美术馆、博物馆的信息化负责人要选型或者自研预约能力另一类是做智慧文旅项目的乙方开发需要在交付前把并发、防刷、对账这些事提前想清楚。下面这些内容我按照真实落地时最容易出问题的顺序来讲从数据模型到接口实现再到上线后必踩的坑。2. 把预约拆成五张表为什么库存要单独建一张2.1 为什么预约不是买票预约系统和票务系统的业务约束不一样。票务系统关心“哪张票卖给了谁”要处理座位号、价格、支付状态预约系统只关心“某个时段还能进多少人”。免费预约的美术馆用户爽约成本为零不到场也不取消名额就这么白白浪费了。所以我一般把设计重心放在三个约束上场次容量、用户限购、爽约惩罚。这里要提一个容易忽略的设计点展厅的容量不一定是“一天一个数字”。特展可能分为上午场、下午场或者按小时切分成多个入场时段。每个时段对应一个可预约的场次记录库存也是在场次维度上统计的。如果展厅里同时有多个展览各展览独立预约那就以展览加上时段来区分场次。为什么要把库存拆成单独一张表而不是直接往场次表里加一个 remain 字段因为预约系统的写操作频率很高每单成功、每单取消、每单超时关单都会改库存。把库存单独放在一张表里场次表本身不参与高频更新锁竞争只会落在库存表的单行上后续加缓存、做对账也方便。这是我踩过坑之后才坚持的设计不要贪图查询方便把容量和剩余人数放在同一行里到了高并发阶段你会后悔的。2.2 核心表结构场次、库存、预约单、用户档案、黑名单五张核心表缺一不可下面给出可以直接拿来改的建表 SQL。CREATE TABLE show_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, art_exhibition_id BIGINT NOT NULL COMMENT 展览ID, session_start DATETIME NOT NULL COMMENT 入场开始时间, session_end DATETIME NOT NULL COMMENT 入场截止时间, total_capacity INT NOT NULL COMMENT 该场次最大入场人数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可约 0停约, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE session_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL UNIQUE COMMENT 与场次一对一, remain INT NOT NULL COMMENT 剩余可约名额, capacity INT NOT NULL COMMENT 冗余总容量用于对账, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB;这里有两个细节。第一remaining和capacity冗余存储不是为了省一次查询而是为了在回补库存时做“加回不超过上限”的判断后面会专门讲。第二乐观锁版本号是给最终落库用的高并发入口靠 Redis 挡流量但数据库这一层不能完全裸奔否则极端情况下两个事务同时读到remain1再同时更新就会超卖。预约单表是所有业务的核心它的唯一约束设计决定了重复预约会不会发生。CREATE TABLE reservation_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL COMMENT 客户端生成的幂等ID, user_id BIGINT NOT NULL COMMENT 预约人ID, session_id BIGINT NOT NULL, visit_date DATE NOT NULL, visitor_name VARCHAR(50) NOT NULL, id_card VARCHAR(30) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0处理中 1已确认 2已取消 3已核销 4爽约, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_request_id (request_id), UNIQUE KEY uk_user_session (user_id, session_id) ) ENGINEInnoDB;uk_user_session保证了“一个用户一个场次只能有一单”这是业务规则层uk_request_id是幂等兜底客户端重复提交、消息队列重复投递都会在这里被数据库挡下来。很多人只做了代码判重结果并发场景下两个请求同时通过检查然后各插一条数据库唯一索引才是最后一层不可能绕过的防线。用户档案表和黑名单表负责支撑“限购与处罚”。CREATE TABLE user_visit_profile ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL UNIQUE, total_orders INT NOT NULL DEFAULT 0, cancel_count INT NOT NULL DEFAULT 0, no_show_count INT NOT NULL DEFAULT 0, blacklist_until DATETIME NULL COMMENT 黑名单解封时间, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB;这张表不是用来展示的它是给风控规则提供数据的。后面第 4 章的“爽约两次冻结 30 天”就是查这张表得出的结论。五张表建完核心业务链路已经能跑通了不需要一上来就设计“预约商品”“营销活动”“渠道分销”这些概念美术馆场景先把链路跑顺再考虑扩展。2.3 状态机、取消回补与关单定时任务预约单的状态不能随便跳。我常用的一套流转是这样的处理中到底已确认已确认到已核销处理中和已确认都可以走到已取消已确认如果开场前 2 小时还没核销自动标记为爽约。核销状态只有现场闸机或工作人员扫码确认后才能置为已核销系统里不允许反向操作。取消回补是这个系统里最需要小心的时序问题。正确的顺序是先判断当前remain capacity然后用 Redis 脚本把名额加回最后更新 MySQL 订单状态为“已取消”。如果顺序反过来MySQL 更新成功了但 Redis 加了失败库存就“蒸发”了如果回补不加判断消息重试时又加了一次库存就变成容量1。这两头都是真实发生过的问题。超时未确认的订单怎么处理我习惯在预扣 Redis 名额时给占坑 key 设置 15 分钟过期同时有一个定时任务扫描所有“处理中”状态的订单超过 15 分钟仍未落库确认的主动回补名额并置为“已取消”。定时任务的扫描频率不能太高每分钟一次足够否则高峰期会重复扫描大量订单。3. 预约接口怎么扛住开抢瞬间Redis预扣、异步落库、幂等三件套3.1 先想清楚为什么不直接在MySQL里扣库存低并发下直接在 MySQL 里执行UPDATE session_stock SET remain remain - 1 WHERE remain 0完全没问题但开抢瞬间几千个请求打过来行锁会让请求排队连接池一旦耗尽整个预约接口的响应时间会从几十毫秒飙到几秒网关超时后客户端重试又放进来一批请求雪崩就是这么来的。对比一下两种方案的特性对比点直接MySQL扣减Redis预扣异步落库单行性能约每秒几百次行锁更新Redis单线程每秒数万次HINCRBY连接池压力全部打到数据库数据库只接收有限消费线程失败回补事务回滚即可需要独立的回补链路较复杂最终一致性强一致依赖消息队列需对账兜底所以我的结论很明确Redis 挡流量MySQL 做最终确认。两者不是替代关系而是各干一段活。3.2 Redis Lua预扣脚本与占坑令牌Redis 预扣必须用 Lua 脚本保证原子性不能用先 GET 再 DECR 这种两步操作。-- KEYS[1] session:stock:{sessionId} -- ARGV[1] 预扣数量通常为1 -- ARGV[2] 当前毫秒时间戳 if redis.call(EXISTS, KEYS[1]) 0 then return -1 -- 库存key还没初始化 end local remain tonumber(redis.call(HGET, KEYS[1], remain)) if remain nil then return -2 -- key存在但没有remain字段数据异常 end if remain tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call(HINCRBY, KEYS[1], remain, -tonumber(ARGV[1])) redis.call(HSET, KEYS[1], updated_at, ARGV[2]) return 1 -- 预扣成功为什么用 Hash 而不用 String因为库存 key 会同时被预扣和对账两条链路使用Hash 可以同时维护 remain 和 updated_at还能冗余存一份 capacity对账的时候不用再回查数据库。Lua 脚本把 check 和 update 放在一个原子操作里从根本上避免了并发读改写问题。调用方的逻辑很直接DefaultRedisScriptLong redisScript new DefaultRedisScript(luaScript, Long.class); Long result redisTemplate.execute( redisScript, Collections.singletonList(session:stock: sessionId), String.valueOf(1), String.valueOf(System.currentTimeMillis()) ); if (result ! null result 1L) { // 预扣成功生成占坑令牌 String token UUID.randomUUID().toString().replace(-, ); redisTemplate.opsForValue().set( session:preorder: requestId, token, Duration.ofMinutes(15) ); // 返回给前端“预约处理中” }占坑令牌是做什么用的它相当于一张暂时有效的凭证后端异步落库时要用这个 token 换取正式的订单确认。15 分钟过期意味着如果 15 分钟内异步链路没有完成落库这个名额会被自动回补。这是“预约超时关单”的缓存侧实现。3.3 异步落库与幂等唯一键预扣成功后不要在请求线程里直接 insert 订单表。一个 insert 要查用户、查场次、写流水耗时十几毫秒开抢瞬间几千并发照样把数据库拖垮。常见做法是预扣成功后发一条消息到 MQ消费端再落库。RabbitListener(queues order.create.queue) public void onCreateOrder(PreOrderMessage msg) { try { reservationOrderMapper.insertWithRequestId(msg); orderService.confirm(msg.getRequestId()); } catch (DuplicateKeyException e) { // uk_request_id 冲突说明消息重复直接忽略 log.warn(duplicate message ignored: {}, msg.getRequestId()); } }这里的关键是消费端必须处理重复消息。RabbitMQ/Kafka 在极端情况下会重投消息如果消费端没有幂等保护一个订单会被插入两次。数据库的uk_request_id就是为了挡住这种重复。那么insertWithRequestId怎么写最稳的就是用INSERT INTO ... ON DUPLICATE KEY UPDATE或者先按 request_id 查一次查不到再插。前者性能更好撞唯一键时只影响一行。同步链路和异步链路的分界点在这里就清晰了前端提交请求后马上收到“处理中”真正的订单要等消费端落库后才变为“已确认”。用户等不了一般 12 秒内就会完成体验上没问题如果队列积压导致确认时间拉长到十几秒那就是要报警的级别了。3.4 队列参数、消费线程与回补时机队列参数的设置直接影响预约高峰的稳定性我常用的起点参数可以照抄参数推荐值说明队列名order.create.queue单队列足够不需要按场次拆分消费线程数CPU核数 × 2太高会增加数据库连接压力消费失败重试3次超过3次进死信队列死信队列order.create.dead人工处理或触发回补消息积压告警超过5000条说明消费端故障需立即介入回补库存的触发点有三个用户主动取消预约、订单超时未确认、死信队列人工处理后释放名额。三个触发点都必须走同一条回补逻辑。回补脚本和预扣脚本是对称的但多了一个关键判断加回之前必须检查remain capacity否则重复回补会把库存加到比总容量还大。这就是为什么我在建表时要冗余存储 capacity。没有这个判断回补逻辑就不是幂等的消息重试一次库存就多一张这是最容易让人挠头的数据异常。4. 防刷与限流把抢票机器人和真人分开的梯次配置4.1 三层限流入口、用户、存储热点的参数怎么设美术馆预约和电商秒杀有个明显区别免费或低价票的黄牛会写脚本按场次轮询请求量不大但特征非常明显——同一个 IP 短时间高频请求、同一个设备指纹关联多个账号、永远不触发滑块验证。所以防刷不能只靠服务端限流还要在“人”的维度上做识别。三层限流的参数可以这样起步层级手段推荐参数说明网关层令牌桶预约接口单机 50 QPS桶容量 100只限制预约提交接口查询接口单独放行用户层按用户限流每用户每分钟最多 1 次预约请求防止手速党不停试错存储层Redis热点key保护库存key 单key QPS超过 500 直接拒绝防单场次热点请求打爆 Redis网关层的令牌桶要注意区分接口路径。美术馆用户的真实行为是先浏览展讯、再看剩余名额、最后提交预约。如果把浏览和查询都限制到 50 QPS正常用户也会被误伤。限流只施加在/reservation/submit这个提交动作上查询接口单独放开。第三个“存储层保护”很多人会忽略。Redis 单实例性能再高也经不住几千个请求同时打同一个 key。而且 Redis 是单线程的一个热点 key 的慢操作会拖慢整个实例。所以我常用 Sentinel 或网关层做热点 key 识别超过阈值直接返回“当前预约人数过多”不走到库存预扣那一步。还有个值得说的设计选择要不要做排队我一般不做同步排队。预约系统跟秒杀不一样“已满”就是已满用户要的是确定性结果而不是排队等待。真有需求可以做候补名单用户提交候补信息后异步通知不要占用预扣链路。4.2 设备指纹与验证码的选择别把老人拦在门外强制滑块验证码能挡住绝大多数脚本但它有明显的副作用美术馆用户群里中老年用户占比不低我在真实项目中见过开抢前强制验证码的配置带来的流失率接近 10%。这不是风控能力问题是产品选择问题。所以我用的阶梯式策略是开抢前 5 分钟到开抢后 10 分钟对所有预约请求启用滑块验证其余时段只对命中风控规则的用户启用。风控规则的命中条件是客观数据不掺人工判断同一 IP 在 5 分钟内预约请求超过 10 次同一设备指纹关联超过 3 个账号同一手机号段在 10 分钟内注册超过 5 个新账号命中后先弹验证码通过后正常放行。不要一命中就封禁代理 IP 的误伤率太高用户被莫名封号后的投诉成本比黄牛的成本高得多。设备指纹的采集要注意合规不能做跨应用共享只在本系统内使用。4.3 黑名单判定与处罚爽约和退票阈值怎么定黑名单处罚不能只盯“退票”要区分两个概念。退票是提前取消并释放名额对系统没损失爽约是既不取消也不到场名额白白浪费。所以处罚规则应该侧重爽约退票次数作为辅助信号。我习惯的规则是30 天内取消超过 3 次或连续爽约 2 次冻结预约资格 30 天。冻结后用户看到的是“您目前暂时无法预约”具体原因不展示避免被绕过。SELECT user_id, SUM(CASE WHEN status 2 THEN 1 ELSE 0 END) AS cancel_cnt, SUM(CASE WHEN status 4 THEN 1 ELSE 0 END) AS no_show_cnt FROM reservation_order WHERE created_at DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY user_id HAVING cancel_cnt 3 OR no_show_cnt 2;注意这里统计爽约要用status 4也就是“已确认但未核销且开场时间已过”的记录不能算处理中或已取消的。很多团队栽在这里把“用户提交了预约但没确认”也算成爽约导致大量正常用户被冻结。5. 预约上线后的五个经典翻车现场现象、原因、解决5.1 库存超卖现场进场人数超出容量现象系统显示预约已满但现场核销时发现实际进入展厅的人数超过了 total_capacity。用户也有反馈拿着“已确认”的预约码闸机却提示无效。原因预扣 Redis 名额成功后异步落库失败但回补链路没触发Redis 显示的名额被占用数据库里却没有任何订单。或者反过来落库成功但回补重复执行直接把容量加超了。两套存储之间没有权威对账问题积累到现场才爆发。解决以 MySQL 已确认订单为唯一权威数据Redis 只做快速判断不做最终依据。每天凌晨跑对账任务发现不一致时以 MySQL 为准校准 Redis。同时核销接口直接查询数据库订单状态不看 Redis 缓存。现场再加一道闸机白名单特展工作人员可以人工核对证件放行。5.2 幂等失效导致重复预约现象用户同时收到三条预约成功短信后台能查到三张同一个场次的订单票量库存被多占三张。原因前端生成了 requestId但点击“提交”按钮后没有固定住这个ID网络抖动触发浏览器重试时生成了新的 requestId后端当成三单处理。另一条路径是 MQ 消息重投消费端没做幂等同一消息插了三次。解决数据库加uk_request_id唯一索引这是最终的兜底。消费端捕获 DuplicateKeyException 后直接确认消息不做任何业务处理。前端的修复是把 requestId 固定在页面生命周期内进入页面时生成一次整个预约流程复用提交成功后立即禁用按钮。5.3 分布式锁失效导致回补错乱现象某个场次的剩余名额在取消回补后变成了 capacity1也就是库存比总容量还多。用户仍然能正常预约但后台数据已经明显不对。原因回补逻辑用SETNX做了分布式锁但业务代码在锁内执行了查询数据库、更新状态、写日志等多个操作总耗时超过了锁的过期时间。锁自动释放后另一个线程也进入回补逻辑两边各加了一次库存。解决回补逻辑用 Lua 脚本把“判断 remain capacity”和“加回名额”放在一个原子操作里这样即使重复执行也不会超过容量上限。分布式锁的过期时间放宽到 5 秒同时锁内不要做远程调用和慢查询只做 Redis 操作和状态写入。5.4 取消回补丢失导致库存蒸发现象用户取消了预约系统提示取消成功但场次的剩余名额没有增加库存直接“蒸发”了很多用户约不上。原因取消操作的事务里MySQL 订单状态已更新为“已取消”但回补 Redis 名额的步骤抛了异常事务没覆盖外部存储。MySQL 说取消了Redis 说没取消两边不一致。解决引入一张reservation_cancel_message表取消操作先写一条回补消息再执行状态更新。由定时任务扫描这张表把没有成功回补的记录重新执行回补回补成功的消息标记为已完成。这张回补消息表要有唯一约束同一个 requestId 只能回补一次。5.5 时间窗口配置错误导致提前放票现象还没到开抢时间部分用户已经提交预约成功了后台排查发现场次状态还是“未开始”。原因前端提交预约请求时把用户本地的开抢时间传给了后端后端拿这个时间做校验。不同用户的手机时间不一致有的用户把设备时间调快就能提前几分钟提交。解决所有开抢时间的判断以后端服务器时间为准前端只传场次 ID不传时间参数。接口层拦截所有带上时间字段的预约请求。同一场次的开抢时间统一存为 DATETIME不接收“某用户自定义时间”这种数据。上线前可以用自动化脚本统一检查所有接口入参禁止出现startTime之类的前端可控时间字段。6. 用压测和对账数据说服馆方验收清单与一个核账脚本预约系统上线前拿三组压测数据去跟馆方汇报比讲一百页架构图都有说服力。第一组普通浏览场景100 并发持续 5 分钟第二组开抢瞬间500 并发持续 30 秒第三组高频重试场景单用户循环提交预约/取消 1000 次。压力机只看三个指标库存有没有超卖、订单有没有重复、回补有没有丢失。前两个看数据库最终数据第三个看 Redis 剩余名额是否恢复到初始值。库存对账是上线后每天必做的动作我习惯用下面这个 SQL 作为第一道防线SELECT s.id AS session_id, s.total_capacity, ss.remain, s.total_capacity - ss.remain AS calc_sold, (SELECT COUNT(*) FROM reservation_order o WHERE o.session_id s.id AND o.status IN (1, 3)) AS mysql_sold FROM show_session s JOIN session_stock ss ON ss.session_id s.id HAVING calc_sold ! mysql_sold;这个脚本把“MySQL 里已确认和已核销的订单数”当作权威值和“总容量减去 Redis 剩余名额”做比较只要两边不一致就说明预扣链路或回补链路出了问题。跑出来不为空的场次需要当天人工介入。给馆方做验收汇报时我还会把预约系统的日志埋点完整讲一遍预扣成功、MQ 投递、消费落库、状态确认四个时间点都要有日志。一次预约从提交到成功正常用时应该不超过 2 秒如果超过 5 秒优先看队列积压再看消费线程是否卡在数据库连接上。我接手任何一个预约系统第一件事不是改架构而是先跑一遍对账 SQL把过去一周每个场次的数据拉出来。这个习惯救过我多次——有一个项目连续三天特展场次的对账差值在 2 到 5 人之间最后定位到客服手动退款路径没有走回补逻辑这就是核账脚本的价值。压测时还有一个经验把“取消后立即再次预约”作为独立场景跑一遍这条链路最容易在压力下出问题而且问题往往到了现场才暴露。希望这些踩坑经验能帮到你。本文还有配套的精品资源点击获取
返回列表