PHP如何实现海量文件秒传去重与分布式存储完整方案

各位老铁,大家晚上好!

欢迎来到今天的《PHP精英闭门会》。

我知道,一提到PHP,有些人嘴角就会不自觉地上扬,露出那种“这语言太菜了”的嘲讽微笑。有人说PHP是“世界上最好的语言”,也有人说它是“世界上唯一的语言”——除了英语。

咱们今天不吹不黑,咱们就聊聊这个看似“被玩坏了”的语言,如何在海量文件秒传去重分布式存储这种硬核场景下,来一场漂亮的“逆风翻盘”。

想象一下,如果你的网盘有100亿个文件,其中80%都是重复的,你的服务器是不是得哭着喊妈?如果每个文件都存一遍,那硬盘的碳基生物都快挤爆了。

所以,今天我们的任务就是:用PHP,搞定它。让文件秒传,让存储瘦身,让架构高大上。


第一回:指纹的艺术——哈希与去重的哲学

要实现秒传,首先得有个“身份证”。咱们人类看脸认人,计算机看什么认人?看哈希值。

哈希,就像是一顿大餐的“电子菜单摘要”。不管这顿饭是米其林三星的满汉全席,还是路边摊的盒饭,只要食材一样、做法一样、调料一样,那这个“摘要”就一定一样。

在PHP里,我们要用的工具就是 hash_file()

1.1 为什么不用MD5?

很多老铁第一反应是:MD5!快用MD5!
别急,MD5是上世纪的产物,虽然有极小概率的“碰瓷”事件(两个不同的文件算出一样的哈希),但在分布式秒传场景下,MD5够用了。除非你想挑战一下数学界的极限,或者你的文件里藏着恶意构造的碰撞数据,否则MD5就是那个靠谱的刑警队长。

1.2 演示代码:生成指纹

假设我们有一个大文件,我们不需要把它读进内存(内存会炸),我们可以流式计算。

<?php
// 文件指纹生成器
class FileFingerprint {
    public static function getHash($filePath, $algo = 'md5') {
        if (!file_exists($filePath)) {
            return false;
        }

        // 打开文件,只读,二进制模式
        // 避免全量加载内存,这对海量存储至关重要
        $handle = fopen($filePath, 'rb');

        // 初始化哈希上下文
        $ctx = hash_init($algo);

        // 每次读取 4KB (4096 bytes) 的数据块
        // 这就像是用勺子舀汤,而不是把整个锅倒进碗里
        while (!feof($handle)) {
            $buffer = fread($handle, 4096);
            hash_update($ctx, $buffer);
        }

        fclose($handle);
        return hash_final($ctx);
    }
}

// 测试
$file = 'super_big_movie.mp4';
$hash = FileFingerprint::getHash($file, 'md5');
echo "文件的数字指纹是: " . $hash . "n";

这行代码一跑,你就拿到了这个文件的“灵魂”。接下来,我们怎么利用这个灵魂?


第二回:大象装冰箱——文件分片与切片

秒传是针对“文件整体”的,但分布式存储往往是针对“数据块”的。为什么?

因为如果你的文件只有几MB,直接上传到某个服务器,那太简单了。但如果文件是10GB呢?网络抖动一下,上传就断了。断点续传虽然救得回来,但为了传输效率,我们得把大象装进冰箱,分几步走。

第一步:切片。

我们需要把大文件切成小片。每片的大小比如是 2MB。这样,如果上传了5片,断了,下次只要传剩下的那几片,不用重头再来。

2.1 分片逻辑

在客户端(或者PHP的CLI脚本中),我们需要计算每片的哈希。这样,即使文件整体没变,某一小片坏了,我们也能知道。

<?php
class FileChunker {
    public static function chunkFile($filePath, $chunkSize = 2097152) {
        $fileSize = filesize($filePath);
        $hashes = [];
        $chunks = [];

        $handle = fopen($filePath, 'rb');

        // 计算总片数
        $totalChunks = ceil($fileSize / $chunkSize);

        for ($i = 0; $i < $totalChunks; $i++) {
            $buffer = fread($handle, $chunkSize);
            if (empty($buffer)) break;

            // 计算当前片的哈希
            $chunkHash = md5($buffer);
            $offset = $i * $chunkSize;

            $chunks[] = [
                'index' => $i,
                'hash' => $chunkHash,
                'offset' => $offset,
                'size' => strlen($buffer),
                'content' => $buffer // 注意:真实场景不要这么存,用临时文件存
            ];
            $hashes[] = $chunkHash;
        }

        fclose($handle);

        // 计算文件的组合哈希(这相当于文件的指纹)
        $fileHash = md5(implode('', $hashes)); 

        return [
            'file_hash' => $fileHash,
            'chunks' => $chunks
        ];
    }
}

好了,现在我们有了:

  1. 文件指纹file_hash
  2. 分片指纹:每个 chunk_hash

第三回:轮盘赌与一致性哈希——选服务器

现在,我们把分片发出去。发给谁?发给A服务器?B服务器?C服务器?如果发给A,后来又发给B,数据就乱了。

这就需要分布式存储的调度策略。最常用的就是一致性哈希

3.1 什么是一致性哈希?

想象一个圆圈,上面有很多点(服务器节点)。每个文件或数据块,都有一个哈希值,这个哈希值在圆环上找到一个位置,顺时针方向最近的那个点,就是它的宿主服务器。

如果加了一台服务器,受影响的只有顺时针方向紧挨着的那几个节点,而不是全网的数据。这就像把轮盘赌的筹码推得离赢家更近一点。

3.2 PHP实现一致性哈希

我们需要一个哈希函数,把服务器IP和文件哈希映射到 0 到 2^32 之间。

<?php
class DistributedStorage {
    private $nodes = []; // 服务器节点列表 ['192.168.1.1:9000' => weight, ...]
    private $ring = [];  // 哈希环,格式 [hash => node]
    private $virtualNodes = 150; // 虚拟节点数量,越多越均匀

    public function __construct($nodes) {
        $this->nodes = $nodes;
        $this->buildRing();
    }

    // 构建哈希环
    private function buildRing() {
        foreach ($this->nodes as $node => $weight) {
            // 每个真实节点生成虚拟节点,保证负载均衡
            for ($i = 0; $i < $this->virtualNodes; $i++) {
                $vNode = $node . '#' . $i; // 虚拟节点标识
                $hash = crc32($vNode); // 使用crc32快速哈希
                $this->ring[$hash] = $node;
            }
        }
        // 对环进行排序
        ksort($this->ring);
    }

    // 根据文件哈希选择服务器
    public function getNode($fileHash) {
        // 1. 计算文件哈希值
        $hash = crc32($fileHash);

        // 2. 在环中寻找顺时针最近的节点
        foreach ($this->ring as $h => $node) {
            if ($hash <= $h) {
                return $node;
            }
        }

        // 3. 如果哈希值大于环上最大值,取第一个节点(环的闭合特性)
        return reset($this->ring);
    }
}

// 使用示例
$storage = new DistributedStorage([
    '192.168.1.10:9000' => 1,
    '192.168.1.11:9000' => 1,
    '192.168.1.12:9000' => 1,
]);

$hash = md5("这是一个测试文件.txt");
$server = $storage->getNode($hash);
echo "文件应该存到服务器: " . $server . "n";

这一步,我们完成了数据的“路由”。


第四回:大脑的记录——元数据库

光有文件和路由还不够。我们要知道哪个文件存在哪个服务器的哪个路径下。这就需要一个元数据库

对于海量数据,SQL数据库(MySQL/PostgreSQL)可能扛不住高并发写入。但为了架构的清晰和事务的ACID特性,我们可以把“元数据表”和“数据存储”分开。

4.1 数据库设计

你需要三张表:

  1. files_meta:文件主表(文件名、文件大小、创建时间、file_hash、存储路径、服务器地址)。
  2. chunks_meta:分片表(分片索引、所属文件ID、分片哈希、服务器地址)。
  3. storage_nodes:存储节点表(节点IP、磁盘使用率、状态)。
CREATE TABLE files_meta (
    id INT AUTO_INCREMENT PRIMARY KEY,
    file_name VARCHAR(255) NOT NULL,
    file_hash CHAR(32) NOT NULL COMMENT '文件唯一指纹',
    file_size BIGINT NOT NULL,
    storage_path VARCHAR(512),
    node_ip VARCHAR(50),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_file_hash (file_hash) -- 核心去重索引!
) ENGINE=InnoDB;

CREATE TABLE chunks_meta (
    id INT AUTO_INCREMENT PRIMARY KEY,
    file_id INT NOT NULL,
    chunk_index INT NOT NULL,
    chunk_hash CHAR(32) NOT NULL,
    node_ip VARCHAR(50),
    FOREIGN KEY (file_id) REFERENCES files_meta(id)
);

注意那个 UNIQUE KEY uk_file_hash!这就是去重的基石。如果你上传一个文件,数据库发现 file_hash 已存在,直接返回 storage_path,这就是秒传!


第五回:秒传协议——Web端的握手

现在,客户端和服务器已经准备好了。怎么让它们“牵手”?

5.1 秒传流程

  1. 客户端:检测到有文件要上传 -> 计算本地文件的MD5 -> 询问服务器:“大哥,你有这个指纹的文件吗?”
  2. 服务器:查库 -> 发现存在 -> 返回:“有的,路径是 /disk01/a/b/c.mp4,下载吧。”
  3. 客户端:收到路径 -> 直接下载(或者显示“文件已存在”) -> 结束。

如果不存在:

  1. 服务器:“没有。”
  2. 客户端:“行,那我开始切片,并发发给你。”
  3. 服务器:分片接收,分片哈希校验,存入磁盘,写入数据库。
  4. 服务器:“好了,这是你的链接。”

5.2 PHP API实现(伪代码)

<?php
// 上传接口
function uploadHandler() {
    $fileHash = $_POST['hash'] ?? '';
    $fileSize = $_POST['size'] ?? 0;
    $chunkIndex = $_POST['chunk_index'] ?? 0;
    $chunkData = $_FILES['chunk']['tmp_name'] ?? null;

    if (!$fileHash) {
        return json_encode(['code' => 400, 'msg' => 'Missing file hash']);
    }

    // 1. 查库,看是否已存在(秒传)
    $stmt = $pdo->prepare("SELECT storage_path, node_ip FROM files_meta WHERE file_hash = ?");
    $stmt->execute([$fileHash]);
    $record = $stmt->fetch();

    if ($record) {
        // 秒传成功!
        return json_encode([
            'code' => 200, 
            'type' => 'instant', 
            'path' => $record['storage_path'], 
            'msg' => 'File exists, no need to upload!'
        ]);
    }

    // 2. 如果秒传失败,说明是新文件,需要存储
    if ($chunkData) {
        // 获取存储节点
        $storage = new DistributedStorage($nodes);
        $nodeIp = $storage->getNode($fileHash);

        // 模拟保存到节点 (实际应该用S3 SDK或OSS SDK)
        // saveToNode($nodeIp, $chunkData, $chunkIndex);

        // 暂存分片元数据
        // insertChunkMeta($fileHash, $chunkIndex, $nodeIp);

        // 这里省略后续分片合并逻辑...
        return json_encode(['code' => 201, 'msg' => 'Chunk uploaded']);
    }

    return json_encode(['code' => 500, 'msg' => 'Error']);
}

第陆回:PHP的隐痛与解药——异步与并发

这里有个大坑。如果不用扩展,PHP是单线程的。如果有1000个人同时上传文件,计算MD5、切片、发送请求,PHP脚本可能还没跑完,内存就爆了,或者连接就超时了。

这就是为什么我要提到 SwooleWorkerman。这俩货是PHP的“外骨骼装甲”,让PHP跑出Node.js的并发能力。

6.1 使用Swoole实现秒传服务

普通的 file_get_contents 是同步阻塞的,这就像是挤牙膏,一点一点来,效率极低。用Swoole,我们可以开启协程,并发处理。

<?php
use SwooleCoroutine as Co;

// 启动协程池
Co::set(['max_num' => 1000]); // 最大协程数

// 模拟1000个并发上传请求
Co::create(function () {
    for ($i = 0; $i < 1000; $i++) {
        Co::create(function () use ($i) {
            // 并发发起秒传检测
            $client = new SwooleCoroutineHttpClient('192.168.1.100', 9501);
            $client->post('/api/check.php', [
                'hash' => 'md5_hash_' . $i, // 假装是文件指纹
                'size' => 1024 * $i
            ]);

            $response = $client->body;
            var_dump($response);
            $client->close();
        });
    }
});

你看,这个并发量瞬间就上来了。处理海量文件请求,没有异步非阻塞,简直是在自虐。


第柒回:断点续传与重组

虽然我们实现了秒传,但分片上传本身也可能断。所以,必须有断点续传机制。

核心逻辑:

  1. 客户端记录已上传的片号:uploaded_chunks = [0, 1, 3]
  2. 再次上传时,只发送片号 2, 4, 5。
  3. 服务器端记录该文件ID已上传的片号列表。
  4. 收到片号2时,检查是否已存在,不存在则写入。

7.1 服务端合并(伪代码)

当所有分片都上传完毕,或者最后一片上传完毕时,服务器需要把分散在不同节点上的数据“拼”起来。

function mergeFile($fileHash) {
    // 1. 获取该文件所有的分片元数据
    $chunks = getChunksByHash($fileHash);

    // 2. 创建一个临时的大文件句柄
    $tempFile = tempnam(sys_get_temp_dir(), 'merge_');
    $fp = fopen($tempFile, 'wb');

    // 3. 循环每个分片,从对应节点下载并写入
    foreach ($chunks as $chunk) {
        // 从具体的节点下载
        $content = downloadFromNode($chunk['node_ip'], $chunk['path']);
        // 写入大文件
        fwrite($fp, $content);
    }

    fclose($fp);

    // 4. 校验合并后的文件哈希是否与原哈希一致(容错)
    if (FileFingerprint::getHash($tempFile) === $fileHash) {
        // 5. 如果一致,重命名为正式文件名,存入最终的存储目录
        rename($tempFile, '/final_storage/' . $fileHash . '.dat');
        return true;
    } else {
        // 哎呀,数据坏了,删了重传
        unlink($tempFile);
        return false;
    }
}

第捌回:分布式文件系统的现实——别造轮子

讲了这么多PHP代码,各位老铁可能会想:“既然这么复杂,我自己写个PHP脚本存文件,还要搞哈希环,万一硬盘挂了怎么办?数据丢了咋办?”

没错。PHP本身是解释型语言,天生不适合做底层文件系统。真正的“分布式存储”在业界是 Ceph, GlusterFS, 或者 MinIO(兼容S3协议)。

我们的PHP架构应该是这样的:

  1. PHP Web层:负责处理HTTP请求、计算哈希、调度服务器、写数据库、返回结果。
  2. S3兼容存储后端:比如 MinIO。
    • PHP直接操作S3 SDK(AWS SDK for PHP)。
    • putObject 自动上传文件。
    • copyObject 实现秒传(AWS底层也是基于MD5校验的)。
    • getBucketLocation 帮我们做路由。

这才是资深架构师的玩法:
不要让PHP去管理硬盘的inode,让PHP去管理业务逻辑和元数据。

// 使用 AWS SDK (S3 兼容) 的秒传伪代码
use AwsS3S3Client;

$s3 = new S3Client([
    'version' => 'latest',
    'region'  => 'us-west-2',
    'endpoint' => 'http://localhost:9000', // MinIO地址
    'credentials' => [
        'key'    => 'minioadmin',
        'secret' => 'minioadmin',
    ]
]);

// 尝试直接复制(如果文件已存在,S3会直接复用存储)
try {
    $result = $s3->copyObject([
        'Bucket' => 'my-bucket',
        'Key'    => 'dest/my-file.zip',
        'CopySource' => 'my-bucket/source/my-file.zip'
    ]);
    echo "秒传成功,只是做了一个元数据引用!n";
} catch (S3Exception $e) {
    echo "文件不存在,开始上传...";
    // 保存逻辑
}

第玖回:海量数据的索引优化

回到我们最初的主题,如果真的有几亿个文件,你的SQL查询会慢到怀疑人生。

这时候,你需要一个搜索引擎

9.1 Elasticsearch 集成

把文件元数据同步到ES中。ES是基于Lucene的,索引速度极快。

  • 用户搜索文件 -> 查询ES -> ES返回 Hash -> PHP查本地数据库 -> 得到路径。
  • 秒传检查 -> 用户上传前计算Hash -> 查ES -> ES返回 “Exists: true”。

这比 SELECT * FROM files_meta WHERE file_hash = 'xxx' 快了不止一个数量级。


第拾回:完整方案的拼图

好了,咱们把上面散落的拼图拼起来,看看一个“PHP海量文件秒传系统”的全貌:

  1. 客户端(手机/PC)

    • 初始化上传:计算文件MD5。
    • 第一步: 发送MD5给PHP API。
    • 情况A(秒传命中):PHP API查ES -> 发现存在 -> 返回下载链接。结束。
    • 情况B(秒传未命中):PHP API返回失败。客户端开始分片上传。
  2. PHP API服务

    • 接收分片数据。
    • 使用 Swoole 处理高并发写入。
    • 根据文件Hash,通过 一致性哈希算法 确定目标MinIO节点。
    • 调用MinIO SDK:PutObject
    • 同时更新MySQL(元数据)和ES(索引)。
  3. 分布式存储

    • MinIO集群。
    • 自动多副本(比如3副本),保证数据不丢。
    • 自动负载均衡(你不用关心哪个硬盘在哪,SDK会帮你算)。

总结

各位老铁,今天的课程讲得差不多了。

我们用PHP实现海量文件秒传,核心就两个词:指纹路由

  1. hash_file(或MD5)给文件定个性,这是去重的基础。
  2. 用一致性哈希(或者S3 SDK自带的路由)决定文件存哪,这是分布式的骨架。
  3. 用数据库(MySQL/ES)记录元数据,这是系统的灵魂。
  4. 最后,别忘了用 Swoole 给PHP这辆老爷车装上涡轮增压,不然它是跑不起来的。

记住:

  • 秒传是前端算好Hash,后端查库。
  • 去重是后端存文件时校验Hash。
  • 分布式是存文件时选对服务器。

PHP不仅能写CRUD,在架构设计和高并发处理上,只要找对工具(Swoole, S3 SDK, Redis),它依然是那个无所不能的“世界上最好的语言”。

好了,下课!赶紧去把你的项目改造成秒传模式吧,省下的硬盘钱,够你买好几个键盘了!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注