2026/9/14 12:35:13

C#电子病历管理系统设计与实现:从数据模型到权限审计

C#电子病历管理系统设计与实现:从数据模型到权限审计 简介面向C#初学者与毕业设计开发者这份源码完整呈现了基于ASP.NET Web Forms的电子病历管理系统覆盖用户权限、病历录入、患者信息维护、病历检索、报告生成与数据安全等核心模块可直接运行或作为课程设计与毕业设计的基础框架。压缩包共138个文件其中33个.cs源文件与26个.aspx页面构成主要业务逻辑辅以CSS/JS控制界面样式、DLL封装组件、MDF/LDF/DB数据库文件整体仅1.34MB结构紧凑且便于快速部署调试。已有127人学习下载适合希望深入理解C#企业级开发、诊所或小型医院信息化流程的人群。通过阅读源码可掌握ADO.NET数据访问、多角色权限设计、病历数据建模及典型WebForms分层架构同时内含GIF/PNG/JPG界面资源、解决方案文件与txt说明便于对照查看运行效果与设计思路为二次开发与功能扩展提供扎实参考。1. 该从哪套架子开始写 C# 电子病历管理系统医院信息科或软件公司的老项目里C# 写的电子病历管理系统并不少见。脱胎于 .NET Framework 时代的 WinForms 桌面端再逐步演进到 WPF 和 ASP.NET Core Web 端这套技术栈在医疗信息化里留下了大量存量代码。很多人拿到一个“电子病历管理系统源码.zip”第一反应是打开解决方案看结构但真正上手时才发现病历系统不是普通的增删改查它要管的是患者主索引、结构化病历文书、医嘱闭环、诊断编码和权限留痕。只看代码不看模型改一改就会把病历数据改坏。这篇文章要做的是把这套系统拆成一个可复现的实现路径从技术选型、数据模型设计到用 C# 落地患者管理、病历书写、查询统计、PDF 导出和操作审计。读者如果是准备接手或重构这类源码的 .NET 工程师可以直接跳到第 3 章对表结构、第 4 章对查询写法如果是打算用 C# 从零写一套小规模病历系统建议按顺序读完中间给出的建库脚本和代码片段都是能直接落地的。2. C# 电子病历管理系统的技术选型与项目结构2.1 选桌面端还是 B/S 架构先看使用场景电子病历系统的使用者是医生、护士、病案室和医务科使用场景决定了架构选型。如果是单院区的住院部终端数量二三十台数据量不大WinForms 或 WPF 桌面客户端完全够用开发效率高打印控件和键盘操作响应直接。如果是多院区或集团医院要支持远程会诊、移动查房和多机构共享那就得考虑 ASP.NET Core Web API 加前端 SPA 的 B/S 架构。把两个方案的取舍摆出来对比对比项桌面端WinForms/WPFWeb 端ASP.NET Core部署复杂度每台终端装客户端只部署服务器浏览器访问离线使用支持本地缓存依赖网络打印控制直接调 System.Drawing.Printing需走浏览器打印或服务端 PDF权限管控客户端本地校验服务端统一校验更安全二次开发难度相对低状态管理简单前后端分离模型更复杂我的观点很简单如果是拿这套源码做产品原型或院内部署优先桌面端原因是交付快、沟通成本低。如果目标是要长期演进、接入集成平台HL7、FHIR从一开始就做 Web API 更省事。C# 目前在这两条路上都有成熟框架不用纠结语言能力问题纠结的是项目周期和运维能力。2.2 数据存储与 ORMSQL Server 还是 SQLiteEF Core 还是 Dapper医疗数据最忌讳丢数据数据库选型首先看事务能力和备份恢复能力。常见做法是开发环境用 SQL Server LocalDB 或 SQLite生产环境用 SQL Server 标准版如果预算不够就用 PostgreSQL。SQL Server 的优势在于和 .NET 生态的集成度比如 TransactionScope、SqlDependency、全文索引这些在病历检索和消息推送场景里都有用处。ORM 的选择要看团队习惯。EF Core 适合模型驱动开发迁移工具能自动同步表结构但在复杂查询和批量更新上不如 Dapper 直观。Dapper 适合那些 SQL 功底扎实、希望掌控每一条查询的团队。我个人在病历系统里倾向混用写入和事务用 EF Core复杂统计和报表查询用 Dapper。这样既能利用 EF Core 的变更跟踪又不会在报表 SQL 上被 LINQ 的表达式树束缚。2.3 解决方案的分层结构与命名约束一个能长期维护的 C# 解决方案至少拆成四个项目不要全塞进一个类库里。EMR.sln ├── EMR.Domain // 实体、枚举、领域服务接口 ├── EMR.Infrastructure // EF Core DbContext、仓储实现、文件存储 ├── EMR.Application // 用例逻辑、DTO、校验 ├── EMR.Api // ASP.NET Core Web API 或 WinForms 启动项目Domain 层不引用任何基础设施包这样后续换数据库或加消息队列影响范围只限制在 Infrastructure 内。Application 层负责组织用例流程比如“提交病历”这个动作要在事务里同时写病历主表、内容表、操作日志表这层代码只编排不落 SQL。Api 层只处理输入输出和权限校验。命名上表名一律用英文复数或单数要保持一致字段名用 PascalCase 对应 C# 属性日期字段统一带上At后缀比如CreatedAt、SubmittedAt避免后面做统计时对时间字段的语义产生歧义。主键不用自然键一律用自增Id或Guid病历号、患者号这些业务编号单独建唯一索引不充当主键。3. 核心数据模型与建库脚本患者、病历文书和医嘱怎么落表3.1 患者主索引MPI设计要避免的坑电子病历系统的第一张表是患者主索引Master Patient Index它不能简单设计成“姓名性别身份证号”的字段堆叠。同一个患者可能在不同科室登记过多次住院和门诊是两个号甚至姓名在历史档案里有过变更。正确做法是建一张Patients表做主数据再建PatientVisits表记录每次就诊。CREATE TABLE Patients ( Id INT IDENTITY(1,1) PRIMARY KEY, PatientNo NVARCHAR(32) NOT NULL, Name NVARCHAR(50) NOT NULL, Gender TINYINT NOT NULL DEFAULT 0, BirthDate DATE NULL, IdCardNo NVARCHAR(18) NULL, Phone NVARCHAR(20) NULL, Address NVARCHAR(200) NULL, CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), UpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() ); CREATE UNIQUE INDEX UX_Patients_PatientNo ON Patients(PatientNo);PatientNo是业务编号由系统生成格式建议是P yyyyMMdd 4位流水号。不要用身份证号做业务主键因为部分患者新生儿、外籍人员没有身份证而且身份证号涉及敏感信息不该在病历系统里做关联主键到处引用。Gender用TINYINT存枚举不在数据库存中文避免编码混乱。3.2 病历文书的结构化存储主表加内容表病历文书是这套系统的核心资产类型包括入院记录、病程记录、出院小结、手术记录等。每种文书的字段不一样如果为每种文书建一张表项目后期会陷入表数量爆炸的困境。业界常用 EAV实体-属性-值或 JSON 扩展我更推荐“主表 内容表”的方式。CREATE TABLE MedicalRecords ( Id INT IDENTITY(1,1) PRIMARY KEY, RecordNo NVARCHAR(40) NOT NULL, VisitId INT NOT NULL FOREIGN KEY REFERENCES PatientVisits(Id), RecordType TINYINT NOT NULL, -- 1入院记录 2病程记录 3出院小结 Title NVARCHAR(100) NOT NULL, Status TINYINT NOT NULL DEFAULT 0, -- 0草稿 1已提交 2已归档 AuthorId INT NOT NULL, AuthorName NVARCHAR(50) NOT NULL, -- 冗余存储用于列表展示 CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), UpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), SubmittedAt DATETIME2 NULL ); CREATE TABLE MedicalRecordContents ( Id INT IDENTITY(1,1) PRIMARY KEY, RecordId INT NOT NULL FOREIGN KEY REFERENCES MedicalRecords(Id), SectionName NVARCHAR(50) NOT NULL, -- 例如主诉、现病史、既往史 ContentJson NVARCHAR(MAX) NOT NULL, -- 存储该小节的结构化内容 SortOrder INT NOT NULL DEFAULT 0 );把文书的固定信息放主表把不定长的段落内容放子表这样查询列表时只扫主表打开文书时再按RecordId加载内容段。ContentJson字段存的是该小节的 JSON比如“主诉”可能是{text:咳嗽3天,duration:3天}这样既保留了结构化检索能力又避免了为不同文书建几十张表。3.3 医嘱与诊断编码闭环和版本管理医嘱表要比普通业务表多两个字段ClinicalStatus和CancelReason。医嘱不是写进去就完事它有开立、执行、停止、作废等状态流转。CREATE TABLE MedicalOrders ( Id INT IDENTITY(1,1) PRIMARY KEY, OrderNo NVARCHAR(40) NOT NULL, VisitId INT NOT NULL, OrderType TINYINT NOT NULL, -- 1药品 2检查 3检验 4治疗 Content NVARCHAR(500) NOT NULL, Dosage NVARCHAR(100) NULL, Frequency NVARCHAR(50) NULL, ClinicalStatus TINYINT NOT NULL DEFAULT 0, -- 0开立 1执行 2停止 3作废 CreatedBy INT NOT NULL, CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), StopReason NVARCHAR(200) NULL ); CREATE INDEX IX_MedicalOrders_VisitId ON MedicalOrders(VisitId);诊断编码建议用 ICD-10不要自己在表里维护诊断字典直接引国家标准码表。如果不想引入大量基础数据可以只存ICDCode和ICDName两个字段但一定要给ICDCode建索引因为后续的疾病统计报表都靠这个字段分组。4. 关键功能在 C# 里的落地实现4.1 数据访问EF Core 的上下文配置与连接串注意事项EF Core 的DbContext配置里最容易踩坑的是连接串和迁移程序集。如果解决方案里Dbcontext放在EMR.Infrastructure但启动项目是EMR.Api迁移命令要指定两个参数。dotnet ef migrations add InitSchema \ --project src/EMR.Infrastructure \ --startup-project src/EMR.Api \ --output-dir Migrations--project指定包含DbContext的项目--startup-project指定启动项目--output-dir把迁移文件放在指定目录。不指定这些参数时EF Core 默认去启动项目的目录找DbContext经常报“无法创建 DbContext”的错误。连接串不要写在appsettings.json里用明文开发环境还可以接受生产环境一定要用环境变量或配置中心覆盖。医疗数据对安全的要求不是一句空话连接串泄露等于把整个病区数据拱手送人具体可以这样配置builder.Services.AddDbContextEmrDbContext(options { var connStr Environment.GetEnvironmentVariable(EMR_DB_CONNECTION) ?? builder.Configuration.GetConnectionString(DefaultConnection); options.UseSqlServer(connStr, sql { sql.EnableRetryOnFailure(3, TimeSpan.FromSeconds(5), null); }); });EnableRetryOnFailure是 SqlServer 提供的一个弹性策略在数据库发生瞬时故障时自动重试三次每次间隔五秒。病历写入操作涉及多张表如果中途断连重试比直接抛异常更符合临床操作习惯。注意它只处理瞬时故障不处理语法错误和约束冲突。4.2 患者信息维护Dapper 写动态查询避免拼接 SQL 注入患者查询是病历系统的入口功能医生输入姓名、病历号、手机号等条件组合检索。用 EF Core 的IQueryable动态拼接条件没问题但如果是报表类接口我习惯用 Dapper 直接写 SQL。下面是一段典型的多条件动态查询public async TaskIEnumerablePatientDto SearchPatientsAsync(PatientQuery query) { var sql new StringBuilder( SELECT Id, PatientNo, Name, Gender, BirthDate, Phone, Address FROM Patients WHERE 1 1 ); var parameters new DynamicParameters(); if (!string.IsNullOrWhiteSpace(query.Name)) { sql.Append( AND Name LIKE Name); parameters.Add(Name, $%{query.Name}%); } if (!string.IsNullOrWhiteSpace(query.PatientNo)) { sql.Append( AND PatientNo PatientNo); parameters.Add(PatientNo, query.PatientNo); } if (query.BirthDateFrom.HasValue) { sql.Append( AND BirthDate BirthDateFrom); parameters.Add(BirthDateFrom, query.BirthDateFrom.Value); } sql.Append( ORDER BY Id DESC OFFSET Offset ROWS FETCH NEXT PageSize ROWS ONLY); parameters.Add(Offset, (query.Page - 1) * query.PageSize); parameters.Add(PageSize, query.PageSize); using var conn _dbConnectionFactory.CreateConnection(); return await conn.QueryAsyncPatientDto(sql.ToString(), parameters); }这段代码的核心在DynamicParameters。用户输入的搜索词全部通过参数化方式传入杜绝了拼接 SQL 的注入风险。OFFSET ... FETCH NEXT是 SQL Server 的分页写法注意必须配合ORDER BY使用否则报错。分页参数Offset和PageSize也走参数化不要在 SQL 里写死页码。搜索条件里如果还有身份证号、性别这些精确匹配字段继续往if块里加就行。这种写法的性能上限是单表百万行级别加了索引后基本能稳定在毫秒级返回对电子病历系统的患者检索场景足够了。4.3 病历内容保存用事务保证主表和内容表的一致性保存一份病历文书要同时处理MedicalRecords主表和各小节内容。不能先写主表再写子表否则中途出错会导致主表有记录而内容缺失医生重新打开就是一张空白的已提交病历。public async Task SaveRecordAsync(SaveRecordCommand cmd) { await using var transaction await _db.Database.BeginTransactionAsync(); var record await _db.MedicalRecords .Include(r r.Contents) .FirstOrDefaultAsync(r r.Id cmd.RecordId); if (record is null) { record new MedicalRecord { RecordNo await GenerateRecordNoAsync(cmd.VisitId), VisitId cmd.VisitId, RecordType cmd.RecordType, Title cmd.Title, Status cmd.Status, AuthorId cmd.AuthorId, AuthorName cmd.AuthorName }; _db.MedicalRecords.Add(record); } else { record.Title cmd.Title; record.Status cmd.Status; record.UpdatedAt DateTime.UtcNow; _db.MedicalRecordContents.RemoveRange(record.Contents); } foreach (var section in cmd.Sections) { record.Contents.Add(new MedicalRecordContent { SectionName section.SectionName, ContentJson JsonSerializer.Serialize(section.Content), SortOrder section.SortOrder }); } await _db.SaveChangesAsync(); await transaction.CommitAsync(); }BeginTransactionAsync开启的事务把主表更新和子表重建包在了一起。更新已存在的病历时先RemoveRange再新增是为了避免逐条比对增删改的麻烦病历内容段数量通常在几十条以内全删全插的代价完全可以接受。注意RemoveRange只是标记删除SaveChangesAsync时才会真正生成 DELETE 语句。GenerateRecordNoAsync生成病历号时要考虑并发。用DateTime.Now.ToString(yyyyMMddHHmmss)加随机数只适合低并发演示正式环境建议在数据库层用序列或独立流水号表否则两个医生同时保存可能出现同一个病历号做病案归档时直接冲突。4.4 图表展示与病历打印WinForms 下用 PrintDocument 输出病历系统的打印需求绕不开出院小结、检验报告单都需要纸质或 PDF 归档。WinForms 里最常见的做法是使用PrintDocument控件。核心逻辑在PrintPage事件里用Graphics对象画文本和线条这种方式的优点是所见即所得但不适合做复杂排版。private void PrintDocument_PrintPage(object sender, PrintPageEventArgs e) { var font new Font(宋体, 12); var brush Brushes.Black; float y 20; e.Graphics.DrawString(出院小结, new Font(宋体, 20, FontStyle.Bold), brush, 220, y); y 40; e.Graphics.DrawString($姓名{_patient.Name}, font, brush, 40, y); y 25; e.Graphics.DrawString($住院号{_visit.InpatientNo}, font, brush, 40, y); y 25; e.Graphics.DrawLine(Pens.Black, 30, y, 770, y); y 20; foreach (var section in _recordSections) { e.Graphics.DrawString(${section.SectionName}, font, brush, 40, y); y 20; e.Graphics.DrawString(section.ContentText, font, brush, 60, y); y section.ContentText.Length / 30 * 22 10; } e.HasMorePages false; }关键参数是e.Graphics.DrawString的坐标定位y变量像光标一样逐行下移。DrawLine画分隔线HasMorePages false表示只有一页如果内容超长要计算总高度后把HasMorePages设为true并把y重置否则内容会被截断。这里有一个真实的坑Graphics.DrawString用Font(宋体, 12)在部分没有安装中文字体的 Windows Server 上会显示成方框。解决办法是引用系统字体后检查FontFamily是否可用或者用微软雅黑替代宋体作为默认打印字体。4.5 使用 iText 7 生成 PDF 病历归档文件如果系统要做无纸化归档把病历渲染成 PDF 比直接打印更稳妥。iText 7 是 C# 生态里常用的 PDF 生成库它的定位坐标体系和 WinForms 打印不同是从页面左下角为原点计算的。public byte[] RenderRecordToPdf(MedicalRecordView record) { using var ms new MemoryStream(); var writer new PdfWriter(ms); var pdf new PdfDocument(writer); var page pdf.AddNewPage(); var canvas new PdfCanvas(page); var rect new Rectangle(36, 36, page.GetPageSize().GetWidth() - 72, page.GetPageSize().GetHeight() - 72); // 在指定矩形框内排版 canvas.Rectangle(rect); canvas.Stroke(); var text new PdfText(rect); text.SetTextAlignment(TextAlignment.LEFT); text.AddParagraph(患者姓名 record.PatientName); text.AddParagraph(病历类型 record.RecordTypeName); foreach (var section in record.Sections) { text.AddParagraph(section.SectionName section.ContentText); } new Canvas(canvas, pdf) .Add(text) .Close(); pdf.Close(); return ms.ToArray(); }new Rectangle(36, 36, width - 72, height - 72)的含义是距离页面四边各 36 磅约 1.27 厘米这是常见的页边距设置。PdfText的矩形框决定了内容的排版边界超出部分不会自动换页而是被截断所以长篇病历时要注意分段拆页。iText 7 里Canvas的Close()必须调用它负责把内容对象刷入页面。5. 权限控制与操作审计电子病历不能是谁都能改的文档5.1 基于角色的 RBAC 设计医生、护士、病案员能做什么病历系统的权限比一般管理系统要严格医生能写自己的病历但不能改别人的护士可以录入生命体征但不能修改诊断病案室只能归档和调阅不能编辑。最常见的落地方式是 RBAC基于角色的访问控制三张核心表是用户、角色、权限再加两张关联表。CREATE TABLE Roles ( Id INT IDENTITY(1,1) PRIMARY KEY, RoleCode NVARCHAR(50) NOT NULL UNIQUE, RoleName NVARCHAR(50) NOT NULL ); CREATE TABLE RolePermissions ( RoleId INT NOT NULL, PermissionCode NVARCHAR(100) NOT NULL, PRIMARY KEY (RoleId, PermissionCode) );权限码建议用Module:Action的格式比如record:write表示写病历record:submit表示提交病历record:archive表示归档。判断权限时不用去查数据库登录时把当前用户的所有权限码加载进内存缓存如IMemoryCache每次请求只做字符串判断性能损耗可以忽略。5.2 行级权限一条 SQL 管住“只看本科室”角色权限控制的是“能不能做”行级权限控制的是“能看到哪些数据”这是个经常被忽略的坑。一个住院医不能看到全院所有患者的病历只能看到自己科室和管床患者的。简单做法是在查询病历列表时强制带上科室过滤条件。public async TaskListMedicalRecordListItem GetMyDepartmentRecordsAsync(int departmentId, int page, int size) { var sql SELECT r.Id, r.RecordNo, r.Title, r.Status, r.CreatedAt, p.Name AS PatientName, p.PatientNo FROM MedicalRecords r INNER JOIN PatientVisits v ON r.VisitId v.Id INNER JOIN Patients p ON v.PatientId p.Id WHERE v.DepartmentId DepartmentId ORDER BY r.CreatedAt DESC OFFSET Offset ROWS FETCH NEXT Size ROWS ONLY ; using var conn _dbConnectionFactory.CreateConnection(); return (await conn.QueryAsyncMedicalRecordListItem(sql, new { DepartmentId departmentId, Offset (page - 1) * size, Size size })).ToList(); }不要把科室筛选逻辑写在 C# 的内存里先查出全部再 where一旦数据量过万页面直接卡死。DepartmentId参数必须从当前登录用户的 Token 或 Session 中获取不能接受前端传值否则用户改一下请求参数就能越权访问其他科室的数据。联合查询里PatientVisits充当了病历和患者之间的桥表如果查询慢先确认VisitId两边都有索引。5.3 操作日志与防篡改哈希链的做法病历一旦提交归档就不可修改这是合规要求。技术上能做的是记录操作日志同时对关键内容做哈希校验。行级哈希的做法是每当病历内容保存时把上一版的哈希和当前内容拼在一起做 SHA256形成一条哈希链。public string ComputeRecordHash(MedicalRecord record) { var contentBuilder new StringBuilder(); foreach (var section in record.Contents.OrderBy(s s.SortOrder)) { contentBuilder.Append(section.SectionName) .Append(|) .Append(section.ContentJson) .Append(|); } var rawString ${record.RecordNo}:{record.SubmittedAt:O}:{record.PreviousHash}:{contentBuilder}; var bytes SHA256.HashData(Encoding.UTF8.GetBytes(rawString)); return Convert.ToHexString(bytes); }PrevioustHash字段需要加在MedicalRecords表上。每当病历提交时取归档状态的最新一条记录的哈希值作为PreviousHash然后计算新的哈希存到当前记录。任何人如果在数据库里直接改内容只要重新计算哈希就会发现与存储值不一致。实际操作中不需要全局遍历校验只需在对账时从某条记录开始沿哈希链回溯即可。操作日志表则是另外一张独立的表记录谁在什么时间做了什么操作包括修改前后的内容摘要。这张表只允许插入不允许更新和删除数据库账号的权限层面就要限制住光靠应用层约束不够。6. 部署交付与性能调优的几个具体技巧6.1 桌面端自动更新ClickOnce 或自包含发布WinForms 或 WPF 客户端最头疼的是版本更新。几十台终端让 IT 逐个拷贝 exe 不现实。如果项目还在 .NET Framework 时代直接使用 ClickOnce 发布是最快路径。ClickOnce 在 Visual Studio 发布向导里点几下就能生成部署包客户端首次运行 URL 或 UNC 路径即安装之后每次启动自动检查更新。如果项目已经迁移到 .NET 6/8用 .NET CLI 自包含发布加手写更新器更靠谱因为微软对 ClickOnce 的跨平台支持有限。dotnet publish -c Release -r win-x64 --self-contained true -o ./publish-r win-x64指定运行时标识--self-contained true表示目标机器不需要装 .NET 运行时。发布产物里所有 DLL 和 exe 一起打包用压缩工具打成 zip放到一个内网共享目录。客户端启动时先请求一个version.json对比本地版本号不一致就下载 zip 解压替换。注意替换 exe 时要用rename先把旧文件改名为.bak复制新文件后再删除备份避免文件被占用导致覆盖失败。6.2 Web API 部署到 IIS 或 Windows 服务的注意事项ASP.NET Core Web API 部署到 IIS 时一个高频问题是 502.5 进程退出。排错先看 Windows 事件查看器通常是缺少 .NET 托管模块或应用池的“启用 32 位应用程序”设置错误。检查两项即可。第一应用池的 .NET CLR 版本要改成“无托管代码”因为 ASP.NET Core 是自带运行时不走传统 ASP.NET 的 CLR。第二IIS 要装 .NET Core Hosting Bundle这一步漏了会出现 HTTP 500.19 错误。数据库连接串如果放在appsettings.Production.jsonIIS 运行池账号需要对该文件有读取权限。更安全的做法是在 IIS 站点级别设置环境变量在“配置编辑器”里找到system.webServer/aspNetCore节点把environmentVariables里的EMR_DB_CONNECTION设成生产连接串这样源码里完全不出现生产凭据。6.3 慢查询排查先看执行计划而不是调代码电子病历系统的性能瓶颈大概率不在 C# 代码而在 SQL。遇到病历列表打开慢、报表导出超时第一件事是抓取实际执行的 SQL 语句在 SQL Server Management Studio 里看执行计划。常见的病历系统慢查询有两个特征一是WHERE条件里的字段没有索引比如按ContentJson里某个 JSON 字段过滤这种写法索引完全失效要改成在表里单独冗余一个可索引字段二是大表JOIN顺序不合理SQL Server 的优化器通常会调整顺序但如果表之间统计信息过期就可能选择错误。这时执行UPDATE STATISTICS Patients WITH FULLSCAN刷新一下统计信息。CREATE INDEX IX_MedicalRecords_Status_SubmittedAt ON MedicalRecords(Status, SubmittedAt) INCLUDE (RecordType, AuthorId);INCLUDE子句把查询里需要返回但不需要参与搜索的列加入索引的叶子节点这样当查询条件命中Status和SubmittedAt时SQL Server 只需扫描索引就能返回列数据不需要回表。归档列表和科室统计报表常按状态和时间过滤这条复合索引会让查询效率明显提升。6.4 给查询加超时和重试避免界面卡死数据库连接池耗尽是一个隐蔽但常见的问题。程序里某个查询忘了释放连接或者事务长时间未提交连接池默认最大连接数是 100达到上限后新的请求全部排队等待界面看起来就是“卡死”。排查时执行sp_who2看到大量Sleeping状态的连接基本可以断定是有连接没释放。在 Dapper 里用using包裹连接是标准写法这一点要执行到位。还有另一个实用技巧是给查询加 CommandTimeout防止某条异常查询把应用线程挂死。using var conn _dbConnectionFactory.CreateConnection(); conn.Open(); using var cmd new SqlCommand( SELECT * FROM MedicalRecords WHERE Status Status AND SubmittedAt From , conn); cmd.CommandTimeout 30; cmd.Parameters.AddWithValue(Status, 2); cmd.Parameters.AddWithValue(From, DateTime.Now.AddDays(-30)); using var reader cmd.ExecuteReader();CommandTimeout 30表示这条命令最多执行 30 秒超时后由客户端主动取消不会一直等下去。同时要注意AddWithValue有一个隐患当传入的参数值是DateTime或字符串时SQL Server 可能推断出错误的参数类型导致索引失效。更稳妥的做法是用cmd.Parameters.Add(Status, SqlDbType.TinyInt).Value 2显式指定类型。最后给出一个排查链接池泄漏的常用命令执行结果里DB_NAME为EMR的会话数如果持续增长优先查自己代码里的连接释放逻辑而不是调大连接池上限。调大上限只是延迟了问题爆发的时机不解决根因。本文还有配套的精品资源点击获取