说起来,我在医院HIS集成项目里真被病历文件夹上传折腾过。患者做一次检查,影像科导出几十个DICOM文件加一份报告PDF,临床医生又会开一份会诊记录,这些资料最终要合并进同一个患者的电子病历目录。如果只是小文件还好,一旦遇到多人并发上传同一个患者的资料,问题立刻暴露:文件夹错位、同名文件互相覆盖、报告版本新旧不分。后来我们用HTML5把上传模块整个重写,并发冲突才算真正压下来。这篇文章我会把整套思路和踩坑过程完整拆开,包括为什么选HTML5、合并冲突怎么处理、并发参数怎么定,以及真正上线后踩过的几个坑。
1. 病历文件夹上传并发冲突到底在冲突什么
1.1 一次让我印象深刻的现场事故
当时是某地区医院的信息系统升级改造,我参与其中的病历上传模块重构。测试阶段出现过一次真实事故:某天上午,门诊护士、影像科医生和心内科医生几乎同时上传同一个患者编号的资料。影像科导出的是整个检查目录,里面带着“检查报告.pdf”“影像序列”、DICOM文件;心内科医生上传的是刚补写的会诊意见,文件名也叫“检查报告.pdf”;门诊护士那边则是从工作站导出的历史病历压缩包。
三个上传动作在几乎同一时间落库,最后患者目录里出现了多个同名文件,有些内容被旧版本覆盖。医生打开病历时看到的是旧版报告,差点因为影像结论过期影响判断。最后是靠数据库备份才把数据找回来,但这件事让我下定决心,病历上传绝对不能继续用“一股脑写入最终目录”的做法。
这个案例很典型,它包含了并发冲突的三种形态:同名文件互相覆盖、目录结构错乱、新旧版本无法分辨。只要系统里有多个用户、多个终端同时操作同一患者,冲突就迟早会出现。
1.2 传统表单上传的三大硬伤
早几年HIS系统里最常见的做法是表单上传。页面放一个<input type="file">,用户选择文件,浏览器用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 type="file" 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切成若干片,全局控制同时在传的分片数量,一般设为3~5个。这样既不会太慢,也不会把服务端连接池打满。接口设计成幂等:每个分片请求都带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,不仅速度没有显著提升,反而会出现大量连接堆积。我测试过,全局并发3~5已经足够覆盖大多数场景,稳定性和速度最平衡。
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给了我们一套足够好的底层能力,真正决定系统稳不稳定的是任务设计、并发控制、合并裁决和日志回溯这些工程决策。希望这篇分享能帮你少踩几个坑。