资讯动态

基于HTML5的病历文件夹上传并发冲突解决方案

发布时间:2026/10/17 4:34:26 来源:尧图企业网站定制
说起来我在医院HIS集成项目里真被病历文件夹上传折腾过。患者做一次检查影像科导出几十个DICOM文件加一份报告PDF临床医生又会开一份会诊记录这些资料最终要合并进同一个患者的电子病历目录。如果只是小文件还好一旦遇到多人并发上传同一个患者的资料问题立刻暴露文件夹错位、同名文件互相覆盖、报告版本新旧不分。后来我们用HTML5把上传模块整个重写并发冲突才算真正压下来。这篇文章我会把整套思路和踩坑过程完整拆开包括为什么选HTML5、合并冲突怎么处理、并发参数怎么定以及真正上线后踩过的几个坑。1. 病历文件夹上传并发冲突到底在冲突什么1.1 一次让我印象深刻的现场事故当时是某地区医院的信息系统升级改造我参与其中的病历上传模块重构。测试阶段出现过一次真实事故某天上午门诊护士、影像科医生和心内科医生几乎同时上传同一个患者编号的资料。影像科导出的是整个检查目录里面带着“检查报告.pdf”“影像序列”、DICOM文件心内科医生上传的是刚补写的会诊意见文件名也叫“检查报告.pdf”门诊护士那边则是从工作站导出的历史病历压缩包。三个上传动作在几乎同一时间落库最后患者目录里出现了多个同名文件有些内容被旧版本覆盖。医生打开病历时看到的是旧版报告差点因为影像结论过期影响判断。最后是靠数据库备份才把数据找回来但这件事让我下定决心病历上传绝对不能继续用“一股脑写入最终目录”的做法。这个案例很典型它包含了并发冲突的三种形态同名文件互相覆盖、目录结构错乱、新旧版本无法分辨。只要系统里有多个用户、多个终端同时操作同一患者冲突就迟早会出现。1.2 传统表单上传的三大硬伤早几年HIS系统里最常见的做法是表单上传。页面放一个input typefile用户选择文件浏览器用multipart/form-data把整个文件流发给后端后端收到后直接写入某个固定目录。这种方案有三个硬伤基本注定了并发场景下会出问题。第一没有暂存区概念。文件到达服务端后直接覆盖目标路径下的同名文件后到的把先到的冲掉。如果两个文件夹都包含“报告.pdf”后一次上传永远会把前一次覆盖掉几乎没有商量余地。第二没有相对路径传递能力。患者病历不是一个平面文件堆应该有“检查报告/影像序列/病理切片”这种层级。传统表单上传拿不到用户本地文件夹的层级结构也无法把相对路径一起传给后端。很多系统只能让用户自己把文件压缩成一个压缩包后端再解压但压缩包内部结构依然是黑盒。第三没有断点和去重机制。文件稍微大一点网络波动就容易失败。失败之后整个任务从头再来服务端可能已经写入一部分数据留下一堆半截文件。前端也无法计算文件内容MD5根本分辨不了两个同名文件到底是不是同一个内容。1.3 HTML5改变了哪些底层规则HTML5 的 File API 和目录选择能力让我第一次觉得上传其实可以做得更严谨。浏览器端可以直接拿到用户选择的整个文件夹每个文件对象都带name、size、lastModified还能通过webkitRelativePath拿到相对路径。也就是说浏览器不仅能上传一个文件还能把“整个文件夹结构”原封不动地提交上来。更重要的是HTML5 允许在浏览器端读取文件内容的分片意味着可以本地计算 MD5可以先分片后上传可以控制并发窗口可以在上传完成前先做一次完整的“内容登记”。再加上服务端配合一个暂存区和一个合并流程就把并发冲突从“盲目覆盖”变成“有序提交服务端裁决”。这套思路对我后面整个实现帮助非常大。它不再是简单的文件传输而是一个带有校验、排序、裁决机制的数据迁移过程。2. 为什么选用HTML5而不是插件方案2.1 医院内网环境与技术限制医院的信息系统很特殊大部分场景跑在内网外网访问受限公共云存储SDK基本用不上。终端设备有的是老旧PC有的是近两年新配的工作站浏览器版本差别特别大。过去很多HIS模块靠ActiveX控件或Flash来上传文件现在已经越来越不安全浏览器官方也在禁用手动老控件新采购的机器很难兼容。如果继续走桌面客户端路线等于每家医院都要预装一个专用上传程序后续升级时还要挨个终端去更新。对信息科来说维护成本高得离谱临床科室也容易因为版本不一致出现各种奇怪问题。HTML5是浏览器原生能力只要终端使用现代浏览器打开网页就能用不需要单独安装任何控件。这一点在医院这种“终端数量多、运维人力少”的环境里尤其重要。2.2 HTML5 File API带来的原生能力HTML5 的方案核心依赖两个能力目录选择与文件内容访问。目录选择靠的是input typefile webkitdirectory或directory属性。用户点一下就能把整个病历文件夹提交到页面里。浏览器会把所有文件展开为FileList每个文件还有一个webkitRelativePath属性比如影像检查/CT增强/2025-05-10/0001.dcm。这正好对应病历文件夹的层级。文件内容访问靠的是File对象和Blob.slice。我们可以把一个大文件切成若干片逐片上传也可以借助浏览器端的能力计算分片的MD5作为内容指纹。这样服务端不需要先存整个文件就能判断某个分片是否已经上传过。再配合lastModified时间戳我们还能判断同一个路径下的文件哪个更新。这个属性来自操作系统的文件修改时间不是前端随意伪造对于病历版本裁决很有价值。2.3 兼容性兜底策略虽然HTML5能力很强大但浏览器兼容仍不能忽视。在基于Chromium内核的主流浏览器上目录上传和File API稳定部分WebKit内核浏览器对目录选择支持不够好更老的内核浏览器基本无法使用。我的处理方式是做一套降级路径。检测到浏览器不支持目录选择时界面上切换成“多选文件”模式让用户手动选择同一次检查导出的多个文件实在不行就提示用户把文件夹压缩成压缩包由后端解压后进行合并。虽然体验差一些但至少流程不会中断。这个兜底策略建议在项目启动时就定下来不要等上线后再补。病历上传是高频操作如果临床科室在某个浏览器上突然传不了电话会立刻打到你这里。3. 并发冲突的三层拆解与整体设计3.1 任务级并发唯一任务号和临时目录要处理并发冲突首先要分清“并发发生在哪一层”。我把它拆成三层任务级、文件级、传输级。任务级并发是指多个用户几乎同时上传同一个患者的病历资料。如果两端上传流程都直接写最终病历目录那后一个任务就无法感知前一个任务写到哪了。所以我引入了“上传任务”概念每次选择文件夹后前端生成一个唯一的taskId可以使用UUID服务端只往该任务的临时目录里写数据。任务临时目录结构大概是这样/upload_root/ {taskId}_A/ 影像检查/CT增强/... {taskId}_B/ 会诊记录/...两个并发任务各自拥有独立的暂存区互相不干扰。业务上看起来是“两个人在同一时间传同一患者”但物理上是“两个任务各传各的最后由合并阶段统一裁决”。这一步非常关键能避免绝大多数文件错乱问题。3.2 文件级冲突MD5、相对路径和lastModified任务级并发解决之后真正的“冲突”集中在文件级。我定义了一个文件唯一标识patientId / relativePath / fileName。不管谁上传只要最终合并时这个唯一标识在目标目录里已经存在就说明可能冲突。接下来用MD5判断内容是否相同。如果两个文件的内容完全一致直接跳过、无需处理如果内容不同再用lastModified判断新旧。新文件比旧文件晚则保留新的旧文件比新文件晚则不能覆盖并给出预警。有人可能会问为什么不用文件名做判断就够了因为同名文件在临床场景太常见了。报告PDF可能叫“检查报告.pdf”也可能叫“报告.pdf”但内容可能完全不是同一个检查。所以合并阶段绝不能只看文件名一定要结合相对路径、文件大小、MD5和修改时间综合判断。3.3 传输级并发分片窗口与幂等重试第三层是传输层并发。一个病历文件夹可能有几百个文件如果一次性全部发起上传请求浏览器和服务端都会扛不住。我采用分片上传并发窗口。每个文件按2MB切成若干片全局控制同时在传的分片数量一般设为35个。这样既不会太慢也不会把服务端连接池打满。接口设计成幂等每个分片请求都带taskId、fileKey、chunkIndex、md5服务端发现某一分片已经存在时直接返回成功不做重复存储。这样网络闪断后客户端重试同一分片不会造成服务端数据重复。重试时配合指数退避第一次等1秒第二次等2秒第三次等4秒避免并发高峰时疯狂重试把网络拖垮。3.4 服务端合并裁决不是覆盖而是裁决所有分片上传完成后前端调用“任务完成”接口。服务端检查暂存区里文件齐全后进入合并阶段。合并阶段必须加一把“患者级锁”。我用的是患者ID作为锁粒度同一时间只允许一个任务对某个患者的目录做合并。第二个任务即使已经上传完也要等第一个任务释放锁后再合并。锁的实现可以用数据库行锁也可以用内存级锁服务看现有系统技术栈。合并裁决的逻辑大致是遍历暂存区每个文件先看最终目录里是否存在相同路径不存在则直接写入存在则比较MD5相同则跳过不同则以修改时间决定保留谁。被替换的旧文件不直接删除而是移动到history目录文件名加上时间戳便于事后溯源。合并结束后生成一份冲突处理报告把“覆盖了谁”“新增了谁”“因为什么覆盖”全部列清楚。这套流程的核心原则是上传只是暂存合并才是落库。只要合并阶段是串行的并发冲突就永远不可能发生在落库的那一瞬间。4. 关键代码与参数配置4.1 前端目录遍历、分片上传队列前端核心是遍历文件夹和分片上传。下面这段代码展示了我常用的做法const CHUNK_SIZE 2 * 1024 * 1024; // 2MB const MAX_CONCURRENCY 3; async function handFolderUpload(inputFiles, patientId) { const taskId createTaskId(); const fileTasks []; await fetch(/api/upload/init, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ taskId, patientId, fileCount: inputFiles.length }) }); for (const file of inputFiles) { const relativePath file.webkitRelativePath || file.name; fileTasks.push({ taskId, file, relativePath, fileKey: ${patientId}/${relativePath}, lastModified: file.lastModified, fileSize: file.size }); } let index 0; const worker async () { while (index fileTasks.length) { const task fileTasks[index]; await uploadOneFile(task); } }; await Promise.all( Array.from({ length: MAX_CONCURRENCY }, () worker()) ); await fetch(/api/upload/complete, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ taskId }) }); } async function uploadOneFile(task) { const { taskId, file, fileKey, lastModified, fileSize } task; let offset 0; let chunkIndex 0; while (offset file.size) { const slice file.slice(offset, offset CHUNK_SIZE); const md5 await calcMd5(slice); const form new FormData(); form.append(taskId, taskId); form.append(fileKey, fileKey); form.append(chunkIndex, chunkIndex); form.append(md5, md5); form.append(lastModified, lastModified); form.append(fileSize, fileSize); form.append(chunk, slice); await uploadChunkWithRetry(/api/upload/chunk, form, 3); offset CHUNK_SIZE; chunkIndex; } } async function uploadChunkWithRetry(url, form, retries) { let delay 1000; for (let i 0; i retries; i) { try { const resp await fetch(url, { method: POST, body: form }); if (resp.ok) return; } catch (e) { // 网络异常进入重试 } await new Promise(resolve setTimeout(resolve, delay)); delay Math.min(delay * 2, 8000); } throw new Error(分片上传失败: ${form.get(fileKey)} chunk ${form.get(chunkIndex)}); }需要注意这段代码里的calcMd5可以用浏览器端现成的加密接口实现也可以用MD5计算库。分片大小不要设得过大超过10MB后浏览器和服务端内存都会比较紧张也不能太小太小请求数量太多网络开销反而不划算。我实际用下来2MB分片适合大多数医院内网带宽。如果是千兆内网可以调到4MB上传速度会更快一些。4.2 服务端合并流程与冲突仲裁伪代码服务端合并逻辑是核心。下面给出一段可读性较高的伪代码async function mergeUploadTask(taskId) { const task await getUploadTask(taskId); const patientId task.patientId; await acquirePatientLock(patientId); const stagedFiles await listStagedFiles(taskId); for (const stagedFile of stagedFiles) { const relativePath stagedFile.relativePath; const existingFile await findFileByPatientAndPath(patientId, relativePath); if (!existingFile) { await moveToFinalPath(stagedFile); await addMergeLog(taskId, relativePath, INSERT, 新增文件); continue; } if (existingFile.md5 stagedFile.md5) { await addMergeLog(taskId, relativePath, SKIP, 内容相同保留原文件); continue; } if (stagedFile.lastModified existingFile.lastModified) { await moveToHistory(existingFile); await moveToFinalPath(stagedFile); await addMergeLog(taskId, relativePath, UPDATE, 新版覆盖旧版); } else { await addMergeLog(taskId, relativePath, KEEP_OLD, 检测到旧版未覆盖); } } await markTaskMerged(taskId); await releasePatientLock(patientId); }这段伪代码逻辑比较直观。关键在于加锁、比较MD5、比较修改时间、移动旧版到历史目录。不要小看moveToHistory这一步它是事后追责和回溯还原的最后防线。没有历史版本一旦覆盖错就只能靠数据库备份恢复了。4.3 并发参数与容量规划参考并发参数不是随便定的需要结合医院实际网络和终端性能。我通常会按下面这套初始值配置再根据现场实测调整参数初始值调整依据分片大小2MB千兆内网可调到4MB慢速网络降到1MB全局并发数3终端性能较差时降到2服务器压力大时降到2上传请求超时30秒内网一般30秒足够重试次数3次次数太多容易叠加拥塞重试退避1秒、2秒、4秒指数退避避免风暴患者锁等待时间5分钟超过5分钟提示合并排队单任务最大文件数2000个防止一次性提交过多文件拖垮浏览器并发数这里多说一句。很多人以为并发越高上传越快实际不是。浏览器对同一域名有连接数上限每个请求还有握手和解析开销。把并发数从5调到20不仅速度没有显著提升反而会出现大量连接堆积。我测试过全局并发35已经足够覆盖大多数场景稳定性和速度最平衡。4.4 冲突报告如何反馈给用户合并裁决完成后不能默默告诉用户“上传成功”因为可能发生了旧版覆盖、新版覆盖等动作。我让服务端生成一份“冲突处理报告”页面上直接弹出一个表格方便用户确认相对路径处理结果原因旧文件状态检查报告/2025-05-10/报告.pdf新版覆盖旧版新文件修改时间更晚已移入历史目录会诊记录/会诊意见.docx保留原文件上传文件比现有版本更旧未覆盖影像序列/0001.dcm新增文件原目录无此路径无这样临床科室能直观看到本次上传到底改了什么。尤其是“保留原文件”的情况如果不明确提示用户会以为上传成功却没看到自己传的文件很容易投诉。报告做得越透明运维扯皮就越少。5. 实测中踩过的五个坑5.1 隐藏文件、临时文件混进病历包第一次测试时我发现上传的文件数量和用户本地文件夹里的文件数量对不上。后来一查是因为目录选择时浏览器会把隐藏文件、缩略图缓存、临时文件一起放进来比如某些以~$开头的Office临时文件还有图片缩略图缓存文件。这些文件不仅占用上传资源还会在最终病历目录里产生垃圾数据。我的处理是在前端遍历文件列表时做一层过滤过滤掉隐藏文件、临时文件、.DS_Store、缩略图文件并且服务端合并时再做一次同样的过滤双保险。5.2 文件名规范不一致造成重复不同科室对同一份资料命名方式完全不同。有的叫“检查报告.pdf”有的叫“CT报告.pdf”还有的全角括号与半角括号混用。服务端如果不做标准化就可能把同一份报告在目录里存两份。我的做法是合并时对相对路径做规范化去除文件名的首尾空格、统一全角数字转半角、统一斜杠分隔符比较路径时不区分大小写。但要保留原始文件名作为显示名不能因为标准化而把用户文件名改了。这一步看似简单实际影响很大。病历目录里如果出现大量重复文件医生浏览病历时会非常痛苦。5.3 并发窗口过大服务端连接被打满一开始我把全局并发数设成了10结果服务端在百兆内网环境下频繁出现连接堆积部分请求直接超时。后来把并发数调低到3服务端负载明显下降上传总体时间反而更快。排查这类问题时不要只盯着前端请求还要看服务端的连接状态。如果出现大量半开连接十有八九是并发窗口开得太大了。建议直接从3开始测再逐步往上加。5.4 前端报“完成”服务端却有几个分片丢失上传最后一步前端直接调用“任务完成”接口但服务端检查发现分片不完整。原因往往是某个分片上传失败后重试次数用尽前端没有正确捕获异常直接跳过了后续逻辑。排查后我做了两个改动一是“任务完成”接口在服务端校验分片总数不完整就直接返回错误不让合并开始二是在前端添加一个预检阶段遍历任务列表确认所有分片状态全部满足后再提交完成请求。这样就彻底杜绝了“假完成”的情况。5.5 合并过程锁粒度不对造成互相等待一开始我图省事用了一把全局锁所有患者合并任务都排队。结果一旦某个患者目录文件特别多其他患者的上传合并全部被堵住临床科室反馈“上传完一直没看到文件”。后来改成按患者ID加锁不同患者之间的合并可以并行同一个患者内的合并严格串行。这个改动让整体吞吐量提升了一倍以上而且冲突安全性没有下降。锁粒度是这类并发设计里最需要拿捏的部分。锁太粗系统卡顿锁太细又会因为锁竞争出现死锁或覆盖漏洞。按业务实体加锁是我目前觉得最适合病历目录场景的方案。6. 上线后的体会和几个细节建议6.1 压测数据与运行稳定性上线前做过一轮模拟压测。选了包含200多个文件、总大小约400MB的病历文件夹用5个终端同时上传同一个患者的资料连续跑了多轮。结果稳定下来后没有再出现同名文件覆盖和旧版本覆盖新版本的问题。每轮上传完成后服务端生成的冲突报告都能准确列出被替换的文件历史目录里也能找到旧版本备份。其中一个大尺寸文件夹在慢速万兆外网环境下的上传耗时明显降低因为分片并发和断点重试让网络利用率高了不少。最明显的变化是不再需要临床护士在电话里跟信息科说“我传完之后看不到某某报告”因为合并失败会明确提示冲突也会明确反馈。6.2 一些对我很有用的细节最后分享几个在实际落地中让我受益很大的细节。合并日志一定要存。花不了多少空间但事后排查纠纷时价值极高。没有日志单凭文件名和时间戳很难解释清楚为什么某个报告变成了旧版。重试策略里一定要加退避时间。我之前写过固定间隔重试遇到网络波动时导致大量请求同时重发把服务端又压垮了一轮。改成指数退避后这个问题基本消失。报告模板可以考虑做成可导出的。医院信息科往往要求上传记录能打印存档把冲突报告导出成Excel或PDF会很有用。这个功能不需要最开始做但设计数据表时要把相关字段留好。还有一个小技巧前端在上传前先把整个文件清单发到服务端让服务端生成一份清单ID后端临时目录里也保存这份清单。这样即使浏览器崩溃信息科也能凭任务号查到用户当初到底上传了哪些文件而不是什么都查不到。病历文件夹上传并不是一个高深的技术问题但它非常考验工程细节。HTML5给了我们一套足够好的底层能力真正决定系统稳不稳定的是任务设计、并发控制、合并裁决和日志回溯这些工程决策。希望这篇分享能帮你少踩几个坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑