☰
医院预约挂号小程序源码解析:号源并发扣减与订单状态回滚实战
2026/10/15 22:11:00 网站建设 项目流程

简介:这份资源是面向高校计算机专业毕业设计场景的微信小程序医院预约挂号系统配套源码与数据库文档,适合正在准备Java方向毕设、需要完整项目参考的本科生及自学者。资源以docx文档形式交付,压缩包内共1个文件,约883KB,内容围绕系统关键技术点展开,涵盖微信小程序前端、Java后端、Spring Boot与SSM框架选型,以及MySQL数据库设计等核心模块。文档从项目背景与意义切入,梳理了传统窗口挂号效率低、专家号资源紧张等痛点,并给出线上预约挂号的解决思路;随后依次解析用户管理、医生管理、预约管理、通知提醒等功能模块的设计与实现,同时涉及HTTPS数据传输、敏感信息加密、缓存优化与高并发处理等常见技术难点的应对方案。目前已有381人学习,可为毕设选题、技术选型与论文撰写提供较完整的参考框架。

1. 从一份医院预约挂号小程序源码说起:它到底能跑通什么

很多做医疗信息化的开发者拿到一份“医院预约挂号小程序源码+数据库”时,第一反应是打开压缩包看目录结构,然后试图直接导入运行。但真正落地过的人都知道,这类源码的价值不在于“能不能跑”,而在于它把挂号业务里最麻烦的几件事——号源池的并发扣减、排班与号别的关系、订单状态机、取消预约后的号源回滚——用一套可读的代码固定下来了。你拿到的不只是一堆页面,而是一个已经替你踩过坑的业务骨架。

这份源码通常包含小程序端(科室列表、医生详情、排班日历、确认下单、我的预约)和服务端(排班管理、号源生成、订单接口、支付回调),数据库里至少会有科室表、医生表、排班表、号源表、订单表、用户表这几张核心表。它适合两类人:一类是想快速搭一个预约类小程序原型的产品或全栈开发者,另一类是正在做医疗类项目、需要参考号源并发控制写法的后端工程师。接下来我会按“先看懂数据模型,再跑通最小链路,最后处理并发和状态回滚”的顺序,把这份源码里真正值得抄的部分拆开讲。

2. 先看懂数据库:号源、排班、订单三张表怎么串

2.1 号源表不是排班表的附属,而是独立库存

很多新手会把“排班”和“号源”混成一张表,结果一到取消预约就翻车。常见做法是拆成两张表:doctor_schedule记录某医生某天上午/下午在哪个科室出诊、总号数、挂号费;schedule_source记录每一条具体号源,比如“第1号、第2号……第N号”,每个号源有独立状态(可约、已约、锁定、作废)。这样做的原因是:取消预约时只需要把对应号源状态改回“可约”,而不是去改排班表的总数,避免并发下计数错乱。

-- 排班表:定义医生某天某时段的出诊计划 CREATE TABLE doctor_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, dept_id BIGINT NOT NULL, work_date DATE NOT NULL, time_slot TINYINT NOT NULL COMMENT '1上午 2下午', total_count INT NOT NULL DEFAULT 0, fee DECIMAL(10,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0停诊', UNIQUE KEY uk_doctor_date_slot (doctor_id, work_date, time_slot) ); -- 号源表:每一条记录就是一个可挂的号 CREATE TABLE schedule_source ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, source_no INT NOT NULL COMMENT '第几号', status TINYINT NOT NULL DEFAULT 0 COMMENT '0可约 1已约 2锁定 3作废', order_id BIGINT DEFAULT NULL, version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', UNIQUE KEY uk_schedule_no (schedule_id, source_no), KEY idx_schedule_status (schedule_id, status) );

上面这段建表语句里,version字段是后面做乐观锁的关键,uk_schedule_no保证同一个排班下不会出现重复号。idx_schedule_status是为了查“某排班下还有多少可约号”时走索引。注意status用 0/1/2/3 而不是字符串,是为了减少存储和比较开销。

2.2 订单表要能追溯号源,而不是只存医生和时间

订单表如果只存doctor_id、work_date,取消时你根本不知道要回滚哪个号。正确做法是订单表里存schedule_id和source_id,并且把号源状态变更和订单状态变更放在同一个事务里。

CREATE TABLE appointment_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, source_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已预约 2已取消 3已完成 4已过期', pay_amount DECIMAL(10,2) NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id, status), KEY idx_source (source_id) );

uk_order_no防止重复下单,idx_user_status用于“我的预约”列表查询,idx_source用于根据号源反查订单。这里status的状态机是:待支付→已预约→已完成,或者待支付→已过期,已预约→已取消。状态流转必须用代码控制,不能靠数据库默认值。

2.3 用一条 SQL 查“某医生某天还有多少号”

这是小程序端排班日历最常用的查询。不要用SELECT COUNT(*)再减已约数,直接按状态统计。

SELECT s.id AS schedule_id, s.work_date, s.time_slot, s.fee, SUM(CASE WHEN src.status = 0 THEN 1 ELSE 0 END) AS available_count, SUM(CASE WHEN src.status = 1 THEN 1 ELSE 0 END) AS booked_count FROM doctor_schedule s LEFT JOIN schedule_source src ON src.schedule_id = s.id WHERE s.doctor_id = ? AND s.work_date BETWEEN ? AND ? AND s.status = 1 GROUP BY s.id, s.work_date, s.time_slot, s.fee ORDER BY s.work_date, s.time_slot;

这条 SQL 的LEFT JOIN保证即使某个排班还没生成号源也能查出来(available_count为 0)。CASE WHEN比多次子查询快,因为只扫一遍schedule_source。参数?分别是医生 ID、开始日期、结束日期。如果查出来available_count为 0,小程序端就置灰该时段。

3. 跑通最小预约链路:从选号到下单的四个接口

3.1 号源锁定接口:先锁再付,避免超卖

用户点击“确认预约”时,不能直接改号源状态为“已约”,而是先改成“锁定”,等支付成功再改成“已约”。锁定要加过期时间,否则用户不支付会一直占号。常见做法是在schedule_source加lock_time字段,或者用 Redis 存锁。

# 伪代码:号源锁定,使用乐观锁防止并发 def lock_source(schedule_id, source_id, user_id): # 1. 查询号源当前状态和版本号 row = db.query( "SELECT id, status, version FROM schedule_source WHERE id=%s AND schedule_id=%s", (source_id, schedule_id) ) if not row or row.status != 0: return {"code": 4001, "msg": "号源不可约"} # 2. 乐观锁更新:只有版本号匹配且状态仍为可约才更新 affected = db.execute( "UPDATE schedule_source SET status=2, version=version+1, lock_time=NOW() " "WHERE id=%s AND version=%s AND status=0", (source_id, row.version) ) if affected == 0: return {"code": 4002, "msg": "手慢了,请重新选择"} # 3. 创建待支付订单 order_no = gen_order_no() db.execute( "INSERT INTO appointment_order (order_no, user_id, schedule_id, source_id, status) " "VALUES (%s, %s, %s, %s, 0)", (order_no, user_id, schedule_id, source_id) ) return {"code": 0, "order_no": order_no}

这段代码的关键在第二步:UPDATE ... WHERE version=%s AND status=0。如果两个请求同时进来,只有一个能更新成功,另一个affected为 0,直接返回“手慢了”。这比先SELECT再UPDATE安全得多。lock_time用于后续定时任务释放超时未支付的号源。

3.2 支付回调接口:把锁定号源变成已约

支付回调必须做幂等,因为支付平台可能重复通知。用order_no查订单,如果已经是“已预约”就直接返回成功。

def pay_callback(order_no, trade_no): order = db.query("SELECT id, source_id, status FROM appointment_order WHERE order_no=%s", (order_no,)) if not order: return {"code": 4003, "msg": "订单不存在"} if order.status == 1: return {"code": 0, "msg": "已处理"} # 幂等 # 事务:更新订单状态 + 更新号源状态 with db.transaction(): db.execute( "UPDATE appointment_order SET status=1, update_time=NOW() WHERE id=%s AND status=0", (order.id,) ) db.execute( "UPDATE schedule_source SET status=1, order_id=%s WHERE id=%s AND status=2", (order.id, order.source_id) ) return {"code": 0, "msg": "ok"}

这里UPDATE appointment_order ... WHERE status=0保证只有待支付订单能被改成已预约。号源从status=2(锁定)改成status=1(已约),并回填order_id。两个更新在同一个事务里,要么都成功,要么都回滚。

3.3 取消预约接口:号源回滚与状态机校验

取消预约不是简单把订单改成“已取消”,还要把号源改回“可约”,并且要判断当前订单状态是否允许取消。

def cancel_order(order_no, user_id): order = db.query( "SELECT id, source_id, status FROM appointment_order WHERE order_no=%s AND user_id=%s", (order_no, user_id) ) if not order: return {"code": 4004, "msg": "订单不存在"} if order.status not in (0, 1): return {"code": 4005, "msg": "当前状态不可取消"} with db.transaction(): db.execute( "UPDATE appointment_order SET status=2, update_time=NOW() WHERE id=%s AND status IN (0,1)", (order.id,) ) db.execute( "UPDATE schedule_source SET status=0, order_id=NULL, version=version+1 WHERE id=%s", (order.source_id,) ) return {"code": 0, "msg": "取消成功"}

注意号源回滚时version=version+1,这样如果有其他请求正在锁定这个号,乐观锁会失败,避免刚回滚就被错误锁定。status IN (0,1)保证只有待支付或已预约的订单能取消,已完成或已过期的不能取消。

3.4 超时未支付释放:定时任务怎么写

锁定号源后,如果用户 15 分钟不支付,需要自动释放。常见做法是用定时任务扫appointment_order里status=0且create_time超过 15 分钟的订单,逐个取消。

-- 查出超时未支付订单 SELECT id, source_id FROM appointment_order WHERE status = 0 AND create_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE) LIMIT 100;

然后对每条订单执行和取消预约相同的逻辑:订单改“已过期”,号源改回“可约”。注意要加LIMIT,避免一次锁太多行。定时任务频率建议 1 分钟一次,用分布式锁保证只有一个实例在跑。

4. 并发扣减号源:乐观锁、悲观锁和 Redis 怎么选

4.1 乐观锁适合号源竞争不激烈的场景

上面用的version字段就是乐观锁。它的优点是实现简单、不依赖额外组件,缺点是冲突多时大量请求失败重试。医院挂号通常一个医生一天几十个号,并发不会像秒杀那么高,乐观锁足够。但如果某个热门专家号放号瞬间有几千人抢,乐观锁会导致大量“手慢了”,用户体验差。

4.2 悲观锁SELECT ... FOR UPDATE的边界

如果一定要用数据库悲观锁,可以这样写:

START TRANSACTION; SELECT id, status FROM schedule_source WHERE schedule_id = ? AND status = 0 ORDER BY source_no LIMIT 1 FOR UPDATE; -- 然后更新这条记录为锁定 UPDATE schedule_source SET status = 2 WHERE id = ?; COMMIT;

FOR UPDATE会锁住查到的行,其他事务阻塞。问题是如果WHERE条件没有走索引,会锁表。所以schedule_id和status必须有联合索引。另外,ORDER BY source_no LIMIT 1要配合索引,否则排序也会锁更多行。这种写法适合号源数量少、并发集中的场景,但不要在高并发下用。

4.3 Redis 预扣减:把库存放到内存里

如果放号瞬间并发很高,常见做法是提前把号源数量加载到 Redis,用DECR原子操作扣减。扣减成功再异步落库。但要注意 Redis 和数据库的一致性:如果 Redis 扣了但数据库写失败,需要补偿。一般用 Redis 做第一层过滤,数据库做最终一致。

# Redis 预扣减示例 def try_lock_source_redis(schedule_id, source_id): key = f"source:stock:{schedule_id}" # 先判断该号源是否在可约集合里 if not redis.sismember(f"source:available:{schedule_id}", source_id): return False # 原子移除,移除成功表示抢到 removed = redis.srem(f"source:available:{schedule_id}", source_id) if removed == 1: redis.hset(f"source:lock:{source_id}", "user_id", user_id) redis.expire(f"source:lock:{source_id}", 900) return True return False

source:available:{schedule_id}是一个 Set,存所有可约号源 ID。SREM是原子操作,返回 1 表示抢到。抢到后写一个带过期时间的锁 key。后续支付成功再删掉锁 key 并更新数据库。如果支付失败或超时,定时任务把号源 ID 重新SADD回集合。

4.4 三种方案对比与选型建议

方案实现复杂度并发能力一致性适用场景
乐观锁低中强号源少、并发一般
悲观锁中低强号源少、必须强一致
Redis 预扣高高最终一致放号瞬间高并发

我一般会先用乐观锁跑通业务,等确实出现性能瓶颈再引入 Redis。不要一上来就上 Redis,否则数据一致性问题会让你花更多时间排查。

5. 避坑与排查:源码跑不起来时先看这五条

5.1 导入数据库后中文乱码

现象:科室名、医生名显示成问号。原因:建库时字符集不是utf8mb4,或者连接字符串没指定字符集。解决:建库用CREATE DATABASE xxx DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;,连接串加?characterEncoding=utf8。如果已经建了表,用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4;逐表改。

5.2 号源状态更新了但订单没生成

现象:用户看到“预约成功”但“我的预约”里没有。原因:锁定号源和创建订单不在同一个事务里,或者创建订单失败后没有回滚号源状态。解决:把UPDATE schedule_source和INSERT appointment_order放在同一个with db.transaction()里,任何一步失败都抛异常回滚。

5.3 取消预约后号源没释放

现象:用户取消后,该号仍然显示“已约”。原因:取消接口只改了订单状态,忘了改号源状态,或者改号源时WHERE条件写错。解决:检查取消逻辑里是否有UPDATE schedule_source SET status=0 WHERE id=order.source_id,并且确认order.source_id不为空。如果用了 Redis,还要把号源 ID 重新加回可约集合。

5.4 定时任务重复释放同一个号

现象:一个号被释放两次,导致两个用户抢到同一个号。原因:定时任务没有加锁,多个实例同时扫到同一批超时订单。解决:用SELECT ... FOR UPDATE或 Redis 分布式锁,保证同一订单只被一个实例处理。另外,释放前再查一次订单状态,如果已经不是待支付就跳过。

5.5 支付回调重复通知导致重复预约

现象:用户支付一次,但订单状态被改了两次,号源被重复占用。原因:支付回调没有做幂等。解决:在回调入口先查订单状态,如果已经是“已预约”就直接返回成功。更新时用WHERE status=0限制,保证只有待支付订单能被更新。

6. 进阶技巧:用状态机 + 对账脚本守住最后一道门

6.1 把订单状态流转写成一张表

与其在代码里到处写if status == 0,不如把允许的流转定义成配置。比如:

ORDER_STATUS_FLOW = { 0: [1, 2, 4], # 待支付 -> 已预约/已取消/已过期 1: [2, 3], # 已预约 -> 已取消/已完成 2: [], # 已取消 终态 3: [], # 已完成 终态 4: [], # 已过期 终态 } def can_transition(from_status, to_status): return to_status in ORDER_STATUS_FLOW.get(from_status, [])

这样每次改状态前先调can_transition,不合法就拒绝。好处是状态机集中管理,新增状态时只改一个地方。

6.2 每天跑一次对账脚本

再好的代码也可能因为异常、超时、人工干预出现数据不一致。我习惯每天凌晨跑一个对账脚本,检查三件事:订单状态为“已预约”但号源状态不是“已约”的记录;号源状态为“已约”但订单状态不是“已预约”的记录;锁定超过 1 小时但订单还是“待支付”的记录。前两类直接告警人工处理,第三类自动释放。

-- 对账查询1:订单已预约但号源不是已约 SELECT o.order_no, o.source_id, s.status AS source_status FROM appointment_order o JOIN schedule_source s ON s.id = o.source_id WHERE o.status = 1 AND s.status != 1; -- 对账查询2:号源已约但订单不是已预约 SELECT s.id AS source_id, o.order_no, o.status AS order_status FROM schedule_source s LEFT JOIN appointment_order o ON o.id = s.order_id WHERE s.status = 1 AND (o.id IS NULL OR o.status != 1);

这两条 SQL 跑出来如果有结果,说明数据已经不一致了。不要等到用户投诉才发现。

6.3 一个我踩过的坑:别在事务里调外部接口

早期我在锁定号源的事务里调了短信接口发验证码,结果短信服务超时导致整个事务回滚,号源没锁上,用户以为预约失败。后来改成:先提交事务,再异步发短信。事务里只做数据库操作,任何外部调用都放到事务外。这个习惯帮我省了很多后悔药。

6.4 验证方法:用 JMeter 模拟 50 个并发抢同一个号

想验证乐观锁是否生效,可以用 JMeter 或 wrk 对锁定接口发 50 个并发请求,看是否只有一个成功。如果成功数大于 1,说明锁没起作用。检查UPDATE ... WHERE version=%s AND status=0是否真的带上了版本号,以及数据库隔离级别是否是REPEATABLE READ。如果用的是 Redis 预扣,检查SREM返回值是否被正确判断。

最后说一个我的习惯:每次改完号源相关逻辑,一定会手动跑一遍“锁定→支付→取消→再锁定”的完整链路,并且查一次对账 SQL。这个习惯比写单元测试还管用,因为很多问题只在状态流转时出现。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询