Spring Boot SSE 实时推送教程
基于 Spring Boot 4.1.1 + Java 25 + WebFlux + WebMVC 的 Server-Sent Events 实战教程

一、项目概述
本项目通过两个相互独立的案例,对比演示 SSE 在命令式(WebMVC)和响应式(WebFlux)两种编程模型下的实现方式。阅读本文档即可独立完成全部代码,无需额外参考资料。
- 案例一(订单跟踪):WebMVC + SseEmitter + 虚拟线程 + Jackson ------ 命令式风格
- 案例二(服务器监控):WebFlux + Flux + 真实硬件数据(MXBeans + nvidia-smi)------ 响应式风格
1.1 架构图
两个案例共享同一个前端大屏页面(index.html),后端通过两个独立的 Controller 提供 SSE 服务:
#mermaid-svg-iVcFHD4N15Wjg3jw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iVcFHD4N15Wjg3jw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iVcFHD4N15Wjg3jw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iVcFHD4N15Wjg3jw .error-icon{fill:#552222;}#mermaid-svg-iVcFHD4N15Wjg3jw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iVcFHD4N15Wjg3jw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iVcFHD4N15Wjg3jw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iVcFHD4N15Wjg3jw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iVcFHD4N15Wjg3jw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iVcFHD4N15Wjg3jw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iVcFHD4N15Wjg3jw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iVcFHD4N15Wjg3jw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iVcFHD4N15Wjg3jw .marker.cross{stroke:#333333;}#mermaid-svg-iVcFHD4N15Wjg3jw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iVcFHD4N15Wjg3jw p{margin:0;}#mermaid-svg-iVcFHD4N15Wjg3jw .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-iVcFHD4N15Wjg3jw .cluster-label text{fill:#333;}#mermaid-svg-iVcFHD4N15Wjg3jw .cluster-label span{color:#333;}#mermaid-svg-iVcFHD4N15Wjg3jw .cluster-label span p{background-color:transparent;}#mermaid-svg-iVcFHD4N15Wjg3jw .label text,#mermaid-svg-iVcFHD4N15Wjg3jw span{fill:#333;color:#333;}#mermaid-svg-iVcFHD4N15Wjg3jw .node rect,#mermaid-svg-iVcFHD4N15Wjg3jw .node circle,#mermaid-svg-iVcFHD4N15Wjg3jw .node ellipse,#mermaid-svg-iVcFHD4N15Wjg3jw .node polygon,#mermaid-svg-iVcFHD4N15Wjg3jw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-iVcFHD4N15Wjg3jw .rough-node .label text,#mermaid-svg-iVcFHD4N15Wjg3jw .node .label text,#mermaid-svg-iVcFHD4N15Wjg3jw .image-shape .label,#mermaid-svg-iVcFHD4N15Wjg3jw .icon-shape .label{text-anchor:middle;}#mermaid-svg-iVcFHD4N15Wjg3jw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-iVcFHD4N15Wjg3jw .rough-node .label,#mermaid-svg-iVcFHD4N15Wjg3jw .node .label,#mermaid-svg-iVcFHD4N15Wjg3jw .image-shape .label,#mermaid-svg-iVcFHD4N15Wjg3jw .icon-shape .label{text-align:center;}#mermaid-svg-iVcFHD4N15Wjg3jw .node.clickable{cursor:pointer;}#mermaid-svg-iVcFHD4N15Wjg3jw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-iVcFHD4N15Wjg3jw .arrowheadPath{fill:#333333;}#mermaid-svg-iVcFHD4N15Wjg3jw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-iVcFHD4N15Wjg3jw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-iVcFHD4N15Wjg3jw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iVcFHD4N15Wjg3jw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-iVcFHD4N15Wjg3jw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iVcFHD4N15Wjg3jw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-iVcFHD4N15Wjg3jw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-iVcFHD4N15Wjg3jw .cluster text{fill:#333;}#mermaid-svg-iVcFHD4N15Wjg3jw .cluster span{color:#333;}#mermaid-svg-iVcFHD4N15Wjg3jw div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-iVcFHD4N15Wjg3jw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-iVcFHD4N15Wjg3jw rect.text{fill:none;stroke-width:0;}#mermaid-svg-iVcFHD4N15Wjg3jw .icon-shape,#mermaid-svg-iVcFHD4N15Wjg3jw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iVcFHD4N15Wjg3jw .icon-shape p,#mermaid-svg-iVcFHD4N15Wjg3jw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-iVcFHD4N15Wjg3jw .icon-shape .label rect,#mermaid-svg-iVcFHD4N15Wjg3jw .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iVcFHD4N15Wjg3jw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-iVcFHD4N15Wjg3jw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-iVcFHD4N15Wjg3jw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 数据源
Spring Boot 服务端 :8080
浏览器端 index.html
案例一面板
步骤条 + 事件日志
案例二面板
5 仪表盘 + 3 趋势线
EventSource 订单订阅
EventSource 监控订阅
SseEmitterController
WebMVC + 虚拟线程
FluxSseController
WebFlux + Flux
订单状态 Map
模拟数据库
JDK MXBeans
CPU / 内存 / 磁盘 / GC
nvidia-smi
NVIDIA 显卡
1.2 技术栈
| 技术 | 版本 | 说明 |
|---|---|---|
| Java | 25 | LTS 版本,支持虚拟线程 |
| Spring Boot | 4.1.1 | 主框架 |
| Spring WebMVC | - | 案例一:Servlet 容器 |
| Spring WebFlux | - | 案例二:响应式容器 |
| Jackson | - | JSON 序列化(ObjectMapper) |
| Logback | 1.5.38 | 日志框架(SLF4J 实现,不使用 Lombok) |
| ECharts | 6.1 | 前端数据可视化 |
1.3 项目结构
sse/
├── pom.xml # Maven 依赖配置
├── README.md # 本教程文档
├── img/ # 文档图片
├── src/main/java/com/lihaozhe/sse/
│ ├── SseApplication.java # 启动类
│ └── controller/
│ ├── SseEmitterController.java # 案例一:订单跟踪(WebMVC + SseEmitter)
│ └── FluxSseController.java # 案例二:服务器监控(WebFlux + Flux)
└── src/main/resources/
├── application.yaml # 应用配置(启用虚拟线程)
└── static/index.html # 前端数据大屏(Claude 风格)
二、SSE 基础知识
2.1 什么是 SSE?
SSE(Server-Sent Events)是一种服务器向客户端单向推送 数据的协议,基于 HTTP 长连接,响应头为 Content-Type: text/event-stream。
2.2 SSE vs WebSocket
| 特性 | SSE | WebSocket |
|---|---|---|
| 方向 | 服务器 → 客户端(单向) | 双向 |
| 协议 | 标准 HTTP | 独立协议(ws://) |
| 数据格式 | 纯文本 | 文本 / 二进制 |
| 自动重连 | 浏览器内置支持 | 需手动实现 |
| 客户端 API | 原生 EventSource |
需引入库(如 SockJS) |
| 适用场景 | 实时通知、监控看板、日志流、订单跟踪 | 聊天、游戏、协同编辑 |
结论:只需要服务器到客户端的推送时,SSE 更轻量、更简单;需要双向通信才用 WebSocket。
2.3 SSE 协议格式
服务端按以下文本格式推送,每一帧用空行分隔:
id: 1
event: status_change
data: {"orderId":"ORD-1A2B3C4D","status":"支付成功"}
id: 2
event: logistics_update
data: {"location":"北京市朝阳区分拣中心"}
event:事件名,客户端用EventSource.addEventListener('事件名', ...)或onmessage(默认message)接收id:事件编号,重连时浏览器通过Last-Event-ID请求头自动携带- 空行:帧结束标记
2.4 一次 SSE 轮询的简化流程图

三、两种技术方案的对比和选择
这是本教程的核心理论部分。两条实现路线之争本质是 命令式(Imperative)与响应式(Reactive) 的编程范式之争。
3.1 编程模型根本差异

- 命令式(WebMVC + SseEmitter) :写代码的人说"第 1 步做什么、第 2 步做什么"。每个请求占用一条线程,线程睡眠等待、手动循环、手动推送。阻塞式 ------
Thread.sleep/send会卡住当前线程。 - 响应式(WebFlux + Flux) :写代码的人只"描述"一条数据管道(每秒取数 → 构建事件 → 推给订阅者),真正执行交给框架的事件循环。非阻塞------等待时不占用业务线程。
3.2 多维度对比表
| 对比维度 | 案例一(WebMVC + SseEmitter) | 案例二(WebFlux + Flux) |
|---|---|---|
| 编程模型 | 命令式(手动 send()、手动循环) |
响应式(声明式管道) |
| 线程模型 | 每个任务 / 连接占用一条线程(虚拟线程) | 事件循环(Reactor Netty,少量线程服务全部连接) |
| 推送节奏控制 | 命令式配合 Thread.sleep,由订单流程驱动 |
Flux.interval 定时驱动 |
| 数据构建 | ObjectMapper 手动构建 JSON |
ObjectMapper 手动构建 JSON |
| 客户端断开感知 | send() 抛 IOException(下次写失败才知道) |
取消信号立即回调 doOnCancel |
| 资源管理 | 手动注册 / 移除连接池 | 框架自动管理订阅生命周期 |
| 超时处理 | new SseEmitter(超时毫秒) |
无需(框架管理) |
| 多客户端广播 | CopyOnWriteArrayList 手动遍历推送 |
每个请求独立 Flux,天然隔离 |
| 学习曲线 | 低,普通 Java 程序员零门槛 | 高,需理解 Reactive Streams / 反压 |
| 生态依赖 | Servlet 栈传统生态,所有老代码兼容 | 需要 R2DBC / WebClient 等配合,且贯穿全链路 |
| 排查难度 | 异常栈、断点调试直观 | 调用链路深、栈被"反应堆化",调试成本高 |
3.3 背压(Backpressure)
- 问题:生产者推送速率 > 消费者处理速率时,消息如何不把内存撑爆?
- 案例一 :命令式没有内建背压,广播多快取决于
startOrderFlow的节奏(受Thread.sleep控制);高速推送场景需自己实现队列限流。 - 案例二 :Reactor 内建
request(n)背压机制,订阅者未拉取的数据由管道缓冲或丢帧(onBackpressureLatest等),框架层处理。
3.4 什么时候选哪个?(选型决策树)

3.5 经验法则(满分心智模型)
- 状态机类流程 (有明确的阶段推进 + 每一步耗时不可控):→ SseEmitter + 虚拟线程。订单跟踪属于此类,业务代码就是一支流程函数,好写、好测、好排错。
- 节拍驱动流 (固定间隔采集 / 转发,连接可能长期挂住、连接数可上千):→ WebFlux + Flux。监控看板属于此类,服务整个连接池不占一整条等待线程。
- 全新云原生项目 + 团队已全链路响应式 :→ 首选 WebFlux,享受 IO 密集场景下的高连接价值。
- 遗留 Servlet 项目升级 / 深度依赖 ThreadLocal / JPA 生态 :→ 用 WebMVC + 虚拟线程,不必引入响应式的复杂度。
一句话:"追求每连接吞吐极限选 WebFlux;追求简单、可控、贴合业务思维选虚拟线程。"
四、案例一:电商订单状态跟踪(WebMVC + SseEmitter)
4.1 场景描述
模拟电商平台的订单状态实时推送。就像淘宝 / 京东的"订单详情"页面,状态自动更新,无需手动刷新:
已下单(1) → 支付成功(2) → 商家接单(3) → 配送中(4,含物流播报) → 已签收(5) → 完成(-1)
4.2 订单状态流程图
#mermaid-svg-TdxCSk825KbIURTf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-TdxCSk825KbIURTf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-TdxCSk825KbIURTf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-TdxCSk825KbIURTf .error-icon{fill:#552222;}#mermaid-svg-TdxCSk825KbIURTf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-TdxCSk825KbIURTf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-TdxCSk825KbIURTf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-TdxCSk825KbIURTf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-TdxCSk825KbIURTf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-TdxCSk825KbIURTf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-TdxCSk825KbIURTf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-TdxCSk825KbIURTf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-TdxCSk825KbIURTf .marker.cross{stroke:#333333;}#mermaid-svg-TdxCSk825KbIURTf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-TdxCSk825KbIURTf p{margin:0;}#mermaid-svg-TdxCSk825KbIURTf .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-TdxCSk825KbIURTf .cluster-label text{fill:#333;}#mermaid-svg-TdxCSk825KbIURTf .cluster-label span{color:#333;}#mermaid-svg-TdxCSk825KbIURTf .cluster-label span p{background-color:transparent;}#mermaid-svg-TdxCSk825KbIURTf .label text,#mermaid-svg-TdxCSk825KbIURTf span{fill:#333;color:#333;}#mermaid-svg-TdxCSk825KbIURTf .node rect,#mermaid-svg-TdxCSk825KbIURTf .node circle,#mermaid-svg-TdxCSk825KbIURTf .node ellipse,#mermaid-svg-TdxCSk825KbIURTf .node polygon,#mermaid-svg-TdxCSk825KbIURTf .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-TdxCSk825KbIURTf .rough-node .label text,#mermaid-svg-TdxCSk825KbIURTf .node .label text,#mermaid-svg-TdxCSk825KbIURTf .image-shape .label,#mermaid-svg-TdxCSk825KbIURTf .icon-shape .label{text-anchor:middle;}#mermaid-svg-TdxCSk825KbIURTf .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-TdxCSk825KbIURTf .rough-node .label,#mermaid-svg-TdxCSk825KbIURTf .node .label,#mermaid-svg-TdxCSk825KbIURTf .image-shape .label,#mermaid-svg-TdxCSk825KbIURTf .icon-shape .label{text-align:center;}#mermaid-svg-TdxCSk825KbIURTf .node.clickable{cursor:pointer;}#mermaid-svg-TdxCSk825KbIURTf .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-TdxCSk825KbIURTf .arrowheadPath{fill:#333333;}#mermaid-svg-TdxCSk825KbIURTf .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-TdxCSk825KbIURTf .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-TdxCSk825KbIURTf .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TdxCSk825KbIURTf .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-TdxCSk825KbIURTf .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TdxCSk825KbIURTf .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-TdxCSk825KbIURTf .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-TdxCSk825KbIURTf .cluster text{fill:#333;}#mermaid-svg-TdxCSk825KbIURTf .cluster span{color:#333;}#mermaid-svg-TdxCSk825KbIURTf div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-TdxCSk825KbIURTf .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-TdxCSk825KbIURTf rect.text{fill:none;stroke-width:0;}#mermaid-svg-TdxCSk825KbIURTf .icon-shape,#mermaid-svg-TdxCSk825KbIURTf .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TdxCSk825KbIURTf .icon-shape p,#mermaid-svg-TdxCSk825KbIURTf .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-TdxCSk825KbIURTf .icon-shape .label rect,#mermaid-svg-TdxCSk825KbIURTf .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TdxCSk825KbIURTf .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-TdxCSk825KbIURTf .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-TdxCSk825KbIURTf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1 已下单
2 支付成功
3 商家接单
4 配送中
物流播报 1
物流播报 2
物流播报 3
5 已签收
-1 完成
连接关闭
4.3 订单全流程时序图
虚拟线程 SseEmitterController EventSource 浏览器 虚拟线程 SseEmitterController EventSource 浏览器 #mermaid-svg-VPlQIYKwjAjGiQcU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VPlQIYKwjAjGiQcU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VPlQIYKwjAjGiQcU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VPlQIYKwjAjGiQcU .error-icon{fill:#552222;}#mermaid-svg-VPlQIYKwjAjGiQcU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VPlQIYKwjAjGiQcU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VPlQIYKwjAjGiQcU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VPlQIYKwjAjGiQcU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VPlQIYKwjAjGiQcU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VPlQIYKwjAjGiQcU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VPlQIYKwjAjGiQcU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VPlQIYKwjAjGiQcU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VPlQIYKwjAjGiQcU .marker.cross{stroke:#333333;}#mermaid-svg-VPlQIYKwjAjGiQcU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VPlQIYKwjAjGiQcU p{margin:0;}#mermaid-svg-VPlQIYKwjAjGiQcU .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VPlQIYKwjAjGiQcU text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-VPlQIYKwjAjGiQcU .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VPlQIYKwjAjGiQcU .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-VPlQIYKwjAjGiQcU .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-VPlQIYKwjAjGiQcU .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-VPlQIYKwjAjGiQcU #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-VPlQIYKwjAjGiQcU .sequenceNumber{fill:white;}#mermaid-svg-VPlQIYKwjAjGiQcU #sequencenumber{fill:#333;}#mermaid-svg-VPlQIYKwjAjGiQcU #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-VPlQIYKwjAjGiQcU .messageText{fill:#333;stroke:none;}#mermaid-svg-VPlQIYKwjAjGiQcU .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VPlQIYKwjAjGiQcU .labelText,#mermaid-svg-VPlQIYKwjAjGiQcU .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-VPlQIYKwjAjGiQcU .loopText,#mermaid-svg-VPlQIYKwjAjGiQcU .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-VPlQIYKwjAjGiQcU .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VPlQIYKwjAjGiQcU .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-VPlQIYKwjAjGiQcU .noteText,#mermaid-svg-VPlQIYKwjAjGiQcU .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-VPlQIYKwjAjGiQcU .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VPlQIYKwjAjGiQcU .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VPlQIYKwjAjGiQcU .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VPlQIYKwjAjGiQcU .actorPopupMenu{position:absolute;}#mermaid-svg-VPlQIYKwjAjGiQcU .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-VPlQIYKwjAjGiQcU .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VPlQIYKwjAjGiQcU .actor-man circle,#mermaid-svg-VPlQIYKwjAjGiQcU line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-VPlQIYKwjAjGiQcU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /sse/order/create submit(startOrderFlow) {orderId, subscribeUrl} new EventSource("/sse/order/orderId") GET 订阅(text/event-stream) 注册 SseEmitter 到连接池 事件 status_change(初始快照) 3s 后 updateStatus(2) 事件 status_change(支付成功) 2s 后 updateStatus(3) 事件 status_change(商家接单) 3s 后开始配送,每 2s 一次 事件 logistics_update ×3(含 statusCode 4) 2s 后 updateStatus(5) 事件 status_change(已签收) 广播 complete 事件 complete(订单流程全部完成) 延迟 5s closeAllClients complete 帧(连接关闭)
4.4 pom.xml(完整)
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sse</artifactId>
<version>0.0.1</version>
<name>sse</name>
<description>SSE 实时推送教程</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<!-- 案例一:WebMVC + 虚拟线程 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- 案例二:WebFlux -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- JSON 序列化:Jackson -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
4.5 application.yaml(完整)
yaml
spring:
application:
name: sse
threads:
virtual:
enabled: true # 启用虚拟线程(案例一依赖此配置)
4.6 启动类 SseApplication.java(完整)
java
package com.lihaozhe.sse;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SseApplication {
public static void main(String[] args) {
SpringApplication.run(SseApplication.class, args);
}
}
4.7 案例 1 全源码 SseEmitterController.java(完整)
java
package com.lihaozhe.sse.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.*;
/**
* ============================================================
* 案例一:电商订单状态跟踪(WebMVC + SseEmitter + 虚拟线程)
* ============================================================
*
* 【现实场景】
* 用户下单后,页面通过 SSE 实时接收订单状态变化:
* 已下单 → 支付成功 → 商家接单 → 配送中 → 已签收
* 就像淘宝/京东的"订单详情"页面,状态自动更新,无需手动刷新。
*
* 【测试方式】
* 第1步:POST 创建订单
* curl -X POST http://localhost:8080/sse/order/create?product=xiaomi 17
* 第2步:GET 订阅订单状态(浏览器打开即可看到实时推送)
* http://localhost:8080/sse/order/ORD-XXXX
*
* 【SSE 事件类型】
* event: status_change → 订单状态变化
* event: logistics_update → 物流位置更新
* event: heartbeat → 心跳保活
* event: complete → 订单流程结束
* ============================================================
*/
@RestController
public class SseEmitterController {
private static final Logger log = LoggerFactory.getLogger(SseEmitterController.class);
private final ObjectMapper objectMapper = new ObjectMapper();
// ========== 订单存储(模拟数据库) ==========
private final ConcurrentHashMap<String, Integer> orderStatusMap = new ConcurrentHashMap<>();
// ========== SSE 客户端连接池 ==========
private final ConcurrentHashMap<String, CopyOnWriteArrayList<SseEmitter>> clientMap = new ConcurrentHashMap<>();
// 虚拟线程执行器
private final ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
/**
* 创建订单(模拟用户下单)
*/
@PostMapping(value = "/sse/order/create", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> createOrder(@RequestParam(defaultValue = "Spring Boot 实战教程") String product) {
String orderId = "ORD-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
orderStatusMap.put(orderId, 1);
log.info("订单创建: {} - {}", orderId, product);
// 在虚拟线程中启动状态自动流转
virtualExecutor.submit(() -> startOrderFlow(orderId, product));
return Map.of(
"orderId", orderId,
"product", product,
"status", "已下单",
"subscribeUrl", "/sse/order/" + orderId
);
}
/**
* 订阅订单状态(SSE 端点)
*/
@GetMapping(value = "/sse/order/{orderId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribeOrder(@PathVariable String orderId) {
// 校验订单是否存在
if (!orderStatusMap.containsKey(orderId)) {
SseEmitter errorEmitter = new SseEmitter();
virtualExecutor.submit(() -> {
try {
ObjectNode error = objectMapper.createObjectNode();
error.put("error", "订单不存在: " + orderId);
error.put("hint", "请先 POST /sse/order/create 创建订单");
errorEmitter.send(SseEmitter.event()
.name("error")
.data(objectMapper.writeValueAsString(error)));
errorEmitter.complete();
} catch (Exception ignored) {
}
});
return errorEmitter;
}
// 创建 SSE 连接,超时 30 分钟
SseEmitter emitter = new SseEmitter(1_800_000L);
// 注册到客户端连接池
clientMap.computeIfAbsent(orderId, k -> new CopyOnWriteArrayList<>()).add(emitter);
log.info("客户端订阅订单 {},当前连接数: {}", orderId, clientMap.get(orderId).size());
// 客户端断开时自动清理
emitter.onCompletion(() -> removeClient(orderId, emitter));
emitter.onTimeout(() -> removeClient(orderId, emitter));
emitter.onError(e -> removeClient(orderId, emitter));
// 连接建立后,立即推送当前订单状态
virtualExecutor.submit(() -> {
try {
int currentStatus = orderStatusMap.getOrDefault(orderId, 1);
String statusText = getStatusText(currentStatus);
ObjectNode node = objectMapper.createObjectNode();
node.put("orderId", orderId);
node.put("status", statusText);
node.put("statusCode", currentStatus);
node.put("message", "已连接,当前订单状态: " + statusText);
node.put("type", "snapshot");
emitter.send(SseEmitter.event()
.name("status_change")
.id(orderId)
.data(objectMapper.writeValueAsString(node)));
} catch (Exception e) {
log.debug("推送初始状态失败: {}", e.getMessage());
}
});
return emitter;
}
/**
* 模拟订单状态自动流转(后台定时任务驱动)
*
* 真实项目中,这个流程由支付回调、物流系统回调等异步事件驱动。
*/
private void startOrderFlow(String orderId, String product) {
try {
// ===== 阶段1→2:等 3 秒后支付成功 =====
Thread.sleep(3000);
updateStatus(orderId, product, 2, "支付成功", "支付宝支付 ¥99.00 成功");
// ===== 阶段2→3:等 2 秒后商家接单 =====
Thread.sleep(2000);
updateStatus(orderId, product, 3, "商家接单", "商家已确认订单,正在打包");
// ===== 阶段3→4:等 3 秒后开始配送 =====
Thread.sleep(3000);
String[] locations = {"北京市朝阳区仓库", "北京市海淀区分拣中心", "海淀区中关村营业点"};
String[] messages = {"包裹已从仓库发出", "包裹已到达分拣中心", "快递员正在派送,请保持手机畅通"};
for (int i = 0; i < locations.length; i++) {
Thread.sleep(2000);
ObjectNode logistics = objectMapper.createObjectNode();
logistics.put("orderId", orderId);
logistics.put("status", "配送中");
logistics.put("statusCode", 4);
logistics.put("location", locations[i]);
logistics.put("progress", (i + 1) + "/" + locations.length);
logistics.put("message", messages[i]);
broadcastToOrder(orderId, "logistics_update", objectMapper.writeValueAsString(logistics));
}
// ===== 阶段4→5:等 2 秒后签收 =====
Thread.sleep(2000);
updateStatus(orderId, product, 5, "已签收", "订单已完成,感谢您的购买!");
// 标记完成
orderStatusMap.put(orderId, -1);
ObjectNode done = objectMapper.createObjectNode();
done.put("orderId", orderId);
done.put("message", "订单流程全部完成");
broadcastToOrder(orderId, "complete", objectMapper.writeValueAsString(done));
// 延迟 5 秒后关闭所有连接
Thread.sleep(5000);
closeAllClients(orderId);
log.info("订单 {} 流程全部完成,连接已关闭", orderId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("订单 {} 流程被中断", orderId);
} catch (Exception e) {
log.error("订单 {} 流程异常: {}", orderId, e.getMessage());
}
}
/**
* 更新订单状态并广播给所有监听客户端
*/
private void updateStatus(String orderId, String product, int statusCode, String status, String message) throws Exception {
orderStatusMap.put(orderId, statusCode);
ObjectNode node = objectMapper.createObjectNode();
node.put("orderId", orderId);
node.put("product", product);
node.put("status", status);
node.put("statusCode", statusCode);
node.put("message", message);
broadcastToOrder(orderId, "status_change", objectMapper.writeValueAsString(node));
}
/**
* 广播消息给订阅该订单的所有客户端
*/
private void broadcastToOrder(String orderId, String eventName, String data) {
CopyOnWriteArrayList<SseEmitter> clients = clientMap.get(orderId);
if (clients == null || clients.isEmpty()) return;
for (SseEmitter emitter : clients) {
try {
emitter.send(SseEmitter.event()
.name(eventName)
.id(orderId)
.data(data));
} catch (IOException e) {
clients.remove(emitter);
log.debug("移除断开的客户端: {}", orderId);
}
}
}
/**
* 关闭订单的所有 SSE 连接
*/
private void closeAllClients(String orderId) {
CopyOnWriteArrayList<SseEmitter> clients = clientMap.remove(orderId);
if (clients != null) {
for (SseEmitter emitter : clients) {
emitter.complete();
}
clients.clear();
}
}
/**
* 从客户端池中移除指定连接
*/
private void removeClient(String orderId, SseEmitter emitter) {
CopyOnWriteArrayList<SseEmitter> clients = clientMap.get(orderId);
if (clients != null) {
clients.remove(emitter);
}
}
/**
* 状态码 → 状态文本
*/
private String getStatusText(int statusCode) {
return switch (statusCode) {
case 1 -> "已下单";
case 2 -> "支付成功";
case 3 -> "商家接单";
case 4 -> "配送中";
case 5 -> "已签收";
default -> "未知";
};
}
}
4.8 关键设计点详解
- 虚拟线程 :
Executors.newVirtualThreadPerTaskExecutor()。每个订单流程占用一条"轻量级线程",Thread.sleep挂起的成本极低,成本差异相比平台线程可达上千倍。 - Data 结构选择 :订单状态
ConcurrentHashMap、连接池包裹CopyOnWriteArrayList。读多写少 + 遍历时可能被改的场景,选CopyOnWriteArrayList避免手动加锁。 - 断开感知 :命令式的痛点。
emitter.send()内部写网络流,失败抛IOException才知道客户端已断开。所以广播时用 try-catch 包住,移除失败的连接。 - 三重回调兜底 :
onCompletion/onTimeout/onError,无论连接关闭的原因是什么,都从连接池移除 emitter,避免内存泄漏。
五、案例二:服务器实时监控看板(WebFlux + Flux)
5.1 场景描述
实时采集服务器硬件数据(CPU、内存、GPU、磁盘等),通过 SSE 推送到前端看板。重点:所有数据都是真实数据,不使用随机数。
5.2 真实数据来源
| 数据 | API | 说明 |
|---|---|---|
| 系统 CPU | OperatingSystemMXBean.getCpuLoad() |
JDK 14+ 提供 |
| 进程 CPU | OperatingSystemMXBean.getProcessCpuLoad() |
JVM 进程级 |
| 系统内存 | OperatingSystemMXBean.getTotalMemorySize() / getFreeMemorySize() |
总物理内存 / 可用物理内存 |
| JVM 堆 | MemoryMXBean.getHeapMemoryUsage() |
堆内存使用情况 |
| 磁盘 | java.io.File.listRoots() |
所有磁盘分区 |
| GPU | nvidia-smi 命令行 |
NVIDIA 显卡专用 |
| GC | GarbageCollectorMXBean |
垃圾回收统计 |
| JVM 运行时 | RuntimeMXBean / ThreadMXBean |
运行时长 / 线程数 |
| 网络 | NetworkInterface |
本机 IP 地址列表 |
为什么 WebFlux 案例里不使用随机数?随机数虽然能演示"数据在变化",但违背了"贴近真实场景"的教学目标。MXBeans 是 JDK 自带的标准接口,
getCpuLoad()等方法直接反映真实系统状态,零成本获取。
5.3 案例 2 全源码 FluxSseController.java(完整)
java
package com.lihaozhe.sse.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.sun.management.OperatingSystemMXBean;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.net.InetAddress;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* ============================================================
* 案例二:实时服务器监控看板(WebFlux + Flux 响应式流 + 真实硬件数据)
* ============================================================
*
* 数据来源(JDK MXBeans + nvidia-smi,全部真实数据):
* 系统级 CPU → OperatingSystemMXBean.getCpuLoad()
* 进程级 CPU → OperatingSystemMXBean.getProcessCpuLoad()
* 系统内存 → OperatingSystemMXBean(总内存/可用内存)
* JVM 堆内存 → MemoryMXBean
* 磁盘 → java.io.File(所有分区)
* 网络地址 → NetworkInterface(本机 IPv4 地址列表)
* GC / 类加载 → GarbageCollectorMXBean / ClassLoadingMXBean
* JVM 运行时 → RuntimeMXBean / ThreadMXBean
* GPU → nvidia-smi 命令行(NVIDIA 显卡)
*
* Flux 生命周期钩子:
* doOnSubscribe → 客户端连接时触发
* doOnCancel → 客户端断开时触发(自动取消数据流)
* doFinally → 数据流终止时的最终清理
* ============================================================
*/
@RestController
public class FluxSseController {
private static final Logger log = LoggerFactory.getLogger(FluxSseController.class);
private final ObjectMapper objectMapper = new ObjectMapper();
/** 服务器监控 SSE 端点(无限流) */
@GetMapping(value = "/sse/monitor", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> serverMonitor() {
return Flux.interval(Duration.ofSeconds(1)) // 每秒发射一次计数器
.map(this::buildMetricsEvent) // 构建监控事件
.doOnSubscribe(sub -> log.info("监控客户端已连接"))
.doOnCancel(() -> log.info("客户端断开,自动取消数据流"))
.doFinally(signal -> log.info("数据流终止,信号: {}", signal));
}
/** 构建包含真实硬件数据的监控事件 */
private ServerSentEvent<String> buildMetricsEvent(long seq) {
OperatingSystemMXBean osBean = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
MemoryMXBean memBean = ManagementFactory.getMemoryMXBean();
// ===== CPU 使用率(系统级 + 进程级,JDK 14+ 直接提供) =====
double systemCpu = percent(osBean.getCpuLoad());
double jvmCpu = percent(osBean.getProcessCpuLoad());
// ===== 系统内存 =====
long totalMemMB = osBean.getTotalMemorySize() / (1024 * 1024);
long freeMemMB = osBean.getFreeMemorySize() / (1024 * 1024);
long usedMemMB = totalMemMB - freeMemMB;
double memPercent = totalMemMB > 0 ? (double) usedMemMB / totalMemMB * 100 : 0;
// ===== JVM 堆内存 =====
long heapUsed = memBean.getHeapMemoryUsage().getUsed() / (1024 * 1024);
long heapMax = memBean.getHeapMemoryUsage().getMax() / (1024 * 1024);
double heapPercent = heapMax > 0 ? (double) heapUsed / heapMax * 100 : 0;
// ===== 磁盘空间(系统盘) =====
File rootFile = File.listRoots().length > 0 ? File.listRoots()[0] : new File("/");
long diskTotalGB = rootFile.getTotalSpace() / (1024 * 1024 * 1024);
long diskFreeGB = rootFile.getFreeSpace() / (1024 * 1024 * 1024);
long diskUsedGB = diskTotalGB - diskFreeGB;
double diskPercent = diskTotalGB > 0 ? (double) diskUsedGB / diskTotalGB * 100 : 0;
// ===== 磁盘分区列表 =====
var diskArray = objectMapper.createArrayNode();
for (File dbRoot : File.listRoots()) {
long totalB = dbRoot.getTotalSpace();
if (totalB <= 0) continue;
ObjectNode d = diskArray.addObject();
d.put("root", dbRoot.getAbsolutePath());
d.put("totalGB", totalB / (1024 * 1024 * 1024));
d.put("freeGB", dbRoot.getFreeSpace() / (1024 * 1024 * 1024));
d.put("usagePercent", Math.round((double) (totalB - dbRoot.getFreeSpace()) / totalB * 1000) / 10.0);
}
// ===== JVM 运行时 =====
long uptimeMs = ManagementFactory.getRuntimeMXBean().getUptime();
long uptimeMin = uptimeMs / 60000;
int threadCount = ManagementFactory.getThreadMXBean().getThreadCount();
int loadedClassCount = ManagementFactory.getClassLoadingMXBean().getLoadedClassCount();
// ===== GC 统计 =====
long gcCount = 0;
long gcTimeMs = 0;
for (GarbageCollectorMXBean gcBean : ManagementFactory.getGarbageCollectorMXBeans()) {
gcCount += gcBean.getCollectionCount();
gcTimeMs += gcBean.getCollectionTime();
}
// ===== 告警判断 =====
boolean cpuAlert = systemCpu > 65;
boolean memAlert = memPercent > 75;
boolean gpuAlert = false;
String eventType = "metrics";
String alertMsg = "";
// ===== 用 ObjectMapper 构建 JSON =====
try {
ObjectNode root = objectMapper.createObjectNode();
root.put("timestamp", LocalDateTime.now().withNano(0).toString());
root.put("seq", seq + 1);
root.put("hostname", getHostname());
root.put("os", System.getProperty("os.name", "Unknown") + " " + System.getProperty("os.arch", "unknown"));
ObjectNode cpuNode = root.putObject("cpu");
cpuNode.put("systemPercent", systemCpu);
cpuNode.put("jvmPercent", jvmCpu);
cpuNode.put("cores", Runtime.getRuntime().availableProcessors());
ObjectNode memNode = root.putObject("memory");
memNode.put("totalMB", totalMemMB);
memNode.put("usedMB", usedMemMB);
memNode.put("freeMB", freeMemMB);
memNode.put("usagePercent", Math.round(memPercent * 10.0) / 10.0);
ObjectNode heapNode = root.putObject("jvmHeap");
heapNode.put("usedMB", heapUsed);
heapNode.put("maxMB", heapMax);
heapNode.put("usagePercent", Math.round(heapPercent * 10.0) / 10.0);
ObjectNode diskNode = root.putObject("disk");
diskNode.put("root", rootFile.getAbsolutePath());
diskNode.put("totalGB", diskTotalGB);
diskNode.put("usedGB", diskUsedGB);
diskNode.put("freeGB", diskFreeGB);
diskNode.put("usagePercent", Math.round(diskPercent * 10.0) / 10.0);
diskNode.set("partitions", diskArray);
ObjectNode netNode = root.putObject("network");
netNode.set("ipAddresses", objectMapper.valueToTree(ipAddresses()));
ObjectNode jvmNode = root.putObject("jvm");
jvmNode.put("uptimeMinutes", uptimeMin);
jvmNode.put("threadCount", threadCount);
jvmNode.put("loadedClassCount", loadedClassCount);
jvmNode.put("gcCount", gcCount);
jvmNode.put("gcTimeMs", gcTimeMs);
jvmNode.put("javaVersion", System.getProperty("java.version", "unknown"));
// ===== GPU 信息 =====
ObjectNode gpuNode = buildGpuNode();
root.set("gpu", gpuNode);
// GPU 告警
gpuAlert = gpuNode.has("utilizationPercent") && gpuNode.get("utilizationPercent").asDouble() > 90;
cpuAlert = systemCpu > 65;
memAlert = memPercent > 75;
if (cpuAlert || memAlert || gpuAlert) {
eventType = "alert";
alertMsg = cpuAlert
? "CPU 使用率过高: " + Math.round(systemCpu) + "%"
: memAlert
? "内存使用率过高: " + Math.round(memPercent) + "%"
: "GPU 使用率过高: " + gpuNode.get("utilizationPercent").asDouble() + "%";
}
root.put("alert", cpuAlert || memAlert || gpuAlert);
root.put("alertMessage", alertMsg);
return ServerSentEvent.<String>builder()
.id(String.valueOf(seq + 1))
.event(eventType)
.data(objectMapper.writeValueAsString(root))
.build();
} catch (Exception e) {
log.error("序列化监控数据失败", e);
return ServerSentEvent.<String>builder()
.id(String.valueOf(seq + 1))
.event("error")
.data("{\"error\": \"序列化失败\"}")
.build();
}
}
/**
* 通过 nvidia-smi 采集 GPU 信息(NVIDIA 显卡专用,非 NVIDIA 返回占位数据)
*/
private ObjectNode buildGpuNode() {
ObjectNode gpuNode = objectMapper.createObjectNode();
try {
ProcessBuilder pb = new ProcessBuilder(
"nvidia-smi",
"--query-gpu=name,memory.total,memory.used,memory.free,utilization.gpu,temperature.gpu",
"--format=csv,noheader,nounits"
);
pb.redirectErrorStream(true);
Process proc = pb.start();
String line;
int gpuIndex = 0;
try (BufferedReader reader = new BufferedReader(new InputStreamReader(proc.getInputStream()))) {
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
// CSV 行示例:NVIDIA GeForce RTX 4090, 24564, 6231, 18333, 12, 44
String[] parts = line.split(",\\s*");
if (parts.length >= 6) {
ObjectNode card = gpuNode.putObject(String.valueOf(gpuIndex));
card.put("name", parts[0].trim());
card.put("memoryTotalMB", safeLong(parts[1]));
card.put("memoryUsedMB", safeLong(parts[2]));
card.put("memoryFreeMB", safeLong(parts[3]));
card.put("utilizationPercent", safeDouble(parts[4]));
card.put("temperatureC", safeDouble(parts[5]));
}
gpuIndex++; // 支持多卡
}
}
gpuNode.put("count", gpuIndex);
gpuNode.put("available", true);
// 取第一块卡的字段作为顶层便捷字段(方便前端直接 d.gpu.xxx 访问)
if (gpuIndex > 0) {
gpuNode.put("utilizationPercent", gpuNode.get("0").get("utilizationPercent").asDouble());
gpuNode.put("temperatureC", gpuNode.get("0").get("temperatureC").asDouble());
gpuNode.put("name", gpuNode.get("0").get("name").asText());
gpuNode.put("memoryTotalMB", gpuNode.get("0").get("memoryTotalMB").asLong());
gpuNode.put("memoryUsedMB", gpuNode.get("0").get("memoryUsedMB").asLong());
gpuNode.put("memoryFreeMB", gpuNode.get("0").get("memoryFreeMB").asLong());
}
} catch (Exception e) {
// nvidia-smi 不可用(非 NVIDIA 显卡或未安装驱动),返回占位数据
gpuNode.put("available", false);
gpuNode.put("count", 0);
gpuNode.put("name", "N/A(未检测到 NVIDIA 显卡)");
gpuNode.put("utilizationPercent", 0);
gpuNode.put("temperatureC", 0);
}
return gpuNode;
}
private long safeLong(String s) {
try { return Long.parseLong(s.trim()); } catch (Exception e) { return 0; }
}
private double safeDouble(String s) {
try { return Double.parseDouble(s.trim()); } catch (Exception e) { return 0; }
}
/**
* 0..1 的 CPU 负载转百分比(-1 或 NaN 表示暂不可用)
*/
private double percent(double load) {
return (load < 0 || Double.isNaN(load)) ? 0 : Math.round(load * 1000) / 100.0;
}
/**
* 获取本机非回环 IPv4 地址列表
*/
private List<String> ipAddresses() {
List<String> result = new ArrayList<>();
try {
var interfaces = java.net.NetworkInterface.getNetworkInterfaces();
while (interfaces.hasMoreElements()) {
var ni = interfaces.nextElement();
if (ni.isLoopback() || !ni.isUp()) continue;
for (var addr : java.util.Collections.list(ni.getInetAddresses())) {
String ip = addr.getHostAddress();
if (addr instanceof java.net.Inet4Address && !ip.startsWith("169.254.")) {
result.add(ip);
}
}
}
} catch (Exception e) {
log.debug("获取网络信息失败: {}", e.getMessage());
}
return result.isEmpty() ? List.of("unknown") : result;
}
/**
* 获取主机名
*/
private String getHostname() {
try {
return InetAddress.getLocalHost().getHostName();
} catch (Exception e) {
return "unknown";
}
}
}
5.4 Flux 数据流图
#mermaid-svg-DvqHtJoZ5T7pELcC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DvqHtJoZ5T7pELcC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DvqHtJoZ5T7pELcC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DvqHtJoZ5T7pELcC .error-icon{fill:#552222;}#mermaid-svg-DvqHtJoZ5T7pELcC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DvqHtJoZ5T7pELcC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DvqHtJoZ5T7pELcC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DvqHtJoZ5T7pELcC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DvqHtJoZ5T7pELcC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DvqHtJoZ5T7pELcC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DvqHtJoZ5T7pELcC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DvqHtJoZ5T7pELcC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DvqHtJoZ5T7pELcC .marker.cross{stroke:#333333;}#mermaid-svg-DvqHtJoZ5T7pELcC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DvqHtJoZ5T7pELcC p{margin:0;}#mermaid-svg-DvqHtJoZ5T7pELcC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DvqHtJoZ5T7pELcC .cluster-label text{fill:#333;}#mermaid-svg-DvqHtJoZ5T7pELcC .cluster-label span{color:#333;}#mermaid-svg-DvqHtJoZ5T7pELcC .cluster-label span p{background-color:transparent;}#mermaid-svg-DvqHtJoZ5T7pELcC .label text,#mermaid-svg-DvqHtJoZ5T7pELcC span{fill:#333;color:#333;}#mermaid-svg-DvqHtJoZ5T7pELcC .node rect,#mermaid-svg-DvqHtJoZ5T7pELcC .node circle,#mermaid-svg-DvqHtJoZ5T7pELcC .node ellipse,#mermaid-svg-DvqHtJoZ5T7pELcC .node polygon,#mermaid-svg-DvqHtJoZ5T7pELcC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DvqHtJoZ5T7pELcC .rough-node .label text,#mermaid-svg-DvqHtJoZ5T7pELcC .node .label text,#mermaid-svg-DvqHtJoZ5T7pELcC .image-shape .label,#mermaid-svg-DvqHtJoZ5T7pELcC .icon-shape .label{text-anchor:middle;}#mermaid-svg-DvqHtJoZ5T7pELcC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DvqHtJoZ5T7pELcC .rough-node .label,#mermaid-svg-DvqHtJoZ5T7pELcC .node .label,#mermaid-svg-DvqHtJoZ5T7pELcC .image-shape .label,#mermaid-svg-DvqHtJoZ5T7pELcC .icon-shape .label{text-align:center;}#mermaid-svg-DvqHtJoZ5T7pELcC .node.clickable{cursor:pointer;}#mermaid-svg-DvqHtJoZ5T7pELcC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DvqHtJoZ5T7pELcC .arrowheadPath{fill:#333333;}#mermaid-svg-DvqHtJoZ5T7pELcC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DvqHtJoZ5T7pELcC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DvqHtJoZ5T7pELcC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DvqHtJoZ5T7pELcC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DvqHtJoZ5T7pELcC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DvqHtJoZ5T7pELcC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DvqHtJoZ5T7pELcC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DvqHtJoZ5T7pELcC .cluster text{fill:#333;}#mermaid-svg-DvqHtJoZ5T7pELcC .cluster span{color:#333;}#mermaid-svg-DvqHtJoZ5T7pELcC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DvqHtJoZ5T7pELcC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DvqHtJoZ5T7pELcC rect.text{fill:none;stroke-width:0;}#mermaid-svg-DvqHtJoZ5T7pELcC .icon-shape,#mermaid-svg-DvqHtJoZ5T7pELcC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DvqHtJoZ5T7pELcC .icon-shape p,#mermaid-svg-DvqHtJoZ5T7pELcC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DvqHtJoZ5T7pELcC .icon-shape .label rect,#mermaid-svg-DvqHtJoZ5T7pELcC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DvqHtJoZ5T7pELcC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DvqHtJoZ5T7pELcC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DvqHtJoZ5T7pELcC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Flux.interval 1s
时间触发
buildMetricsEvent
采集 MXBeans + nvidia-smi
Jackson
结构化 JSON
ServerSentEvent
event: metrics / alert
前端 EventSource /sse/monitor
5.5 Flux 生命周期钩子
| 钩子 | 触发时机 | 用途 |
|---|---|---|
doOnSubscribe |
客户端建立连接 | 记录日志、初始化资源 |
doOnCancel |
客户端断开连接 | 自动取消数据流(无需手动管理) |
doFinally |
数据流终止 | 最终清理(无论正常/异常) |
对比案例一:WebFlux 的取消信号是即时的------浏览器断开 TCP,Reactor 立刻停止灌数据。SseEmitter 只有在下一次
send失败时才感知,这是两种范式在"清理时机"上最本质的差别。
六、前端数据大屏(完整)
6.1 页面结构
- 上下两个卡片:案例一在上方(33% 高度),案例二在下方占满剩余空间
Grid + Flex布局,overflow: hidden,无滚动条,尺寸随浏览器自适应- Claude(Anthropic)品牌色系


6.2 前端设计要点
- 静态资源位置 :
src/main/resources/static/index.html,启动后直接访问http://localhost:8080/ - ECharts 引入:使用国内镜像(npmmirror),避免 CDN 延迟
- 两个独立
EventSource:分别订阅/sse/order/{id}与/sse/monitor - 两种事件驱动策略 :订单用
addEventListener('status_change', ...)分类监听;监控用alert事件独立处理 - 仪表盘动态变色:超过 60% 变暖色,超过 85% 变红色
6.3 index.html(完整,一个文件搞定前端)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSE 实时推送数据大屏</title>
<script src="https://registry.npmmirror.com/echarts/6.1.0/files/dist/echarts.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@500;600;700&family=Lora:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
<style>
:root {
/* ===== Anthropic / Claude 品牌色 ===== */
--bg-base: #141413;
--bg-card: rgba(32, 32, 30, .88);
--bg-panel: rgba(20, 20, 19, .75);
--border: rgba(228, 226, 220, .10);
--border-active: rgba(217, 119, 87, .45);
--text-main: #faf9f5;
--text-sub: #b0aea5;
--text-muted: rgba(176, 174, 165, .55);
/* 强调色 */
--accent-orange: #d97757;
--accent-blue: #6a9bcc;
--accent-green: #788c5d;
--accent-warm: #c4956a;
--accent-rose: #bf7a6c;
--danger: #c06050;
/* 辅助 */
--shadow-card: 0 2px 20px rgba(0, 0, 0, .35);
--font-heading: 'Poppins', 'Microsoft YaHei', Arial, sans-serif;
--font-body: 'Lora', 'PingFang SC', Georgia, serif;
--font-mono: 'Consolas', 'Menlo', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: var(--font-body);
background: var(--bg-base);
color: var(--text-main);
padding: clamp(10px, 1.6vh, 20px) clamp(12px, 2vw, 28px);
display: flex;
flex-direction: column;
gap: clamp(8px, 1.4vh, 16px);
}
/* ===== 顶部标题栏 ===== */
header.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex: 0 0 auto;
padding: 0 4px;
}
header.page-header h1 {
font-family: var(--font-heading);
font-size: clamp(16px, 2.6vh, 24px);
font-weight: 600;
letter-spacing: .5px;
color: var(--text-main);
}
header.page-header h1 b {
color: var(--accent-orange);
font-weight: 700;
}
.conn-badge { display: flex; gap: 10px; }
.conn-badge span {
font-family: var(--font-body);
font-size: clamp(10px, 1.4vh, 12px);
padding: 4px 14px;
border-radius: 20px;
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text-sub);
transition: all .3s;
}
.conn-badge span.on {
color: var(--accent-green);
border-color: rgba(120, 140, 93, .40);
background: rgba(120, 140, 93, .08);
}
/* ===== 主体 ===== */
.stack {
display: flex;
flex-direction: column;
gap: clamp(8px, 1.4vh, 16px);
flex: 1;
min-height: 0;
}
.case-order { flex: 0 0 33%; min-height: 0; }
.case-monitor { flex: 1; min-height: 0; }
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 16px;
overflow: hidden;
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
transition: border-color .4s;
}
.card:hover { border-color: rgba(228, 226, 220, .16); }
.card > header {
display: flex;
justify-content: space-between;
align-items: center;
padding: clamp(6px, 1.2vh, 12px) clamp(12px, 1.8vw, 22px);
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.card > header h2 {
font-family: var(--font-heading);
font-size: clamp(13px, 1.8vh, 16px);
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.card > header h2::before {
content: "";
width: 4px;
height: 16px;
border-radius: 3px;
}
.case-order > header h2::before { background: var(--accent-green); }
.case-monitor > header h2::before { background: var(--accent-orange); }
.card > header .tag {
font-family: var(--font-body);
font-size: clamp(10px, 1.3vh, 11px);
padding: 3px 12px;
border-radius: 14px;
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text-sub);
font-style: italic;
}
.card .card-body {
display: flex;
flex-direction: column;
gap: clamp(6px, 1.2vh, 12px);
padding: clamp(8px, 1.6vh, 16px) clamp(12px, 1.8vw, 22px);
flex: 1;
min-height: 0;
}
/* ===== 图表区 ===== */
.charts-row {
display: flex;
gap: clamp(8px, 1.2vw, 14px);
flex: 1;
min-height: 0;
}
.chart, .chart-sm {
flex: 1;
min-width: 0;
height: 100%;
min-height: 0;
}
/* ===== 信息条 ===== */
.info-bar {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: clamp(4px, .7vh, 8px);
flex: 0 0 auto;
padding-top: clamp(4px, .8vh, 8px);
border-top: 1px solid var(--border);
}
.info-bar span {
font-family: var(--font-body);
font-size: clamp(10px, 1.35vh, 12px);
padding: 4px 13px;
border-radius: 14px;
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text-sub);
white-space: nowrap;
}
.info-bar span b {
color: var(--accent-blue);
font-weight: 600;
}
.info-bar span.gpu-info b {
color: var(--accent-rose);
}
/* ===== 案例一:订单区 ===== */
.order-main {
display: flex;
gap: clamp(10px, 1.6vw, 22px);
flex: 1;
min-height: 0;
}
.order-left {
flex: 1.4;
display: flex;
flex-direction: column;
justify-content: space-evenly;
gap: clamp(8px, 1.6vh, 14px);
min-width: 0;
}
.order-right {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.steps { display: flex; align-items: center; }
.step {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
flex: 1;
font-family: var(--font-body);
font-size: clamp(10px, 1.45vh, 12px);
color: var(--text-sub);
transition: color .3s;
}
.step .dot {
width: clamp(22px, 3.4vh, 30px);
height: clamp(22px, 3.4vh, 30px);
border-radius: 50%;
background: var(--bg-panel);
border: 1.5px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(10px, 1.35vh, 12px);
font-family: var(--font-heading);
transition: all .3s;
}
.step.active {
color: var(--accent-green);
}
.step.active .dot {
background: var(--accent-green);
border-color: var(--accent-green);
color: var(--bg-base);
box-shadow: 0 0 16px rgba(120, 140, 93, .35);
}
.step.done { color: rgba(120, 140, 93, .55); }
.step.done .dot {
background: rgba(120, 140, 93, .12);
border-color: rgba(120, 140, 93, .35);
}
.order-actions { display: flex; align-items: center; gap: 14px; }
#btn-create {
font-family: var(--font-heading);
font-size: clamp(12px, 1.6vh, 14px);
font-weight: 600;
padding: clamp(7px, 1.3vh, 11px) clamp(16px, 2.2vw, 28px);
border: none;
border-radius: 24px;
background: var(--accent-orange);
color: var(--bg-base);
cursor: pointer;
transition: filter .2s, box-shadow .2s;
white-space: nowrap;
}
#btn-create:hover {
filter: brightness(1.1);
box-shadow: 0 0 20px rgba(217, 119, 87, .30);
}
#btn-create:disabled {
filter: grayscale(.5) brightness(.6);
cursor: not-allowed;
}
.footnote {
font-family: var(--font-body);
font-size: clamp(10px, 1.4vh, 12px);
color: var(--text-sub);
line-height: 1.6;
}
.footnote b { color: var(--accent-green); font-weight: 600; }
/* 消息日志 */
.log {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
font-family: var(--font-mono);
font-size: clamp(10px, 1.35vh, 12px);
scrollbar-width: none;
}
.log::-webkit-scrollbar { display: none; }
.log p {
background: var(--bg-panel);
border-left: 3px solid var(--accent-blue);
border-radius: 0 8px 8px 0;
padding: clamp(5px, 1vh, 8px) 12px;
line-height: 1.45;
color: var(--text-sub);
}
.log p.event-status_change { border-color: var(--accent-orange); }
.log p.event-logistics_update { border-color: var(--accent-blue); }
.log p.event-complete { border-color: var(--accent-green); }
.log p.event-error { border-color: var(--danger); }
.log p .ev-name { color: var(--accent-orange); margin-right: 6px; font-weight: 600; }
.log p .ev-time { color: var(--text-muted); }
.log .empty-hint {
border: 1px dashed var(--border);
border-left: none;
color: var(--text-muted);
text-align: center;
font-style: italic;
}
footer {
flex: 0 0 auto;
text-align: center;
font-family: var(--font-body);
font-size: clamp(10px, 1.3vh, 12px);
color: var(--text-muted);
padding: 0 8px;
font-style: italic;
}
</style>
</head>
<body>
<header class="page-header">
<h1>SSE 实时推送数据大屏 · <b>Server-Sent Events</b></h1>
<div class="conn-badge">
<span id="badge-order">案例一:未连接</span>
<span id="badge-monitor">案例二:未连接</span>
</div>
</header>
<div class="stack">
<!-- 案例一 -->
<section class="card case-order">
<header>
<h2>案例一 · 电商订单状态跟踪</h2>
<span class="tag">WebMVC / SseEmitter + 虚拟线程 + Jackson</span>
</header>
<div class="card-body">
<div class="order-main">
<div class="order-left">
<div class="steps" id="steps">
<div class="step"><div class="dot">1</div>已下单</div>
<div class="step"><div class="dot">2</div>支付成功</div>
<div class="step"><div class="dot">3</div>商家接单</div>
<div class="step"><div class="dot">4</div>配送中</div>
<div class="step"><div class="dot">5</div>已签收</div>
</div>
<div class="order-actions">
<button id="btn-create">创建订单并订阅</button>
<div class="footnote" id="order-info">
点击按钮创建订单:服务器用<b>虚拟线程</b>自动走完「下单 → 支付 → 接单 → 配送 → 签收」全流程,
每一步通过 SSE 实时推送到右侧消息流,完成后连接自动关闭。
</div>
</div>
</div>
<div class="order-right">
<div class="log" id="order-log"><p class="empty-hint">暂无事件 ------ 订单事件推送后将显示在这里</p></div>
</div>
</div>
</div>
</section>
<!-- 案例二 -->
<section class="card case-monitor">
<header>
<h2>案例二 · 服务器实时监控看板</h2>
<span class="tag">WebFlux / Flux + 真实硬件数据(MXBeans + nvidia-smi)</span>
</header>
<div class="card-body">
<!-- 5 个仪表盘:CPU / 内存 / GPU / JVM 堆 / 磁盘 -->
<div class="charts-row" style="flex: 5;">
<div id="gaugeCpu" class="chart"></div>
<div id="gaugeMemory" class="chart"></div>
<div id="gaugeGpu" class="chart"></div>
<div id="gaugeJvm" class="chart"></div>
<div id="gaugeDisk" class="chart"></div>
</div>
<!-- 3 条趋势线:CPU / GPU / JVM 堆 -->
<div class="charts-row" style="flex: 2.2;">
<div id="cpuLine" class="chart-sm"></div>
<div id="gpuLine" class="chart-sm"></div>
<div id="heapLine" class="chart-sm"></div>
</div>
<div class="info-bar" id="info-sys"></div>
<div class="info-bar" id="info-hw"></div>
</div>
</section>
</div>
<footer>教程演示项目 · 事件流驱动 UI · 刷新页面重新订阅</footer>
<script>
/* ============================================================
* Anthropic / Claude 品牌色常量
* ============================================================ */
const C = {
bgBase: '#141413',
bgPanel: 'rgba(20,20,19,.75)',
orange: '#d97757',
blue: '#6a9bcc',
green: '#788c5d',
warm: '#c4956a',
rose: '#bf7a6c',
textSub: '#b0aea5',
textMuted: 'rgba(176,174,165,.55)',
border: 'rgba(228,226,220,.10)',
};
/* ===== SSE 日志 ===== */
const logBox = document.getElementById('order-log');
const appendLog = (eventName, data) => {
const hint = logBox.querySelector('.empty-hint');
if (hint) hint.remove();
const p = document.createElement('p');
p.className = 'event-' + eventName;
p.innerHTML = `<span class="ev-name">[${eventName}]</span><span class="ev-time">${new Date().toLocaleTimeString()}</span><br>${data}`;
logBox.appendChild(p);
logBox.scrollTop = logBox.scrollHeight;
};
/* ===== 案例一:订单 ===== */
const badgeOrder = document.getElementById('badge-order');
const orderInfo = document.getElementById('order-info');
const btnCreate = document.getElementById('btn-create');
const steps = document.querySelectorAll('#steps .step');
let orderES = null;
const setStep = code => {
steps.forEach((s, i) => {
s.classList.remove('active', 'done');
if (i + 1 < code) s.classList.add('done');
else if (i + 1 === code) s.classList.add('active');
});
};
const subscribeOrder = orderId => {
if (orderES) orderES.close();
logBox.innerHTML = '';
orderES = new EventSource('/sse/order/' + orderId);
badgeOrder.textContent = '案例一:已连接 ' + orderId;
badgeOrder.classList.add('on');
orderES.addEventListener('status_change', e => {
const d = JSON.parse(e.data);
setStep(d.statusCode);
orderInfo.innerHTML = `订单 <b>${d.orderId}</b> · ${d.status} · ${d.message}`;
appendLog('status_change', d.message);
});
orderES.addEventListener('logistics_update', e => {
const d = JSON.parse(e.data);
setStep(d.statusCode); // 物流播报也点亮"配送中"步骤
appendLog('logistics_update', `${d.message}(${d.location} · ${d.progress})`);
});
orderES.addEventListener('complete', e => {
appendLog('complete', JSON.parse(e.data).message);
badgeOrder.textContent = '案例一:订单完成';
});
orderES.addEventListener('error', e => {
if (e.data) {
const d = JSON.parse(e.data);
appendLog('error', d.error + ' · ' + d.hint);
orderInfo.textContent = d.hint;
}
});
};
btnCreate.addEventListener('click', async () => {
btnCreate.disabled = true;
const res = await fetch('/sse/order/create', { method: 'POST' });
const order = await res.json();
orderInfo.innerHTML = `订单 <b>${order.orderId}</b> · ${order.product}`;
subscribeOrder(order.orderId);
btnCreate.disabled = false;
});
/* ============================================================
* 案例二:仪表盘(Anthropic 风格配色)
* ============================================================ */
const gaugeDefine = [
{ el: 'gaugeCpu', title: '系统 CPU', color: C.blue, field: d => d.cpu.systemPercent },
{ el: 'gaugeMemory', title: '物理内存', color: C.green, key: 'memory' },
{ el: 'gaugeGpu', title: 'GPU 使用率', color: C.rose, field: d => d.gpu?.utilizationPercent || 0 },
{ el: 'gaugeJvm', title: 'JVM 堆', color: C.warm, key: 'heap' },
{ el: 'gaugeDisk', title: '系统盘', color: C.orange, field: d => d.disk.usagePercent }
];
const makeGauge = (def) => {
const chart = echarts.init(document.getElementById(def.el));
chart.setOption({
animation: false,
title: {
text: def.title,
left: 'center',
top: 4,
textStyle: {
fontFamily: 'Poppins, Microsoft YaHei, Arial',
color: C.textSub,
fontSize: 11,
fontWeight: 500
}
},
series: [{
type: 'gauge',
startAngle: 200,
endAngle: -20,
min: 0,
max: 100,
radius: '84%',
center: ['50%', '58%'],
progress: {
show: true,
width: 8,
roundCap: true,
itemStyle: { color: def.color }
},
axisLine: {
lineStyle: { width: 8, color: [[1, C.border]] }
},
axisTick: { show: false },
axisLabel: {
color: C.textMuted,
fontSize: 8,
distance: 12
},
splitLine: {
length: 4,
distance: 10,
lineStyle: { color: 'rgba(228,226,220,.12)' }
},
pointer: {
show: true,
length: '55%',
width: 3,
offsetCenter: [0, 0],
itemStyle: { color: def.color }
},
anchor: {
show: true,
size: 6,
showAbove: true,
itemStyle: { color: def.color, borderColor: C.bgBase, borderWidth: 2 }
},
title: { show: false },
detail: {
valueAnimation: false,
offsetCenter: [0, '38%'],
fontFamily: 'Poppins, Arial',
fontSize: 16,
fontWeight: 600,
color: def.color,
formatter: '{value}'
},
data: [{ value: 0 }]
}]
});
return chart;
};
const gaugeCtx = gaugeDefine.map(def => ({ ...def, chart: makeGauge(def) }));
/* 动态变色:<60 原色 → <85 暖色 → 红 */
const loadColor = v => v < 60 ? null : v < 85 ? C.warm : C.danger;
const setGauge = (ctx, data) => {
let value, detail;
if (ctx.key === 'memory') {
value = data.memory.usagePercent;
detail = (data.memory.usedMB / 1024).toFixed(1) + 'G';
} else if (ctx.key === 'heap') {
value = data.jvmHeap.usagePercent;
detail = data.jvmHeap.usedMB + 'M';
} else {
value = ctx.field(data);
detail = typeof value === 'number' ? value.toFixed(1) : '0';
}
const active = loadColor(value) || ctx.color;
ctx.chart.setOption({ series: [{
data: [{ value: Math.min(value, 100), detail }],
progress: { itemStyle: { color: active } },
pointer: { itemStyle: { color: active } },
anchor: { itemStyle: { color: active, borderColor: C.bgBase } },
detail: { color: active },
axisLine: { lineStyle: { color: [[1, C.border]] } }
}] });
};
/* ============================================================
* 趋势线
* ============================================================ */
const MAX_POINTS = 90;
const gradient = color => new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: color },
{ offset: 1, color: 'rgba(20,20,19,0)' }
]);
const makeLine = (el, titleText, color) => {
const chart = echarts.init(document.getElementById(el));
chart.setOption({
animation: false,
title: {
text: titleText,
left: 'center',
top: 2,
textStyle: {
fontFamily: 'Poppins, Microsoft YaHei, Arial',
color: C.textSub,
fontSize: 10,
fontWeight: 500
}
},
grid: { top: 28, left: 40, right: 14, bottom: 20 },
xAxis: {
type: 'category',
boundaryGap: false,
data: [],
axisLabel: { color: C.textMuted, fontSize: 8 },
axisLine: { lineStyle: { color: C.border } }
},
yAxis: {
type: 'value',
max: 100,
axisLabel: { color: C.textMuted, fontSize: 8, formatter: '{value}%' },
splitLine: { lineStyle: { color: 'rgba(228,226,220,.06)' } }
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(32,32,30,.92)',
borderColor: C.border,
textStyle: { color: '#faf9f5', fontFamily: 'Lora, serif', fontSize: 11 }
},
series: [{
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { opacity: 0.20, color: gradient(color) },
lineStyle: { width: 2, color },
data: []
}]
});
return chart;
};
const cpuLine = makeLine('cpuLine', 'CPU 趋势(90s)', C.blue);
const gpuLine = makeLine('gpuLine', 'GPU 趋势(90s)', C.rose);
const heapLine = makeLine('heapLine', 'JVM 堆趋势(90s)', C.warm);
const trendState = {
cpu: { chart: cpuLine, times: [], values: [] },
gpu: { chart: gpuLine, times: [], values: [] },
heap: { chart: heapLine, times: [], values: [] }
};
const push = (key, v) => {
const st = trendState[key];
st.times.push(new Date().toLocaleTimeString('zh-CN', { hour12: false }));
st.values.push(Math.round(v * 10) / 10);
if (st.times.length > MAX_POINTS) { st.times.shift(); st.values.shift(); }
st.chart.setOption({ xAxis: { data: st.times }, series: [{ data: st.values }] });
};
/* ============================================================
* 信息条(两行:系统 + 硬件)
* ============================================================ */
const infoSys = document.getElementById('info-sys');
const infoHw = document.getElementById('info-hw');
const renderInfo = d => {
const gpuInfo = d.gpu?.available
? [`GPU: <b>${d.gpu.name}</b>`, `显存: <b>${d.gpu.memoryUsedMB}M / ${d.gpu.memoryTotalMB}M</b>`, `温度: <b>${d.gpu.temperatureC}\u00B0C</b>`]
: [`GPU: <b>${d.gpu?.name || 'N/A'}</b>`];
infoSys.innerHTML = [
`主机: <b>${d.hostname}</b>`,
`系统: <b>${d.os}</b>`,
`CPU 核心: <b>${d.cpu.cores}</b>`,
`物理内存: <b>${(d.memory.totalMB / 1024).toFixed(0)} GB</b>`,
`运行: <b>JVM ${d.jvm.uptimeMinutes} 分钟</b>`,
`线程: <b>${d.jvm.threadCount}</b>`,
`GC: <b>${d.jvm.gcCount} 次 / ${d.jvm.gcTimeMs} ms</b>`,
`Java: <b>${d.jvm.javaVersion}</b>`,
`IP: <b>${d.network.ipAddresses.join(' / ')}</b>`,
].map(s => `<span>${s}</span>`).join('');
infoHw.innerHTML = [
...gpuInfo.map(s => `<span class="gpu-info">${s}</span>`),
...(d.disk.partitions || []).map(p => `分区 <b>${p.root}</b> ${p.usagePercent}%`).map(s => `<span>${s}</span>`)
].join('');
};
/* ===== resize ===== */
window.addEventListener('resize', () => {
gaugeCtx.forEach(g => g.chart.resize());
cpuLine.resize();
gpuLine.resize();
heapLine.resize();
});
/* ============================================================
* SSE 订阅
* ============================================================ */
const badgeMonitor = document.getElementById('badge-monitor');
const monitorES = new EventSource('/sse/monitor');
monitorES.addEventListener('open', () => {
badgeMonitor.textContent = '案例二:已连接';
badgeMonitor.classList.add('on');
});
monitorES.addEventListener('metrics', e => {
const d = JSON.parse(e.data);
gaugeCtx.forEach(g => setGauge(g, d));
push('cpu', d.cpu.systemPercent);
push('gpu', d.gpu?.utilizationPercent || 0);
push('heap', d.jvmHeap.usagePercent);
renderInfo(d);
});
monitorES.addEventListener('alert', e => {
badgeMonitor.textContent = '!! ' + JSON.parse(e.data).alertMessage;
});
monitorES.addEventListener('error', () => {
badgeMonitor.textContent = '案例二:已断开(自动重连中)';
badgeMonitor.classList.remove('on');
});
</script>
</body>
</html>
七、快速开始
7.1 环境要求
- JDK 25+
- Maven 3.9+
- (可选)NVIDIA 显卡驱动,用于 GPU 监控
7.2 启动步骤
bash
# 1. 编译
mvn clean compile
# 2. 启动
mvn spring-boot:run
# 3. 访问
# 浏览器打开 http://localhost:8080/
7.3 接口测试
bash
# 创建订单
curl -X POST "http://localhost:8080/sse/order/create?product=xiaomi17"
# 订阅订单(浏览器直接打开,能看到实时推送的帧)
http://localhost:8080/sse/order/ORD-XXXXXXXX
# 监控端点(浏览器直接打开,能看到每秒推送的 metrics 帧)
http://localhost:8080/sse/monitor
7.4 验证要点
| 验证项 | 预期结果 |
|---|---|
| 打开首页 | 大屏"案例二:已连接"变绿,仪表盘指针实时摆动 |
| 点击"创建订单并订阅" | 右侧日志依次出现 status_change / logistics_update 事件,步骤条逐步点亮 |
| 配送中 | logistics_update 事件也会把步骤 4 点亮为"配送中" |
| 订单完成后 | 日志出现 complete 事件,连接自动关闭 |
直接 GET /sse/monitor |
每秒收到一行 event: metrics + JSON data |
八、注意事项
- 虚拟线程 :需在
application.yaml中配置spring.threads.virtual.enabled: true - GPU 监控 :需要安装 NVIDIA 驱动和
nvidia-smi,非 NVIDIA 显卡返回占位数据(available: false),前端显示N/A - JSON 序列化 :统一使用 Jackson
ObjectMapper的ObjectNode构建结构化数据,不要字符串拼接(中文、引号、特殊字符都会踩坑) - 日志 :使用纯 SLF4J API(
org.slf4j.Logger+LoggerFactory),日志实现由 Spring Boot starter 自动带入的 Logback 提供,本项目不使用 Lombok - CORS:生产环境需配置跨域策略(教程前后端同源,无需处理)
- 内存泄漏:确保客户端断开时及时从连接池移除(案例一三重回调 + 广播失败移除已兜底)
- SSE 限制:浏览器对同域名 SSE 连接数有限制(HTTP/1.1 约 6 个),测试时不要开太多标签页
- 超时问题 :
SseEmitter设置了 30 分钟超时;Nginx / 网关代理 SSE 时注意proxy_read_timeout与proxy_buffering: off配置