☰
医院管理系统数据库设计:表结构、索引与并发事务实践
2026/10/31 11:20:59 网站建设 项目流程

简介:面向计算机专业学生与数据库课程设计者的一份医院管理系统数据库设计文档,围绕医院信息系统(HIS)的需求分析与建模展开,适合用作课程设计、毕业设计选题参考或数据库课程配套案例。文档从门诊、住院两大部门及药库、药房、财务、检查科室等组织结构入手,梳理挂号、收费、取药、检查、住院、手术等业务流程,并讨论数据完整性、一致性、安全性、系统性能、可扩展性与法规遵从等设计要点,同时给出实体关系图、表结构与外键关联、索引优化及数据冗余平衡的思路。包内共1个docx文件,压缩包约398KB,篇幅集中、便于查阅。已有175人学习,可作为理解医院业务流与关系数据库落地过程的入门材料。

1. 门诊高峰期的一次挂号卡顿,暴露的是表结构问题

常见的一幕是:上午九点半,挂号窗口排到门口,系统却开始转圈,日志里一片锁等待超时。事后排查,问题往往不在服务器配置,而在医院管理系统数据库设计本身——号源余量靠应用层先查后改,没有数据库约束兜底,两个窗口同时扣减就把数据写花了。这个标题讲的不是某个框架,而是一套从患者主索引、号源、医嘱到收费退费的建表思路:域怎么划分、主键怎么选、约束加在哪、事务边界画多大。它适合正在给医院或区域医疗平台做后端与数据建模的开发者,也适合接手一套跑了两三年、脏数据开始冒头、想重构又不敢动的旧库的人。下面按「先定边界 → 再落表 → 处理并发 → 避坑 → 上线验证」的顺序走一遍。

2. 先定边界再画表:医院管理系统数据库设计的域划分与主键选型

2.1 六个核心域,以及外键该往哪个方向指

医院业务最大的特点是「一个患者身上挂着一长串事件」,从预约、挂号、就诊、开单、执行到结算,每一环都有自己的状态机。如果一开始就把表按「患者」「医生」「费用」随便堆在一个库里,跑到两三年后你会发现:改一个字段要评估十几个模块,删一条数据不知道谁还在引用。

我一般会先划六个域,划完之后再决定表放哪:

域主要表与其他域的关系日均增长量级
主数据域患者、科室、医生、诊疗项目、药品目录被其他所有域引用,只增不删,改走版本小
挂号预约域号源、排班、挂号记录、预约记录依赖主数据,产出就诊记录大,写多读多
就诊域就诊记录、病历、诊断、体征挂号的后继,医嘱的前驱中
医嘱域医嘱主表、医嘱明细、执行记录依赖就诊,产出收费明细大
收费域收费单、收费明细、支付流水、退费单依赖医嘱与挂号大,强一致要求最高
支撑域字典、操作日志、对账文件、归档表横切,被各域写入中

划分的原则只有一条:外键方向统一为「后发生的指向先发生的」,挂号指向患者,就诊指向挂号,医嘱指向就诊,收费明细指向医嘱。方向统一之后,任何一张表的数据来源都是唯一的,排查问题顺着 ID 往上追就行,不用满库找关联。

反过来的常见做法是双向挂着——患者表里存一个「最近一次就诊 ID」,就诊表里又存患者 ID。看着方便,实际是给自己埋雷:患者表被高频更新,缓存全失效,主从延迟一上来就出现「患者有病历但病历查不到人」。

提示:字典表(性别、婚姻状况、证件类型、医嘱状态)单独放支撑域,业务表只存字典码,不存中文。这条不做的项目,后期做统计口径统一时会非常痛苦。

2.2 主键用自增、UUID 还是业务号

这个问题在很多方案里被一句「用自增就行」带过,但医院场景有个特殊要求:数据要能跨库、跨院区合并。所以选择要结合部署形态。

方案优点代价适用
bigint 自增索引窄、写入快、页分裂少跨库合并要重新映射,暴露数据量单院区、单库
雪花 ID(long)全局唯一、趋势递增、索引友好依赖发号器,时钟回拨要处理多院区、需要合并汇总
UUID / 字符串本地生成无依赖16 字节以上,二级索引膨胀明显,随机写日志类、非核心表
纯业务号做主键语义直观业务规则一改就得改主键,级联成本极高不推荐

我一般用bigint(或雪花 long)做主键 + 业务号做唯一索引。比如挂号表主键reg_id,另外加一个reg_no(挂号流水号)并对它建唯一索引。窗口和患者看到的是reg_no,程序内部走reg_id,两不打扰。业务号将来要改编码规则(加院区前缀、加日期段)时,只动生成逻辑,不动主键。

-- 挂号表:主键与业务号分离 CREATE TABLE reg_record ( reg_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', reg_no VARCHAR(32) NOT NULL COMMENT '挂号流水号,对外展示', patient_id BIGINT UNSIGNED NOT NULL COMMENT '患者主索引ID', dept_id BIGINT UNSIGNED NOT NULL COMMENT '科室ID', doctor_id BIGINT UNSIGNED NOT NULL COMMENT '医生ID', source_id BIGINT UNSIGNED NOT NULL COMMENT '号源ID', reg_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '挂号时间', reg_status TINYINT NOT NULL DEFAULT 1 COMMENT '1已挂号 2已就诊 3已退号 4爽约', PRIMARY KEY (reg_id), UNIQUE KEY uk_reg_no (reg_no), KEY idx_patient_time (patient_id, reg_time), KEY idx_source (source_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='挂号记录';

这段 DDL 里三个地方值得说。reg_no单独建唯一索引,是为了防止窗口重复提交时插出两条一样的号;idx_patient_time是复合索引,顺序不能反,因为最高频的查询是「某患者按时间倒序看历史挂号」,时间放前面就用不上;source_id单列索引服务于号源核销统计。reg_status用 tinyint 存码值,不存中文,字典在支撑域。

2.3 患者主索引:同一个人三次就诊怎么归并

这是医院数据库设计里最容易返工的一块。同一个人可能拿身份证挂一次、拿就诊卡挂一次、急诊没带证件又建一次,最后三条患者记录,病历分散在三个 ID 下。

简化版的主索引做法是:证件类型 + 证件号建唯一索引(允许为空,因为急诊可以先建临时档),姓名 + 性别 + 出生日期建普通复合索引用于疑似重复匹配,手机号单独建索引做辅助检索。匹配策略放在应用层做「疑似合并」,人工确认后再合并,不要自动合库。

CREATE TABLE patient_master ( patient_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '患者主索引ID', patient_name VARCHAR(64) NOT NULL COMMENT '姓名', gender_code TINYINT NOT NULL COMMENT '1男 2女 0未知', birth_date DATE DEFAULT NULL COMMENT '出生日期', id_type TINYINT DEFAULT NULL COMMENT '证件类型字典码', id_no VARCHAR(64) DEFAULT NULL COMMENT '证件号,加密或脱敏存储', phone VARCHAR(20) DEFAULT NULL COMMENT '手机号', is_merged TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1已被合并', merged_to BIGINT UNSIGNED DEFAULT NULL COMMENT '合并到的主索引ID', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (patient_id), UNIQUE KEY uk_idno (id_type, id_no), KEY idx_name_birth (patient_name, birth_date), KEY idx_phone (phone) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='患者主索引';

关键点是is_merged和merged_to两个字段:合并时不做物理删除,只把旧档标记为已合并并指向新档 ID,所有业务表里的旧patient_id保持不动,查询时通过视图或应用层做一次跳转。这样合错了还能回退,比直接 UPDATE 全库业务表安全得多——那条路一旦走错,就是血泪经验。

3. 从挂号到结算:五张核心表的字段设计与约束落地

3.1 号源表:把「余量」变成可被数据库约束的字段

号源是全院并发最高的表。很多系统把它设计成「排班表 + 计数」,余量靠SELECT COUNT(*) FROM reg_record WHERE source_id = ?算出来。这在几百并发下就是灾难。

正确的做法是给号源表加两个字段:total_qty(总号数)和remain_qty(剩余号数),并把扣减写成带条件的 UPDATE,让数据库的原子性替你做校验。

CREATE TABLE reg_source ( source_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '号源ID', dept_id BIGINT UNSIGNED NOT NULL COMMENT '科室ID', doctor_id BIGINT UNSIGNED NOT NULL COMMENT '医生ID', work_date DATE NOT NULL COMMENT '出诊日期', time_period TINYINT NOT NULL COMMENT '1上午 2下午 3夜间', total_qty INT NOT NULL COMMENT '总号数', remain_qty INT NOT NULL COMMENT '剩余号数', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY (source_id), UNIQUE KEY uk_dept_doctor_date (dept_id, doctor_id, work_date, time_period), KEY idx_date_dept (work_date, dept_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='号源';

uk_dept_doctor_date这个联合唯一索引是防脏的关键:同一个医生、同一天、同一时段只能有一条号源记录,排班导入脚本跑两遍也不会插出重复号源。这比在应用层「先查有没有再插入」可靠得多,因为两个导入任务并发跑的时候,应用层判断必然漏。

扣减语句长这样,注意remain_qty > 0这个条件必须在 WHERE 里,不能在应用层判断:

UPDATE reg_source SET remain_qty = remain_qty - 1, version = version + 1 WHERE source_id = 1001 AND remain_qty > 0; -- 执行后检查 affected_rows:等于 1 才算扣减成功,等于 0 说明号已满

这条语句在 InnoDB 下会对source_id = 1001这一行加行锁,remain_qty > 0在锁内求值,所以不存在「两个事务都读到 1,都减成 0」的问题。参数上唯一要调的是事务长度——扣减和插入挂号记录必须在同一个事务里,但事务里不要再放发短信、调排队叫号接口这类操作,否则锁持有时间被外部系统拖长,高峰期直接锁等待。

3.2 就诊表与医嘱明细:冗余字段该不该放

就诊表是挂号的后继,一次挂号对应一次就诊(退号、爽约除外)。字段上最容易被忽略的是visit_type(初诊/复诊/急诊)和visit_status。这两个字段决定了病历能不能写、医嘱能不能开,属于状态机入口。

CREATE TABLE visit_record ( visit_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '就诊ID', reg_id BIGINT UNSIGNED NOT NULL COMMENT '挂号ID', patient_id BIGINT UNSIGNED NOT NULL COMMENT '患者ID,冗余自挂号', dept_id BIGINT UNSIGNED NOT NULL COMMENT '科室ID,冗余自挂号', visit_type TINYINT NOT NULL DEFAULT 1 COMMENT '1初诊 2复诊 3急诊', visit_status TINYINT NOT NULL DEFAULT 0 COMMENT '0候诊 1就诊中 2已结束 3已取消', start_time DATETIME DEFAULT NULL COMMENT '接诊时间', end_time DATETIME DEFAULT NULL COMMENT '结束时间', PRIMARY KEY (visit_id), UNIQUE KEY uk_reg (reg_id), KEY idx_patient_time (patient_id, start_time), KEY idx_dept_status (dept_id, visit_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='就诊记录';

patient_id和dept_id是从挂号表冗余过来的。冗余字段要不要放,判断标准是:这个字段会不会变,以及查询时能不能避免 JOIN。患者和科室在一次就诊生命周期内不会变,冗余是安全的;但如果你把患者的姓名也冗余过来,某天患者改名字,历史就诊记录的名字就对不上了,这种就不能冗余。

uk_reg这个唯一约束保证一次挂号只能产生一条就诊记录。这条约束看着不起眼,实际上是防止「重复接诊」这类脏数据最省事的办法。

医嘱明细拆表的逻辑是:医嘱主表存开立信息(谁开的、什么时间、状态),明细表存具体项目和用法用量。一张医嘱单可以有十几条明细,拆开后主表窄、明细表宽,分别命中不同查询。

CREATE TABLE order_detail ( detail_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '明细ID', order_id BIGINT UNSIGNED NOT NULL COMMENT '医嘱主表ID', item_id BIGINT UNSIGNED NOT NULL COMMENT '诊疗项目/药品ID', item_type TINYINT NOT NULL COMMENT '1药品 2检查 3检验 4治疗', qty DECIMAL(10,2) NOT NULL DEFAULT 1 COMMENT '数量', unit_price DECIMAL(12,2) NOT NULL COMMENT '单价,下单时快照', exec_status TINYINT NOT NULL DEFAULT 0 COMMENT '0未执行 1已执行 2已退', PRIMARY KEY (detail_id), KEY idx_order (order_id), KEY idx_item_type (item_type, exec_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医嘱明细';

unit_price是快照字段,必须在开单那一刻把价格写死。药品和项目调价是常态,如果收费时再去项目表取价格,就会出现「昨天开的单今天涨价了」的争议。这类快照字段在收费域里是硬要求,item_name同理也建议冗余一份。

3.3 收费单与支付流水:金额字段的类型选择

金额字段用DECIMAL(12,2),不要用FLOAT或DOUBLE。这不是教条,是浮点二进制表示不了 0.1 这类十进制小数,累加几百笔之后对账必然差几分钱,而医院的账差一分钱都要写说明。

并发上,收费单和支付流水要分开:收费单记录「应收」,流水记录「实收/实退」。一笔收费单可以对应多次支付(先付一部分、后补缴),所以是 1:N 关系,流水表用biz_type + biz_no + direction建唯一索引做幂等。

CREATE TABLE pay_flow ( flow_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '流水ID', charge_id BIGINT UNSIGNED NOT NULL COMMENT '收费单ID', biz_type TINYINT NOT NULL COMMENT '1挂号费 2门诊收费 3住院预缴', biz_no VARCHAR(32) NOT NULL COMMENT '业务单号', direction TINYINT NOT NULL COMMENT '1收 2退', amount DECIMAL(12,2) NOT NULL COMMENT '金额', pay_channel TINYINT NOT NULL COMMENT '1现金 2扫码 3医保', trade_no VARCHAR(64) DEFAULT NULL COMMENT '外部渠道交易号', flow_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (flow_id), UNIQUE KEY uk_biz (biz_type, biz_no, direction), KEY idx_charge (charge_id), KEY idx_time_channel (flow_time, pay_channel) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付流水';

uk_biz是整套设计里最值得加的一个约束。支付渠道回调是出了名的会重发,没有这个唯一键,同一笔缴费被回调两次就多收一次钱,事后只能靠人工退款。有了它,第二次插入直接报唯一键冲突,捕获后返回成功即可,这就是最朴素的幂等。

3.4 复合索引的字段顺序:三个高频查询的排法

索引不是越多越好,医院系统单表建到十几个索引,写入会被拖慢。按查询场景倒推顺序:

查询场景建议索引顺序理由
患者查历史就诊(按时间倒序分页)(patient_id, start_time)等值列在前,排序列在后,可避免 filesort
科室查当日候诊列表(dept_id, visit_status)两个都是等值,区分度高的放前
收费对账按日期 + 渠道汇总(flow_time, pay_channel)范围列在前,范围后的列只用于覆盖,不用于过滤
药品查询按名称模糊(item_name)前缀匹配只有前缀匹配like 'xx%'能走索引

一个常见误用是把(visit_status, dept_id)建成索引然后查dept_id = ? AND visit_status = 1。理论上优化器能调整,但等值列之间顺序影响不大;真正影响大的是把范围列放前面,比如(start_time, dept_id)去查某天的某科室,一旦时间范围跨几天,后面的列就退化成扫描过滤。记住一条:等值在前,范围在后,排序字段跟在等值后面。

4. 事务与并发:号源扣减、余额扣费和退费回滚怎么写

4.1 号源扣减三种写法的取舍与隔离级别

第一种是乐观锁:先读version,UPDATE 时带上WHERE version = ?,影响行数为 0 就重试。优点是锁持有时间极短,缺点是高并发下重试多,抢热门专家号时可能重试十几次。

第二种是悲观锁:SELECT ... FOR UPDATE锁住行再判断再更新。优点是逻辑直观,缺点是锁持有时间长,如果事务里夹了其他操作,容易连锁等待。在 MySQL 的默认可重复读隔离级别下,FOR UPDATE是当前读,读到的是最新已提交数据,所以不会出现读到旧余量的问题。

第三种是条件更新(前面 3.1 演示的写法):不显式加锁,靠WHERE remain_qty > 0在行锁内求值。这是我最常用的方式,代码最短,也不需要重试循环,判空靠affected_rows。

START TRANSACTION; -- 1. 条件扣减号源,靠 affected_rows 判断是否抢到号 UPDATE reg_source SET remain_qty = remain_qty - 1, version = version + 1 WHERE source_id = 1001 AND remain_qty > 0; -- 2. 插入挂号记录(应用层在此判断上一步影响行数,为 0 则 ROLLBACK) INSERT INTO reg_record (reg_no, patient_id, dept_id, doctor_id, source_id, reg_status) VALUES ('R20240515000123', 88001, 12, 305, 1001, 1); -- 3. 提交。短信通知、叫号推送放到提交之后做 COMMIT;

事务边界就这三步。reg_no的生成不要放在事务里做(比如SELECT MAX(reg_no)再 +1),那会引入额外的锁竞争和重复风险,正确做法是应用层用「日期 + 序列号」预生成或用发号器,在开启事务前就拿到。

隔离级别上,挂号扣减用默认可重复读即可,因为条件更新是当前读,天然安全。但如果你在同一个事务里还要SELECT一次号源展示给用户,要注意可重复读下快照读可能读到旧值,别用读到的值再做业务判断。

4.2 收费与退费:状态机加流水,别用余额字段硬扛

收费单表要有明确的charge_status:0 待支付、1 已支付、2 部分退费、3 全额退费、4 已作废。退费不是删记录,是插一条direction = 2的流水并把状态推进。

-- 退费:先插红字流水(唯一索引保证不会重复退),再改收费单状态 START TRANSACTION; INSERT INTO pay_flow (charge_id, biz_type, biz_no, direction, amount, pay_channel, trade_no) VALUES (77001, 2, 'C2024051500088', 2, 128.50, 2, 'RF20240515000901'); UPDATE charge_order SET charge_status = 3, refund_amount = 128.50 WHERE charge_id = 77001 AND charge_status = 1; -- 只有已支付才能退,条件放在 WHERE 里防并发重复退 COMMIT;

注意WHERE charge_status = 1这个条件,它是退费并发安全的核心。两个退费窗口同时操作同一张单,只有一个能把状态从 1 改走,另一个affected_rows为 0,直接提示「该单据状态已变更,请刷新」。加上流水表的唯一索引,双保险。

对账靠什么?靠流水表。按flow_time区间 +pay_channel汇总direction = 1减direction = 2,与渠道账单比对。所以流水表绝不能物理删除,也不能 UPDATE 金额,写错了就冲红再补一笔。这条规矩要在开发规范里写死,否则迟早有人为了「修个数据」直接 UPDATE,账就再也对不上了。

4.3 历史数据归档:分区和搬表各管一段

医院数据的特点是「热数据集中在近三个月,冷数据要留很多年」。挂号、就诊、流水这三张表增长最快。

MySQL 的分区表(RANGE 按flow_time年份)能缓解单表膨胀,但分区键必须在所有唯一索引里,这意味着uk_biz得带上时间字段,反而削弱了幂等约束。所以我一般不用分区,改成归档表 + 定时搬迁:主表只保留近 N 个月,历史数据按年搬到pay_flow_2023这类结构相同的归档表,查询历史时按时间路由到对应表。

搬迁脚本要注意两点:一是用主键范围分批(每批 5000 行)删除,不要一次DELETE WHERE flow_time < ?,那会产生巨大的 undo 和主从延迟;二是先插归档表再删主表,中间用事务包住,避免搬一半失败丢数据。

注意:归档前先确认没有外键指向这些表,或者外键已改为应用层维护。医院系统里带物理外键的表在归档时会互相阻塞。

5. 数据库设计的五个避坑记录:从字段类型到权限隔离

5.1 字段类型与字符集上的三个坑

坑一:金额用了 FLOAT。现象是月末对账时,几十万条流水的汇总金额和渠道账单差 0.03 元,怎么查都查不出哪笔错了。 原因是浮点数用二进制表示十进制小数,0.1 在二进制里是无限循环,累加十万次误差被放大。 解决办法是金额字段统一DECIMAL(12,2),如果历史表已经建成 FLOAT,迁移时用ALTER TABLE ... MODIFY amount DECIMAL(12,2),迁移前先用SELECT SUM(amount)存一份基准值,迁完比对。更彻底的做法是用BIGINT存「分」,彻底避开小数。

坑二:字符集用了 utf8。现象是患者姓名里带生僻字或者少数民族名字,存进去变成问号,导出报表一片乱码。 原因是 MySQL 里的utf8实际是三字节实现,覆盖不了四字节字符。 解决办法是建库时就定utf8mb4,建表显式写DEFAULT CHARSET=utf8mb4,连接串里的characterEncoding也要同步。已经建错的库改起来麻烦:要改库、改表、改列三级,还要考虑索引长度限制(utf8mb4 下单列索引前缀长度要重新算),最好在项目初期就定死。

坑三:状态字段用 varchar 存中文。现象是同一个「已退费」状态,有的表里是「已退费」,有的是「已退款」,有的是「退费」,统计口径全乱。 原因是状态值没有统一字典,各模块自己拍脑袋写。 解决办法是全部改成TINYINT存码值,字典集中放在支撑域,前端展示时再翻译。改造成本比想象中低,一个模块一个模块替换即可,但一定要在订单类表上加CHECK约束或者应用层枚举校验,防止有人再写回字符串。

5.2 事务与连接池上的坑

坑四:事务里夹了远程调用。现象是高峰期突然大批请求报连接池耗尽,日志显示大量事务长时间未提交。 原因是事务方法里调了短信网关或叫号接口,对方超时 5 秒,行锁就被持有 5 秒,几十个并发就把连接池占满。 解决办法是把非数据库操作全部移出事务:先提交事务,再在事务提交后的钩子里发通知;如果必须保证一致性,用本地消息表——在同一个事务里往消息表插一条待发送记录,由定时任务异步投递。

坑五:关键路径读从库,刚挂号查不到。现象是患者刚挂完号,去分诊台扫码提示「无此挂号记录」,刷新几次又好了。 原因是写主库、读从库,主从延迟几百毫秒。 解决办法是挂号、缴费这类写后立即可读的路径强制走主库,用会话级路由或者代码里显式指定数据源;对延迟不敏感的报表查询才走从库。另外,主从延迟监控要加到告警里,延迟超过阈值时自动把所有读切回主库。

5.3 权限与运维上的坑

坑六:开发账号直接给了全库权限。现象是某次上线后核心表数据被改,binlog 里能看到 UPDATE 但不知道是谁执行的。 原因是开发、测试、运维共用一个高权限账号,操作无痕。 解决办法是按角色拆账号:报表账号只读指定视图,应用账号只有 DML 权限没有 DDL,运维账号单独审批。患者身份证号、手机号这类敏感字段,通过视图做脱敏后再授权给查询类账号,业务表本身不对普通账号开放 SELECT。这件事越早做越省事,等库里有上百万患者数据再补权限体系,审计压力会很大。

6. 上线前用造数脚本压一遍,比上线后救火便宜得多

表结构定完不等于能用。我习惯在上线前造一批比生产环境更大的数据,把三个最慢的查询跑一遍,看执行计划而不是看感觉。

import random, datetime import pymysql conn = pymysql.connect(host="127.0.0.1", port=3306, user="bench", password="***", database="his_demo", charset="utf8mb4") cur = conn.cursor() # 造 50 万条挂号,时间跨度两年,用于验证复合索引和分页 base = datetime.datetime(2023, 1, 1) rows = [] for i in range(500000): reg_time = base + datetime.timedelta(minutes=random.randint(0, 1051200)) rows.append((f"R{i:012d}", random.randint(1, 200000), random.randint(1, 60), random.randint(1, 800), random.randint(1, 20000), reg_time, random.randint(1, 4))) if len(rows) >= 5000: # 分批提交,避免大事务 cur.executemany( "INSERT INTO reg_record (reg_no, patient_id, dept_id, doctor_id," " source_id, reg_time, reg_status) VALUES (%s,%s,%s,%s,%s,%s,%s)", rows) conn.commit() rows.clear() conn.commit() cur.close() conn.close()

executemany每批 5000 行是关键参数。批次太小,往返开销占大头;批次太大,单事务 undo 膨胀,回滚段撑爆。5000 到 10000 之间是比较稳的区间,机器配置好的可以往上调,但别超过 5 万。

造完数之后跑三条查询,每条前面加EXPLAIN,重点看type、key、rows三列:

待验查询期望结果不合格时的典型表现
某患者按时间倒序取 20 条type=ref,key=idx_patient_time,Extra无Using filesort出现Using filesort,说明索引顺序反了
某科室当日候诊列表type=ref,rows在百级rows到万级,说明缺(dept_id, visit_status)
某日流水按渠道汇总type=range,key=idx_time_channel,Extra有Using indextype=ALL,说明时间字段没进索引

第三条如果看到Using index,说明是覆盖索引,连回表都省了,这在汇总类查询里差别很大。

最后补一张上线前的复查表,逐项打勾再放行:

检查项判定标准
金额字段类型全库无 FLOAT/DOUBLE 存金额
字符集库、表、连接三处均为 utf8mb4
唯一约束挂号流水号、支付流水幂等键、就诊与挂号一对一均已建
高频查询索引三条核心查询EXPLAIN均无Using filesort且rows可控
权限应用账号无 DDL,敏感字段查询走视图
归档策略大表有按时间搬迁脚本,且脚本分批提交

这套东西我第一次做的时候嫌麻烦,直接上了生产,结果第二周就碰上对账差三分钱和重复回调多收一笔,返工花的时间是造数脚本的十倍不止。后来就养成习惯了:表结构评审通过不算完,压完数据、看过执行计划、权限核过一遍,才算能上线。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询