# 记一次并发请求报错
背景是要做一个类似爬虫的功能,从第3方网站下载资源。首先有个函数判断是否url地址有效,所以需要请求一遍。 本地测试没有问题,但上线后,概率性地出现一个错误:
write: connection reset by peer
上网一查,这错还是比较常见的,加上关键字go
搜索下,也有不少。
# close大法
首先看的是这篇Go 解决"Connection reset by peer"或"EOF"问题 (opens new window)
上面说:
go
在解决问题之前需要了解关于go是如何实现connection的一些背景小知识:
有两个协程,一个用于读,一个用于写(就是readLoop
和writeLoop
)。在大多数情况下,readLoop
会检测socket
是否关闭,并适时关闭connection
。如果一个新请求在readLoop
检测到关闭之前就到来了,那么就会产生EOF
错误并中断执行,而不是去关闭前一个请求。
这里也是如此,我执行时建立一个新的连接,这段程序执行完成后退出,再次打开执行时服务器并不知道我已经关闭了连接,所以提示连接被重置;如果我不退出程序而使用for循环多次发送时,旧连接未关闭,新连接却到来,会报EOF
。
提出的解决方案是对 req 增加属性设置:
req.Close = true
它会阻止连接被重用,可以有效的防止这个问题,也就是Http的短连接
该博主问题与我的类似,于是我写了个小例子来测试,代码如下:
package main
import (
"log"
"net/http"
"sync"
"time"
)
func IsUrlOk(url string) bool {
response, err := http.Get(url)
if err != nil {
log.Fatalf("[IsUrlOk] get url【%s】error! error is %s", url, err.Error())
return false
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
log.Println("response.StatusCode", response.StatusCode)
return false
}
return true
}
func IsUrlOk2(url string) bool {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
log.Println("req err")
return false
}
req.Close = true
response, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("[IsUrlOk] get url【%s】error! error is %s", url, err.Error())
return false
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
log.Println("response.StatusCode", response.StatusCode)
return false
}
return true
}
func main() {
url := "https://npm.taobao.org/mirrors/node/v14.7.0/node-v14.7.0-win-x64.zip"
// url := "https://cdn.npm.taobao.org/dist/node/v14.7.0/node-v14.7.0-win-x64.zip"
// b := IsUrlOk(url)
// b := IsUrlOk2(url)
// log.Println(b)
var wg sync.WaitGroup
startTime := time.Now()
for i := 0; i <= 100; i++ {
wg.Add(1)
go func() {
// b := IsUrlOk(url)
IsUrlOk2(url)
wg.Done()
}()
}
wg.Wait()
log.Println(time.Since(startTime))
time.Sleep(1 * time.Second)
}
其中,IsUrlOk
与IsUrlOk2
的区别是前者是我旧的请求代码,后者是加了Close
后的代码。
并发量设置为100
,但依然会偶现上面的错误,尤其是在运行结束后,立即再次运行。
将并发量设置为500
后,复现的概率就大大增加了。
# TCP握手
在头疼之际,找到了另一篇文章记一次压测问题定位:connection reset by peer,TCP三次握手后服务端发送RST (opens new window)
为了方便,我把文章主要内容扒来:
# 问题定位以及原因
connection reset by peer
的含义是往对端写数据的时候,对端提示已经关闭了连接。一般往一个已经被关闭的socket
写会提示这个错误。但是通过log分析,服务端没有应用层面的close
,客户端也没有应用层面的write
。抓包发现客户端建立TCP
完成3次握手后,服务端立刻就回了RST
,关闭了连接。RST
的情况见的多,这种情况着实没有遇到过。最后N次baidu google
,终于找到答案。
# TCP三次握手后服务端直接RST的真相
内核中处理TCP
连接时维护着两个队列:SYN
队列和ACCEPT
队列。
服务端在建立连接过程中,内核的处理过程如下:
- 客户端使用
connect
调用向服务端发起TCP
连接,内核将此连接信息放入SYN队列
,返回SYN-ACK
- 服务端内核收到客户端的
ACK
后,将此连接从SYN队列
中取出,放入ACCEPT队列
- 服务端使用
accept
调用将连接从ACCEPT队列
中取出
上述抓包说明,3次握手已经完成。
但是应用层accept
并没有返回,说明问题出在ACCEPT队列
中。
那么什么情况下,内核准确的说应该是TCP协议栈
会在三次握手完成后发RST
呢?
原因就是ACCEPT队列
满了,上述(2)中,服务端内核收到客户端的ACK
后将连接放入ACCEPT队列
失败,就有可能回RST
拒绝连接。
进一步来看Linux协议栈
的一些逻辑:
Linux协议栈的一些逻辑
SYN队列
和ACCEPT队列
的长度是有限制的,SYN队列
长度由内核参数tcp_max_syn_backlog
决定,ACCEPT队列
长度可以在调用listen(backlog)
通过backlog
,但总最大值受到内核参数somaxconn
(/proc/sys/net/core/somaxconn
)限制。
若SYN队列
满了,新的SYN包
会被直接丢弃。
若ACCEPT队列
满了,建立成功的连接不会从SYN队列
中移除,同时也不会拒绝新的连接,这会加剧SYN队列
的增长,最终会导致SYN队列
的溢出。
当ACCEPT队列
溢出之后,只要打开tcp_abort_on_flow
内核参数(默认为0
,关闭),建立连接后直接回RST
,拒绝连接(可以通过/proc/net/netstat
中ListenOverflows
和ListenDrops
查看拒绝的数目)。
所以真相找到了:就是ACCEPT队列
溢出了导致TCP
三次握手后服务端发送RST
。
我压测的时候起500
个goruntine
,同时跟服务端建立HTTP
连接,可能导致了服务端的ACCEPT队列
溢出。这里之所以用可能,是因为并没有找到证据,只是理论上分析。
但是验证这个问题简单:修改一下内核参数somaxconn
。
果然,用以下方法修改后,500
个也不会再报错了。
echo 10000 >/proc/sys/net/core/somaxconn
# 永久修改somaxconn
修改/proc/sys/net/core/somaxconn
后,重启后保存不了
在/etc/sysctl.conf
中添加如下
net.core.somaxconn = 2048
然后在终端中执行
sysctl -p