多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

门诊系统数据库设计:从挂号到取药,一张表放错就全盘卡死

门诊系统数据库设计:从挂号到取药,一张表放错就全盘卡死 简介这份文档是面向高校软件工程、计算机等专业学生的数据库课程设计参考资料围绕医院门诊管理系统展开适合正在完成数据库原理课程设计或需要撰写课程论文的学习者。压缩包内共1个doc文件约1.5MB内容为完整的课程设计论文涵盖需求分析、数据库结构设计、物理设计、实施与测试及总结附录等章节。文档以挂号、收费、诊断、取药和治疗等门诊流程为主线详细讲解分E-R图与全局E-R图的建立、关系模式构建与规范化处理、用户子模式设计以及关系模式逻辑结构定义等核心知识点并给出数据库实施与测试的具体思路。目前已有79人学习下载读者可借此获得一套结构完整的课程设计范本理解从概念设计到落地实施的数据库开发全流程同时掌握E-R图绘制、范式优化与子模式划分等实用方法为课程答辩或后续项目实践提供参考。1. 门诊系统数据库设计从挂号到取药一张表放错就全盘卡死做过课程设计的人都有体会功能画得再花哨数据库一塌糊涂答辩时三个问题就能把你问穿。门诊管理系统尤其如此——挂号、就诊、开方、收费、发药五个环节环环相扣任何一张表的字段设计失误都会在联调阶段变成“查不到号”“重复扣费”“库存对不上”这类玄学问题。这个课程设计的核心不是写多少界面而是把业务流抽象成一组能自洽的关系模式再用范式理论砍掉冗余、用约束兜住一致性。它适合正在做数据库课设的本科生也适合想补一补关系建模基本功的开发者。下面我按实际动手顺序把从需求到建表、从索引到排错的完整路径拆开讲你照着做就能跑通一套能演示、能答辩、能扛住追问的门诊库。2. 先画业务流再建表门诊五个环节的实体与关系拆解2.1 从挂号到取药业务流里藏着哪些实体很多同学一上来就打开数据库客户端开始建表结果建到一半发现“医生排班”和“挂号记录”的关系理不清回头改表结构前面写的查询全废。正确顺序是先拿纸笔把业务流走一遍。门诊的典型流程是患者到院→选择科室和医生→挂号产生挂号单→候诊叫号→医生接诊产生病历→开处方和检查单→患者缴费→药房发药或检验科执行。把每个环节里“需要长期保存的信息”圈出来就是候选实体。我一般会圈出这几类患者基本信息、联系方式、科室名称、位置、医生姓名、职称、所属科室、排班医生在哪个时间段出诊、限号多少、挂号单谁、挂哪个排班、什么状态、病历本次就诊的诊断、主诉、处方明细开了什么药、多少量、药品名称、规格、库存、单价、收费单应收、实收、支付方式。注意“挂号单”和“病历”要分开因为一个患者同一天可能挂两次不同科室但每次就诊只对应一份病历。这个区分不做后面查“某患者历史诊断”就会串数据。关系方面患者与挂号单是一对多排班与挂号单是一对多一个排班放多个号挂号单与病历是一对一病历与处方明细是一对多药品与处方明细是一对多。把这些关系写成文字描述再转成 ER 图比直接想表结构靠谱得多。常见做法是用 draw.io 或纸笔画 Chen 氏 ER 图实体用矩形、关系用菱形、属性用椭圆标出主键和外键走向。这一步花半小时后面省三小时。2.2 把 ER 图转成关系模式主键、外键与联系表ER 图转关系模式有固定规则每个实体转一张表实体的属性转字段实体的主键转表的主键一对多关系把“一”方的主键放到“多”方做外键多对多关系必须单独建一张联系表把双方主键放进去做联合主键。门诊里最容易被忽略的是“医生-科室”关系——一个医生可能轮转多个科室一个科室有多个医生这是多对多需要一张 doctor_department 表。如果你图省事在医生表里加一个 department_id 字段那医生轮转时就只能改字段历史记录全丢。另一个坑是“排班”的粒度。排班表里要存医生 ID、科室 ID、出诊日期、时段上午/下午、限号数、已挂数。已挂数这个字段是冗余的因为可以从挂号单里 count 出来但实际系统里几乎都会冗余它因为每次挂号都要检查余号实时 count 在并发下容易超卖。课程设计里你可以先不冗余用事务加行锁保证一致性答辩时能讲清楚“为什么冗余”和“冗余后怎么保证一致”反而是加分项。转完关系模式后用范式检查一遍。患者表里如果出现“患者ID、姓名、挂号科室名”科室名依赖挂号单而不是患者这就违反 2NF应该把科室名放回科室表。处方明细表里如果出现“药品单价”单价依赖药品 ID 而不依赖处方明细主键也违反 2NF应该只存药品 ID 和数量单价从药品表查。但注意收费单里的“实收金额”是业务快照药品调价后历史收费不能变所以这个冗余是故意保留的叫反范式设计。答辩时被问到“为什么这里不拆”你就答“业务快照需要保留历史价格”。2.3 用 SQL 建出第一版表结构下面这套 DDL 是我在课设里常用的最小可用版本MySQL 8.0 语法字段类型和约束都按门诊场景调过。你可以直接复制执行再按自己需求加字段。-- 患者表手机号唯一避免同一患者重复建档 CREATE TABLE patient ( patient_id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, gender TINYINT NOT NULL COMMENT 0未知 1男 2女, birth_date DATE, phone VARCHAR(20) NOT NULL, id_card VARCHAR(18) UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 科室表名称唯一 CREATE TABLE department ( dept_id INT PRIMARY KEY AUTO_INCREMENT, dept_name VARCHAR(50) NOT NULL, location VARCHAR(100), UNIQUE KEY uk_dept_name (dept_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 医生表一个医生可属于多个科室所以不在这里放 dept_id CREATE TABLE doctor ( doctor_id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, title VARCHAR(20) COMMENT 职称, phone VARCHAR(20) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 医生-科室多对多联系表 CREATE TABLE doctor_department ( doctor_id BIGINT NOT NULL, dept_id INT NOT NULL, PRIMARY KEY (doctor_id, dept_id), FOREIGN KEY (doctor_id) REFERENCES doctor(doctor_id), FOREIGN KEY (dept_id) REFERENCES department(dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 排班表医生在某天某时段出诊限号数控制放号 CREATE TABLE schedule ( schedule_id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, dept_id INT NOT NULL, work_date DATE NOT NULL, time_slot TINYINT NOT NULL COMMENT 1上午 2下午, total_quota INT NOT NULL DEFAULT 0, booked_count INT NOT NULL DEFAULT 0, UNIQUE KEY uk_doctor_date_slot (doctor_id, work_date, time_slot), FOREIGN KEY (doctor_id) REFERENCES doctor(doctor_id), FOREIGN KEY (dept_id) REFERENCES department(dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 挂号单状态机 0待就诊 1已就诊 2已取消 CREATE TABLE registration ( reg_id BIGINT PRIMARY KEY AUTO_INCREMENT, patient_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, reg_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 0, fee DECIMAL(10,2) NOT NULL DEFAULT 0, FOREIGN KEY (patient_id) REFERENCES patient(patient_id), FOREIGN KEY (schedule_id) REFERENCES schedule(schedule_id), KEY idx_patient (patient_id), KEY idx_schedule (schedule_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段 DDL 里有几个参数值得说。utf8mb4是必须的患者姓名里可能有生僻字utf8存不下。DECIMAL(10,2)用于金额不要用FLOAT浮点累加会出现 0.10.2≠0.3 的经典问题收费对账时就是血泪教训。schedule表上的uk_doctor_date_slot唯一键防止同一医生同一天同一时段被排两次班。registration表上的idx_patient和idx_schedule是为后面查“某患者挂号记录”和“某排班已挂人数”准备的没有这两个索引数据量一上来查询就全表扫描。执行完可以用SHOW CREATE TABLE registration\G检查外键和索引是否生效。如果报错errno 150通常是外键字段类型和引用字段类型不一致比如patient_id一边是BIGINT一边是INT改成一致即可。3. 挂号并发与库存扣减事务、锁和三个必调参数3.1 为什么“先查余号再插入”会超卖课程设计答辩时老师最爱问“两个人同时挂最后一个号怎么办”。如果你代码里写的是先SELECT booked_count FROM schedule WHERE schedule_id?判断小于total_quota再INSERT挂号单、UPDATE已挂数那在并发下必然超卖。因为两个事务可能同时读到booked_count9、total_quota10都判断可以挂然后都插入最后已挂数变成 11。这就是典型的竞态条件单机测试时因为操作快不容易复现一上压力测试就翻车。解决思路有三种。第一种是悲观锁在查询排班时加FOR UPDATE把行锁住第二个事务必须等第一个提交后才能读。第二种是乐观锁在schedule表加version字段更新时UPDATE ... SET booked_countbooked_count1, versionversion1 WHERE schedule_id? AND version?影响行数为 0 就重试。第三种是直接用数据库的原子更新UPDATE schedule SET booked_countbooked_count1 WHERE schedule_id? AND booked_count total_quota根据影响行数判断是否成功。课程设计里我推荐第三种代码最少语义最清晰。3.2 用一条原子 UPDATE 兜住余号把挂号逻辑写成存储过程或应用层事务核心是那条带条件的 UPDATE。下面用 Python PyMySQL 演示重点看cursor.rowcount的判断。import pymysql def register(conn, patient_id, schedule_id, fee): with conn.cursor() as cur: # 开启事务 conn.begin() try: # 原子扣减只有余号充足时才更新成功 cur.execute( UPDATE schedule SET booked_count booked_count 1 WHERE schedule_id %s AND booked_count total_quota, (schedule_id,) ) if cur.rowcount 0: # 影响行数为 0说明余号不足或排班不存在 conn.rollback() return {ok: False, msg: 号源已满} # 插入挂号单 cur.execute( INSERT INTO registration (patient_id, schedule_id, fee) VALUES (%s, %s, %s), (patient_id, schedule_id, fee) ) reg_id cur.lastrowid conn.commit() return {ok: True, reg_id: reg_id} except Exception as e: conn.rollback() raise e这段代码的关键在WHERE booked_count total_quota。数据库在执行 UPDATE 时会对满足条件的行加排他锁两个并发事务只有一个能先拿到锁并更新成功另一个会等待等第一个提交后重新读取最新值再判断此时booked_count已经等于total_quota条件不满足rowcount为 0直接回滚。整个过程不需要应用层加锁也不会超卖。参数上conn.begin()和conn.commit()必须成对异常时rollback不能漏否则连接池里的连接会带着未提交事务被复用导致后续查询看到脏数据。3.3 隔离级别和锁等待超时怎么设MySQL 默认隔离级别是REPEATABLE READ在这个级别下上面的原子 UPDATE 行为是正确的。但如果你把隔离级别改成READ COMMITTED并发行为会略有不同不过原子 UPDATE 依然安全因为它依赖的是行锁而不是快照读。课程设计里不建议改隔离级别保持默认即可。需要关注的是innodb_lock_wait_timeout默认 50 秒如果某个事务长时间不提交其他挂号请求会一直等用户体验极差。可以在会话级设小一点比如 5 秒超时后应用层捕获异常提示“系统繁忙请重试”。-- 查看当前锁等待超时 SHOW VARIABLES LIKE innodb_lock_wait_timeout; -- 会话级设置为 5 秒 SET SESSION innodb_lock_wait_timeout 5;另外registration表的status字段在取消挂号时要回滚booked_count这个操作也要放在同一个事务里并且用UPDATE schedule SET booked_count booked_count - 1 WHERE schedule_id? AND booked_count 0保证不会减成负数。取消和挂号共用同一把行锁不会互相干扰。4. 查询性能与索引病历、处方、收费三张表的慢查询排查4.1 病历和处方表怎么建才不会拖垮查询病历表和处方明细表是数据量增长最快的两张表。一个患者一次就诊产生一条病历一条病历可能对应五条处方明细三甲医院日门诊量上万一年下来处方明细轻松过千万。如果表结构设计得不好查“某患者近三个月用药记录”就会变成全表扫描。病历表我一般这样建CREATE TABLE medical_record ( record_id BIGINT PRIMARY KEY AUTO_INCREMENT, reg_id BIGINT NOT NULL, patient_id BIGINT NOT NULL, doctor_id BIGINT NOT NULL, chief_complaint VARCHAR(500), diagnosis VARCHAR(500), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_reg (reg_id), KEY idx_patient_time (patient_id, create_time), KEY idx_doctor_time (doctor_id, create_time), FOREIGN KEY (reg_id) REFERENCES registration(reg_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意patient_id和doctor_id是冗余的因为通过reg_id能关联到挂号单再拿到患者和医生。但查询“某患者历史诊断”时如果每次都 join 挂号单性能会差很多所以这里故意冗余并在插入时保证与挂号单一致。idx_patient_time是联合索引支持“某患者按时间倒序查病历”这种最高频的查询。联合索引的字段顺序很重要patient_id在前是因为等值查询create_time在后是因为范围排序反过来建索引就用不上排序优化。处方明细表CREATE TABLE prescription_detail ( detail_id BIGINT PRIMARY KEY AUTO_INCREMENT, record_id BIGINT NOT NULL, drug_id INT NOT NULL, quantity INT NOT NULL, unit_price DECIMAL(10,2) NOT NULL COMMENT 开方时单价快照, FOREIGN KEY (record_id) REFERENCES medical_record(record_id), FOREIGN KEY (drug_id) REFERENCES drug(drug_id), KEY idx_record (record_id), KEY idx_drug (drug_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;unit_price是快照药品调价不影响历史处方金额。idx_record支持按病历查明细idx_drug支持统计某药品用量。如果要做“某患者某时间段用了哪些药”需要 join 病历表这时候medical_record上的idx_patient_time就派上用场了。4.2 用 EXPLAIN 定位慢查询的三个信号写完查询后养成用EXPLAIN看一眼的习惯。下面这条是查“患者 1001 近 30 天处方”的 SQLEXPLAIN SELECT pd.detail_id, pd.quantity, d.drug_name FROM prescription_detail pd JOIN medical_record mr ON pd.record_id mr.record_id JOIN drug d ON pd.drug_id d.drug_id WHERE mr.patient_id 1001 AND mr.create_time DATE_SUB(NOW(), INTERVAL 30 DAY);看EXPLAIN结果时重点盯三个字段type、key、rows。type出现ALL说明全表扫描必须加索引出现ref或range才算正常。key显示实际使用的索引如果是NULL说明没走索引。rows是预估扫描行数如果这个数接近表总行数索引基本没起作用。上面这条查询理想情况下mr表走idx_patient_timepd表走idx_recordd表走主键。如果pd表显示ALL检查idx_record是否建了或者record_id类型是否和medical_record.record_id一致。另一个常见信号是Extra列出现Using filesort或Using temporary。filesort说明排序没走索引如果查询里有ORDER BY mr.create_time DESC而索引是(patient_id, create_time)排序就能直接利用索引顺序不会出现filesort。temporary说明用了临时表通常出现在GROUP BY和ORDER BY字段不一致时尽量让它们一致。4.3 收费单的金额字段与对账查询收费单表要记录应收、实收、优惠、支付方式、状态。金额字段全部用DECIMAL(10,2)不要用INT存分然后应用层除 100那样容易在除法和四舍五入上出错。对账查询是“某日实收总额”SQL 很简单SELECT DATE(create_time) AS day, SUM(actual_amount) AS total FROM charge WHERE create_time 2025-01-01 AND create_time 2025-01-02 AND status 1 GROUP BY DATE(create_time);这条查询要在create_time和status上建联合索引idx_time_status (create_time, status)。注意WHERE里create_time是范围条件status是等值条件联合索引把等值字段放前面、范围字段放后面或者反过来取决于数据分布。如果绝大多数收费单都是已支付状态status选择性很低那把create_time放前面更合适。可以用SHOW INDEX FROM charge看索引基数基数高的字段放前面。5. 避坑与排查课设答辩前必须自查的五个问题5.1 外键报错 errno 150类型和引擎不一致现象建表时提示ERROR 1215 (HY000): Cannot add foreign key constraint或者errno 150。原因通常是外键字段和引用字段的类型不完全一致比如一边BIGINT UNSIGNED一边BIGINT或者字符集不同或者存储引擎不是 InnoDB。解决用SHOW CREATE TABLE 子表和SHOW CREATE TABLE 父表对比字段定义确保类型、字符集、排序规则完全一致引擎都是 InnoDB。如果还不行检查父表的引用字段是否有索引MySQL 要求被引用字段必须是主键或唯一索引。5.2 中文乱码连接字符集没设对现象插入中文姓名后查出来是问号或者????。原因数据库、表、连接三处字符集不一致。解决建库时CREATE DATABASE clinic DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;建表时指定DEFAULT CHARSETutf8mb4连接串里加charsetutf8mb4。如果已经建好表用ALTER TABLE patient CONVERT TO CHARACTER SET utf8mb4;转换。注意utf8mb4_unicode_ci和utf8mb4_general_ci的区别前者排序更准确课设里用哪个都行但全库要统一。5.3 事务没提交导致查不到数据现象代码里插入了挂号单但另一个连接查不到。原因插入所在的连接没有commit或者用了自动提交关闭的会话。解决检查代码里conn.commit()是否执行异常分支是否漏了rollback。用SHOW PROCESSLIST看是否有长时间Sleep的连接持有未提交事务。课设里建议把autocommit设为False显式控制事务边界避免“以为提交了其实没有”的翻车。5.4 索引建了但没走隐式类型转换现象patient_id是BIGINT查询时写成WHERE patient_id 1001字符串带引号MySQL 会做隐式类型转换导致索引失效。解决参数化查询时确保 Python 传的是int而不是str或者 SQL 里写WHERE patient_id 1001。用EXPLAIN看key是否为NULL就能确认。另一个隐式转换是字符集不同比如表是utf8mb4连接是utf8join 时也会失效统一字符集即可。5.5 删除科室时外键约束报错现象想删一个科室提示Cannot delete or update a parent row: a foreign key constraint fails。原因doctor_department或schedule表里还有引用该科室的记录。解决要么先删子表记录要么在建外键时加ON DELETE CASCADE让子记录跟着删。但门诊系统里不建议级联删因为科室删除应该是逻辑删除加is_deleted字段物理删除会丢历史数据。课设里可以演示逻辑删除答辩时说明“为什么不用物理删除”。6. 从课设到可演示系统用视图和存储过程收尾课设答辩时老师往往不满足于“表建好了”还想看“能不能查出一个完整业务视图”。这时候加两个视图和一段存储过程演示效果会好很多。第一个视图是“患者挂号详情”把患者、排班、医生、科室 join 在一起查一次就能看到谁挂了哪个医生的号。CREATE VIEW v_registration_detail AS SELECT r.reg_id, p.name AS patient_name, p.phone, d.name AS doctor_name, dep.dept_name, s.work_date, s.time_slot, r.status, r.fee FROM registration r JOIN patient p ON r.patient_id p.patient_id JOIN schedule s ON r.schedule_id s.schedule_id JOIN doctor d ON s.doctor_id d.doctor_id JOIN department dep ON s.dept_id dep.dept_id;第二个视图是“医生工作量统计”按医生统计已就诊人数用于演示 group by 和聚合。CREATE VIEW v_doctor_workload AS SELECT d.doctor_id, d.name AS doctor_name, COUNT(r.reg_id) AS total_reg, SUM(CASE WHEN r.status 1 THEN 1 ELSE 0 END) AS visited_count FROM doctor d LEFT JOIN schedule s ON d.doctor_id s.doctor_id LEFT JOIN registration r ON s.schedule_id r.schedule_id GROUP BY d.doctor_id, d.name;存储过程可以封装“取消挂号”逻辑更新挂号单状态为已取消同时把排班的已挂数减一两步在一个事务里完成。DELIMITER // CREATE PROCEDURE cancel_registration(IN p_reg_id BIGINT) BEGIN DECLARE v_schedule_id BIGINT; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; -- 锁定挂号单防止重复取消 SELECT schedule_id INTO v_schedule_id FROM registration WHERE reg_id p_reg_id AND status 0 FOR UPDATE; IF v_schedule_id IS NULL THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 挂号单不存在或已取消; END IF; UPDATE registration SET status 2 WHERE reg_id p_reg_id; UPDATE schedule SET booked_count booked_count - 1 WHERE schedule_id v_schedule_id AND booked_count 0; COMMIT; END // DELIMITER ;调用时CALL cancel_registration(1001);即可。这段存储过程里FOR UPDATE锁住挂号单行防止两个请求同时取消同一张单EXIT HANDLER捕获异常后回滚并重新抛出保证不会留下半截事务。参数p_reg_id是挂号单主键调用前确认存在且状态为待就诊。最后说一个我自己的习惯每次改完表结构一定用mysqldump --no-data导出一次 schema存到版本目录里和上一版 diff 一下。课设期间表结构会反复改没有版本对比改到后面自己都忘了哪个字段是干嘛的。这个习惯帮我省过很多次“答辩前夜发现字段对不上”的后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表