FastDFS

为什么要有分布式文件系统?

单机时代

优点:文件访问比较便利,项目直接引用,实现起来简单,无需任何复杂技术,保存文件和访问文件都 很方便。

缺点:一方面,文件和代码耦合在一起,文件越多存放越混乱。另一方面,如果流量比较大,静态文件 访问会占据一定的资源,影响正常业务进行,不利于网站快速发展。

独立文件服务器

优点:Web/App服务器可以更专注发挥动态处理的能力。独立存储,更方便做扩容、容灾和数据迁移; 方便做图片等资源请求的负载均衡,方便应用各种缓存策略(HTTP Header、Proxy Cache等),也更 加方便迁移到CDN。

缺点:单机存在性能瓶颈,容灾、垂直扩展性稍差

分布式文件系统

优点:扩展能力: 毫无疑问,扩展能力是一个分布式文件系统最重要的特点;高可用性: 在分布式文件 系统中,高可用性包含两层,一是整个文件系统的可用性,二是数据的完整和一致性;弹性存储: 可以 根据业务需要灵活地增加或缩减数据存储以及增删存储池中的资源,而不需要中断系统运行。 缺点:系统复杂度稍高,需要更多服务器。

为什么要使用FastDFS

通用的分布式文件系统的优点的是开发体验好,但是系统复杂性高、性能一般,而专用的分布式文件系统虽然开发体验性差,但是系统复杂性低并且性能高。fastDFS非常适合存储图片等那些小文件,fastDFS不对文件进行分块,所以它就没有分块合并的开销,fastDFS网络通信采用socket,通信速度很快。

FastDFS体系结构

FastDFS是一个开源的轻量级分布式文件系统,它对文件进行管理,功能包括:文件存储、文件同步、文件访问(文件上传、文件下载)等,解决了大容量存储和负载均衡的问题。特别适合以文件为载体的在线服务,如相册网站、视频网站等等。

FastDFS为互联网量身定制,充分考虑了冗余备份、负载均衡、线性扩容等机制,并注重高可用、高性能等指标,使用FastDFS很容易搭建一套高性能的文件服务器集群提供文件上传、下载等服务。

FastDFS 架构包括 Tracker server 和 Storage server。客户端请求 Tracker server 进行文件上传、下载,通过Tracker server 调度最终由 Storage server 完成文件上传和下载。

Tracker server 作用是负载均衡和调度,通过 Tracker server 在文件上传时可以根据一些策略找到Storage server 提供文件上传服务。可以将 tracker 称为追踪服务器或调度服务器。Storage server 作用是文件存储,客户端上传的文件最终存储在 Storage 服务器上,Storageserver 没有实现自己的文件系统而是利用操作系统的文件系统来管理文件。可以将storage称为存储服务器。

img

Tracker

Tracker Server作用是负载均衡和调度,通过Tracker server在文件上传时可以根据一些策略找到Storage server提供文件上传服务。可以将tracker称为追踪服务器或调度服务器。

FastDFS集群中的Tracker server可以有多台,Tracker server之间是相互平等关系同时提供服务,Tracker server不存在单点故障。客户端请求Tracker server采用轮询方式,如果请求的tracker无法提供服务则换另一个tracker。

Storage

Storage Server作用是文件存储,客户端上传的文件最终存储在Storage服务器上,Storage server没有实现自己的文件系统而是使用操作系统的文件系统来管理文件。可以将storage称为存储服务器。

Storage集群采用了分组存储方式。storage集群由一个或多个组构成,集群存储总容量为集群中所有组的存储容量之和。一个组由一台或多台存储服务器组成,组内的Storage server之间是平等关系,不同组的Storage server之间不会相互通信,同组内的Storage server之间会相互连接进行文件同步,从而保证同组内每个storage上的文件完全一致的。一个组的存储容量为该组内的存储服务器容量最小的那个,由此可见组内存储服务器的软硬件配置最好是一致的。

采用分组存储方式的好处是灵活、可控性较强。比如上传文件时,可以由客户端直接指定上传到的组也可以由tracker进行调度选择。一个分组的存储服务器访问压力较大时,可以在该组增加存储服务器来扩充服务能力(纵向扩容)。当系统容量不足时,可以增加组来扩充存储容量(横向扩容)。

Storage状态收集

Storage server会连接集群中所有的Tracker server,定时向他们报告自己的状态,包括磁盘剩余空间、文件同步状况、文件上传下载次数等统计信息。

文件上传流程

img

客户端上传文件后存储服务器将文件ID返回给客户端,此文件ID用于以后访问该文件的索引信息。文件索引信息包括:组名,虚拟磁盘路径,数据两级目录,文件名。

img

组名

文件上传后所在的 storage 组名称,在文件上传成功后有storage 服务器返回,需要客户端自行保存。

虚拟磁盘路径

storage 配置的虚拟路径,与磁盘选项store_path*对应。如果配置了,store_path0 则是 M00,如果配置了 store_path1 则是 M01,以此类推。

数据两级目录

storage 服务器在每个虚拟磁盘路径下创建的两级目录,用于存储数据文件。

文件名

与文件上传时不同。是由存储服务器根据特定信息生成,文件名包含:源存储服务器 IP 地址、文件创建时间戳、文件大小、随机数和文件拓展名等信息。

文件上传内部原理

1、选择tracker server和group

当集群中不止一个tracker server时,由于tracker之间是完全对等的关系,客户端在upload文件时可以 任意选择一个trakcer。 当tracker接收到upload_file的请求时,会为该文件分配一个可以存储该文件的 group,使用store_lookup选择group的规则:

  • 0、Round robin,所有的group间轮询
  • 1、Specified group,指定某一个确定的group
  • 2、Load balance,剩余存储空间多的group优先

2、选择storage server

当选定group后,tracker会在group内选择一个storage server给客户端,使用store_server选择 storage的规则:

  • 0、Round robin,在group内的所有storage间轮询
  • 1、First server ordered by ip,按ip排序
  • 2、First server ordered by priority,按优先级排序(优先级在storage上配置)

3、选择storage path

当分配好storage server后,客户端将向storage发送写文件请求,storage将会为文件分配一个数据存 储目录 storage server可以有多个存放文件的存储路径(可以理解为多个磁盘),store_path支持如下 规则:

  • 0、Round robin,多个存储目录间轮询
  • 2、剩余存储空间最多的优先

4、生成文件名

选定存储目录之后,storage会为文件生一个文件名,由storage server ip、文件创建时间、文件大小、 文件crc32和一个随机数拼接而成,然后将这个二进制串进行base64编码,转换为可打印的字符串。 选 择两级目录 当选定存储目录之后,storage会为文件分配一个文件名,每个存储目录下有两级256*256 的子目录,storage会按文件fileid进行两次hash,路由到其中一个子目录,然后将文件以这个文件标示 为文件名存储到该子目录下。

5、返回文件id

当文件存储到某个子目录后,即认为该文件存储成功,接下来会为该文件返回一个文件id,由group、 存储目录、两级子目录、内部文件名、文件后缀名(由客户端指定,主要用于区分文件类型)拼接而成。

group1/M00/00/00/wKjTiF7iGy6AMefcAACGZa9JdFo097.png

  • 组名:文件上传后所在的存储组名称,在文件上传成功后有存储服务器返回,需要客户端自行保 存。
  • 虚拟磁盘路径:存储服务器配置的虚拟路径,与磁盘选项store_path*对应。
  • 数据两级目录:存储服务器在每个虚拟磁盘路径下创建的两级目录,用于存储数据文件。
  • 文件名:与文件上传时不同。是由存储服务器根据特定信息生成,文件名包含:源存储服务器IP地 址、文件创建时间戳、文件大小、随机数和文件拓展名等信息。

文件下载流程

img

tracker根据请求的文件路径即文件ID 来快速定义文件。

比如请求下边的文件:

img

  1. 通过组名tracker能够很快的定位到客户端需要访问的存储服务器组是group1,并选择合适的存储服务器提供客户端访问。
  2. 存储服务器根据“文件存储虚拟磁盘路径”和“数据文件两级目录”可以很快定位到文件所在目录,并根据文件名找到客户端需要访问的文件。

文件同步

文件同步原理

写文件时,客户端将文件写至group内一个storage server即认为写文件成功,storage server写完文件 后,会由后台线程将文件同步至同group内其他的storage server。

每个storage写文件后,同时会写一份binlog,binlog里不包含文件数据,只包含文件名等元信息,这份 binlog用于后台同步,storage会记录向group内其他storage同步的进度,以便重启后能接上次的进度 继续同步。进度以时间戳的方式进行记录,所以最好能保证集群内所有server的时钟保持同步。

storage的同步进度会作为元数据的一部分汇报到tracker上,tracke在选择storage的时候会以同步进度 作为参考。比如一个group内有A、B、C 三个storage server,A向C同步到进度为T1 ,B向C同步到时 间戳为T2(T2 > T1),tracker接收到这些同步进度信息时,就会进行整理,将最小的那个做为C的同步 时间戳,本例中T1即为C的同步时间戳(即所有T1以前写的数据都已经同步到C上了)。同理,根据上 述规则,tracker会为A、B生成一个同步时间戳。

tracker选择group内可用的storage的规则

  1. 该文件上传到的源头storage
    • 源头storage只要存活着,肯定包含这个文件,源头的地址被编码在文件名中。
  2. 文件创建时间戳==storage被同步到的时间戳 且(当前时间-文件创建时间戳) > 文件同步最大时间 (如5分钟)
    • 文件创建后,认为经过最大同步时间后,肯定已经同步到其他storage了。
  3. 文件创建时间戳 < storage被同步到的时间戳。
    • 同步时间戳之前的文件确定已经同步了
  4. (当前时间 - 文件创建时间戳) > 同步延迟阀值。
    • 经过同步延迟阈值时间,认为文件肯定已经同步了。

文件删除

删除处理流程与文件下载类似:

  1. Client询问Tracker server可以删除指定文件的Storage server,参数为文件ID(包含组名和文件名)。

  2. Tracker server返回一台可用的Storage server。

  3. Client直接和该Storage server建立连接,完成文件删除。

    文件删除API:delete_file

断点续传

提供appender file的支持,通过upload_appender_file接口完成,appender file允许在创建后,对该 文件进行append操作。实际上,appender file与普通文件的存储方式是相同的,不同的是, appender file不能被合并存储到trunk file。续传涉及到的文件大小MD5不会改变。续传流程与文件上 传类似,先定位到源storage,完成完整或部分上传,再通过binlog进行同group内server文件同步。

断点续传的API:upload_appender_file

FastDFS搭建

安装FastDFS镜像

拉取镜像

docker pull morunchang/fastdfs

查看fastdfs镜像

docker images|grep fastdfs

运行tracker

docker run -d --name tracker --net=host morunchang/fastdfs sh tracker.sh

运行storage

docker run -d --name storage --net=host -e TRACKER_IP=192.168.3:22122 -e GROUP_NAME=group1 morunchang/fastdfs sh storage.sh
参数解释
  • 使用的网络模式是–net=host, 192.168.0.100是宿主机的IP。-net=host这种模式只能在linux环境下使用,而windows和mac没有这种模式。如果在云服务器上那么TRACKER_IP就是公网IP。
  • group1是组名,即storage的组
  • 如果想要增加新的storage服务器,再次运行该命令,注意更换 新组名

配置Nginx

Nginx在这里主要提供对FastDFS图片访问的支持,Docker容器中已经集成了Nginx,我们需要修改nginx的配置,进入storage的容器内部,修改nginx.conf

docker exec -it storage  /bin/bash

查看nginx配置文件

vi /etc/nginx/conf/nginx.conf

检查是否有如下配置

location ~ /M00 {
    root /data/fast_data/data;
    ngx_fastdfs_module;
}

如果不存在 加上去并重启服务

docker restart storage
安装Nginx目的

nginx集成了FastDFS,可以通过它的ngx_fastdfs_module模块,可以通过该模块访问Tracker获取图片所存储的Storage信息,然后访问Storage信息获取图片信息。

查看启动容器

img

文件存储服务实战

pom包配置

我们使用Spring Boot 2.2.2.RELEASE、jdk使用1.8。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>yang.org</groupId>
    <artifactId>fastdfs</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.2.RELEASE</version>
    </parent>
    <!--依赖包-->
    <dependencies>
        <dependency>
            <groupId>net.oschina.zcx7878</groupId>
            <artifactId>fastdfs-client-java</artifactId>
            <version>1.27.0.0</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

    </dependencies>

    <repositories>
        <repository>
            <id>central</id>
            <name>aliyun maven</name>
            <url>http://maven.aliyun.com/nexus/content/groups/public/</url>
            <layout>default</layout>
            <!-- 是否开启发布版构件下载 -->
            <releases>
                <enabled>true</enabled>
            </releases>
            <!-- 是否开启快照版构件下载 -->
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
    </repositories>
</project>

加入了fastdfs-client-java包,用来调用FastDFS相关的API。

FastDfs配置文件

resources目录下添加fdfs_client.conf文件

connect_timeout = 60
network_timeout = 60
charset = UTF-8
http.tracker_http_port = 8080
http.anti_steal_token = no
http.secret_key = 123456
tracker_server = 192.168.0.100:22122
tracker_server = 192.168.0.100:22122

配置文件设置了连接的超时时间,编码格式以及tracker_server地址等信息

封装FastDFS上传工具类

FastDFSFile

封装FastDFSFile,文件基础信息包括文件名、内容、文件类型、作者等。

public class FastDFSFile implements Serializable {

    //文件名字
    private String name;
    //文件内容
    private byte[] content;
    //文件扩展名
    private String ext;
    //文件MD5摘要值
    private String md5;
    //文件创建作者
    private String author;

    public FastDFSFile(String name, byte[] content, String ext, String md5, String author) {
        this.name = name;
        this.content = content;
        this.ext = ext;
        this.md5 = md5;
        this.author = author;
    }

    public FastDFSFile(String name, byte[] content, String ext) {
        this.name = name;
        this.content = content;
        this.ext = ext;
    }
    //省略getter、setter
}
FastDFSClient

封装FastDFSClient类,包含常用的上传、下载、删除等方法。


public class FastDFSClient {

    /***
     * 初始化tracker信息
     */
    static {
        try { 
            //首先在类加载的时候读取相应的配置信息,并进行初始化。
            //获取tracker的配置文件fdfs_client.conf的位置
            String filePath = new ClassPathResource("fdfs_client.conf").getPath();
            //加载tracker配置信息
            ClientGlobal.init(filePath);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /****
     * 文件上传
     * @param file : 要上传的文件信息封装->FastDFSFile
     * @return String[]
     *          1:文件上传所存储的组名
     *          2:文件存储路径
     */
    public static String[] upload(FastDFSFile file) {
        //获取文件作者
        NameValuePair[] meta_list = new NameValuePair[1];
        meta_list[0] = new NameValuePair(file.getAuthor());

        /***
         * 文件上传后的返回值
         * uploadResults[0]:文件上传所存储的组名,例如:group1
         * uploadResults[1]:文件存储路径,例如:M00/00/00/wKjThF0DBzaAP23MAAXz2mMp9oM26.jpeg
         */
        String[] uploadResults = null;
        try {
            //获取StorageClient对象
            StorageClient storageClient = getStorageClient();
            //执行文件上传
            uploadResults = storageClient.upload_file(file.getContent(), file.getExt(), meta_list);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return uploadResults;
    }


    /***
     * 获取文件信息
     * @param groupName:组名
     * @param remoteFileName:文件存储完整名
     */
    public static FileInfo getFile(String groupName, String remoteFileName) {
        try {
            //获取StorageClient对象
            StorageClient storageClient = getStorageClient();
            //获取文件信息
            return storageClient.get_file_info(groupName, remoteFileName);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    /***
     * 文件下载
     * @param groupName:组名
     * @param remoteFileName:文件存储完整名
     * @return
     */
    public static InputStream downFile(String groupName, String remoteFileName) {
        try {
            //获取StorageClient
            StorageClient storageClient = getStorageClient();
            //通过StorageClient下载文件
            byte[] fileByte = storageClient.download_file(groupName, remoteFileName);
            //将字节数组转换成字节输入流
            return new ByteArrayInputStream(fileByte);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    /***
     * 文件删除实现
     * @param groupName:组名
     * @param remoteFileName:文件存储完整名
     */
    public static void deleteFile(String groupName, String remoteFileName) {
        try {
            //获取StorageClient
            StorageClient storageClient = getStorageClient();
            //通过StorageClient删除文件
            storageClient.delete_file(groupName, remoteFileName);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }


    /***
     * 获取组信息
     * @param groupName :组名
     */
    public static StorageServer getStorages(String groupName) {
        try {
            //创建TrackerClient对象
            TrackerClient trackerClient = new TrackerClient();
            //通过TrackerClient获取TrackerServer对象
            TrackerServer trackerServer = trackerClient.getConnection();
            //通过trackerClient获取Storage组信息
            return trackerClient.getStoreStorage(trackerServer, groupName);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    /***
     * 根据文件组名和文件存储路径获取Storage服务的IP、端口信息
     * @param groupName :组名
     * @param remoteFileName :文件存储完整名
     */
    public static ServerInfo[] getServerInfo(String groupName, String remoteFileName) {
        try {
            //创建TrackerClient对象
            TrackerClient trackerClient = new TrackerClient();
            //通过TrackerClient获取TrackerServer对象
            TrackerServer trackerServer = trackerClient.getConnection();
            //获取服务信息
            return trackerClient.getFetchStorages(trackerServer, groupName, remoteFileName);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    /***
     * 获取Tracker服务地址
     */
    public static String getTrackerUrl() {
        try {
            //创建TrackerClient对象
            TrackerClient trackerClient = new TrackerClient();
            //通过TrackerClient获取TrackerServer对象
            TrackerServer trackerServer = trackerClient.getConnection();
            //获取Tracker地址
            return "http://" + trackerServer.getInetSocketAddress().getHostString() + ":" + ClientGlobal.getG_tracker_http_port();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    /***
     * 获取TrackerServer
     */
    public static TrackerServer getTrackerServer() throws Exception {
        //创建TrackerClient对象
        TrackerClient trackerClient = new TrackerClient();
        //通过TrackerClient获取TrackerServer对象
        TrackerServer trackerServer = trackerClient.getConnection();
        return trackerServer;
    }

    /***
     * 获取StorageClient
     * @return
     * @throws Exception
     */
    public static StorageClient getStorageClient() throws Exception {
        //获取TrackerServer
        TrackerServer trackerServer = getTrackerServer();
        //通过TrackerServer创建StorageClient
        StorageClient storageClient = new StorageClient(trackerServer, null);
        return storageClient;
    }
}

编写上传控制类

从MultipartFile中读取文件信息,然后使用FastDFSClient将文件上传到FastDFS集群中。

@RestController
@CrossOrigin
public class FileController {

    /***
     * 文件上传
     * @return
     */
    @PostMapping(value = "/upload")
    public String upload(@RequestParam("file") MultipartFile file) throws Exception {
        //封装一个FastDFSFile
        FastDFSFile fastDFSFile = new FastDFSFile(
                file.getOriginalFilename(), //文件名字
                file.getBytes(),            //文件字节数组
                StringUtils.getFilenameExtension(file.getOriginalFilename()));//文件扩展名

        //文件上传
        String[] uploads = FastDFSClient.upload(fastDFSFile);
        //组装文件上传地址
        return "http://192.168.0.100:8080" + "/" + uploads[0] + "/" + uploads[1];
    }
}

微服务配置

application.yml配置

在resources文件夹下创建application.yml

spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 10MB
  application:
    name: file
server:
  port: 8080

max-file-size是单个文件大小,max-request-size是设置总上传的数据大小

启动类
@SpringBootApplication
public class FileApplication {

    public static void main(String[] args) {
        SpringApplication.run(FileApplication.class);
    }
}

测试

启动微服务

image-20220120235754305

测试

POST 127.0.0.1:8080/upload 

最后会返回一个结果。

访问http://192.168.0.100:8080/group1/M00/00/00/wKhAgF8id-uAAW14AAXWyTAhn-w197.jpg

FastDFS配置优化

最大连接数设置

配置文件:tracker.conf 和 storage.conf
参数名:max_connections
缺省值:256
说明:FastDFS为一个连接分配一个task buffer,为了提升分配效率,FastDFS采用内存池的做法。
FastDFS老版本直接事先分配 max_connections 个buffer,这个做法显然不是太合理,在
max_connections 设置过大的情况下太浪费内存。v5.04对预分配采用增量方式,tracker一次预分配
1024个,storage一次预分配256个。
#define ALLOC_CONNECTIONS_ONCE 1024
总的task buffer初始内存占用情况测算如下
改进前:max_connections buffer_size
改进后:max_connections和预分配的连接中那个小
buffer_size
使用v5.04及后续版本,可以根据实际需要将 max_connections 设置为一个较大的数值,比如 10240
甚至更大。
注意此时需要将一个进程允许打开的最大文件数调大到超过max_connections否则FastDFS server启动
会报错。
vi /etc/security/limits.conf 重启系统生效

  • soft nofile 65535
  • hard nofile 65535
    另外,对于32位系统,请注意使用到的内存不要超过3GB

工作线程数设置

配置文件:tracker.conf 和 storage.conf 参数名: work_threads 缺省值:4 说明:为了避免CPU上下文切换的开销,以及不必要的资源消耗,不建议将本参数设置得过大。为了发挥出 多个CPU的效能,系统中的线程数总和,应等于CPU总数。 对于tracker server,公式为: work_threads + 1 = CPU数 对于storage,公式为: work_threads + 1 + (disk_reader_threads + disk_writer_threads) * store_path_count = CPU数

storage目录数设置

配置文件: storage.conf 参数名:subdir_count_per_path 缺省值:256 说明:FastDFS采用二级目录的做法,目录会在FastDFS初始化时自动创建。存储海量小文件,打开了 trunk存储方式的情况下,建议将本参数适当改小,比如设置为32,此时存放文件的目录数为 32 32 = 1024。假如trunk文件大小采用缺省值64MB,磁盘空间为2TB,那么每个目录下存放的trunk文件数均值 为:2TB/(1024 64MB) = 32个

storage磁盘读写线程设置

配置文件: storage.conf 参数名:disk_rw_separated:磁盘读写是否分离 参数名:disk_reader_threads:单个磁盘读线程数 参数名:disk_writer_threads:单个磁盘写线程数 如果磁盘读写混合,单个磁盘读写线程数为读线程数和写线程数之和,对于单盘挂载方式,磁盘读写线程分 别设置为 1即可 如果磁盘做了RAID,那么需要酌情加大读写线程数,这样才能最大程度地发挥磁盘性能

storage同步延迟相关设置

配置文件: storage.conf 参数名:sync_binlog_buff_interval:将binlog buffer写入磁盘的时间间隔,取值大于0,缺省值 为60s 参数名:sync_wait_msec:如果没有需要同步的文件,对binlog进行轮询的时间间隔,取值大于0,缺省 值为200ms 参数名: sync_interval:同步完一个文件后,休眠的毫秒数,缺省值为0 为了缩短文件同步时间,可以将上述3个参数适当调小即可