
最近在跟某医院HIS系统的病历模块做改造头疼了大半个月的“病历文件夹上传并发冲突”问题总算用HTML5那一套标准能力给理顺了。这个标题看着像在问技术方案其实真正干过的人都知道问题从来不只是“能不能传”而是“很多人同时传、重复传、传一半、传乱了怎么办”。如果你也在做医疗信息化或者正在给HIS系统做前端升级这篇文章应该能帮你少踩很多坑。我会把这次改造里从现象、选型、设计一直到落地和排查的全过程都摊开来讲包括踩坑实录和一些常规文档里不会写的细节。没有太多高深理论尽量说人话你拿去就能照着做。1. 问题重现病历文件夹上传的并发冲突到底长什么样1.1 三个最容易出事的场景先说第一个场景也是这次改造的直接导火索。某一天早上一位管床医生给患者上传入院记录护士同时在上传护理记录主治医生又在上传检查报告三个人几乎同一时间点了“上传文件夹”。老系统收到请求后都是直接往同一个患者文件夹下面写文件。结果呢两个线程同时判断目录不存在同时创建同时写入最后后到的文件把先到的一部分覆盖了。整个病历文件夹的目录结构成了“拼接怪”有的文件是A医生的有的是B医生的还有的根本没写进去。第二个场景更隐蔽同一份血常规报告PDFA医生上周上传过一次这周B医生又从另一台电脑上传了一遍。老逻辑不校验内容只看路径和文件名相同就直接覆盖。病历里的历史版本就此丢了真到医患纠纷要翻原始记录时拿不出来信息科和医务科都头大。医院病历本身就是法律文书这种丢版本的问题风险极高。第三个场景是性能层面的。一个病历文件夹里塞了几百个文件尤其是影像序列、病理切片这类小文件特别多的目录。前端在拿到文件夹后把所有文件一股脑发起上传请求浏览器的连接数瞬间打满HIS后端的线程池、数据库连接池跟着遭殃。结果就是上传接口超时、其他业务页面卡顿数据库里还留下一堆“半提交”状态的脏记录。这种问题不是偶尔出现门诊高峰几乎天天见。1.2 老方案的坑为什么之前用插件反而更难搞很多老HIS系统早年是用ActiveX控件、Java Applet或者Flash来做文件夹上传的原因很简单当年浏览器不支持文件夹级别的读取只能靠插件补位。但插件的麻烦事太多了。首先是客户端环境极度受限只能在特定浏览器、特定版本的IE上运行换了浏览器就用不了。其次医院内网终端数量多、更新又慢浏览器一升级安全策略收紧控件说挂就挂信息科的人天天修也不是办法。更坑的一点是插件上传大多走私有协议不走标准HTTP后端基本拿不到中间状态。你要是想在上传过程中做校验、限流、事务控制根本无从下手。所以并发冲突在老方案里几乎是“无解”的只能靠人肉协调让科室错开时间上传。这显然不是正常的做法。2. 选型分析为什么是HTML5来收拾这个烂摊子2.1 HTML5真正改变游戏规则的三个能力HTML5能解决这个问题靠的不是某一项技术而是三件事凑到了一起。第一是文件夹读取能力。现在的浏览器通过input标签的webkitdirectory属性可以直接让用户选择整个文件夹并且拿到文件夹内所有文件的相对路径。这对病历文件夹这种树状结构是天然的匹配不需要插件不需要额外安装。第二是分片与异步能力。HTML5的Blob对象支持把大文件切成多个小片段配合FileReader和XMLHttpRequest或者fetch可以把一个大文件拆成若干小块分批上传。这就给断点续传、并发限流打下了基础。以前一个500MB的DICOM影像文件传一半断了只能从头再来现在可以接着传。第三是后台计算与本地存储。Web Worker可以在后台线程里处理文件哈希计算不会让页面卡死IndexedDB可以在浏览器本地缓存上传状态刷新页面之后还能恢复。这一套组合下来上传这件事就从“一次性碰运气”变成了“有状态、可恢复、可追踪”。2.2 替换插件后HIS系统能获得什么换到HTML5方案之后好处是实打实的。医院客户端不需要再装任何控件浏览器原生支持Chromium内核的浏览器都是开箱即用极大降低信息科的维护成本。同时上传过程走标准HTTP协议后端可以全程参与从接收请求、校验文件内容、控制并发、记录版本到最终提交每一步都可控。还有一个容易被忽略的点HTML5浏览器本身的沙箱机制更安全。文件不会不经选择就被后台读取所有操作用户可见、可取消这在医院这种数据敏感性很强的环境里是个刚需。安全性提上来了医务科那边也好交代。3. 核心设计思路先想清楚“并发冲突”怎么治3.1 治病先治根把“并发”变“排队加可回滚”先说一个核心思路。解决并发冲突不是让上传动作本身变得更快而是让整个文件夹的提交“要么全部生效要么全部不生效”。我当时的做法是引入“批次”的概念每一次文件夹上传操作前端生成一个全局唯一的批次ID所有文件先传到一个服务端临时存储区全部传完之后服务端再一次性把临时区的文件迁移到正式病历目录。整个迁移过程加锁锁的粒度按“患者ID 病历类型”走这样不同科室之间就算同时上传也不会互相踩踏。用一个生活化的例子来类比病历室收材料以前是所有人挤在一个抽屉前抢着放现在改成多个窗口收材料材料先放到暂存柜等这批材料全部收齐了管理员再一次性整理进病历袋。中间哪怕有一个人没交齐管理员也不会把这批材料放进袋子里。这个思路虽然简单但确实能解决大部分并发覆盖问题。3.2 用文件指纹判断“是不是同一个文件”文件名是靠不住的。同一份检查报告在不同科室的电脑上可能叫“报告.pdf”也可能叫“report.pdf”甚至可能叫“新建文档.pdf”。如果只按文件名和路径判断必然会出现重复上传和覆盖问题。所以我在方案里引入文件指纹计算用MD5或者SHA-1算出每个文件的哈希值再结合文件大小形成唯一标识。服务端保存一张“文件索引表”核心字段是“患者ID 病历类型 相对路径 文件哈希 文件大小”。上传批次提交时逐个文件比对这张表。同一个文件内容完全一致直接跳过不再重复存储内容不一致但路径相同说明是更新版本此时不是覆盖旧文件而是生成新版本保留历史快照。这样一来重复上传和覆盖问题就同时解决了。3.3 最终一致至少保证文件夹最终状态可用做医疗系统的人都会有这种感觉不能接受一个病历文件夹处于“传了一半”的中间状态。哪怕上传过程中出现十个文件失败也不能让已经成功的几个文件提前出现在正式目录里否则临床医生看到的病历就是残缺的容易误判。所以方案里做了一个强制约定上传阶段只写临时区对外部不可见只有批次内所有文件都确认接收完毕才进入提交阶段。提交阶段在服务端做三件事哈希去重、版本登记、原子移动。任意一步失败整个批次回滚临时区清理掉。这个设计牺牲了一点实时性但换来了病历数据的完整性和可追溯性非常值得。4. 实操全过程如何一步步把HTML5上传模块落地4.1 第一步前端读取文件夹生成待上传清单页面上的写法很简单一个input标签加上webkitdirectory属性就够了input typefile idfolderPicker webkitdirectory multiple /用户选择文件夹后前端从input.files里拿到所有File对象每个对象的webkitRelativePath字段保存的就是它在文件夹里的相对路径。这一步要做的关键动作不是直接上传而是先构建一个“待上传清单”把文件路径、大小、最后修改时间都记录成结构化数据同时生成一个批次ID。document.getElementById(folderPicker).addEventListener(change, function (e) { const files Array.from(e.target.files); const batchId generateBatchId(); const fileList files.map((file, index) ({ id: index 1, batchId, name: file.name, path: file.webkitRelativePath, size: file.size, lastModified: file.lastModified, file, status: pending })); // 保存到本地队列等待后续处理 window.uploadQueue { batchId, fileList }; });这里有个实操细节input.files拿到的是扁平数组不是嵌套目录树。上传时不需要前端自己建目录把相对路径原样传给服务端服务端在临时区里按照这个相对路径落盘就行等提交时再整体搬移。流程上省了前端递归建树的工作也避免路径拼接错误。4.2 第二步分片计算文件指纹避免重复文件文件清单有了之后在真正上传之前先做一轮文件指纹计算。这一步的作用是提前发现重复文件免得把几百个相同文件上传上去浪费带宽和存储。计算哈希不能直接在页面主线程里做否则遇到几百兆的影像文件页面会直接假死。要把计算丢给Web Worker在后台线程里分片读取、分片计算。const worker new Worker(/js/hash-worker.js); worker.onmessage function (e) { const { id, hash } e.data; updateFileStatus(id, ready, hash); }; fileList.forEach(item { worker.postMessage({ id: item.id, file: item.file }); });在Worker内部对文件做分片哈希计算每片1MB左右。算完之后前端把哈希结果连同文件路径、大小一起发给服务端进行一次“预检”。服务端比对文件索引表后返回每个文件应该走哪种处理方式跳过、覆盖、新增还是冲突。这一步能提前把重复文件筛掉用户还没上传就知道哪些文件不需要再传。4.3 第三步控制并发上传数量防止连接池被打爆上传阶段最忌讳一股脑把几百个请求同时发出去。我在项目里踩过这个坑前端刚改成HTML5方案时没有限流结果上传批量文件时HIS服务器直接报警连挂号收费都变慢了。后来我在前端加了一个并发控制的调度器思路很简单同一时刻最多只允许N个上传请求在跑这个N我一般设成3到5。async function runWithLimit(tasks, limit 3) { const executing new Set(); for (const task of tasks) { const promise task().finally(() { executing.delete(promise); }); executing.add(promise); if (executing.size limit) { await Promise.race(executing); } } await Promise.all(executing); }每个文件作为独立任务交给调度器执行。这里需要强调一下上传请求本身也做了分片处理较大的文件切成多个分片每个分片单独上传。这样做的目的有两个一是单次请求体量小超时概率低二是某个分片失败了只需要重传这一片不用整个文件从头来。4.4 第四步服务端接收、临时存储与原子提交后端接口的设计是整个方案的重头戏。我当时是按两个接口来设计的一个负责接收分片数据一个负责批次提交。分片接收接口的逻辑很简单把分片写入临时存储目录目录结构以批次ID为根/tmp/his-upload/{batchId}/{相对路径}/xxx.pdf.part全部碎片收齐后前端调用批次提交接口。这个接口内部要做的事情比较重核心是加锁、校验、移动、登记版本。下面这段伪代码展示了核心流程PostMapping(/commit-batch) public Result commitBatch(RequestBody CommitRequest request) { String lockKey patient: request.getPatientId() :folder: request.getFolderType(); boolean locked redisLock.acquire(lockKey, 30_000); if (!locked) { return Result.error(当前病历正在被其他人提交请稍后重试); } try { // 1. 校验批次完整性 if (!tempStore.isBatchComplete(request.getBatchId())) { throw new IllegalStateException(批次文件不完整); } // 2. 去重比对生成新版本号 VersionInfo version versionService.createVersion(request.getPatientId(), request.getFolderType(), request.getBatchId()); // 3. 将临时文件原子移动到正式病历目录 fileStorage.moveToFormalDir(request.getBatchId(), request.getPatientId(), version.getVersionNo()); // 4. 记录索引 fileIndexService.rebuildIndex(request.getBatchId(), request.getPatientId(), request.getFolderType(), version.getVersionNo()); return Result.ok(version); } finally { redisLock.release(lockKey); } }锁这里多说一句。按患者维度和病历类型加锁是为了防止两个不同的批次同时对一个患者的同一个病历目录做文件移动操作。这个锁的粒度不能太大层级过深会导致并发上传全被串行化影响用户体验但也不能太小至少要能管住“同一个患者同一类目录”。实践下来用Redis分布式锁比较顺手医院内网环境里Redis的稳定性足够。4.5 第五步冲突检测与提示提交完成之后服务端返回的结果里要带一份“冲突文件明细”。这份明细包含哪些文件因为内容相同被跳过了、哪些文件路径相同但内容不同被生成了新版本。前端收到之后在界面上弹一个确认窗口告诉用户这次上传一共成功了多少、跳过了多少、更新了多少。这里有个经验要分享不要把冲突当成错误来处理而是当成正常业务流程。病历版本被更新本来就是诊疗过程中的常事只要历史快照保留完整就不需要让用户觉得操作失败了。医院信息科也更喜欢这种“看得见变化”的设计。5. 常见问题与排查技巧实录5.1 上传到一半失败浏览器没报错这个现象很典型用户上传到百分之七八十进度卡住半天然后前端什么提示都没有用户以为传完了实际服务端那边根本没有这个批次。排查下来多数是网关超时、服务器重启或者网络抖动导致的请求中断而前端又没有做断点续传断掉之后所有已传分片全部作废。解决方案是在本地通过IndexedDB保存每个分片的上传状态重新发起上传时先检查哪些分片已经确认接收只传缺失的部分。这个改造量不大但对体验提升特别明显。病历文件夹动辄几百个文件真让用户重来一次护士台那边要骂人的。5.2 并发一大其他业务也跟着慢有一次上线后发现门诊收费变慢了查了一圈根因是上传模块把后端线程池和数据库连接池占满了。虽然前端已经限制到3个并发但架不住多个科室同时使用再加上影像大文件分片多请求总量仍然不小。解决办法是把上传接口单独拆到一个独立的服务实例上或者至少给上传模块配置独立的线程池和数据库连接池。和HIS其他高并发业务共享同一份资源一旦上传高峰来了就是互相拖累。这个教训让我明白限流不能只看前端后端也要有容量规划。5.3 计算MD5卡顿、页面假死第一版我贪图省事在页面主线程里直接算文件MD5结果遇到一个几百MB的视频文件时整个页面卡了十几秒用户以为是系统崩了。后面改成Web Worker分片计算才解决问题。实操中还有一个优化点哈希计算不一定要在上传前把所有文件全部算完。可以先算小文件大文件排到后面配合并发调度器让计算和上传重叠进行。这样用户看到的是“上传已经在跑了”而不是“还在准备中”体感明显好很多。5.4 相同内容文件反复上传病历库越来越大如果没有做文件指纹去重同一个PDF被传十次服务器上就存十份物理文件存储占用直线上升备份时间也越来越长。医院机房存储很多时候没那么宽裕这个问题不能忽视。我们的做法是文件地址引用化服务端只保存一份物理文件文件索引表里记录所有引用了这个物理文件的病历路径。删除病历的时候也不会直接删物理文件而是检查引用计数计数为零才真正删除。这样既去重又不影响病历的独立版本。这一套下来病历存储的增长速度慢了很多。5.5 浏览器不支持文件夹选择怎么办webkitdirectory属性名义上是WebKit内核的私有实现虽然现代Chromium内核浏览器都支持但医院里总有那么几台老电脑还在用老版本浏览器或者用户习惯用某些国产浏览器的兼容模式文件夹选择功能会失效。我们做了一个降级方案检测到浏览器不支持webkitdirectory时前端只开放多文件选择或者引导用户先把文件夹压缩成ZIP包再上传服务端收到ZIP后自动解压并重建相对路径。虽然体验差一点但至少功能不会缺失。实际推广时信息科也会同步建议科室使用指定的浏览器两条腿走路才稳。6. 一点个人体会这活真正难的不是技术是流程配合技术方案我前后写了四版最后一版其实改动最大的是流程而不是代码。真正把所有问题往后推的不是某个高深的API而是“批次提交”和“版本保留”这两个业务规则。前端、后端、数据库、运维任何一环不配合这套东西就转不起来。我个人在实际操作中的体会是做医院信息化项目最好先拉着医务科、病案室和护理部的相关人员把流程确认清楚再动代码。上传不需要用户看懂所谓的技术细节他们要的是一个确定的结果点了上传病历就在那里不会被别人盖掉以后也翻得出旧版本。信息科要的则是出了问题能定位、能回滚、能在十分钟之内对领导解释清楚发生了什么。如果你也要做类似改造我的建议是先花两三天把现有系统的上传行为完整摸一遍把所有覆盖、失败、超时、卡顿的场景都列出来再开始设计。HTML5那套工具本身解决不了所有问题但配合合理的流程设计它绝对能把“并发冲突”这个老大难压到几乎为零。