> ## Content Index
> Fetch the complete content index at: https://www.hubwiz.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# SSE：REST和WS之间的低调英雄
- URL: https://www.hubwiz.com/blog/sse-the-humble-hero-between-rest-and-websockets/
- Published: 2026-05-02T00:05:11.000Z
- Updated: 2026-05-02T00:05:11.000Z
- Description: SSE就像是订阅了一个信使服务，服务器在有新闻时向你发送更新，但你不需要维护一个昂贵的双向通信通道。
- Author: admin
- Tags: APPLICATION, #Import 2026-10-14 14:41

一家成长中的 SaaS 公司的平台工程师在一个周二早上排查通知系统时，注意到了一些奇怪的现象。他们的 WebSocket 实现非常稳固，零投诉。但基础设施成本呢？高得离谱。数千个持久连接，每一个都像一个小吸血鬼，从服务器集群中汲取资源。与此同时，他们用于不太关键更新的基于 REST 的轮询方案每隔几秒就用请求轰炸 API，制造了一场不必要流量的雷暴。她不禁想：难道没有介于两者之间的东西吗？

许多架构师面临的挑战是请求-响应模式和全双工通信之间的虚假二分法。我们在需要简单性时选择 REST，在需要实时双向通信时选择 WebSockets。但中间有一片广阔的地带：服务器需要向客户端推送更新，但客户端很少需要回传数据的场景。股票行情、实时仪表盘、进度通知、服务器日志流式传输到浏览器——这些都是伪装成双向高速公路的单行道。这正是 Server-Sent Events (SSE) 悄然出色的领域，它就像那个可靠的朋友，坐在角落里，不需要持续关注，但总在你需要的时候出现。

## 1、外交信使

Server-Sent Events 可以看作是一种外交信使服务。使用传统 REST，你会不断派送信使（HTTP 请求）来查看是否有新闻："有什么更新吗？""现在呢？""还没有？"这既令人疲惫又低效。WebSockets 则像是安装了一条专用电话线。功能强大，但无论你是否在通话，都要为那套基础设施付费。

SSE 则不同。它就像是订阅了一个信使服务，服务器在有新闻时向你发送更新，但你不需要维护一个昂贵的双向通信通道。连接保持打开，服务器在有话要说时推送数据，你的客户端负责监听。简单。优雅。HTTP 原生。

在其核心，SSE 构建在标准 HTTP 之上。客户端发出请求，服务器以 `text/event-stream` 内容类型响应，并保持连接打开。服务器随后可以随时以格式化文本的形式发送事件。浏览器内置的 `EventSource` API 自动处理重连，你获得一个干净、简洁的接口来接收更新。

## 2、技术深入

SSE 运行在一个极其简洁的协议上。当客户端建立连接时，服务器以特定的头部响应，然后以文本格式流式传输事件。每个事件遵循一个简单的结构：

```
event: message_type
data: {"key": "value"}
id: unique_event_id

```

末尾的空行表示事件的结束。浏览器的 `EventSource` 会自动解析这些内容，处理重连，甚至跟踪最后接收的事件 ID 以恢复中断的流。

## 3、协议规范

| 特性       | SSE        | WebSockets        | 长轮询     |
| -------- | ---------- | ----------------- | ------- |
| 方向       | 服务器到客户端    | 双向                | 客户端到服务器 |
| 协议       | HTTP       | WebSocket (ws://) | HTTP    |
| 自动重连     | 内置         | 手动                | 手动      |
| 消息格式     | 文本 (UTF-8) | 文本或二进制            | 任意      |
| 浏览器支持    | 优秀（IE 除外）  | 优秀                | 通用      |
| 代理/防火墙友好 | 是          | 有时存在问题            | 是       |
| 连接开销     | 低          | 中等                | 高       |

## 4、SSE 大放异彩的场景

并非每个实时问题都需要相同的解决方案。SSE 在数据主要单向流动——从服务器到客户端——的场景中发挥最佳。如果你的架构中服务器产生连续的更新流，客户端需要消费这些更新，SSE 通常是最简单且最节省资源的选择。它自然地融入现有的 HTTP 基础设施，这意味着在防火墙、代理和负载均衡器方面更少意外。在以下场景将 SSE 作为首选方案：

- **更新主要从服务器流向客户端**：实时动态、通知、监控仪表盘
- **你希望 HTTP 兼容性**：无需特殊配置即可通过代理、负载均衡器和 CDN 工作
- **自动重连很重要**：浏览器原生处理，支持指数退避
- **你已经投入了 HTTP 基础设施**：认证、压缩、缓存都按预期工作
- **资源效率很重要**：对于单向通信，开销低于 WebSockets

## 5、何时应该另寻他法

SSE 是一把锋利的工具，但并非每项工作都适合它。它的单向特性——在正确场景中是最大优势——在客户端需要频繁或复杂地向服务器发送消息时就变成了劣势。在决定使用 SSE 之前，检查以下条件是否适用于你的用例。如果是，不同的协议会更好地服务你，避免你与技术本质对抗。在以下场景跳过 SSE：

- **你需要真正的双向通信**：聊天应用、协作编辑、实时游戏
- **二进制数据至关重要**：WebSockets 更高效地处理二进制
- **需要支持 Internet Explorer**：不过如果你还在支持 IE，我们需要单独谈谈
- **亚秒级延迟至关重要**：WebSockets 由于其基于帧的协议，延迟略低

## 6、动手实践

让我们构建一个实际示例：实时服务器健康监控仪表盘。服务器将向已连接的客户端推送 CPU、内存和请求指标。

我们将创建一个单一的 ASP.NET Core Web API 项目，它同时将 HTML 客户端作为静态文件提供。完成后，你将拥有一个可工作的 SSE 流，只需运行 `dotnet run` 即可在任何浏览器中测试。

### 5.1 项目结构

```
HealthMonitor/
├── HealthMonitor.Api/
│   ├── Controllers/
│   │   └── MetricsController.cs          # SSE 流式端点
│   ├── Models/
│   │   └── ServerMetrics.cs              # 指标记录
│   ├── Services/
│   │   ├── IAuthService.cs               # 认证抽象
│   │   ├── MockAuthService.cs            # 开发/测试实现
│   │   └── FakeAuthenticationHandler.cs  # 开发认证中间件
│   ├── Program.cs                        # 启动 + 静态文件
│   └── HealthMonitor.Api.csproj          # 引用客户端文件夹
├── HealthMonitor.Client/
│   └── index.html                        # 浏览器仪表盘
└── HealthMonitor.Tests/
    └── SseLoadTest.cs                    # 并发负载测试

```

创建项目：

```
mkdir HealthMonitor && cd HealthMonitor
dotnet new webapi -n HealthMonitor.Api --no-openapi
dotnet new xunit -n HealthMonitor.Tests
mkdir HealthMonitor.Client

```

### 5.2 定义模型

从数据模型开始，以便控制器可以干净地引用它：

```csharp
// HealthMonitor.Api/Models/ServerMetrics.cs
namespace HealthMonitor.Api.Models;

public record ServerMetrics
{
    public int CpuUsage { get; init; }
    public int MemoryUsage { get; init; }
    public int RequestsPerSecond { get; init; }
    public DateTime Timestamp { get; init; }
}

```

### 5.3 设置流式端点

这是包含认证和重连支持的完整控制器文件（在步骤 3 和 5 中涵盖）：

```csharp
// HealthMonitor.Api/Controllers/MetricsController.cs
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using HealthMonitor.Api.Models;
using HealthMonitor.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace HealthMonitor.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class MetricsController(IAuthService authService) : ControllerBase
{
    [Authorize]
    [HttpGet("stream")]
    public async Task StreamMetrics(
        [FromHeader(Name = "Last-Event-ID")] string? lastEventId,
        CancellationToken cancellationToken)
    {
        var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (!await authService.CanViewMetrics(userId))
        {
            Response.StatusCode = StatusCodes.Status403Forbidden;
            return;
        }

        Response.Headers.Append("Content-Type", "text/event-stream");
        Response.Headers.Append("Cache-Control", "no-cache");
        Response.Headers.Append("Connection", "keep-alive");

        // 如果客户端重连，从最后接收的事件恢复
        var eventId = int.TryParse(lastEventId, out var id) ? id : 0;

        while (!cancellationToken.IsCancellationRequested)
        {
            try
            {
                var metrics = await GatherSystemMetrics();

                // 格式化为 SSE 事件
                var eventData = $"id: {++eventId}\n" +
                              $"event: metrics\n" +
                              $"data: {JsonSerializer.Serialize(metrics, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })}\n\n";

                var bytes = Encoding.UTF8.GetBytes(eventData);
                await Response.Body.WriteAsync(bytes, cancellationToken);
                await Response.Body.FlushAsync(cancellationToken);

                await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
            }
            catch (Exception ex) when (ex is OperationCanceledException ||
                                       ex is IOException)
            {
                // 客户端断开连接，优雅退出
                break;
            }
        }
    }

    private Task<ServerMetrics> GatherSystemMetrics() =>
        Task.FromResult(new ServerMetrics
        {
            CpuUsage = Random.Shared.Next(10, 90),
            MemoryUsage = Random.Shared.Next(40, 80),
            RequestsPerSecond = Random.Shared.Next(100, 1000),
            Timestamp = DateTime.UtcNow
        });
}

```

关键实现细节：

- **Content-Type 头**：对于 SSE 必须是 `text/event-stream`
- **Cache-Control**：防止代理缓冲流
- **Flush**：确保事件立即到达客户端的关键
- **取消处理**：优雅处理客户端断开连接
- **事件 ID**：跟踪流中的位置，以便重连的客户端可以恢复

### 5.4 添加认证

控制器使用 `[Authorize]`，因此项目需要注册认证方案。对于开发环境，一个伪造的处理器会自动认证每个请求，这样你就可以在没有真实身份提供者的情况下进行测试。

```csharp
// HealthMonitor.Api/Services/IAuthService.cs
namespace HealthMonitor.Api.Services;

public interface IAuthService
{
    Task<bool> CanViewMetrics(string? userId);
}

```

```csharp
// HealthMonitor.Api/Services/MockAuthService.cs
namespace HealthMonitor.Api.Services;

public class MockAuthService : IAuthService
{
    public Task<bool> CanViewMetrics(string? userId)
    {
        // 开发/测试环境允许所有用户。生产环境中替换为真实认证逻辑。
        return Task.FromResult(true);
    }
}

```

```csharp
// HealthMonitor.Api/Services/FakeAuthenticationHandler.cs
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;

namespace HealthMonitor.Api.Services;

public class FakeAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public FakeAuthenticationHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock)
        : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "dev-user") };
        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

```

### 5.5 组装在一起

`Program.cs` 注册所有服务，设置伪造认证方案，并将 `HealthMonitor.Client/` 文件夹作为静态文件提供，使仪表盘在根 URL 加载：

```csharp
// HealthMonitor.Api/Program.cs
using Microsoft.Extensions.FileProviders;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddSingleton<HealthMonitor.Api.Services.IAuthService, HealthMonitor.Api.Services.MockAuthService>();

builder.Services.AddAuthentication("DevAuth")
    .AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions,
               HealthMonitor.Api.Services.FakeAuthenticationHandler>("DevAuth", _ => { });

builder.Services.AddAuthorization();

var app = builder.Build();

// 将 HealthMonitor.Client/（兄弟文件夹）作为根静态文件提供
var clientRoot = Path.Combine(builder.Environment.ContentRootPath, "..", "HealthMonitor.Client");
var clientFileProvider = new PhysicalFileProvider(clientRoot);

app.UseDefaultFiles(new DefaultFilesOptions
{
    FileProvider = clientFileProvider,
    DefaultFileNames = new List<string> { "index.html" }
});
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = clientFileProvider,
    RequestPath = ""
});

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

```

更新 `HealthMonitor.Api.csproj` 以将客户端文件夹复制到构建输出：

```xml
<!-- HealthMonitor.Api/HealthMonitor.Api.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <Content Include="..\HealthMonitor.Client\**\*" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>
</Project>

```

### 5.6 处理重连

浏览器会自动重连并发送 `Last-Event-ID` 头。步骤 2 中的控制器已经通过 `[FromHeader(Name = "Last-Event-ID")]` 读取它，并从中初始化 `eventId`，因此流会从正确的位置恢复。如果你有持久化的事件存储，可以在进入实时循环之前重放 ID 大于 `lastEventId` 的遗漏事件。

### 5.7 创建浏览器仪表盘

此文件位于兄弟文件夹 `HealthMonitor.Client/` 中。API 项目引用并将其作为静态文件提供（在步骤 4 中配置），因此 `EventSource` URL 是相对于同一来源的，没有硬编码端口，也没有 CORS 问题：

```html
<!-- HealthMonitor.Client/index.html -->
<!DOCTYPE html>
<html lang="en">
<body>
  <p>CPU: <span id="cpu">--</span></p>
  <p>Memory: <span id="memory">--</span></p>
  <p>Req/s: <span id="rps">--</span></p>

  <script>
    const eventSource = new EventSource('/api/metrics/stream', {
        withCredentials: true
    });

    eventSource.addEventListener('metrics', (event) => {
        const metrics = JSON.parse(event.data);
        updateDashboard(metrics);
        console.log(`Event ID: ${event.lastEventId}`);
    });

    eventSource.addEventListener('error', (error) => {
        console.error('SSE error:', error);
        // EventSource 会自动尝试重连
    });

    function updateDashboard(metrics) {
        document.getElementById('cpu').textContent = `${metrics.cpuUsage}%`;
        document.getElementById('memory').textContent = `${metrics.memoryUsage}%`;
        document.getElementById('rps').textContent = metrics.requestsPerSecond;
    }
  </script>
</body>
</html>

```

现在从 `HealthMonitor.Api/` 运行 `dotnet run`。检查控制台输出中显示的分配端口（例如 `Now listening on: http://localhost:5241`），然后打开该 URL。仪表盘将加载并立即开始流式传输指标。

### 5.8 测试和监控

**使用 curl 手动测试：**

```
curl -N -H "Accept: text/event-stream" http://localhost:<PORT>/api/metrics/stream

```

**负载测试 `HealthMonitor.Tests/SseLoadTest.cs`：**

```csharp
// HealthMonitor.Tests/SseLoadTest.cs
using System.IO;
using System.Net.Http;
using Xunit;

namespace HealthMonitor.Tests;

public class SseLoadTest
{
    [Fact]
    public async Task Should_Handle_Multiple_Concurrent_Connections()
    {
        var tasks = Enumerable.Range(0, 100)
            .Select(_ => ConnectAndReceiveEvents())
            .ToArray();

        await Task.WhenAll(tasks);
    }

    private async Task ConnectAndReceiveEvents()
    {
        using var client = new HttpClient();
        client.Timeout = TimeSpan.FromMinutes(5);

        using var response = await client.GetAsync(
            "http://localhost:<PORT>/api/metrics/stream", // 将 <PORT> 替换为 dotnet run 输出中显示的端口
            HttpCompletionOption.ResponseHeadersRead);

        using var stream = await response.Content.ReadAsStreamAsync();
        using var reader = new StreamReader(stream);

        var eventCount = 0;
        while (eventCount < 10) // 接收 10 个事件后退出
        {
            var line = await reader.ReadLineAsync();
            if (line?.StartsWith("data:") == true)
                eventCount++;
        }
    }
}

```

SSE 连接是持久的。与在毫秒内完成的普通请求不同，每个连接会无限期地保持响应流打开。一个能正常处理单个连接的服务器，在数百个并发连接下可能会崩溃——如果它阻塞线程或耗尽连接池的话。这个测试同时打开 100 个连接，每个读取 10 个事件后退出，以验证服务器是否按照 SSE 承诺的方式进行扩展。在部署前运行此测试。确保 `HealthMonitor.Api` 已在运行，然后从 `HealthMonitor.Tests/` 文件夹执行：

```
dotnet test

```

## 6、结束语

Server-Sent Events 在你的架构工具箱中应占有一席之地：

- **拥抱简单性**：SSE 为你提供了服务器推送，同时保持 HTTP 熟悉的语义和基础设施兼容性
- **选择正确的工具**：对于服务器到客户端的更新使用 SSE，对于双向通信使用 WebSockets，对于请求-响应使用 REST
- **利用内置功能**：自动重连和事件 ID 跟随浏览器的 EventSource API 免费提供
- **自信地扩展**：SSE 连接比 WebSockets 更轻量，比轮询效率高无数倍

这是你的作业：看看你当前的架构。你在哪里轮询更新？你在哪里维护 WebSocket 连接只是为了推送偶尔的通知？SSE 能否简化你的生活并降低基础设施成本？

或许最重要的是：你上一次质疑"标准"解决方案是否真的是你特定用例的正确解决方案是什么时候？

---

原文链接: [Server-Sent Events: The Humble Hero Between REST and WebSockets](https://www.architectviewmaster.com/blog/server-sent-events-the-humble-hero-between-rest-and-websockets/?ref=hubwiz.com)

汇智网翻译整理，转载请标明出处