# 记一次并发请求报错
背景是要做一个类似爬虫的功能,从第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