关于http长连接的一个坑

作者:杨润炜
日期:2022/1/2 10:42

关于http长连接

http1.1引入了长连接功能,为了解决连接复用,避免每次数据传输都需要tcp三次握手四次挥手的时间消耗。

http长连接的应用场景

web服务器与浏览器等客户端连接时,常使用http长连接,减少tcp建立连接的时间,优化程序性能。

踩坑排坑填坑的过程

突然被坑

这个坑发生在公司内网环境,本身内网环境是相对稳定可靠的,http长连接用于客户端请求上游服务获取处理结果,由于场景是流量一直不中断的,所以两端保持长连接比较适合,客户端建立长连接池与服务端进行数据通信。并且,客户端和服务器都采用多机多实例部署。
当时的情况是某些部署服务端的服务器宕机(实际是交换机异常导致断网),客户端持续出现固定比例的失败。设想的情况是机器宕机后长连接中断,通信会自动恢复,但却一直没有恢复的迹象。

仔细排坑

当时没办法,只能动用“重启大法”,重启客户端进程后立即恢复了。
在分析问题时发现了一些线索,比如服务端进程同时服务了好几个场景,且都是长连接,但只有某个场景出现异常。服务端用的是同一套框架实现不同的接口给不同的客户端使用,所以有理由怀疑是客户端实现不同导致的。
接下来,我仔细比较了正常与异常客户端的实现,发现其使用的http长连接库不同,正常的使用了agentkeepalive(ps: 客户端和服务端都用node.js实现)且配置了freeSocketTimeout和timeout,异常的使用了http agent但没有使用timeout,后者是node.js官方库,前者基于后者,对长连接做了些优化,比如支持连接的TTL、空闲和活跃长连接的超时。
找了资料研究了http 长连接的机制,它实际是tcp长连接,所以建立和释放连接都客户端和服务端协商清楚才行,比如建立连接是三次握手,释放连接是四次挥手,缺少某个环节会导致无法建立或释放连接。
我们再来还原下当时故障的底层情况。当服务端机器网络异常时,客户端tcp层完全不知道与之连接的服务端已经中断掉了,这时候还傻傻地保持与其连接的状态。当时有部分的服务器运行的服务端是正常的,所以只有一定比较的失败情况。
如果长连接长时间不传输数据,主动把它释放掉(发起tcp挥手“口令”,四次挥手或发现对端已关闭则释放tcp连接),则不会出现上述故障。agentkeepalive的freeSocketTimeout、timeout和http agent的timeout都适配了这些的情况。
这个问题的底层源由,跟node.js 5秒规律性中断连接的问题是一样的。详细参考这个issue:13391

场景复现

接下来用一些工具来模拟下当时的场景,以便检验理论的正确性。

  • 使用server.js模拟服务端,部署在机器:192.168.1.2;
  • 使用client.js模拟客户端,不断发请求给服务端,部署在机器:192.168.1.3;
  • 使用iptable来模拟机器断网的情况;
  • 使用tcpdump抓包查看服务端与客户端的网络交互;

server.js

  1. const http = require('http');
  2. const server = http.createServer(async (req, res) => {
  3. res.end('server res: '+Date.now());
  4. });
  5. // server.keepAliveTimeout = 30 * 1000 // default: 5*1000
  6. server.listen(8000, () => {
  7. console.log('server listen...')
  8. })

client.js

  1. const http = require('http');
  2. const agent = new http.Agent({ keepAlive: true, timeout: 5 * 1000, maxSockets: 1, maxTotalSockets: 1 });
  3. const options = {
  4. host: '192.168.1.2',
  5. port: 8000,
  6. path: '/',
  7. agent
  8. };
  9. async function main() {
  10. for(let i =0;i<100000;i++) {
  11. try {
  12. await r()
  13. } catch(err) {
  14. console.log('req err', err.message)
  15. }
  16. await delay(5000)
  17. }
  18. console.log('done')
  19. }
  20. function delay(ms) {
  21. return new Promise((resolve, reject) => {
  22. setTimeout(resolve, ms)
  23. })
  24. }
  25. function r() {
  26. const reqId = Date.now() + "-" + Math.random()
  27. return new Promise((resolve, reject) => {
  28. let done = false
  29. setTimeout(() => {
  30. if (!done) {
  31. reject(new Error('timeout in reqId: ' + reqId))
  32. }
  33. }, 3*1000)
  34. console.log('send req in reqId: ' + reqId)
  35. // Make a request
  36. const req = http.request(options);
  37. req.end();
  38. req.on('response', (res) => {
  39. let buf = Buffer.from('')
  40. res.on('data', data => {
  41. buf = Buffer.concat([buf, data])
  42. })
  43. res.on('end', ()=> {
  44. console.log(buf.toString(), 'in reqId: ' + reqId)
  45. done = true
  46. resolve()
  47. })
  48. });
  49. req.on('error', error => {
  50. done = true
  51. reject(new Error(error.message + ' in reqId: ' + reqId))
  52. })
  53. })
  54. }
  55. main()

步骤

  1. 在192.168.1.2运行server.js
    keepAliveTimeout可以设置久一点,默认5秒可能不够时间操作下面的流程,建议是30秒
  2. 在192.168.1.3运行client.js
  3. 查看建立的长连接端口:
    1. lsof -i:8000
    结果:
    1. COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
    2. node 12900 yangrunwei 21u IPv4 1439691810 0t0 TCP c2:42974->c3:8000 (ESTABLISHED)
    说明端口是42974
  4. 在192.168.1.2机器上禁止192.168.1.3的42974端口输入和输出数据
    1. sudo iptables -A INPUT -p tcp -d 192.168.1.3 --dport 42974 -j DROP
    2. sudo iptables -A OUTPUT -p tcp -d 192.168.1.3 --dport 42974 -j DROP
    等待30秒(基于第一步设置的服务端长连接timeout时间,等待服务端把长连接释放掉)
  5. 此时client程序提示连接超时,此时的并发1,设置了最多的长连接也是1,因为该连接已经无法传输数据,但tcp并没有释放它。(此处可以多停留一段时间验证)
  6. 在192.168.1.3抓包
    1. sudo tcpdump tcp port 8000 and dst host 192.168.1.2
    看到tcp一直在重试一个数据,因为一直发送失败
    1. Flags [P.], seq 240:320, ack 376, win 342, options [nop,nop,TS val 396832519 ecr 398209177], length 80
    2. Flags [P.], seq 240:320, ack 376, win 342, options [nop,nop,TS val 396832720 ecr 398209177], length 80
    在192.168.1.2抓包,没有收到数据
    1. sudo tcpdump tcp port 8000 and dst host 192.168.1.3
  7. 恢复网络
    先查找iptable规则所在的索引:
    1. sudo iptables -L --line-numbers
    分别删除索引(index1, index2)所在的规则:
    1. sudo iptables -D INPUT {index1}
    2. sudo iptables -D OUTPUT {index2}
  8. 查看抓包情况
    服务端发送了RST,表示连接已失效:
    1. Flags [S.], seq 1476040904, ack 4093690286, win 43690, options [mss 65480,sackOK,TS val 398225200 ecr 396844531,nop,wscale 7], length 0
    客户端终于知道连接失效,回复后立即释放掉这个连接:
    1. Flags [.], ack 1476040905, win 342, options [nop,nop,TS val 396844532 ecr 398225200], length 0
    ps: 如果网络较快恢复,服务端长连接未释放的话,当前长连接将会继续使用(没到长连接timeout失效前)

至此,场景复现完毕,可以充分证明长连接机器突然宕机或网络中断时,确实没有释放掉。不过在平时退出程序的情况下(ctr+c或kill信号),是可以正常关闭长连接。这是因为操作系统在tcp层为我们做了连接关闭的事情。可以继续用上面的实验,减去断网的操作即可。感兴趣的朋友可以试一下。

合理填坑

问题总算是理清了,现在考虑修复的问题。agentkeepalive和http agent在正确配置后都可以解决该问题,不过考虑到agentkeepalive支持了空闲长连接、活跃长连接的超时,能够避免长连接空占过多资源,所以还是选用它比较合适。

参考

sock_tcp_reconnects
Explain http keep-alive mechanism
Connection management in HTTP/1.x
node.js issue-13391
TCP-Keepalive-HOWTO

感谢您的阅读!
如果看完后有任何疑问,欢迎拍砖。
欢迎转载,转载请注明出处:http://www.yangrunwei.com/a/120.html
邮箱:glowrypauky@gmail.com
QQ: 892413924