招聘平台大量使用 RabbitMQ 做异步处理——发邮件、发短信、统计埋点、视频转码、简历解析等等。用了几年,踩了不少坑。今天把消息队列在招聘场景下的问题和解决方案整理出来。
一、消息丢失#
最早用 RabbitMQ 时,消息生产者发完就不管了,消费者处理失败也不重试。有次 RabbitMQ 节点重启,一批报名通知消息丢了,用户没收到报名成功短信,投诉了好几个。
解决方案#
- 生产者确认:开启 publisher confirm,消息到达 Broker 后确认,失败重试
- 持久化:队列和消息都设为持久化(durable),重启不丢
- 消费者手动 ack:处理成功再 ack,失败不 ack 重新入队
- 死信队列:处理失败多次的消息进死信队列,人工排查
1
2
3
4
5
6
7
8
9
10
11
| // 生产者确认
$channel->confirm_select();
$channel->set_ack_handler(function ($message) {
// 消息已到达 Broker
});
$channel->set_nack_handler(function ($message) {
// 消息未到达,重试
});
// 持久化消息
$msg = new AMQPMessage($body, ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]);
|
二、重复消费#
消费者处理成功但 ack 时网络断了,消息重新入队,消费者又处理一次。报名通知发了两条,用户投诉骚扰。
解决方案#
幂等设计:消费端做幂等,同一个消息处理多次结果一样。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| public function handleSignupNotify($message)
{
$data = json_decode($message->body, true);
$msgId = $data['message_id'];
// 幂等检查:已处理过直接返回
if (Yii::$app->redis->sismember("msg:processed:signup", $msgId)) {
$message->ack();
return;
}
// 业务处理
$this->sendSignupEmail($data['user_id']);
// 标记已处理
Yii::$app->redis->sadd("msg:processed:signup", $msgId);
Yii::$app->redis->expire("msg:processed:signup", 86400 * 7);
$message->ack();
}
|
三、队列堆积#
视频转码队列,大促期间上传视频多,转码服务处理不过来,队列堆积几万条,用户等很久视频才转好。
解决方案#
- 消费者扩容:根据队列长度自动扩容消费者实例
- 优先级队列:重要消息(如报名通知)优先级高,优先处理
- 分流:不同类型消息用不同队列,互不影响
- 降级:非核心消息(如统计埋点)堆积时可以丢弃一部分
1
2
3
4
5
6
7
8
9
10
| // 优先级队列
$channel->queue_declare('video_transcode', false, true, false, false, false, [
'x-max-priority' => ['I', 10],
]);
// 高优先级消息
$msg = new AMQPMessage($body, [
'delivery_mode' => 2,
'priority' => 10, // 高优先级
]);
|
四、死信处理#
消费失败的消息不断重试,永远处理失败(如数据格式错误),占用队列资源。
解决方案#
死信队列 + 重试次数限制:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // 声明死信队列
$channel->queue_declare('dlq', false, true, false, false);
// 业务队列绑定死信
$channel->queue_declare('signup_notify', false, true, false, false, false, [
'x-dead-letter-exchange' => ['S', ''],
'x-dead-letter-routing-key' => ['S', 'dlq'],
]);
// 消费时判断重试次数
$retryCount = $message->get('application_headers')['x-retry-count'] ?? 0;
if ($retryCount > 3) {
// 超过3次,直接进死信
$message->nack(false);
return;
}
// 处理失败,重试次数+1,重新入队
$message->get('application_headers')->set('x-retry-count', 'I', $retryCount + 1);
$message->nack(true);
|
五、顺序消息#
简历状态变更消息(创建→审核→发布)需要保证顺序,否则状态错乱。RabbitMQ 默认不保证顺序。
解决方案#
单队列 + 单消费者:需要顺序的消息放同一个队列,用单消费者处理(或按 key 哈希到固定消费者)。
1
2
3
| // 按 resume_id 哈希,同一个简历的消息到同一个队列
$queueIndex = crc32($resumeId) % $consumerCount;
$channel->basic_publish($msg, '', "resume_status_{$queueIndex}");
|
六、总结#
RabbitMQ 使用的核心经验:
- 不丢消息:生产者确认 + 持久化 + 手动 ack + 死信队列
- 不重复消费:消费端幂等设计,消息 ID 去重
- 不堆积:消费者扩容 + 优先级队列 + 分流 + 降级
- 死信处理:重试次数限制 + 死信队列 + 人工排查
- 顺序消息:单队列单消费者,或按 key 哈希固定消费者
- 监控告警:队列长度、消费速率、错误率、死信数量,都要监控
消息队列不是"发了就完事",要考虑消息丢失、重复、堆积、死信等各种异常情况。生产环境用消息队列,一定要做好幂等、重试、监控,否则出了问题排查起来很痛苦。