资讯详情

高并发挂号系统实战:Django+Redis保障号源与支付状态一致

📅 2026/9/10 9:29:10 | 华诺云谱 👁 阅读
高并发挂号系统实战:Django+Redis保障号源与支付状态一致
简介基于DjangoMySQLRedis实现的医院挂号系统以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 侧的最终库存真正抗并发的是 Redisremain_count 在平时只当“底账”用。 订单表里最需要关心的是 status后面所有超时任务、支付回调、对账程序都在围绕这个字段转。 建表时字符集用 utf8mb4MySQL 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_length64) department models.CharField(max_length64) title models.CharField(max_length32, blankTrue) class Meta: db_table doctor class Schedule(models.Model): doctor models.ForeignKey(Doctor, on_deletemodels.CASCADE) date models.DateField(db_indexTrue) period models.CharField(max_length16) # 上午 MORNING / 下午 AFTERNOON total_count models.PositiveIntegerField(default30) remain_count models.PositiveIntegerField(default30) price models.DecimalField(max_digits8, decimal_places2) class Meta: db_table schedule indexes [ models.Index(fields[date, period], nameidx_date_period), ] class Order(models.Model): STATUS_CHOICES [ (UNPAID, 待支付), (PAID, 已支付), (CANCELLED, 用户取消), (CLOSED, 超时关闭), ] order_no models.CharField(max_length32, uniqueTrue) schedule models.ForeignKey(Schedule, on_deletemodels.PROTECT) patient_name models.CharField(max_length64) patient_card models.CharField(max_length32, db_indexTrue) status models.CharField(max_length16, choicesSTATUS_CHOICES, defaultUNPAID) amount models.DecimalField(max_digits8, decimal_places2) created_at models.DateTimeField(defaulttimezone.now) paid_at models.DateTimeField(nullTrue, blankTrue) class Meta: db_table registration_order indexes [ models.Index(fields[status, created_at], nameidx_status_created), ]代码里有几个参数值得单独说明。order_no用uniqueTrue重复创建订单时数据库会抛 IntegrityError这是防下单接口被并发刷的兜底。on_deletemodels.PROTECT保证一个排班还有订单引用时排班不能被直接删除。 金额用 DecimalField 而不是 FloatField精度由数据库层保证支付宝回调对金额比较时不会因为浮点误差出错。 索引方面date period组合索引覆盖按天、按半天查排班的场景status created_at复合索引是给超时订单扫描准备的没有这个索引订单量上去后关闭任务会把表扫成慢查询。2.3 订单状态机支付与超时都在改同一个 status订单状态不能随意改至少要约定下面几条流转关系当前状态可流转到触发条件UNPAIDPAID支付宝异步通知验签通过trade_status 为 TRADE_SUCCESSUNPAIDCLOSED超过 30 分钟未支付后台关闭任务执行UNPAIDCANCELLED用户主动取消且订单未支付PAIDREFUNDED医院退号退款流程处理完成一个常见误区是直接用 schedule.remain_count 判断号源是否可用其实完整链路是 Redis 先扣MySQL 后记账订单状态只负责最终一致性。 因此不要写“先 UPDATE schedule SET remain_countremain_count-1 WHERE remain_count0再创建订单”的代码这种写法在单个请求里没问题放到放号高峰就是行锁排队。 下一节讲 Redis 预扣是怎么把这条路径拆开的。3. Redis 预扣号源把放号并发从 MySQL 行锁里解放出来挂号系统放号瞬间的并发比日常页面访问高一个数量级。 如果每次都走 MySQL 的UPDATE ... WHERE remain_count 0同一排班这一行会被多个事务锁住后面请求全部排队。 常见做法是放号时先把库存写到 Redis利用 Redis 单线程模型保证扣减是原子的再把最终数据落回 MySQL。 这里用到的是 Redis 数据类型里最简单的 Stringkey 设计成schedule:stock:{排班 ID}value 就是剩余号数。3.1 用 Lua 脚本把“查库存 减库存”合并成一次原子操作直接调用 Redis 的 DECR 虽然快但 DECR 不会告诉你减到负数没有。 如果库存已经是 0DECR 会把 value 变成 -1挂号请求拿到负库存也没意义。 所以要在 Lua 脚本里先判断再减import redis r redis.Redis(host127.0.0.1, port6379, db0, decode_responsesTrue) 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 fschedule:stock:{schedule_id} remain r.eval(DECR_STOCK, 1, key) return remainKEYS[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 fschedule:lock:{schedule_id}:{patient_card} ok r.set(lock_key, 1, nxTrue, ex10) if not ok: raise APIException(请勿重复提交正在处理中) try: stock try_lock_schedule(schedule_id) if stock 0: raise APIException(该时段号源已挂完) order Order.objects.create( order_nogenerate_order_no(), schedule_idschedule_id, patient_namepatient_name, patient_cardpatient_card, amountschedule.price, statusUNPAID, ) except Exception: # 扣减成功但订单创建失败需要把 Redis 号源放回去 r.incr(fschedule:stock:{schedule_id}) raise finally: r.delete(lock_key)nxTrue保证只有第一次写入成功ex10表示锁 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( appidALIPAY_APP_ID, app_notify_urlNOTIFY_URL, app_private_key_stringAPP_PRIVATE_KEY, alipay_public_key_stringALIPAY_PUBLIC_KEY, sign_typeRSA2, debugFalse, ) response alipay.api_alipay_trade_precreate( subjectf挂号费-{doctor_name}, out_trade_noorder.order_no, total_amountstr(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_noout_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_SUCCESS和TRADE_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 forder:unpaid:{order.order_no} r.set(unpaid_key, str(order.schedule_id), ex1800)这个 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(host127.0.0.1, port6379, db0, decode_responsesTrue) class Command(BaseCommand): help 关闭超时未支付订单并回补库存 def handle(self, *args, **options): expire_at timezone.now() - timezone.timedelta(minutes30) with transaction.atomic(): orders Order.objects.select_for_update().filter( statusUNPAID, created_at__ltexpire_at ) for order in orders: if order.status ! UNPAID: continue order.status CLOSED order.save() Schedule.objects.filter( idorder.schedule_id, remain_count__ltF(total_count), ).update(remain_countF(remain_count) 1) r.incr(fschedule:stock:{order.schedule_id})最值得注意的细节是 MySQL 回补条件remain_count__ltF(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(idschedule_id) mysql_remain schedule.remain_count unpaid_count Order.objects.filter( schedule_idschedule_id, statusUNPAID ).count() redis_expected mysql_remain - unpaid_count redis_key fschedule:stock:{schedule_id} if int(r.get(redis_key) or 0) ! redis_expected: r.set(redis_key, redis_expected) print(frepair 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_statusTRADE_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 模块在验签时报错这一步卡住的人不在少数。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。