《完整设计:Spring Boot 3 整合 SSE (Server-Sent Events) 企业级最佳实践(一)》
《代码示例:Spring Boot 3 整合 SSE (Server-Sent Events) 企业级最佳实践(二)》
《配置与测试部署:Spring Boot 3 整合 SSE (Server-Sent Events) 企业级最佳实践(三)》
文章目录
- [Spring Boot 3 SSE 配置、测试、部署配置和运维指南](#Spring Boot 3 SSE 配置、测试、部署配置和运维指南)
-
- [1. 配置文件详解](#1. 配置文件详解)
-
- [1.1 Redis 消息监听器配置](#1.1 Redis 消息监听器配置)
- [1.2 心跳调度器配置](#1.2 心跳调度器配置)
- [1.3 安全配置](#1.3 安全配置)
- [1.4 异步线程池配置](#1.4 异步线程池配置)
- [1.5 限流过滤器(防止滥用)](#1.5 限流过滤器(防止滥用))
- [1.6 自定义 Actuator 端点](#1.6 自定义 Actuator 端点)
- [2. 业务场景实现示例](#2. 业务场景实现示例)
-
- [2.1 实时通知服务](#2.1 实时通知服务)
- [2.2 文件上传进度监控](#2.2 文件上传进度监控)
- [2.3 数据大屏实时刷新](#2.3 数据大屏实时刷新)
- [3. 前端客户端示例](#3. 前端客户端示例)
-
- [3.1 原生 JavaScript 实现](#3.1 原生 JavaScript 实现)
- [3.2 React 客户端示例](#3.2 React 客户端示例)
- [1. 测试方案](#1. 测试方案)
-
- [1.1 单元测试](#1.1 单元测试)
-
- [ConnectionManager 测试](#ConnectionManager 测试)
- [MessageService 测试](#MessageService 测试)
- [1.2 集成测试](#1.2 集成测试)
- [1.3 压力测试](#1.3 压力测试)
-
- [JMeter 测试计划](#JMeter 测试计划)
- [Gatling 压力测试脚本](#Gatling 压力测试脚本)
- [1.4 性能测试报告模板](#1.4 性能测试报告模板)
- [2. Docker 部署](#2. Docker 部署)
-
- [2.1 Dockerfile](#2.1 Dockerfile)
- [2.2 docker-compose.yml](#2.2 docker-compose.yml)
- [2.3 Prometheus 配置](#2.3 Prometheus 配置)
- [3. Kubernetes 部署](#3. Kubernetes 部署)
-
- [3.1 Deployment](#3.1 Deployment)
- [3.2 Service](#3.2 Service)
- [3.3 Ingress](#3.3 Ingress)
- [3.4 ConfigMap](#3.4 ConfigMap)
- [3.5 HorizontalPodAutoscaler](#3.5 HorizontalPodAutoscaler)
- [4. Nginx 负载均衡配置](#4. Nginx 负载均衡配置)
-
- [4.1 完整 Nginx 配置](#4.1 完整 Nginx 配置)
- [5. 监控和告警](#5. 监控和告警)
-
- [5.1 Grafana Dashboard JSON](#5.1 Grafana Dashboard JSON)
- [5.2 Prometheus 告警规则](#5.2 Prometheus 告警规则)
- [6. 常见问题排查](#6. 常见问题排查)
-
- [6.1 问题诊断清单](#6.1 问题诊断清单)
- [6.2 日志分析](#6.2 日志分析)
Spring Boot 3 SSE 配置、测试、部署配置和运维指南
本文档包含配置文件、测试方案、部署配置和运维指南。
1. 配置文件详解
1.1 Redis 消息监听器配置
java
package com.enterprise.sse.config;
import com.enterprise.sse.manager.SseConnectionManager;
import com.enterprise.sse.model.SseEvent;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
/**
* Redis 消息监听器 - 集群消息分发
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
@ConditionalOnProperty(name = "sse.redis.enabled", havingValue = "true", matchIfMissing = true)
public class RedisMessageListenerConfig {
private final SseConnectionManager connectionManager;
private final ObjectMapper objectMapper;
@Value("${sse.redis.channel:sse:message}")
private String redisChannel;
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory connectionFactory,
MessageListenerAdapter messageListenerAdapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(messageListenerAdapter, new PatternTopic(redisChannel));
log.info("Redis 消息监听器已启动,监听频道: {}", redisChannel);
return container;
}
@Bean
public MessageListenerAdapter messageListenerAdapter() {
return new MessageListenerAdapter(new RedisMessageListener(), "onMessage");
}
public class RedisMessageListener {
public void onMessage(String message, String pattern) {
try {
SseEvent event = objectMapper.readValue(message, SseEvent.class);
log.debug("收到Redis消息: eventType={}, targetUser={}",
event.getEvent(), event.getTargetUserId());
// 根据目标类型分发
if (event.getTargetUserId() != null) {
connectionManager.sendToUser(event.getTargetUserId(), event);
} else if (event.getTargetGroupId() != null) {
connectionManager.sendToGroup(event.getTargetGroupId(), event);
} else {
connectionManager.broadcast(event);
}
} catch (Exception e) {
log.error("处理Redis消息失败: message={}", message, e);
}
}
}
}
1.2 心跳调度器配置
java
package com.enterprise.sse.scheduler;
import com.enterprise.sse.manager.SseConnectionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* SSE 心跳调度器
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "sse.heartbeat.enabled", havingValue = "true", matchIfMissing = true)
public class SseHeartbeatScheduler {
private final SseConnectionManager connectionManager;
/**
* 发送心跳 - 每30秒
*/
@Scheduled(fixedDelayString = "${sse.heartbeat.interval-seconds:30}000")
public void sendHeartbeat() {
connectionManager.getOnlineUsers().forEach(userId -> {
try {
connectionManager.sendHeartbeat(userId);
} catch (Exception e) {
log.warn("发送心跳失败: userId={}", userId);
}
});
}
/**
* 清理超时连接 - 每分钟
*/
@Scheduled(fixedDelay = 60000, initialDelay = 60000)
public void cleanupTimeoutConnections() {
int cleaned = connectionManager.cleanupTimeoutConnections();
if (cleaned > 0) {
log.info("清理超时连接: {} 个", cleaned);
}
}
/**
* 打印统计 - 每5分钟
*/
@Scheduled(fixedDelay = 300000, initialDelay = 60000)
public void printStats() {
SseConnectionManager.ConnectionStats stats = connectionManager.getStats();
log.info("SSE统计: 活跃={}, 总消息={}",
stats.activeConnections(), stats.totalMessagesSent());
}
}
1.3 安全配置
java
package com.enterprise.sse.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
/**
* Spring Security 配置
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/sse/connect").authenticated()
.requestMatchers("/actuator/**").permitAll()
.anyRequest().authenticated()
)
.httpBasic();
return http.build();
}
}
1.4 异步线程池配置
java
package com.enterprise.sse.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 异步任务线程池
*/
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("sse-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
}
1.5 限流过滤器(防止滥用)
java
package com.enterprise.sse.filter;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Refill;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* SSE 连接限流过滤器
*/
@Slf4j
@Component
public class SseRateLimitFilter implements Filter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
if (!httpRequest.getRequestURI().contains("/api/sse/connect")) {
chain.doFilter(request, response);
return;
}
String userId = getUserId(httpRequest);
if (userId == null) {
httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "未认证");
return;
}
Bucket bucket = buckets.computeIfAbsent(userId, k -> createBucket());
if (bucket.tryConsume(1)) {
chain.doFilter(request, response);
} else {
log.warn("用户连接频率过高: userId={}", userId);
httpResponse.sendError(HttpServletResponse.SC_TOO_MANY_REQUESTS,
"连接请求过于频繁,请稍后再试");
}
}
private Bucket createBucket() {
// 每分钟5个令牌
Bandwidth limit = Bandwidth.classic(5, Refill.intervally(5, Duration.ofMinutes(1)));
return Bucket.builder().addLimit(limit).build();
}
private String getUserId(HttpServletRequest request) {
String userId = request.getParameter("userId");
if (userId == null && request.getUserPrincipal() != null) {
userId = request.getUserPrincipal().getName();
}
return userId;
}
}
1.6 自定义 Actuator 端点
java
package com.enterprise.sse.actuator;
import com.enterprise.sse.manager.SseConnectionManager;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* SSE 监控端点
* 访问: /actuator/sse
*/
@Component
@Endpoint(id = "sse")
@RequiredArgsConstructor
public class SseActuatorEndpoint {
private final SseConnectionManager connectionManager;
@ReadOperation
public Map<String, Object> getSseInfo() {
SseConnectionManager.ConnectionStats stats = connectionManager.getStats();
return Map.of(
"stats", stats,
"onlineUsers", connectionManager.getOnlineUsers(),
"onlineCount", stats.activeConnections()
);
}
}
2. 业务场景实现示例
2.1 实时通知服务
java
package com.enterprise.sse.demo;
import com.enterprise.sse.service.SseMessageService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* 通知服务
*/
@Service
@RequiredArgsConstructor
public class NotificationService {
private final SseMessageService sseMessageService;
/**
* 订单状态更新通知
*/
public void notifyOrderStatus(String userId, String orderId, String status) {
String title = "订单状态更新";
String content = String.format("您的订单 %s 已%s", orderId, status);
sseMessageService.sendNotification(userId, title, content);
}
/**
* 系统维护通知(广播)
*/
public void notifySystemMaintenance(String message) {
sseMessageService.broadcast(
SseEvent.systemBroadcast("系统维护: " + message)
);
}
}
2.2 文件上传进度监控
java
package com.enterprise.sse.demo;
import com.enterprise.sse.service.SseMessageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
/**
* 文件上传服务(带进度推送)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileUploadService {
private final SseMessageService sseMessageService;
public String uploadWithProgress(String userId, String taskId, MultipartFile file)
throws IOException {
long fileSize = file.getSize();
long uploadedBytes = 0;
try (InputStream inputStream = file.getInputStream()) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
// 模拟上传
uploadChunk(buffer, bytesRead);
uploadedBytes += bytesRead;
int percentage = (int) ((uploadedBytes * 100) / fileSize);
// 推送进度
sseMessageService.sendProgress(userId, taskId, percentage,
String.format("已上传 %d%%", percentage));
if (percentage % 10 == 0) {
log.info("上传进度: taskId={}, progress={}%", taskId, percentage);
}
}
}
sseMessageService.sendProgress(userId, taskId, 100, "上传完成");
return "file-url-" + taskId;
}
private void uploadChunk(byte[] data, int length) {
try {
Thread.sleep(50); // 模拟延迟
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
2.3 数据大屏实时刷新
java
package com.enterprise.sse.demo;
import com.enterprise.sse.model.SseEvent;
import com.enterprise.sse.service.SseMessageService;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Random;
/**
* 数据大屏服务
*/
@Service
@RequiredArgsConstructor
public class DashboardService {
private final SseMessageService sseMessageService;
private final Random random = new Random();
/**
* 每5秒推送实时数据
*/
@Scheduled(fixedDelay = 5000)
public void pushRealtimeData() {
Map<String, Object> data = Map.of(
"销售额", random.nextInt(100000),
"访问量", random.nextInt(10000),
"在线用户", random.nextInt(1000),
"timestamp", System.currentTimeMillis()
);
sseMessageService.sendToGroup("dashboard", SseEvent.builder()
.id(String.valueOf(System.currentTimeMillis()))
.event("dashboard-data")
.data(data)
.build());
}
}
3. 前端客户端示例
3.1 原生 JavaScript 实现
html
<!DOCTYPE html>
<html>
<head>
<title>SSE Client Demo</title>
<meta charset="UTF-8">
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 20px auto;
padding: 20px;
}
#status { font-size: 18px; font-weight: bold; margin: 10px 0; }
#messages {
border: 1px solid #ddd;
padding: 10px;
max-height: 500px;
overflow-y: auto;
}
.message {
padding: 8px;
margin: 5px 0;
border-radius: 4px;
background: #f5f5f5;
}
.message.notification {
background: #e3f2fd;
border-left: 4px solid #2196f3;
}
.message.system {
background: #fff3e0;
border-left: 4px solid #ff9800;
}
</style>
</head>
<body>
<h1>SSE 实时通知演示</h1>
<div id="status">未连接</div>
<div id="messages"></div>
<script>
const SSE_URL = 'http://localhost:8080/api/sse/connect';
const USER_ID = 'user123';
const AUTH_TOKEN = btoa('admin:admin123');
let eventSource;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
function connect() {
const url = `${SSE_URL}?userId=${USER_ID}`;
eventSource = new EventSource(url);
eventSource.onopen = function(event) {
console.log('SSE 连接已建立', event);
document.getElementById('status').textContent = '已连接';
document.getElementById('status').style.color = 'green';
reconnectAttempts = 0;
};
eventSource.onmessage = function(event) {
console.log('收到消息:', event.data);
addMessage('默认消息', event.data);
};
eventSource.addEventListener('connected', function(event) {
const data = JSON.parse(event.data);
addMessage('系统', `欢迎!连接ID: ${event.lastEventId}`);
});
eventSource.addEventListener('notification', function(event) {
const data = JSON.parse(event.data);
addMessage('通知', JSON.stringify(data), 'notification');
if (Notification.permission === 'granted') {
new Notification(data.title || '新通知', {
body: data.content || data
});
}
});
eventSource.addEventListener('progress', function(event) {
const data = JSON.parse(event.data);
updateProgress(data);
});
eventSource.addEventListener('system', function(event) {
const data = JSON.parse(event.data);
addMessage('系统广播', data, 'system');
});
eventSource.onerror = function(event) {
console.error('SSE 连接错误:', event);
document.getElementById('status').textContent = '连接错误';
document.getElementById('status').style.color = 'red';
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++;
console.log(`尝试重连 (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
setTimeout(() => {
eventSource.close();
connect();
}, 3000);
}
};
}
function addMessage(type, content, className = '') {
const messagesDiv = document.getElementById('messages');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${className}`;
messageDiv.innerHTML = `
<strong>[${new Date().toLocaleTimeString()}] ${type}:</strong>
<span>${content}</span>
`;
messagesDiv.insertBefore(messageDiv, messagesDiv.firstChild);
if (messagesDiv.children.length > 50) {
messagesDiv.removeChild(messagesDiv.lastChild);
}
}
function updateProgress(data) {
let progressBar = document.getElementById('progress-bar');
if (!progressBar) {
progressBar = document.createElement('div');
progressBar.id = 'progress-bar';
progressBar.style.cssText = 'width: 100%; background: #ddd; height: 20px; margin: 10px 0;';
progressBar.innerHTML = '<div id="progress-fill" style="height: 100%; background: #4caf50; width: 0%;"></div>';
document.body.insertBefore(progressBar, document.getElementById('messages'));
}
const fill = document.getElementById('progress-fill');
fill.style.width = data.percentage + '%';
fill.textContent = data.percentage + '%';
if (data.percentage === 100) {
setTimeout(() => progressBar.remove(), 2000);
}
}
if (Notification.permission === 'default') {
Notification.requestPermission();
}
connect();
window.addEventListener('beforeunload', () => {
if (eventSource) {
eventSource.close();
}
});
</script>
</body>
</html>
3.2 React 客户端示例
jsx
import { useEffect, useState, useRef } from 'react';
function SseClient() {
const [status, setStatus] = useState('未连接');
const [messages, setMessages] = useState([]);
const eventSourceRef = useRef(null);
useEffect(() => {
const userId = 'user123';
const url = `http://localhost:8080/api/sse/connect?userId=${userId}`;
eventSourceRef.current = new EventSource(url);
eventSourceRef.current.onopen = () => {
setStatus('已连接');
};
eventSourceRef.current.addEventListener('notification', (event) => {
const data = JSON.parse(event.data);
addMessage('通知', data.content);
});
eventSourceRef.current.onerror = () => {
setStatus('连接错误');
};
return () => {
eventSourceRef.current?.close();
};
}, []);
const addMessage = (type, content) => {
setMessages(prev => [{
type,
content,
time: new Date().toLocaleTimeString()
}, ...prev].slice(0, 50));
};
return (
<div>
<h1>SSE 实时通知</h1>
<div>状态: {status}</div>
<div>
{messages.map((msg, index) => (
<div key={index}>
[{msg.time}] {msg.type}: {msg.content}
</div>
))}
</div>
</div>
);
}
export default SseClient;
前端实现要点:
- 使用原生 EventSource API
- 实现自动重连机制
- 监听不同类型的事件
- 浏览器通知集成
- 进度条动态更新
1. 测试方案
1.1 单元测试
ConnectionManager 测试
java
package com.enterprise.sse.manager;
import com.enterprise.sse.model.SseEvent;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import static org.junit.jupiter.api.Assertions.*;
/**
* 连接管理器单元测试
*/
class SseConnectionManagerTest {
private SseConnectionManager connectionManager;
@BeforeEach
void setUp() {
connectionManager = new SseConnectionManager(
new ObjectMapper(),
new SimpleMeterRegistry()
);
}
@Test
void testCreateConnection() {
String userId = "test-user";
SseEmitter emitter = connectionManager.createConnection(
userId, "127.0.0.1", "Test-Agent"
);
assertNotNull(emitter);
assertEquals(1, connectionManager.getStats().activeConnections());
}
@Test
void testSendToUser() {
String userId = "test-user";
connectionManager.createConnection(userId, "127.0.0.1", "Test");
SseEvent event = SseEvent.notification(userId, "测试消息");
boolean result = connectionManager.sendToUser(userId, event);
assertTrue(result);
}
@Test
void testGroupMessaging() {
String groupId = "test-group";
String user1 = "user1";
String user2 = "user2";
connectionManager.createConnection(user1, "127.0.0.1", "Test");
connectionManager.createConnection(user2, "127.0.0.1", "Test");
connectionManager.addToGroup(user1, groupId);
connectionManager.addToGroup(user2, groupId);
SseEvent event = SseEvent.builder()
.event("test")
.data("group message")
.build();
int sent = connectionManager.sendToGroup(groupId, event);
assertEquals(2, sent);
}
@Test
void testConnectionLimit() {
String userId = "test-user";
// 创建最大允许数量的连接
for (int i = 0; i < 3; i++) {
connectionManager.createConnection(
userId + i, "127.0.0.1", "Test"
);
}
// 超过限制应该抛出异常
assertThrows(IllegalStateException.class, () -> {
connectionManager.createConnection(userId, "127.0.0.1", "Test");
});
}
@Test
void testCleanupTimeoutConnections() throws InterruptedException {
String userId = "test-user";
connectionManager.createConnection(userId, "127.0.0.1", "Test");
// 模拟超时(实际场景需要等待超时时间)
Thread.sleep(100);
int cleaned = connectionManager.cleanupTimeoutConnections();
assertTrue(cleaned >= 0);
}
@Test
void testBroadcast() {
connectionManager.createConnection("user1", "127.0.0.1", "Test");
connectionManager.createConnection("user2", "127.0.0.1", "Test");
connectionManager.createConnection("user3", "127.0.0.1", "Test");
SseEvent event = SseEvent.systemBroadcast("系统通知");
int sent = connectionManager.broadcast(event);
assertEquals(3, sent);
}
}
MessageService 测试
java
package com.enterprise.sse.service;
import com.enterprise.sse.manager.SseConnectionManager;
import com.enterprise.sse.model.SseEvent;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.Arrays;
import java.util.concurrent.Executor;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 消息服务测试
*/
@ExtendWith(MockitoExtension.class)
class SseMessageServiceTest {
@Mock
private SseConnectionManager connectionManager;
@Mock
private StringRedisTemplate redisTemplate;
@Mock
private ObjectMapper objectMapper;
@Mock
private Executor taskExecutor;
@InjectMocks
private SseMessageService messageService;
@Test
void testSendToUser() {
String userId = "test-user";
SseEvent event = SseEvent.notification(userId, "测试");
when(connectionManager.sendToUser(eq(userId), any())).thenReturn(true);
boolean result = messageService.sendToUser(userId, event);
assertTrue(result);
verify(connectionManager, times(1)).sendToUser(eq(userId), any());
}
@Test
void testSendNotification() {
String userId = "test-user";
when(connectionManager.sendToUser(eq(userId), any())).thenReturn(true);
messageService.sendNotification(userId, "标题", "内容");
verify(connectionManager, times(1)).sendToUser(eq(userId), any());
}
@Test
void testSendProgress() {
String userId = "test-user";
when(connectionManager.sendToUser(eq(userId), any())).thenReturn(true);
messageService.sendProgress(userId, "task-1", 50, "处理中");
verify(connectionManager, times(1)).sendToUser(eq(userId), any());
}
@Test
void testBatchSend() {
when(connectionManager.sendToUser(anyString(), any())).thenReturn(true);
int sent = messageService.sendBatch(
Arrays.asList("user1", "user2", "user3"),
SseEvent.systemBroadcast("测试")
);
assertEquals(3, sent);
}
}
1.2 集成测试
java
package com.enterprise.sse.integration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* SSE 集成测试
*/
@SpringBootTest
@AutoConfigureMockMvc
class SseIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(username = "testuser")
void testSseConnect() throws Exception {
mockMvc.perform(get("/api/sse/connect")
.param("userId", "testuser"))
.andExpect(status().isOk())
.andExpect(header().string("Content-Type", "text/event-stream"));
}
@Test
void testSseConnectUnauthorized() throws Exception {
mockMvc.perform(get("/api/sse/connect")
.param("userId", "testuser"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser")
void testJoinGroup() throws Exception {
mockMvc.perform(post("/api/sse/group/test-group/join"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
@Test
@WithMockUser(username = "testuser")
void testGetStats() throws Exception {
mockMvc.perform(get("/api/sse/stats"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.activeConnections").exists());
}
@Test
@WithMockUser(username = "testuser")
void testGetOnlineUsers() throws Exception {
mockMvc.perform(get("/api/sse/online-users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.users").isArray());
}
}
1.3 压力测试
JMeter 测试计划
xml
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2">
<hashTree>
<TestPlan>
<stringProp name="TestPlan.comments">SSE 压力测试</stringProp>
<boolProp name="TestPlan.functional_mode">false</boolProp>
<boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
</TestPlan>
<hashTree>
<!-- 线程组:模拟1000个并发连接 -->
<ThreadGroup>
<stringProp name="ThreadGroup.num_threads">1000</stringProp>
<stringProp name="ThreadGroup.ramp_time">60</stringProp>
<stringProp name="ThreadGroup.duration">300</stringProp>
</ThreadGroup>
<hashTree>
<!-- HTTP 请求:建立 SSE 连接 -->
<HTTPSamplerProxy>
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">8080</stringProp>
<stringProp name="HTTPSampler.path">/api/sse/connect</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
</HTTPSamplerProxy>
</hashTree>
</hashTree>
</hashTree>
</jmeterTestPlan>
Gatling 压力测试脚本
scala
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._
class SseLoadTest extends Simulation {
val httpProtocol = http
.baseUrl("http://localhost:8080")
.acceptHeader("text/event-stream")
.basicAuth("admin", "admin123")
val scn = scenario("SSE Load Test")
.exec(
sse("Connect").get("/api/sse/connect?userId=${userId}")
.await(300.seconds)(
sse.checkMessage("check").check(regex(".*"))
)
)
setUp(
scn.inject(
rampUsers(1000) during (60.seconds)
).protocols(httpProtocol)
)
}
1.4 性能测试报告模板
markdown
# SSE 性能测试报告
## 测试环境
- 服务器: AWS EC2 t3.large (2 vCPU, 8GB RAM)
- 数据库: Redis 6.2
- 网络: 1Gbps
## 测试场景
- 并发连接数: 5000
- 测试时长: 5分钟
- 消息推送频率: 1条/秒/连接
## 测试结果
| 指标 | 数值 |
|------|------|
| 最大并发连接数 | 5000 |
| 平均响应时间 | 45ms |
| 95% 响应时间 | 120ms |
| 99% 响应时间 | 250ms |
| 错误率 | 0.01% |
| CPU 使用率 | 65% |
| 内存使用率 | 4.2GB |
## 结论
系统在5000并发连接下运行稳定,满足生产环境要求。
2. Docker 部署
2.1 Dockerfile
dockerfile
FROM eclipse-temurin:17-jre-alpine
# 设置工作目录
WORKDIR /app
# 安装必要工具
RUN apk add --no-cache curl
# 复制 JAR 文件
COPY target/sse-demo-1.0.0.jar app.jar
# 创建日志目录
RUN mkdir -p /app/logs
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
# 暴露端口
EXPOSE 8080
# JVM 参数优化
ENV JAVA_OPTS="-Xms512m -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200"
# 启动应用
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
2.2 docker-compose.yml
yaml
version: '3.8'
services:
# Redis
redis:
image: redis:7-alpine
container_name: sse-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
networks:
- sse-network
# SSE 应用
sse-app:
build: .
container_name: sse-app
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
- REDIS_HOST=redis
- REDIS_PORT=6379
- SERVER_PORT=8080
depends_on:
redis:
condition: service_healthy
volumes:
- ./logs:/app/logs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 5s
retries: 3
networks:
- sse-network
restart: unless-stopped
# Prometheus
prometheus:
image: prom/prometheus:latest
container_name: sse-prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
networks:
- sse-network
# Grafana
grafana:
image: grafana/grafana:latest
container_name: sse-grafana
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana-data:/var/lib/grafana
depends_on:
- prometheus
networks:
- sse-network
networks:
sse-network:
driver: bridge
volumes:
redis-data:
prometheus-data:
grafana-data:
2.3 Prometheus 配置
yaml
# prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'sse-app'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['sse-app:8080']
3. Kubernetes 部署
3.1 Deployment
yaml
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: sse-service
labels:
app: sse-service
spec:
replicas: 3
selector:
matchLabels:
app: sse-service
template:
metadata:
labels:
app: sse-service
spec:
containers:
- name: sse-service
image: your-registry/sse-service:1.0.0
ports:
- containerPort: 8080
name: http
env:
- name: SPRING_PROFILES_ACTIVE
value: "prod"
- name: REDIS_HOST
value: "redis-service"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: redis-secret
key: password
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "2000m"
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumeMounts:
- name: logs
mountPath: /app/logs
volumes:
- name: logs
emptyDir: {}
3.2 Service
yaml
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: sse-service
spec:
type: ClusterIP
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800 # 3小时
ports:
- port: 8080
targetPort: 8080
protocol: TCP
name: http
selector:
app: sse-service
3.3 Ingress
yaml
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sse-ingress
annotations:
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-read-timeout: "86400"
nginx.ingress.kubernetes.io/proxy-send-timeout: "86400"
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/session-cookie-name: "sse-route"
nginx.ingress.kubernetes.io/session-cookie-max-age: "10800"
spec:
ingressClassName: nginx
rules:
- host: sse.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sse-service
port:
number: 8080
tls:
- hosts:
- sse.example.com
secretName: sse-tls-secret
3.4 ConfigMap
yaml
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: sse-config
data:
application.yml: |
spring:
application:
name: sse-service
data:
redis:
host: ${REDIS_HOST}
port: 6379
sse:
connection:
max-per-user: 3
global-limit: 10000
timeout-seconds: 3600
heartbeat:
enabled: true
interval-seconds: 30
3.5 HorizontalPodAutoscaler
yaml
# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: sse-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: sse-service
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 15
4. Nginx 负载均衡配置
4.1 完整 Nginx 配置
nginx
# nginx.conf
upstream sse_backend {
# IP Hash 保证粘性会话
ip_hash;
server 192.168.1.101:8080 max_fails=3 fail_timeout=30s weight=1;
server 192.168.1.102:8080 max_fails=3 fail_timeout=30s weight=1;
server 192.168.1.103:8080 max_fails=3 fail_timeout=30s weight=1;
keepalive 32;
}
server {
listen 80;
server_name sse.example.com;
# 强制 HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name sse.example.com;
# SSL 证书
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# 日志
access_log /var/log/nginx/sse-access.log;
error_log /var/log/nginx/sse-error.log;
# SSE 连接端点
location /api/sse/connect {
proxy_pass http://sse_backend;
# SSE 必需配置
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
# 超时配置
proxy_read_timeout 24h;
proxy_connect_timeout 5s;
proxy_send_timeout 24h;
# 传递客户端信息
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 禁用压缩
proxy_set_header Accept-Encoding "";
# 关闭代理缓冲
proxy_set_header X-Accel-Buffering no;
}
# 其他 API 端点
location /api/ {
proxy_pass http://sse_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 标准超时
proxy_read_timeout 60s;
proxy_connect_timeout 5s;
}
# 健康检查
location /actuator/health {
proxy_pass http://sse_backend;
access_log off;
}
# 静态资源
location /static/ {
alias /var/www/sse/static/;
expires 7d;
add_header Cache-Control "public, immutable";
}
}
5. 监控和告警
5.1 Grafana Dashboard JSON
json
{
"dashboard": {
"title": "SSE Service Monitoring",
"panels": [
{
"title": "Active Connections",
"targets": [
{
"expr": "sse_connections_created_total - sse_connections_closed_total"
}
]
},
{
"title": "Message Send Rate",
"targets": [
{
"expr": "rate(sse_messages_sent_total[1m])"
}
]
},
{
"title": "Message Failure Rate",
"targets": [
{
"expr": "rate(sse_messages_failed_total[1m]) / rate(sse_messages_sent_total[1m])"
}
]
}
]
}
}
5.2 Prometheus 告警规则
yaml
# alert-rules.yml
groups:
- name: sse_alerts
interval: 30s
rules:
# 连接数过高告警
- alert: HighConnectionCount
expr: sse_connections_created_total - sse_connections_closed_total > 8000
for: 5m
labels:
severity: warning
annotations:
summary: "SSE连接数过高"
description: "当前连接数 {{ $value }}, 超过阈值8000"
# 消息发送失败率告警
- alert: HighMessageFailureRate
expr: rate(sse_messages_failed_total[5m]) / rate(sse_messages_sent_total[5m]) > 0.05
for: 2m
labels:
severity: critical
annotations:
summary: "消息发送失败率过高"
description: "失败率 {{ $value | humanizePercentage }}"
# 服务不可用告警
- alert: ServiceDown
expr: up{job="sse-app"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "SSE服务不可用"
description: "实例 {{ $labels.instance }} 已下线"
6. 常见问题排查
6.1 问题诊断清单
| 问题 | 检查项 | 解决方案 |
|---|---|---|
| 连接立即断开 | 防火墙、Nginx配置 | 检查端口开放,配置正确 |
| 消息未收到 | Redis连接、订阅状态 | 验证Redis可用性 |
| 内存持续增长 | 僵尸连接、内存泄漏 | 启用定时清理任务 |
| CPU使用率高 | 线程池、消息频率 | 调整线程池大小 |
| 网络延迟高 | 带宽、网络拓扑 | 优化网络配置 |
6.2 日志分析
bash
# 查看错误日志
tail -f logs/sse-service.log | grep ERROR
# 统计连接数
grep "创建 SSE 连接" logs/sse-service.log | wc -l
# 查看最近的异常
grep -A 10 "Exception" logs/sse-service.log | tail -20
# 分析慢查询
grep "slow" logs/sse-service.log