最近接了个看起来很普通的活儿把一批客户数据通过短信网关发出去。刚开始我真没当回事想着不就是个for循环一条条调接口嘛。结果第一次全量跑一万条短信硬生生跑了二十多分钟业务方直接把电话打到我这来了。后来把发送逻辑改成 Go 协程并发同样的数据量压到了两三分钟接口响应也稳定了。这篇文章就把这个改造过程里踩过的坑、对比过的方案和最终沉淀下来的并发发送架子完整写出来给同样做短信接口对接的同学一个参考。这个项目本质上就是一个典型的Go 高并发外部 HTTP 接口调用场景上游有一批手机号和模板参数下游是一个第三方短信服务商中间要解决并发度控制、限流、重试、幂等和错误汇总一堆问题。和纯内部服务的高并发不太一样短信接口通常是外部系统有 QPS 限制、有网络延迟、有鉴权签名稍微不注意就把网关打爆了或者漏发一片。下面按我实际动手的顺序来拆。1. 项目思路与瓶颈拆解1.1 短信批量发送到底慢在哪先算一笔账。假设你对接的短信服务商接口单次调用的耗时是 150ms——这个数字很典型包含了你的服务器到短信网关的往返网络开销以及网关处理、下发、回执的时间。如果你的代码是串行循环for _, req : range reqs { err : sendOne(req) if err ! nil { log.Printf(send %s failed: %v, req.Phone, err) } time.Sleep(10 * time.Millisecond) // 怕被限流手动加个间隔 }1000 条短信就是 1000 × (150ms 10ms) 160 秒一万条就是 1600 秒接近 27 分钟。这个延迟完全不是业务逻辑消耗的全是网络往返等待。搞清楚这一点优化方向就清楚了网络请求是 IO 密集型操作单条短信本身的 CPU 开销可以忽略不计瓶颈在等待延迟。并发能把多条请求的等待时间重叠起来。我在项目里粗测过正常云厂商短信接口的响应时间一般在 80~300ms 之间有的平台还会走到运营商网关响应更慢。不管怎样这个量级意味着串行发送一万条没有任何优化空间等待时间线性增长。而如果并发控制在 20 左右理论上可以缩短接近 20 倍。1.2 为什么用 Go 协程而不是多线程选 Go 不是因为它时髦而是这个场景下协程确实是顺手工具。goroutine 非常轻量初始栈只有几 KB开几千个并发开不了几个线程时的内存占用协程能抗住。channel 的语义和这个场景天然匹配。生产者把手机号、模板参数塞进 channel多个 worker 从 channel 消费天然就是标准的生产者-消费者模型。sync.WaitGroup和errgroup可以干净地收集并发结果不会像多线程回调那样把状态散得到处都是。我见过同事用 Java 线程池做同样的事也不是不行但代码里多线程共享状态、拆任务、合并结果写起来比 Go 的 channel goroutine 繁琐不少。1.3 整体架构扇出 汇聚模型这个项目我用了最经典也最实用的并发模型叫扇出-汇聚扇出把待发送的短信请求列表作为任务源派发给多个 worker 协程去执行。汇聚worker 执行完成之后把发送结果成功/失败/错误原因汇总到结果通道里主流程统一统计。一句话版任务列表 - channel - N 个 worker 并发发送 - 结果聚合 - 输出报告。给你画个文字版的流程短信任务列表 | v 任务 Channel (带缓冲缓冲大小 任务数或固定值) | v Worker 1 Worker 2 Worker 3 ... Worker N \ | | / \ | | / v v v v 结果汇总 | v 成功/失败统计失败重试队列这个模型的优点在于并发度可控。想加快速度调大 worker 数量想降低对短信网关的压力调小并发数即可业务代码不用动。我用这个结构跑了几轮压测整体稳定没有出现过协程泄漏或者结果丢数据的情况。2. 核心细节解析与接口对接要点2.1 短信 HTTP 接口的通用对接姿势先明确一下短信接口长什么样。市面上的短信服务商无论是云厂商还是运营商的平台基本都是 HTTP 接口常见形式是POST https://sms.example.com/api/v1/send Content-Type: application/x-www-form-urlencoded参数一般包括参数名示例值说明mobile13800138000接收手机号一次一个或批量template_idSMS_123456短信模板 ID一般需要提前审核template_param{code:123456}模板变量占位符填充sign_name某某公司已备案的短信签名timestamp20250101120000时间戳一般用于签名signature64位十六进制串用密钥对参数做签名生成的摘要对 Go 来说请求构造并不难最坑的是签名算法。不同的服务商规则不一样有的把所有参数按字典序拼接然后 MD5有的用 HMAC-SHA256有的还要加上随机数 nonce。我遇到的这家是参数名按字典序排序 keyvalue 拼接 末尾拼上 appSecret MD5。核心封装长这样// BuildSign 生成短信接口签名 // 规则所有请求参数按字典序排序拼接成 k1v1k2v2...在末尾追加 keysecret做 MD5 func BuildSign(params map[string]string, secret string) string { keys : make([]string, 0, len(params)) for k : range params { keys append(keys, k) } sort.Strings(keys) var sb strings.Builder for _, k : range keys { sb.WriteString(k) sb.WriteByte() sb.WriteString(params[k]) sb.WriteByte() } sb.WriteString(key) sb.WriteString(secret) digest : md5.Sum([]byte(sb.String())) return hex.EncodeToString(digest[:]) }这里有个细节必须提醒参数拼接的时候一定要排除签名本身和那些 URL 编码后的特殊字符不然服务端拿到的字段值跟你本地签名时不一致签名永远校验不过。我之前接过一个平台它的模板参数是个 JSON 字符串我一开始没转义就拼进去了排错排了两个小时。2.2 HTTP 客户端配置与连接复用短信发送是短连接还是长连接看你怎么配置http.Client。很多同学直接用全局http.DefaultClient这是个大坑——默认 client 没有设置超时一旦短信网关卡住协程会全部挂在那里等响应最后雪崩。我在这个项目里的 HTTP 客户端配置如下var smsHTTPClient http.Client{ Timeout: 5 * time.Second, Transport: http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 100, IdleConnTimeout: 90 * time.Second, MaxConnsPerHost: 100, TLSHandshakeTimeout: 3 * time.Second, }, }重点解释三个参数Timeout是整个请求的总超时包含连接、发送、等待响应整个过程。我设 5 秒短信网关正常情况下 300ms 内能返回超过 5 秒基本可以断定这条请求失败了无需继续等。MaxIdleConnsPerHost控制到同一域名保持的空闲连接数。并发发送时如果每次请求都新建 TCP 连接会发现大量 TIME_WAIT 状态连接建立本身也要几十毫秒严重影响吞吐。调大这个值让连接复用。MaxConnsPerHost限制对同一主机的最大连接数防止把下游网关连接数打爆。还有一个点请求 body 一定要 close。Go 官方文档明确建议读取完响应立即resp.Body.Close()否则连接无法归还到连接池。我在代码里用defer resp.Body.Close()并且哪怕不看响应内容也要调用一次io.Copy(io.Discard, resp.Body)把 body 读完这样连接才能真正复用。这个看似不起眼的点在并发场景下的吞吐差异非常明显。2.3 批量任务的数据准备与状态流转发送之前数据准备环节有几个容易忽略的事手机号去重和格式清洗。实际业务里经常出现同一个手机号出现在多个名单里的情况要决定是只发一次还是分别处理。我用一个map[string]struct{}在内存里去重同时把 86 前缀、空格、横线全部规整掉。排除退订/黑名单用户。短信服务商一般会在接口层面拦截黑名单号码但更稳妥的做法是在业务侧先把明显不该收短信的号码过滤掉。我写了个简单的黑名单查询作为前置过滤器。任务状态字段必须落地。不能用内存里的变量记录任务进度因为程序重启就没了。我在 MySQL 里建了发送批次表和明细表CREATE TABLE sms_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_name VARCHAR(100) NOT NULL, total_count INT NOT NULL, success_count INT DEFAULT 0, fail_count INT DEFAULT 0, status TINYINT DEFAULT 0 COMMENT 0待执行 1执行中 2完成 3部分失败, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sms_task_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL, phone VARCHAR(20) NOT NULL, template_id VARCHAR(50) NOT NULL, template_param VARCHAR(500), biz_id VARCHAR(64) NOT NULL COMMENT 业务幂等ID, status TINYINT DEFAULT 0 COMMENT 0待发送 1成功 2失败, error_msg VARCHAR(255), retry_count INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_id (biz_id) );这两张表是后面做幂等、重试、对账的基石。一开始我以为可以靠日志记录后来发现一旦发送失败要重新捞数据补发没有明细表简直寸步难行。3. 实操过程从串行到协程批量发送3.1 步骤一串行发送的 demo 与真实差距先给个最朴素的串行实现用来做基准测试。发送单条的代码长这样type SmsRequest struct { Phone string TemplateID string Params map[string]string BizID string } func sendOne(req *SmsRequest) error { params : map[string]string{ mobile: req.Phone, template_id: req.TemplateID, template_param: mustJSON(req.Params), timestamp: time.Now().Format(20060102150405), } params[signature] BuildSign(params, secret) form : url.Values{} for k, v : range params { form.Set(k, v) } httpReq, _ : http.NewRequest(POST, smsAPIURL, strings.NewReader(form.Encode())) httpReq.Header.Set(Content-Type, application/x-www-form-urlencoded) resp, err : smsHTTPClient.Do(httpReq) if err ! nil { return fmt.Errorf(post sms api: %w, err) } defer resp.Body.Close() body, _ : io.ReadAll(resp.Body) if resp.StatusCode ! http.StatusOK { return fmt.Errorf(unexpected status %d, body%s, resp.StatusCode, string(body)) } // 解析服务端返回的 JSON判断 code 是否成功 // ... return nil }注意里面mustJSON只是把 map 转成 JSON 字符串返回 error 的场景省略了。发送方返回的 JSON 一般长这样{ code: 0, message: OK, requestId: 123456789 }code 0才算成功。这个判断必须写对因为有些服务商在 HTTP 200 时返回业务失败 code只检查StatusCode会漏掉。串行主流程func RunSerial(reqs []*SmsRequest) { for _, req : range reqs { if err : sendOne(req); err ! nil { log.Printf([短信串行] phone%s biz_id%s err%v, req.Phone, req.BizID, err) } else { log.Printf([短信串行] phone%s 发送成功, req.Phone) } time.Sleep(10 * time.Millisecond) } }我在测试环境拿 1000 条数据跑单条约 120~150ms串行总耗时约 150 秒。这个 150 秒就是后面所有优对比的基准线。3.2 步骤二无节制并发与信号量控制接下来很自然地会这么改func RunNaiveConcurrent(reqs []*SmsRequest) { var wg sync.WaitGroup for _, req : range reqs { wg.Add(1) go func(r *SmsRequest) { defer wg.Done() if err : sendOne(r); err ! nil { log.Printf([短信并发] phone%s err%v, r.Phone, err) } }(req) } wg.Wait() }这段代码在我第一次写的时候也踩过坑。Go 1.22 之前的版本循环变量复用go func() { ... }(req)必须在循环体内传参否则 goroutine 读到的可能是最后一条。哪怕团队都用 Go 1.22 了我仍然习惯显式传参这个好习惯能让代码一点歧义都没有。老实说这段代码跑起来确实快1000 条瞬间就发完了但代价也立竿见影1 万条任务就开 1 万个 goroutine虽然 goroutine 轻量但全部同时发出去短信网关直接被轰成 429Too Many Requests大量请求被限流。网关限流导致大量超时、重试整体可用性反而下降。没有重试逻辑失败的直接丢弃日志刷屏压根不知道哪些失败了。所以我加了信号量控制最大并发数。Go 里最简单的信号量就是一个带缓冲的 channelfunc RunLimitedConcurrent(reqs []*SmsRequest, limit int) { sem : make(chan struct{}, limit) var wg sync.WaitGroup for i : range reqs { wg.Add(1) sem - struct{}{} // 没有空位时阻塞等待 go func(r *SmsRequest) { defer wg.Done() defer func() { -sem }() // 释放空位 if err : sendOne(r); err ! nil { log.Printf([受限并发] phone%s err%v, r.Phone, err) } }(reqs[i]) } wg.Wait() close(sem) }注意sem - struct{}{}写在go func()外面这样可以在主协程里就完成限流排队而不是让 goroutine 全部创建出来再各自抢信号量避免无谓的资源占用。defer func() { -sem }()必须放在defer wg.Done()之后否则释放顺序会乱。这个版本同样跑 1000 条limit20 时耗时从 150 秒降到了约 15 秒快了接近十倍。而且下游没有出现限流错误。3.3 步骤三errgroup channel 的完整实现信号量方案能解决并发数量问题但错误处理还是太原始——只打日志无法知道整体有多少成功、多少失败、哪些需要重发。要把结果汇聚回来我引入了errgroup再从结果 channel 收集发送结果import ( golang.org/x/sync/errgroup ) type SendResult struct { Req *SmsRequest Err error } func RunBatchSend(reqs []*SmsRequest, limit int) (success int, fail []SendResult, err error) { ctx, cancel : context.WithCancel(context.Background()) defer cancel() g, ctx : errgroup.WithContext(ctx) sem : make(chan struct{}, limit) resultCh : make(chan SendResult, len(reqs)) for _, req : range reqs { req : req g.Go(func() error { // 并发限制 select { case sem - struct{}{}: defer func() { -sem }() case -ctx.Done(): return ctx.Err() } err : sendOne(req) resultCh - SendResult{Req: req, Err: err} return nil // 这里不返回 err避免某个发送失败导致整个 errgroup 取消 }) } if err : g.Wait(); err ! nil { return 0, nil, err } close(resultCh) var failList []SendResult for r : range resultCh { if r.Err ! nil { failList append(failList, r) } else { success } } return success, failList, nil }这个版本的几个设计决策解释一下worker 内不把发送错误返回给 errgroup而是放进 resultCh。因为短信发送失败一两条是常态不能让一条失败把整个批次取消掉。errgroup 的取消机制更适合遇到不可恢复错误就整体终止的场景。resultCh的缓冲设成len(reqs)这样 worker 发送结果不需要在发送完成时同步等待接收方读取Goroutine 不会因为写 channel 阻塞。ctx的传播主要在重试和超时处使用比如单个 worker 卡在 HTTP 调用上外面的取消信号能传递进来。完整的批量发送主流程变成func main() { reqs : loadReqsFromDB(10000) successCnt, failList, err : RunBatchSend(reqs, 20) if err ! nil { log.Fatalf(批量发送异常: %v, err) } log.Printf(发送完成: 成功%d 失败%d, successCnt, len(failList)) // 把 failList 里的条目落库进入重试流程 }这一版代码到线上跑了半个月1 万条左右的批次基本能在 2~3 分钟内完成且没有出现过大面积失败。协程方案在这里成立的核心原因就是网络等待时间可以被并行掩盖这是 IO 密集型任务的通用规律。4. 可靠性设计限流、重试与幂等4.1 短信平台的限制与令牌桶限流即便我们做了并发限制很多短信平台的真实瓶颈是 QPS每秒请求数而不是并发连接数。比如平台文档写着单账号 QPS 上限 50那你并发调到 100 也没用网关照样返回限流错误。正确做法是在发送层加一个令牌桶限流器让请求按恒定速率流出。Go 官方扩展库golang.org/x/time/rate很好用import golang.org/x/time/rate func runWithRateLimit(reqs []*SmsRequest, workers int, qps int) { limiter : rate.NewLimiter(rate.Limit(qps), 1) // 每秒最多 qps 个burst1 var wg sync.WaitGroup sem : make(chan struct{}, workers) for _, req : range reqs { wg.Add(1) sem - struct{}{} go func(r *SmsRequest) { defer wg.Done() defer func() { -sem }() // 先等待令牌超时则放弃 ctx, cancel : context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err : limiter.Wait(ctx); err ! nil { log.Printf([限流] phone%s 等待令牌超时跳过: %v, r.Phone, err) return } if err : sendOne(r); err ! nil { log.Printf([限流] phone%s err%v, r.Phone, err) } }(req) } wg.Wait() }这里burst1表示令牌桶容量为 1严格按每秒固定速度发放令牌。如果你的平台允许瞬时突发可以把 burst 调大一些比如 burst5让流量略有弹性。我实测下来严格控制 QPS 后429 基本绝迹发送成功率升到 99.5% 以上。4.2 重试策略指数退避与 jitter短信发送这种外部依赖失败是常态而重试几乎是必须的。但重试必须讲究策略否则就是给网关火上浇油。先明确哪些错误需要重试网络层错误连接超时、读超时、TLS 握手失败可重试。HTTP 5xx网关内部错误可重试。HTTP 429限流可重试但应该先等待更长时间。HTTP 4xx签名错误、模板不存在、参数错误不可重试重试一万次也不会成功反而污染日志。我的重试代码func sendWithRetry(req *SmsRequest, maxRetry int) error { var interval 200 * time.Millisecond for attempt : 0; attempt maxRetry; attempt { err : sendOne(req) if err nil { return nil } if !isRetryable(err) { return err } if attempt maxRetry { return fmt.Errorf(达到最大重试次数: %w, err) } // 指数退避 jitter 随机抖动 sleep : interval time.Duration(rand.Intn(50))*time.Millisecond log.Printf(phone%s 第 %d 次重试等待 %v原因: %v, req.Phone, attempt1, sleep, err) time.Sleep(sleep) interval * 2 if interval 3*time.Second { interval 3 * time.Second } } return nil } func isRetryable(err error) bool { if errors.Is(err, context.DeadlineExceeded) { return true } // 通过包装的结构体判断 HTTP 状态码 var httpErr *HTTPStatusError if errors.As(err, httpErr) { return httpErr.Code 500 || httpErr.Code http.StatusTooManyRequests } return true }关于 jitter抖动值的补充为什么重试间隔要加随机数原因很简单——如果一万条失败请求同时进入重试流程且重试间隔完全一样那它们会再次同时打向网关造成新一轮的惊群。加 0~50ms 的随机抖动可以把流量打散。指数退避的初始值200ms是我压测之后选的太短50ms基本等于没有退避太重试1s让整体补发时间拖得太长。上限 3 秒保证单条最终失败的可等待时间可控。4.3 幂等控制防止重复发送短信不仅怕漏发更怕重复发。漏发了用户收不到通知重复发了用户被骚扰客服投诉直接过来。重复发送最常见的两个场景程序发送过程中宕机重启发到一半的任务重新跑。重试时网关已收到请求但响应超时程序员又发了一次。解决思路是引入业务幂等 ID。我在上一节的表结构中已经加了biz_id发送之前生成一个唯一值可以用taskID phone做 MD5或者直接用 UUID并在数据库里建立唯一索引。发送前先执行 INSERT如果因为唯一键冲突插入失败说明这条短信已经处理过了直接跳过func tryAcquireBizID(bizID string) (bool, error) { _, err : db.Exec( INSERT INTO sms_task_item (task_id, phone, biz_id, status) VALUES (?, ?, ?, 0) ON DUPLICATE KEY UPDATE id id, taskID, bizID, ) if err ! nil { // 唯一索引冲突说明已经存在 if strings.Contains(err.Error(), 1062) { return false, nil } return false, err } return true, nil }写操作和发送操作拆开func sendWithIdempotence(req *SmsRequest) error { claimed, err : tryAcquireBizID(req.BizID) if err ! nil { return err } if !claimed { log.Printf(phone%s 已处理过跳过, req.Phone) return nil } err sendWithRetry(req, 3) if err ! nil { updateBizStatus(req.BizID, statusFailed, err.Error()) return err } updateBizStatus(req.BizID, statusSuccess, ) return nil }这个先插库再发送的模式核心在于利用数据库的唯一约束做分布式锁/去重。数据库层面可能没有唯一索引的读者可以想象成抢座座位只有一个先到先得后来的人一看座位没了就知道自己来晚了。另外在业务侧还可以设置一个同一手机号 5 分钟内只能发一条相同模板短信的缓存比如 RedisSETNX。短信平台自己也可能有频控但业务侧主动控制能省下不少被拒的请求量。5. 常见问题与排查技巧实录这部分是实战中真正消耗时间的部分整理几个高频问题和你可能会用到的排查手段。5.1 漏发与部分失败症状跑完任务发现统计数据比预期少了几十条。排查步骤先看数据库sms_task_item里status2的失败记录优先补发这些。再看失败记录的错误信息如果全部是限流说明并发调用太过激如果只有零星几条超时一般是网络抖动重试即可。如果失败记录里根本没有那条手机号那问题出在数据源分发阶段——比如 list 分页漏了一条或者 worker 消费 channel 时有 bug。用对账逻辑total_count success_count fail_count必须恒成立不成立就说明程序有丢数据。我在项目里加了一个定时对账任务把sms_task的总数和明细表统计 count 对比任何对不上都会触发告警。这对批量发送类任务是保命的。5.2 超时与连接耗尽症状并发跑起来后日志里大量 context deadline exceeded 或 connection refused越重试越严重。排查步骤用netstat或者ss -s看系统 TIME_WAIT 连接数。如果几万个说明 HTTP 连接没有复用每发一条都新建 TCP。检查http.Transport的MaxIdleConnsPerHost是否设置resp.Body.Close()是否被正确触发。检查下游网关的连接数限制。我们曾经并发调大到 100网关日志里全是 too many open connections这说明并发不是越大越好要回退到 30~50。检查是否设置Timeout。没设置超时网关一旦卡住worker 全部睡眠在响应等待上整个发送任务就挂住了。下面这个配置我在生产环境验证过比较稳client : http.Client{ Timeout: 5 * time.Second, Transport: http.Transport{ MaxIdleConnsPerHost: 64, MaxConnsPerHost: 100, IdleConnTimeout: 90 * time.Second, }, }如果单机并发仍然不够就升级成多节点部署每台机器限制住并发数而不是无限压单机。5.3 并发参数怎么调这是读者问得最多的问题。直接给结论没有绝对正确的数字只有一套调整思路约束条件设置建议平台无明确 QPS 限制并发数取 20~50过多会导致连接数爆炸和无效等待平台有明确 QPS 限制并发数取 QPS 上限的 2~3 倍但严格用令牌桶限流到 QPS 值单条接口耗时较长500ms加大并发到 50~100让更多请求在途抵消延迟机器配置较低1C2G并发控制在 10~20协程虽轻但调度也有成本单批次数量极大10 万分批次执行每批 5000~10000 条批间留 100ms 间隔避免长时间占用连接池我用过一个简单公式来估算最大并发数 ≈ 平台单账号 QPS × 单条请求耗时。比如 QPS50单条约 150ms那并发数 50 × 0.15 ≈ 7~8 就能满足 QPS 要求。当然工程上会留点余量取 15~20 就够了。这个公式帮我避免了好几次无脑拉高并发的失误。5.4 协程泄漏与 panic 问题症状任务跑完应用内存不释放或者进程里 goroutine 数量持续增长。排查步骤是否每个go func()都有对应的wg.Done()。我的习惯是defer wg.Done()写在 goroutine 的第一行这样无论后面怎么 return 都不会漏。worker 里是否用for range ch来处理任务并且任务 channel 是否在所有发送请求写入后被close()了。没有 closefor range 永远不退出worker 永远活等。发送代码是否有 panic 未被 recover。panic 会直接让整个进程崩溃协程里的 panic 也一样。我在 worker 里加了defer func() { if r : recover(); r ! nil { log.Printf(panic: %v, r) } }()防止单条数据脏导致整个批量任务崩掉。还有一个很容易犯的错resultCh 没被正确 close 就尝试 range。我这个项目的处理方式是只在g.Wait()全部完成后才close(resultCh)这样接收方才能安全遍历完所有结果。6. 后期还能怎么扩展这套协程批量发送的架子不只是短信能用凡是外部 HTTP 接口的批量调用邮件推送、消息推送、外呼触达都能套用。核心就是三件套受控并发信号量或 worker pool、限流令牌桶、重试指数退避 幂等。我个人建议你在动手写代码之前先把短信服务商的接口文档完整读一遍特别是限流说明、签名算法、错误码表。这个项目里 70% 的问题不是 Go 协程的问题而是对接细节的问题。其次监控告警一定要尽早接上。我后来在代码里加了结构化日志log/slog每一轮发送都打出一个包含 total、success、fail、cost 的 JSON然后接入监控平台。这样线上出了问题不用靠业务方打电话直接看报表就能定位。最后分享一个小技巧实际使用中挺管用的发送压力测试不要光测高并发要测长时间低并发。短时压测 1000 条跑得飞快但持续跑 10 万条连接池、内存、下游频控的问题才会暴露。我用每分钟 1000 条的恒定速率连续跑了半小时发现网关偶尔会出现 30 秒左右的响应变慢于是把超时从 3 秒改成了 5 秒并把重试上限从 2 次提到 3 次之后整体稳了很多。这类长稳测试才是决定方案能不能上线的关键一环。