ProcessBatchEpisodeGenerationCommand.php 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. <?php
  2. namespace App\Console\Commands;
  3. use Illuminate\Console\Command;
  4. use Illuminate\Support\Facades\DB;
  5. use Symfony\Component\Process\Process;
  6. /**
  7. * 批量生成分集定时任务(调度器)
  8. * 每次执行:找出有 pending 任务的 anime_id,为最多 N 个(默认5)空闲 anime 各拉起一个后台子进程,
  9. * 由子进程独占处理对应 anime 的待生成剧集。
  10. */
  11. class ProcessBatchEpisodeGenerationCommand extends Command
  12. {
  13. /**
  14. * The name and signature of the console command.
  15. *
  16. * @var string
  17. */
  18. protected $signature = 'batch:generate-episodes';
  19. /**
  20. * The console command description.
  21. *
  22. * @var string
  23. */
  24. protected $description = '批量生成分集定时任务:并发调度不同anime_id的子进程处理,并发数默认5';
  25. /**
  26. * Execute the console command.
  27. *
  28. * @return int
  29. */
  30. public function handle()
  31. {
  32. dLog('command')->info('开始执行批量生成分集调度任务...');
  33. $concurrency = (int) env('BATCH_EPISODE_GENERATION_CONCURRENCY', 5);
  34. if ($concurrency < 1) {
  35. $concurrency = 1;
  36. }
  37. // 找出所有有待处理任务的 anime_id
  38. $animeIds = DB::table('mp_batch_episode_generation_details')
  39. ->where('status', 'pending')
  40. ->distinct()
  41. ->pluck('anime_id')
  42. ->map(function ($animeId) {
  43. return (int) $animeId;
  44. })
  45. ->sort()
  46. ->values()
  47. ->toArray();
  48. if (empty($animeIds)) {
  49. dLog('command')->info('没有待处理的任务,本次调度结束');
  50. return 0;
  51. }
  52. $spawned = 0;
  53. foreach ($animeIds as $animeId) {
  54. if ($spawned >= $concurrency) {
  55. dLog('command')->info("已达到最大并发数 {$concurrency},停止调度");
  56. break;
  57. }
  58. $lockName = ProcessBatchAnimeEpisodeGenerationCommand::LOCK_PREFIX . $animeId;
  59. // 仅当该 anime 的处理锁空闲时才拉起子进程;
  60. // 真正的加锁由子进程完成,避免锁挂在调度进程的连接上
  61. $isFree = DB::selectOne('SELECT IS_FREE_LOCK(?) AS is_free', [$lockName]);
  62. if (!$isFree || (int) $isFree->is_free !== 1) {
  63. dLog('command')->info("anime_id: {$animeId} 正在被其他进程处理,跳过");
  64. continue;
  65. }
  66. try {
  67. $this->spawnWorker($animeId);
  68. $spawned++;
  69. dLog('command')->info("已拉起子进程处理 anime_id: {$animeId}");
  70. } catch (\Throwable $e) {
  71. dLog('command')->error("拉起子进程失败 anime_id: {$animeId}: " . $e->getMessage());
  72. logDB('command', 'error', '批量分集调度:拉起子进程失败', [
  73. 'anime_id' => $animeId,
  74. 'error' => $e->getMessage(),
  75. 'trace' => $e->getTraceAsString()
  76. ]);
  77. }
  78. }
  79. dLog('command')->info("批量生成分集调度完成,本次拉起子进程数: {$spawned}");
  80. return 0;
  81. }
  82. /**
  83. * 拉起一个后台子进程,独占处理指定 anime_id 的待生成剧集
  84. *
  85. * @param int $animeId
  86. * @return void
  87. */
  88. private function spawnWorker($animeId)
  89. {
  90. $command = [
  91. $this->phpBinary(),
  92. base_path('artisan'),
  93. 'batch:generate-episodes:anime',
  94. (string) $animeId,
  95. ];
  96. if (DIRECTORY_SEPARATOR === '/') {
  97. // Linux:用 nohup 后台启动并立即返回。
  98. // 使用 exec 而不是 Symfony Process,避免父进程退出时 Process 析构(stop(0))影响子进程;
  99. // 子进程输出写入日志文件,便于排查拉起失败的原因。
  100. $logFile = storage_path('logs/batch-worker-' . date('Ymd') . '.log');
  101. $commandLine = 'nohup ' . implode(' ', array_map('escapeshellarg', $command))
  102. . ' >> ' . escapeshellarg($logFile) . ' 2>&1 &';
  103. exec($commandLine);
  104. dLog('command')->info("拉起子进程命令: " . $commandLine);
  105. } else {
  106. // Windows:数组参数直接启动(开发环境)
  107. $process = new Process($command);
  108. $process->disableOutput();
  109. $process->setTimeout(null);
  110. $process->setIdleTimeout(null);
  111. $process->start();
  112. }
  113. }
  114. /**
  115. * 获取当前 PHP 可执行文件的真实路径
  116. *
  117. * 部分编译版 PHP(如 LNMP 一键包)会在编译期把 PHP_BINARY 常量硬编码成固定路径,
  118. * 与实际运行中的二进制不一致,直接用 PHP_BINARY 拉子进程会失败。
  119. * 因此依次优先使用:
  120. * 1. 环境变量 PHP_BINARY(可在 crontab 中显式指定);
  121. * 2. Linux 下 /proc/self/exe(当前真实运行的二进制,与启动本命令的 PHP 一致);
  122. * 3. 回退到 PHP_BINARY 常量。
  123. *
  124. * @return string
  125. */
  126. private function phpBinary()
  127. {
  128. $envBinary = getenv('PHP_BINARY');
  129. if ($envBinary && is_executable($envBinary)) {
  130. return $envBinary;
  131. }
  132. if (DIRECTORY_SEPARATOR === '/' && ($real = @readlink('/proc/self/exe')) && is_executable($real)) {
  133. return $real;
  134. }
  135. return PHP_BINARY;
  136. }
  137. }