高并发挂号系统实战:Django+Redis保障号源与支付状态一致
2026/9/10 9:29:10 网站建设 项目流程

简介:基于Django+MySQL+Redis实现的医院挂号系统,以Python语言开发,面向需要快速搭建或学习Web挂号平台的学生、开发人员及课程设计场景。系统完整覆盖患者、医生、管理员三类角色,支持用户注册登录、科室/医生/时段选择、病情填写、支付宝扫码支付、挂号单生成展示,以及医生端处理患者挂号和后台数据增删改查等核心业务。运行前需在支付宝开放平台申请API及公钥私钥,源码附有相应说明。压缩包内共82个文件,约234KB,其中Python源码负责后端逻辑与接口,HTML/CSS及JavaScript构成前端页面与交互,另有图片资源、配置文件和README文档辅助理解与部署。项目结构清晰,Django路由、视图、模型、静态文件和迁移文件一应俱全,便于二次开发。已有2687人浏览学习,适合作为毕业设计或项目实战的参考范式,有助于理解真实业务流与支付集成细节。

1. 医院挂号系统最难的从来不是页面,而是号源和支付的状态一致

医院挂号系统用 Python + Django + MySQL + Redis 组合,最常踩的坑不是页面渲染,而是放号并发、支付回调延迟、待支付订单占着号源不释放。 放号时几十个人同时点挂号,MySQL 行锁让接口延迟到不可用;用户扫了码不付款,号源被占住 30 分钟;支付宝回调晚到几秒,订单状态就出现不一致。 用 Django 管理业务模型,MySQL 记录排班和订单,Redis 做号源预扣和防重锁,再对接支付宝扫码付款,是医院信息科和外包项目里常见的一套落地组合。 下面按表结构、号源扣减、支付验签、超时回补、宝塔部署的顺序把可复现的细节讲完,适合要独立搭起可演示项目的后端工程师。

2. 用 Django 模型把医生、排班和挂号订单落库到 MySQL

挂号系统的数据模型并不复杂,核心是医生、排班、订单三张表。 业务上“放号”就是在排班表里加一条记录,把 total_count 设为当天该时段的号源总量;“挂号”则是创建一条订单,订单指向某个排班实例。 支付状态放在订单表里单独维护,不要放到排班表上,否则每次查号源都要关联支付状态,索引和事务都会变复杂。

2.1 三张核心表:医生、排班、挂号订单

表名关键字段用途
doctorid, name, department, title医生基础信息,科室和职称冗余在这张表
scheduleid, doctor_id, date, period, total_count, remain_count, price一位医生在某天上午或下午的号源池
registration_orderid, order_no, schedule_id, patient_name, patient_card, status, amount, created_at, paid_at每笔挂号订单及支付状态、支付时间

排班表里的 remain_count 是 MySQL 侧的最终库存,真正抗并发的是 Redis;remain_count 在平时只当“底账”用。 订单表里最需要关心的是 status,后面所有超时任务、支付回调、对账程序都在围绕这个字段转。 建表时字符集用 utf8mb4,MySQL 8.0 默认就是这个值;唯一要注意的是订单号字段要加唯一约束,这是防重复创建订单的最后一道屏障。

2.2 在 Django 里创建 app 并编写模型

先用python manage.py startapp registration建出应用,再在registration/models.py里写模型,完整代码如下:

from django.db import models from django.utils import timezone class Doctor(models.Model): name = models.CharField(max_length=64) department = models.CharField(max_length=64) title = models.CharField(max_length=32, blank=True) class Meta: db_table = "doctor" class Schedule(models.Model): doctor = models.ForeignKey(Doctor, on_delete=models.CASCADE) date = models.DateField(db_index=True) period = models.CharField(max_length=16) # 上午 MORNING / 下午 AFTERNOON total_count = models.PositiveIntegerField(default=30) remain_count = models.PositiveIntegerField(default=30) price = models.DecimalField(max_digits=8, decimal_places=2) class Meta: db_table = "schedule" indexes = [ models.Index(fields=["date", "period"], name="idx_date_period"), ] class Order(models.Model): STATUS_CHOICES = [ ("UNPAID", "待支付"), ("PAID", "已支付"), ("CANCELLED", "用户取消"), ("CLOSED", "超时关闭"), ] order_no = models.CharField(max_length=32, unique=True) schedule = models.ForeignKey(Schedule, on_delete=models.PROTECT) patient_name = models.CharField(max_length=64) patient_card = models.CharField(max_length=32, db_index=True) status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="UNPAID") amount = models.DecimalField(max_digits=8, decimal_places=2) created_at = models.DateTimeField(default=timezone.now) paid_at = models.DateTimeField(null=True, blank=True) class Meta: db_table = "registration_order" indexes = [ models.Index(fields=["status", "created_at"], name="idx_status_created"), ]

代码里有几个参数值得单独说明。order_nounique=True,重复创建订单时数据库会抛 IntegrityError,这是防下单接口被并发刷的兜底。on_delete=models.PROTECT保证一个排班还有订单引用时,排班不能被直接删除。 金额用 DecimalField 而不是 FloatField,精度由数据库层保证,支付宝回调对金额比较时不会因为浮点误差出错。 索引方面,date + period组合索引覆盖按天、按半天查排班的场景;status + created_at复合索引是给超时订单扫描准备的,没有这个索引,订单量上去后关闭任务会把表扫成慢查询。

2.3 订单状态机:支付与超时都在改同一个 status

订单状态不能随意改,至少要约定下面几条流转关系:

当前状态可流转到触发条件
UNPAIDPAID支付宝异步通知验签通过,trade_status 为 TRADE_SUCCESS
UNPAIDCLOSED超过 30 分钟未支付,后台关闭任务执行
UNPAIDCANCELLED用户主动取消,且订单未支付
PAIDREFUNDED医院退号退款流程处理完成

一个常见误区是直接用 schedule.remain_count 判断号源是否可用,其实完整链路是 Redis 先扣,MySQL 后记账,订单状态只负责最终一致性。 因此不要写“先 UPDATE schedule SET remain_count=remain_count-1 WHERE remain_count>0,再创建订单”的代码;这种写法在单个请求里没问题,放到放号高峰就是行锁排队。 下一节讲 Redis 预扣是怎么把这条路径拆开的。

3. Redis 预扣号源:把放号并发从 MySQL 行锁里解放出来

挂号系统放号瞬间的并发,比日常页面访问高一个数量级。 如果每次都走 MySQL 的UPDATE ... WHERE remain_count > 0,同一排班这一行会被多个事务锁住,后面请求全部排队。 常见做法是放号时先把库存写到 Redis,利用 Redis 单线程模型保证扣减是原子的,再把最终数据落回 MySQL。 这里用到的是 Redis 数据类型里最简单的 String,key 设计成schedule:stock:{排班 ID},value 就是剩余号数。

3.1 用 Lua 脚本把“查库存 + 减库存”合并成一次原子操作

直接调用 Redis 的 DECR 虽然快,但 DECR 不会告诉你减到负数没有。 如果库存已经是 0,DECR 会把 value 变成 -1,挂号请求拿到负库存也没意义。 所以要在 Lua 脚本里先判断再减:

import redis r = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True) DECR_STOCK = """ local stock = tonumber(redis.call('GET', KEYS[1]) or '0') if stock <= 0 then return -1 end redis.call('DECR', KEYS[1]) return stock - 1 """ def try_lock_schedule(schedule_id: int) -> int: key = f"schedule:stock:{schedule_id}" remain = r.eval(DECR_STOCK, 1, key) return remain

KEYS[1]是调用方传入的 key,不能拿字符串拼进脚本里,这是 Lua 脚本的安全习惯。tonumber把 Redis 返回的字符串转成数字;如果 key 不存在,GET 返回 nil,脚本里用or '0'兜底,相当于把未初始化库存视为 0。 整个脚本在 Redis 单线程里执行,不会有两个请求同时读到同一个 stock 值,这一步就解决了超卖问题。 放号操作本身仍然走 Django 管理命令,执行schedule:stock:{id}对应的 SET 命令,值从排班表的 total_count 同步过来。

Redis 里几个核心 key 的设计可以整理成下面这张表,方便后面排查时对照:

Redis Key数据类型过期时间说明
schedule:stock:{schedule_id}String不设置,放号结束后清理剩余号源,由 Lua 脚本扣减
schedule:lock:{schedule_id}:{patient_card}String10 秒重复挂号防重锁
order:unpaid:{order_no}String1800 秒待支付订单标记,供超时任务和排查使用

3.2 用分布式锁拦截同一患者重复挂号

用户手快点了两次“挂号”,前端按钮禁用只防正常人,挡不住重试请求。 在创建订单前给“患者 + 排班”维度加一把 Redis 锁,没抢到锁的直接提示处理中,比数据库唯一索引更早拦住请求。

lock_key = f"schedule:lock:{schedule_id}:{patient_card}" ok = r.set(lock_key, "1", nx=True, ex=10) if not ok: raise APIException("请勿重复提交,正在处理中") try: stock = try_lock_schedule(schedule_id) if stock < 0: raise APIException("该时段号源已挂完") order = Order.objects.create( order_no=generate_order_no(), schedule_id=schedule_id, patient_name=patient_name, patient_card=patient_card, amount=schedule.price, status="UNPAID", ) except Exception: # 扣减成功但订单创建失败,需要把 Redis 号源放回去 r.incr(f"schedule:stock:{schedule_id}") raise finally: r.delete(lock_key)

nx=True保证只有第一次写入成功,ex=10表示锁 10 秒自动过期,防止代码异常时锁被永久占住。 正常创建订单的耗时通常小于 1 秒,10 秒余量足够;如果后面还要接支付预下单,也建议整个请求控制在 3 秒内。 finally 里的 delete 是主动释放锁,实际项目更严格的做法是给锁 value 存一个随机 token,删除前判断 token 是否属于自己,避免误删其他请求的锁。 如果 Redis 扣了库存但 Order 创建失败,要在 except 里立刻 incr 回补,并打一条错误日志,后面的对账任务会再来纠正一次。

3.3 库存回补只解决“扣多了”,不解决“没扣回来”

上面代码里,下单失败会把 Redis 库存加回去。 但还有另一种情况:Redis 扣减成功后,支付宝回调把订单改成 PAID,订单已经履约,这时 Redis 里的号源和 MySQL 的 remain_count 都不应该再加。 因此回补必须是“给未支付订单”做的操作,不能见到失败就无脑 incr。 订单状态定义成 UNPAID,是为了让超时关闭、用户取消、支付成功三条路径都能复用同一套库存回补逻辑,区别只在触发点不同。 到这里并发扣减的主链路已经讲完,下一节把支付宝扫码付款接进来。

4. 支付宝扫码支付接入:预下单二维码与异步通知验签

支付宝扫码付款在挂号系统里通常对应两种产品形态:当面付里的扫码支付,和电脑网站支付。 挂号页面在手机或大屏上展示二维码时,当面付的alipay.trade.precreate更合适,服务端拿到一个二维码内容串,前端用组件渲染成二维码,用户用支付宝扫一下完成付款。 电脑网站支付返回的是跳转链接,适合 PC 浏览器,扫码场景基本不会用。 下面代码基于 python-alipay-sdk 这个库。

4.1 用预下单接口生成二维码

# pip install python-alipay-sdk from alipay import AliPay ALIPAY_APP_ID = "2021001000000000" APP_PRIVATE_KEY = open("./keys/app_private_key.pem").read() ALIPAY_PUBLIC_KEY = open("./keys/alipay_public_key.pem").read() NOTIFY_URL = "https://his.example.com/alipay/notify/" alipay = AliPay( appid=ALIPAY_APP_ID, app_notify_url=NOTIFY_URL, app_private_key_string=APP_PRIVATE_KEY, alipay_public_key_string=ALIPAY_PUBLIC_KEY, sign_type="RSA2", debug=False, ) response = alipay.api_alipay_trade_precreate( subject=f"挂号费-{doctor_name}", out_trade_no=order.order_no, total_amount=str(order.amount), ) if response.get("code") == "10000": qr_code = response["qr_code"] # 把 qr_code 返回前端,前端用 qrcode 库渲染成二维码 else: raise APIException("支付宝预下单失败")

total_amount传的是元单位的字符串,不是分,str(order.amount)能把 Decimal 转成“30.00”。out_trade_no用我们自己的订单号,支付宝在异步通知里原样回传,正好用来定位订单。 初始化时app_private_key_string填应用私钥,alipay_public_key_string填支付宝公钥,这两把钥匙在开放平台的密钥配置里都能拿到;不要把支付宝公钥填成应用公钥,否则验签时每笔回调都过不了。

4.2 异步通知接口:验签参数必须是 bytes,不是 str

支付成功后,支付宝会向app_notify_url发一个 POST 请求,所有参数以表单形式提交。 回调接口第一件事是验签,签名不对直接拒绝。 很多第一次接入的人会在这一步看到argument should be integer or bytes-like object, not 'str',原因是alipay.verify期望的签名参数是 bytes,而我们直接从 POST 里取到的是 str,所以要把 sign 编码一次。

from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt from django.db import transaction from django.utils import timezone from registration.models import Order @csrf_exempt def alipay_notify(request): if request.method != "POST": return HttpResponse("fail") data = request.POST.dict() sign = data.pop("sign", "") # 支付宝验签需要 bytes,直接用 str 会报 argument should be integer... if not alipay.verify(data, sign.encode("utf-8")): return HttpResponse("fail") out_trade_no = data.get("out_trade_no") trade_status = data.get("trade_status") if trade_status not in ("TRADE_SUCCESS", "TRADE_FINISHED"): return HttpResponse("success") with transaction.atomic(): order = Order.objects.select_for_update().get(order_no=out_trade_no) if order.status == "PAID": return HttpResponse("success") order.status = "PAID" order.paid_at = timezone.now() order.save() return HttpResponse("success")

代码里data.pop("sign", "")先取出签名,剩下的表单字段交给验签函数排序处理。 注意支付宝要求通知接口的响应体必须一字不差是success,返回 JSON 会被判定为失败并反复重试。 业务上只处理TRADE_SUCCESSTRADE_FINISHED,其他状态比如WAIT_BUYER_PAY不用关单,等正式结果出来再处理。 用select_for_update()锁住订单行,同一笔订单并发收到两次回调时只有一个线程能把它改成 PAID,这是幂等更新的最后防线。

提示:支付宝异步通知会重试多次,每次重试间隔递增。 接口必须保证“重复通知不改变结果”,否则就会把一笔订单反复置为已支付。

4.3 回调字段和订单状态对不上怎么办

支付宝异步通知里常用的字段如下:

字段含义业务处理
out_trade_no商户订单号,对应 Order.order_no定位订单
trade_no支付宝交易号记录到订单里,退款时要用
trade_status交易状态只有 TRADE_SUCCESS / TRADE_FINISHED 才置为已支付
total_amount本次支付金额与 Order.amount 比较,不一致要告警
seller_id收款方支付宝账号 PID防止回调串到其他应用

如果订单状态已经是 PAID,再次收到回调直接返回 success,保证重试不会重复执行。 验签通过但 total_amount 对不上时,最稳妥的做法是记录告警并返回 fail,让支付宝继续重试;不要直接把订单置为已支付。 支付回调经常出问题的其实不是签名算法,而是部署后 Nginx 转发把 POST 参数弄丢,或者 Django 的 ALLOWED_HOSTS 没配好,这类问题放到第 6 章一起说。

5. 超时订单自动关闭与号源回补:把待支付状态拉回一致

用户扫了码但不付款,订单会一直占着号源。 支付宝对未支付订单默认关闭时间是 30 分钟,业务侧要在同一时间点把库存放回去。 常见做法是用 Redis 的 TTL 记待支付订单,然后定时任务扫 MySQL 完成关单和回补,而不是依赖 Redis 过期事件去改 MySQL。

5.1 创建订单时顺手写一条 Redis 待支付标记

在创建订单的接口里,Order 保存成功后加一行:

unpaid_key = f"order:unpaid:{order.order_no}" r.set(unpaid_key, str(order.schedule_id), ex=1800)

这个 key 的存活时间跟支付宝交易超时时间保持一致,都是 30 分钟。 key 过期只代表“该扫库了”,不代表订单真的关了;真正的关闭动作在管理命令里做。 不建议用 Redis 的 keyspace 通知监听过期事件来关订单,因为 Redis 开启持久化或集群模式后,过期事件可能延迟甚至丢失,最终还是要靠 MySQL 定时扫描兜底。

5.2 用 Django 管理命令扫描超时订单并回补号源

关闭任务用 Django 管理命令写,放进registration/management/commands/close_expired_orders.py,用系统 cron 每 5 分钟执行一次。

from django.core.management.base import BaseCommand from django.db import transaction from django.db.models import F from django.utils import timezone from registration.models import Order, Schedule import redis r = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True) class Command(BaseCommand): help = "关闭超时未支付订单并回补库存" def handle(self, *args, **options): expire_at = timezone.now() - timezone.timedelta(minutes=30) with transaction.atomic(): orders = Order.objects.select_for_update().filter( status="UNPAID", created_at__lt=expire_at ) for order in orders: if order.status != "UNPAID": continue order.status = "CLOSED" order.save() Schedule.objects.filter( id=order.schedule_id, remain_count__lt=F("total_count"), ).update(remain_count=F("remain_count") + 1) r.incr(f"schedule:stock:{order.schedule_id}")

最值得注意的细节是 MySQL 回补条件remain_count__lt=F("total_count"),这样可以避免异常情况把 remain_count 加到超过 total_count。 Redis 侧直接 incr 就行,因为 Redis 只做预扣,超卖风险已经被 Lua 脚本挡掉。 事务块里的select_for_update()把超时订单全部锁住,避免同一笔订单被支付回调改成 PAID 后又被执行关单。 有些团队会用 MySQL 存储过程做批量关单,但对 Django 项目来说,把逻辑放在管理命令里能直接使用 ORM、Redis 连接和日志,维护成本更低。

回补动作建议打结构化日志,方便后面排查:

日志字段内容示例用途
schedule_id1001定位 Redis key 和排班
order_no202501010001关联订单
actionclose_expired区分是关单还是取消
before_statusUNPAID观察状态跳变

5.3 对账任务:Redis 库存和 MySQL 不一致时以 MySQL 为准

运维一段时间后难免出现 Redis 和 MySQL 库存对不上的情况,比如进程在 Redis 扣减后崩掉、订单创建失败但回补代码没执行,又或者支付回调和超时任务同时并发。 日常验证可以在放号低峰期执行下面的对账逻辑:

def reconcile_schedule(schedule_id: int): schedule = Schedule.objects.get(id=schedule_id) mysql_remain = schedule.remain_count unpaid_count = Order.objects.filter( schedule_id=schedule_id, status="UNPAID" ).count() redis_expected = mysql_remain - unpaid_count redis_key = f"schedule:stock:{schedule_id}" if int(r.get(redis_key) or 0) != redis_expected: r.set(redis_key, redis_expected) print(f"repair stock {schedule_id}: {redis_key} -> {redis_expected}")

对账公式是“Redis 剩余号源 = MySQL 剩余号源 - 待支付订单数”,因为待支付订单已经从 Redis 里扣过号,但还没从 MySQL 的 remain_count 中扣掉,属于合法占用。 这个任务不能在放号高峰跑,否则修 Redis 的同时又有人在下单,越修越乱;一般放在凌晨 2 点到 5 点执行。 到这里业务闭环已经完整了,最后一部分说部署和排错。

6. 宝塔部署 Django 后,支付宝回调调不通的排查顺序

Django 开发环境里runserver能收到支付宝回调,部署到宝塔后收不到,大多不是代码问题,而是入口配置没对。 下面这套排查顺序在多个项目里验证过,按它走能把回调问题控制在十分钟内定位。

6.1 Nginx 到 uWSGI 的配置别让 POST 参数丢在门口

宝塔面板部署 Django 项目时,通常会用 Nginx 加 uWSGI 或 Gunicorn 运行。 支付宝回调是 POST 请求,Nginx 配置里至少要有下面的片段:

location /alipay/notify/ { uwsgi_pass 127.0.0.1:8001; include uwsgi_params; uwsgi_read_timeout 120; }

uwsgi_pass要和 uWSGI 启动参数里的 socket 地址一致,宝塔的 Python 项目管理器默认会生成对应配置。 这里容易踩两个坑:一个是回调路径带斜杠导致跳转,POST 被转成 GET;另一个是client_max_body_size设置太小,表单稍大就返回 413。 支付宝的通知虽是表单字段,大小通常不超 2KB,但保险起见在 http 块里写上client_max_body_size 10m;。 改完配置要执行nginx -t && nginx -s reload,然后看 Django 日志确认请求确实进到了应用。

6.2 用支付宝沙箱和手工构造回调验证接口

正式环境验证回调之前,先在支付宝沙箱里把整条链路走通。 沙箱申请的 appid、应用私钥、支付宝公钥和 RSA2 签名流程跟正式环境一致,唯一区别是钱不是真钱。 配置app_notify_url时要用公网能访问到的地址,Django 的ALLOWED_HOSTS必须包含这个域名或 IP,不然回调会被 Django 拒掉。 如果本地开发不方便暴露回调地址,可以用 Postman 手工往本地接口发一笔trade_status=TRADE_SUCCESS的通知,先验证业务分支能不能把订单改成 PAID;验签那层还得走沙箱真实回调,手工数据没有支付宝私钥签名。 有团队为了省沙箱流程,直接用市面上说的“支付宝模拟器 1:1”调接口,模拟器只能伪造表单结构,伪造不了支付宝私钥签名,模拟器能通不代表线上能通。

6.3 命令行快速核对 Redis 中的号源与待支付标记

回调调不通还有一个常见原因:订单状态确实改了,但接口返回的不是支付宝要求的 success,导致支付宝在十几秒内不断重试。 排查时可以一边看 Django 日志,一边用 Redis 命令行确认当前状态。

redis-cli GET schedule:stock:1001 redis-cli KEYS "order:unpaid:*" | xargs -I {} redis-cli TTL {}

第一条命令查排班 ID 为 1001 的剩余号源,第二条命令列出所有待支付订单的 TTL。 想可视化地看 key 的剩余时间和 value,可以用 Redis Desktop Manager 这类客户端,连上 6379 端口后按前缀搜索order:unpaid:*,直接看到哪些订单还压在缓存里。 日常排查时给回调接口加一行结构化日志,把原始 body、sign 前 20 位、验签结果、订单原状态都打出来,线上出问题先看这条日志,比反复断点调试管用。 在麒麟这类国产 Linux 系统上部署时,如果 Python 是源码编译安装,启动前要确认 openssl-devel 已经装好,否则支付宝 SDK 使用的加密库会因缺少 SSL 模块在验签时报错,这一步卡住的人不在少数。

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

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

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

立即咨询