十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

医院挂号系统实战:Django+MySQL+Redis四层架构详解

医院挂号系统实战:Django+MySQL+Redis四层架构详解 简介一套基于Python Django框架、搭配MySQL与Redis的医院挂号系统源码面向正在学习Web开发的学生、初级开发者及需要快速搭建课设或毕设项目的群体。系统完整实现患者端注册登录、按科室/医生/时间挂号、填写病情、支付宝支付、挂号单展示、医生端登录并处理挂号单以及管理员后台的数据增删查改覆盖典型预约挂号业务全流程。压缩包共收录73个文件以Python源码为核心21个py同时包含前端页面模板13个html、样式文件14个css、交互脚本3个js、项目配置与数据迁移文件等整体大小223KB目录结构按Django项目规范组织便于直接运行与二次开发。已有1001人学习下载。借助这份源码读者可深入理解Django MTV架构、ORM模型设计、Redis缓存应用、支付接口集成思路以及多角色权限控制等关键知识点文件中的静态资源与媒体目录配置也提供了完整的前后端协作参考适合作为课程设计或实战训练的基础工程。1. 为什么医院挂号要 Python、Django、MySQL、Redis 四层配合早上八点的三甲医院放号场景同一秒几百个请求冲进来抢同一个专家号源。MySQL 的行锁在那一刻会迅速堆积接口响应从几十毫秒变成十几秒Django 的数据库连接池先被打满随后整个服务开始超时重试。挂号系统的难点从来不是增删改查而是把号源扣减在这个峰值并发窗口里做成原子操作。市面上的医院挂号系统源码包基本采用同一套技术组合Python 写业务逻辑Django 做 Web 框架和 ORMMySQL 存科室、医生、号源和预约单Redis 负责查询缓存和号源的并发扣减。下面按这个组合里最常见的实现思路把数据建模、Redis 原子扣减、MySQL 慢查询优化和部署验证四块逐一讲清楚适合正在做 Django 项目、想完整复现挂号闭环的后端开发。2. Django MySQL先把挂号业务的数据模型立住挂号系统的业务流本身不复杂用户选科室、看医生、看某天的剩余号确认后生成一条预约记录。真正的复杂度集中在“剩余号数”会被高频修改每修改一次都必须和预约单的创建在同一个事务边界内完成。我习惯先把 MySQL 表结构草拟出来再倒过去写 Django Model因为 DDL 阶段就能把索引、唯一键、字符集这类问题暴露出来。2.1 建库建表挂号最小数据集的 MySQL 字段设计一个最小可跑的挂号系统至少需要四张表dept 科室表、doctor 医生表、schedule 号源表、appointment 预约表。下面是起步阶段的建表语句。CREATE DATABASE IF NOT EXISTS hospital DEFAULT CHARACTER SET utf8mb4; CREATE TABLE dept ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, intro TEXT ) ENGINEInnoDB; CREATE TABLE doctor ( id INT PRIMARY KEY AUTO_INCREMENT, dept_id INT NOT NULL, name VARCHAR(50) NOT NULL, title VARCHAR(30), INDEX idx_dept (dept_id) ) ENGINEInnoDB; CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id INT NOT NULL, work_date DATE NOT NULL, total_num INT NOT NULL DEFAULT 20, remain_num INT NOT NULL DEFAULT 20, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_doctor_date (doctor_id, work_date), INDEX idx_remain (remain_num) ) ENGINEInnoDB; CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, schedule_id BIGINT NOT NULL, appoint_no VARCHAR(32) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0已预约 1已取消 2已完成, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_schedule (schedule_id), INDEX idx_user (user_id), UNIQUE KEY uk_appoint_no (appoint_no) ) ENGINEInnoDB;这段 DDL 里有三个设计点要解释清楚。第一个是 schedule 表上的 uk_doctor_date 联合唯一键它保证同一医生同一天只存在一行号源记录这是防止超卖的第一道数据库约束。第二个是 version 字段它不做业务展示只用来做乐观锁计数扣减号源时 where 条件带上 version 可以避免脏写。第三个是 appointment.appoint_no 的唯一键这个编号接管了幂等控制同一个下单请求重复提交时数据库层直接报唯一键冲突业务代码捕获异常即可。字符集统一用 utf8mb4否则用户姓名里的生僻字会直接写入失败。另外doctor 表没有建物理外键而是用普通索引关联 dept因为 InnoDB 的物理外键在并发写入时会扩大锁范围生产环境通常用应用层保证关联正确性表结构只保留逻辑关联。表名核心字段索引/约束在挂号链路上的作用deptid, name, intro主键 id科室字典列表页筛选依据doctorid, dept_id, name, title索引 dept_id医生档案关联排班scheduleid, doctor_id, work_date, total_num, remain_num, version唯一(doctor_id, work_date)普通索引 remain_num每日号源与剩余号appointmentid, user_id, schedule_id, appoint_no, status, created_at唯一 appoint_no索引 schedule_id/user_id用户订单与退号状态2.2 用 Django Model 定义科室、医生、号源和预约单对应上面的 MySQL 表models.py 可以这样写。这里没有做任何自定义管理器字段名、类型和 DDL 保持一一对应。from django.db import models class Dept(models.Model): name models.CharField(max_length50) intro models.TextField(blankTrue) class Doctor(models.Model): dept models.ForeignKey(Dept, on_deletemodels.CASCADE) name models.CharField(max_length50) title models.CharField(max_length30, blankTrue) class Schedule(models.Model): doctor models.ForeignKey(Doctor, on_deletemodels.CASCADE) work_date models.DateField() total_num models.IntegerField(default20) remain_num models.IntegerField(default20) version models.IntegerField(default0) class Meta: constraints [ models.UniqueConstraint( fields[doctor, work_date], nameuk_doctor_date ) ] class Appointment(models.Model): STATUS_CHOICES ( (0, 已预约), (1, 已取消), (2, 已完成), ) user models.ForeignKey(auth.User, on_deletemodels.CASCADE) schedule models.ForeignKey(Schedule, on_deletemodels.CASCADE) appoint_no models.CharField(max_length32, uniqueTrue) status models.SmallIntegerField(choicesSTATUS_CHOICES, default0) created_at models.DateTimeField(auto_now_addTrue)Model 里的 constraints 数组对应 DDL 的 uk_doctor_dateappoint_no 的 uniqueTrue 对应 uk_appoint_no。Django 的 ForeignKey 默认会建物理外键如果 DBA 不允许物理外键可以在迁移时手动设置 db_constraintFalse只保留普通索引。status 字段用 SmallIntegerField 加 choices不要存中文枚举否则过滤和排序都要走字符比较性能和可读性都差。user 字段直接关联 Django 自带 auth.User省去单独建用户表的工作后续对接 JWT 或 session 都很方便。2.3 号源扣减的事务边界哪些逻辑必须在一个原子操作里项目里最常见的错误是把“判断剩余号数”和“插入预约单”拆到两个视图里。第一步查了 remain_num第二步插入订单前另一个请求已经把号扣走于是出现超卖。事务边界必须把两步焊在一起并用 select_for_update 锁住 schedule 行。from django.db import transaction transaction.atomic def create_appointment(user, schedule_id): schedule Schedule.objects.select_for_update().get(pkschedule_id) if schedule.remain_num 0: raise ValueError(号源已约满) schedule.remain_num - 1 schedule.save(update_fields[remain_num]) return Appointment.objects.create( useruser, scheduleschedule, appoint_nogenerate_appoint_no(), )select_for_update 在 InnoDB 下走主键行锁同一行记录上的并发请求会排队执行事务提交后释放。这个方案能保证正确但吞吐量有限放号高峰会把 MySQL 的行锁队列拉得很长。后续要配合 Redis 把大多数请求在数据库外面先拦一道。这里还要注意事务函数内部不要调第三方接口短信、推送这些动作要放到事务提交之后通过消息队列异步消费把 MySQL 事务的停留时间控制在十毫秒以内是整个写路径的优化目标。3. Redis 接管号源库存从缓存到原子扣减的完整做法挂号系统的流量特征是“读多写少但写集中在放号瞬间”。如果让所有请求都打到 MySQL数据库连接会被先打满。Redis 在这种架构里承担两层角色读取端的查询缓存写入端的号源原子扣减。下面这套做法在源码包里最常见也是面试里最常被追问的落地细节。3.1 号源查询先走 Redis缓存 Key 设计与穿透预防挂号系统里最高频的接口是“某医生某天还剩多少号”这个查询完全可以被 Redis 挡住。它用到的 Redis 数据类型是 StringKey 格式我统一写成schedule:remain:{schedule_id}。查询逻辑分两步先读缓存缓存 miss 才回源 MySQL 并回填。这一步最需要防的是缓存穿透也就是大量请求带着不存在的 schedule_id 打过来Redis 没数据MySQL 被拖死。import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) def get_remain_num(schedule_id): cache_key fschedule:remain:{schedule_id} val r.get(cache_key) if val is not None: return int(val) from app.models import Schedule try: s Schedule.objects.get(pkschedule_id) # 回填缓存并设置 TTLnxTrue 防止并发回源互相覆盖 r.set(cache_key, str(s.remain_num), ex60, nxTrue) return s.remain_num except Schedule.DoesNotExist: # 空值缓存 10 秒挡住不存在 ID 的重复穿透 r.set(cache_key, -1, ex10, nxTrue) return -1这里三个参数很关键。ex60 表示 60 秒过期窗口期内 MySQL 最多被回源一次nxTrue 是 setnx只在 Key 不存在时写入避免多个线程同时回源后互相覆盖空值缓存把不存在的 ID 也缓存 10 秒调用方拿到 -1 后直接提示“号源不存在”。TTL 的长短要跟着后台排班修改频率走如果运营后台允许临时改号源数TTL 缩到 20 到 30 秒更保险。Redis 操作命令/脚本使用场景读剩余号GET schedule:remain:1001列表页展示号源原子扣减EVAL DECR_SCRIPT下单时预占号源回补号源EVAL INCR_SCRIPT取消订单时释放直接失效DEL schedule:remain:1001后台修改排班后清缓存3.2 用 Redis Lua 脚本扣号把“读-改-写”压缩成一步仅仅用 GET 再 SET 扣减号源会出现两个进程同时读到 1又各自写回 0多扣了一次。Redis 的 Lua 脚本在单线程里执行脚本中间不会被其他命令插入因此可以用它实现真正的原子扣减。DECR_SCRIPT if tonumber(redis.call(get, KEYS[1])) 0 then return -1 end return redis.call(decr, KEYS[1]) def try_dec_remain(schedule_id): cache_key fschedule:remain:{schedule_id} val r.get(cache_key) if val is None: s Schedule.objects.filter(pkschedule_id).only(remain_num).first() if s is None: return -1 r.set(cache_key, str(s.remain_num), ex60, nxTrue) script r.register_script(DECR_SCRIPT) return int(script(keys[cache_key]))脚本第一行先检查剩余数小于等于 0 返回 -1否则执行 decr 扣减。register_script 把脚本注册进连接对象后续调用省去每次传递脚本源码的开销。返回值就是扣减后的剩余数调用方拿到 -1 就知道号源已约满。这里要特别说明扣的只是 Redis 里的数字还没有落 MySQL所以它只能当预占不能替代订单。真正生成订单要靠下一节的 MySQL 落库。3.3 预占成功后 MySQL 怎么落库双写不一致怎么处理Redis 扣减成功后要立刻写 MySQL。如果 MySQL 落库失败必须回补号源否则用户端看到“预约失败”号源却被消耗掉了。我一般把下单写库拆成两步条件 update 扣 MySQL 号源成功后再插入预约单。try: with transaction.atomic(): updated Schedule.objects.filter( pkschedule_id, remain_num__gt0 ).update( remain_nummodels.F(remain_num) - 1, versionmodels.F(version) 1 ) if updated 0: rollback_remain(schedule_id) raise ValueError(号源不足) Appointment.objects.create( useruser, schedule_idschedule_id, appoint_nogenerate_appoint_no() ) except Exception: rollback_remain(schedule_id) raiseupdate 配合 F 表达式构造 SQL 层原子更新不用先把行读进 Django。updated 返回受影响行数为 0 就说明 MySQL 的 remain_num 已经是 0触发回补。rollback_remain 是 INCR_SCRIPT把 Redis 里的数字加 1。这种做法在极端情况下会让 Redis 和 MySQL 短暂不一致但最终以 MySQL 为准缓存过期后自然回源纠正。如果业务要求强一致就得把扣减逻辑全部放进 MySQL 存储过程或单事务但存储过程里的行锁和事务边界一样难扩展Redis 方案本质上是用最终一致换吞吐量。注意Redis 扣减返回成功只表示号源被临时占用MySQL 落库失败后必须执行回补否则会出现前端提示“预约失败”号源却被消耗掉的假象。4. Django 业务代码落地预约下单、取消订单与退号python django 搭建 web 项目时新建 app 之后业务逻辑一般集中在 views 和 services 两个文件。挂号系统真正的难点不是 models.py 怎么写而是预约、取消、退号三条链路怎么不互相干扰。下面把三条链路的代码路径串一遍顺便处理两个高频踩坑点。4.1 预约下单Redis 先扣MySQL 后落库的两阶段交付下单接口把逻辑拆成两段第一阶段在 Redis 里扣减号源第二阶段写 MySQL 生成预约单。成功路径和补偿路径都要明确。def book_appointment(user, schedule_id): if not Schedule.objects.filter(pkschedule_id).exists(): return {code: 404, msg: 号源不存在} remain try_dec_remain(schedule_id) # 第一阶段Redis 原子扣减 if remain -1: return {code: 400, msg: 已约满} try: with transaction.atomic(): # 第二阶段MySQL 落库 updated Schedule.objects.filter( pkschedule_id, remain_num__gt0 ).update( remain_nummodels.F(remain_num) - 1, versionmodels.F(version) 1 ) if updated 0: raise ValueError(schedule locked) order Appointment.objects.create( useruser, schedule_idschedule_id, appoint_nogenerate_appoint_no() ) except Exception: rollback_remain(schedule_id) # 补偿把 Redis 加回去 return {code: 500, msg: 下单失败} cache.delete(fschedule:remain:{schedule_id}) # 清缓存等待回源 return {code: 0, order_id: order.id}第一阶段的核心是 try_dec_remain它用 Lua 脚本扣减 Redis 里的剩余数返回 -1 直接结束。第二阶段用条件 update 落 MySQL受影响行为 0 或抛异常都走补偿把 Redis 回补。最后删除缓存让剩余号下次查询时从 MySQL 重新回源。这样做的好处是Redis 挡掉绝大多数无效请求MySQL 只处理真正下单的请求事务短且锁竞争小。generate_appoint_no 建议用时间戳加随机数加用户 ID 拼接保证全局唯一长度控制在 32 位以内。4.2 取消订单的正确姿势状态更新而不是物理删除取消订单是把预约单状态从 0 改成 1同时把号源加回去。很多源码用 Appointment.objects.filter(...).delete() 直接删行这是最典型的错误号源没回补历史数据也没了。django 执行查询-删除对象这类操作在挂号这种有排队语义的场景里一定不能直接删除。正确做法是用状态字段标记取消并在同一事务内回补号源。transaction.atomic def cancel_appointment(user, appoint_no): order Appointment.objects.select_for_update().filter( appoint_noappoint_no, useruser ).first() if order is None or order.status ! 0: raise ValueError(订单不存在或已处理) order.status 1 order.save(update_fields[status]) Schedule.objects.filter(pkorder.schedule_id).update( remain_nummodels.F(remain_num) 1, versionmodels.F(version) 1 ) cache.delete(fschedule:remain:{order.schedule_id})select_for_update 先锁住订单行避免同一订单被并发取消两次。update_fields 只更新 status 字段避免 Django 默认把所有字段重写一遍。号源回补用 F 表达式在数据库层做原子加 1不用把旧值读出来再写。最后删缓存。退号的流程和取消类似只是把 status 改成 2并且可能要记录退号时间和操作人可以在 Appointment 上再加 canceled_at 和 cancel_user 两个字段。4.2.1 用 Django admin 快速核对订单状态开发期和排查期打开 admin 页面看订单状态比写 SQL 更直观。这也是 django admin 界面美化与实用化的起点注册 Appointment 到 admin 之后用 list_display 控制列表列用 list_filter 提供状态筛选用 search_fields 支持订单号搜索。from django.contrib import admin from app.models import Appointment admin.register(Appointment) class AppointmentAdmin(admin.ModelAdmin): list_display [appoint_no, user, schedule, status, created_at] list_filter [status] search_fields [appoint_no]admin 里还可以通过 list_editable 直接改 status做人工补偿时很顺手。需要注意的是多对多字段不要放进 list_display渲染开销大页面会明显变慢。这个配置在联调阶段能省下大量时间也是验收源码包是否跑通的第一道检查点。5. MySQL 慢查询排查与 ORM 改写挂号列表页的百毫秒优化挂号列表页要联科室、医生、号源三张表再带上日期和可用号筛选条件。数据量到几十万行时接口开始变慢第一反应不该是加缓存而是先看 SQL 的执行计划。InnoDB 的索引结构是 B 树写不好联合索引等值过滤和排序都会全表扫描。5.1 先用 EXPLAIN 看执行计划再动手用 django.db.connection.queries 或者直接把 SQL 拿到 MySQL 命令行回放。EXPLAIN 输出里最值得看的是 type 和 rows 两列。EXPLAIN SELECT s.*, d.name, p.name FROM schedule s JOIN doctor d ON d.id s.doctor_id JOIN dept p ON p.id d.dept_id WHERE s.work_date 2025-06-18 AND s.remain_num 0 ORDER BY s.doctor_id;type 从好到坏大致是 eq_ref、ref、range、index、ALL。看到 ALL 说明 schedule 表在整表扫描没有可用索引rows 是预估扫描行数数字越大优化空间越大。如果 type 是 range说明已经用上了 work_date 相关索引。先记录执行计划再决定加什么索引不要拿一个索引试一下看快不快数据量变化后结论往往不可靠。5.2 组合索引设计日期条件放最左再谈排序列表页过滤条件用 work_date排序用 doctor_id。在这个场景里联合索引 (work_date, doctor_id) 比 (doctor_id, work_date) 更合适因为等值条件在最左能命中索引前缀B 树扫出来的数据天然按 doctor_id 有序ORDER BY 就不需要额外做文件排序。ALTER TABLE schedule ADD INDEX idx_date_doctor (work_date, doctor_id);这个索引和 DDL 里的唯一键 uk_doctor_date(doctor_id, work_date) 看起来很像但列顺序不同。唯一键服务的是“一个医生一天只有一行”的约束查询索引服务的是“按日期查可用号”的路径两者目的不同可以同时存在。把组合索引列顺序和最左前缀原则对齐后列表接口的耗时通常能降到原来的十分之一以下。如果查询条件里还带了科室过滤可以把 dept_id 放在 work_date 后面做成三列联合索引。5.3 select_related 消除 N1别让 ORM 把查询放大模板里访问 order.schedule.doctor.name 时Django ORM 默认每访问一个外键就发起一条 SQL。100 行数据可能产生 300 条 SQL数据库往返时间占掉接口耗时大头。select_related 通过一次 join 把关联数据拉齐。from app.models import Schedule schedule_list Schedule.objects.filter( work_date2025-06-18, remain_num__gt0 ).select_related(doctor__dept).order_by(doctor_id)select_related 的参数顺序对应外键链doctor 关联医生表doctor__dept 继续联到科室表。执行后 Django 的 SQL 日志里应该只有一条带两个 join 的语句。select_related 只适用于外键和一对一这类单值关联多对多关系要用 prefetch_related 另开二次查询再合入。这两者差异是 Django 面试常考点也是改写慢查询时第一个要检查的地方。6. 部署到生产前必做的三个验证压测、缓存回源、Redis 连接数最后落到一个可复现的验收流程。源码包在手部署完不能只看系统能登录就算完我一般按三条线验证它是否能扛住放号场景。6.1 用 ab 压测号源查询接口并观察命中率用 gunicorn 起 4 个 worker再对上线的列表接口做 2000 次请求、50 并发压测ab -n 2000 -c 50 http://127.0.0.1:8000/api/schedules/?date2025-06-18压测后通过 redis-cli 执行 INFO stats观察 keyspace_hits 和 keyspace_misses 两个计数器。命中率低于 90% 说明缓存 Key 设计还有问题多半是查询参数没有完整拼进 Key或者 TTL 设得太短。命中率到 95% 以上时读接口基本不构成 MySQL 压力。想看直观变化用 redis desktop manager 连接 Redis观察 schedule:remain 前缀的 Key 在请求前后的数值变化。6.2 核对 Redis 连接池与 gunicorn worker 的乘积gunicorn 每个 worker 是独立进程各自持有 Redis 连接池。worker 数是 4、池大小是 10 时Redis 最大连接数就是 40。生产环境如果用宝塔面板部署 Django要特别注意面板生成的 gunicorn 配置往往只有单进程单线程调整 worker 数时 Redis 连接数会同步变化这两个参数必须一起核算。保守的池配置redis.ConnectionPool( max_connections20, socket_timeout5, socket_connect_timeout5 )max_connections 按“单 worker 峰值并发”除以 worker 数再加 20% 余量设置。socket_timeout 和 socket_connect_timeout 必须设置否则 Redis 卡住时 Django 请求会一直挂到系统默认超时拖垮整个进程。6.3 用一条脚本验证预约-取消闭环写一个总验证脚本按顺序跑五步登录取 token、查剩余号、下单、取消订单、再查剩余号。两次查询的剩余号应该一致中间任何一步数据不一致直接定位到缓存和数据库的同步逻辑。这个脚本建议放进 CI 里每次改 Redis 或排班逻辑都跑一遍比手工点页面可靠得多。本文还有配套的精品资源点击获取
返回列表