一、先说说这个坑长什么样

最近有个朋友来找我,说他们的聊天应用上了CDN加速之后,用户开始频繁掉线。现象很典型:页面打开没问题,能连上,也能收发消息,但过不了几分钟,连接就会断掉。再自动重连,然后又断。用户投诉说“网络不好”,可他们自己直连源站的时候,一点问题都没有。这其实就是CDN和WebSocket之间闹别扭了。

这类问题在很多实时通信场景里都会遇到,比如在线客服、股票行情、协同编辑、物联网消息推送。只要用了CDN加速,同时又需要保持一个长时间不断的网络连接,就很可能掉进这个坑里。我朋友当时第一反应是服务器配置有问题,换了好几台机器,调了一堆内核参数,结果都没用。后来他才想到,问题可能出在用户和源站之间的那个“中间人”——CDN身上。

今天我就用大白话,把排查思路和解决方法捋一遍,让你下次遇到时不用像我朋友那样手忙脚乱。

二、为什么CDN会掐断长连接

2.1 WebSocket是怎么建立连接的

要搞懂这个问题,得先知道WebSocket是怎么“搭上线”的。你可以把普通的HTTP请求想象成“打电话问路”:你拨通电话,问一句,对方答一句,然后挂断。每个电话之间都没有关系。而WebSocket更像“加微信聊天”:先互相加好友,然后就能一直发消息,不用每次都重新拨号。

这个“加好友”的过程,就是WebSocket的“升级握手”。浏览器发一个普通的HTTP GET请求,但带上几个特殊的头,比如Upgrade: websocketConnection: Upgrade,意思是对面说:我想要把这次连接升级成一个长连接。服务器如果同意,就会返回状态码101 Switching Protocols,然后两边就通过同一条TCP连接开始双向传数据。注意,这里说的“握手”不是TCP层面的三次握手,而是HTTP协议里的升级动作。而在客户端和服务器之间,可能还有一层层的中间设备,比如路由器、防火墙、CDN节点,每一层都可能影响这次升级。

2.2 CDN边缘节点在握手时的角色

CDN在这个过程里就像一个快递中转站。它本来最擅长的是接收一些短小请求,比如图片、网页文件,然后从离你最近的节点快速返回内容。因为普通HTTP请求用完就断,CDN只负责转发一次,所以很轻松。

但WebSocket请求不是用完就断的。当你的客户端先跟CDN边缘节点完成握手,之后所有消息都要沿着这条连接来回跑。CDN边缘节点就成了一个“代理”,它要一直帮着两边递话。可问题是,很多CDN边缘节点默认没有开启对Upgrade协议的支持。它们收到带Upgrade: websocket的请求时,可能没意识到这是一个长连接请求,而是当成普通HTTP请求处理,直接转发给源站,然后源站返回了一个普通的HTTP响应。这就像快递中转站看到对方写“请把包裹升级成长期直接联系”,结果中转站不认识这句话,就把信壳子原样拆了,只回了个“收到”。

更麻烦的是,就算CDN节点支持WebSocket,它还可能设置了一些不太友好的策略。比如,空闲连接超过30秒没有数据交换,就自动断开。如果你的应用没做心跳,或者是聊天室消息不频繁,很容易触发这个超时。还有一些CDN节点会限制每条连接的最大时长,到了时间就强制断开。

2.3 常见断连原因

断连通常有三个原因:

第一,CDN边缘节点没有配置允许WebSocket,手都握不上,或者握到一半就被打回原形。第二,CDN的缓存规则把握手请求当成了普通请求,往缓存里塞,导致返回了错误的状态码。第三,即使握手成功,CDN的某个中间层因为长时间没有数据传输,主动断开了空闲连接。尤其当你的应用没有做心跳保活时,这个“空闲超时”就会特别准。

另外,还有一个小众原因:CDN回源的时候,如果源站不支持HTTP/1.1的Upgrade头,或者回源连接用的是HTTP/2,而HTTP/2下CDN又没做好兼容,也可能导致握手失败。

三、一步步排查:从客户端到边缘节点

3.1 先确认是不是CDN的锅

遇到断连,别急着改代码,先做个对比实验。把服务部署在测试环境,用一个不带CDN的域名直连源站,跑一晚上看会不会断。同时用CDN域名跑相同时间段,观察断连情况。如果直连不断,CDN断,那基本可以锁定问题在CDN这一段。

我这个朋友当时就是这么验证的。他先拿自己的手机连公司Wi-Fi,直连源站的测试域名,聊了半小时没断。再用CDN域名,结果不到五分钟就断了一次。这就说明,源站和客户端网络都没问题,问题就出在CDN。

3.2 用脚本看握手响应

判断CDN到底有没有正确转发握手,最直接的办法是自己写一个脚本,往目标地址发一个WebSocket握手请求,看看返回的状态码是什么。下面这个Node.js脚本用原生http模块实现,不依赖第三方库。

// 技术栈:Node.js (JavaScript)
// 作用:手动发起一次WebSocket握手请求,打印服务器返回的状态码和响应头

const http = require('http');

// 你要测试的CDN加速域名,注意这里不带ws://或wss://
const hostname = 'chat.yourcdn.com';
const path = '/socket';
const port = 80; // 如果用的是HTTPS,记得改成443并使用https模块,之后再说

const options = {
  hostname,
  port,
  path,
  method: 'GET',
  headers: {
    Connection: 'Upgrade',   // 告诉服务器:我要升级连接
    Upgrade: 'websocket',    // 升级协议是WebSocket
    'Sec-WebSocket-Version': 13, // WebSocket协议版本
    'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', // 握手用的随机密钥,通常由客户端生成
  },
};

const req = http.request(options, (res) => {
  console.log('普通响应状态码:', res.statusCode);
  console.log('响应头:', JSON.stringify(res.headers, null, 2));
  // 如果返回101,说明服务器支持升级;
  // 如果返回200或其它状态码,说明CDN把它当成普通请求了。
  res.resume();
});

// 这个事件只有在服务器返回101时才会触发
req.on('upgrade', (res, socket) => {
  console.log('升级成功!状态码:', res.statusCode);
  socket.destroy();
});

req.on('error', (err) => {
  console.error('请求失败:', err.message);
});

req.end();

如果你跑完这个脚本,发现返回的是200 OK,而不是101 Switching Protocols,那就说明CDN完全没有把握手请求当作WebSocket来对待。这时候你的浏览器客户端实际会发生什么?它会认为服务器拒绝升级,于是连接直接失败,或者发出一个普通HTTP请求。而在线应用里,你看到的往往不是立即断开,而是反复连接,看起来就像“不稳定”一样。

3.3 查看回源请求头是否被改写

有些CDN虽然能返回101,但它在回源的时候把一些关键请求头改掉了,比如把Upgrade头去掉,或者把Connection改成了close。这样源站没办法识别出这是一个WebSocket升级请求,于是返回一个普通响应,CDN再把那个普通响应传回来,你的客户端就只能傻眼。

这时候你可以在源站上放一个小小的HTTP服务,打印出收到的请求头,然后通过CDN域名去访问这个服务。下面这个Node.js脚本就是一个简易的源站日志服务。

// 技术栈:Node.js (JavaScript)
// 作用:在源站打印所有收到的请求头,方便你对比CDN回源时头有没有被改写

const http = require('http');

const server = http.createServer((req, res) => {
  console.log('收到请求,请求头如下:');
  console.log(JSON.stringify(req.headers, null, 2));
  console.log('-------------------');
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('ok');
});

// 监听9000端口,把CDN回源地址指到这个端口即可
server.listen(9000, () => {
  console.log('源站模拟服务已启动:http://localhost:9000');
});

启动这个服务之后,你再用前面的握手脚本来请求CDN域名。观察源站的日志。如果日志里能看到upgrade: websocketconnection: upgrade,说明CDN原样把请求头传回来了,问题可能出在别处。如果日志里的头和客户端发的不一致,比如upgrade不见了,那就是CDN干的坏事。

3.4 别忘了检查证书和域名

还有一个容易被忽略的坑:如果你的CDN加速域名用的是HTTPS,也就是wss://,那么CDN边缘节点在跟源站通信时,也可能因为证书配置不正确导致握手失败。尤其是有些CDN回源走的是HTTP,而源站强制要求HTTPS,两者冲突,连接就会在握手过程中被重置。

还有,域名本身也可能有讲究。有些CDN要求升级协议绑定的路径和普通文件缓存路径分开,如果你网页是/static,WebSocket路径是/socket,那CDN可能会对/socket也应用了静态文件的缓存规则,从而影响握手。所以检查域名和路径配置也很有必要。

3.5 加一个全链路日志

要彻底看清哪一层出了问题,最好让客户端、CDN、源站三层都打上日志。客户端打日志能看到“我发了握手请求”,源站打日志能看到“我收到了握手请求”。CDN的日志一般可以在控制台下载,里面会记录每次请求的响应状态码,如果状态码不是101,那就能定位到CDN。很多云厂商还提供实时日志,你可以在出问题的时候去翻。

我在实战中一般建议:先跑脚本拿响应头,再去源站看请求头,最后结合CDN日志判断。这三步做完,十有八九的问题都能水落石出。

四、怎么解决:让CDN放行WebSocket

问题找到了,解决起来一般分三步。

第一步,去CDN控制台找“WebSocket支持”或者“连接类型”相关的开关,把它打开。大部分主流CDN厂商都有这个开关,有的默认关,有的默认开,你需要确认一下。如果你用的是自建CDN,比如自己维护的Nginx反向代理,那就要在Nginx配置里加上对Upgrade头的透传,并设置长连接超时时间。这里不展开Nginx配置了,因为本文的技术栈是JavaScript,Nginx配置属于运维范畴,不过原理都是一样的。

第二步,检查缓存规则。WebSocket的握手请求只应该被转发,不应该被缓存。你可以在CDN配置里加上一条规则:针对/socket这类握手路径,禁止缓存。有的CDN还区分“动态请求”和“静态请求”,你可以把握手路径标记为动态请求。

第三步,如果CDN支持修改回源HTTP版本,尽量使用HTTP/1.1。虽然HTTP/2也支持WebSocket,但很多CDN在HTTP/2下的兼容性并不完美,容易出现连接被重置的问题。回源采用HTTP/1.1会更稳。

另外,如果你有独立的聊天业务,而其他业务共用同一个CDN域名,建议单独申请一个域名专门给WebSocket使用。这样可以完全隔离缓存规则和超时设置,避免互相影响。

下面用一个简单的Node.js示例演示,在客户端如何做好心跳保活和异常重连,来应对CDN可能的“空闲断开”:

// 技术栈:Node.js (JavaScript)
// 作用:演示WebSocket客户端如何做心跳保活和自动重连

const WebSocket = require('ws');

// 你的WebSocket地址,换成实际测试地址
const url = 'wss://chat.example.com/socket';
let ws = null;
let heartbeatTimer = null;

// 心跳间隔:10秒发一次
const HEARTBEAT_INTERVAL = 10000;
// 重连延迟:断开后2秒重连
const RECONNECT_DELAY = 2000;

function connect() {
  console.log('正在连接...');
  ws = new WebSocket(url);

  ws.on('open', () => {
    console.log('连接成功');
    // 连接建立后,开始定时发送心跳
    heartbeatTimer = setInterval(() => {
      if (ws.readyState === WebSocket.OPEN) {
        console.log('发送心跳');
        ws.send('heartbeat');
      }
    }, HEARTBEAT_INTERVAL);
  });

  ws.on('message', (data) => {
    console.log('收到数据:', data.toString());
  });

  ws.on('close', (code, reason) => {
    console.log('连接关闭,关闭码:', code, '原因:', reason.toString());
    // 清理心跳定时器
    if (heartbeatTimer) {
      clearInterval(heartbeatTimer);
      heartbeatTimer = null;
    }
    // 无论什么原因,过一会儿重连
    console.log(`将在${RECONNECT_DELAY / 1000}秒后重连`);
    setTimeout(connect, RECONNECT_DELAY);
  });

  ws.on('error', (err) => {
    console.error('发生错误:', err.message);
    // 这里不需要手动重连,因为error之后一般会触发close
  });
}

connect();

这个重连逻辑很简单,但这几乎是所有在线应用保命的基础。加上心跳之后,即使CDN有很长的空闲超时,你也能通过定时消息占用通道,让它以为连接一直活跃。

如果你发现CDN控制台实在没有“WebSocket支持”这个开关,怎么办?那就只能在系统架构上妥协。比如,你可以把WebSocket单独放在一个不做CDN加速的域名上,让动态长连接直接回源,而把页面、图片、视频这些静态资源交给CDN。这样做的缺点是,长连接不再享受CDN的节点加速,但至少稳定性有保障。这在很多企业里其实是标准做法。

还有一种思路是,用轮询替代WebSocket。但轮询的实时性差,而且请求量大,对源站压力大,不建议在实时性要求高的场景下用。所以,最好的办法还是让CDN真正支持WebSocket,而不是绕开它。

五、注意事项和场景分析

5.1 什么业务适合用WebSocket + CDN

并不是所有WebSocket都要套CDN。如果你的用户分布在全国甚至全球,源站只有几台服务器,那么CDN确实能帮你减少网络延迟,因为用户可以就近连到边缘节点。但前提是边缘节点和源站之间必须建立一条可靠的长连接。如果CDN配置不当,就会变成“加速了个寂寞”,反而增加断连风险。

适合的场景有:实时榜单、股票行情、在线文档协同、多人小游戏。这些场景对延迟敏感,而且连接数相对可控,CDN的边缘节点能够充分利用。

不适合的场景有:海量设备高频率上报数据的物联网,或者超大规模聊天室。因为每个边缘节点都要维持大量长连接,如果CDN厂商的节点资源不够,或者连接数上限太低,反而会成为瓶颈。

5.2 技术优缺点

CDN加速WebSocket的优点很明显。第一,客户端可以就近接入,网络路径更短,延迟更低。第二,源站不需要暴露公网IP,隐藏了真实地址,防御DDoS的能力更强。第三,CDN可以对握手请求做基础校验,过滤一些恶意连接。

缺点也很直观。首先,CDN多了一层中转,调试困难。你很难看到浏览器到CDN之间到底发了什么。其次,CDN可能有限制连接数或者空闲超时,而这个超时你可能无法完全自定义。再次,大部分CDN对WebSocket的支持不如对HTTP那么成熟,很多额外的功能比如压缩、多路复用都用不上。

5.3 使用时的注意事项

总结几个我踩过或见过的坑:

  1. 一定要开启CDN的WebSocket支持,否则一切白搭。
  2. 握手路径不要套用缓存规则,否则握手请求会被CDN返回缓存响应。
  3. 客户端必须做心跳,但心跳间隔要短于CDN的空闲超时,一般建议30秒以内。
  4. 如果CDN回源使用的HTTP版本不规范,尝试强制设置为HTTP/1.1。
  5. 使用wss://时,证书不能只挂在源站,边缘节点的证书配置也要正确。
  6. 生产环境不要只看浏览器控制台,多用脚本抓包或打印响应头,确认是哪一层断的。
  7. 如果你用了多个CDN,比如主备切换,记得每个节点都要做同样配置,别只配了主节点。
  8. 在CDN上启用WebSocket功能后,最好先在小范围灰度测试,再逐步放量,免得影响所有用户。

六、总结

回到我朋友的那个案例,最后他做的就三件事:在CDN控制台打开WebSocket支持,把握手路径加入“不缓存”名单,再让客户端加一个心跳重连。改完之后,掉线问题就消失了,用户也不投诉了。

WebSocket长连接和CDN之间的问题,本质上就是“一个想长期聊天的人,遇到了一个习惯送完快递就关门的中转站”。你要做的,就是让中转站知道“这个人不是来取快递的,是来搭长期伙的”。只要握手能被正确转发,后续的长连接维护就只是时间问题。希望这篇博客能帮你在遇到类似问题时,少走一点弯路,早点把问题解决掉。