医院预约挂号系统数据库设计实战:防超卖与事务一致性

发布时间:2026/10/11 15:43:55
医院预约挂号系统数据库设计实战:防超卖与事务一致性 简介微信小程序医院预约挂号系统毕业设计资料包面向Java方向计算机专业学生及需要实现同类预约挂号项目的开发者。文档围绕项目背景、技术选型与系统设计展开重点梳理微信小程序、Java、Spring Boot、SSM、MySQL等技术的适用场景并具体介绍用户管理、医生管理、预约管理、通知提醒等核心模块的设计思路同时针对数据安全、高并发处理、系统稳定性等现实问题给出解决方向可作为毕业设计开题、开发与论文撰写的直接参考。整个资源只包含1个docx文档大小约883KB内含摘要、目录及完整章节结构已有381人学习下载。借助该文档可以快速把握医院预约挂号小程序的整体架构理解前后端分离开发模式与MySQL数据库设计方法减少项目初期的调研和选型成本。1. 微信小程序医院预约挂号源码跑通容易数据库设计才是分水岭拿到一份《微信小程序医院预约挂号小程序源码数据库.docx》时大多数人的第一反应是把 SQL 导入本地库改一下 appid编译跑起来。但我见过太多类似项目止步于这一步——Demo 能跑一放到真实医院场景就翻车。预约挂号的生死点不在页面 UI而在号源的并发扣减和数据库状态一致性同一个医生上午 8 点的号10 个人同时点击最后是该让 10 个人都下单成功还是只有 1 个成功、其余 9 个收到“号源已被预约”的提示这不是玄学是数据库设计问题。我结合一个社区医院预约项目的二次开发经历把源码背后的数据模型、下单事务、防超卖策略和上线前必做的检查一次讲透。适合拿到源码后想真正部署、或准备自己从零写一套的开发者。2. 预约挂号的领域模型从挂号动作倒推五张核心表2.1 先画业务动作再建表一次挂号要经过哪几步不要拿到源码先读代码先把一次挂号的完整动作在纸上画出来用户进入小程序选择科室进入科室后看到医生列表选择某个医生看到该医生某一天的排班选择具体时间点填写就诊人姓名和手机号确认后系统锁定这个号源生成预约单用户完成支付或确认最后医院端能看到当日预约列表。顺着这个动作链核心表其实是可以倒推出来的科室表承载“按科室找医生”医生表承载“医生简介和排班”排班表承载“某医生在某个日期出诊”号源表承载“这个排班下具体几点几分可以约”预约单表承载“谁在什么时间约了哪个医生”。再加上用户表一共六张表前五张是业务核心。我一般不建议把科室、医生、排班这些信息全部塞进一张大宽表里尽管很多教学 Demo 为了省事把医生姓名、科室名直接冗余在预约单里。表面看查询方便但后续改科室名称、医生职称时会产生大量脏数据。合理的做法是核心业务表只存 ID 和外键展示层的冗余字段单独考虑比如预约单里冗余 doctor_name 是为了给用户发通知时不必再 JOIN。2.2 排班与号源建模总量余量模式还是时间槽模式排班和号源是整个系统里最容易设计错的地方。常见做法有两种一是“总量余量模式”排班表里存 total_count 和 remain_count用户下单时把 remain_count 减一二是“时间槽模式”把“上午 8:00-11:30”这一个排班拆成 20 个具体的时间点每个时间点对应数据库里的一行。预约挂号小程序我更推荐时间槽模式。原因很实际总量余量模式只能告诉患者“还剩 10 个号”但无法告诉患者“你约的是几点几分”患者到了医院还得排队等时间槽模式可以让患者直接选择 8:00、8:20、8:40 这样的具体时间点医院分时就诊的压力也小很多。另一个技术层面的原因是时间槽模式天然适合用数据库行锁来控制并发一个时间槽就是一行抢号时对这个 UPDATE影响行数足以判断成败。实际建模时我会把两者结合schedule 表表示某医生某天某个时段上午/下午出诊time_slot 表在该 schedule 下生成具体时间点。schedule 表里的 remain_count 只作为列表页的估算展示真实剩余数量始终以 time_slot 中 status 为可约的数量为准。这样既保留了列表查询的简单也保证了下单时数据的一致性。2.3 建表 SQL一套可直接落地的 MySQL 脚本下面这套表结构是我做社区医院预约项目时沉淀下来的MySQL 5.7 以上可直接执行。字符集用 utf8mb4避免患者姓名里出现生僻字时报错。CREATE DATABASE IF NOT EXISTS hospital_appointment DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE hospital_appointment; -- 用户表小程序 openid 唯一 CREATE TABLE user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, openid VARCHAR(64) NOT NULL, nickname VARCHAR(50) DEFAULT , phone VARCHAR(20) DEFAULT , create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB; -- 科室表 CREATE TABLE department ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL, sort_order INT NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB; -- 医生表 CREATE TABLE doctor ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, department_id INT UNSIGNED NOT NULL, name VARCHAR(50) NOT NULL, title VARCHAR(20) DEFAULT , avatar VARCHAR(255) DEFAULT , PRIMARY KEY (id), KEY idx_department (department_id) ) ENGINEInnoDB; -- 排班表某医生某天某时段出诊 CREATE TABLE schedule ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, department_id INT UNSIGNED NOT NULL, doctor_id INT UNSIGNED NOT NULL, work_date DATE NOT NULL, period TINYINT NOT NULL DEFAULT 0 COMMENT 0上午 1下午 2晚上, total_count INT NOT NULL DEFAULT 0, remain_count INT NOT NULL DEFAULT 0 COMMENT 仅用于列表展示, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可约 0停诊, version INT NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_date_doctor_period (doctor_id, work_date, period) ) ENGINEInnoDB; -- 号源表一个排班被拆成多个具体时间点 CREATE TABLE time_slot ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, schedule_id INT UNSIGNED NOT NULL, slot_time DATETIME NOT NULL COMMENT 具体开始时间, end_time DATETIME DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可约 1已锁/已约 2停用, booked_by INT UNSIGNED DEFAULT NULL COMMENT 占用该号源的用户ID, booked_at DATETIME DEFAULT NULL, version INT NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_schedule_status (schedule_id, status) ) ENGINEInnoDB; -- 预约单表 CREATE TABLE appointment ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, appointment_no VARCHAR(32) NOT NULL COMMENT 对外展示的预约号, user_id INT UNSIGNED NOT NULL, schedule_id INT UNSIGNED NOT NULL, slot_id INT UNSIGNED NOT NULL, doctor_id INT UNSIGNED NOT NULL, patient_name VARCHAR(50) NOT NULL, patient_phone VARCHAR(20) NOT NULL, idempotency_key VARCHAR(64) NOT NULL COMMENT 防重复下单唯一键, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已确认 2已取消 3已完成, remark VARCHAR(255) DEFAULT , create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, cancel_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_slot (slot_id), UNIQUE KEY uk_appointment_no (appointment_no), UNIQUE KEY uk_idempotency (user_id, idempotency_key), KEY idx_user_status (user_id, status), KEY idx_doctor_date (doctor_id, schedule_id) ) ENGINEInnoDB;这几张表里最需要注意的索引和约束有三处。appointment表的uk_slot唯一约束保证了一个号源只能被一条预约记录占用这是防超卖的最后一道兜底。uk_idempotency唯一约束配合应用层生成的idempotency_key能挡住用户双击造成的重复单。time_slot表的booked_by字段不是外键只是用来记录占用者后续做超时释放时可以直接判断。关于外键我特意没有在表之间加 FOREIGN KEY。原因是在高并发下单场景下数据库外键会引入额外的行锁和校验开销而且一旦业务要调整表结构外键比普通索引更麻烦。关联的完整性由服务端事务来保证这是常见的中台项目做法。如果你维护能力有限加上外键也不是不行但要做好性能预案。3. 小程序端到数据库的完整链路登录、选号、下单与号源扣减3.1 登录换 openid不要信任前端传过来的用户标识很多从 docx 里直接抄出来的 Demo 代码为了调试方便会在前端拿到wx.getUserProfile里的昵称甚至直接把 openid 通过wx.setStorageSync存到本地请求时再带上来。这个做法在真机上线时非常危险openid 是用户身份的凭证一旦被抓包任何人都能伪装成别的用户操作预约单。正确流程是小程序端调用wx.login拿到临时 code把 code 交给后端后端再用 code 换取 openid。// 小程序端 wx.login({ success: async ({ code }) { const res await request.post(/api/auth/login, { code }); // 后端返回的 token 存入 storage wx.setStorageSync(token, res.data.token); } });// 后端 Node.js使用 express const axios require(axios); const crypto require(crypto); router.post(/api/auth/login, async (req, res) { const { code } req.body; // 这里必须用服务端 appid 和 secret不能放在小程序端 const { data } await axios.get(https://api.weixin.qq.com/sns/jscode2session, { params: { appid: process.env.WX_APPID, secret: process.env.WX_SECRET, js_code: code, grant_type: authorization_code } }); if (!data.openid) { return res.status(401).json({ message: 登录失败 }); } // upsert 用户 const [rows] await db.query( INSERT INTO user (openid, nickname) VALUES (?, ?) ON DUPLICATE KEY UPDATE nickname VALUES(nickname), [data.openid, ] ); const userId rows.insertId || (await db.query(SELECT id FROM user WHERE openid?, [data.openid]))[0][0].id; const token crypto.randomBytes(24).toString(hex); // 把 token 和 userId 的映射存到 Redis 或 session 表 await redis.set(session:${token}, userId, EX, 7200); res.json({ token }); });这里的要点是code2session接口的appid和secret只能出现在服务端环境变量里代码仓库里出现明文 secret 就是一个事故。换取到 openid 后后端只返回一个随机 token小程序端后续所有接口都带这个 token。token 和 userId 的映射建议存 Redis设置过期时间这样用户在 token 过期后重新走一遍登录流程体验上是无感的。3.2 下单接口锁号、插入预约单必须在同一个事务里下单是整个预约系统的核心也是源码里最容易被写坏的地方。常见错误是先查询时间槽状态发现是可约再插入预约单最后再更新时间槽状态。这个流程在并发下一定会超卖两个请求同时查到可约都进入插入预约单的逻辑最后两个人都下单成功。正确做法是直接用一条UPDATE语句去抢占号源影响行数等于 1 才算抢到。router.post(/api/appointments, async (req, res) { const userId req.auth.userId; const { slotId, patientName, patientPhone, idempotencyKey } req.body; if (!slotId || !patientName || !patientPhone || !idempotencyKey) { return res.status(400).json({ message: 参数不完整 }); } const conn await mysql.createConnection(dbConfig); await conn.beginTransaction(); try { // 1. 抢占号源只有 status0 的行能被更新成功 const [lock] await conn.execute( UPDATE time_slot SET status 1, booked_by ?, booked_at NOW(), version version 1 WHERE id ? AND status 0, [userId, slotId] ); if (lock.affectedRows 0) { await conn.rollback(); return res.status(410).json({ message: 号源已被其他人预约请重新选择 }); } // 2. 生成预约单号并插入预约记录 const appointmentNo generateNo(DA); await conn.execute( INSERT INTO appointment (appointment_no, user_id, schedule_id, slot_id, doctor_id, patient_name, patient_phone, idempotency_key, status) VALUES (?, ?, (SELECT schedule_id FROM time_slot WHERE id ?), ?, ?, ?, ?, ?, 0), [appointmentNo, userId, slotId, slotId, req.body.doctorId, patientName, patientPhone, idempotencyKey] ); // 3. 扣减排班表的展示余量 await conn.execute( UPDATE schedule SET remain_count remain_count - 1, version version 1 WHERE id (SELECT schedule_id FROM time_slot WHERE id ?) AND remain_count 0, [slotId] ); await conn.commit(); res.json({ appointmentNo, status: 0 }); } catch (e) { await conn.rollback(); if (e.code ER_DUP_ENTRY) { return res.status(409).json({ message: 请勿重复提交 }); } throw e; } });这段代码的逻辑顺序是刻意的先锁号再建单最后扣余量。锁号失败立即回滚不需要再做任何判断。INSERT预约记录时依赖uk_slot唯一约束即使有极端并发导致同一条 slot 被两次 UPDATE 成功理论上不该发生第二张预约单也会因为slot_id重复而插入失败。idempotencyKey重复时同样会被唯一索引拦截返回ER_DUP_ENTRY前端收到 409 时可以引导用户查看已有的预约结果而不是报系统性错误。这里还需要说清楚一个容易混淆的概念remain_count扣减不是防超卖的依据只是让列表页展示的余量更平滑。真实能不能约只认time_slot.status。如果remain_count和实际 slot 状态偶尔对不上通常是取消预约时忘记回滚余量后面我会专门讲。3.3 前端页面与接口如何对应科室到确认下单的四步跳转小程序前端通常按四步串起来科室列表页、医生列表页、排班选号页、确认下单页。每一步对应一组接口后端只要保证接口返回的数据结构稳定前端可以很快替换成自己的 UI。// 小程序端选号流程示意 async function selectFlow() { // 1. 获取科室列表 const deps await request.get(/api/departments); // 2. 进入科室获取医生列表 const doctors await request.get(/api/doctors, { departmentId: deps[0].id }); // 3. 选择医生获取排班日期 const schedules await request.get(/api/schedules, { doctorId: doctors[0].id }); // 4. 选择排班获取可约号源 const slots await request.get(/api/slots, { scheduleId: schedules[0].id }); // 5. 选择时间槽提交预约 await request.post(/api/appointments, { slotId: slots[0].id, doctorId: doctors[0].id, patientName: 张三, patientPhone: 13800000000, idempotencyKey: ${Date.now()}-${Math.random().toString(36).slice(2)} }); }这里有两个前端细节值得注意。第一选号页加载号源时不要一次性返回所有状态只返回status 0的可约号已约的显示为灰色即可减少小程序端渲染压力。第二确认下单按钮要加loading状态点击后立即置灰本地再生成一个idempotencyKey这个 key 的作用在并发场景尤其明显——用户手机网络差时连续点了两次后端靠幂等键直接返回第一条建单结果而不是生成两个号。接口层的参数校验也不能交给前端。后端对patientPhone做正则校验对patientName做长度限制防止有人绕过小程序直接请求接口写入异常数据。这些边界写进下单接口里比写 100 个前端校验都有用。4. 数据库与并发避坑超卖、重复订单与脏数据排查4.1 并发超卖号源状态查了又改为何还是超卖现象压测时用 50 个并发同时抢一个时间槽结果出现两张预约单都提示成功或者两个用户进入选号页时都看到同一个时间点显示可约提交时也都成功了。原因这是最经典的“先查后改”竞态。下单接口先SELECT判断status 0再UPDATE改状态两条 SQL 之间有时间窗口第二个请求在第一个请求提交前读到了旧状态于是也进入了下单流程。即使把两条 SQL 放进事务默认的 MySQL 隔离级别是可重复读普通SELECT并不会锁住这一行无法挡住并发。解决把判断和修改合并成一条UPDATE ... WHERE id ? AND status 0用affectedRows判断是否抢占成功。这是数据库层面最简单有效的防超卖手段。如果还想更保险appointment表上要对slot_id加唯一索引保证一个号源只能对应一张有效预约单。很多老源码只有普通索引一旦业务代码有竞态唯一索引就是最后一道保险。顺带说一句Redis 里的预占操作只是加速和限流不能替代数据库事务。Redis 的锁可能在服务重启时丢失最终一致性还是要靠数据库层来兜底。4.2 取消预约后号源没释放数据状态不一致的典型病例现象用户在前端点击“取消预约”预约单状态变成已取消用户列表里也看不到了。但其他用户在选号页看到这个时间点依然是灰色无法预约。原因取消接口大概率写了两个独立的更新操作——先更新appointment表状态再更新time_slot表状态。第二步失败时没有回滚第一步或者第一步失败时第二步已经执行导致预约单已取消但号源还锁着。解决取消预约必须也是事务操作并且更新顺序不能反过来。先更新预约单为已取消再释放号源。如果先释放号源然后更新预约单失败会出现号源被释放但用户订单看起来还有效的严重问题医院现场容易扯皮。下面是正确的取消事务。router.post(/api/appointments/cancel, async (req, res) { const userId req.auth.userId; const { appointmentId } req.body; const conn await mysql.createConnection(dbConfig); await conn.beginTransaction(); try { // 1. 先把预约单置为已取消条件包含 userId 防止越权取消 const [upd] await conn.execute( UPDATE appointment SET status 2, cancel_time NOW() WHERE id ? AND user_id ? AND status IN (0, 1), [appointmentId, userId] ); if (upd.affectedRows 0) { await conn.rollback(); return res.status(404).json({ message: 预约单不存在或已取消 }); } // 2. 再释放号源 await conn.execute( UPDATE time_slot SET status 0, booked_by NULL, booked_at NULL, version version 1 WHERE booked_by ? AND status 1 AND id (SELECT slot_id FROM appointment WHERE id ?), [userId, appointmentId] ); // 3. 回补排班余量 await conn.execute( UPDATE schedule s JOIN appointment a ON a.schedule_id s.id SET s.remain_count s.remain_count 1 WHERE a.id ?, [appointmentId] ); await conn.commit(); res.json({ message: 已取消 }); } catch (e) { await conn.rollback(); throw e; } });这个案例的价值在于状态不一致不是并发专属问题普通串行操作也会因为缺少事务而出问题。排查这类脏数据时SQL 里加条件WHERE booked_by ?很关键可以避免误释放其他号源。4.3 重复提交点了一下没反应再点就多了一张预约单现象用户在下单页点击确认页面卡了 1 秒用户又点了一次几秒后微信支付或预约记录里出现了两条相同的预约。原因典型的前端未做防重复 后端缺少幂等控制。部分源码下单接口只校验了参数没有对同一用户的同一次请求做去重网络重试时就会插入多条单。解决前端把按钮置灰这只是体验优化不是可靠方案。真正可靠的是后端幂等键唯一约束。用户进入下单页时生成一个唯一idempotencyKey可以用 UUID提交时带上后端appointment表的uk_idempotency唯一索引会拦住第二次插入。如果拿到ER_DUP_ENTRY说明用户已经提交过直接查已有预约单返回而不是报错。还可以在 Redis 里用SETNX idempotency:{key} 1 EX 600做提前拦截减轻数据库压力。4.4 时区导致日期错位上午 8 点查不到当天的号现象后端部署在云服务器上数据库和服务器默认是 UTC 时区。医院的运营人员每天早上 8 点打开小程序看到排班列表里昨天还有号今天的号却全部显示“已结束”。原因CURDATE()或NOW()返回的是数据库会话时区的当前时间如果 MySQL 连接串没指定serverTimezoneAsia/Shanghai或者服务器时区是 UTC那么“今天”的边界就偏移了 8 小时。排班表work_date存的是 DATE 类型没错但查询“当前可约排班”时如果用work_date CURDATE()就会在 0 点到 8 点之间查错日期。解决在 MySQL 连接串里显式加serverTimezoneAsia/Shanghai同时建议后端所有时间传参都用时间戳前端展示层再格式化。这里有一个习惯值得养成凡是存日期直接用DATE类型凡是存时间点直接用DATETIME。不要用字符串拼时间因为字符串排序和时区转换会埋下一堆暗雷。5. 让源码从 Demo 走向可上线号源回滚、索引与权限检查5.1 取消预约的号源回滚状态机怎么设计才不容易乱在第 4 章的取消事务里我只贴了代码这里展开讲状态机。预约单的状态流转应该闭合待支付0可以到已确认1、已取消2已确认1可以到已完成3、已取消2待支付超时也要到已取消2。已取消和已完成是终态不能再做任何支付或取消操作。号源状态和预约单状态必须同步变化。下单占号时预约单是待支付号源是已锁支付成功后预约单是已确认号源保持已约取消时预约单是已取消号源回到可约。这个同步关系用一张状态对照表最直观。场景appointment.statustime_slot.status下单未支付0 待支付1 已锁支付成功1 已确认1 已约用户取消2 已取消0 可约超时未支付2 已取消0 可约完成就诊3 已完成1 已约关键点是任何状态迁移都必须在一个事务里完成。我见过最乱的源码是把这些状态分散在七八个接口里改结果出现“支付成功但号源还是可约”的鬼畜现象。状态机表贴在项目文档里改代码时对着它核对能少踩很多坑。5.2 列表查询的索引与分页经验别让选号接口变成慢查询选号页每次打开都会触发一次可约号源查询按医生、日期、时段查排班再查该排班下的可约 slot。这个查询在高并发下很容易变成慢查询尤其是排班和号源两张表都扫全表。索引设计要针对两个查询维度。第一个维度是按医生和日期查排班schedule表的联合唯一索引uk_date_doctor_period已经覆盖。第二个维度是按schedule_id查可约 slottime_slot表的idx_schedule_status索引是最佳选择。需要注意这个联合索引的顺序是(schedule_id, status)不是(status, schedule_id)因为查询条件里schedule_id是等值status是范围等值前置才能让索引发挥最大作用。-- 慢查询排查示例查看未走索引的选号查询 EXPLAIN SELECT slot_time FROM time_slot WHERE schedule_id 12 AND status 0 ORDER BY slot_time;如果这个查询的type是ALL说明没走索引优先检查idx_schedule_status是否建立。分页方面预约记录列表不要用OFFSET翻页数据量大了之后越翻越慢。常见做法是改成 keyset 分页传入上次列表最后一条记录的id用WHERE id ? ORDER BY id DESC LIMIT 20代替OFFSET。在小程序里加载历史预约记录时这个优化效果非常明显。5.3 上线前检查清单权限、备份、日志与配额从 Demo 到上线我习惯按下面的清单过一遍每一项都是真实踩坑换来的。检查项具体做法常见坑接口权限除登录接口外其余接口都校验请求头里的 token并获取当前用户 IDDemo 里用 debug 模式绕过登录上线遗漏就会出越权数据库备份宝塔或 crontab 每天凌晨全量备份备份文件存到其他磁盘只备份了数据库没备份上传的头像文件恢复后发现图片全丢操作日志下单、取消、支付回调都记录用户 ID、操作类型、时间、耗时没有日志时排查脏数据只能靠猜效率极低微信支付/虚拟支付配置预约挂号类目需要在小程序后台选择医疗类目并申请相关权限类目不匹配时真机调用支付会直接被拦截连接池大小Node 后端 MySQL 连接池设置 10-20根据压测调整连接池设太大数据库连接数被打满服务直接雪崩权限校验这块再提醒一句所有写操作都要在 SQL 条件里带上当前用户 ID比如取消预约时WHERE id ? AND user_id ?。很多 Demo 源码只按预约单 ID 更新状态攻击者遍历 ID 就能把别人的预约取消掉。这种问题代码审计时不容易发现但上线后风险极高。备份恢复这件事建议在正式启用前完整演练一次。某次我们只演练了mysqldump备份没演练恢复结果发现备份文件权限不对、无法导入差点把运营数据弄丢。恢复演练的脚本要写进运维文档遇到事情时照着执行比现场回忆可靠得多。6. 一个让预约系统更稳的小技巧号源预占与超时释放下单接口直接改数据库没问题但如果你的业务量到了一定规模或者不想让太多数据库事务堆积我建议在真正写预约单之前加一层 Redis 预占。核心思路是用户选好时间点但还没支付时先用 Redis 把这个 slot 锁住锁的过期时间设置为 10 分钟。10 分钟内没有支付确认Redis 锁自动失效号源回到可约状态。const Redis require(ioredis); const redis new Redis(); // 下单接口里的预占逻辑 const locked await redis.set(reserve:slot:${slotId}, userId, EX, 600, NX); if (!locked) { return res.status(409).json({ message: 该时间点刚刚被人选走请换一个时间 }); } try { // 原来的数据库事务UPDATE time_slot, INSERT appointment await createAppointment({ userId, slotId, ... }); } catch (e) { // 事务失败要主动释放预占锁 await redis.del(reserve:slot:${slotId}); throw e; }这里要注意的是Redis 预占成功后接着执行数据库事务事务失败时有两条释放路径一是代码里catch后主动DEL二是等待 Redis 过期自动释放。两条路径都可能漏掉比如进程在catch之前崩溃。所以我更习惯把 Redis 预占当成“第一道闸”真正决定数据状态的还是第 3 章的数据库事务。Redis 锁丢了数据库事务也会重新判断 slot 状态不会造成超卖。更完整的兜底在超时订单扫描任务里。看板上常出现一种脏数据数据库里预约单是待支付Redis key 因为服务器重启丢了号源一直锁着。这时候需要一个定时任务把待支付订单挑出来逐个回滚。任务每次拉取 200 条处理完后继续下一批避免一次性把表锁住。// 每分钟执行一次的超时订单扫描 async function scanExpiredAppointments() { const [rows] await db.query( SELECT id, slot_id, user_id FROM appointment WHERE status 0 AND create_time DATE_SUB(NOW(), INTERVAL 10 MINUTE) AND cancel_time IS NULL ORDER BY create_time ASC LIMIT 200 ); for (const row of rows) { const conn await mysql.createConnection(dbConfig); await conn.beginTransaction(); try { await conn.execute( UPDATE appointment SET status 2, cancel_time NOW() WHERE id ? AND status 0, [row.id] ); await conn.execute( UPDATE time_slot SET status 0, booked_by NULL, booked_at NULL, version version 1 WHERE id ? AND status 1, [row.slot_id] ); await conn.commit(); } catch (e) { await conn.rollback(); } finally { await conn.end(); } } }这个扫描任务的关键是条件里同时限定status 0和cancel_time IS NULL这样即使某个订单被重复扫描第二次也会因为状态已变而跳过。配合 Redis 预占整个预约系统就有了两层保险第一层是 Redis 快速抢占挡住大部分热点请求第二层是数据库唯一约束和状态判断兜住所有业务边界。回看早期的项目我最大的教训就是只相信 Redis 锁以为 key 过期就是万事大吉结果一次服务重启让一堆待支付订单卡死运营手动改了半天的数据库。从那以后我养成了一个习惯缓存层可以失效数据库里的状态永远要能被一条简单的 SQL 修复。这个习惯延续到了所有业务系统里预约挂号只是其中之一。希望这篇笔记能帮你少走这些弯路也祝你能把这套源码真正变成稳定可上线的小程序。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询