WebSocket是什么?和HTTP是什么区别?长轮询是什么?服务器推送是什么?

摘要:从一次"实现聊天室功能的技术选型"出发,深度剖析WebSocket、HTTP长轮询、SSE服务器推送的实现原理与性能对比。通过短轮询到长轮询到WebSocket的演进过程、以及协议升级的握手流程,揭秘为什么聊天室必须用WebSocket、为什么长轮询会浪费资源、以及SSE为什么只能单向推送。配合时序图展示消息推送流程,给出不同实时通信场景的最佳选型。


💥 翻车现场

周二下午,哈吉米接到了一个需求。

产品经理:"我们要做一个在线客服聊天功能,用户和客服实时对话。"
哈吉米:"好的!"(内心:怎么实现实时通信?)

第一版:短轮询

// 前端每1秒请求一次新消息
setInterval(() => {
    axios.get('/api/message/new').then(resp => {
        if (resp.data.length > 0) {
            // 显示新消息
            showMessages(resp.data);
        }
    });
}, 1000);  // 每秒请求一次

上线第一天

问题:
1. 服务器QPS暴增(1000个用户 × 1次/秒 = 1000 QPS)
2. 大部分请求都是"没有新消息"(浪费资源)
3. 消息延迟(最多1秒延迟)
4. 用户体验差

哈吉米:"短轮询太浪费了,有没有更好的方案?"

南北绿豆和阿西噶阿西来了。

南北绿豆:"实时通信有4种方案:短轮询、长轮询、SSE、WebSocket。"
阿西噶阿西:"来,我逐个给你讲。"


🤔 方案1:短轮询(Polling)

原理

客户端每隔一段时间(如1秒)请求一次服务器

流程:
1. 客户端:请求新消息
2. 服务器:查询数据库,返回结果(可能为空)
3. 等待1秒
4. 重复步骤1

时序图

sequenceDiagram
    participant Client as 客户端
    participant Server as 服务器
    participant DB as 数据库

    loop 每1秒
        Client->>Server: 1. GET /api/message/new
        Server->>DB: 2. 查询新消息
        DB->>Server: 3. 无新消息
        Server->>Client: 4. 返回:[](空)
        Note over Client: 等待1秒
    end
    
    Note over DB: 有新消息到达
    
    Client->>Server: 5. GET /api/message/new
    Server->>DB: 6. 查询新消息
    DB->>Server: 7. 有新消息
    Server->>Client: 8. 返回:[msg1, msg2]

优点

  • ✅ 实现简单

缺点

  • ❌ 大量无效请求(浪费服务器资源)
  • ❌ 消息延迟(最多1秒)
  • ❌ 频繁建立/关闭连接

🤔 方案2:长轮询(Long Polling)

原理

客户端请求后,服务器不立即返回:
- 如果有新消息 → 立即返回
- 如果没有新消息 → 挂起请求,等待(如30秒)
- 超时或有新消息 → 返回

好处:
- 减少无效请求
- 消息实时性好(几乎立即返回)

代码实现

服务端

@RestController
public class MessageController {
    
    // 用于通知有新消息
    private final Map<Long, CountDownLatch> waitingClients = new ConcurrentHashMap<>();
    
    /**
     * 长轮询接口
     */
    @GetMapping("/api/message/longPolling")
    public Result longPolling(@RequestParam Long userId, 
                              @RequestParam Long lastMessageId) {
        
        // 1. 先查询是否有新消息
        List<Message> messages = messageService.getNewMessages(userId, lastMessageId);
        
        if (!messages.isEmpty()) {
            // 有新消息,立即返回
            return Result.ok(messages);
        }
        
        // 2. 没有新消息,挂起请求
        CountDownLatch latch = new CountDownLatch(1);
        waitingClients.put(userId, latch);
        
        try {
            // 等待30秒(超时)或被唤醒(有新消息)
            boolean hasNew = latch.await(30, TimeUnit.SECONDS);
            
            if (hasNew) {
                // 有新消息,查询并返回
                messages = messageService.getNewMessages(userId, lastMessageId);
                return Result.ok(messages);
            } else {
                // 超时,返回空
                return Result.ok(Collections.emptyList());
            }
            
        } catch (InterruptedException e) {
            return Result.error("请求中断");
        } finally {
            waitingClients.remove(userId);
        }
    }
    
    /**
     * 发送消息时,唤醒等待的客户端
     */
    @PostMapping("/api/message/send")
    public Result sendMessage(@RequestBody Message message) {
        // 保存消息
        messageService.save(message);
        
        // 唤醒等待的客户端
        CountDownLatch latch = waitingClients.get(message.getToUserId());
        if (latch != null) {
            latch.countDown();  // 唤醒
        }
        
        return Result.ok();
    }
}

前端

// 长轮询(递归调用)
function longPolling() {
    axios.get('/api/message/longPolling', {
        params: {
            userId: 10086,
            lastMessageId: lastMsgId
        },
        timeout: 35000  // 超时35秒(比服务器超时长)
    }).then(resp => {
        if (resp.data.length > 0) {
            // 显示新消息
            showMessages(resp.data);
            lastMsgId = resp.data[resp.data.length - 1].id;
        }
        
        // 立即发起下一次请求
        longPolling();
    }).catch(err => {
        // 出错后,等待1秒再重试
        setTimeout(longPolling, 1000);
    });
}

// 启动长轮询
longPolling();

长轮询时序图

sequenceDiagram
    participant Client as 客户端
    participant Server as 服务器

    Client->>Server: 1. 请求新消息(长轮询)
    Note over Server: 没有新消息,挂起请求
    Note over Server: 等待30秒或有新消息
    
    Note over Server: 10秒后,有新消息到达
    Server->>Client: 2. 返回新消息(挂起了10秒)
    
    Client->>Server: 3. 立即发起下一次请求
    Note over Server: 没有新消息,挂起
    Note over Server: 等待30秒
    
    Note over Server: 30秒超时,无新消息
    Server->>Client: 4. 返回空(超时)
    
    Client->>Server: 5. 立即发起下一次请求

优点

  • ✅ 减少无效请求(有消息才返回)
  • ✅ 实时性好(消息立即返回)

缺点

  • ❌ 服务器要维护大量挂起的连接
  • ❌ 占用线程资源(每个请求一个线程)
  • ❌ 仍然是HTTP请求(有HTTP头开销)

🤔 方案3:SSE(Server-Sent Events)服务器推送

原理

SSE:
- 基于HTTP
- 单向通信(服务器 → 客户端)
- 持久连接
- 文本协议

代码实现

服务端

@RestController
public class SseController {
    
    /**
     * SSE接口
     */
    @GetMapping(value = "/api/message/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter sse(@RequestParam Long userId) {
        SseEmitter emitter = new SseEmitter(0L);  // 0表示不超时
        
        // 异步发送消息
        CompletableFuture.runAsync(() -> {
            try {
                while (true) {
                    // 查询新消息
                    List<Message> messages = messageService.getNewMessages(userId);
                    
                    if (!messages.isEmpty()) {
                        // 推送消息
                        emitter.send(messages);
                    }
                    
                    // 休息1秒
                    Thread.sleep(1000);
                }
            } catch (Exception e) {
                emitter.completeWithError(e);
            }
        });
        
        return emitter;
    }
}

前端

// SSE客户端
const eventSource = new EventSource('/api/message/sse?userId=10086');

eventSource.onmessage = function(event) {
    const messages = JSON.parse(event.data);
    showMessages(messages);
};

eventSource.onerror = function(error) {
    console.error('SSE错误', error);
    eventSource.close();
};

优点

  • ✅ 单向推送场景很方便
  • ✅ 基于HTTP(兼容性好)
  • ✅ 自动重连

缺点

  • ❌ 只能服务器推送(单向)
  • ❌ 不能客户端发消息

适用场景

  • 实时通知
  • 股票行情
  • 新闻推送

🤔 方案4:WebSocket(推荐⭐⭐⭐⭐⭐)

原理

WebSocket:
- 全双工通信(客户端 ↔ 服务器)
- 持久连接
- 二进制/文本协议
- 基于TCP,先通过HTTP握手,再升级协议

WebSocket握手流程

HTTP升级为WebSocket:

客户端请求:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket             ← 关键:请求升级协议
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL...  ← 随机字符串
Sec-WebSocket-Version: 13

服务器响应:
HTTP/1.1 101 Switching Protocols  ← 状态码101:协议切换
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0s...  ← 根据客户端Key计算

握手完成后:
连接从HTTP协议切换到WebSocket协议
后续通信不再是HTTP,而是WebSocket帧

握手时序图

sequenceDiagram
    participant Client as 客户端
    participant Server as 服务器

    Note over Client,Server: 阶段1:HTTP握手
    Client->>Server: 1. GET /chat HTTP/1.1<br/>Upgrade: websocket
    Server->>Server: 2. 验证Sec-WebSocket-Key
    Server->>Client: 3. HTTP/1.1 101 Switching Protocols<br/>Upgrade: websocket
    
    Note over Client,Server: 阶段2:协议升级完成
    Note over Client,Server: 后续通信不再是HTTP,而是WebSocket帧
    
    Client->>Server: 4. WebSocket帧:客户端消息
    Server->>Client: 5. WebSocket帧:服务器消息
    
    loop 双向通信
        Client->>Server: 6. 发送消息
        Server->>Client: 7. 推送消息
    end

WebSocket代码实现

服务端(Spring Boot)

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new ChatWebSocketHandler(), "/chat")
                .setAllowedOrigins("*");
    }
}

@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {
    
    // 存储所有连接的用户
    private static final Map<Long, WebSocketSession> SESSIONS = new ConcurrentHashMap<>();
    
    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        // 连接建立
        Long userId = getUserId(session);
        SESSIONS.put(userId, session);
        
        System.out.println("用户" + userId + "连接成功");
    }
    
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        // 接收客户端消息
        String payload = message.getPayload();
        ChatMessage chatMsg = JSON.parseObject(payload, ChatMessage.class);
        
        // 推送给目标用户
        WebSocketSession targetSession = SESSIONS.get(chatMsg.getToUserId());
        if (targetSession != null && targetSession.isOpen()) {
            targetSession.sendMessage(new TextMessage(JSON.toJSONString(chatMsg)));
        }
    }
    
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        // 连接关闭
        Long userId = getUserId(session);
        SESSIONS.remove(userId);
        
        System.out.println("用户" + userId + "断开连接");
    }
}

前端

// WebSocket客户端
const ws = new WebSocket('ws://localhost:8080/chat');

// 连接打开
ws.onopen = function() {
    console.log('WebSocket连接成功');
    
    // 发送消息
    ws.send(JSON.stringify({
        toUserId: 10087,
        content: '你好'
    }));
};

// 接收消息
ws.onmessage = function(event) {
    const message = JSON.parse(event.data);
    showMessage(message);
};

// 连接关闭
ws.onclose = function() {
    console.log('WebSocket连接关闭');
};

// 错误处理
ws.onerror = function(error) {
    console.error('WebSocket错误', error);
};

📊 4种方案对比

性能对比(1000个用户在线聊天)

方案请求次数/秒服务器压力实时性双向通信
短轮询1000极高差(1秒延迟)
长轮询33(平均30秒一次)高(挂起连接)好(立即返回)
SSE1(建立连接)❌ 单向
WebSocket1(建立连接)极好✅ 双向

资源占用对比

短轮询

1000个用户:
- 每秒1000个HTTP请求
- 每个请求:建立连接 + 查询数据库 + 关闭连接
- 数据库QPS:1000
- 网络带宽:1000 × 500字节(HTTP头) = 500KB/s

长轮询

1000个用户:
- 同时挂起1000个HTTP连接
- 1000个线程(每个连接一个线程)
- 线程栈空间:1000 × 1MB = 1GB
- 消息到达时,立即返回

WebSocket

1000个用户:
- 1000个WebSocket连接(长连接)
- 使用NIO(一个线程管理多个连接)
- 线程数:10-20个(不是1000个)
- 内存占用:低
- 消息推送:立即(双向通信)

流量对比(发送1条消息"hello")

HTTP短轮询

HTTP请求:
GET /api/message/new?userId=10086 HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0
Accept: application/json
Cookie: ...

(约300字节HTTP头)

HTTP响应:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 50

{"code":0,"data":[{"content":"hello"}]}

总流量:约400字节

WebSocket

WebSocket帧:
[Frame Header: 2-6字节][Payload: "hello"]

总流量:约10字节

流量节省:(400 - 10) / 400 = 97.5%

🎯 何时用什么方案?

选型建议

场景推荐方案原因
在线聊天WebSocket双向、实时、低延迟
游戏WebSocket实时性要求高
协同编辑WebSocket双向实时同步
实时通知SSE单向推送即可
股票行情SSE单向推送
简单轮询短轮询实时性要求不高
兼容性要求高长轮询兼容老浏览器

技术栈选择

Spring生态

WebSocket:
- Spring WebSocket
- STOMP协议(消息代理)
- SockJS(兼容方案)

SSE:
- Spring MVC的SseEmitter

Netty

- 高性能WebSocket服务器
- 适合百万连接

🎓 面试标准答案

题目:WebSocket和HTTP有什么区别?

答案

核心区别

特性HTTPWebSocket
通信方式请求-响应(半双工)双向通信(全双工)
连接短连接(或Keep-Alive)长连接
协议应用层协议基于TCP,先HTTP握手再升级
开销每次请求有HTTP头(300-500字节)握手后只有2-6字节帧头
实时性差(轮询)好(推送)
服务器推送❌ 不支持(需要轮询)✅ 支持
浏览器支持✅ 所有✅ 现代浏览器

工作流程

HTTP

  1. 客户端发起请求
  2. 服务器响应
  3. 关闭连接(或Keep-Alive)

WebSocket

  1. HTTP握手(协议升级)
  2. 升级为WebSocket协议
  3. 持久连接,双向通信
  4. 任意一方可主动发送消息

适用场景

  • WebSocket:聊天、游戏、协同编辑
  • HTTP:普通API、文件下载

题目:长轮询是什么?

答案

长轮询(Long Polling)

原理

  • 客户端发起请求
  • 服务器不立即返回,挂起请求
  • 有新数据或超时 → 返回
  • 客户端收到响应后,立即发起下一次请求

优缺点

  • ✅ 减少无效请求
  • ✅ 实时性好
  • ❌ 服务器压力大(挂起大量连接)
  • ❌ 占用线程(每个连接一个线程)

vs 短轮询

  • 短轮询:每秒请求一次,大部分返回空
  • 长轮询:有消息才返回,减少无效请求

vs WebSocket

  • 长轮询:仍然是HTTP,有HTTP头开销
  • WebSocket:升级为专用协议,开销小

🎉 结束语

一周后,哈吉米把聊天室改成了WebSocket。

哈吉米:"用WebSocket后,1000个用户在线,服务器CPU才用20%!"

南北绿豆:"对,WebSocket是实时通信的最佳方案,双向、低延迟、低开销。"

阿西噶阿西:"记住:聊天、游戏用WebSocket,单向推送用SSE,简单场景用HTTP。"

哈吉米:"还有长轮询虽然比短轮询好,但仍然不如WebSocket。"

南北绿豆:"对,理解了这4种方案的区别,就知道如何选型了!"


记忆口诀

短轮询频繁请求浪费资源大
长轮询挂起连接等新消息
SSE服务器推送单向通信
WebSocket双向全双工最优选
HTTP请求响应,WebSocket持久连接