ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从业务链路到MySQL表结构:门诊收费模块数据库设计实战

从业务链路到MySQL表结构:门诊收费模块数据库设计实战

简介:「医院管理系统数据库设计.docx」是一份面向计算机专业学生与课程设计者的数据库建模文档,围绕医院信息系统(HIS)的组织结构、业务流程与建库要点展开,适用于数据库课程设计、毕业设计选题参考及需求分析训练。文档以门诊与住院两大部门为主线,梳理挂号、收费、检查、药房、病案、手术等科室的职责与数据流向,给出需求分析方法与实体关系梳理思路,并讨论数据完整性、一致性、安全性、并发性能、可扩展性与法规遵从等设计考量,同时涉及ER图转表、外键关联、索引优化与适度冗余的取舍。整包仅含1个docx文件,约398KB,轻量易读,可直接当作文档模板与写作框架参考。目前已有175人学习。读者能从中获得较完整的部门关系与业务流程图描述、需求分析书写范式,以及把业务语言落成表结构的实践路径,便于快速搭建数据库设计文档骨架。

1. 接手一套半成品门诊收费模块,我先翻的是那份数据库设计文档

接手一套别人做了一半的门诊收费模块时,和代码一起丢过来的还有一份《医院管理系统数据库设计》文档。翻完发现,真正值钱的不是后面那堆字段清单,而是前面几十页的业务流程——挂号处怎么收费、药房怎么向药库领药、住院病人转科时床位和医嘱怎么跟着走。这些流程决定了后面每张表长什么样、外键往哪挂。这份资料适合两类人:一类是做计算机专业课程设计,需要一套完整的需求分析、数据字典、数据流图骨架直接改成自己的题目;另一类是真的在接手 HIS 里某个模块,需要快速看懂存量表结构的来龙去脉。下面按「业务 → 表 → 约束 → 坑 → 改造」的顺序拆。

2. 业务骨架拆解:门诊和住院两条链路决定了你要建多少张表

拿到一份数据库设计文档,多数人的第一反应是直接翻到建表那一节抄字段。这个顺序是反的。文档里那些部门清单、业务活动描述看着像凑字数的前言,其实它们才是表的来源——每出现一个需要长期记录信息的动作,就对应一张表或一组表;每出现一个只做一次的动作,往往只是一条状态变更。先把业务链路捋顺,后面建表的时候就不会出现「这张表为什么要存在」的疑问。

这份文档的组织结构描述里点名了十几类部门:门诊部门和住院部门两大类,下面挂口腔科、内科、外科、皮肤科、骨科这些科室;支撑部门有药库、药房、设备科、财务科、后勤仓库、门诊收费处、门诊挂号处、问讯处、住院处、检查科室、血库、病案室、手术室,再加上行政部门。把这些部门当成「谁产生数据、谁消费数据」的节点来看,实体就浮出来了。

2.1 从组织结构读实体:十几个部门反推出二十多张表

从部门职责里能直接数出来的实体有这么几组。第一组是人:病人、医生、护士、行政人员,其中医生还要按主治医师、副主任医师、普通医师、教授副教授再分级,这一层在文档里是作为「医生的职称」属性写的,实际建模时如果要做排班和挂号费分级,职称最好单独抽一张字典表,别硬编码成字符串。第二组是物:药品、设备、血液、床位。第三组是单据:挂号单、门诊病历、处方、检查申请单与结果报告单、收款单、住院登记单、转科申请单、医嘱单、药品请领单、出入库单、手术申请单与通知单、病案。第四组是关系:科室与医生的从属、医生与病人的诊疗关系、药品与供应商的采购关系。

文档里给出的数据结构一共十五个:挂号单、门诊病历、门诊处方、检查项目、订单、药品、病人、医生、处理方案、收费项目、供应商、药库、医嘱、手术、病案。这十五个是骨架,但直接照抄成表会出问题,最典型的是「门诊处方」这个结构,组成写的是「处方号、主治医师、病人姓名、病人性别、病人年龄、处方内容」,处方内容一个char(100)就装完了。这种设计在纸面上够用,一旦要对账——某盒药这个月出了多少、哪个医生的处方里某类抗生素占比多少——就完全查不出来,因为药品明细被压在了一个字符串里。

我的处理方式是:凡是文档里写成「XX 内容」「XX 记录」这种大文本字段的,一律当成待拆的信号。处方拆成处方头加处方明细,医嘱拆成医嘱头加医嘱明细,检查单拆成申请单加结果报告,病案本身可以保留为一条主记录但检查、手术、用药各自挂明细。拆完之后,原本十五个结构大约会膨胀到二十五到三十张表,这个量级对一个中小型医院的门诊加住院业务是合理的。

2.2 门诊链路:挂号、处方、收费、发药的四段式流转

门诊这条链路的业务描述在文档里写得相当细,值得逐句抠。初诊病人先到挂号处登记基本信息(姓名、年龄、住址、联系方式),复诊病人直接挂号排号,挂号处同时负责病历管理。然后到收费处缴挂号费,持挂号凭证去对应科室就诊。医生诊疗后开出诊断结果、处方或者检查申请单。如果是处方,病人拿着处方去收费处划价交费,再持收费凭证到药房取药;如果是检查单,同样先交费再去检查科室,检查完拿到结果报告单,可以回原科室复诊。药房发药之后库存减少,降到一定量就向药库申领。

这段流程拆成数据流就是四条:挂号登记(病人 → 挂号单)、病历(门诊医生 → 门诊挂号处)、处方信息(门诊医生、收费处 → 药房)、检查单(门诊医生 → 检查科室)。文档的数据流表里也是这四条,只是重复贴了一遍,整理的时候去重就行。

真正影响表结构的是这里面的「先交费后执行」顺序。挂号费要在就诊前收,处方费和检查费要在执行前收,意味着收费单不能只记一个总额挂在病人身上,必须能指向具体的业务单据——挂的是哪个号、开的是哪张处方、哪张检查单。所以收费明细表至少要三个来源字段:业务类型、业务单据号、金额。文档里的「收费项目」结构写的是「收费项目号、挂号费、药品费、检查费」,这种一行三列的写法是把三种性质完全不同的费用塞进同一条记录,退费的时候根本拆不开,后面第 4 章会专门讲怎么改。

还有一个容易漏的点:药房向药库领药。文档写「当药房库存的药品减少到一定量的时候,药房人员应到药库办理药品申领」,对应的单据是药品请领单,数据流是药房 → 药库。但数据结构清单里没有这个表,只在数据流里出现了名字。这类「流程里提到、结构里没定义」的单据,就是建表时最容易被跳过、上线后最容易被追着补的。

2.3 住院链路:入院排床、医嘱执行、手术与出院的状态闭环

住院这条链路比门诊长得多,状态也复杂得多。病人拿着入院通知单到住院处办手续,登记基本信息、交预交款或押金;办完之后由病区科室按就诊科室安排床位,录入缴费信息;病区按医生开的医嘱执行,医嘱内容包括用药和检查申请;病区把用药分类汇总成药品申领单统一向药库领药,按时按量发给住院病人并记录发药情况;检查申请单转给检查科室,检查科室处理完把通知发回病区,病区再通知病人去检查;需要手术的,病区提交手术申请给手术室,手术室安排日程、准备器械材料,然后把手术通知发回病区;病人可以出院时先在病区办出院登记、办出科,再到住院处办出院手续;需要转科的,在病区办转科手续,转入病区重新安排床位。

把这段翻译成表,至少有这些:住院登记单(住院号、病人号、入院时间、入院科室、预交金额)、床位表(床位号、病区号、科室、状态)、床位占用记录(住院号、床位号、开始时间、结束时间,转床转科都往这张表里插记录而不是改原记录)、医嘱头与医嘱明细、药品申领单与明细、检查申请与结果、手术申请与手术记录、出院/转科记录。

住院和门诊最大的结构差异在于「时间」和「状态」。门诊是一次性的,挂号—就诊—收费—取药当天闭环,状态少;住院是持续多天的,床位会转、科室会转、医嘱会停会改、费用是滚动记账最后统一结算。所以住院侧的表几乎都要带生效时间和失效时间,费用也不能像门诊那样一单一结,得有一个住院费用明细表持续累加,出院时再汇总成结算单。文档里「病案」这个数据结构写的是「病案号、病人、入院科室、入院日期、入院诊断、出院日期、出院诊断」,这是典型的出院后才补全的记录,不能拿它当住院主表用——住院期间病案号还没生成,主表得是住院登记单。

维度门诊链路住院链路
主键载体挂号号 / 病历号住院号
生命周期当天闭环跨天,可转科转床
费用模式逐单收费,先付后执行预交金 + 滚动记账,出院结算
核心单据挂号单、处方、检查单医嘱、申领单、手术记录、病案
状态字段必要性中(退号、退费)高(在院、转科、出院、结算)

这张对比表不是装饰,它决定了两条链路能不能共用一套收费表和一套单据表。我的做法是共用病人、医生、药品、科室这些主数据,业务单据完全分开建,收费明细通过「业务类型 + 业务单号」统一挂到一张费用流水表上,这样财务侧做日结、月结的时候只查一张表。

3. 数据字典落地:把 char(8) 编码翻译成可执行的 DDL

文档的数据字典部分信息量很大,但它是按「数据项—数据结构—数据流—数据存储—处理过程」五段分开写的,同一张表在五个地方各出现一次,字段名还不完全一致。落到真实库之前必须先做一次归并:以数据结构里的字段组成为准,用数据项里的类型长度去补全类型,数据流和数据存储只用来校验有没有漏表。

这里有一个绕不开的选择:文档里几乎所有主键都是char(8),订单号是char(20),病区号还写成了char(10)但描述里说「8 位组成唯一编码」。这套编码规则本身没问题,国内不少 HIS 确实用 8 位定长业务码。问题在于两种做法混用会造成后面无穷的麻烦——有的表拿业务码当主键,有的表又自己加自增 ID,外键指向就乱了。

我的建议是:主数据表(病人、医生、药品、科室、供应商)用自增或雪花 ID 做代理主键,业务编码单独一列加唯一索引;业务单据表(挂号单、处方、医嘱、手术)反过来,直接用业务编码做主键,因为这些单据号本身就是全局唯一的,而且要打印在纸质单据上,多一层映射反而增加对账成本。文档没规定 DBMS,下面统一按 MySQL 8 写,换 SQL Server 只需要把AUTO_INCREMENT换成IDENTITY、DATETIME换成DATETIME2,其余基本不用动。

3.1 三张主数据表:病人、医生、药品

病人表和医生表在文档里的字段高度对称,都是「编号、姓名、性别、年龄、身份证号、出生日期、住址、联系方式」,医生多一个职称和科室。这里有两个细节要改:一是年龄不该存,出生日期有了年龄随时算,存了就要每年批量更新,纯属给自己找活;二是身份证号必须加唯一约束,重复建档是门诊系统里最常见的脏数据来源,同一个病人被建了三次档案,病历就散了。

-- 病人主表:代理主键 + 业务编码唯一 CREATE TABLE patient ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '代理主键', patient_no CHAR(8) NOT NULL COMMENT '病人号,8位业务编码', name VARCHAR(32) NOT NULL COMMENT '姓名', gender CHAR(1) NOT NULL DEFAULT 'U' COMMENT '性别 M/F/U', birth_date DATE NULL COMMENT '出生日期,年龄由此推算', id_card VARCHAR(18) NULL COMMENT '身份证号', phone VARCHAR(20) NULL COMMENT '联系方式', address VARCHAR(128) NULL COMMENT '住址', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_patient_no (patient_no), UNIQUE KEY uk_id_card (id_card), -- 防重复建档 KEY idx_name_phone (name, phone) -- 无身份证时的兜底检索 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='病人主数据';

这段的重点在三个索引。uk_patient_no保证业务编码不重复,因为挂号、处方、病案都要引用它;uk_id_card是防重复建档的硬约束,允许 NULL 是因为儿童和急诊无名氏确实可能没有身份证,MySQL 的唯一索引允许多个 NULL,正好符合需求;idx_name_phone是为「病人报不出身份证号,前台用姓名加手机号查档」这个高频场景准备的。性别用CHAR(1)存 M/F/U 而不是存「男/女」,是为了兼容后续可能出现的英文界面和接口对接,展示层再翻译。

药品表的字段文档给的是「药品编号、药品名、规格、单位、数量、单价、金额、生产日期、保质期」。这里的坑在于「数量」和「金额」——这两个是库存和交易的结果,不该放在药品主数据里。药品表只描述「这是什么药」,库存数量放库存表,金额放单据明细。保质期也不该存天数,存生产日期加有效期末日更好,因为同一批药的生产日期不同,保质期天数相同但到期日不同,按批次管理才查得出近效期。

-- 药品主数据:只描述属性,不存库存和金额 CREATE TABLE drug ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, kind_no CHAR(8) NOT NULL COMMENT '药品编号', drug_name VARCHAR(64) NOT NULL COMMENT '药品名', spec VARCHAR(64) NULL COMMENT '规格,如 0.25g*24片', unit VARCHAR(16) NOT NULL COMMENT '最小单位:片/支/瓶', retail_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '零售单价', is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否在用,停用不删', PRIMARY KEY (id), UNIQUE KEY uk_kind_no (kind_no), KEY idx_name (drug_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药品主数据'; -- 批次库存:数量、效期都在这一层 CREATE TABLE drug_stock ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, drug_id BIGINT UNSIGNED NOT NULL, warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '药库或药房', batch_no VARCHAR(32) NOT NULL COMMENT '生产批号', expire_date DATE NOT NULL COMMENT '有效期至', quantity INT NOT NULL DEFAULT 0 COMMENT '当前库存,禁止为负', PRIMARY KEY (id), UNIQUE KEY uk_drug_wh_batch (drug_id, warehouse_id, batch_no), KEY idx_expire (expire_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药品批次库存';

把库存按批次拆开之后,近效期预警、先进先出发药、按批号召回这三件事才有地方落。文档里「库存少到一定程度系统应提出警告」这条需求,落地就是一条定时查询:按drug_id汇总quantity,和药品表里一个min_stock阈值比较。这个阈值字段我一般直接加在drug表上,不单独建表。

3.2 挂号单、处方头与处方明细:把 char(100) 拆开

挂号单在文档里的组成是「挂号号、挂号类别、主治医师、挂号人姓名、挂号科室、挂号日期」。直接照抄的问题是:没有状态字段,退号怎么办?没有费用字段,挂号费从哪来?没有就诊序号,同科室多个病人怎么排队?我在实际项目里至少会加上状态、挂号费、支付方式、就诊序号四个字段。

CREATE TABLE registration ( gh_no CHAR(8) NOT NULL COMMENT '挂号号', patient_id BIGINT UNSIGNED NOT NULL, dept_id BIGINT UNSIGNED NOT NULL COMMENT '挂号科室', doctor_id BIGINT UNSIGNED NULL COMMENT '指定医生,普通号为 NULL', reg_type CHAR(2) NOT NULL COMMENT '挂号类别:初诊/复诊/专家', visit_seq INT NOT NULL COMMENT '当日科室就诊序号', reg_fee DECIMAL(8,2) NOT NULL DEFAULT 0.00 COMMENT '挂号费', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待就诊 1已就诊 2已退号', reg_date DATE NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (gh_no), KEY idx_dept_date (dept_id, reg_date), KEY idx_patient (patient_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门诊挂号单';

visit_seq这个字段是我踩过坑之后加的——最初用自增 ID 排序叫号,结果退号之后序号出现空洞,屏幕上跳号,病人以为被插队,投诉了好几次。改成按科室、按日期单独取号之后才消停。取号本身要放在一个事务里,用SELECT ... FOR UPDATE锁住当日该科室的最大序号再 +1。

处方拆成头和明细两张表。头表存处方号、病人、医生、科室、开方时间、状态、总金额;明细存药品、数量、用法用量、单次剂量、频次、天数。文档里「处方内容 char(100)」拆开之后大概长这样:

CREATE TABLE prescription ( cf_no CHAR(8) NOT NULL COMMENT '处方号', patient_id BIGINT UNSIGNED NOT NULL, doctor_id BIGINT UNSIGNED NOT NULL, dept_id BIGINT UNSIGNED NOT NULL, diag_text VARCHAR(255) NULL COMMENT '临床诊断', total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 0 COMMENT '0已开 1已收费 2已发药 3已退', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (cf_no), KEY idx_patient_time (patient_id, created_at), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门诊处方头'; CREATE TABLE prescription_detail ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, cf_no CHAR(8) NOT NULL, drug_id BIGINT UNSIGNED NOT NULL, quantity INT NOT NULL COMMENT '数量', dosage VARCHAR(32) NULL COMMENT '单次剂量,如 0.5g', frequency VARCHAR(16) NULL COMMENT '频次,如 tid', days INT NULL COMMENT '天数', unit_price DECIMAL(10,2) NOT NULL COMMENT '开方时单价,锁价用', amount DECIMAL(10,2) NOT NULL, PRIMARY KEY (id), KEY idx_cf_no (cf_no), KEY idx_drug (drug_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='处方明细';

unit_price存的是开方瞬间的价格快照,这一点很关键。药品调价是常态,如果明细不锁价,三个月后重打一张旧处方,金额会跟着新价格变,对账的时候就是一笔糊涂账。用法用量拆成剂量、频次、天数三列而不是一段文本,是为了后续做合理用药审查——有了结构化字段才能自动判断「这个剂量超过成人上限」。

3.3 医嘱与手术记录:时间戳和状态字段一个都别省

医嘱是住院侧的核心,文档里写的是「诊断单序号、诊断类别、启动日期、处理日期、医嘱内容、主治医师、病人」。这套字段最大的问题是只有启动和处理两个时间点,表达不了「长期医嘱每天执行、临时医嘱一次执行、中途停止」这几种情况。实际建模要拆成医嘱头(一次开立)和医嘱执行记录(每次执行一条),停止医嘱靠stop_time和状态位标记,而不是删记录。

CREATE TABLE medical_order ( order_no CHAR(8) NOT NULL COMMENT '医嘱号', admission_no CHAR(8) NOT NULL COMMENT '住院号', order_type CHAR(1) NOT NULL COMMENT 'L长期 T临时', content VARCHAR(255) NOT NULL COMMENT '医嘱内容', doctor_id BIGINT UNSIGNED NOT NULL, start_time DATETIME NOT NULL COMMENT '开始执行时间', stop_time DATETIME NULL COMMENT '停止时间,长期医嘱用', status TINYINT NOT NULL DEFAULT 0 COMMENT '0开立 1执行中 2已停 3已作废', PRIMARY KEY (order_no), KEY idx_admission (admission_no, start_time), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='住院医嘱';

手术记录同理,文档写的是「手术序号、主刀医师、住院号、病人、手术名称、手术日期、手术结果、手术室」,缺了麻醉方式、术前诊断、术中记录、器械材料使用。器械材料这部分在文档里是作为业务描述提到的——「手术室需将麻醉记录、术中医嘱、材料、器械的使用记录在案」——但结构清单里没有对应的表。这类耗材要单独建一张手术耗材表,因为耗材是要计费的,直接并进手术记录里就没法算成本。

4. 约束与一致性:库存、金额、状态机三处必须设防的地方

设计文档里讲「数据完整性」「数据一致性」,多数时候停留在一句原则。真到写代码那天,这三个词要落到具体的位置上:库存扣减落在UPDATE的WHERE条件里,金额一致性落在触发器和事务边界上,状态机落在字段枚举和状态变更日志上。这一章讲的就是这三处最容易翻车的地方。

4.1 药品库存扣减:唯一可靠的防线在 UPDATE 的 WHERE 里

发药的典型写法是先查库存、判断够不够、够了再更新。这个顺序在单线程下没问题,两个人同时发同一批药就会超卖:都查到剩 10 盒,都判断够,都扣 6 盒,最后库存变成 -2。

正确做法是把判断塞进更新语句本身,靠数据库的行锁保证原子性:

-- 发药扣减:条件更新,影响行数为 0 即代表库存不足 UPDATE drug_stock SET quantity = quantity - :need_qty WHERE drug_id = :drug_id AND warehouse_id = :pharmacy_id AND batch_no = :batch_no AND quantity >= :need_qty; -- 应用层判断:affected_rows == 0 时抛库存不足,回滚整个事务

quantity >= :need_qty这个条件就是防线。InnoDB 执行这条 UPDATE 时会对命中行加排他锁,两个事务排队进,后一个进来时quantity已经被减过,条件不成立,影响行数为 0,业务层据此回滚。相比先SELECT再判断,少了一次往返,也少了一个竞态窗口。

还要加一层兜底:quantity列上加CHECK (quantity >= 0)。MySQL 8.0.16 之后支持 CHECK 约束并真正生效。这层约束平时不会触发,一旦有别的代码路径绕过上面的条件更新直接改表,它会立刻报错,比事后对账发现负库存强得多。

批次的选择逻辑也值得说一句。同一个药品可能有多个批次,发药时优先发近效期的,这是行业惯例,也是减少报损最直接的手段。实现上就是按expire_date ASC取批次,逐批扣减直到满足数量,整个过程放在一个事务里。

4.2 金额一致性:收费项目别塞成一行,账单要能重算

文档里的「收费项目」结构是「收费项目号、挂号费、药品费、检查费」,一行三个金额列。这种设计在只有一个病人一条记录的时候看着挺省事,但它隐含了一个假设:一个病人一次就诊只会产生这三种费用各一笔。现实是门诊一次开两张处方、三张检查单很常见,住院更不用说,一天几十条费用明细。所以收费必须做成「费用流水 + 结算单」两层:

CREATE TABLE charge_item ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, patient_id BIGINT UNSIGNED NOT NULL, biz_type CHAR(2) NOT NULL COMMENT 'GH挂号 CF处方 JC检查 YP药品 ZY住院', biz_no VARCHAR(20) NOT NULL COMMENT '来源业务单号', item_name VARCHAR(64) NOT NULL, amount DECIMAL(10,2) NOT NULL, pay_status TINYINT NOT NULL DEFAULT 0 COMMENT '0未付 1已付 2已退', charge_time DATETIME NULL, PRIMARY KEY (id), KEY idx_biz (biz_type, biz_no), KEY idx_patient_status (patient_id, pay_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='费用流水';

biz_type加biz_no这一对构成了「这笔钱是从哪张单子来的」的追溯链路。退费的时候按这两个字段反查,把对应的处方或检查单状态改回可退,同时插一条负数流水而不是删原记录。财务对账要的是可追溯的流水,删记录等于把账本撕了一页。

金额的另一处隐患是「头表总额」和「明细合计」不一致。处方头的total_amount必须由明细汇总写回,而且要在同一个事务里完成。我的习惯是明细全部插完之后执行一条UPDATE prescription SET total_amount = (SELECT SUM(amount) FROM prescription_detail WHERE cf_no = ?) WHERE cf_no = ?,而不是在应用层算好了再传进去。应用层算和数据库存之间只要有一次并发插入,数就对不上。

4.3 索引与数据量:一年一万张挂号单意味着什么

文档在数据存储部分给了数据量估算:挂号单、门诊病历、病人信息、检查单、医嘱都是每年一万张左右。这个量级对 MySQL 来说很小,单表十万级完全不需要分库分表,甚至不需要分区。但它揭示了一个更重要的事实:这些表是「写多读也多」的,每年新增一万,五年就是五万,加上历史查询需求,索引设计不能只考虑当天。

挂号单最典型的查询是「今天这个科室挂了哪些人」,对应(dept_id, reg_date)联合索引;第二典型的是「这个病人以前来过几次」,对应patient_id单列索引。处方表的查询集中在「这个病人最近开了什么药」和「这个药这个月出了多少」,前者走(patient_id, created_at),后者走明细表的drug_id。医嘱表按住院号查,(admission_no, start_time)一个联合索引覆盖绝大多数场景。

有一条血泪经验:别给每个字段都建索引。我见过一张挂号单上建了七个单列索引,写入性能掉了一半,查询也没快多少。判断标准很简单——出现在WHERE、JOIN ON、ORDER BY里且选择性足够高的字段才建,性别、状态这种只有两三个值的字段单独建索引基本没用,放进联合索引的最左边才有意义。

5. 避坑与排查:从设计文档到能跑的库之间的五道坎

文档写得再细,落到真实库还是会走样。下面这五条是我在不同项目里反复遇到的,按「现象 → 原因 → 解决」记下来,遇到类似的可以直接对照排查。

5.1 编码与主数据上的三处重复定义

坑一:同一个「供应商号」出现两个不同的别名和类型。

现象:建表的时候发现数据项表里既有「供应商号 别名 Gys_no 类型 char(8)」,又有「供应商号 别名 Trader_no 类型 char(8)」,订单表里用的又是Trader_no Int(8)。三个地方对不上,外键建不起来。

原因:文档是多人分工写的,数据项清单和订单表分别由不同的人整理,没有做最后的归并校对。这种情况在小组作业里极其常见。

解决:以订单表和数据流里出现频率最高的那个为准,统一成trader_no CHAR(8),Gys_no作为历史别名记录在字段注释里,不再使用。同时把「供应商」和「生产厂家」区分开——文档里订单结构有「生产地点」字段,说明采购单其实同时涉及供应商和厂家两个实体,硬塞进一个字段后期会出问题。

坑二:病区号 char(10) 但描述写「8 位组成唯一编码」。

现象:按描述写 8 位校验,导入历史数据时发现有一批 10 位的病区号,全部被拒。

原因:类型长度和业务规则不一致,文档没说明哪个优先。

解决:以类型长度为准放宽校验,业务层只校验非空和唯一,长度交给数据库约束。这类「描述和类型打架」的地方,优先信类型,因为类型是要落到 DDL 里的,描述只是注释。

坑三:病人表和医生表的字段名不统一。

现象:病人表用Br_name,医生表用doctor_name,关联查询的时候要写一堆别名。

原因:文档里病人相关字段用的是拼音缩写(Br_name、Br_sex、Br_age),医生相关字段用的是英文(doctor_no),两套命名风格混在一起。

解决:建库之前统一成一套。我的做法是全部改成下划线英文(patient_name、doctor_name),拼音缩写在注释里保留一份对照,方便老系统对接时查。这一步看着琐碎,但不做的话,后面每写一个 JOIN 都要翻一次文档。

5.2 流程状态与库存口径上的两处断链

坑四:挂号单没有状态,退号退费无从下手。

现象:上线第一周就遇到病人挂完号不想看了要退,前台改了金额字段,结果日结报表和实际收款差了几十块。

原因:文档的挂号单结构里只有「挂号号、类别、医师、姓名、科室、日期」,完全没有状态和费用字段。业务描述里也没提退号流程。

解决:加status字段(待就诊/已就诊/已退号)和reg_fee,退号走状态变更加一条负数费用流水,不删挂号记录、不改原金额。同时补一条约束:已就诊的挂号单不允许退号,只能走退费流程。这类「业务描述里没写、但现实中一定会发生」的反向流程,是设计文档最容易漏的部分,建模时要把每个正向流程都问一句「反着来怎么办」。

坑五:药房和药库是两套库存,但没有调拨单据表。

现象:药房库存对不上,查了半天发现是药房向药库领药的时候直接改了两边的数量,中间没有任何单据,谁在什么时候领了多少完全查不到。

原因:文档的数据流里提到了「药品请领单(药房 → 药库)」,但数据结构清单里没有定义这张表,开发的时候图省事就跳过了。

解决:补一张请领单主表加明细表,字段至少包括请领单号、请领药房、目标药库、状态(待审/已发/已收)、申请时间、发药时间。库存变更必须挂在这张单上——药库出库、药房入库,两条流水对应同一张单,数量对不上立刻能定位到是哪一步断的。这条经验适用范围很广:凡是涉及两个库存地点之间转移的,中间一定要有一张单据表,哪怕业务上只是一步操作。

5.3 权限与审计的缺失

坑六:文档要求分级访问,但模型里没有任何权限表。

现象:需求分析里明确写了「系统应对不同的数据设置不同的访问级别,限制访问用户可查询和处理数据的类别和内容」「应对不同用户设置不同的权限」,但翻遍数据结构,只有医生、病人这些业务实体,没有用户、角色、权限。

原因:需求分析和数据库设计脱节。写需求的人想到了,写结构的人没接住。

解决:补三张表——用户表(登录名、密码哈希、关联的员工 ID)、角色表、用户角色关联表,再补一张角色权限表(角色、资源、操作)。权限的粒度按「模块 + 操作」来,比如「药房 + 发药」「收费处 + 退费」,不要细到字段级,字段级权限在业务系统里维护成本极高,收益很小。

另外建议加一张操作日志表,记录谁在什么时候改了哪张表的哪条记录。医疗数据一旦出纠纷,没有操作日志就是黑匣子,说不清楚。

6. 二次开发:在这套模型上长出扩展模块

拿到这套设计之后,真正的工作才刚开始。我一般的做法是先跑通「最小闭环」:病人建档 → 挂号 → 开处方 → 收费 → 发药,这五步能连通、库存和金额对得上,再往上加检查、住院、手术。加模块的时候按下面的顺序走,返工最少。

第一,先加表再加代码。每加一个新模块,先写迁移脚本,把建表和索引一次写全,跑通了再写业务代码。迁移脚本按日期编号存放,只增不改,已经上过生产的那份永远不动。我给个模板:

-- V20240601__add_role_permission.sql CREATE TABLE sys_role ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, role_code VARCHAR(32) NOT NULL COMMENT '角色编码', role_name VARCHAR(64) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_role_code (role_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_role_permission ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, role_id BIGINT UNSIGNED NOT NULL, resource VARCHAR(64) NOT NULL COMMENT '模块,如 pharmacy.dispense', action VARCHAR(16) NOT NULL COMMENT '操作,如 read/write/audit', PRIMARY KEY (id), UNIQUE KEY uk_role_res_act (role_id, resource, action) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

第二,验证方法要能回放。写一个造数脚本,模拟一百个病人走完整链路,最后校验三件事:所有处方的头表总额等于明细合计、所有发药记录扣减的库存总量等于药品出库单总量、所有费用流水的合计等于结算单金额。这三条任何一条对不上,说明某个环节的事务边界有问题。

第三,改结构之前先看影响面。改一个字段类型,先查这个字段被哪些外键、索引、视图引用,再看有没有历史数据能兼容。我习惯在本地库上用SHOW CREATE TABLE把所有涉及的表导出来对比一遍,而不是凭记忆判断。

扩展方向需要新增的表不需要动的表
权限与审计用户、角色、权限、操作日志全部业务表
医保结算医保目录、结算单、对账流水处方、检查单(只加字段)
报表统计汇总宽表或物化视图流水类表
排队叫号队列、叫号记录挂号单(复用 visit_seq)

最后说个教训。早些年我改一套类似的库,觉得「就加个字段,加完就完事了」,直接在测试库上改完就同步到另一个环境,结果忘了那个环境有张视图依赖这张表的字段顺序,视图直接失效,第二天门诊挂号全部报错。从那以后我每次改表结构都强制走一遍迁移脚本,本地跑通、测试库跑通、比对SHOW CREATE TABLE确认一致才敢往下一个环境推。这套流程多花十分钟,省下的是半夜被叫起来的机会。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进