在银行系统里“文件夹上传”这个词一出现往往意味着一个很难缠的工程问题你面对的不是单个大文件而是一大批带目录结构的影像回单、凭证扫描件、交易流水或对账文件少则几十个文件多则上千个小文件、几百MB甚至几个GB。要是还按传统思路一个HTTP Multipart请求把整个文件夹推给一台服务器很快你会看到两个让人头疼的现象上传任务动不动中断中途失败后全部重来业务高峰期某台节点网卡、磁盘、CPU被打满旁边几台节点闲得只能吃灰。所以当有人问我“银行系统C#如何设计文件夹分片上传的跨服务器节点负载均衡方案”时我第一反应是这个问题的核心其实不是“负载均衡算法怎么写”而是怎么把“文件夹级任务”拆成“可独立投递、可定向重试的分片”再把“这个分片该送哪台节点”变成一个可计算、可补偿、可审计的调度决策。下面我把整套方案的约束条件、选型逻辑、链路设计和生产环境踩坑过程完整展开讲一遍希望对正在做银行影像平台、非结构化存储网关的同学有帮助。1. 银行上传场景的硬约束为什么不能“单机接盘”1.1 从“文件夹上传”看业务本质很多开发者第一次接触“文件夹上传”时以为就是客户端遍历一下目录把文件逐个传上去加个进度条就完事。但在银行系统里文件夹往往不是一个展示概念它是一个强业务概念。比如柜面凭证影像包一个批次对应一个文件夹文件夹里的子目录可能代表不同业务类型文件名里通常编码了交易日期、柜员号、流水号、唯一标识。这样的数据结构一旦被拆开就必须保证三件事目录结构保留、文件顺序关系保留、批次维度可追踪。否则就算文件内容都传到服务器业务系统做批量导入时也会因为找不到相对路径而直接失败。所以我们在做设计时第一件事不是在服务器上调“轮询策略”而是把“文件夹”抽象成一个逻辑传输单元。服务器端有一个TransferId代表整个文件夹上传任务TransferId下面挂文件清单每个文件再按大小决定是否继续分片。这个分层模型是所有后续设计的基础。1.2 银行系统的四个硬性指标论坛上很多关于上传的讨论都集中在“网速”“分片大小”“并发数”但银行场景还要额外卡四个指标每一项都会直接影响架构选型数据不丢任何一个分片到达服务器后必须落盘成功并登记元数据后才算成功一旦进程崩溃重启后要能恢复进度不能靠客户端完全重传。顺序不乱同一批次文件夹内文件可能存在前后依赖比如先导数据文件后对账明细合并完成的最终目录必须和客户端原始目录一致。全程可审计每个分片上传的节点IP、完成时间、校验值、操作用户都要记录这是银行内审和监管检查的底线。故障可切换某台节点宕机或磁盘故障时正在传的任务不能全部作废得能在短时间内恢复并提供其他节点接替。这四条加在一起决定了“跨服务器节点”不是锦上添花而是刚需。我们需要把数据分散到多台机器上承担存储、计算和合并职责同时把节点故障的影响范围控制在单个分片或单个任务级别。2. 跨节点负载均衡的选型逻辑权重轮询与一致性哈希之争2.1 让节点“量力而行”权重动态调整负载均衡最容易犯的错误是追求“绝对平均”。真实环境里服务器配置可能有差异有的节点是32核128GB内存有的可能是8核16GB的老机器磁盘方面有的挂SSD有的挂普通SATA。如果平均分配任务慢节点会拖累整个批次快节点又在空转。我在实践中的做法是给每个节点定义一组权重值初始来自部署时的硬编码配置运行中动态修正CPU使用率高于80%权重降为原来的60%磁盘剩余空间低于可用阈值时该节点进入“不参与新任务”名单节点健康探测连续失败三次直接从候选池剔除恢复成功后重新按基础权重加入权重的调整影响到两件事新分片分配时的节点选择倾向以及一致性哈希上的虚拟节点分布密度。说实话只做普通轮询也没太大问题但银行场景的峰值突发性很强权重动态调节能让节点池在“快慢搭配”时也能跑得比较稳。2.2 分片与节点的绑定关系一致性哈希让重试不“乱套”在分片上传里负载均衡不能只看“下一台给谁”还要考虑“重试时给谁”。如果客户端第一次传某分片去了节点A因为网络抖动没收到响应第二次重试被负载均衡器送到了节点B那么节点B上只有半个分片A上可能也有半个分片最终合并时数据完整性和幂等性都会变成灾难。解决这个问题的思路是用一致性哈希把分片“钉”在某个节点上。分片键可以设计为“TransferId 相对路径 分片序号”一致性哈希计算出一个哈希值映射到节点哈希环上这样同一个分片重试时只要节点池不变化它总能落到同一台节点上。同时一致性哈希还带来一个额外好处新增或删除节点时只有少量分片需要迁移而不是全量重新分配。这对银行的生产环境很重要因为我们不可能为了加一台机器就让所有正在上传的客户端任务全部失效。2.3 比CPU更重要的水位指标做节点健康判断时我踩过一个坑只盯着CPU和内存。结果某台节点CPU空闲但磁盘写入延迟已经到了几百毫秒因为共享存储IO被其他业务打满了。分片上传是个典型的IO密集型任务真正决定它快不快的往往不是CPU算力而是磁盘队列深度、网络出入带宽、系统打开文件句柄数。后来我把节点健康探测扩展成三层存活层/health接口能否正常返回容量层磁盘剩余空间、临时目录能否创建测试文件压力层最近5分钟平均IO延迟、TCP连接数、分片写入失败率健康探测结果每3秒刷新一次并缓存到调度服务的本地内存中。节点返回状态变化时通过事件通知而不是轮询数据库来更新避免一轮节点状态同步拖到几十秒。维度权重轮询一致性哈希组合方案权重哈希实现复杂度低中中分片重试定位不保证能定位能定位新增节点迁移成本低低低异构节点适配弱弱强瞬时热点分散一般较好较好3. 文件夹分片上传的整体设计从客户端到存储节点的完整链路3.1 客户端分片策略固定大小与动态步长怎么选客户端收到一个文件夹后先扫描目录生成文件清单包括每个文件的相对路径、大小、最后修改时间并对整个清单做一次CRC摘要随第一批元数据一起发送给调度服务。调度服务据此生成FileId和Chunk列表。分片大小我建议按网络环境区分不要一套写死。银行内网环境普遍延迟低、带宽稳定可以设置较大的分片比如8MB如果用户可能通过公网接入建议降到1MB2MB减少二进制流在途时间提高失败重传的粒度。文件夹里有很多不足一个分片大小的小文件时可以先把几个小文件合并成一个批次分片减少HTTP请求数量但同时要在元数据里保存每个文件在分片内的偏移量否则合并时还原不了目录结构。这里有个容易被忽略的点分片大小影响“最后一块”和“多线程并发数”的估算。不能简单用“文件总大小/分片大小”计算分片数因为客户端必须事先把分片清单上报并得到服务器确认后再传数据这样调度层才能做全局分配。3.2 任务调度层分片元数据、进度登记与失败重试调度服务是整个方案的大脑。它维护一个上传会话状态机Created客户端提交文件清单服务端生成TransferIdDistributing根据分片键为每个分片计算目标节点写入分片分配表Uploading客户端按照分配表逐片上传Merging所有分片完成后调度器触发合并Completed合并确认审计记录落库Failed发生不可恢复错误可重试或人工介入数据库里至少需要两张表上传任务表和分片状态表。上传任务表保存TransferId、批次摘要、文件清单JSON、当前状态、优先级等分片状态表保存每个分片的FileId、ChunkIndex、目标节点、实际接收节点、大小、MD5、状态待传/已传/校验失败/已合并。我强烈建议把“目标节点”和“实际接收节点”分开存储。因为一致性哈希只决定“应该去哪”但网络路由故障、节点切换等情况下客户端可能把分片送到了备用节点。合并时我们要根据实际接收节点去拉数据而不是预定的目标节点。这一条在我看到的很多设计文档里都被漏掉了。3.3 节点存储层的落盘与临时文件生命周期每个存储节点接收分片之后不会立刻写入最终目录而是先写入一个隔离的临时目录目录结构按TransferId/FileId/ChunkIndex规划。分片文件命名直接采用“TransferId_FileId_ChunkIndex.part”的格式避免多客户端并发上传时名字冲突。临时文件的回收策略分两种情况正常完成的分片在合并成功并确认审计日志写入后删除长时间未完成的分片通过后台清理任务扫描“最近更新时间超过24小时且所属任务状态不是Uploading/Merging”的临时文件删除并标记对应分片为丢失允许客户端感知到后重传。这个生命周期周期设计非常重要因为分片上传最常见的故障就是“残留临时文件占满磁盘”。你要是没做好清理就算负载均衡做得再好也会在几天后被某个全空磁盘拖垮。4. 跨节点分片的上传接口与合并机制4.1 基于分块协议的上传接口状态推进客户端请求某个分片时我推荐用显式的“短连接分片上传”接口而不是一个长WebSocket通道传到底。每个分片请求独立鉴权、独立校验、独立落盘这样任何一个分片失败后重试的粒度就非常灵活。一个比较成熟的分片上传接口设计是PUT /api/v1/upload/chunk/{transferId}/{fileId}/{chunkIndex} Header: X-Auth-Token: 签名字段 X-Chunk-MD5: 本分片的MD5 X-Chunk-Size: 本分片字节大小 Body: 分片二进制数据存储节点收到分片后先校验MD5和大小确认无误再写临时目录写成功后返回分片状态码。如果MD5校验失败返回错误码并要求客户端重传调度服务记录一次“校验失败”事件。这个设计把校验工作下推到存储节点避免所有数据都经调度服务中转减少单点瓶颈和带宽消耗。4.2 合并阶段的排序校验MD5、跨节点拉取与会话锁定当所有分片状态变为“已传”后调度服务开始执行合并。合并任务首先锁定TransferId防止客户端在合并期间继续上传新的分片或重试老分片。锁定机制用数据库乐观锁即可任务表里记录版本号合并开始时把状态改为Merging如果并发冲突就放弃当前合并并重新读取。由于分片可能分散在多台节点上调度服务需要根据分片状态表里的“实际接收节点”字段向各节点发起并行拉取。我一般用“两个并发批次”来做先按文件维度并发拉取该文件的所有分片到调度器的暂存区做一次拼接生成完整文件然后校验整个文件的MD5是否与客户端最初上报的文件摘要一致校验通过后写入最终存储目录更新状态表。这里有个细节合并拉取时的临时文件必须和上传临时目录分开避免节点间互相干扰。合并结束后调度器再调用节点上的“删除分片临时文件”接口一次清理一个任务的所有分片避免大批量删除请求压垮IO。4.3 断点续传和分片重传的唯一定位规则断点续传的核心是“分片状态表必须能回答这个分片我到底收没收到”。客户端续传时不再盲目从第0片开始传而是向调度服务发一个“查询TransferId状态”的请求调度服务返回所有分片状态列表客户端只重传状态不是“已传”的分片。这个设计下分片重传的幂等性由UploadId直接保证。同一个TransferId下一个FileIdChunkIndex只能对应一条分片记录。即使客户端因为超时重发了同一个分片到同一个节点节点端先检查临时文件是否已存在且校验值一致如果一致则直接返回成功不再覆盖文件。这个幂等判断虽然简单但确实能挡住绝大多数网关层重试带来的重复分片问题。5. 银行级安全与审计签名、加密与权限校验的设计要点5.1 传输加密与分片加密的区别很多方案落地点都只说“用HTTPS传输”但在银行文件系统里传输加密和落盘加密是两个层面。传输过程中HTTPS能保证链路安全但文件一旦进入服务器临时目录如果没有落盘加密运维人员、磁盘故障后的数据恢复工具都有可能接触到明文文件。所以我建议对敏感类型的文件夹客户端在上传前先用对称加密算法加密分片内容再计算MD5。这样服务器端看到的和存储的都是密文。合并时可以做“密文拼接”最终需要明文数据时再通过解密流程处理。服务器端尽量不长期保留明文数据。不过要提醒一下这种设计会增加密钥管理和审计追溯的复杂度建议只在客户资料、账号证明文件等真正高敏感目录上启用。5.2 鉴权与防重放时间戳、签名、一次性Token分片上传接口如果只依赖登录Cookie和权限校验容易遇到两个风险恶意用户重放同一个分片请求、越权覆盖别人正在上传的任务。我的做法是给每个上传任务生成一个一次性上传凭证凭证里包含TransferId、可传分片范围、过期时间。每次分片请求都携带基于密钥计算的HMAC签名签名内容为“TransferId FileId ChunkIndex 过期时间戳 随机Nonce”。存储节点校验签名有效后再把Nonce记录到Redis缓存中如果同一个Nonce被再次提交直接拒绝并触发告警。这个机制能比较有效地防止分片重放和越权覆盖。5.3 操作审计与日志关联如何定位“哪个节点存了哪一片”既然负载均衡会把同一任务的多个分片分散到不同节点审计就必须做到“以分片为最小审计粒度”。我建议在分片状态表上增加一张审计扩展表记录每次分片写入操作的节点IP、端口、请求ID、客户端IP、UserAgent、文件MD5、磁盘路径、上报结果。后续如果需要排查“某个批次为什么少了一个分片”用TransferId查询审计表直接就能还原出这个分片从客户端到哪个节点、在哪个时间点成功落盘的完整链路。银行审计人员要的是“能指着某一条日志给出明确结论”而不是含糊的“某个时间段大致传过一批文件”。6. C#工程落地的关键代码与配置示例6.1 用IHttpClientFactory做节点健康探测与并发控制C#侧的服务端在做节点健康探测时不建议直接用new HttpClient()在高并发场景下容易把socket和连接池耗爆。我通常把健康探测封装成一个HostedService每个节点一个HttpClient实例由IHttpClientFactory统一管理生命周期。public sealed class NodeHealthProbeService : BackgroundService { private readonly IHttpClientFactory _httpClientFactory; private readonly IOptionsNodePoolOptions _options; private readonly NodeWeightRegistry _weightRegistry; public NodeHealthProbeService( IHttpClientFactory httpClientFactory, IOptionsNodePoolOptions options, NodeWeightRegistry weightRegistry) { _httpClientFactory httpClientFactory; _options options; _weightRegistry weightRegistry; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer new PeriodicTimer(TimeSpan.FromSeconds(3)); while (await timer.WaitForNextTickAsync(stoppingToken)) { foreach (var node in _options.Value.Nodes) { var client _httpClientFactory.CreateClient($probe-{node.NodeId}); try { var response await client.GetAsync(${node.BaseUrl}/health, stoppingToken); if (response.IsSuccessStatusCode) { _weightRegistry.MarkHealthy(node.NodeId); } else { _weightRegistry.MarkUnhealthy(node.NodeId); } } catch { _weightRegistry.MarkUnhealthy(node.NodeId); } } } } }健康状态会同步到权重注册表这个注册表就是所有节点选择逻辑的数据来源避免每次分配分片时都去数据库拉节点列表。6.2 一个轻量的一致性哈希实现避免重复造轮子一致性哈希网上有很多实现但我建议团队还是保留一个轻量级版本方便按业务键自定义虚拟节点数量和权重因子。核心逻辑并不复杂把每个物理节点的哈希值和若干虚拟节点的哈希值放进一个有序列表搜索分片键的哈希值在环上找后继节点。public sealed class ConsistentHashRouterT { private readonly SortedDictionaryuint, T _ring new(); private readonly int _virtualNodeCount; public ConsistentHashRouter(int virtualNodeCount 160) { _virtualNodeCount virtualNodeCount; } public void AddNode(T node, int weight 1) { var nodeKey node!.ToString()!; for (int i 0; i _virtualNodeCount * weight; i) { var hash ComputeHash(${nodeKey}:{i}); _ring[hash] node; } } public void RemoveNode(T node) { var nodeKey node!.ToString()!; for (int i 0; i _virtualNodeCount * weight; i) { var hash ComputeHash(${nodeKey}:{i}); _ring.Remove(hash); } } public T? GetNode(string key) { if (_ring.Count 0) return default; var hash ComputeHash(key); var successor _ring.Keys.FirstOrDefault(k k hash); if (successor 0 !_ring.ContainsKey(successor)) { successor _ring.Keys.First(); } return _ring[successor]; } private static uint ComputeHash(string input) { using var sha System.Security.Cryptography.SHA256.Create(); var bytes sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input)); return BitConverter.ToUInt32(bytes, 0); } }实际工程中我不希望每个分片都用字符串拼接一次计算哈希可以在分片元数据生成时就把目标节点计算好写入分配表。这样调度器只需要读表不需要在每次HTTP请求时再算一遍。6.3 分片合并的并行调度与异常处理合并阶段我用SemaphoreSlim控制并发拉取数量避免同时开启上百个HTTP连接导致节点连接池被压垮。下面是简化版本的合并调度逻辑public async Taskbool MergeTransferAsync(string transferId, CancellationToken token) { var chunks await _chunkStateRepository.GetChunksByTransferIdAsync(transferId); var groups chunks.GroupBy(c c.FileId); using var semaphore new SemaphoreSlim(8); var tasks groups.Select(async g { await semaphore.WaitAsync(token); try { var orderedChunks g.OrderBy(c c.ChunkIndex).ToList(); var tempFile Path.Combine(_mergeTempFolder, ${transferId}_{g.Key}.tmp); await using var fs File.Create(tempFile); foreach (var chunk in orderedChunks) { var chunkBytes await _nodeClient.PullChunkAsync( chunk.ActualNode, transferId, chunk.FileId, chunk.ChunkIndex, token); await fs.WriteAsync(chunkBytes, token); } var md5 CalculateMd5(tempFile); if (md5 ! g.First().OriginalFileMd5) { throw new InvalidDataException($File {g.Key} md5 mismatch); } // 写入最终目录、更新状态 } finally { semaphore.Release(); } }); await Task.WhenAll(tasks); return true; }注意这段代码里的异常处理并没有把单个文件失败蔓延成整个批次失败。一旦某个文件的MD5校验失败我会把该文件的所有分片标记为“待重传”由客户端重新上传而不是带着错误数据继续合并。7. 生产环境实战我踩过的几个“隐形”问题7.1 节点磁盘满的检测滞后导致的分片全部失败第一次上线时我们的健康检查只看“当前剩余空间比例”但分片上传往往是突发性的某个节点可能在短短几分钟内接收几百GB数据。等健康检查发现磁盘剩余空间不足时新的分片已经在往这个节点发了结果就是一批批写入失败。后来我把磁盘预检放到“接受分片前”的单次请求拦截器里每台存储节点在写入临时文件前先检查当前分片大小和剩余空间百分比低于预警线直接返回503并通知调度服务把该节点摘除。虽然增加了大约0.1毫秒的判断开销但彻底避免了“写一半发现磁盘满了”的状态。7.2 客户端上传超时与网关重试导致的重复分片银行网络偶尔会有长达几秒的抖动客户端第一次发送分片后等不到服务器响应网关层又会自动重发。如果分片接口没有幂等设计就会出现同一个分片在节点上被写入两次第二次覆盖时如果两次内容不同数据就悄悄损坏了。我们最终在存储节点上对每个分片增加了“临时文件锁”写入期间持有文件锁写入完成后记录分片状态后续重复请求直接读取已存在分片的MD5做比对。如果客户端重发的分片MD5和已落盘分片不一致返回409冲突要求客户端重新拉取任务状态而不是盲目把旧分片覆盖掉。7.3 容器化节点与固定IP的“身份漂移”问题团队后来把存储节点容器化部署原本用IP端口作为节点唯一标识。结果某次故障后容器重启分配的IP变了一致性哈希环上的旧节点身份还残留着新分片该去新地址却找不到节点整个上传任务卡住。这块的教训是节点身份必须用稳定唯一标识比如部署时生成的NodeId或容器名IP只能作为“当前地址”进行动态刷新。哈希环使用NodeId计算健康检查使用IP进行连接两者解耦。这样做之后即使节点重启换IP哈希环也不会重建正在处理的分片任务仍然可以顺畅交接。最后再分享一个小技巧如果你所在团队正在做这类方案我建议先把“分片状态表”和“节点选择逻辑”用单元测试锁死特别是分片键的哈希计算后续加节点、减节点时只要这几个用例不回归线上基本不会出大乱子。测试用例不复杂随便构造二十个分片断言它们均匀落在节点池里删掉一个节点后确认存量分片重试时绝大多数仍保持不变极少数分配到新节点这就够了。当年我接手这类需求时最让我意外的不是负载均衡或分片算法本身而是“看起来简单的上传功能”背后藏着这么多分布式问题。希望这篇拆解能帮你把整个方案看通透落地时少走我走过的弯路。