Spring-Angular SseEmitter仅本地可用,Google Cloud部署失效求助
问题背景
使用Spring SseEmitter实现企业变更通知推送,本地环境(含本地部署生产版本)功能正常,但部署到Google Cloud(GCP)平台后消息始终无法送达。Spring服务器日志偶现以下异常:
AsyncRequestTimeoutExceptionHttpMediaTypeNotAcceptableException
相关代码与配置
Spring SSE端点
@GetMapping(value = "/notifications", headers = "Accept=*/*", consumes = MediaType.ALL_VALUE, produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter getCurrentUserNotificationsEmitter() { return companyNotifierService.createEmitter(SessionUtils.getCurrentCompanyUuid(), SessionUtils.getCurrentUserUuid()); }
Angular消费端代码
listen(): void { const eventSource = new EventSource(`/webfront${this.serviceUrl}/notifications`, { withCredentials: true }); eventSource.addEventListener('update', () => {/* 处理变更通知 */}); eventSource.onmessage(() => console.log('heartbeat')); // 异常时重启连接 eventSource.onerror(() => { eventSource.close(); listen(); }) }
服务端消息发送代码
void notifyUser(String userId, String companyUuid){ final SseEmitter emitter; // 从数据结构中找到对应用户的emitter emitter.send(SseEmitter.event() .reconnectTime(30000) .data("company-updated", MediaType.TEXT_PLAIN) .id(UUID.randomUUID().toString()) .name("update")); }
Express代理服务器配置(生产环境)
// 生产环境与IE11测试用Web服务器,其他场景使用ng server const express = require('express'); const https = require('https'); const path = require('path'); const fs = require('fs'); const httpProxy = require('http-proxy'); const logger = require('morgan'); const errorHandler = require('errorhandler'); const _httpsOptions = { key: fs.readFileSync('../security/localhost.key'), cert: fs.readFileSync('../security/localhost.crt') }; const app = express(); const proxy = new httpProxy.createProxyServer({target: 'https://localhost:8763', secure: false}); app.set('port', process.env.PORT || 8773); app.use(logger('dev')); app.use(express.static(path.join(__dirname, '../../dist/clientng'))); if (app.get('env') === 'development') { app.use(errorHandler()); } app.all('/auto/*', function (req, res) { console.log(req.protocol); console.log(req.hostname); const url = req.protocol + '://' + req.hostname + ':' + app.get('port') + '/#' + req.url; res.redirect(url); }); app.all('/*', function (req, res) { proxy.web(req, res, function (err) { console.log(err); if (err) throw err; }); }); https.createServer(_httpsOptions, app).listen(app.get('port'), function () { console.log('Express server listening on port ' + app.get('port')); fs.writeFile('node.pid', process.pid.toString(), function (err) { if (err) { console.log(err); } }); });
Spring MVC异步配置
mvc: async: request-timeout: 210000 # ms
异常日志片段
org.springframework.web.context.request.async.AsyncRequestTimeoutException: null at org.springframework.web.context.request.async.TimeoutDeferredResultProcessingInterceptor.handleTimeout(TimeoutDeferredResultProcessingInterceptor.java:42) ...(省略栈跟踪) 14:11:21.354 [http-nio-8080-exec-6] WARN o.s.w.s.m.m.a.ExceptionHandlerExceptionResolver - Failure in @ExceptionHandler public lu.legitech.lexnow.json.common.BasicErrorMessage lu.legitech.lexnow.controller.ErrorController.uncaughtException(java.lang.Exception) org.springframework.web.HttpMediaTypeNotAcceptableException: Could not find acceptable representation at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor.writeWithMessageConverters(AbstractMessageConverterMethodProcessor.java:306) ...(省略栈跟踪)
可能的原因与解决方案
1. GCP负载均衡/Ingress超时不匹配
GCP HTTP(S)负载均衡默认30秒空闲超时,远小于Spring配置的210秒异步超时。当连接空闲超过30秒,负载均衡会主动断开连接,导致SSE连接中断,推送无法送达,同时触发AsyncRequestTimeoutException。
解决方案:
- 创建Backend Config配置,将超时时间设置为与Spring异步超时一致(210秒):
apiVersion: cloud.google.com/v1 kind: BackendConfig metadata: name: sse-backend-config spec: timeoutSec: 210 - 在Ingress资源中关联该Backend Config:
annotations: cloud.google.com/backend-config: '{"default": "sse-backend-config"}'
2. Express代理缺失长连接配置
当前http-proxy配置未设置长连接与超时参数,在GCP环境中可能提前断开空闲连接。
解决方案:
修改代理配置,添加超时与长连接参数:
const proxy = new httpProxy.createProxyServer({ target: 'https://localhost:8763', secure: false, proxyTimeout: 210000, // 匹配Spring超时 timeout: 210000, keepAlive: true, keepAliveMsecs: 30000 }); // 同时设置Express服务器超时 app.set('timeout', 210000);
3. SSE心跳机制不完善
服务端未定期发送心跳包,GCP中间网络组件会断开长时间无数据的连接。虽然Angular端监听了心跳,但无服务端主动推送的心跳,连接仍会被判定为空闲。
解决方案:
在创建SseEmitter时启动定时心跳任务(间隔小于负载均衡超时,比如25秒):
// 在companyNotifierService.createEmitter方法中添加 SseEmitter emitter = new SseEmitter(210000L); // 使用定时任务池执行心跳 ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); executor.scheduleAtFixedRate(() -> { try { // 发送心跳消息,触发客户端onmessage emitter.send(SseEmitter.event().data("heartbeat")); } catch (IOException e) { // 连接失效,移除emitter并关闭定时任务 removeEmitter(userId, companyUuid); executor.shutdown(); } }, 0, 25000, TimeUnit.MILLISECONDS);
4. 异常处理器不兼容SSE格式
HttpMediaTypeNotAcceptableException是因为全局异常处理器在SSE连接超时后,试图返回JSON格式的BasicErrorMessage,但SSE连接的Accept头为text/event-stream,无对应消息转换器支持。
解决方案:
为SSE连接单独处理超时异常,返回符合SSE格式的消息后关闭连接:
@ExceptionHandler(AsyncRequestTimeoutException.class) public void handleSseTimeout(SseEmitter emitter) { try { emitter.send(SseEmitter.event() .name("timeout") .data("连接超时,正在重连...") .reconnectTime(30000)); emitter.complete(); } catch (IOException e) { emitter.completeWithError(e); } }
5. SSL证书配置不符合生产环境要求
当前Express使用本地自签名证书,在GCP生产环境中可能导致SSL握手失败或连接不稳定。
解决方案:
使用GCP托管的SSL证书(Managed SSL Certificates)替换本地自签名证书,确保客户端与Express、Express与Spring后端之间的SSL连接正常。
内容的提问来源于stack exchange,提问作者César Castro Aroche

