首页 / 资讯中心 / 文章详情

图书馆预约系统实战:从需求拆解到高并发架构

图书馆预约系统实战:从需求拆解到高并发架构 ★ FEATURED ARTICLE
做图书馆预约系统这个项目最早是接了一个内部需求某图书馆每天开馆前门口排长队占座靠书包和水杯管理方想上一套线上预约。我一开始没当回事觉得不就是“选个座位、点个预约、到馆签到”嘛日历组件加一张表就能搞定。真把需求吃透、把线上问题跑过一遍之后才发现一个真实可用的预约系统难点根本不在“预约”两个字上而在“真实可用”这四个字上。这篇文章就把我从需求拆解到上线运维的完整思路和踩坑过程摊开讲给正在做或者准备做类似系统的开发者一个参考。1. 需求拆解你以为在做一个“预约”其实在做一套“规则引擎”1.1 先搞清楚谁会真用这套系统任何系统一旦进入真实环境第一件事就是梳理用户角色和真实场景。图书馆预约系统的角色并不只有“读者”一个至少包括三类读者选座、预约、签到、暂离、释放、查看违约记录。管理员维护座位信息、查看实时占用、处理异常预约、导出统计报表。外部联动方入口闸机、楼层大屏、门禁系统需要通过接口读取预约状态。读者端的核心诉求是“确定性”。我约到的座位必须是我的别人不能在我到达之前把它抢走我签到之后这个座位在其他人眼里必须呈现“已占用”而不是还能被预约。这个诉求听起来理所当然但很多初版系统恰恰是栽在这里因为查询走了缓存、预约走了另一条链路的逻辑导致数据不一致。管理员的诉求则是“可解释”。出了纠纷要能查到某个人某天某个时刻的完整记录包括签到时间、释放时间、违约原因不能一句“系统bug”打发。所以从第一天起关键动作的埋点和日志就要设计进去而不是上线之后补。1.2 业务规则的边界条件清单真实可用的系统业务规则必须穷举边界。很多开发者在接到需求后直接画表、写接口忽略了规则的完整定义。我整理了一份决策清单每一项都直接影响数据模型时段定义全天是自由时长还是拆成固定时段提前期允许提前几天预约取消规则开场前多久可以取消签到宽限开场后多久不签到算违约暂离规则离座多久会自动释放违约惩罚几次违约停用多久退座规则读者离开时是否需要主动释放这些规则看起来是产品经理的事但每一环都影响你到底要不要做时间片表、要不要维护状态机、要不要引入消息队列。如果一开始不逼着业务方把这些边界确认清楚后面开发到一半改需求代价是翻倍的。1.3 从需求文档到系统设计的取舍第一版我选了“自由时长 30分钟最小粒度”的方案理由是给读者最大灵活性。但这个选择带来了两个问题冲突检测要从简单的“固定时段查重”变成区间重叠判断座位利用率的统计口径也变得很模糊——一个座位被约了14:00到16:00和15:00到17:00中间重叠了1小时怎么算它的空闲碎片后来改成“固定时段划分 允许提前签到”的混合模式比如上午、下午、晚间三个大时段同时允许读者在时段开始前入场签到。冲突判断退化成区间交集判断统计报表也清晰了。这个改动背后的核心认知是图书馆座位是一种稀缺资源设计目标不是“像会议室一样灵活”而是“像电影院一样可预期”。大多数读者并不需要精确到半小时的自由预约他们需要的是“我明天上午去图书馆一定有位子”。2. 数据库与资源建模座位不是“一行记录”那么简单2.1 座位、楼层、区域与时间片的建模思路很多初学者会把“座位”当成一张表里的不同行然后直接在预约表里存座位号。这个思路不算错但当你要处理“某区域有没有空位”“某层楼还剩几个座位”“这个座位某时段是否被占用”时查询会写得很痛苦。我的做法是把座位建模为“静态资产”和“时间占用”两层。静态资产就是座位本身记录它的物理位置和状态CREATE TABLE seat ( id bigint unsigned NOT NULL AUTO_INCREMENT, building_id int NOT NULL, floor tinyint NOT NULL, room varchar(32) NOT NULL, area varchar(32) NOT NULL, code varchar(16) NOT NULL, row_no smallint NOT NULL, col_no smallint NOT NULL, status tinyint NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, device_code varchar(32) DEFAULT COMMENT 座位二维码标识, PRIMARY KEY (id), UNIQUE KEY uk_room_code (room, code) ) ENGINEInnoDB;时间占用则要落到预约记录上。每个座位在某个时间段内只有一条有效预约预约的状态、签到时间、释放时间都要记录下来方便回溯CREATE TABLE appointment ( id bigint unsigned NOT NULL AUTO_INCREMENT, appointment_no varchar(32) NOT NULL COMMENT 业务单号, user_id varchar(32) NOT NULL, seat_id bigint NOT NULL, book_date date NOT NULL, start_time time NOT NULL, end_time time NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0已预约 1已签到 2已释放 3已取消 4已过期, checkin_time datetime DEFAULT NULL, release_time datetime DEFAULT NULL, created_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_appointment_no (appointment_no), KEY idx_user_date (user_id, book_date), KEY idx_seat_date_status (seat_id, book_date, status) ) ENGINEInnoDB;这样拆的好处是座位状态可以随时由“当前时间是否有有效预约”推导出来而不是维护一个update频繁的status字段。如果你在seat表里存一个“是否被占用”的布尔值并发一高就会不停更新同一行锁竞争非常激烈。2.2 时段冲突检测的核心SQL与索引设计固定时段模式下冲突检测的逻辑是“查同一座位、同一天、同一时间段内是否存在状态为已预约或已签到的记录”SELECT COUNT(*) FROM appointment WHERE seat_id ? AND book_date ? AND status IN (0, 1) AND start_time ? AND end_time ?;这里的和组合能正确判断区间重叠。假设已有预约是14:00到16:00新请求是15:00到17:00那么14:00 17:00且16:00 15:00重叠成立查得出来。边界条件是如果新请求的结束时间刚好等于已有预约的开始时间比如14:10到14:00不合法或者某时段到14:00结束另一个从14:00开始这两段不应该算冲突所以条件里用了严格大于和严格小于。索引设计上我建的是(seat_id, book_date, status)的联合索引。查询时先等值过滤seat_id和book_date再用status过滤最后用start_time和end_time做范围判断。一个几百座位的小型图书馆每天几千条预约量这套查询完全顶得住。2.3 事务边界如何避免“明明有空位却约不上”冲突检测和插入预约必须在一个事务里完成。我之前的第一版代码是先查后插但没有加锁结果上线当天就出了问题两个用户同时点了同一个座位两个请求都通过了“是否有冲突”的检查然后都插入成功座位超卖了。解决思路有两个方向。方向一是用数据库行锁在事务里先SELECT * FROM seat WHERE id ? FOR UPDATE把这一行座位锁住后面的冲突检测和插入就串行化了。这个方案简单但问题是你锁的是seat表不是预约记录同一座位确实能锁住但并发不高时还好一旦同一时段大量用户预约不同座位锁的范围被不合理放大Locker等待和死锁概率都会上升。方向二是用一个“时间段占用表”做硬约束。我用了后者这也是固定时段模型带来的红利。设计思路是每个预约在time_slice表里占一行字段包含seat_id、book_date、start_time、end_time、appointment_id并加唯一索引(seat_id, book_date, start_time)CREATE TABLE seat_time_slice ( id bigint unsigned NOT NULL AUTO_INCREMENT, seat_id bigint NOT NULL, book_date date NOT NULL, start_time time NOT NULL, end_time time NOT NULL, appointment_id bigint DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_seat_slice (seat_id, book_date, start_time) ) ENGINEInnoDB;预约时先插入time_slice行如果插入影响行数为0说明这个座位的这个开始时间已被占用直接返回冲突。数据库唯一索引是最后一道防线的兜底任何应用层逻辑都可能在并发下出漏洞唯一键是铁律。3. 高并发抢座把“最后一秒”的公平性留住3.1 抢座场景的QPS特征与瓶颈分析预约系统最典型的并发高峰是每天早上放号的那一刻。比如规定7点开放预约2000个座位几千个学生同时刷新流量全打在同一个时间点上。这个流量峰值和日常预约完全不是一个数量级。我测过一组数据某个图书馆2000个座位放号后前3秒内涌入约4000个预约请求QPS超过1300。如果所有请求都直接打到数据库做冲突检测和插入数据库会瞬间被打爆慢查询和锁等待会把整个系统拖垮。所以必须在数据库前再加一道拦截闸门。3.2 基于Redis的原子预占方案我用的方案是“Redis预占 数据库确认”。预占阶段的关键是原子性。用一段带过期时间的原子命令某个座位某个时间段的预占键同一时刻只有一个客户端能设置成功SET lock:seat:{seatId}:{bookDate}:{startTime} 1 NX EX 5NX表示只有在键不存在时才设置成功EX表示键的生存时间是5秒。设置成功的请求才允许继续进行数据库写入设置失败说明这个时间段刚刚被别人抢走直接返回“手慢了”。这里有个细节预占成功后如果数据库写入失败必须主动删除预占键否则要等它自己过期期间其他用户会被误伤。我见过很多次因为忘删锁导致的“明明有空位却约不上”的投诉。3.3 数据库唯一约束兜底Redis预占不是最终依据因为锁会过期、进程会崩溃、Redis服务也可能抖动。真正确保不超卖的还是数据库那一层唯一约束。前面说的seat_time_slice表唯一索引(seat_id, book_date, start_time)就是硬约束。在固定时段模型下只要预约的开始时间都一样唯一键直接兜住同座位同时段的重复预约。预约接口的完整流程是Redis预占SET lock:seat:... NX EX预占成功则进入事务插入appointment和seat_time_slice插入time_slice时如果遇到唯一键冲突回滚事务删除Redis锁返回“已被预约”插入成功则提交事务发送预约成功通知顺序上必须严格要求Redis预占里设置的key不能和数据库的已占用状态混用否则会出现“Redis显示可用、数据库实际已约”的偏差。我的做法是预占成功后把Redis里另一个完整的“座位时间状态”键也更新为已占用供读多写少的查询直接读取。3.4 队列与异步处理如果预估流量比这个还猛比如那种全城抢票级别的峰值可以考虑把预约请求先丢进消息队列由消费者批量落库。但队列方案有个产品层面的代价用户感知从“立刻成功”变成了“稍后确认”。对图书馆预约这种场景我的建议是不要一上来就上队列。先做索引优化再做Redis预占最后才是异步化。过度设计会让系统复杂度上升反而变得不那么“真实可用”。如果单库单实例能扛住1300 QPS就完全没必要引入额外的中间件。4. 防占座与真实运营从“约了不来”到“来了才有座”4.1 签到机制的设计预约不等于入座这是很多demo系统停步的地方。真实运营中最大的挑战是“约了不来”一个人占了座位不出席其他人想坐却坐不了。所以签到机制是区分“演示系统”和“可用系统”的分水岭。我实现的设计是到场后扫座位上的二维码签到或者到馆内自助机刷卡签到。规则是预约开始前30分钟就可以签到。预约开始后30分钟内未签到预约自动释放座位变为空闲。自动释放同时记录一次违约。这里要用一个常驻定时任务扫描逾期未签到的预约扫描条件大致是SELECT id, user_id, seat_id FROM appointment WHERE status 0 AND book_date CURRENT_DATE AND start_time DATE_SUB(NOW(), INTERVAL 30 MINUTE);扫到结果后批量把状态置为“已过期”并推送消息给读者。4.2 违约记录与惩罚策略没有惩罚体系的预约系统最终都会变成占座系统。我们刚上线时为了照顾读者情绪做了一周“无惩罚观察期”结果违约率超过15%等于每6个预约就有1个放鸽子。后来启用了三档策略第一次违约发消息提醒不处罚第二次违约扣信用分预约权限保留第三次违约暂停预约权限7天。同时把“剩余违约次数”直接展示在读者端首页效果比抽象信用分好得多。实测两个月违约率降到4%左右。有个细节值得提违约记录要留够上下文比如关联的预约单号、座位号、时间、当时的系统判断依据。否则读者打电话来申诉“我明明签到了”你找不到签到记录做对质会非常被动。4.3 暂离与动态释放真实场景里读者不可能一直坐在座位上总要吃饭、去洗手间。如果不允许暂离规则就会显得不近人情如果允许暂离就必须防止有人用“暂离”霸占座位一整天。我的设计是读者点“暂离”后座位保留30分钟其他用户在查询里能看到该座位状态为“暂离中”但不可预约。30分钟未返回座位自动释放同时记一次轻微违约不会直接触发暂停权限但会计入次数。这里实现的重点是状态流转。暂离不是一个独立状态而是预约状态的一个标记位。我当时的实现是给appointment表增加一个temporary_leave_at字段记录暂离开始时间而不是新增一个状态值。这样扫描任务可以根据这个时间判断是否超时释放状态机也不需要额外扩展。5. 核心功能实现预约、签到、释放的闭环代码5.1 预约接口的实现要点预约接口是整个系统最核心的链路。把前面的设计串起来伪代码如下def create_appointment(user_id, seat_id, book_date, start, end): lock_key flock:seat:{seat_id}:{book_date}:{start} ok redis.set(lock_key, 1, nxTrue, ex5) if not ok: return error(该时段刚刚被约走换个座位试试) try: with transaction(): existed db.query(SELECT COUNT(*) FROM appointment WHERE seat_id? AND book_date? AND status IN (0,1) AND start_time ? AND end_time ?, seat_id, book_date, end, start) if existed 0: return error(该时段已被预约) appointment_id insert_appointment(...) try: insert_seat_time_slice(seat_id, book_date, start, end, appointment_id) except UniqueKeyError: raise BizError(手慢了座位刚被约走) notify_user(user_id, appointment_id) return appointment_id except Exception: redis.delete(lock_key) raise这里的几个细节值得强调。第一事务提交成功后不需要删预占锁而是把Redis里的座位时间段状态更新为“已占用”供后续查询直接使用。第二异常回滚时一定要清理预占锁否则5秒内其他用户都会被挡在门外。第三预约单号的生成不要用UUID太长且用户截图不方便用日期加自增序列就够了。5.2 签到与释放状态机预约状态必须是一个收敛的状态机不允许随意跳转。我定义的状态包括已预约、已签到、暂离中、已释放、已取消、已过期。状态跳转表如下当前状态触发动作目标状态已预约扫码签到已签到已预约用户取消已取消已预约超时未到已过期已签到主动释放已释放已签到暂离超时已释放已签到闭馆强扫已结束有人会问取消和释放有什么区别区别在于时间边界。开场前可以随时取消开场后只能退座不能取消。为什么要卡这么严如果不限制读者可以先预约一个热门座位到了现场发现不满意取消后立刻再约另一个座位等于把预约系统当成了“库存探测工具”对其他排队等座的人不公平。5.3 消息通知模块真实可用系统的体验很大程度上靠消息通知撑起来。我把通知拆分成了四类预约成功通知包含座位号、时间段、签到提醒。签到提醒开场前15分钟推送内容带上二维码或座位编号。违约通知写明哪次违约、什么原因、何时恢复话术统一规范。释放通知读者主动退座后告知“座位已释放可重新选座”。通知渠道优先选择站内消息和邮件避免依赖外部短信服务商。邮件模板里要包含预约单号、座位信息、时间方便读者截图申诉。如果遇到短时间内大量发送比如放号后的成功通知需要走异步队列慢慢发不能同步调用否则数据库事务已经提交但用户端迟迟收不到通知体验会很差。6. 常见问题与排查技巧实录6.1 问题速查表把我在上线期间遇到过的问题整理成一张速查表排查的时候照着对照会快很多现象可能原因快速定位方法明明有空位约不上Redis预占锁残留查锁的TTL手动删除后再试同一座位被两人约到事务边界没加唯一约束查time_slice表是否有重复行签到提示预约不存在日期边界算错检查跨天时book_date的计算逻辑定时释放任务重复执行多实例没加分布式任务锁看同批预约是否被处理了两次座位状态与实际不符缓存与数据库不一致先修数据库再清缓存观察补偿任务大屏显示剩余数不准查询走了统计聚合改为内存缓存定时刷新6.2 三个高发坑的详细排查坑一跨天日期边界。用JavaScript的new Date()处理日期时如果直接调用toISOString()晚上8点之后就会把本地时间算成第二天导致预约写入的book_date是错的读者签到自然找不到记录。解决方式是用本地年月日手动拼接格式化函数避免任何隐式时区转换。这个坑在写脚本、写定时任务、写管理后台时都可能遇到排查时要先确认入参的日期字符串到底是谁生成的。坑二Redis预占锁的生命周期和数据库事务不一致。预占锁TTL如果设得太短比如5秒而数据库事务排队超过了5秒锁自动释放后面的用户就能预占同一个座位可能引发后续一连串的报错或重复。解决方式是把预占锁TTL调到30秒数据库写入失败时主动删除。同时要记得正确性的底线是数据库唯一索引Redis锁只是拦截层不能把“Redis有锁”当成“数据一定正确”。坑三定时任务在集群环境下重复执行。如果部署了多个实例释放任务、违约扫描任务可能被多个实例同时执行导致同一条预约被处理两次状态被覆盖。我们是用事务锁的方式解决扫描前先获取一个数据库级别的会话锁只有拿到锁的实例才能执行任务。这个方案简单有效比引入分布式协调服务更轻量。6.3 几个真实上线后的软问题除了技术问题还有几个容易被忽略的“软问题”。第一门口大屏展示实时剩余座位刷新周期设置10秒就够了数据直接读内存缓存千万不要让大屏请求打到数据库。第二期末考试周流量是平时的十倍热点座位的缓存Key要单独拆出来避免一个大Key被高频读写拖垮。第三每个小时做一次状态核对补偿任务把那些“已签到但读者已经离开却忘记释放”的座位检查出来避免资源被无效占用。第四所有关键动作必须打印带业务单号的日志线上排查只能靠这些日志串联完整链路。写到这里回到开头那句“真实可用”。我做这个系统最大的体会是真实可用不是一个功能点而是一整条链路的综合结果。规则设计、并发控制、违约惩罚、日志监控每一个环节都会在某个深夜变成线上事故。预约系统虽然只是图书馆信息化里一个小模块但它把数据库事务、缓存一致性、任务调度、消息通知全串起来了特别适合用来练手也特别容易翻车。如果你也在做类似系统建议先从最保守的规则起步固定时段、严格签到、明确惩罚等跑稳了再逐步放宽。规则可以后期迭代但超卖、串座这种数据错误一旦发生用户信任基本就没了。最后再分享一个细节上线前一定要用两个账号同时抢同一个座位测试并发这种“反人类测试”能在十分钟内暴露你设计里最脆弱的地方。
阅读完成 · 觉得有帮助?
咨询建站