推送通知“失联”风暴:Spring Boot 服务端连接池耗尽、Token 过期,你的消息还发的出去吗?
推送通知“失联”风暴Spring Boot 服务端连接池耗尽、Token 过期你的消息还发的出去吗你兴冲冲地在 Spring Boot 应用中接入了 FCM / APNs用户下单、收到消息时实时推送体验丝滑。可随着设备量突破百万推送延迟从毫秒涨到几十秒甚至直接失败凌晨营销推送时FCM 返回大量 401 错误原来是 Access Token 过期却没有自动刷新某天运维发现推送服务的 HTTP 连接池耗尽连健康检查都超时更可怕的是推送任务多实例部署后同一用户收到好几条重复通知抱怨“轰炸”。这一切乱象都源于对推送服务端连接管理的忽视证书管理、Token 生命周期、连接池调优、多实例去重、异常重试每个环节都可能成为推送系统的致命短板。本文将深入 Spring Boot 中推送通知的服务端连接管理疑难杂症从 FCM / APNs 客户端配置、HTTP 连接池、Token 自动刷新、证书优雅切换、到分布式环境下的推送去重与流量控制给你一套让推送消息“发得出、发得快、不重复”的完整方案。一、血泪现场推送连接管理失控的四重灾难1.1 Token 过期全平台推送失败你的 FCM 服务端使用firebase-adminSDK初始化后长期运行。某天凌晨所有推送请求返回401 Unauthorized数十万条消息滞留。排查发现 OAuth2 Access Token 在几小时前已经过期而 SDK 未能自动刷新或刷新逻辑因网络抖动失败导致整个推送链路瘫痪。1.2 连接池被打满服务假死为了提升推送速度你开了几百个并发线程向 FCM 发送 HTTP 请求但未配置连接池。瞬间大量TIME_WAIT或CLOSE_WAIT占用所有可用端口新的请求无法建立连接推送服务假死甚至影响同一 JVM 内其他服务的正常通信。1.3 证书过期APNs 推送全线崩溃推送服务运行得好好的突然 APNs 返回403 Forbidden。查了半天才发现APNs 的 Provider Certificate 已过期而你没有设置证书即将到期的监控告警也没有实现优雅的证书热替换。紧急更换证书、重启服务耗时数小时期间用户收不到任何通知。1.4 多实例重复发送用户投诉“被轰炸”为了高可用你部署了 3 个推送服务实例每个实例都从数据库拉取“待推送”消息进行发送。由于没有分布式锁或任务去重机制同一条消息被三个实例同时发送用户手机上弹出三遍相同的通知体验极差。这些事故的共同根源是没有以生产级标准治理与推送服务FCM/APNs/信鸽的连接把推送看成一次性的 HTTP 调用忽略了连接生命周期、安全凭证、并发控制和幂等性。二、根因剖析推送服务端连接的四个核心维度推送通知的本质是服务端与外部推送网关的长连接 / HTTPS 连接管理需要解决认证与凭证管理FCM 的 OAuth2 Token、APNs 的 JWT Token 或证书需要自动刷新和安全存储。HTTP 连接池与 FCM / APNs 等 HTTPS 端点通信时必须配置合理的连接池防止资源耗尽。异步与批量高吞吐下需要使用异步客户端并支持批量发送减少网络开销。分布式协同多实例部署时需避免重复推送实现任务分片或锁。错误处理与重试针对不同的错误Token 过期、设备 Token 无效、网络超时实现差异化重试与反馈清理。Spring Boot 项目中虽然firebase-adminSDK 封装了 FCM 细节但开发者往往直接使用默认配置未根据生产规模调优。对于 APNs常用第三方库pushy或java-apns同样需要精细配置连接池和证书。下面我们逐一给出解决方案。三、解决方案一FCM 连接管理与 Token 刷新3.1 正确初始化 FirebaseApp避免重复创建firebase-adminSDK 内部维护了 HTTP 连接池并且自动管理 OAuth2 Token 刷新。但很多项目每次发送都调用FirebaseApp.getInstance()并重新构建FirebaseMessaging导致连接池碎片化。应确保应用启动时初始化一次。ConfigurationpublicclassFcmConfig{PostConstructpublicvoidinitialize(){if(FirebaseApp.getApps().isEmpty()){FirebaseOptionsoptionsFirebaseOptions.builder().setCredentials(GoogleCredentials.fromStream(newClassPathResource(firebase-service-account.json).getInputStream())).build();FirebaseApp.initializeApp(options);}}BeanpublicFirebaseMessagingfirebaseMessaging(){returnFirebaseMessaging.getInstance();}}3.2 Token 过期自动刷新GoogleCredentials默认会在过期前自动刷新前提是运行环境有网络权限。某些受限环境如私有化部署可能失败。可以通过配置setHttpTransportFactory设置代理或监听 Token 刷新失败事件。另外监控 Token 剩余有效期可通过GoogleCredentials.getAccessToken()查询。3.3 HTTP 传输配置firebase-admin使用底层GoogleHttpClient其连接池可通过系统属性或自定义HttpTransport调整。在 Spring Boot 中可以通过FirebaseOptions.builder().setHttpTransport(...)注入自定义的NetHttpTransport并配置最大连接数等。HttpTransporthttpTransportnewNetHttpTransport.Builder().setMaxIdleConnections(20).setConnectTimeout(5000).build();FirebaseOptionsoptionsFirebaseOptions.builder().setCredentials(credentials).setHttpTransport(httpTransport).build();注意如果使用FcmClient非官方 SDK直接调用 FCM HTTP v1 API需使用RestTemplate或WebClient并配置连接池后文详述。四、解决方案二APNs 连接管理与证书优雅切换4.1 使用pushy库的高性能连接池pushy专门为 APNs 设计基于 Netty支持多路复用和连接池可配置并发流。Spring Boot 中将其注册为 Bean。ConfigurationpublicclassApnsConfig{BeanpublicApnsClientapnsClient()throwsException{ApnsClientclientnewApnsClientBuilder().setApnsServer(ApnsClientBuilder.DEVELOPMENT_APNS_HOST)// 或 PRODUCTION.setSigningKey(ApnsSigningKey.loadFromInputStream(newClassPathResource(apns-key.p8).getInputStream(),TEAM_ID,KEY_ID)).setConcurrentConnections(10).build();returnclient;}}pushy内部自动维护连接Token 过期会自动重新签名不需要手动干预。4.2 证书过期监控与热替换若使用证书非 Token必须在证书到期前替换。可以实现ApnsClientProvider动态返回最新ApnsClient配合定时任务检查证书有效期并重建客户端。ComponentpublicclassApnsClientManager{privatevolatileApnsClientclient;privateInstantcertificateExpiration;PostConstructpublicvoidinit(){refreshClient();}Scheduled(fixedDelay60_000)publicvoidcheckCertificate(){if(Instant.now().isAfter(certificateExpiration.minus(7,ChronoUnit.DAYS))){log.warn(APNs certificate is about to expire, refreshing...);refreshClient();}}privatesynchronizedvoidrefreshClient(){// 关闭旧 clientif(client!null)client.close();// 重新创建clientbuildNewClient();certificateExpirationparseExpiration();}publicApnsClientgetClient(){returnclient;}}对于 Token 认证JWT无需担忧pushy自动处理。但 JWT 的 Key ID 和 Team ID 需妥善保管。4.3 连接监控通过ApnsClientMetricsListenerpushy 提供或自行包装收集连接数、发送成功率等并暴露给 Micrometer。五、解决方案三通用 HTTP 推送客户端的连接池配置对于不使用 Firebase SDK 而直接调用 FCM HTTP v1 API、华为推送、小米推送等的场景需要自行管理RestTemplate/WebClient的连接池。5.1 使用PoolingHttpClientConnectionManagerBeanpublicRestTemplatefcmRestTemplate(){PoolingHttpClientConnectionManagercmnewPoolingHttpClientConnectionManager();cm.setMaxTotal(200);cm.setDefaultMaxPerRoute(100);CloseableHttpClienthttpClientHttpClients.custom().setConnectionManager(cm).setKeepAliveStrategy((response,context)-30_000).build();HttpComponentsClientHttpRequestFactoryfactorynewHttpComponentsClientHttpRequestFactory(httpClient);factory.setConnectTimeout(5000);factory.setReadTimeout(10000);returnnewRestTemplate(factory);}或者使用 WebClientBeanpublicWebClientwebClient(){HttpClienthttpClientHttpClient.create(ConnectionProvider.builder(push-pool).maxConnections(200).pendingAcquireMaxCount(500).build()).responseTimeout(Duration.ofSeconds(10));returnWebClient.builder().clientConnector(newReactorClientHttpConnector(httpClient)).build();}5.2 Token 刷新拦截器编写ClientHttpRequestInterceptor在发送前检查 Token 是否过期过期则用 Refresh Token 获取新 Token并缓存。publicclassOAuth2TokenInterceptorimplementsClientHttpRequestInterceptor{privatefinalTokenProvidertokenProvider;OverridepublicClientHttpResponseintercept(HttpRequestrequest,byte[]body,ClientHttpRequestExecutionexecution){request.getHeaders().setBearerAuth(tokenProvider.getValidToken());returnexecution.execute(request,body);}}六、解决方案四分布式推送去重与任务分片多实例同时从数据库拉取待推送任务时必须保证同一条消息不会被重复发送。6.1 基于数据库的任务锁定在待推送任务表中增加status和instance_id字段使用乐观锁更新ModifyingQuery(UPDATE PushTask t SET t.status SENDING, t.instanceId :instanceId WHERE t.id IN (SELECT id FROM PushTask WHERE status PENDING ORDER BY createTime ASC LIMIT :limit))intlockTasks(Param(limit)intlimit,Param(instanceId)StringinstanceId);每个实例定期lockTasks获取一批任务更新为自己的实例 ID然后处理。这利用了数据库的原子更新避免了重复获取。6.2 使用 Redis 分布式锁 队列更轻量将所有待推送 ID 压入 Redis List多个实例通过LPOP获取天然不重复。若担心数据丢失可配合数据库状态变更。StringtaskIdredisTemplate.opsForList().leftPop(push:queue);if(taskId!null){send(taskId);}6.3 推送任务幂等性即使重复发送了推送如果业务允许尽量保证“至少一次”。但对于用户通知重复发送会严重影响体验所以必须去重。如果使用第三方推送服务FCM 本身不保证不会重复推送但服务端应避免重复发起。七、解决方案五错误处理与无效Token清理推送失败的反馈必须回收否则无效设备Token会积累导致后续推送效率降低和成本增加。FCM响应中会返回error字段如UNREGISTERED、INVALID_ARGUMENT此时应从数据库删除对应 Token。APNs返回410 Gone或reason: Unregistered同样需要清理。实现ApnsPushNotificationResponseListenerpushy或 FCM 的批量回调集中处理失败 Token批量删除。// 使用 pushy 的监听器client.sendNotification(pushNotification).addListener(future-{try{PushNotificationResponseSimpleApnsPushNotificationresponsefuture.get();if(response.isAccepted()){log.info(Push accepted);}else{Stringreasonresponse.getRejectionReason();if(reason.contains(Unregistered)){// 标记或删除该 token}}}catch(Exceptione){...}});批量推送时记录每一个 Token 的发送结果将无效的 Token 收集起来定期批量删除避免在循环中逐条查询删除DB 压力大。八、解决方案六推送流量控制与背压大促时可能瞬间产生数百万条推送不能无限制调用第三方 API否则会被限流或打挂。本地速率限制使用Bucket4j或Guava RateLimiter控制发送速率。批次控制FCM 批量最大 500 条APNs 支持 HTTP/2 多路复用但也要注意并发度。根据推送服务限制设置合理的批量大小和并发度。背压机制使用有界队列当队列满时降级如丢弃低优先级推送、或暂存数据库。RateLimiterlimiterRateLimiter.create(500.0);// 每秒最多500条for(Messagemessage:messages){limiter.acquire();firebaseMessaging.sendAsync(message);}九、监控与告警让推送连接“透明化”通过 Micrometer 暴露指标push_requests_total成功/失败计数push_connection_pool_activepush_token_expired_totalpush_latency_seconds在 Grafana 中设置告警Token 过期次数 0、连接池利用率 80%、推送失败率突增等。十、常见坑点速查表现象根因解决FCM 全平台 401Access Token 过期且自动刷新失效检查网络代理监听GoogleCredentials.refresh()监控 token 寿命APNs 返回 403证书过期或 JWT 签名错误实现证书即将过期告警与热替换检查 Key ID连接超时或端口耗尽未配置连接池短连接频繁创建配置带连接池的 HttpClient设置 KeepAlive用户收到多条重复通知多实例重复拉取同一任务数据库乐观锁或 Redis 队列去重无效 Token 堆积未根据推送反馈清理无效设备监听推送失败响应批量删除过期 Token大促时推送服务被限流或崩溃未做流量控制和并发限制使用 RateLimiter限制批量大小实现背压十一、最佳实践让推送连接稳如磐石初始化即单例FCM / APNs 客户端应用时创建一次注入容器避免重复创建销毁。连接池必须配无论是 SDK 还是自定义 HTTP 客户端都要指定合理的maxTotal、defaultMaxPerRoute。Token 自动刷新监控不能默认相信 SDK 一定能自动刷新要监控 Token 寿命和刷新失败次数。证书与密钥安全存储使用 Vault 或环境变量启动时加载并支持动态刷新。分布式任务去重数据库乐观锁 Redis 队列组合保证 Exactly-once 投递。反馈回收建立无效 Token 清理管道避免邮件通知式的无穷重试。速率与并发控制利用 RateLimiter 有界队列防止打爆第三方服务。推送状态可观测所有推送请求带上 traceId结果入库方便故障排查。灰度与降级重大活动前逐步放开流量出现故障可一键降级为短信或静默。定期演练模拟证书到期、网络中断等场景验证自动恢复能力。十二、结语别让推送通知成为“薛定谔的消息”推送通知看似只是一个简单的 HTTP 请求但其背后的连接管理、凭证生命周期、分布式协同和容错机制决定了它是可靠的消息通道还是随缘的“看运气”。现在检查你的推送服务FCM Token 是否自动刷新连接池配了吗多实例发送会重复吗按照本文的方子逐一加固让每一次推送都精准抵达用户成为业务增长的催化剂而非客诉的导火索。