用SQS+多进程实现机器学习请求熔断与流量整形 1. 项目概述用 SQS 多进程“熔断”机器学习请求洪峰你有没有遇到过这种场景一个刚上线的图像分类 API白天流量平缓每秒 2~3 个请求模型推理稳如老狗可一到晚上八点运营同学突然在社群里发了个裂变海报5 分钟内涌入 800 并发请求——服务器 CPU 瞬间飙到 98%Redis 连接池打满GPU 显存 OOM下游数据库开始报too many connections整个服务像被塞进微波炉的金属勺滋滋冒烟、噼啪跳闸。这不是故障演练这是真实发生在我上一个推荐系统项目里的“熔断现场”。而“Melting ML Requests by Using SQS and Multiprocessing”这个标题说的正是我们后来亲手打造的一套请求消融系统它不靠扩容硬扛也不靠限流丢弃而是把汹涌而来的 ML 请求“倒进一个大漏斗”用 AWS SQS 做缓冲池再用 Python 多进程从池底匀速抽水让模型服务始终运行在最舒适、最经济、最可控的节奏里。核心关键词就三个SQS消息队列、Multiprocessing多进程、ML Requests机器学习请求。它不是给模型提速而是给整个请求生命周期做“节律重置”——适合所有正在被突发流量折磨的算法工程师、MLOps 工程师和后端开发也特别适合那些预算有限、不想为峰值流量常年预留 3 倍冗余资源的中小团队。它解决的从来不是“模型能不能算”而是“系统敢不敢接”。这个方案的本质是把传统 Web 服务中“请求-响应”的强耦合链路硬生生拆成“生产-存储-消费”三段异步流水线。用户发起请求我们不立刻调模型而是快速校验参数、生成唯一 trace_id、序列化 payload然后一把塞进 SQS 队列——这个动作平均耗时 15ms几乎不占后端资源真正吃 CPU 和 GPU 的模型推理则由一组独立的、数量可控的 worker 进程在后台安静、稳定、按自己节奏批量拉取、批处理、写回结果。我试过同一台 c5.4xlarge 实例16 核 32G纯 Flask 同步接口扛不住 120 QPS但换成这套架构后轻松支撑 400 QPS 的稳定吞吐且 P99 延迟从 2.8s 降到 1.1s。它不炫技但极其务实没有引入 Kafka 的运维复杂度没用 Celery 的 broker 依赖更没碰任何需要翻墙配置的第三方服务——整套方案完全基于 AWS 原生服务 标准 Python 库部署一条pip install boto3就能跑起来。接下来我会带你一层层剥开这个“熔断器”的内部结构告诉你为什么选 SQS 而不是 SNS为什么坚持用multiprocessing而非asyncio以及那些在压测时差点让我们通宵改代码的坑到底长什么样。2. 整体架构设计与技术选型逻辑2.1 为什么是“熔断”而不是“限流”或“扩容”先说清楚一个根本问题我们为什么不做简单的 Nginx 限流或者直接 Auto Scaling 组扩容因为这两者都治标不治本。Nginx 限流比如limit_req zoneml burst20 nodelay本质是暴力丢包——当第 21 个请求撞上来用户直接收到 503体验断崖式下跌而你的业务指标比如转化率、留存率会跟着一起跳水。Auto Scaling 更麻烦EC2 实例启动要 90 秒以上容器冷启动也要 30 秒等新实例真正挂载上负载均衡器、通过健康检查、开始承接流量时洪峰早过去了留给你的是 5 分钟空转的账单和一堆没来得及释放的闲置资源。我们算过一笔账某次大促期间为应对预估峰值临时加了 8 台 p3.2xlargeGPU 实例实际只用了 17 分钟但整晚计费按小时算多花了 $126而这些钱足够买 3 年 SQS 的高吞吐配额。所以“熔断”的核心思想不是对抗流量而是驯服流量——把不可控的瞬时脉冲转化为可控的、平滑的、可预测的恒定负载。就像水电站的大坝不是挡住洪水而是把山洪蓄成水库再通过闸门匀速放水发电。2.2 SQS为什么选它做“熔断漏斗”的物理载体在消息队列选型上我们对比了 SQS、KafkaMSK、SNS Lambda 三种方案最终锁死 SQS理由非常具体运维零负担MSK 是全托管 Kafka但你需要管 ZooKeeper、Broker 配置、Topic 分区数、Consumer Group Offset 提交策略……而 SQS 完全无服务端组件。创建队列就是点几下控制台boto3.client(sqs).create_queue(QueueNameml-inference-queue)一行代码搞定。我们的 MLOps 团队只有 2 个人没人力也没意愿去维护一个消息中间件集群。天然幂等与失败重试SQS 的VisibilityTimeout机制是神来之笔。Worker 拉取一条消息后该消息会在队列中“隐形”一段时间比如设为 300 秒。如果 Worker 在这期间崩溃或处理超时消息自动重回队列等待下一个 Worker 拉取。这比自己在 Redis 里实现 ACK 机制、处理网络分区、搞分布式锁要简单 10 倍。我们实测过故意在模型推理中途kill -9进程消息 5 分钟后准时重出且只被成功处理一次。成本极度透明SQS 按请求次数计费$0.40 per 1 million requests不是按带宽或存储。一次SendMessage、一次ReceiveMessage、一次DeleteMessage各算一次请求。我们日均 200 万请求月账单稳定在 $0.85比租一台 t3.micro 实例还便宜。而 MSK 按 Broker 实例小时计费起步就是 $0.19/hour × 3 Broker $137/月还没算存储和数据传输费。与 Python 生态无缝衔接boto3是 AWS 官方 SDK安装即用文档齐全错误码清晰比如AWS.SimpleQueueService.NonExistentQueue表示队列不存在AWS.SimpleQueueService.MessageNotInflight表示消息已删除。不像某些开源 MQPython client 文档残缺报错信息全是ConnectionRefusedError: [Errno 111]查半天才发现是 SASL 认证没配对。提示我们刻意避开了 FIFO 队列。虽然它保证严格顺序但吞吐量上限只有 3000 TPS且必须带MessageGroupId对我们这种无序批处理场景是性能枷锁。标准队列Standard Queue理论无限吞吐且乱序对模型推理完全无影响——每个请求都是独立的谁先谁后不重要。2.3 Multiprocessing为什么不用 asyncio 或 Celery很多人第一反应是“既然是异步处理为啥不用asyncio”答案很现实我们的模型推理是 CPU/GPU 密集型不是 I/O 密集型。asyncio擅长处理大量并发的网络请求比如同时调 1000 个 HTTP API但它无法真正并行执行torch.tensor.matmul()这种计算。Python 的 GIL全局解释器锁会让所有async任务在单个 CPU 核上轮转CPU 利用率永远卡在 100% 单核其他 15 个核干瞪眼。而multiprocessing直接绕过 GIL每个 worker 进程独占一个 CPU 核还能绑定独立的 GPU 设备通过CUDA_VISIBLE_DEVICES环境变量。我们用nvidia-smi监控过asyncio方案下gpu0显存占用 85%但利用率长期低于 10%换成multiprocessing后4 个 worker 各占gpu0~gpu3显存均匀分配GPU 利用率稳定在 65%~78%。至于 Celery它太重了。Celery 需要单独部署一个 brokerRedis/RabbitMQ一个 result backend同样 Redis还要配celery beat做定时任务flower做监控。而我们的目标是“最小可行熔断器”代码要能在 1 小时内写完、测试、上线。最终 worker 脚本只有 127 行核心逻辑就是def worker_process(queue_url, model_path): sqs boto3.client(sqs) model load_model(model_path) # 加载一次复用整个进程生命周期 while True: resp sqs.receive_message( QueueUrlqueue_url, MaxNumberOfMessages10, # 一次拉 10 条提升吞吐 WaitTimeSeconds20, # 长轮询减少空请求 VisibilityTimeout300 ) if Messages not in resp: continue batch [] for msg in resp[Messages]: body json.loads(msg[Body]) batch.append((msg[ReceiptHandle], body)) # 批量推理 results model.infer_batch([item[1][image_b64] for item in batch]) # 批量写回 删除 for (handle, req), res in zip(batch, results): save_result_to_s3(req[request_id], res) sqs.delete_message(QueueUrlqueue_url, ReceiptHandlehandle)没有 broker 配置没有 task decorator没有复杂的序列化协议——干净得像一碗白粥。2.4 架构全景图三层解耦的物理实现整个系统物理上分为三个隔离层彼此只通过 SQS 队列通信接入层API Gateway Lambda / EC2负责接收用户 HTTP 请求做轻量级校验JWT token 解析、参数格式检查、文件大小限制然后json.dumps()序列化请求体调用sqs.send_message()把消息推入队列。关键点这里绝不加载模型绝不做任何耗时计算。我们用 API Gateway Lambda 组合Lambda 函数内存设为 512MB超时 30 秒99% 的请求在 12ms 内完成入队P99 延迟 25ms。缓冲层SQS Standard Queue作为唯一的共享状态中心。我们设置了两个关键参数MessageRetentionPeriod120960014 天确保极端情况下消息不会丢失DelaySeconds0消息入队即可见不延迟。队列策略Queue Policy严格限制只有接入层和 worker 层的 IAM Role 有sqs:ReceiveMessage和sqs:DeleteMessage权限杜绝越权读写。计算层EC2 Auto Scaling Group一组固定数量的 worker 实例我们设为 4 台 c5.4xlarge每台启动时自动拉起 4 个multiprocessing.Process对应 4 个 CPU 核每个进程加载一个模型实例。Worker 进程通过supervisord管理崩溃后自动重启。它们只做一件事从 SQS 拉消息 → 批量推理 → 写结果 → 删消息。结果不写回队列避免循环而是直传 S3s3://ml-results/{request_id}.json再通过 SNS 或 WebSocket 推送给前端。这三层之间没有网络直连没有共享内存没有状态同步。接入层崩了队列里的消息还在计算层全挂了新请求照常入队甚至 SQS 本身出问题概率极低我们还有 CloudWatch Alarm 自动触发 Lambda 把消息备份到 S3。这种松耦合才是高可用的底层逻辑。3. 核心细节解析与实操要点3.1 SQS 队列的精细化配置不只是创建那么简单很多人以为create_queue之后就能用了其实 SQS 的魔鬼藏在细节里。我们踩过三个深坑现在都固化成了 SOPVisibilityTimeout 必须大于最大处理时间这是最致命的配置。我们最初设为 60 秒但某次模型更新后单张高清图推理耗时涨到 72 秒导致消息在 60 秒后自动重出被另一个 worker 重复处理用户收到两条一模一样的结果邮件。解决方案在 worker 启动时先用一张典型样本做timeit测试得出max_inference_time然后设VisibilityTimeout max_inference_time * 2留足 buffer。我们现在的脚本里有一段硬编码# 预热测试确保 VisibilityTimeout 设置合理 test_img load_test_image() start time.time() _ model.infer(test_img) max_time time.time() - start visibility_timeout int(max_time * 2.5) # 乘以 2.5防抖动最终我们队列的VisibilityTimeout设为 300 秒5 分钟覆盖了 99.9% 的请求。ReceiveMessage 的 BatchSize 与 WaitTimeSeconds 黄金组合MaxNumberOfMessages不是越大越好。设为 10一次拉 10 条但如果其中 1 条处理失败整批 10 条都要重试SQS 不支持部分删除。我们实测发现MaxNumberOfMessages5WaitTimeSeconds20是最佳平衡点20 秒长轮询保证低空请求率5 条一批既提升吞吐减少网络往返又控制失败成本。压测数据显示相比BatchSize1吞吐量提升 3.2 倍相比BatchSize10失败重试量下降 68%。Dead-Letter QueueDLQ是最后一道保险必须配我们创建了一个名为ml-inference-dlq的死信队列主队列的RedrivePolicy设为{ deadLetterTargetArn: arn:aws:sqs:us-east-1:123456789012:ml-inference-dlq, maxReceiveCount: 3 }意思是同一条消息被拉取 3 次都处理失败比如 JSON 解析异常、S3 写入超时就自动移入 DLQ不再重试。DLQ 我们配了 CloudWatch Events 规则一旦有消息进入立刻触发 Lambda 发 Slack 告警并把消息内容存入 DynamoDB 供人工排查。上线三个月DLQ 共收到 17 条消息全是上游传了非法 base64 字符串导致的binascii.Error没有一次是模型或基础设施问题。3.2 Multiprocessing Worker 的健壮性设计一个裸的Process很容易变成“僵尸进程”。我们给 worker 加了四层防护进程级心跳与优雅退出每个 worker 进程启动时会向本地/tmp/worker_heartbeat_pid.json写入当前时间戳和 PID。一个独立的health_monitor.py脚本每 30 秒扫描一次/tmp/worker_heartbeat_*如果发现某个 PID 的时间戳超过 120 秒未更新就判定进程僵死kill -15发送 SIGTERM。worker 主循环里捕获signal.SIGTERM先清空本地缓存、关闭模型句柄再sys.exit(0)。这样避免了kill -9导致的 GPU 显存泄漏。模型加载的“进程单例”模式torch.load()和tf.keras.models.load_model()都很重。如果每个Process启动时都重新加载4 个进程就要加载 4 次浪费 12 秒启动时间且显存重复占用。我们的解法是在if __name__ __main__:下先用model load_model()加载一次然后用multiprocessing.set_start_method(spawn)再Process(targetworker_func, args(model,))把模型对象传进去。注意必须用spawn方法fork会复制整个父进程内存包括已加载的模型反而更耗资源。GPU 设备的硬绑定4 台 worker 服务器每台有 1 张 V100。如果不指定4 个进程会争抢cuda:0导致显存 OOM。我们在启动每个进程前设置环境变量import os os.environ[CUDA_VISIBLE_DEVICES] str(gpu_id) # gpu_id 从 0 到 3 循环这样进程 0 只能看到cuda:0进程 1 只能看到cuda:1彻底隔离。内存泄漏的主动防御PyTorch 的torch.cuda.empty_cache()不是万能的。我们观察到长时间运行后nvidia-smi显示显存占用缓慢上涨。根源是 Python 的gc没及时回收 tensor。解决方案在每次 batch 推理后强制触发import gc import torch # ... 推理完成后 del outputs, inputs torch.cuda.empty_cache() gc.collect()3.3 请求体的设计哲学小而精拒绝“大包裹”很多人把整个 HTTP request body 原样塞进 SQS这是大忌。SQS 单条消息最大 256KB而一张 1080p 图片 base64 编码后轻松破 2MB。我们的请求体message body严格遵循“三原则”只存元数据不存原始数据用户上传的图片、音频、文本全部先存到 S3得到一个s3://bucket/key的 URI。SQS 消息里只存这个 URI、用户 ID、请求时间戳、trace_id。例如{ request_id: req_abc123, s3_uri: s3://ml-uploads/2023/10/25/abc123.jpg, user_id: usr_xyz789, timestamp: 2023-10-25T14:30:00Z }这样单条消息 1KBSQS 吞吐无压力。预签名 URL 替代直接访问worker 从 S3 读文件不能用长期有效的 Access Key安全风险。我们让接入层生成 1 小时有效期的预签名 URLs3_client.generate_presigned_url( get_object, Params{Bucket: ml-uploads, Key: key}, ExpiresIn3600 )消息体里存这个 URLworker 直接requests.get(url)下载无需任何 AWS 凭据。结果回传的异步通知机制用户提交请求后API 立即返回{status: accepted, request_id: req_abc123}不等结果。结果写入 S3 后我们用 S3 Event Notification 触发 LambdaLambda 查数据库拿到用户注册的 WebSocket 连接 ID或邮箱推送{request_id: req_abc123, result: {...}}。这样用户侧可以做轮询或长连接体验丝滑。4. 实操过程与核心环节实现4.1 从零搭建5 分钟部署一个可运行的熔断器下面是你能直接复制粘贴、5 分钟跑起来的最小可行版本。假设你已有 AWS 账号和aws-cli配置好第一步创建 SQS 队列# 创建主队列 aws sqs create-queue --queue-name ml-inference-queue \ --attributes {VisibilityTimeout:300,MessageRetentionPeriod:1209600} # 获取队列 URL记下来后面要用 QUEUE_URL$(aws sqs get-queue-url --queue-name ml-inference-queue --query QueueUrl --output text) # 创建死信队列 aws sqs create-queue --queue-name ml-inference-dlq # 获取 DLQ URL DLQ_URL$(aws sqs get-queue-url --queue-name ml-inference-dlq --query QueueUrl --output text) # 绑定 DLQ 到主队列 aws sqs set-queue-attributes \ --queue-url $QUEUE_URL \ --attributes {\RedrivePolicy\:\{\\\deadLetterTargetArn\\\:\\\$(aws sqs get-queue-attributes --queue-url $DLQ_URL --attribute-names QueueArn --query Attributes.QueueArn --output text)\\\,\\\maxReceiveCount\\\:\\\3\\\}\}第二步编写接入层Lambda 函数创建文件api_handler.pyimport json import boto3 import uuid from datetime import datetime sqs boto3.client(sqs) def lambda_handler(event, context): try: # 解析 bodyAPI Gateway 代理集成 body json.loads(event[body]) # 校验必要字段 if s3_uri not in body: return {statusCode: 400, body: json.dumps({error: missing s3_uri})} # 构造消息体 message_body { request_id: str(uuid.uuid4()), s3_uri: body[s3_uri], user_id: body.get(user_id, anonymous), timestamp: datetime.utcnow().isoformat() Z } # 发送到 SQS sqs.send_message( QueueUrlhttps://sqs.us-east-1.amazonaws.com/123456789012/ml-inference-queue, MessageBodyjson.dumps(message_body) ) return { statusCode: 202, body: json.dumps({ status: accepted, request_id: message_body[request_id] }) } except Exception as e: return {statusCode: 500, body: json.dumps({error: str(e)})}部署命令zip function.zip api_handler.py aws lambda create-function \ --function-name ml-api-handler \ --runtime python3.9 \ --role arn:aws:iam::123456789012:role/lambda-sqs-execution-role \ --handler api_handler.lambda_handler \ --zip-file fileb://function.zip \ --timeout 30 \ --memory-size 512第三步编写 Worker 脚本创建文件worker.pyimport boto3 import json import time import os import signal import sys import torch from pathlib import Path # 全局变量用于优雅退出 shutdown_flag False def signal_handler(signum, frame): global shutdown_flag print(fReceived signal {signum}, shutting down...) shutdown_flag True signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) def load_model(): # 这里替换成你的模型加载逻辑 # 例如return torch.jit.load(model.pt) return lambda x: {prediction: cat, confidence: 0.95} def worker_process(queue_url, model): sqs boto3.client(sqs, region_nameus-east-1) while not shutdown_flag: try: # 拉取消息 response sqs.receive_message( QueueUrlqueue_url, MaxNumberOfMessages5, WaitTimeSeconds20, VisibilityTimeout300, MessageAttributeNames[All] ) if Messages not in response: continue messages response[Messages] batch_requests [] receipt_handles [] for msg in messages: body json.loads(msg[Body]) batch_requests.append(body) receipt_handles.append(msg[ReceiptHandle]) # 批量推理此处简化为模拟 results [] for req in batch_requests: # 实际这里调用 model.infer(req[s3_uri]) result model(req[s3_uri]) results.append(result) # 写入 S3 结果此处简化为打印 for req, res in zip(batch_requests, results): print(fProcessed {req[request_id]}: {res}) # 删除已处理消息 for handle in receipt_handles: sqs.delete_message(QueueUrlqueue_url, ReceiptHandlehandle) except Exception as e: print(fWorker error: {e}) time.sleep(1) # 防止疯狂报错 print(Worker process exiting gracefully) if __name__ __main__: QUEUE_URL https://sqs.us-east-1.amazonaws.com/123456789012/ml-inference-queue model load_model() # 启动 4 个进程 from multiprocessing import Process processes [] for i in range(4): p Process(targetworker_process, args(QUEUE_URL, model)) p.start() processes.append(p) # 等待所有进程 for p in processes: p.join()第四步启动 Worker在 EC2 实例上运行pip install boto3 torch python worker.py现在你已经拥有了一个可运行的“熔断器”。用 curl 测试curl -X POST https://your-api-gateway-id.execute-api.us-east-1.amazonaws.com/prod/infer \ -H Content-Type: application/json \ -d {s3_uri: s3://ml-uploads/test.jpg}你会立刻收到202 Accepted然后在 worker 控制台看到处理日志。4.2 性能调优从 100 QPS 到 800 QPS 的实测记录我们用locust做了三轮压测参数和结果如下表压测轮次Worker 数量每 Worker 进程数SQS BatchSize平均延迟 (P99)稳定吞吐 (QPS)错误率第一轮2212.1s1050.2%第二轮4451.3s4200.03%第三轮445 GPU 绑定0.95s7800.00%关键调优点BatchSize 从 1 到 5网络 I/O 减少 80%ReceiveMessage请求次数从 420 次/秒降到 84 次/秒SQS 账单直降。GPU 绑定后nvidia-smi显示 4 张 GPU 利用率从 35% 均匀拉升到 72%显存占用从 12GB/卡降到 9.2GB/卡说明碎片化减少。增加WaitTimeSeconds20空轮询ReceiveMessage返回空从每秒 15 次降到 0.3 次进一步节省 SQS 请求费用。我们还发现一个反直觉现象增加 Worker 进程数到 8 个吞吐反而下降到 650 QPS。原因是 CPU 核心争抢加剧torch的 CUDA kernel 启动延迟上升。最终我们锁定 4 进程/4 核是最优解。4.3 监控告警让系统自己说话没有监控的熔断器就像没有仪表盘的飞机。我们用 CloudWatch 搭建了三层监控SQS 层监控核心指标ApproximateNumberOfMessagesVisible队列积压数。我们设了两个告警Critical: 1000 条持续 5 分钟 → 触发 Slack 告警提示“队列严重积压检查 Worker 是否宕机”Warning: 200 条持续 15 分钟 → 发邮件提示“流量高于预期关注 GPU 利用率”Worker 层监控每个 worker 进程上报自定义指标MLWorker_Uptime_Seconds进程存活秒数和MLWorker_Batch_Latency_Ms单次 batch 处理毫秒数。用aws cloudwatch put-metric-data每 60 秒上报一次。如果Uptime_Seconds突然归零说明进程崩溃立即触发 Lambda 重启。模型层监控在worker_process里埋点统计model.infer()的time.time()差值上报为Model_Inference_Latency。当 P95 800ms自动触发 Lambda从 S3 下载最近 10 个请求的样本用torch.profiler分析热点生成报告存入 S3。这套监控上线后我们第一次在凌晨 2 点收到告警登录一看是某张图片分辨率超高12000x8000导致单次推理卡住 4 分钟触发了 DLQ。我们立刻从 DLQ 里捞出消息手动修复后重发全程 8 分钟用户无感知。5. 常见问题与排查技巧实录5.1 “消息重复消费”不是 Bug是 SQS 的 Feature这是新人最常 panic 的问题。日志里看到Processed req_abc123: {...}出现两次马上以为系统坏了。其实这是 SQS 的“至少一次交付”At-Least-Once Delivery特性在起作用。原因只有两个VisibilityTimeout设置过短或 worker 进程在delete_message前崩溃。排查步骤查 CloudWatch Logs看两次日志的时间戳间隔是否 ≈VisibilityTimeout比如都是隔 300 秒出现→ 确认是超时重出。查nvidia-smi看对应时间点 GPU 利用率是否突降至 0 → 确认是进程崩溃。查/var/log/supervisor/worker-stderr.log找Segmentation fault或CUDA out of memory→ 定位崩溃原因。终极解决方案在结果写入 S3 时用s3.put_object的If-None-Match: *参数确保只写入一次try: s3.put_object( Bucketml-results, Keyf{req_id}.json, Bodyjson.dumps(result), IfNoneMatch* # 仅当对象不存在时写入 ) except ClientError as e: if e.response[Error][Code] PreconditionFailed: print(fResult for {req_id} already exists, skipping) else: raise5.2 “Worker 启动慢CPU 100% 卡住”现象python worker.py执行后top 看 CPU 100%但日志没输出nvidia-smi显存不动。99% 是模型加载卡在torch.load()。根因分析PyTorch 模型文件.pt如果是在 Windows 上保存的可能包含\r\n换行符Linux 下torch.load()会尝试 mmap 整个文件而大模型文件1GBmmap 失败陷入无限重试。实操解法在模型保存端训练脚本强制用 Unix 换行torch.save(model.state_dict(), model.pt, _use_new_zipfile_serializationTrue)或在 worker 端用torch.load(..., map_locationcpu)先加载到 CPU再model.to(cuda)。我们还发现torch.jit.script()编译后的模型加载速度比torch.load()快 3.7 倍且无此问题强烈推荐。5.3 “SQS 队列突然收不到消息”某天下午接入层日志显示send_message成功但 worker 日志一片寂静。ApproximateNumberOfMessagesVisible一直为 0。排查路径aws sqs get-queue-attributes --queue-url $QUEUE_URL --attribute-names All→ 看LastModifiedTimestamp是否更新 → 如果没更新说明消息根本没发出去。检查 Lambda 的 Execution Role 权限 → 我们发现 IAM Policy 里漏写了sqs:SendMessage只写了sqs:ReceiveMessage。检查 VPC Endpoint → 如果 Lambda 在 VPC 内必须配com.amazonaws.us-east-1.sqs