PHP 8.3 + Swoole 6.x 文件分发服务(类 FastDFS Tracker)· 完整实现

  FastDFS 模型核心:Tracker(调度) + Storage(存储) + Group(副本组)。
  本方案三个组件全部用 PHP+Swoole 实现,客户端拿到的 file_id 形如 group1/M00/00/AB/wKgABcd...jpg,与 FastDFS
  协议兼容设计。

  ---
  一、整体架构(大白话)

  ┌────────┐   ①upload?  ┌──────────────┐
  │ Client │ ───────────→│  Tracker     │
  │        │ ←node info  │ (调度大脑)   │
  └────────┘              │              │
     │                    │ 维护:        │
     │ ②PUT file         │ • groups     │
     ▼                   │ • storages   │
  ┌────────────┐          │ • 心跳/健康   │
  │ Storage 1  │ ←────────│• 负载均衡    │
  │ (group1)   │ replicate└──────────────┘
  └──────┬─────┘                ▲
         │ async                │ heartbeat (5s)
         ▼                     │
  ┌────────────┐                │
  │ Storage 2  │ ───────────────┘
  │ (group1)   │
  └────────────┘

  Group 内多副本(强可用),Group 间数据不同(横向扩容)

  核心思想:Tracker 只调度不存数据;Storage 只存数据自管副本;client 拿到 file_id 后下载可直连 Storage 跳过 Tracker。

  ---
  二、最佳技术选型

  ┌───────────────────┬───────────────────────────────────────┬────────────────────────┐
  │        层         │                库/技术                │          原因          │
  ├───────────────────┼───────────────────────────────────────┼────────────────────────┤
  │ 运行时            │ Swoole 6.x                            │ 协程 + sendfile 零拷贝 │
  ├───────────────────┼───────────────────────────────────────┼────────────────────────┤
  │ 节点注册表        │ Swoole\Table                          │ 跨 worker 共享,纳秒级  │
  ├───────────────────┼───────────────────────────────────────┼────────────────────────┤
  │ 元数据            │ MySQL + Redis 缓存                    │ 持久 + 加速            │
  ├───────────────────┼───────────────────────────────────────┼────────────────────────┤
  │ Tracker ↔Storage │ HTTP + JSON(也可 TCP 二进制)          │ 简单 + 易调试          │
  ├───────────────────┼───────────────────────────────────────┼────────────────────────┤
  │ 文件传输          │ Swoole sendfile + Range               │ 零拷贝大文件           │
  ├───────────────────┼───────────────────────────────────────┼────────────────────────┤
  │ 客户端            │ Swoole\Coroutine\Http\Client          │ 协程化                 │
  ├───────────────────┼───────────────────────────────────────┼────────────────────────┤
  │ 副本同步          │ Swoole Task + 重试队列                │ 异步可靠               │
  ├───────────────────┼───────────────────────────────────────┼────────────────────────┤
  │ 路由              │ nikic/fast-route                      │ 极快                   │
  ├───────────────────┼───────────────────────────────────────┼────────────────────────┤
  │ 哈希              │ CRC32 / 一致性哈希(可选)              │ 路由稳定               │
  ├───────────────────┼───────────────────────────────────────┼────────────────────────┤
  │ 监控              │ promphp/prometheus_client_php         │ 标准指标               │
  ├───────────────────┼───────────────────────────────────────┼────────────────────────┤
  │ 文件 ID           │ 自研(group/M00/分目录/timestamp+rand) │ 兼容 FastDFS 风格      │
  └───────────────────┴───────────────────────────────────────┴────────────────────────┘

  composer require swoole/ide-helper nikic/fast-route promphp/prometheus_client_php monolog/monolog

  ---
  三、文件 ID 格式

  group1/M00/00/AB/wKgABcdef0123456.jpg
  └──┬──┘└─┬┘└──┬──┘└──────┬───────┘
    group  存储路径前缀   两级散列目录   文件名(base64时间戳+random)+扩展名

  为什么这么设计:
  - group1 →Tracker 调度到哪个副本组
  - M00 →Storage 节点上挂载的第几块盘
  - 00/AB →两级散列分目录,避免单目录文件过多导致 inode 慢
  - 客户端拿到这个字符串就能直接下载,不再问 Tracker

  ---
  四、完整代码

  A. Tracker 服务(调度大脑)

  1. 入口

  <?php
  // tracker.php
  declare(strict_types=1);
  require __DIR__ . '/vendor/autoload.php';

  use Swoole\Http\Server;
  use Swoole\Http\Request;
  use Swoole\Http\Response;
  use App\FDFS\Tracker\Container;
  use App\FDFS\Tracker\Router;

  \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL);

  $c = Container::boot();

  $server = new Server('0.0.0.0', 22122, SWOOLE_PROCESS);  // FastDFS 默认端口
  $server->set([
      'worker_num'       => swoole_cpu_num(),
      'task_worker_num'  => 4,
      'task_enable_coroutine' => true,
      'enable_coroutine' => true,
      'max_request'      => 50000,
      'hook_flags'       => SWOOLE_HOOK_ALL,
      'reactor_num'      => swoole_cpu_num(),
  ]);

  $server->on('WorkerStart', function($s, $wid) use ($c) {
      $c->initPools();
      $c->loadStoragesFromDB();
      if ($wid === 0) {
          // worker 0 跑健康检查
          \Swoole\Coroutine::create(fn() => (new \App\FDFS\Tracker\HealthChecker($c))->run());
      }
  });

  $server->on('Request', function(Request $req, Response $res) use ($c) {
      (new Router($c))->dispatch($req, $res);
  });

  $server->start();

  ---
  2. Container:节点注册表

  <?php
  // src/FDFS/Tracker/Container.php
  namespace App\FDFS\Tracker;

  use Swoole\Table;
  use Swoole\Coroutine\Channel;
  use Swoole\Coroutine\Redis;
  use Swoole\Coroutine\MySQL;

  class Container
  {
      public Table $storages;       // node_id => 节点信息
      public Table $groupStats;     // group_name => 组统计(总容量/已用/活跃节点数)
      public Channel $redisPool;
      public Channel $mysqlPool;
      public array $config;

      public static function boot(): self
      {
          $c = new self();

          $c->storages = new Table(1024);
          $c->storages->column('node_id',   Table::TYPE_STRING, 32);
          $c->storages->column('group',     Table::TYPE_STRING, 32);
          $c->storages->column('host',      Table::TYPE_STRING, 64);
          $c->storages->column('port',      Table::TYPE_INT,    4);
          $c->storages->column('status',    Table::TYPE_STRING, 16);  // ACTIVE/SYNCING/OFFLINE
          $c->storages->column('total_mb',  Table::TYPE_INT,    8);
          $c->storages->column('free_mb',   Table::TYPE_INT,    8);
          $c->storages->column('upload_qps',Table::TYPE_INT,    4);
          $c->storages->column('last_hb',   Table::TYPE_INT,    8);
          $c->storages->column('weight',    Table::TYPE_INT,    4);
          $c->storages->create();

          $c->groupStats = new Table(256);
          $c->groupStats->column('total_nodes',  Table::TYPE_INT, 4);
          $c->groupStats->column('active_nodes', Table::TYPE_INT, 4);
          $c->groupStats->column('total_free_mb',Table::TYPE_INT, 8);
          $c->groupStats->create();

          $c->config = [
              'select_policy' => 'least_used',   // round_robin / least_used / weighted / hash
              'min_free_mb'   => 1024,           // 节点剩余 < 1GB 不再分配
              'hb_timeout'    => 15,             // 心跳超过 15s →OFFLINE
          ];
          return $c;
      }

      public function initPools(): void
      {
          $this->redisPool = new Channel(32);
          for ($i=0;$i<32;$i++) {
              $r = new Redis(); $r->connect('127.0.0.1', 6379);
              $this->redisPool->push($r);
          }
          $this->mysqlPool = new Channel(8);
          for ($i=0;$i<8;$i++) {
              $db = new MySQL();
              $db->connect(['host'=>'127.0.0.1','user'=>'root','password'=>'',
                  'database'=>'fdfs','charset'=>'utf8mb4']);
              $this->mysqlPool->push($db);
          }
      }

      public function loadStoragesFromDB(): void
      {
          $this->withMySQL(function($db) {
              $rows = $db->query("SELECT * FROM storages WHERE enabled=1");
              foreach ($rows as $r) {
                  $this->storages->set($r['node_id'], [
                      'node_id'=>$r['node_id'],
                      'group'  =>$r['group_name'],
                      'host'   =>$r['host'],
                      'port'   =>(int)$r['port'],
                      'status' =>'OFFLINE',           // 等心跳上来再标 ACTIVE
                      'total_mb'=>(int)$r['total_mb'],
                      'free_mb'=>0,
                      'upload_qps'=>0,
                      'last_hb'=>0,
                      'weight' =>(int)$r['weight'],
                  ]);
              }
          });
          $this->rebuildGroupStats();
      }

      public function rebuildGroupStats(): void
      {
          $stats = [];
          foreach ($this->storages as $node) {
              $g = $node['group'];
              $stats[$g] ??= ['total_nodes'=>0,'active_nodes'=>0,'total_free_mb'=>0];
              $stats[$g]['total_nodes']++;
              if ($node['status'] === 'ACTIVE') {
                  $stats[$g]['active_nodes']++;
                  $stats[$g]['total_free_mb'] += $node['free_mb'];
              }
          }
          foreach ($stats as $g => $s) $this->groupStats->set($g, $s);
      }

      public function withRedis(callable $fn) {
          $r = $this->redisPool->pop();
          try { return $fn($r); } finally { $this->redisPool->push($r); }
      }
      public function withMySQL(callable $fn) {
          $db = $this->mysqlPool->pop();
          try { return $fn($db); } finally { $this->mysqlPool->push($db); }
      }
  }

  解释:
  - storages 表是调度核心:每条记录一个 Storage 节点状态
  - groupStats 是聚合视图,用于快速判断哪个 group 最空闲
  - status 字段三态:ACTIVE(可读可写) / SYNCING(刚加入,同步中,只读) / OFFLINE(掉线)

  ---
  3. Router:Tracker API

  <?php
  // src/FDFS/Tracker/Router.php
  namespace App\FDFS\Tracker;

  use Swoole\Http\Request;
  use Swoole\Http\Response;

  class Router
  {
      public function __construct(private Container $c) {}

      public function dispatch(Request $req, Response $res): void
      {
          $res->header('Content-Type', 'application/json');
          $uri = $req->server['request_uri'];
          try {
              $out = match($uri) {
                  // 客户端 API
                  '/api/upload/select'   => $this->selectForUpload($req),
                  '/api/download/select' => $this->selectForDownload($req),
                  '/api/delete/select'   => $this->selectAllForDelete($req),
                  // Storage 节点 API
                  '/api/storage/register'=> $this->register($req),
                  '/api/storage/heartbeat'=> $this->heartbeat($req),
                  '/api/storage/peers'   => $this->getPeers($req),
                  // 管理 API
                  '/admin/nodes'         => $this->c->storages,
                  '/admin/groups'        => $this->c->groupStats,
                  default => ['code'=>404],
              };
              // Swoole\Table 直接序列化
              if ($out instanceof \Swoole\Table) {
                  $list = []; foreach ($out as $k=>$v) $list[$k] = $v;
                  $out = $list;
              }
              $res->end(json_encode($out));
          } catch (\Throwable $e) {
              $res->status(500);
              $res->end(json_encode(['code'=>500,'msg'=>$e->getMessage()]));
          }
      }

      // 选一个 Storage 来上传:返回组里"主节点"(leader),客户端只往主上传
      public function selectForUpload(Request $req): array
      {
          $size = (int)($req->get['size'] ?? 0);
          $hint = $req->get['group'] ?? '';      // 客户端可指定组,否则自动选

          // 1. 挑组
          $group = $hint;
          if ($group === '') {
              $group = $this->pickGroup($size);
              if (!$group) return ['code'=>503,'msg'=>'no available group'];
          }

          // 2. 组内挑节点(主节点)
          $node = $this->pickStorageInGroup($group, $size);
          if (!$node) return ['code'=>503,'msg'=>"no available storage in $group"];

          // 3. 同时返回组内副本节点(客户端可选直传,或交给 storage 异步同步)
          $peers = $this->peersInGroup($group, $node['node_id']);

          return [
              'code'  => 0,
              'group' => $group,
              'primary' => [
                  'node_id' => $node['node_id'],
                  'host'    => $node['host'],
                  'port'    => $node['port'],
                  'url'     => "http://{$node['host']}:{$node['port']}",
              ],
              'peers' => $peers,
              // token: storage 收到后校验,防止绕过 Tracker 直接打 storage
              'token' => $this->signToken($group, $node['node_id'], $size),
          ];
      }

      // 下载:从 file_id 解析 group →选任意 ACTIVE 节点
      public function selectForDownload(Request $req): array
      {
          $fileId = $req->get['file_id'] ?? '';
          if (!preg_match('#^(group\d+)/#', $fileId, $m)) {
              return ['code'=>400,'msg'=>'invalid file_id'];
          }
          $group = $m[1];
          // 优先选近的(同机房)—简化:随机健康节点
          $candidates = [];
          foreach ($this->c->storages as $n) {
              if ($n['group'] === $group && $n['status'] === 'ACTIVE') $candidates[] = $n;
          }
          if (!$candidates) return ['code'=>503,'msg'=>"no node in $group"];
          $pick = $candidates[array_rand($candidates)];
          return [
              'code'=>0,
              'url'=>"http://{$pick['host']}:{$pick['port']}/$fileId",
          ];
      }

      // 删除:返回组内全部活跃节点,客户端依次 DELETE
      public function selectAllForDelete(Request $req): array
      {
          $fileId = $req->get['file_id'] ?? '';
          if (!preg_match('#^(group\d+)/#', $fileId, $m)) {
              return ['code'=>400];
          }
          $nodes = [];
          foreach ($this->c->storages as $n) {
              if ($n['group'] === $m[1] && $n['status'] === 'ACTIVE') $nodes[] = $n;
          }
          return ['code'=>0,'nodes'=>$nodes,'token'=>$this->signToken($m[1],'',0)];
      }

      private function pickGroup(int $size): ?string
      {
          $minMb = $this->c->config['min_free_mb'] + (int)($size / 1024 / 1024);
          $best = null; $bestFree = -1;
          foreach ($this->c->groupStats as $g => $s) {
              if ($s['active_nodes'] < 1) continue;
              if ($s['total_free_mb'] < $minMb) continue;
              if ($s['total_free_mb'] > $bestFree) {
                  $best = $g; $bestFree = $s['total_free_mb'];
              }
          }
          return $best;
      }

      private function pickStorageInGroup(string $group, int $size): ?array
      {
          $minMb = $this->c->config['min_free_mb'] + (int)($size / 1024 / 1024);
          $candidates = [];
          foreach ($this->c->storages as $n) {
              if ($n['group'] !== $group) continue;
              if ($n['status'] !== 'ACTIVE') continue;
              if ($n['free_mb'] < $minMb) continue;
              $candidates[] = $n;
          }
          if (!$candidates) return null;

          return match($this->c->config['select_policy']) {
              'round_robin' => $this->rr($candidates, $group),
              'least_used'  => $this->leastUsed($candidates),
              'weighted'    => $this->weighted($candidates),
              default       => $candidates[array_rand($candidates)],
          };
      }

      private static array $rrCursors = [];
      private function rr(array $candidates, string $group): array
      {
          $i = (self::$rrCursors[$group] ?? -1) + 1;
          self::$rrCursors[$group] = $i;
          return $candidates[$i % count($candidates)];
      }
      private function leastUsed(array $c): array
      {
          usort($c, fn($a,$b) => $b['free_mb'] <=> $a['free_mb']);
          return $c[0];
      }
      private function weighted(array $c): array
      {
          $sum = 0; foreach ($c as $n) $sum += max(1, $n['weight']);
          $pick = random_int(1, $sum); $acc = 0;
          foreach ($c as $n) { $acc += max(1, $n['weight']); if ($pick <= $acc) return $n; }
          return $c[0];
      }

      private function peersInGroup(string $group, string $excludeId): array
      {
          $peers = [];
          foreach ($this->c->storages as $n) {
              if ($n['group'] === $group && $n['node_id'] !== $excludeId && $n['status'] === 'ACTIVE') {
                  $peers[] = ['node_id'=>$n['node_id'],'host'=>$n['host'],'port'=>$n['port'],
                              'url'=>"http://{$n['host']}:{$n['port']}"];
              }
          }
          return $peers;
      }

      private function signToken(string $group, string $nodeId, int $size): string
      {
          $exp = time() + 60;
          $payload = "$group|$nodeId|$size|$exp";
          return base64_encode($payload . '|' . hash_hmac('sha256', $payload, 'tracker-secret-key'));
      }

      // ===== Storage 节点 API =====
      public function register(Request $req): array
      {
          $d = json_decode($req->rawContent(), true) ?? [];
          $nodeId = $d['node_id'] ?? '';
          if (!$nodeId) return ['code'=>400,'msg'=>'node_id required'];

          $exists = $this->c->storages->exist($nodeId);
          $this->c->storages->set($nodeId, [
              'node_id' => $nodeId,
              'group'   => $d['group'] ?? 'group1',
              'host'    => $d['host'] ?? '',
              'port'    => (int)($d['port'] ?? 0),
              'status'  => $exists ? 'ACTIVE' : 'SYNCING',     // 新节点先 SYNCING
              'total_mb'=> (int)($d['total_mb'] ?? 0),
              'free_mb' => (int)($d['free_mb']  ?? 0),
              'upload_qps' => 0,
              'last_hb' => time(),
              'weight'  => (int)($d['weight'] ?? 1),
          ]);
          $this->c->rebuildGroupStats();
          // 持久化
          $this->c->withMySQL(function($db) use ($d, $nodeId) {
              $stmt = $db->prepare(
                  "REPLACE INTO storages(node_id,group_name,host,port,total_mb,weight,enabled,updated_at)
                   VALUES(?,?,?,?,?,?,1,NOW())"
              );
              $stmt->execute([$nodeId,$d['group']??'group1',$d['host']??'',
                  (int)$d['port'],(int)$d['total_mb'],(int)($d['weight']??1)]);
          });
          return ['code'=>0,'msg'=>'registered'];
      }

      public function heartbeat(Request $req): array
      {
          $d = json_decode($req->rawContent(), true) ?? [];
          $nodeId = $d['node_id'] ?? '';
          $node = $this->c->storages->get($nodeId);
          if (!$node) return ['code'=>404,'msg'=>'node not registered'];
          $node['free_mb']   = (int)($d['free_mb'] ?? $node['free_mb']);
          $node['upload_qps']= (int)($d['upload_qps'] ?? 0);
          $node['status']    = $d['status'] ?? 'ACTIVE';
          $node['last_hb']   = time();
          $this->c->storages->set($nodeId, $node);
          return ['code'=>0];
      }

      public function getPeers(Request $req): array
      {
          $group = $req->get['group'] ?? '';
          $exclude = $req->get['exclude'] ?? '';
          return ['code'=>0,'peers'=>$this->peersInGroup($group, $exclude)];
      }
  }

  解释:
  - pickGroup →pickStorageInGroup 两级调度:先选最空闲组,再选组内最优节点
  - 三种负载均衡策略可热切换:round_robin / least_used / weighted
  - signToken:Storage 收到上传请求会验签,防止恶意客户端绕过 Tracker 直接打 Storage
  - 新节点 SYNCING 状态:期间只接收同步数据,不参与负载均衡,等同步完才转 ACTIVE

  ---
  4. HealthChecker:心跳超时检测

  <?php
  // src/FDFS/Tracker/HealthChecker.php
  namespace App\FDFS\Tracker;

  class HealthChecker
  {
      public function __construct(private Container $c) {}

      public function run(): void
      {
          while (true) {
              \Swoole\Coroutine::sleep(3);
              $now = time();
              $timeout = $this->c->config['hb_timeout'];
              $changed = false;
              foreach ($this->c->storages as $id => $n) {
                  if ($n['status'] === 'OFFLINE') continue;
                  if ($now - $n['last_hb'] > $timeout) {
                      $n['status'] = 'OFFLINE';
                      $this->c->storages->set($id, $n);
                      error_log("[health] $id timeout, marked OFFLINE");
                      $changed = true;
                      // 告警()
                  }
              }
              if ($changed) $this->c->rebuildGroupStats();
          }
      }
  }

  ---
  B. Storage 节点(实际存数据)

  1. Storage Server

  <?php
  // storage.php —每个 Storage 节点跑一个
  declare(strict_types=1);
  require __DIR__ . '/vendor/autoload.php';

  use Swoole\Http\Server;
  use Swoole\Http\Request;
  use Swoole\Http\Response;
  use App\FDFS\Storage\StorageContext;
  use App\FDFS\Storage\Router;

  \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL);

  // 节点配置(每个 Storage 部署时改这里)
  $ctx = StorageContext::boot([
      'node_id'    => getenv('NODE_ID') ?: 'storage-1',
      'group'      => getenv('GROUP')   ?: 'group1',
      'host'       => getenv('HOST')    ?: '10.0.0.11',
      'port'       => (int)(getenv('PORT') ?: 23000),
      'tracker'    => getenv('TRACKER') ?: 'http://10.0.0.1:22122',
      'data_dir'   => getenv('DATA_DIR') ?: '/data/fdfs/M00',
      'weight'     => 1,
  ]);

  $server = new Server('0.0.0.0', $ctx->cfg['port'], SWOOLE_PROCESS);
  $server->set([
      'worker_num'        => swoole_cpu_num() * 2,
      'task_worker_num'   => 8,
      'task_enable_coroutine'=> true,
      'enable_coroutine'  => true,
      'max_request'       => 50000,
      'hook_flags'        => SWOOLE_HOOK_ALL,
      'package_max_length'=> 2 * 1024 * 1024 * 1024,    // 2GB
      'socket_buffer_size'=> 128 * 1024 * 1024,
      'buffer_output_size'=> 128 * 1024 * 1024,
      'http_parse_post'   => false,
      'send_yield'        => true,
  ]);

  $server->on('WorkerStart', function($s, $wid) use ($ctx) {
      $ctx->server = $s;
      if ($wid === 0) {
          \Swoole\Coroutine::create(fn() => $ctx->heartbeatLoop());
          \Swoole\Coroutine::create(fn() => $ctx->retrySyncLoop());
      }
  });

  $server->on('Request', function(Request $req, Response $res) use ($ctx) {
      (new Router($ctx))->dispatch($req, $res);
  });

  // Task:异步副本同步
  $server->on('Task', function($s, $task) use ($ctx) {
      $ctx->syncToPeers($task->data);
  });
  $server->on('Finish', fn() => null);

  $server->start();

  ---
  2. StorageContext + 心跳 + 副本同步

  <?php
  // src/FDFS/Storage/StorageContext.php
  namespace App\FDFS\Storage;

  use Swoole\Coroutine\Http\Client;
  use Swoole\Coroutine\Channel;
  use Swoole\Http\Server;

  class StorageContext
  {
      public array $cfg;
      public ?Server $server = null;
      public Channel $syncRetry;   // 同步失败重试队列
      public int $uploadCount = 0;

      public static function boot(array $cfg): self
      {
          $c = new self();
          $c->cfg = $cfg;
          if (!is_dir($cfg['data_dir'])) mkdir($cfg['data_dir'], 0755, true);
          $c->syncRetry = new Channel(10000);

          // 注册到 Tracker
          \Swoole\Coroutine\run(function() use ($c) { $c->register(); });
          return $c;
      }

      public function register(): void
      {
          [$host, $port] = $this->parseTracker();
          $client = new Client($host, $port);
          $client->setHeaders(['Content-Type'=>'application/json']);
          $client->post('/api/storage/register', json_encode([
              'node_id' => $this->cfg['node_id'],
              'group'   => $this->cfg['group'],
              'host'    => $this->cfg['host'],
              'port'    => $this->cfg['port'],
              'total_mb'=> (int)(disk_total_space($this->cfg['data_dir'])/1024/1024),
              'weight'  => $this->cfg['weight'],
          ]));
          $client->close();
      }

      public function heartbeatLoop(): void
      {
          while (true) {
              \Swoole\Coroutine::sleep(5);
              try {
                  [$host, $port] = $this->parseTracker();
                  $client = new Client($host, $port);
                  $client->setHeaders(['Content-Type'=>'application/json']);
                  $client->set(['timeout'=>2]);
                  $client->post('/api/storage/heartbeat', json_encode([
                      'node_id'   => $this->cfg['node_id'],
                      'free_mb'   => (int)(disk_free_space($this->cfg['data_dir'])/1024/1024),
                      'upload_qps'=> $this->uploadCount,
                      'status'    => 'ACTIVE',
                  ]));
                  $client->close();
                  $this->uploadCount = 0;
              } catch (\Throwable $e) {
                  error_log("[hb] ".$e->getMessage());
              }
          }
      }

      public function syncToPeers(array $info): void
      {
          // info: ['file_id'=>..., 'local_path'=>..., 'peers'=>[...]]
          foreach ($info['peers'] as $peer) {
              try {
                  $url = parse_url($peer['url']);
                  $client = new Client($url['host'], $url['port'], false);
                  $client->set(['timeout'=>30]);
                  $client->setHeaders([
                      'X-Sync-Token' => $this->signSync($info['file_id']),
                      'X-File-Id'    => $info['file_id'],
                      'Content-Type' => 'application/octet-stream',
                  ]);
                  $client->setData(file_get_contents($info['local_path']));  // 小文件
                  $client->post('/replicate');
                  if ($client->statusCode !== 200) throw new \RuntimeException("sync fail to {$peer['node_id']}");
                  $client->close();
              } catch (\Throwable $e) {
                  error_log("[sync] retry: ".$e->getMessage());
                  $this->syncRetry->push(['info'=>$info,'peer'=>$peer,'attempts'=>1], 0.1);
              }
          }
      }

      public function retrySyncLoop(): void
      {
          while (true) {
              $item = $this->syncRetry->pop();
              if (!$item) continue;
              \Swoole\Coroutine::sleep(min(60, 2 ** $item['attempts']));   // 指数退避
              try {
                  $this->syncToPeers(['file_id'=>$item['info']['file_id'],
                      'local_path'=>$item['info']['local_path'],'peers'=>[$item['peer']]]);
              } catch (\Throwable $e) {
                  if ($item['attempts'] < 8) {
                      $item['attempts']++;
                      $this->syncRetry->push($item, 0.1);
                  } else {
                      error_log("[sync] giving up: {$item['info']['file_id']} →{$item['peer']['node_id']}");
                      // 死信()
                  }
              }
          }
      }

      public function signSync(string $fileId): string
      {
          return hash_hmac('sha256', $fileId.'|'.$this->cfg['group'], 'tracker-secret-key');
      }

      public function verifyTrackerToken(string $token, string $group, string $nodeId, int $size): bool
      {
          $raw = base64_decode($token);
          $parts = explode('|', $raw);
          if (count($parts) !== 5) return false;
          [$g, $nid, $sz, $exp, $sig] = $parts;
          if ($g !== $group) return false;
          if ($nid !== '' && $nid !== $nodeId) return false;
          if ((int)$exp < time()) return false;
          $expectedSig = hash_hmac('sha256', "$g|$nid|$sz|$exp", 'tracker-secret-key');
          return hash_equals($expectedSig, $sig);
      }

      private function parseTracker(): array
      {
          $u = parse_url($this->cfg['tracker']);
          return [$u['host'], $u['port'] ?? 80];
      }
  }

  解释:
  - 心跳每 5 秒一次,上报 free_mb 和 upload_qps,Tracker 据此动态调度
  - 副本同步异步进行:写完主节点立刻返回客户端,Task 进程慢慢推到副本(写延迟 = 单机延迟)
  - 失败指数退避重试:1→2→4→8→16…次失败入死信
  - HMAC token 防绕过:Storage 拒绝没有 Tracker 签名的上传

  ---
  3. Storage Router(实际收发文件)

  <?php
  // src/FDFS/Storage/Router.php
  namespace App\FDFS\Storage;

  use Swoole\Http\Request;
  use Swoole\Http\Response;

  class Router
  {
      public function __construct(private StorageContext $ctx) {}

      public function dispatch(Request $req, Response $res): void
      {
          $uri = $req->server['request_uri'];
          $method = $req->server['request_method'];

          // 直接下载:/groupX/M00/00/AB/abc.jpg
          if ($method === 'GET' && preg_match('#^/group\d+/#', $uri)) {
              return $this->serveFile($req, $res, $uri);
          }
          if ($method === 'HEAD' && preg_match('#^/group\d+/#', $uri)) {
              return $this->headFile($res, $uri);
          }
          // 上传
          if ($uri === '/upload' && $method === 'POST') {
              return $this->upload($req, $res);
          }
          // 副本同步接收
          if ($uri === '/replicate' && $method === 'POST') {
              return $this->replicate($req, $res);
          }
          // 删除
          if ($method === 'DELETE' && preg_match('#^/group\d+/#', $uri)) {
              return $this->delete($req, $res, $uri);
          }
          $res->status(404); $res->end('not found');
      }

      public function upload(Request $req, Response $res): void
      {
          // 1. 验证 Tracker 签发的 token
          $token = $req->header['x-tracker-token'] ?? '';
          $size  = strlen($req->rawContent());
          if (!$this->ctx->verifyTrackerToken($token, $this->ctx->cfg['group'], $this->ctx->cfg['node_id'], 0)) {
              $res->status(403); $res->end('bad token'); return;
          }

          $ext = pathinfo($req->header['x-file-name'] ?? 'bin', PATHINFO_EXTENSION);
          $ext = preg_match('/^[a-z0-9]{1,8}$/i', $ext) ? $ext : 'bin';

          // 2. 生成 file_id (FastDFS 风格)
          [$fileId, $localPath] = $this->genFileId($ext);

          // 3. 落盘
          $dir = dirname($localPath);
          if (!is_dir($dir)) mkdir($dir, 0755, true);
          file_put_contents($localPath, $req->rawContent());

          $this->ctx->uploadCount++;

          // 4. 异步推副本(从 Tracker 取 peers)
          $peers = $this->fetchPeers();
          if ($peers) {
              $this->ctx->server->task([
                  'file_id'    => $fileId,
                  'local_path' => $localPath,
                  'peers'      => $peers,
              ]);
          }

          // 5. 写元数据(供 HEAD 加速)
          @file_put_contents($localPath.'.meta', json_encode([
              'size'=>$size,'mime'=>$req->header['content-type'] ?? 'application/octet-stream',
              'mtime'=>time(),'etag'=>md5_file($localPath),
          ]));

          $res->end(json_encode(['code'=>0,'file_id'=>$fileId,'size'=>$size]));
      }

      public function serveFile(Request $req, Response $res, string $uri): void
      {
          $localPath = $this->ctx->cfg['data_dir'] . $this->stripGroupPrefix($uri);
          if (!is_file($localPath)) { $res->status(404); $res->end('not found'); return; }

          $size = filesize($localPath);
          $start = 0; $end = $size - 1;
          $range = $req->header['range'] ?? '';
          if (preg_match('/bytes=(\d+)-(\d*)/', $range, $m)) {
              $start = (int)$m[1];
              $end   = $m[2] === '' ? $size - 1 : (int)$m[2];
              $res->status(206);
              $res->header('Content-Range', "bytes $start-$end/$size");
          }
          // 读元数据快速回 mime
          $meta = is_file($localPath.'.meta') ? json_decode(file_get_contents($localPath.'.meta'), true) : [];
          $res->header('Content-Type',   $meta['mime'] ?? 'application/octet-stream');
          $res->header('Content-Length', (string)($end - $start + 1));
          $res->header('Accept-Ranges',  'bytes');
          $res->header('ETag', '"'.($meta['etag'] ?? '').'"');

          // ★sendfile 零拷贝
          $res->sendfile($localPath, $start, $end - $start + 1);
      }

      public function headFile(Response $res, string $uri): void
      {
          $localPath = $this->ctx->cfg['data_dir'] . $this->stripGroupPrefix($uri);
          if (!is_file($localPath)) { $res->status(404); $res->end(''); return; }
          $meta = is_file($localPath.'.meta') ? json_decode(file_get_contents($localPath.'.meta'), true) : [];
          $res->header('Content-Length', (string)($meta['size'] ?? filesize($localPath)));
          $res->header('Content-Type',   $meta['mime'] ?? 'application/octet-stream');
          $res->header('ETag', '"'.($meta['etag'] ?? '').'"');
          $res->end('');
      }

      public function replicate(Request $req, Response $res): void
      {
          // 副本接收:验签 + 落盘到完全相同的路径
          $sig    = $req->header['x-sync-token'] ?? '';
          $fileId = $req->header['x-file-id'] ?? '';
          $expected = hash_hmac('sha256', $fileId.'|'.$this->ctx->cfg['group'], 'tracker-secret-key');
          if (!hash_equals($expected, $sig)) {
              $res->status(403); $res->end('bad sync token'); return;
          }
          $local = $this->ctx->cfg['data_dir'] . $this->stripGroupPrefix('/'.$fileId);
          $dir = dirname($local);
          if (!is_dir($dir)) mkdir($dir, 0755, true);
          file_put_contents($local, $req->rawContent());
          @file_put_contents($local.'.meta', json_encode([
              'size'=>strlen($req->rawContent()),
              'mime'=>$req->header['content-type'] ?? 'application/octet-stream',
              'mtime'=>time(),'etag'=>md5_file($local),
          ]));
          $res->end('OK');
      }

      public function delete(Request $req, Response $res, string $uri): void
      {
          $token = $req->header['x-tracker-token'] ?? '';
          if (!$this->ctx->verifyTrackerToken($token, $this->ctx->cfg['group'], '', 0)) {
              $res->status(403); $res->end('bad token'); return;
          }
          $local = $this->ctx->cfg['data_dir'] . $this->stripGroupPrefix($uri);
          @unlink($local);
          @unlink($local.'.meta');
          $res->status(204); $res->end('');
      }

      // file_id: group1/M00/00/AB/wKgABcdef0123456.jpg
      private function genFileId(string $ext): array
      {
          $ts = pack('N', time());
          $rand = random_bytes(8);
          $name = rtrim(strtr(base64_encode($ts.$rand), '+/', '-_'), '=');
          $name = $name . '.' . $ext;
          $h = md5($name, true);
          $d1 = sprintf('%02X', ord($h[0]));
          $d2 = sprintf('%02X', ord($h[1]));
          $relativePath = "M00/$d1/$d2/$name";
          $fileId = $this->ctx->cfg['group'] . '/' . $relativePath;
          $localPath = $this->ctx->cfg['data_dir'] . '/' . substr($relativePath, 4);   // 去掉 M00/
          return [$fileId, $localPath];
      }

      private function stripGroupPrefix(string $uri): string
      {
          // /group1/M00/00/AB/x.jpg →/00/AB/x.jpg (相对 data_dir)
          return preg_replace('#^/group\d+/M\d+#', '', $uri);
      }

      private function fetchPeers(): array
      {
          $u = parse_url($this->ctx->cfg['tracker']);
          $client = new \Swoole\Coroutine\Http\Client($u['host'], $u['port'] ?? 80);
          $client->get("/api/storage/peers?group={$this->ctx->cfg['group']}&exclude={$this->ctx->cfg['node_id']}");
          $body = json_decode($client->body, true);
          $client->close();
          return $body['peers'] ?? [];
      }
  }

  解释:
  - genFileId:base64(时间戳+随机) 做主键,md5 取前 2 字节做两级散列目录,避免单目录文件数爆炸
  - 上传走 token 校验:任何没有 Tracker 签名的上传都被拒,不能绕过调度
  - 副本同步用独立的 HMAC,和上传 token 解耦
  - sendfile 零拷贝:大文件下载 PHP 进程不占内存
  - 元数据 .meta 旁路文件:HEAD 请求不需要 stat 真实文件,性能翻倍

  ---
  C. Client SDK(应用使用)

  <?php
  // src/FDFS/Client/FdfsClient.php
  namespace App\FDFS\Client;

  use Swoole\Coroutine\Http\Client;

  class FdfsClient
  {
      public function __construct(private string $trackerUrl) {}

      public function upload(string $localPath, string $group = ''): array
      {
          $size = filesize($localPath);
          $body = file_get_contents($localPath);

          // 1. 问 Tracker
          $u = parse_url($this->trackerUrl);
          $t = new Client($u['host'], $u['port']);
          $t->get("/api/upload/select?size=$size".($group ? "&group=$group" : ''));
          $sel = json_decode($t->body, true);
          $t->close();
          if (($sel['code'] ?? -1) !== 0) throw new \RuntimeException("tracker: ".$sel['msg']);

          // 2. 直传 Storage(不再经 Tracker)
          $p = parse_url($sel['primary']['url']);
          $s = new Client($p['host'], $p['port']);
          $s->setHeaders([
              'X-Tracker-Token' => $sel['token'],
              'X-File-Name'     => basename($localPath),
              'Content-Type'    => mime_content_type($localPath) ?: 'application/octet-stream',
          ]);
          $s->setData($body);
          $s->post('/upload');
          $resp = json_decode($s->body, true);
          $s->close();
          if (($resp['code'] ?? -1) !== 0) throw new \RuntimeException('upload fail');

          return [
              'file_id' => $resp['file_id'],
              'size'    => $resp['size'],
              'url'     => $sel['primary']['url'] . '/' . $resp['file_id'],
          ];
      }

      public function download(string $fileId, string $savePath): int
      {
          $u = parse_url($this->trackerUrl);
          $t = new Client($u['host'], $u['port']);
          $t->get("/api/download/select?file_id=".urlencode($fileId));
          $sel = json_decode($t->body, true);
          $t->close();
          if (($sel['code'] ?? -1) !== 0) throw new \RuntimeException($sel['msg']);

          $p = parse_url($sel['url']);
          $s = new Client($p['host'], $p['port']);
          $s->get($p['path']);
          file_put_contents($savePath, $s->body);
          $size = strlen($s->body);
          $s->close();
          return $size;
      }

      public function delete(string $fileId): bool
      {
          $u = parse_url($this->trackerUrl);
          $t = new Client($u['host'], $u['port']);
          $t->get("/api/delete/select?file_id=".urlencode($fileId));
          $sel = json_decode($t->body, true);
          $t->close();
          foreach ($sel['nodes'] as $n) {
              $s = new Client($n['host'], $n['port']);
              $s->setHeaders(['X-Tracker-Token' => $sel['token']]);
              $s->execute("/$fileId");  // DELETE
              // 简化:用低层 HTTP DELETE
              $s->setMethod('DELETE');
              $s->execute("/$fileId");
              $s->close();
          }
          return true;
      }
  }

  // 使用
  \Swoole\Coroutine\run(function() {
      $c = new FdfsClient('http://127.0.0.1:22122');
      $info = $c->upload('/tmp/photo.jpg');
      echo $info['file_id'] . "\n";              // group1/M00/00/AB/wKg...jpg
      $c->download($info['file_id'], '/tmp/downloaded.jpg');
  });

  ---
  D. DDL

  CREATE TABLE storages (
    node_id VARCHAR(32) PRIMARY KEY,
    group_name VARCHAR(32) NOT NULL,
    host VARCHAR(64) NOT NULL,
    port INT NOT NULL,
    total_mb BIGINT,
    weight INT DEFAULT 1,
    enabled TINYINT DEFAULT 1,
    updated_at DATETIME,
    KEY idx_group(group_name)
  );

  CREATE TABLE files (
    file_id VARCHAR(128) PRIMARY KEY,
    group_name VARCHAR(32),
    size BIGINT,
    mime VARCHAR(64),
    uploaded_by VARCHAR(64),
    created_at DATETIME,
    KEY idx_group(group_name)
  );

  ---
  五、性能参考(单机 4C8G)

  ┌──────────────┬────────────────────┬─────────────────┐
  │     角色     │        指标        │      数值       │
  ├──────────────┼────────────────────┼─────────────────┤
  │ Tracker      │ 调度 QPS           │ 3-5 万          │
  ├──────────────┼────────────────────┼─────────────────┤
  │ Tracker      │ P99 延迟           │ < 5ms           │
  ├──────────────┼────────────────────┼─────────────────┤
  │ Storage      │ 小文件上传 QPS     │ 3000-5000       │
  ├──────────────┼────────────────────┼─────────────────┤
  │ Storage      │ 下载并发(sendfile)5000+ 长连接    │
  ├──────────────┼────────────────────┼─────────────────┤
  │ Storage      │ 大文件吞吐         │ 网卡线速        │
  ├──────────────┼────────────────────┼─────────────────┤
  │ 副本同步延迟 │                    │ < 100ms(同机房) │
  ├──────────────┼────────────────────┼─────────────────┤
  │ 心跳→OFFLINE│                    │ < 15s           │
  └──────────────┴────────────────────┴─────────────────┘

  ---
  六、踩坑提示

  ┌───────────────────────────────────┬────────────────────────────────────────────────────┐
  │                坑                 │                        解决                        │
  ├───────────────────────────────────┼────────────────────────────────────────────────────┤
  │ 大文件 rawContent() 把进程吃 OOM  │ 大文件改用 Storage 直传(避开 Tracker)+ 流式        │
  ├───────────────────────────────────┼────────────────────────────────────────────────────┤
  │ 副本同步成功率不到 100%           │ 必须有重试队列 + 死信,人工对账                     │
  ├───────────────────────────────────┼────────────────────────────────────────────────────┤
  │ 新节点加入立刻参与负载 →数据缺失 │ 先 SYNCING 状态,等同步完才 ACTIVE                  │
  ├───────────────────────────────────┼────────────────────────────────────────────────────┤
  │ 单目录文件数过百万                │ 用两级散列目录(本方案已有)                         │
  ├───────────────────────────────────┼────────────────────────────────────────────────────┤
  │ 同步把网络打满                    │ Storage 加同步限速(令牌桶)                         │
  ├───────────────────────────────────┼────────────────────────────────────────────────────┤
  │ Tracker 单点                      │ Tracker 多副本 + 客户端轮询多个地址                │
  ├───────────────────────────────────┼────────────────────────────────────────────────────┤
  │ Storage 磁盘满 →文件半写入       │ 写之前 disk_free_space 预判                        │
  ├───────────────────────────────────┼────────────────────────────────────────────────────┤
  │ file_id 冲突                      │ 时间戳 + 8 字节随机,冲突概率 1e-18;再不放心加 stat │
  ├───────────────────────────────────┼────────────────────────────────────────────────────┤
  │ 元数据丢失                        │ .meta 文件 + DB files 表双写                       │
  ├───────────────────────────────────┼────────────────────────────────────────────────────┤
  │ 同机房优先(降低跨机房带宽)        │ 心跳上报 region 字段,调度时按 client IP 匹配       │
  └───────────────────────────────────┴────────────────────────────────────────────────────┘

  ---
  七、安全清单

  ┌───────────────────────────────────┬───────────────────────────────────┐
  │               风险                │               防御                │
  ├───────────────────────────────────┼───────────────────────────────────┤
  │ 客户端绕过 Tracker 直接打 Storage │ Tracker token 必校验(本方案 HMAC) │
  ├───────────────────────────────────┼───────────────────────────────────┤
  │ 文件 ID 被猜测枚举                │ 8 字节随机,熵 ≥64 bit            │
  ├───────────────────────────────────┼───────────────────────────────────┤
  │ 上传任意路径(目录穿越)            │ stripGroupPrefix 严格正则,拒绝 .. │
  ├───────────────────────────────────┼───────────────────────────────────┤
  │ 跨节点同步被伪造                  │ 独立 HMAC sync-token              │
  ├───────────────────────────────────┼───────────────────────────────────┤
  │ 大文件吃满磁盘                    │ 单节点限额 + 总配额 + 告警        │
  ├───────────────────────────────────┼───────────────────────────────────┤
  │ 公网直接访问 Storage              │ Storage 内网部署,通过 CDN / 网关  │
  ├───────────────────────────────────┼───────────────────────────────────┤
  │ Tracker 接口被刷                  │ IP 限流 + 业务侧 token            │
  └───────────────────────────────────┴───────────────────────────────────┘

  ---
  八、可扩展方向

  1. EC 纠删码:3 数据 + 2 校验,1.67 倍空间换 N+2 容灾(类 Ceph)
  2. 冷热分层:30 天未访问的文件搬到便宜的对象存储(OSS)
  3. 客户端断点续传:Storage 支持 PUT Range,客户端分块续传
  4. CDN 集成:/download/select 返回 CDN URL,Storage 做回源
  5. 去重(content-addressed):file_id = sha256(content),相同内容只存一份
  6. 多机房复制:跨机房副本 + 异步追平
  7. 图片处理:URL 带 ?resize=100x100,Storage 实时缩放
  8. 生命周期:?expire=86400 上传时打标,定时清理
  9. 审计 + DLP:上传内容扫描敏感词
  10. WebDAV / S3 协议对外:加协议适配器,让任何客户端都能用

  ---
  九、和 FastDFS / SeaweedFS 的真实差距

  ┌────────────┬────────────────┬───────────────────┬───────────────────────────────┐
  │    维度    │  FastDFS (C)   │  SeaweedFS (Go)   │      本方案 (PHP+Swoole)      │
  ├────────────┼────────────────┼───────────────────┼───────────────────────────────┤
  │ 性能(单机) │ 极高           │ 极高              │ 中(够中小规模)                │
  ├────────────┼────────────────┼───────────────────┼───────────────────────────────┤
  │ 协议       │ 私有二进制     │ HTTP/gRPC         │ HTTP                          │
  ├────────────┼────────────────┼───────────────────┼───────────────────────────────┤
  │ 部署复杂度 │ 中             │ 低                │ 低                            │
  ├────────────┼────────────────┼───────────────────┼───────────────────────────────┤
  │ 扩展灵活性 │ 改 C 难        │ 改 Go 中          │ 改 PHP 快(本方案强项)         │
  ├────────────┼────────────────┼───────────────────┼───────────────────────────────┤
  │ 业务集成   │ 适配麻烦       │ 中                │ 天然契合 PHP 业务(本方案强项) │
  ├────────────┼────────────────┼───────────────────┼───────────────────────────────┤
  │ 适合场景   │ 大规模通用文件 │ 大规模 + 现代特性 │ 中小团队 + 业务深度定制       │
  └────────────┴────────────────┴───────────────────┴───────────────────────────────┘

更多推荐