info('开始执行批量生成分集调度任务...'); $concurrency = (int) env('BATCH_EPISODE_GENERATION_CONCURRENCY', 5); if ($concurrency < 1) { $concurrency = 1; } // 找出所有有待处理任务的 anime_id $animeIds = DB::table('mp_batch_episode_generation_details') ->where('status', 'pending') ->distinct() ->pluck('anime_id') ->map(function ($animeId) { return (int) $animeId; }) ->sort() ->values() ->toArray(); if (empty($animeIds)) { dLog('command')->info('没有待处理的任务,本次调度结束'); return 0; } $spawned = 0; foreach ($animeIds as $animeId) { if ($spawned >= $concurrency) { dLog('command')->info("已达到最大并发数 {$concurrency},停止调度"); break; } $lockName = ProcessBatchAnimeEpisodeGenerationCommand::LOCK_PREFIX . $animeId; // 仅当该 anime 的处理锁空闲时才拉起子进程; // 真正的加锁由子进程完成,避免锁挂在调度进程的连接上 $isFree = DB::selectOne('SELECT IS_FREE_LOCK(?) AS is_free', [$lockName]); if (!$isFree || (int) $isFree->is_free !== 1) { dLog('command')->info("anime_id: {$animeId} 正在被其他进程处理,跳过"); continue; } try { $this->spawnWorker($animeId); $spawned++; dLog('command')->info("已拉起子进程处理 anime_id: {$animeId}"); } catch (\Throwable $e) { dLog('command')->error("拉起子进程失败 anime_id: {$animeId}: " . $e->getMessage()); logDB('command', 'error', '批量分集调度:拉起子进程失败', [ 'anime_id' => $animeId, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); } } dLog('command')->info("批量生成分集调度完成,本次拉起子进程数: {$spawned}"); return 0; } /** * 拉起一个后台子进程,独占处理指定 anime_id 的待生成剧集 * * @param int $animeId * @return void */ private function spawnWorker($animeId) { $command = [ $this->phpBinary(), base_path('artisan'), 'batch:generate-episodes:anime', (string) $animeId, ]; if (DIRECTORY_SEPARATOR === '/') { // Linux:用 nohup 后台启动并立即返回。 // 使用 exec 而不是 Symfony Process,避免父进程退出时 Process 析构(stop(0))影响子进程; // 子进程输出写入日志文件,便于排查拉起失败的原因。 $logFile = storage_path('logs/batch-worker-' . date('Ymd') . '.log'); $commandLine = 'nohup ' . implode(' ', array_map('escapeshellarg', $command)) . ' >> ' . escapeshellarg($logFile) . ' 2>&1 &'; exec($commandLine); dLog('command')->info("拉起子进程命令: " . $commandLine); } else { // Windows:数组参数直接启动(开发环境) $process = new Process($command); $process->disableOutput(); $process->setTimeout(null); $process->setIdleTimeout(null); $process->start(); } } /** * 获取当前 PHP 可执行文件的真实路径 * * 部分编译版 PHP(如 LNMP 一键包)会在编译期把 PHP_BINARY 常量硬编码成固定路径, * 与实际运行中的二进制不一致,直接用 PHP_BINARY 拉子进程会失败。 * 因此依次优先使用: * 1. 环境变量 PHP_BINARY(可在 crontab 中显式指定); * 2. Linux 下 /proc/self/exe(当前真实运行的二进制,与启动本命令的 PHP 一致); * 3. 回退到 PHP_BINARY 常量。 * * @return string */ private function phpBinary() { $envBinary = getenv('PHP_BINARY'); if ($envBinary && is_executable($envBinary)) { return $envBinary; } if (DIRECTORY_SEPARATOR === '/' && ($real = @readlink('/proc/self/exe')) && is_executable($real)) { return $real; } return PHP_BINARY; } }