lesismal / go-net-benchmark

5 stars 0 forks source link

请教下go net框架选型 #3

Open C4o opened 1 year ago

C4o commented 1 year ago

老哥好,最近在使用到go网络框架的时候遇到些问题,看到老哥在一些仓库的issues中很多详细的分析,所以想请教一下,打扰了。

最近写一个检测系统,使用场景是通过openresty cosocket来同步检测,并且控制超时是30ms,预期万分之一的超时率。然后检测系统的耗时总和测下来一直是100微秒以内。

在选择网络处理框架的时候,一开始使用gnet v2版本,测下来在单机1w qps的时候就会有10%以上超过30ms了;后面改用了netpoll,虽然单机1w qps的30ms超时率在十万分之六左右,但是发现当qps超过1w5的时候,30ms超时率会上升到十万分之十二。后期这套检测系统的qps大概会有400w,机器二十台,单机20w qps的样子,所以担心会扛不住。

然后之前有一套netty写的类似的,单机3w qps大概只有十万分之五的30ms超时率。所以想着老哥愿意的话帮忙看下什么框架能够符合需求。十分感谢。

lesismal commented 1 year ago

兄弟客气了,欢迎交流!

如果不考虑语言,c/cpp/rust 应该是性能首选,golang、nodejs单纯测这种简单功能也还算快;

如果限制使用golang,有一些疑问:

  1. 使用什么协议检测?有说openresty,是使用http协议检测吗?
  2. 如果是tcp、websocket,是长连接还是短连接?如果是http,有keepalive复用连接吗?
  3. 20w qps是对应多少连接数
  4. openresty同步检测我没太理解,是openresty+golang的检测程序吗?最好给一份完整的链路拓扑
  5. 检测系统是检测什么内容,通常如果只是检测节点可达、延迟,间隔一段时间才会请求一次,所以通常不至于有20w qps/节点这种高频
  6. 被检测的节点与检测服务的网络拓扑距离是怎样的,公网、跨地区远距离吗?
  7. 检测服务的软硬件规格如何

如果用golang、同时存在的连接数量不是特别大,通常用golang标准库就可以了,连接数不是非常大的情况下、poller框架可能不如标准库响应性能好

C4o commented 1 year ago

首先感谢老哥回复。 用golang的话是因为方便维护而且团队人少,没有会 c/cpp/rust 的,之前 java 那套代码很长,而且原作者离职了,改起来稍微有点抽象。

然后老哥的疑问我一一回复下:

  1. 用的 tcp,然后通过拼接特定字符串+http包长度+http包的方式进行传输,然后上面测下来的100微秒是包含了解包和检测了
  2. 是 tcp 的长连接,netpoll的配置是这样的,单台openresty的keepalive目前是2000连接池,超时15s,大概一千台
    
    netpoll.SetNumLoops(runtime.GOMAXPROCS(0))
    netpoll.DisableGopool()
    listener, err := netpoll.CreateListener("tcp", fmt.Sprintf(":%d", np.Port))
    if err != nil {
    logger.Logf(logger.FATAL, "create netpoll listener failed")
    }

if np.eventLoop, err = netpoll.NewEventLoop( handle, netpoll.WithOnPrepare(prepare), netpoll.WithReadTimeout(time.Second), netpoll.WithIdleTimeout(1*time.Minute), ); err != nil { logger.Logf(logger.FATAL, "failed to new netpoll event loop: %v", err) }



4. 打印了netpoll的连接数,64c的机器一直保持1600,我暂时没找到哪里改,后面本来想着改连接数来测试30ms的超时率
5. 我在openresty接收请求后先过一遍这个 tcp 服务,然后根据结果再决定要不要走到业务,然后目前openresty的平均耗时在20ms以内,除了那些超过30ms的,其他我打印下来都是比较快的,10ms内大概
6. 检测系统其实就类似 web 应用防火墙,每个请求包的检测,所以有多少外部请求,就要处理多少
7. openresty 和 tcp 服务的网络一般是同机房,测试下来问题应该不大,因为上面提到的gnet的超时率,我在本地openresty连接本地服务也会这样
8. 硬件64c,128g内存这样子,单机 1w qps 的话,跑起来cpu大概 3%,内存大概两百多M,因为包含了比较多的规则和缓存信息

本来是想用原生库来写的,但是因为比较急着灰度,所以先用netpoll跑起来的,目前来看暂时符合预期,但是这两天有活动,qps上涨,观察到30ms超时率也在增加,所以有点担心。
lesismal commented 1 year ago

是这种拓扑对吗:

openresty -> golang检测服务 -> 其他web服务

openresty -> golang检测服务golang检测服务 -> 其他web服务 都是同机房

如果是这样,那排除公网波动的问题

lesismal commented 1 year ago

硬件64c,128g内存`

这个配置的话,我建议用标准库,单节点20w连接、20w协程完全没问题,响应性能应该是要比poller框架好的。

C4o commented 1 year ago

是这种拓扑对吗:

openresty -> golang检测服务 -> 其他web服务

openresty -> golang检测服务golang检测服务 -> 其他web服务 都是同机房

如果是这样,那排除公网波动的问题

不是的,是openresty到web服务 openresty -> ( ngx_tcp_send -> golang -> ngx_tcp_receive -> openresty ) -> web service

lesismal commented 1 year ago

你上面提到的比如 1w qps,以及后续每个节点20w连接后、20w qps,只是在每轮检测的时候才有这个高qps、不需要每秒都来一轮这个qps吧? 如果是这样,也可以试试根据检测间隔,做成时间轮、把连接错开分布,比如检测间隔是1分钟,那么每秒检测 20w/60 个连接,这样错开请求、避免同时拥堵、降低每个连接的响应延迟

协议解析,可以还用你们之前的tcp发送的部分,直接发送预先生成好的固定buffer,读取可以用http.ReadRequest或者你们如果已经封装的解析逻辑更简化和高效

lesismal commented 1 year ago

openresty -> ( ngx_tcp_send -> golang -> ngx_tcp_receive -> openresty ) -> web service

我更懵了 :joy: 前面说的用gnet、netpoll慢,是指 ngx_tcp 请求 golang 到响应回来比较慢?

C4o commented 1 year ago

你上面提到的比如 1w qps,以及后续每个节点20w连接后、20w qps,只是在每轮检测的时候才有这个高qps、不需要每秒都来一轮这个qps吧? 如果是这样,也可以试试根据检测间隔,做成时间轮、把连接错开分布,比如检测间隔是1分钟,那么每秒检测 20w/60 个连接,这样错开请求、避免同时拥堵、降低每个连接的响应延迟

协议解析,可以还用你们之前的tcp发送的部分,直接发送预先生成好的固定buffer,读取可以用http.ReadRequest或者你们如果已经封装的解析逻辑更简化和高效

单台1w的qps是持续的,每一秒都是这样的 协议解析都已经做好了的,拆tcp包取http,然后解析http包

openresty -> ( ngx_tcp_send -> golang -> ngx_tcp_receive -> openresty ) -> web service

我更懵了 😂 前面说的用gnet、netpoll慢,是指 ngx_tcp 请求 golang 到响应回来比较慢?

对的 逻辑大概是这样的,通过这种方式打印跟golang服务的耗时,绝大部分都是10ms内

local sock = ngx.socket.tcp()
    sock:settimeout(30)

    local ok, err = sock:connect(host, port)
    if not ok then
        sock:close()
        return
    end
    local req = 'flag ' .. #raw_header .. '\r\n' .. raw_header

    local bytes, err = sock:send(req)
    if not bytes then
        sock:close()
        return
    end

    local result, err = sock:receive()
    if not result then
        sock:close()
        return
    end
    sock:setkeepalive(15000, 2000)

    local cost = ngx.now()-start
    local cost = cost*1000
    if cost > 30 then 
        ngx.log(ngx.ERR, ngx.var.uri, " : ", cost, "ms")
    end
lesismal commented 1 year ago

单台1w的qps是持续的,每一秒都是这样的

单台1w,用标准库试试吧,我压测得到的数据,这种通常标准库更快些

协议解析都已经做好了的,拆tcp包取http,然后解析http包

跟用标准库的对比是你们自己实现的这个要快不?因为你前面说用的是gnet、netpoll,异步非阻塞的网络层,解析器速度未必比标准库同步解析快,但是检测的payload小、并且如果你们简化了解析逻辑去掉了不必要的,那是能快

C4o commented 1 year ago

单台1w的qps是持续的,每一秒都是这样的

单台1w,用标准库试试吧,我压测得到的数据,这种通常标准库更快些

好的,我先试试标准库,感谢老哥。现在单台是1w,后续的话单台会有20w qps,但是预期还是想要万分之一以内的30ms超时率

协议解析都已经做好了的,拆tcp包取http,然后解析http包

跟用标准库的对比是你们自己实现的这个要快不?因为你前面说用的是gnet、netpoll,异步非阻塞的网络层,解析器速度未必比标准库同步解析快,但是检测的payload小、并且如果你们简化了解析逻辑去掉了不必要的,那是能快

是的,目前测下来自己实现的http解析比http. ReadRequest要快一些,然后所有除了网络库操作以外的操作耗时,单个请求都在100微妙以内

func handle(ctx context.Context, conn netpoll.Connection) error {
    now := time.Now()
    defer func() {
                 // 100μs
        Observe(time.Since(now).Seconds())
    }()

        // decode connection bytes
        // read http package
        // detector(request)
        // conn.Write()
}
lesismal commented 1 year ago

如果20w连接数确实大、延迟还是高,也可以试下nbio,可能不比gnet快太多、或者同一水平线,但是还可以更多定制,每个连接固定的读buffer,可以尝试ET单IO协程派发事件+不同的协程池、避免event loop协程里处理所有连接的IO导致的部分连接IO延迟(因为都在同一个for循环里排队)。 当然,要想更快,我建议是把epoll这部分拆出来,把不必要的功能都去掉、比如去掉锁、简化写失败时候的缓存等待可写逻辑。因为你的测试场景相当于echo、没有并发写、一个请求对应一个响应,而且只是测试可达、payload也不大。

gnet的benchmark,给标准库用16k的ReadBuffer: https://github.com/gnet-io/gnet-benchmarks/blob/v2/echo-net-server/main.go#L33

gnet自己用默认参数64k: https://github.com/gnet-io/gnet-benchmarks/blob/v2/echo-gnet-server/main.go https://github.com/panjf2000/gnet/blob/master/gnet.go#L465 https://github.com/panjf2000/gnet/blob/master/gnet.go#L418 虽然有区别:gnet是每个poller上一个read buffer所以buffer总内存占用不会太大,标准库每个连接一个buffer、所以单个连接不适合设置得特别大。但测吞吐,这样毕竟不是特别好。把标准库buffer也调大后其实标准库还是很快的

nbio性能对比其他框架、标准库在这里: https://github.com/lesismal/go-net-benchmark/issues/1 ,但最好还是自己跑下测试代码、跑多轮避免环境波动影响。

C4o commented 1 year ago

刚才用net写了下。。发现跟gnet一样的现象,就是如果包比较小的话(大概4k以内),会出现一小部分的请求超过30ms,但是一旦包大了,可能大部分后者全部请求耗时都会超过30ms,都是在本地测试的,之前用netpoll在本地暂时不会出现这种问题。现在拿nbio测一下。

用net是这么写的,老哥有空帮看下。

func process(conn net.Conn) {
    var err error
    var request []byte
    var req http.Request

    now := time.Now()
    codec := Codec{}
    remote := conn.RemoteAddr().String()

    defer func() {
        conn.Close()
    }()

    reader := bufio.NewReader(conn)
    for {
        request, err = codec.DecodeNet(remote, reader)
        if err == ErrEmptyPackage {
            break
        }

        // serialize request
        //req, err = string2xttp(string(request))
        req, err = http.NewRequest(string(request))
        if err != nil {
            logger.Logf(logger.VERBOSE, "failed to decode http from `%s`: %v", remote, err)
            if _, err = conn.Write([]byte("OK;" + err.Error() + "\n")); err != nil {

            }
            continue
        }

        // code, reason, comment := detector.Detect(&req)
        code = "NO"
        reason = "test"

        if _, err = conn.Write([]byte(string(code) + ";" + reason + "\n")); err != nil {
            logger.Logf(logger.ERROR, "failed to write to conn `%s`: %v", remote, err)
        }

    }
}

func (std *Standard) Serve() {
    listen, err := net.Listen("tcp", fmt.Sprintf(":%d", std.Port))
    if err != nil {
        fmt.Println("Listen() failed, err: ", err)
        return
    }
    for {
        conn, err := listen.Accept()
        if err != nil {
            fmt.Println("Accept() failed, err: ", err)
            continue
        }
        go process(conn)
    }
}

func (codec *Codec) DecodeNet(remote string, reader *bufio.Reader) (request []byte, err error) {
    // 从reader里peek出对应的http包长度,然后返回request并且discard掉request的长度
}
lesismal commented 1 year ago

可不可以提供下完整能跑的client server例子,我闲了跑下看看

C4o commented 1 year ago

可不可以提供下完整能跑的client server例子,我闲了跑下看看

好的,我整理下代码,感谢老哥。。

刚测试用了nbio,确实不存在上面说的gnet和net遇到的那种现象了。

但是想问一哈,OnData里的data,咋流式处理呀?之前用其他框架,好像一般都有peek和discard这种。

lesismal commented 1 year ago

OnData里的data就是[]byte,raw bytes的方式自己处理

nbio每个poller是带一个读buffer,有可读事件时默认读到这个buffer里然后调用OnData,如果处理streaming half-packet,需要conn自己对应地去缓存,比如 nbio.Conn.SetSession(buffer) 可以设置session相关地信息、struct里带buffer也行。 但这样是需要从poller buffer拷贝到conn自己的buffer的,稍微浪费,也可以用engine.OnRead来设置自己处理读的handler,这样OnData就失效、nbio不自动读取,然后你自己的handler里直接读到自己conn的常驻buffer上就可以了。因为你的场景是echo、payload预计也不大,所以多数时候可能不会触发half-packet的情况,所以未必能提升太多,可以试试看

C4o commented 1 year ago

OnData里的data就是[]byte,raw bytes的方式自己处理

nbio每个poller是带一个读buffer,有可读事件时默认读到这个buffer里然后调用OnData,如果处理streaming half-packet,需要conn自己对应地去缓存,比如 nbio.Conn.SetSession(buffer) 可以设置session相关地信息、struct里带buffer也行。 但这样是需要从poller buffer拷贝到conn自己的buffer的,稍微浪费,也可以用engine.OnRead来设置自己处理读的handler,这样OnData就失效、nbio不自动读取,然后你自己的handler里直接读到自己conn的常驻buffer上就可以了。因为你的场景是echo、payload预计也不大,所以多数时候可能不会触发half-packet的情况,所以未必能提升太多,可以试试看

了解,我试一下。老哥,代码放在这了 https://github.com/C4o/go-net-demo

lesismal commented 1 year ago

有client的代码吗?这个没看出来检测相关的

lesismal commented 1 year ago

还有延迟统计的逻辑

C4o commented 1 year ago

有client的代码吗?这个没看出来检测相关的

client是用的nginx lua,然后直接请求nginx的方式来调用 lua代码是这样的,要放在openresty的lualib目录。延时统计有个ngx.log(ngx.ERR, ngx.var.uri, " : ", cost, "ms")用来打印耗时的,然后设置了30ms超时,sock:settimeout(30),如果超时了就不会打印耗时了

local ngx_re_split = require("ngx.re").split
local _M = {}
function _M.handler()
    local start = ngx.now()
    local tcp_host = "127.0.0.1"
    local tcp_port = 9002
    local sock = ngx.socket.tcp()
    sock:settimeout(30)
    local ok, err = sock:connect(tcp_host, tcp_port)
    if not ok then
        ngx.log(ngx.ERR, "failed to connect ("..tcp_host..":"..tcp_port.. "): ", err)
        sock:close()
        return
    end
    -- header
    local headers = ngx.req.get_headers()
    -- 获取 raw_header
    local raw_header = ngx.req.raw_header()
    local req = 'FLAG ' .. #raw_header .. '\r\n' .. raw_header
    local bytes, err = sock:send(req)
    if not bytes then
        ngx.log(ngx.ERR, "failed to send request ("..tcp_host..":"..tcp_port.."): ", err)
        sock:close()
        return
    end
    local result, err = sock:receive()
    if not result then
        ngx.log(ngx.ERR, "failed to sock:receive ("..tcp_host..":"..tcp_port.."): ", err)
        sock:close()
        return
    end
    sock:setkeepalive(15000, 2000)
    local cost = ngx.now()-start
    local cost = cost*1000
    -- if cost > 30 then 
    -- 打印耗时
        ngx.log(ngx.ERR, ngx.var.uri, " : ", cost, "ms")
    -- end
    local response_data = ngx_re_split(result, ";")
    if response_data == nil then
        return
    end
    local code = response_data[1]
    if code == "NO" then
        ngx.exit(403)
    end
end
return _M

server段的配置加载个lua就好了

server {
        listen 80;
        server_name _;
        error_log  /var/logs/error.log;
        location / {
            rewrite_by_lua_block {
                local cli = require("client")
                cli.handler()
                ngx.exit(200)
            }
        }
    }
C4o commented 1 year ago

OnData里的data就是[]byte,raw bytes的方式自己处理

nbio每个poller是带一个读buffer,有可读事件时默认读到这个buffer里然后调用OnData,如果处理streaming half-packet,需要conn自己对应地去缓存,比如 nbio.Conn.SetSession(buffer) 可以设置session相关地信息、struct里带buffer也行。 但这样是需要从poller buffer拷贝到conn自己的buffer的,稍微浪费,也可以用engine.OnRead来设置自己处理读的handler,这样OnData就失效、nbio不自动读取,然后你自己的handler里直接读到自己conn的常驻buffer上就可以了。因为你的场景是echo、payload预计也不大,所以多数时候可能不会触发half-packet的情况,所以未必能提升太多,可以试试看

老哥这个说的,有示例代码吗?我这么写好像不太行。。刚才在测试环境跑了下,还是会粘包的,只用OnData处理不了。。

reader := bufio.NewReader(nbio.Conn)
reader.Peek()
reader.Discard()
lesismal commented 1 year ago

不适合用阻塞的方式去读非阻塞的Conn,要不你还是先用OnData吧,OnData传给你的data是已经读取到的,自己处理粘包、半包缓存就可以了,或者参考默认读取的这块逻辑、自己定制OnRead: https://github.com/lesismal/nbio/blob/master/poller_epoll.go#L201

C4o commented 1 year ago

不适合用阻塞的方式去读非阻塞的Conn,要不你还是先用OnData吧,OnData传给你的data是已经读取到的,自己处理粘包、半包缓存就可以了,或者参考默认读取的这块逻辑、自己定制OnRead: https://github.com/lesismal/nbio/blob/master/poller_epoll.go#L201

了解,我先试试。

lesismal commented 1 year ago

好的