MQTT 消息积压不是一个单点问题。它通常不是“Broker 不行”,也不是“PHP 不行”,而是 **消息进入速度大于消费速度 ,或者 ** 离线会话、QoS、订阅方式、数据库写入和业务处理方式没有配合好。
常见现象:
- EMQX 内存持续升高。
- 设备上报正常,但业务后台数据延迟很大。
- ThinkPHP 接口响应变慢,PHP-FPM 被占满。
- 消费命令重启后突然收到大量旧消息。
- 设备离线后,上线瞬间收到一堆过期指令。
- 数据库写入慢,导致 MQTT 消费速度跟不上。
本文示例按 ThinkPHP 8 项目写法组织:Webhook 进入 Controller,入库和业务处理放 Service,常驻消费使用 ThinkPHP Command。
先说结论
用 EMQX + ThinkPHP 8 处理消息,建议按这套思路设计:
1
| 设备 -> EMQX -> ThinkPHP 接收层 -> MySQL 消息表 -> ThinkPHP 后台任务处理
|
核心原则:
重点
消息积压不能只靠“加服务器”解决。先确认积压是在 EMQX 会话里、ThinkPHP 接口、ThinkPHP Command、MySQL 写入,还是业务处理逻辑里。
常见积压原因
两种落地方式
方式一:EMQX Webhook 推给 ThinkPHP
适合普通 Web 项目。ThinkPHP 不需要长连接 MQTT,只需要提供 HTTP 接口让 EMQX 规则引擎转发消息。
1
| 设备发布 MQTT -> EMQX 规则引擎 -> HTTP Webhook -> ThinkPHP 入库 -> 后台任务处理
|
优点:
- 部署简单。
- ThinkPHP 仍然走常见 Web 接口。
- 容易和现有业务系统集成。
- 接收层可以快速返回,避免长时间占用 EMQX 转发链路。
缺点:
- HTTP 接口必须稳定。
- 接口慢会影响转发。
- 高并发时要注意 PHP-FPM 和数据库压力。
方式二:ThinkPHP Command 共享订阅
适合能部署常驻进程的项目。
1
| 设备发布 MQTT -> EMQX -> 多个 php think mqtt:worker 共享订阅 -> MySQL 入库 -> 后台任务处理
|
共享订阅示例:
1
| $share/tp8-workers/device/+/telemetry
|
多个 worker 订阅同一个共享订阅 topic 后,EMQX 会把消息分发给同组消费者,避免所有 worker 都收到同一条消息。
推荐结构:先入库,再处理
无论是 Webhook 还是 ThinkPHP Command,都建议先把消息写入一张原始消息表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| CREATE TABLE mqtt_messages ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, message_key VARCHAR(128) NOT NULL COMMENT '消息唯一键', topic VARCHAR(255) NOT NULL, client_id VARCHAR(128) DEFAULT NULL, qos TINYINT UNSIGNED NOT NULL DEFAULT 0, payload JSON NULL, payload_hash CHAR(64) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending, processing, success, failed, ignored', attempts INT UNSIGNED NOT NULL DEFAULT 0, max_attempts INT UNSIGNED NOT NULL DEFAULT 5, published_at DATETIME DEFAULT NULL, next_run_at DATETIME NOT NULL, locked_by VARCHAR(100) DEFAULT NULL, locked_until DATETIME DEFAULT NULL, last_error TEXT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, finished_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uniq_message_key (message_key), KEY idx_status_next_run (status, next_run_at, id), KEY idx_lock (status, locked_until), KEY idx_topic_created (topic, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
message_key 是防重复的关键。不要只用秒级时间戳,也不要用 updated_at 这种状态更新时间来判断重复。同一秒内完全可能有很多条正常消息,时间只能辅助排序,不能单独作为唯一键。
推荐按设备侧的稳定编号生成,例如:
1 2 3
| device_id + msg_id device_id + seq device_id + ts_ms + seq
|
其中 msg_id 可以是设备生成的消息编号,seq 可以是设备递增序号,ts_ms 是毫秒级上报时间。id 是数据库自增主键,只能标识消息表里的某一行,不能判断两条 MQTT 消息是不是同一条业务消息。
如果设备完全没有提供 msg_id、seq 这类稳定字段,接收端不要硬用 payload_hash + 秒级时间 去去重,因为这样可能把同一秒内的正常重复值误删。更稳妥的做法是先生成接收侧唯一键保证消息不丢,然后尽快补齐设备协议。
SQL 要尽量简单
MQTT 消息表可能增长很快,不建议把复杂统计都压在 MySQL 大表上。
建议:
- 消费领取只按
status、next_run_at、id 走索引。
- 不在领取 SQL 里写复杂
OR、子查询、视图、聚合函数。
- 不在大表上频繁执行
COUNT(*)、GROUP BY、MIN()、TIMESTAMPDIFF()。
- 积压数量可以用 Redis 计数、监控系统或单独指标表记录。
- MySQL 负责可靠状态流转,Redis/Predis 可以负责限流、去重、轻量队列和计数。
本文后面的领取逻辑会先恢复过期 processing,再只领取 pending 消息,避免把条件写得很复杂。
设备消息格式建议
设备上报不要只传一个值,至少带上设备 ID、消息 ID 和时间戳。
1 2 3 4 5 6 7 8 9 10 11 12
| { "msg_id": "00000001", "seq": 1, "device_id": "esp32-001", "ts": "2026-05-28T12:00:00+08:00", "ts_ms": 1780000000123, "type": "telemetry", "data": { "temperature": 26.3, "humidity": 61.5 } }
|
这样 ThinkPHP 可以判断:
- 这条消息是否已经处理过。
- 是否是过期数据。
- 设备时间是否异常。
- 同一设备消息顺序是否错乱。
ThinkPHP 8 配置
config/mqtt.php:
1 2 3 4 5 6 7 8 9 10 11 12
| <?php
return [ 'webhook_token' => env('mqtt.webhook_token', 'change_me'), 'host' => env('mqtt.host', '127.0.0.1'), 'port' => (int) env('mqtt.port', 1883), 'username' => env('mqtt.username', ''), 'password' => env('mqtt.password', ''), 'worker_group' => env('mqtt.worker_group', 'tp8-workers'), 'lock_minutes' => 5, 'recover_limit' => 200, ];
|
.env:
1 2 3 4 5 6
| MQTT_WEBHOOK_TOKEN=change_me_to_long_random_string MQTT_HOST=127.0.0.1 MQTT_PORT=1883 MQTT_USERNAME=worker MQTT_PASSWORD=password MQTT_WORKER_GROUP=tp8-workers
|
可选:Predis 做去重和计数
Redis 不应该替代 MySQL 的最终状态,但很适合做入口削峰、短期去重和指标计数。
安装:
1
| composer require predis/predis
|
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| use Predis\Client as RedisClient;
$redis = new RedisClient([ 'scheme' => 'tcp', 'host' => '127.0.0.1', 'port' => 6379, ]);
$dedupeKey = 'mqtt:dedupe:' . $messageKey; $isNew = $redis->set($dedupeKey, '1', 'EX', 86400, 'NX');
if (!$isNew) { return; }
$redis->incr('metric:mqtt:pending');
|
Redis 去重只能作为加速层,MySQL 里的 message_key 唯一索引仍然要保留。因为 Redis 可能重启、过期或被清理,最终兜底仍然要靠数据库唯一键和业务幂等。
方案一:EMQX Webhook 到 ThinkPHP
EMQX 规则引擎可以把指定 topic 的消息转发到 HTTP 服务。ThinkPHP 接口只做三件事:
- 校验签名或 token。
- 解析 topic 和 payload。
- 幂等入库后快速返回。
route/app.php:
1 2 3 4 5
| <?php
use think\facade\Route;
Route::post('internal/mqtt/ingest', 'internal.Mqtt/ingest');
|
app/controller/internal/Mqtt.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| <?php
declare(strict_types=1);
namespace app\controller\internal;
use app\service\MqttMessageService; use think\Request; use think\Response; use Throwable;
class Mqtt { public function ingest(Request $request, MqttMessageService $service): Response { if ($request->header('x-mqtt-token') !== config('mqtt.webhook_token')) { return json(['message' => 'forbidden'], 403); }
try { $service->saveFromWebhook($request->param()); } catch (Throwable $e) { return json([ 'ok' => false, 'message' => $e->getMessage(), ], 500); }
return json(['ok' => true]); } }
|
接口要快
Webhook 接口不要直接调用第三方接口、不要做复杂统计、不要发模板消息。先入库,快速返回,真正业务处理交给后台任务。
Service:消息入库
app/service/MqttMessageService.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
| <?php
declare(strict_types=1);
namespace app\service;
use DateTimeImmutable; use RuntimeException; use think\facade\Db; use Throwable;
class MqttMessageService { public function saveFromWebhook(array $body): void { $topic = (string) ($body['topic'] ?? ''); $clientId = (string) ($body['clientid'] ?? ''); $qos = (int) ($body['qos'] ?? 0); $payloadRaw = (string) ($body['payload'] ?? '{}'); $payload = json_decode($payloadRaw, true, 512, JSON_THROW_ON_ERROR);
$this->saveRawMessage($topic, $payloadRaw, $payload, $clientId, $qos); }
public function saveRawMessage( string $topic, string $payloadRaw, array $payload, string $clientId = '', int $qos = 1 ): void { $now = date('Y-m-d H:i:s'); $messageKey = $this->buildMessageKey($topic, $payload, $clientId); $publishedAt = $this->parsePublishedAt($payload['ts'] ?? null);
try { Db::table('mqtt_messages')->insert([ 'message_key' => $messageKey, 'topic' => $topic, 'client_id' => $clientId, 'qos' => $qos, 'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE), 'payload_hash' => hash('sha256', $payloadRaw), 'status' => 'pending', 'published_at' => $publishedAt, 'next_run_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); } catch (Throwable $e) { if (!$this->isDuplicateKey($e)) { throw $e; } } }
private function buildMessageKey(string $topic, array $payload, string $clientId): string { $deviceId = (string) ($payload['device_id'] ?? $clientId);
if (!empty($payload['msg_id'])) { return $this->joinMessageKey($deviceId, (string) $payload['msg_id']); }
if ($deviceId !== '' && isset($payload['seq'])) { return $this->joinMessageKey($deviceId, (string) $payload['seq']); }
return 'receive:' . hash('sha256', $topic . '|' . $clientId . '|' . uniqid('', true)); }
private function joinMessageKey(string $deviceId, string $value): string { if ($deviceId === '') { return $value; }
return $deviceId . ':' . $value; }
private function parsePublishedAt(?string $time): ?string { if (!$time) { return null; }
try { return (new DateTimeImmutable($time))->format('Y-m-d H:i:s'); } catch (Throwable $e) { return null; } }
private function isDuplicateKey(Throwable $e): bool { return str_contains($e->getMessage(), 'Duplicate entry') || str_contains($e->getMessage(), '1062'); } }
|
恢复过期消息
后台处理可以沿用 HTTP 轮询任务文章里的思路:HTTP + crontab 或 ThinkPHP Command 都可以。
领取之前,先恢复锁超时的 processing 消息。这一步只按 status + locked_until 索引走。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public function recoverExpired(): void { $now = date('Y-m-d H:i:s');
Db::table('mqtt_messages') ->where('status', 'processing') ->where('locked_until', '<', $now) ->limit((int) config('mqtt.recover_limit')) ->update([ 'status' => 'pending', 'locked_by' => null, 'locked_until' => null, 'updated_at' => $now, ]); }
|
领取消息
恢复过期消息后,领取逻辑只查 pending,不把 processing 恢复条件混在同一条 SQL 里。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| public function claim(string $workerId): ?array { return Db::transaction(function () use ($workerId) { $now = date('Y-m-d H:i:s');
$message = Db::table('mqtt_messages') ->where('status', 'pending') ->where('next_run_at', '<=', $now) ->order('id', 'asc') ->lock(true) ->find();
if (!$message) { return null; }
Db::table('mqtt_messages') ->where('id', $message['id']) ->inc('attempts') ->update([ 'status' => 'processing', 'locked_by' => $workerId, 'locked_until' => date('Y-m-d H:i:s', time() + ((int) config('mqtt.lock_minutes') * 60)), 'updated_at' => $now, ]);
return $message; }); }
|
处理业务时要注意过期消息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| public function handle(array $message): void { $payload = json_decode($message['payload'] ?: '{}', true, 512, JSON_THROW_ON_ERROR);
if ($this->isExpired($payload['ts'] ?? null)) { $this->markIgnored((int) $message['id'], 'expired message'); return; }
if ($this->topicEndsWith($message['topic'], '/telemetry')) { $this->saveTelemetry($payload); $this->markSuccess((int) $message['id']); return; }
if ($this->topicEndsWith($message['topic'], '/event')) { $this->saveDeviceEvent($payload, (string) $message['message_key']); $this->markSuccess((int) $message['id']); return; }
$this->markIgnored((int) $message['id'], 'unknown topic'); }
private function saveTelemetry(array $payload): void { Db::table('device_telemetry')->insert([ 'device_id' => $payload['device_id'] ?? '', 'temperature' => $payload['data']['temperature'] ?? null, 'humidity' => $payload['data']['humidity'] ?? null, 'reported_at' => $this->parsePublishedAt($payload['ts'] ?? null), 'created_at' => date('Y-m-d H:i:s'), ]); }
private function saveDeviceEvent(array $payload, string $messageKey): void { Db::table('device_events')->insert([ 'message_key' => $messageKey, 'device_id' => $payload['device_id'] ?? '', 'event_type' => $payload['type'] ?? 'event', 'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE), 'created_at' => date('Y-m-d H:i:s'), ]); }
private function isExpired(?string $time): bool { if (!$time) { return false; }
try { $messageTime = new DateTimeImmutable($time); } catch (Throwable $e) { return false; }
return $messageTime < new DateTimeImmutable('-10 minutes'); }
private function topicEndsWith(string $topic, string $suffix): bool { return substr($topic, -strlen($suffix)) === $suffix; }
|
状态更新:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| public function markSuccess(int $id): void { Db::table('mqtt_messages') ->where('id', $id) ->update([ 'status' => 'success', 'locked_by' => null, 'locked_until' => null, 'last_error' => null, 'finished_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]); }
public function markIgnored(int $id, string $reason): void { Db::table('mqtt_messages') ->where('id', $id) ->update([ 'status' => 'ignored', 'locked_by' => null, 'locked_until' => null, 'last_error' => $reason, 'finished_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]); }
public function markRetryOrFailed(array $message, Throwable $e): void { $attempts = (int) $message['attempts'] + 1; $maxAttempts = (int) $message['max_attempts']; $delaySeconds = min(3600, 30 * (2 ** min($attempts, 6))); $status = $attempts >= $maxAttempts ? 'failed' : 'pending'; $now = time();
Db::table('mqtt_messages') ->where('id', $message['id']) ->update([ 'status' => $status, 'next_run_at' => date('Y-m-d H:i:s', $now + $delaySeconds), 'locked_by' => null, 'locked_until' => null, 'last_error' => mb_substr($e->getMessage(), 0, 1000), 'finished_at' => $status === 'failed' ? date('Y-m-d H:i:s', $now) : null, 'updated_at' => date('Y-m-d H:i:s', $now), ]); }
|
方案二:ThinkPHP Command 共享订阅
如果你可以部署常驻进程,可以让多个 ThinkPHP Command 直接订阅 EMQX。
安装 MQTT 客户端库:
1
| composer require php-mqtt/client
|
注册命令,config/console.php:
1 2 3 4 5 6 7
| <?php
return [ 'commands' => [ 'mqtt:worker' => \app\command\MqttWorker::class, ], ];
|
app/command/MqttWorker.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| <?php
declare(strict_types=1);
namespace app\command;
use app\service\MqttMessageService; use PhpMqtt\Client\ConnectionSettings; use PhpMqtt\Client\MqttClient; use think\console\Command; use think\console\Input; use think\console\Output; use Throwable;
class MqttWorker extends Command { protected function configure(): void { $this->setName('mqtt:worker') ->setDescription('Consume MQTT messages from EMQX'); }
protected function execute(Input $input, Output $output): int { $clientId = 'tp8-worker-' . getmypid(); $server = (string) config('mqtt.host'); $port = (int) config('mqtt.port'); $group = (string) config('mqtt.worker_group');
$mqtt = new MqttClient($server, $port, $clientId); $settings = (new ConnectionSettings()) ->setUsername((string) config('mqtt.username')) ->setPassword((string) config('mqtt.password')) ->setKeepAliveInterval(30);
$mqtt->connect($settings, true);
$service = app(MqttMessageService::class); $topic = '$share/' . $group . '/device/+/telemetry';
$mqtt->subscribe($topic, function (string $topic, string $message) use ($service, $output): void { try { $payload = json_decode($message, true, 512, JSON_THROW_ON_ERROR); $service->saveRawMessage($topic, $message, $payload); } catch (Throwable $e) { $output->writeln('[mqtt] ' . $e->getMessage()); } }, 1);
$output->writeln('MQTT worker started: ' . $clientId); $mqtt->loop(true);
return self::SUCCESS; } }
|
启动多个 worker:
1 2 3
| php think mqtt:worker php think mqtt:worker php think mqtt:worker
|
生产环境要用 Supervisor、systemd 或容器编排管理 worker,避免进程退出后没人拉起。
Topic 拆分建议
不要让一个订阅处理所有消息。
不推荐:
更推荐:
1 2 3 4
| device/+/telemetry device/+/event device/+/status device/+/command_reply
|
不同类型消息可以进入不同处理链路:
QoS 和离线消息
QoS 不是越高越好。
对于离线设备:
- 不要给高频遥测长期保留离线队列。
- 指令消息要设置有效期,过期不要继续执行。
- 持久会话要配合 Session Expiry,不要无限堆积。
- 设备上线后先同步最新状态,不要盲目执行所有旧消息。
如何确认积压在哪里
先按链路分段排查:
1
| 设备发布速度 -> EMQX 接收速度 -> EMQX 转发速度 -> ThinkPHP 接收速度 -> MySQL 写入速度 -> 业务处理速度
|
检查项:
查看当前是否有积压,尽量用简单查询,不要在大表上频繁聚合。
查看前 100 条待处理消息:
1 2 3 4 5
| SELECT id, topic, message_key, created_at FROM mqtt_messages WHERE status = 'pending' ORDER BY id ASC LIMIT 100;
|
查看最老的一条待处理消息:
1 2 3 4 5
| SELECT id, topic, message_key, created_at FROM mqtt_messages WHERE status = 'pending' ORDER BY id ASC LIMIT 1;
|
处理延迟可以拿最老一条的 created_at,在 PHP 或监控系统里计算,不建议在消息大表里频繁执行时间差聚合。
如果确实需要统计积压数量,更推荐在状态变化时维护 Redis 计数或单独指标表,例如:
1 2
| $redis->incr('metric:mqtt:pending'); $redis->decr('metric:mqtt:pending');
|
单独指标表也可以很简单:
1 2 3 4
| SELECT metric_key, metric_value FROM mqtt_metrics WHERE metric_key = 'pending_total' LIMIT 1;
|
消息积压后的处理步骤
1. 先止血
- 降低设备上报频率。
- 暂停非关键 topic。
- 临时增加
php think mqtt:worker 数量。
- 暂停耗时业务,只保留入库。
- 对明显过期的遥测数据标记为
ignored。
2. 再定位瓶颈
- 如果 EMQX 积压,检查离线会话、QoS、规则引擎和订阅者。
- 如果 ThinkPHP 接口慢,检查 PHP-FPM、Nginx、应用日志。
- 如果 MySQL 慢,检查索引、慢查询和磁盘 IO。
- 如果业务慢,把业务处理拆成异步任务。
3. 最后优化结构
- 高频数据批量写入。
- 事件数据幂等处理。
- 旧遥测数据过期丢弃。
- 不同 topic 分不同消费组。
- 重要消息和普通消息拆开。
- 用监控记录积压数量和最老消息时间。
常见错误设计
消费回调里直接做复杂业务
错误:
1
| 收到 MQTT -> 调第三方接口 -> 查数据库 -> 发短信 -> 更新统计 -> ACK
|
更稳:
1
| 收到 MQTT -> 幂等入库 -> ACK -> 后台任务慢慢处理
|
所有设备共用一个 Client ID
Client ID 冲突会导致连接互相踢下线,表现为设备频繁断开、消息重复或收不到消息。
每个设备必须使用稳定且唯一的 Client ID。
高频数据用 retained message
Retain 适合保存最后状态,不适合保存每条遥测数据。
高频遥测使用 retained message 可能导致新订阅者收到旧数据,引发误判。
订阅 # 后什么都处理
# 很方便,但生产环境里容易把所有消息都压到一个消费者上。
应该按业务拆 topic,并用共享订阅分散压力。
什么时候要上更强的架构
如果消息量继续增大,建议引入专门的数据通道:
- EMQX 规则引擎转发到 Kafka。
- EMQX 数据桥接到 Redis Stream。
- ThinkPHP 只处理业务事件,高频遥测进入时序数据库。
- 设备遥测进入 InfluxDB,业务事件进入 MySQL。
- 用 Go、Java 或 Node.js 写专门消费服务。
ThinkPHP 8 可以处理不少业务场景,但不适合把所有高频实时流都压在 PHP-FPM 请求里。对于高频、大量、低延迟消息,要把接收、存储、计算和业务动作拆开。
最小落地清单
上线前至少确认:
- 设备消息里有
device_id,并且有 msg_id 或 seq。
- 不用秒级时间戳、
updated_at、数据库自增 id 做消息去重。
- ThinkPHP 入库表有
message_key 唯一索引。
- EMQX topic 按业务拆分,不滥用
#。
- 高频遥测可以丢弃过期消息。
- 事件消息必须幂等处理。
- Webhook 接口只做轻量入库。
- ThinkPHP Command 使用共享订阅扩展消费能力。
- MySQL 查询保持简单,积压计数用 Redis 或单独指标表。
- 失败消息有
attempts、next_run_at、last_error。
- 有监控能看到 pending 数量和最老消息时间。
解决 MQTT 消息积压的关键不是某一个参数,而是整条链路都要能承压:EMQX 负责稳定接收和路由,ThinkPHP 负责快速落库和业务处理,MySQL 负责状态、幂等和可恢复。只要入口快、处理可重试、消息可去重,积压就能被定位和逐步消化。