有个团队做了实时大盘——价格、库存、阈值告警。选 WebSocket 的时候没犹豫,觉得做实时通信这不就是标准答案吗。等压测报告出来一看内存占用,才发觉不对劲。

先说结论

10万并发连接下,SSE 的内存占用比 WebSocket 低约 40%。

这是 Ark Protocol 团队的真实压测数据。他们用 Rust + Axum 分别实现了两套服务,在相同条件下跑基准测试:

指标WebSocketSSE差距
内存/连接~52KB~31KB-40%
10万连接总内存~5.2GB~3.1GB-2.1GB
CPU(低频推送)基准基准几乎相同
横向扩展需 stateful 负载均衡标准 HTTP 负载均衡SSE 完胜

差距不是来自某个角落的微优化,是两种协议底层机制的根本差异。

协议机制差异:为什么内存差这么多

WebSocket:一条永久的专线电话

WebSocket 建立后,TCP 连接保持不断开,双方随时能互相扔数据。这样一来:

  • 每个连接在服务端要维护一个独立的 Task:读写分离、状态追踪、连接生命周期管理
  • 协议本身有帧解析开销:Opcode(4位)、Mask 位(1位)、Payload Length(7/16/64位可变)、帧校验
  • 应用层心跳另起定时器:即使没有数据传输,心跳包也要占内存

连接数少的时候这不是问题。上了规模,内存就开始尖叫——每个连接都在吃内存。

SSE:挂在门上的信箱

SSE 基于 HTTP,服务端向客户端单向推数据。连接建立后,客户端只需要一个 EventSource API,服务端按需发送文本事件。

  • HTTP/1.1 流水线特性让连接可以被复用:一个连接可以承载多个客户端的 SSE 流
  • 没有 WebSocket 那种帧解析负担:就是 HTTP 流式响应,Text/Event-Source 类型
  • HTTP 中间件天然认识 SSE:NGINX、Cloudflare、AWS ALB 都知道怎么处理,不会误判为空闲连接

代价也清楚:只能服务端→客户端单向。客户端要发数据?得另开一个 HTTP 请求。

实战场景:什么时候该选谁

选 SSE 的场景

数据流向单一的业务最适合 SSE。比如:

  • 实时价格推送(服务端推,客户端看)
  • 库存数量变化通知
  • 日志流、监控大盘、CI/CD 构建状态
  • 告警阈值触发通知

这些场景里客户端从来不主动喊话,SSE 刚好 cover。

大规模横向扩展是 SSE 的强项。SSE 跑在标准 HTTP 上,流量可以直接甩给任何能处理 HTTP 的负载均衡器。100台 backend 轻松加,不存在 WebSocket 那样的连接状态共享问题。

选 WebSocket 的场景

需要双向、频繁、低延迟交互的业务,WebSocket 更合适。比如:

  • 聊天室、协作编辑(多人同时操作)
  • 游戏操作指令(你一动对方马上看到)
  • 金融交易下单(双向握手确认)

这些场景双向通道是刚需,SSE 硬上就得开两个连接(一个推一个拉),复杂度反而上来。

中间地带:混合架构

也有团队在两个都用:SSE 处理大规模下行数据(行情、通知),WebSocket 处理小量高频上行指令(下单、聊天)。这是合理的折中。

实测数据解读:CPU 和延迟才是另一面

Ark Protocol 的测试里内存差距 40%,CPU 占用两者差距不大。SSE 省内存,WebSocket 省 CPU?这个结论要小心。

真正影响 CPU 的是消息频率和 payload 大小,不是协议本身。毫秒级高频推送场景,WebSocket 的二进制帧(最小 2 字节帧头)比 SSE 的文本事件(data: ...\n\n)更紧凑,CPU 反倒是 WebSocket 更省。

决策不能只看内存一个指标:

维度SSE 优势WebSocket 优势
内存占用✅ 40%↓ at 100K 连接-
CPU 效率(高频)-✅ 二进制帧更紧凑
双向通信✅ 原生支持
横向扩展✅ HTTP 天然支持❌ 需要状态共享
中间件兼容性✅ 标准 HTTP❌ 私有协议
断线重连浏览器自动重连需手动实现
自动心跳均衡器代理需应用层实现

代码对比:Axum 实现 SSE vs WebSocket

SSE 版本

use axum::{Router, routing::get, response::sse::{Sse, Event}};
use tokio_stream::wrappers::BroadcastStream;
use tokio::sync::broadcast;
use std::time::Duration;

async fn sse_handler(broadcast_rx: broadcast::Receiver<String>) -> Sse<Event> {
    let stream = BroadcastStream::new(broadcast_rx).map(|msg| {
        Ok(Event::default().data(msg.unwrap_or_default()))
    });
    Sse::new(stream).keepalive(
        axum::response::sse::keep_alive()
            .interval(Duration::from_secs(15))
    )
}

#[tokio::main]
async fn main() {
    let (tx, _rx) = broadcast::channel::<String>(100);
    let app = Router::new()
        .route("/stream", get(sse_handler));
    // 用 tx.send() 向所有订阅者广播
    println!("SSE server running on :8080");
}

SSE 核心优势

  • 45 行代码搞定
  • 无需维护连接映射表
  • 自动重连由浏览器处理
  • 负载均衡器天然支持

WebSocket 版本

use axum::{Router, routing::get, ws::{WebSocket, WebSocketUpgrade}};
use tokio::sync::broadcast;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;

struct WSState {
    peers: Arc<Mutex<HashMap<String, broadcast::Sender<String>>>>,
}

async fn ws_handler(
    ws: WebSocketUpgrade,
    State(state): State<WSState>,
    Path(client_id): Path<String>,
) -> impl IntoResponse {
    ws.on_upgrade(move |socket| handle_socket(socket, state, client_id))
}

async fn handle_socket(socket: WebSocket, state: WSState, client_id: String) {
    let (sender, mut receiver) = broadcast::channel::<String>(100);
    {
        let mut peers = state.peers.lock().await;
        peers.insert(client_id.clone(), sender.clone());
    }
    let mut rx = state.tx.subscribe();
    let (ws_sender, mut ws_receiver) = socket.split();
    // 处理 WebSocket 消息
    let writer = async {
        while let Ok(msg) = rx.recv().await {
            if ws_sender.send(axum::extract::ws::Message::Text(msg)).send().await.is_err() {
                break;
            }
        }
    };
    let reader = async {
        while let Ok(msg) = ws_receiver.next().await {
            if let Some(Ok(axum::extract::ws::Message::Text(text))) = msg {
                println!("Received: {}", text);
            }
        }
    };
    tokio::join!(writer, reader);
    let mut peers = state.peers.lock().await;
    peers.remove(&client_id);
}

WebSocket 核心挑战

  • 需要维护连接映射表(HashMap + Mutex)
  • 断线重连需要手动实现
  • 负载均衡需要 sticky session 或 WebSocket 感知型 LB
  • 代码量比 SSE 多 60-70%

一句话决策

只需要看数据、扩展性优先 → SSE

需要喊话、低延迟双向交互 → WebSocket

大多数监控、通知、实时数据流场景其实选 SSE 就够了。


想跟踪更多 Rust 异步编程、实时系统架构实战?关注「全栈之巅-梦兽编程」公众号,每周更新。

也欢迎了解 梦兽编程 AI 编程助手服务 ,帮你把 AI 编程工具用到生产环境。