ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Django医院挂号系统改造:ORM设计、事务行锁与并发压测实践

Django医院挂号系统改造:ORM设计、事务行锁与并发压测实践 简介基于PythonDjango的医院挂号系统是一份面向毕业设计场景的完整 Web 项目源码适合计算机专业学生、Django 初学者以及需要快速搭建预约挂号平台的学习者解决患者在线挂号、科室与医生选择、用户登录注册等医疗信息化基础需求。压缩包共 73 个文件整体仅 217KB以 Python 源码、HTML/CSS/JS 前端文件、PNG 图片和 Django 配置文档为主其中 Python 文件用于后端逻辑HTML/CSS/JS 负责页面展示与交互PNG 提供界面素材。工程按 Django 标准项目结构组织包含 manage.py、settings.py、urls.py、asgi.py/wsgi.py 以及 hospital 应用下的 models、views、admin、migrations 等核心模块另有 requirements.txt 依赖清单、README.md 说明文件和 static/templates 等前端资源目录从环境配置到页面渲染链路完整。通过阅读和改造这份源码可深入理解 Django 的 MVT 分层、ORM 数据库映射、用户认证与会话管理以及科室展示、医生排班、预约登记、患者登录、医生登录等页面模块的视图与路由写法项目将业务按应用和模板拆分便于替换页面样式或扩展新功能适合作为课程设计或毕业设计的基础工程。已有 131 人学习/下载是快速上手 Django Web 开发的实用参考。1. 拿到 zip 只是第一步真正值钱的是挂号逻辑这类基于 pythonDjango 的医院挂号系统源码包解压之后几乎都是同一个模子models.py 里躺着一堆表定义views.py 里是增删改查templates 里是几个 bootstrap 页面跑起来能注册、能登录、能“挂号”。可一旦把并发、状态、号源释放这些真实业务问题抛过去这个系统立刻露馅同秒两个患者抢最后一个号两条记录都写进去了患者挂完号过十分钟取消号源没有回写医生停诊已挂号的患者没有任何通知机制。这篇文章就把这套东西按工程标准拆开重做一遍从 Django 的模型设计讲起落到事务与行锁再把环境跑通、后台配置好最后讲清预约状态机与压测。适合刚拿到源码包想改造成自己能说的项目的开发也适合面试前把“挂号系统”这事儿想明白的老手。2. Django ORM 与挂号模型设计排班先行预约表留唯一约束2.1 挂号系统的底层不是“一张表”而是一次排班的时段拆解绝大多数 zip 里的表设计是这样的Hospital、Department、Doctor再连一张 Appointment患者挂号就是在 Appointment 里插入一条记录选中的是“某医生某天”。这能用但它没有回答一个关键问题号源数量从哪来怎么判断还有没有号工程上常见做法是先有一张排班表 Schedule表示“某医生某天某时段出诊放号 N 个”。患者挂号的不是医生整天的可用时间而是这个排班里的某一个“号位”。所以还要有 Slot 表把排班拆成一号、二号、三号直到 N 号。预约表再跟 Slot 一对一关联。from django.db import models class Department(models.Model): name models.CharField(科室名, max_length64, uniqueTrue) class Doctor(models.Model): name models.CharField(医生姓名, max_length32) department models.ForeignKey(Department, on_deletemodels.PROTECT) title models.CharField(职称, max_length16, blankTrue) class Schedule(models.Model): doctor models.ForeignKey(Doctor, on_deletemodels.CASCADE) work_date models.DateField(出诊日期) start_time models.TimeField(开始) end_time models.TimeField(结束) total_slots models.PositiveIntegerField(总号源, default20) left_slots models.PositiveIntegerField(剩余号源, default20) class Meta: unique_together ((doctor, work_date, start_time),)这段代码里最值得说的是 left_slots。它是个冗余计数专门为“挂号时快速判断有没有号”设计的属于反范式。反范式字段在 ORM 里用起来顺手代价是它与 Appointment 表的真实数据可能不一致——取消挂号、停诊改签、超时未支付任何一个环节漏了更新left_slots 就成了摆设。设计模型时就要心里有数这个字段不是“存出来的”是“维护出来的”。2.2 预约主表状态字段加唯一约束数据完整性靠数据库兜底预约表是整套系统的状态中心它不只是“谁在什么时候挂了谁的号”它还要记录这笔预约当前处于什么阶段以及这个阶段什么时候过期。class Appointment(models.Model): STATUS_PENDING PENDING STATUS_PAID PAID STATUS_CANCELLED CANCELLED STATUS_DONE DONE STATUS_CHOICES [ (STATUS_PENDING, 待支付), (STATUS_PAID, 已支付), (STATUS_CANCELLED, 已取消), (STATUS_DONE, 已完成), ] patient models.ForeignKey(Patient, on_deletemodels.CASCADE) slot models.ForeignKey(Slot, on_deletemodels.CASCADE) status models.CharField(状态, max_length16, choicesSTATUS_CHOICES, defaultSTATUS_PENDING) pay_deadline models.DateTimeField(支付截止时间, nullTrue, blankTrue) created_at models.DateTimeField(auto_now_addTrue) class Meta: unique_together ((slot, status),)注意这里的 unique_together。很多实现只对 slot 做唯一约束那就意味着一旦取消预约原来的 slot 条目还要留着否则患者无法重新挂这个号。真正常用的做法是给 slot 加一个状态可用、被占、取消释放。或者更简单唯一约束放在 slot 本身取消失活记录可以删掉或者标记。具体取舍看业务但无论怎么选数据库约束必须是最后一道防线——它不依赖代码有没有写错只要 Duplicate entry 抛出来说明上层逻辑一定有漏洞。2.3 先别急着给患者写前台Admin 才是管理端的第一版从这套系统里拿到 zip 的直接收益是 Django Admin。Django Admin 的 list_display、search_fields、list_filter 三个配置能在一小时内把“科室管理、医生排班、号源查看、预约记录检索”全部做完这比从零写一套后台管理要快得多。挂号大厅的前台页面可以后面用 Django Templates 或前后端分离重写但管理侧先跑通业务才能运转起来。from django.contrib import admin from .models import Schedule, Doctor, Department, Appointment admin.register(Schedule) class ScheduleAdmin(admin.ModelAdmin): list_display (doctor, work_date, start_time, end_time, total_slots, left_slots) list_filter (work_date,) search_fields (doctor__name,)search_fields 里写 doctor__name 就是 Django 的跨表查询语法顺着外键一直点进去。list_filter 对日期字段尤其好用排班管理后台每天只看当天的排班。这套组合是医院挂号后台最常用的三个列表属性值得在 Admin 里优先配上。2.4 表结构速查四张核心表的设计要点表关键字段唯一约束说明Schedulework_date, start_time, end_time, left_slots(doctor, work_date, start_time)排班粒度是“半天”不是整天Slotschedule, seq, time_range(schedule, seq)把排班拆成可抢占的最小单位Appointmentslot, patient, status, pay_deadline(slot) 唯一或业务规则唯一状态机的载体Patientuser, name, id_carduser 唯一与 django.auth.User 关联排班粒度不要做成“整天”医生上午和下午的出诊是两段排班全天排班会让号源计算和停诊处理都变麻烦。Slot 表里 seq 是号位序号time_range 可以存“08:00-08:10”之类的时间窗也可以为 null让患者自己约“上午第三号”。3. 挂号核心逻辑事务加锁防超卖也防重复3.1 先看最典型的“天真实现”为什么错很多 zip 源码包里的挂号逻辑长这样def naive_book(schedule_id, patient_id): schedule Schedule.objects.get(idschedule_id) if schedule.left_slots 0: raise ValueError(号源已满) schedule.left_slots - 1 schedule.save() Appointment.objects.create( scheduleschedule, patient_idpatient_id, statusAppointment.STATUS_PENDING, )这段代码在单用户测的时候永远正确打开页面看到余量 3点挂号余量变 2。问题出在两个请求同时执行 Schedule.objects.get(id...)。两个进程都读到 left_slots1都认为“还有号”都执行减一然后都写回最终 left_slots 变成 0但 Appointment 里多了两条号源从 1 变“超卖”。这不是 Django 特有的问题是任何 ORM 应用层做“先读后写”都会踩的经典竞态。3.2 用 select_for_update 做悲观锁把判断和扣减放进同一事务正确做法是先锁住这一行再在锁内检查余量和插入预约整个操作在一个数据库事务里。from django.db import transaction transaction.atomic def book_slot(schedule_id, patient_id): schedule Schedule.objects.select_for_update().get(idschedule_id) if schedule.left_slots 0: raise ValueError(号源已满: %s % schedule_id) schedule.left_slots - 1 schedule.save(update_fields[left_slots]) appointment Appointment.objects.create( slotget_available_slot(schedule), patient_idpatient_id, statusAppointment.STATUS_PENDING, pay_deadlinetimezone.now() timedelta(minutes15), ) return appointmentselect_for_update 在事务内对 schedule 这一行加了排他锁第二个请求在进入这条 SQL 时会一直等到第一个事务提交。等它拿到锁left_slots 已经被扣减过它读到的就是 0抛出“号源已满”。这个方案的理解成本最低也非常契合 Django 项目MySQL InnoDB 对索引行加锁这里的锁是行级锁不是锁表并发吞吐依然可观。注意两个细节一是必须在事务内函数没有 transaction.atomic 时锁没有意义二是 save 时用 update_fields 只更新 left_slots避免 Django 全字段更新把别的并发修改覆盖掉。还可以在 Appointment 插入时捕获 django.db.IntegrityError 做兜底一旦唯一约束触发 Duplicate说明预约重复直接交给上层返回业务错误。3.3 乐观锁不锁行用版本号解决“读旧写新”悲观锁在数据库压力大、事务时间长时会出现比较明显的排队。如果你经历过抢号高峰会发现 select_for_update 方案下应用服务器的空闲线程唾手可得但数据库的连接全挂在等待锁上。乐观锁的思路是不加锁更新时带上条件靠 UPDATE 影响行数判断抢没抢到。from django.db.models import F def book_slot_optimistic(schedule_id, patient_id): for attempt in range(3): schedule Schedule.objects.get(idschedule_id) if schedule.left_slots 0: raise ValueError(号源已满) updated Schedule.objects.filter( idschedule_id, left_slots__gt0, versionschedule.version, ).update( left_slotsF(left_slots) - 1, versionF(version) 1, ) if updated 0: continue # 有人抢先了重试 appointment Appointment.objects.create(...) return appointment raise ValueError(系统繁忙请重试)这里 version 字段每次更新加 1更新条件里带上“左余量大于 0”和“版本号等于我读到的版本”。如果两条并发请求同时读到 version5只有第一条的 UPDATE 能成功version 变成 6第二条的 UPDATE 影响行数为 0进入重试。乐观锁的优点是没有数据库行锁的排队等待适合跨服务调用场景缺点是需要重试机制写代码稍微绕一点。在这个挂号系统里我一般首选悲观锁因为它的语义直白、不容易写错Django 原生支持。3.4 防重复挂号的业务判定先查一次再兜底约束代码层面还要防止同一患者重复挂同一时段。即使 Slot 是唯一预定单位也架不住用户开两个标签页对同一个 Slot 发起请求。标准的处理方式是把患者维度的重复判定放进事务内if Appointment.objects.filter( patient_idpatient_id, status__in[Appointment.STATUS_PENDING, Appointment.STATUS_PAID], slot__schedule__work_dateschedule.work_date, ).exists(): raise ValueError(您已挂过这个时段的号)这个查询发生在 select_for_update 锁住 schedule 行之后两个并发请求会串行化第二个人进去时第一条的记录已经提交exists 就能命中。要注意 status__in 的写法查询条件是“未取消的预约”因为一个患者挂了号又取消重新挂是合理行为不能算重复。到这里挂号主流程的可靠性就立住了行锁保证号源不超卖唯一约束兜底重复写入事务内业务判断覆盖“已挂过号”的边界。4. 把 zip 包在本地跑通环境、迁移与后台4.1 Python 环境与依赖安装mysqlclient 的坑排掉拿到 zip 包第一步是建虚拟环境不要图省事直接 pip install -r requirements.txt 装到全局。习惯用 python -m venv 隔离版本对齐 requirements.txt 里给的版本即可。python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -r requirements.txt最常见的坑在 mysqlclient。Windows 上这包经常编不过需要提前装 Visual C Build ToolsLinux 上是缺 mysqlclient-dev 系统库sudo apt update sudo apt install default-libmysqlclient-dev pip install mysqlclient装完验证python -c import MySQLdb 不报错就行。项目里 settings.py 的数据库配置看好zip 包默认给的可能是 sqlite3如果文档里说“支持 MySQL”记得把 ENGINE、NAME、USER、PASSWORD 四项改对。4.2 migrate 前的“模型审计”和迁移生成python manage.py makemigrations python manage.py migrate python manage.py createsuperuser迁移前先做一次模型审计目的不是查语法而是查数据库层约束是否符合前两节的假设每个表有没有 unique_together、Appointment 的 status 字段有没有 default、Slot 与 Schedule 外键的 on_delete 是什么。zip 包里的代码经常不写 on_deleteDjango 2.0 之后默认是 CASCADE如果删排班记录会把预约全部级联删掉这在医院场景是事故。建议把 Schedule 对 Doctor、Appointment 对 Slot 都改成 PROTECT 或者 SET_NULL。4.3 用 Django Admin 做后台管理三个属性配置好python manage.py runserver打开 http://127.0.0.1:8000/admin 用刚才的超级账号登录。重点配置三个 ModelAdmin 属性list_display 决定列表页显示哪些列list_filter 加日期和科室两个筛选项search_fields 加患者姓名和医生姓名的跨表搜索。设置好后运营人员可以完成 90% 的日常操作录科室、建医生、排班、查预约。在前台挂号页面上zip 里通常是一套 Bootstrap 的 HTML 模板只做展示和表单提交。这时候不用急着改版式把“挂号成功 / 号源已满 / 请先登录”这三个提示分支写清楚患者体验就到位了。挂号页面提交后进入第 3 章的 book_slot 接口返回 JSON 或重定向都行。4.4 部署时把 runserver 换掉gunicorn 加 Nginx本地 runserver 只适合开发调试。部署到服务器时常见做法是 Nginx 加 gunicorn 加 Django。用 gunicorn 时一条命令就能接替 runserverpip install gunicorn gunicorn hospital.wsgi:application -b 0.0.0.0:8000 -w 4-w 4 表示 4 个 worker 进程。注意不要开太多 workerworker 数与数据库连接数直接相关MySQL 默认连接上限 151gunicorn 每 worker 一个连接已经要算好数据库服务器上别让外部连接占满。Nginx 里配一个 location /static/ 指向 Django 的 STATIC_ROOT剩下 location / 反代到 8000 端口。需要上生产环境的话可以配合宝塔面板做可视化反代和进程守护省去手改 systemd 的时间。5. 预约状态机、超时释放与并发压测验证5.1 四态状态机每个状态转移都要有出口预约的四种状态不是随便枚举的待支付到已支付到已完成是一条主线任何状态都可以进“已取消”。代码里最常见的错误是状态字段随便改没有校验转移合法性。Django 里可以用一个函数收口def transition(appointment, to_status): allowed { Appointment.STATUS_PENDING: {Appointment.STATUS_PAID, Appointment.STATUS_CANCELLED}, Appointment.STATUS_PAID: {Appointment.STATUS_DONE, Appointment.STATUS_CANCELLED}, Appointment.STATUS_CANCELLED: set(), Appointment.STATUS_DONE: set(), } if to_status not in allowed.get(appointment.status, set()): raise ValueError(f非法状态转移: {appointment.status} - {to_status}) appointment.status to_status appointment.save(update_fields[status])一个“已取消”的预约不能重新变回“待支付”一个“已完成”的不能回到“已支付”这些规则应该在业务层拦截而不是把 choices 暴露给视图随便改。取消预约时要回写号源。常见做法不是把 left_slots 加一而是让这个 Slot 重新变成可用状态。用 Django 的 delete 语义时要注意外键Slot 被删掉之后 Appointment 的关联也会出问题所以一般用状态字段翻转而不是物理删除。5.2 超时未支付的号源释放定时清理任务待支付订单最怕“占着号不放”。15 分钟支付截止是一个业务经验值过期的预约要自动取消并释放号源。最简单的方式是每五分钟跑一次定时清理from datetime import timedelta from django.utils import timezone deadline timezone.now() - timedelta(minutes15) expired Appointment.objects.filter( statusAppointment.STATUS_PENDING, created_at__ltdeadline, ) for apt in expired: apt.status Appointment.STATUS_CANCELLED apt.save(update_fields[status]) apt.slot.release() # Slot 状态置为可约项目里如果有 Celery就挂到 beat 的定时任务没有 Celerycron 里跑 Django manage.py 的 custom command 也行。核心是“释放”这个动作必须和“预约取消”在同一个事务里否则会出现号源已释放但预约还是待支付的中间态。5.3 并发压测怎么验证ab 命令与结果解读代码写完不是结束得先证明自己扛得住。压测最简单有效的工具是 Apache abab -n 500 -c 50 http://127.0.0.1:8000/api/book/3/-n 500 是总请求数-c 50 是并发数。观察两个指标Failed requests 必须是 0如果出现 Failed先看是不是数据库连接数打满再对比 Time per request50 并发下如果超过 500ms说明事务写得太慢先查有没有缺索引再考虑把 select_for_update 的锁粒度进一步缩小。也可以在脚本里并发请求后查数据库左余量做最终断言SELECT left_slots FROM schedule WHERE id 3;压测结束时 left_slots 应该等于初始值减去成功预约数多减就是超卖少减就是漏释放。最后说一个很容易被忽略的运维细节医院挂号场景的号源释放与对账归根结底不在代码里而在“支付回调”和“数据库事务”的一致性上。所有状态变更尽量做在同一事务所有支付结果以支付平台回调为准Django 侧只认回调落地后的最终态。这套项目做到了这一点再配合压测验证才算真正从 zip 源码包变成了能上线说话的挂号系统。本文还有配套的精品资源点击获取
返回列表