RabbitMQWorker.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Exceptions\SectionTimeoutException;
  4. use App\Exceptions\TaskFailException;
  5. use App\Jobs\BaseRabbitMQJob;
  6. use App\Jobs\ProcessAITranslateJob;
  7. use App\Services\RabbitMQService;
  8. use App\Tools\Tools;
  9. use Illuminate\Console\Command;
  10. use Illuminate\Support\Facades\Log;
  11. use PhpAmqpLib\Exception\AMQPTimeoutException;
  12. use PhpAmqpLib\Message\AMQPMessage;
  13. use PhpAmqpLib\Wire\AMQPTable;
  14. class RabbitMQWorker extends Command
  15. {
  16. /**
  17. * The name and signature of the console command.
  18. * php -d memory_limit=128M artisan rabbitmq:consume ai_translate
  19. *
  20. * @var string
  21. */
  22. protected $signature = 'rabbitmq:consume {queue} {--reset-loop-count}';
  23. protected $description = '消费 RabbitMQ 队列消息';
  24. private $connection;
  25. private $channel;
  26. private $processedCount = 0;
  27. private $maxLoopCount = 0;
  28. private $queueName;
  29. private $queueConfig;
  30. private $shouldStop = false;
  31. private $timeout = 15;
  32. private $job = null;
  33. public function handle()
  34. {
  35. if (Tools::isStop()) {
  36. return 0;
  37. }
  38. $this->queueName = $this->argument('queue');
  39. $this->queueConfig = config("mint.rabbitmq.queues.{$this->queueName}");
  40. if (! $this->queueConfig) {
  41. $this->error("队列 {$this->queueName} 的配置不存在");
  42. return 1;
  43. }
  44. $this->maxLoopCount = $this->queueConfig['max_loop_count'];
  45. $this->info('启动 RabbitMQ Worker');
  46. $this->info("队列: {$this->queueName}");
  47. $this->info("最大循环次数: {$this->maxLoopCount}");
  48. $this->info("重试次数: {$this->queueConfig['retry_times']}");
  49. $consume = app(RabbitMQService::class);
  50. try {
  51. $consume->setupQueue($this->queueName);
  52. $this->channel = $consume->getChannel();
  53. $this->startConsuming();
  54. } catch (\Exception $e) {
  55. $this->error('Worker 启动失败: '.$e->getMessage());
  56. Log::error('RabbitMQ Worker 启动失败', [
  57. 'queue' => $this->queueName,
  58. 'error' => $e->getMessage(),
  59. ]);
  60. return 1;
  61. } finally {
  62. $this->cleanup();
  63. }
  64. return 0;
  65. }
  66. private function startConsuming()
  67. {
  68. $callback = function (AMQPMessage $msg) {
  69. $this->processMessage($msg);
  70. };
  71. $this->channel->basic_consume(
  72. $this->queueName,
  73. '', // consumer_tag
  74. false, // no_local
  75. false, // no_ack
  76. false, // exclusive
  77. false, // nowait
  78. $callback
  79. );
  80. $this->info('开始消费消息... 按 Ctrl+C 退出');
  81. // 设置信号处理
  82. if (extension_loaded('pcntl')) {
  83. pcntl_signal(SIGTERM, [$this, 'handleSignal']);
  84. pcntl_signal(SIGINT, [$this, 'handleSignal']);
  85. }
  86. while ($this->channel->is_consuming() && ! $this->shouldStop) {
  87. try {
  88. $this->channel->wait(null, false, $this->timeout);
  89. } catch (AMQPTimeoutException $e) {
  90. // 忽略
  91. } catch (\Exception $e) {
  92. $this->error($e->getMessage());
  93. throw $e;
  94. }
  95. if (extension_loaded('pcntl')) {
  96. pcntl_signal_dispatch();
  97. }
  98. // 检查是否达到最大循环次数
  99. if ($this->processedCount >= $this->maxLoopCount) {
  100. $this->info("达到最大循环次数 ({$this->maxLoopCount}),Worker 自动退出");
  101. break;
  102. }
  103. if (Tools::isStop()) {
  104. // 检测到停止标记
  105. break;
  106. }
  107. }
  108. }
  109. private function processMessage(AMQPMessage $msg)
  110. {
  111. try {
  112. Log::info('processMessage start', ['message_id' => $msg->get('message_id')]);
  113. $data = json_decode($msg->getBody());
  114. $this->info('processMessage start '.$msg->get('message_id').'['.count($data).']');
  115. if (json_last_error() !== JSON_ERROR_NONE) {
  116. throw new \Exception('JSON 解析失败: '.json_last_error_msg());
  117. }
  118. // 获取重试次数(从消息头中获取)
  119. $retryCount = 0;
  120. if ($msg->has('application_headers')) {
  121. $headers = $msg->get('application_headers')->getNativeData();
  122. $retryCount = $headers['retry_count'] ?? 0;
  123. }
  124. // 根据队列类型创建对应的 Job
  125. $this->job = $this->createJob($msg->get('message_id'), $data, $retryCount);
  126. try {
  127. // 执行业务逻辑
  128. $this->job->handle();
  129. // 成功处理,确认消息
  130. $msg->ack();
  131. $this->processedCount++;
  132. $this->info("消息处理成功 [{$this->processedCount}/{$this->maxLoopCount}]");
  133. } catch (SectionTimeoutException $e) {
  134. $msg->nack(true, false);
  135. Log::warning('attempt to requeue the message message_id:'.$msg->get('message_id'));
  136. } catch (TaskFailException $e) {
  137. $msg->nack(false, false);
  138. } catch (\Exception $e) {
  139. // requeue
  140. $this->handleJobException($msg, $data, $retryCount, $e);
  141. }
  142. } catch (\Exception $e) {
  143. $this->error('消息处理异常: '.$e->getMessage());
  144. Log::error('RabbitMQ 消息处理异常', [
  145. 'queue' => $this->queueName,
  146. 'error' => $e->getMessage(),
  147. 'message_body' => $msg->getBody(),
  148. ]);
  149. // 拒绝消息并发送到死信队列
  150. // $msg->nack(false, false);
  151. $this->sendToDeadLetterQueue($data, $e);
  152. $msg->ack(); // 确认原消息以避免重复
  153. $this->error('已发送到死信队列');
  154. $this->processedCount++;
  155. }
  156. }
  157. private function createJob(string $messageId, array $data, int $retryCount): BaseRabbitMQJob
  158. {
  159. // 根据队列名称创建对应的 Job 实例
  160. switch ($this->queueName) {
  161. case 'ai_translate':
  162. return new ProcessAITranslateJob(
  163. $this->queueName,
  164. $messageId,
  165. $data,
  166. $retryCount,
  167. );
  168. // 可以添加更多队列类型
  169. default:
  170. throw new \Exception("未知的队列类型: {$this->queueName}");
  171. }
  172. }
  173. private function handleJobException(AMQPMessage $msg, array $data, int $retryCount, \Exception $e)
  174. {
  175. $maxRetries = $this->queueConfig['retry_times'];
  176. if ($retryCount < $maxRetries - 1) {
  177. // 还有重试机会,重新入队
  178. $this->requeueMessage($msg, $data, $retryCount + 1);
  179. $this->info('消息重新入队,重试次数: '.($retryCount + 1)."/{$maxRetries}");
  180. } else {
  181. // 超过重试次数,发送到死信队列
  182. $this->sendToDeadLetterQueue($data, $e);
  183. $msg->ack(); // 确认原消息以避免重复
  184. $this->error('消息超过最大重试次数,已发送到死信队列 ');
  185. Log::error('消息超过最大重试次数,已发送到死信队列 message_id='.$msg->get('message_id'));
  186. }
  187. $this->processedCount++;
  188. }
  189. private function requeueMessage(AMQPMessage $msg, array $data, int $newRetryCount)
  190. {
  191. // 添加重试计数到消息头
  192. // 使用 AMQPTable 包装头部数据
  193. $headers = new AMQPTable([
  194. 'retry_count' => $newRetryCount,
  195. 'original_queue' => $this->queueName,
  196. 'retry_timestamp' => time(),
  197. ]);
  198. $newMsg = new AMQPMessage(
  199. json_encode($data, JSON_UNESCAPED_UNICODE),
  200. [
  201. 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT,
  202. 'timestamp' => time(),
  203. 'message_id' => $msg->get('message_id'),
  204. 'application_headers' => $headers,
  205. 'content_type' => 'application/json; charset=utf-8',
  206. ]
  207. );
  208. // 发布到同一队列
  209. $this->channel->basic_publish($newMsg, '', $this->queueName);
  210. // 确认原消息
  211. $msg->ack();
  212. }
  213. private function sendToDeadLetterQueue(array $data, \Exception $e)
  214. {
  215. $dlqName = $this->queueConfig['dead_letter_queue'];
  216. $dlqData = [
  217. 'original_message' => $data,
  218. 'failure_reason' => $e->getMessage(),
  219. 'failed_at' => date('Y-m-d H:i:s'),
  220. 'queue' => $this->queueName,
  221. 'max_retries' => $this->queueConfig['retry_times'],
  222. ];
  223. $dlqMsg = new AMQPMessage(
  224. json_encode($dlqData),
  225. ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]
  226. );
  227. $this->channel->basic_publish($dlqMsg, '', $dlqName);
  228. Log::error('消息发送到死信队列', [
  229. 'original_queue' => $this->queueName,
  230. 'dead_letter_queue' => $dlqName,
  231. 'error' => $e->getMessage(),
  232. ]);
  233. }
  234. /**
  235. * 处理系统信号
  236. *
  237. * @param int $signal 信号类型
  238. * @param int|false $previousExitCode 上一个退出码
  239. * @return int|false 返回退出码或 false
  240. */
  241. public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
  242. {
  243. $this->info('接收到退出信号,正在优雅关闭...');
  244. $this->shouldStop = true;
  245. if ($this->job) {
  246. $this->job->stop();
  247. }
  248. if ($this->channel && $this->channel->is_consuming()) {
  249. // $this->channel->basic_cancel_on_shutdown(true);
  250. $this->channel->basic_cancel('');
  251. }
  252. // 返回 false 表示信号已处理,不需要进一步传播
  253. return false;
  254. }
  255. private function cleanup()
  256. {
  257. try {
  258. if ($this->channel) {
  259. $this->channel->close();
  260. }
  261. if ($this->connection) {
  262. $this->connection->close();
  263. }
  264. $this->info("连接已关闭,处理了 {$this->processedCount} 条消息");
  265. } catch (\Exception $e) {
  266. $this->error('清理资源时出错: '.$e->getMessage());
  267. }
  268. }
  269. }