概述
FastDFS 的引入与内部结构部分已经交代了 Tracker / Storage / Group 的架构和文件内部命名规则,本文往运维方向深挖:文件在集群里到底怎么流转、节点之间怎么同步、怎么扩容缩容,以及「刚传完的文件查不到」这个经典坑的来龙去脉。
纲要
- 全景流程:上传与下载的完整交互(Client / Tracker / Storage 三端协作)
- Storage 间的同步机制
- 同组互为备份,同步是异步的
- 同步延迟窗口与「刚上传就 404」问题
- 解决方向:读 A 写 A、Nginx 屏蔽、业务容忍
- Tracker 的调度策略
- 如何挑选 Storage(剩余空间、轮询、IP 排序)
- Tracker 集群:彼此不通信、Client 任选、靠冗余保证高可用
- 扩容与缩容
- 同组加机器(自动同步补齐)
- 加新组(真正扩容量)
- Storage 下线(数据安全性来源)
- 文件一致性:file_id 不变,一次写入多次读取
- 断点续传与大文件:appender 文件的能力边界
- 工程衔接:客户端如何找到 Tracker、file_id 如何变成可访问 URL
- 常见误解澄清表
FastDFS 里有三个角色:客户端(我们的 Java 代码)、Tracker server(调度/追踪服务器)、Storage server(存储服务器)。上传下载的交互过程如下:
Storage server Tracker server 客户端 (Java) Storage server Tracker server 客户端 (Java) #mermaid-svg-3zG8dWbFqVCkOvRM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3zG8dWbFqVCkOvRM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3zG8dWbFqVCkOvRM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3zG8dWbFqVCkOvRM .error-icon{fill:#552222;}#mermaid-svg-3zG8dWbFqVCkOvRM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3zG8dWbFqVCkOvRM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3zG8dWbFqVCkOvRM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3zG8dWbFqVCkOvRM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3zG8dWbFqVCkOvRM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3zG8dWbFqVCkOvRM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3zG8dWbFqVCkOvRM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3zG8dWbFqVCkOvRM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3zG8dWbFqVCkOvRM .marker.cross{stroke:#333333;}#mermaid-svg-3zG8dWbFqVCkOvRM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3zG8dWbFqVCkOvRM p{margin:0;}#mermaid-svg-3zG8dWbFqVCkOvRM .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3zG8dWbFqVCkOvRM text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3zG8dWbFqVCkOvRM .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3zG8dWbFqVCkOvRM .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3zG8dWbFqVCkOvRM .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3zG8dWbFqVCkOvRM .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3zG8dWbFqVCkOvRM #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3zG8dWbFqVCkOvRM .sequenceNumber{fill:white;}#mermaid-svg-3zG8dWbFqVCkOvRM #sequencenumber{fill:#333;}#mermaid-svg-3zG8dWbFqVCkOvRM #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3zG8dWbFqVCkOvRM .messageText{fill:#333;stroke:none;}#mermaid-svg-3zG8dWbFqVCkOvRM .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3zG8dWbFqVCkOvRM .labelText,#mermaid-svg-3zG8dWbFqVCkOvRM .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3zG8dWbFqVCkOvRM .loopText,#mermaid-svg-3zG8dWbFqVCkOvRM .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3zG8dWbFqVCkOvRM .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3zG8dWbFqVCkOvRM .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3zG8dWbFqVCkOvRM .noteText,#mermaid-svg-3zG8dWbFqVCkOvRM .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3zG8dWbFqVCkOvRM .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3zG8dWbFqVCkOvRM .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3zG8dWbFqVCkOvRM .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3zG8dWbFqVCkOvRM .actorPopupMenu{position:absolute;}#mermaid-svg-3zG8dWbFqVCkOvRM .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-3zG8dWbFqVCkOvRM .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3zG8dWbFqVCkOvRM .actor-man circle,#mermaid-svg-3zG8dWbFqVCkOvRM line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3zG8dWbFqVCkOvRM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 阶段零:Storage 定期向 Tracker 上报状态 (磁盘容量、剩余空间、同步进度) 客户端保存完整访问路径: Nginx 地址 + group + 路径 + 文件名 ① 上传请求(询问可用 Storage) ② 返回一台 Storage 的 IP + 端口 ③ 直接上传文件 ④ 返回 file_id(组名 + 路径 + 文件名) ⑤ 后台异步同步到同组其他节点 ⑥ 下载请求(按 file_id 查询) ⑦ 返回可用的 Storage 信息 ⑧ 请求文件 ⑨ 返回文件内容
两个值得注意的点:
- Tracker 全程不碰文件内容,它只做「查表 + 策略计算」,返回的是目标 Storage 的地址。文件流直接走客户端到 Storage。
- 下载在实际业务里其实很少走 Java 代码。因为每台 Storage 上都绑了 Nginx,客户端早就拿到了完整 URL,浏览器/APP 直接发 HTTP 请求让 Nginx 转发即可,比绕一圈 Java 后端再读文件流省事得多。
tree
FastDFS 一次上传后客户端掌握的信息
├── Tracker 返回:Storage 的 IP + 端口(临时,用完即弃)
├── Storage 返回:file_id
│ ├── 组名(group1)
│ ├── 虚拟磁盘路径(M00 → 对应一块挂载磁盘)
│ ├── 两级目录(00/00)
│ └── 文件名(含 Base64 编码的元信息:大小、时间戳、CRC32)
└── 客户端落库的完整 URL
└── http://192.168.136.160:8888/group1/M00/00/00/xxx.jpg
Storage 间的同步机制
这是理解 FastDFS 运维行为的关键:同组内所有 Storage 节点保存的内容完全一样,互为备份,没有主从概念。任何一个节点都可以独立承接读写,所以同步不可能是「先复制完再返回」,那等于把写延迟放大 N 倍。
FastDFS 的做法是异步后台同步:
#mermaid-svg-EKDdbALHG4FXBcgh{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EKDdbALHG4FXBcgh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EKDdbALHG4FXBcgh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EKDdbALHG4FXBcgh .error-icon{fill:#552222;}#mermaid-svg-EKDdbALHG4FXBcgh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EKDdbALHG4FXBcgh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EKDdbALHG4FXBcgh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EKDdbALHG4FXBcgh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EKDdbALHG4FXBcgh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EKDdbALHG4FXBcgh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EKDdbALHG4FXBcgh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EKDdbALHG4FXBcgh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EKDdbALHG4FXBcgh .marker.cross{stroke:#333333;}#mermaid-svg-EKDdbALHG4FXBcgh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EKDdbALHG4FXBcgh p{margin:0;}#mermaid-svg-EKDdbALHG4FXBcgh .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-EKDdbALHG4FXBcgh .cluster-label text{fill:#333;}#mermaid-svg-EKDdbALHG4FXBcgh .cluster-label span{color:#333;}#mermaid-svg-EKDdbALHG4FXBcgh .cluster-label span p{background-color:transparent;}#mermaid-svg-EKDdbALHG4FXBcgh .label text,#mermaid-svg-EKDdbALHG4FXBcgh span{fill:#333;color:#333;}#mermaid-svg-EKDdbALHG4FXBcgh .node rect,#mermaid-svg-EKDdbALHG4FXBcgh .node circle,#mermaid-svg-EKDdbALHG4FXBcgh .node ellipse,#mermaid-svg-EKDdbALHG4FXBcgh .node polygon,#mermaid-svg-EKDdbALHG4FXBcgh .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EKDdbALHG4FXBcgh .rough-node .label text,#mermaid-svg-EKDdbALHG4FXBcgh .node .label text,#mermaid-svg-EKDdbALHG4FXBcgh .image-shape .label,#mermaid-svg-EKDdbALHG4FXBcgh .icon-shape .label{text-anchor:middle;}#mermaid-svg-EKDdbALHG4FXBcgh .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EKDdbALHG4FXBcgh .rough-node .label,#mermaid-svg-EKDdbALHG4FXBcgh .node .label,#mermaid-svg-EKDdbALHG4FXBcgh .image-shape .label,#mermaid-svg-EKDdbALHG4FXBcgh .icon-shape .label{text-align:center;}#mermaid-svg-EKDdbALHG4FXBcgh .node.clickable{cursor:pointer;}#mermaid-svg-EKDdbALHG4FXBcgh .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EKDdbALHG4FXBcgh .arrowheadPath{fill:#333333;}#mermaid-svg-EKDdbALHG4FXBcgh .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EKDdbALHG4FXBcgh .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EKDdbALHG4FXBcgh .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EKDdbALHG4FXBcgh .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EKDdbALHG4FXBcgh .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EKDdbALHG4FXBcgh .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EKDdbALHG4FXBcgh .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EKDdbALHG4FXBcgh .cluster text{fill:#333;}#mermaid-svg-EKDdbALHG4FXBcgh .cluster span{color:#333;}#mermaid-svg-EKDdbALHG4FXBcgh div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-EKDdbALHG4FXBcgh .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EKDdbALHG4FXBcgh rect.text{fill:none;stroke-width:0;}#mermaid-svg-EKDdbALHG4FXBcgh .icon-shape,#mermaid-svg-EKDdbALHG4FXBcgh .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EKDdbALHG4FXBcgh .icon-shape p,#mermaid-svg-EKDdbALHG4FXBcgh .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EKDdbALHG4FXBcgh .icon-shape .label rect,#mermaid-svg-EKDdbALHG4FXBcgh .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EKDdbALHG4FXBcgh .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EKDdbALHG4FXBcgh .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EKDdbALHG4FXBcgh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 后台线程
客户端上传到 Storage A
A 写入本地磁盘成功
立即向客户端返回 file_id
客户端业务完成
A 按同步时间戳
把新文件推给同组 B、C
B、C 落盘并记录同步进度 binlog
上传请求在 A 节点落盘成功的那一刻就返回了,同组其他节点的副本是 A 节点的后台线程按自己的同步 binlog 逐个补齐的。这在设计上是合理取舍,文件类业务对「毫秒级一致性」几乎无感,但它带来一个绕不开的窗口期问题。
同步延迟窗口:刚上传就 404
复现一下这个经典故障:文件刚传到同组的 Storage A,客户端立刻拿着 URL 去访问。FastDFS 取文件时会调度同组内的某一台 Storage,这次选中的偏偏是还没同步到该文件的 Storage B,B 上没有这个文件,Nginx 转发后返回 404。过几秒再访问,同步补齐了,又好了。
排查时可以先确认是不是这个原因:登录 B 节点看它对应磁盘下的同步标记文件(data 目录下的 binlog 及 .mark 文件),对比 A 节点的同步进度;如果 B 的同步进度落后于文件名里的时间戳,就是典型的同步延迟窗口。
工程上有三个解决方向:
| 方向 | 做法 | 适用场景 |
|---|---|---|
| 读 A 写 A 的粘性 | 上传后立刻需要回显/处理的场景,直接对 A 节点发起读取(客户端记下上传时 Tracker 返回的那台 Storage 地址) | 上传后马上要生成缩略图、审核等强一致操作 |
| Nginx 层屏蔽 | 在反代层做健康检查与重试:对 B 返回 404 时 fallback 重试到 A;或 Storage 端 Nginx 配置 proxy_next_upstream |
通用方案,对业务代码透明 |
| 业务层容忍 | 前端上传成功后延迟展示,或失败自动重试一次 | 对实时性要求低的场景(如小视频列表) |
交友这类业务天然适合第三种:用户上传头像/视频后,别的用户刷新列表才看到,几秒的同步窗口完全无感。
Tracker 的调度策略
如何选 Storage
客户端发上传请求时,Tracker 要回答两个问题:选哪个组、组内选哪台。常见策略:
| 决策点 | 常见策略 | 说明 |
|---|---|---|
| 选组 | tracker.conf 的 store_lookup:0 轮询 / 1 指定组 / 2 负载均衡 |
0 时在可用组间轮询;1 配合 store_group 固定写某个组;2 按剩余空间挑最宽裕的组 |
| 组内选节点 | 剩余空间优先 / 轮询 | 负载均衡模式下内置按剩余存储空间排序,空间大的优先 |
| 下载/查询选节点 | 轮询( 可配置按源 IP 就近) | 下载时可以在同组内任选一台,这也是同步延迟问题的入口 |
依据来自 Storage 周期性上报的状态:磁盘容量、剩余空间、文件同步状态。Tracker 的内存里维护着这份「集群状态表」,所以常说的「负载均衡和调度」,本质是一张会过期的状态表加上几条选择策略。
Tracker 集群:靠冗余,不靠选举
Tracker 集群的设计很「反直觉」:多台 Tracker 彼此不通信、不共享状态,客户端配置里全列上,任选一台用。每台 Tracker 各自收集 Storage 的心跳,各管各的表。
这跟 ZooKeeper 那类系统完全不同,ZooKeeper 靠多数派选举保证全局一致,Tracker 没有任何选举逻辑,高可用完全靠冗余:挂掉一台,客户端探测失败后换下一台,集群照常工作。代价是各台 Tracker 看到的 Storage 状态可能短暂不一致,但对「挑一台能用的 Storage」这种粗粒度决策来说无关紧要。
生产部署一般 2~3 台 Tracker,客户端的 tracker-list 全配上。
扩容与缩容
Group 机制让容量伸缩变成了纯运维操作:
#mermaid-svg-UmBR2gNYPqZ1NBJT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UmBR2gNYPqZ1NBJT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UmBR2gNYPqZ1NBJT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UmBR2gNYPqZ1NBJT .error-icon{fill:#552222;}#mermaid-svg-UmBR2gNYPqZ1NBJT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UmBR2gNYPqZ1NBJT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UmBR2gNYPqZ1NBJT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UmBR2gNYPqZ1NBJT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UmBR2gNYPqZ1NBJT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UmBR2gNYPqZ1NBJT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UmBR2gNYPqZ1NBJT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UmBR2gNYPqZ1NBJT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UmBR2gNYPqZ1NBJT .marker.cross{stroke:#333333;}#mermaid-svg-UmBR2gNYPqZ1NBJT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UmBR2gNYPqZ1NBJT p{margin:0;}#mermaid-svg-UmBR2gNYPqZ1NBJT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UmBR2gNYPqZ1NBJT .cluster-label text{fill:#333;}#mermaid-svg-UmBR2gNYPqZ1NBJT .cluster-label span{color:#333;}#mermaid-svg-UmBR2gNYPqZ1NBJT .cluster-label span p{background-color:transparent;}#mermaid-svg-UmBR2gNYPqZ1NBJT .label text,#mermaid-svg-UmBR2gNYPqZ1NBJT span{fill:#333;color:#333;}#mermaid-svg-UmBR2gNYPqZ1NBJT .node rect,#mermaid-svg-UmBR2gNYPqZ1NBJT .node circle,#mermaid-svg-UmBR2gNYPqZ1NBJT .node ellipse,#mermaid-svg-UmBR2gNYPqZ1NBJT .node polygon,#mermaid-svg-UmBR2gNYPqZ1NBJT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UmBR2gNYPqZ1NBJT .rough-node .label text,#mermaid-svg-UmBR2gNYPqZ1NBJT .node .label text,#mermaid-svg-UmBR2gNYPqZ1NBJT .image-shape .label,#mermaid-svg-UmBR2gNYPqZ1NBJT .icon-shape .label{text-anchor:middle;}#mermaid-svg-UmBR2gNYPqZ1NBJT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UmBR2gNYPqZ1NBJT .rough-node .label,#mermaid-svg-UmBR2gNYPqZ1NBJT .node .label,#mermaid-svg-UmBR2gNYPqZ1NBJT .image-shape .label,#mermaid-svg-UmBR2gNYPqZ1NBJT .icon-shape .label{text-align:center;}#mermaid-svg-UmBR2gNYPqZ1NBJT .node.clickable{cursor:pointer;}#mermaid-svg-UmBR2gNYPqZ1NBJT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UmBR2gNYPqZ1NBJT .arrowheadPath{fill:#333333;}#mermaid-svg-UmBR2gNYPqZ1NBJT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UmBR2gNYPqZ1NBJT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UmBR2gNYPqZ1NBJT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UmBR2gNYPqZ1NBJT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UmBR2gNYPqZ1NBJT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UmBR2gNYPqZ1NBJT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UmBR2gNYPqZ1NBJT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UmBR2gNYPqZ1NBJT .cluster text{fill:#333;}#mermaid-svg-UmBR2gNYPqZ1NBJT .cluster span{color:#333;}#mermaid-svg-UmBR2gNYPqZ1NBJT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UmBR2gNYPqZ1NBJT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UmBR2gNYPqZ1NBJT rect.text{fill:none;stroke-width:0;}#mermaid-svg-UmBR2gNYPqZ1NBJT .icon-shape,#mermaid-svg-UmBR2gNYPqZ1NBJT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UmBR2gNYPqZ1NBJT .icon-shape p,#mermaid-svg-UmBR2gNYPqZ1NBJT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UmBR2gNYPqZ1NBJT .icon-shape .label rect,#mermaid-svg-UmBR2gNYPqZ1NBJT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UmBR2gNYPqZ1NBJT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UmBR2gNYPqZ1NBJT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UmBR2gNYPqZ1NBJT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 扩容两种姿势
自动同步历史数据
组间文件不同
同组加机器
group1 新增 Storage D
容量不变
读能力与冗余度提升
加新组
新增 group2 一台或多台
容量真正扩大
Storage 下线
同组还有副本 / 文件还在别的组
数据不丢
安全性来自组内冗余
- 同组加机器 :新节点加入后,老节点会通过同步机制把历史数据逐步推给它,直到补齐。适合「读压力大、想多几份副本」的场景,但总容量没有变化,同组是镜像关系。
- 加新组:这是唯一的扩容方式。组与组之间保存的文件不同,新增 group 等于新增一份独立容量,Tracker 会自动把新上传的文件往新组调度。
- Storage 下线 :同组下线一台,文件在同组其他节点上还有完整副本;如果下线的是整组(缩容),该组的数据就没备份了,需要先把数据迁移到别的组再操作。数据安全性完全来自组内冗余,这也是为什么生产上同组至少两台。
一个容易踩的规划坑:扩容加新组后,新组通常是新买的机器,磁盘可能更大。FastDFS 组内要求同组机器配置尽量一致(同步和调度都以组为单位),所以容量规划时按「组」为最小扩容单元来预算,而不是按单台机器。
文件一致性:file_id 不变
FastDFS 是不可变存储(immutable storage):文件写入后内容不可修改,file_id 终生不变。想「更新」一个文件,唯一办法是删掉旧的、传一份新的,拿到新的 file_id 再更新业务表,这也是所有内容类存储(对象存储等)的通用设计。
对交友这类业务反而是契合的:头像、照片、小视频都是「一次写入、多次读取」,天然没有原地改文件内容的需求。需要「改」的场景(比如重新剪视频),走的也是「新文件 + 换引用」的路子。
带来的业务约束:
- 不能像操作本地文件那样
RandomAccessFile原地改几个字节; - 想覆盖上传,只能删除后重传,file_id 会变,业务表里存 file_id 的字段必须跟着更新;
- 删除也是按 file_id 整文件删除,没有局部修改。
断点续传与大文件
FastDFS 支持 appender 文件模式:上传时声明为 appender,之后可以对它做追加写入(modify_file / append_file),配合分块上传就能实现断点续传,客户端记录已传分片偏移量,重传时从断点继续 append。但能力边界也要清楚:appender 文件在 Storage 端会占用额外的元信息管理,且不能直接转成普通文件的合并语义,大文件(GB 级)场景下更常见的做法还是客户端分片 + 业务层合并,FastDFS 只当最终存储。交友类应用的小视频通常在几十 MB 以内,用不到这套机制。
工程衔接:客户端配置
以上原理落到工程里,就是 mf-app-server 的一段 yml。实读工程文件 mf-app-server/src/main/resources/application.yml:
yaml
# 分布式文件系统FDFS配置
fdfs:
so-timeout: 1500 # 读取超时(毫秒)
connect-timeout: 600 # 连接超时(毫秒)
# 缩略图生成参数
thumb-image:
width: 150
height: 150
# TrackerList参数,支持多个
tracker-list: 192.168.136.160:22122
web-server-url: http://192.168.136.160:8888/
这段配置印证了前文的两个原理:
- 客户端只需要知道 Tracker 。
tracker-list指向 Tracker 的 22122 端口,注释明确写了「支持多个」,对应 Tracker 集群靠冗余高可用,客户端全列上、任选一台。客户端从头到尾不需要知道任何 Storage 的地址,每次上传下载前问 Tracker 要。 web-server-url就是 URL 拼接的前缀。它指向 Storage 端 Nginx 的 8888 端口。客户端上传拿到 file_id 后,拼出完整访问地址:
text
http://192.168.136.160:8888/ + group1/M00/00/00/CgDOC1.jpg
└── web-server-url(Nginx) └── file_id(组名/虚拟磁盘/两级目录/文件名)
对应代码里就是 FdfsWebServer 注入的 getWebServerUrl() 加上 StorePath 的 getFullPath() 拼接,落库到 MySQL 里的正是这个完整 URL。用户 APP 拿到 URL 后直接请求 Storage 的 Nginx,Nginx 转发到本机 Storage 的文件目录,整个过程不经过 Java 后端。
常见误解澄清
| 误解 | 实际情况 |
|---|---|
| 同组 Storage 之间是同步阻塞复制,传完才算成功 | 同步是异步的:上传节点落盘即返回,副本由后台线程按同步 binlog 逐步补齐 |
| Tracker 挂了一台,集群就不可用 | 多台 Tracker 彼此不通信、无选举,客户端配置多个地址任选,靠冗余保证高可用 |
| 文件传上去之后可以原地修改内容 | FastDFS 是不可变存储,file_id 不变、内容不可改;只能删旧传新并更新业务表引用 |
| 同组里加越多机器,容量越大 | 同组是镜像冗余关系,容量以「组」为最小单元;扩容量必须加新组 |
| 下载必须走 Java 客户端 API | 常规做法是拼 URL 直接请求 Storage 端 Nginx,Java API 下载只在少数后端处理场景使用 |
官方文档
- FastDFS GitHub 仓库(happyfish100/fastdfs)
- fastdfs-client(tobato 版 Java 客户端,项目所用 1.26.7)
- Nginx 官方文档(proxy_next_upstream 等指令)
总结
- 上传下载的调度核心在 Tracker,但文件流永远直连 Storage;Tracker 只维护状态表加策略,不碰数据。
- 同组互备 + 异步同步是 FastDFS 的性能取舍,代价是「同步延迟窗口」,刚上传就 404 要从粘性读取、Nginx 重试、业务容忍三个方向里选一个来治。
- Tracker 高可用靠冗余不靠选举,这与 ZooKeeper 类系统是两种截然不同的集群哲学。
- 容量以组为单位:同组加机器只加冗余不加容量,扩容量必须加新组;数据安全性来自组内副本。
- 项目工程里
tracker-list与web-server-url两个配置项,分别对应「客户端只认 Tracker」和「file_id 拼 URL 走 Nginx」两个原理落点。
FastDFS 入门案例会把 fastdfs-client 依赖引入工程,跑通第一个上传文件的测试用例。