Spring Boot SSE 实时推送教程

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 关键设计点详解

  1. 虚拟线程Executors.newVirtualThreadPerTaskExecutor()。每个订单流程占用一条"轻量级线程",Thread.sleep 挂起的成本极低,成本差异相比平台线程可达上千倍。
  2. Data 结构选择 :订单状态 ConcurrentHashMap、连接池包裹 CopyOnWriteArrayList。读多写少 + 遍历时可能被改的场景,选 CopyOnWriteArrayList 避免手动加锁。
  3. 断开感知 :命令式的痛点。emitter.send() 内部写网络流,失败抛 IOException 才知道客户端已断开。所以广播时用 try-catch 包住,移除失败的连接。
  4. 三重回调兜底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 前端设计要点

  1. 静态资源位置src/main/resources/static/index.html,启动后直接访问 http://localhost:8080/
  2. ECharts 引入:使用国内镜像(npmmirror),避免 CDN 延迟
  3. 两个独立 EventSource :分别订阅 /sse/order/{id}/sse/monitor
  4. 两种事件驱动策略 :订单用 addEventListener('status_change', ...) 分类监听;监控用 alert 事件独立处理
  5. 仪表盘动态变色:超过 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

八、注意事项

  1. 虚拟线程 :需在 application.yaml 中配置 spring.threads.virtual.enabled: true
  2. GPU 监控 :需要安装 NVIDIA 驱动和 nvidia-smi,非 NVIDIA 显卡返回占位数据(available: false),前端显示 N/A
  3. JSON 序列化 :统一使用 Jackson ObjectMapperObjectNode 构建结构化数据,不要字符串拼接(中文、引号、特殊字符都会踩坑)
  4. 日志 :使用纯 SLF4J API(org.slf4j.Logger + LoggerFactory),日志实现由 Spring Boot starter 自动带入的 Logback 提供,本项目不使用 Lombok
  5. CORS:生产环境需配置跨域策略(教程前后端同源,无需处理)
  6. 内存泄漏:确保客户端断开时及时从连接池移除(案例一三重回调 + 广播失败移除已兜底)
  7. SSE 限制:浏览器对同域名 SSE 连接数有限制(HTTP/1.1 约 6 个),测试时不要开太多标签页
  8. 超时问题SseEmitter 设置了 30 分钟超时;Nginx / 网关代理 SSE 时注意 proxy_read_timeoutproxy_buffering: off 配置
相关推荐
wno7042 小时前
Spring Boot WebFlux增删改查
java·spring boot·后端
行百里er4 小时前
Spring Insight 里如何把 Span 收成一条链路
spring boot·spring cloud·监控
xcl09254 小时前
24小时自助健身房系统软件开发实战指南:从架构到部署
java·spring boot
szephyr6 小时前
WebSocket 实战:心跳、断线重连、鉴权,一次讲清
前端·websocket·node.js·长连接·实时通信
ID34610744207 小时前
【课程设计】基于Spring Boot+Vue的游戏账号租赁系统的设计与实现-计算机毕设 附源码50345
javascript·vue.js·spring boot·python·node.js·php·课程设计
小蒜学长9 小时前
基于RFID技术的仓储管理系统设计(代码+数据库+LW)
java·数据库·spring boot·后端·rfid技术·仓储管理
xcl092510 小时前
健身场馆无人自动化系统:从架构设计到落地实践
java·spring boot
FYKJ_201010 小时前
【毕设分享】基于Web的校园兼职信息发布与申请系统07059
前端·vue.js·spring boot·mysql·typescript·spark·课程设计
雪芽蓝域zzs10 小时前
第 1 节:使用 IDEA 创建 SpringBoot4 Maven 项目
spring boot