查看原文
其他

几行代码为老板省百万-某高并发服务GOGC及UDP Pool优化

The following article is from yifhao Author yifhao

20万QPS-臣妾办不到

前两天一位同学找我, 说他一个需要几百万qps的go重构的服务下周要上线, 但随着容器核数增加, 不具有线性Scale Up, 他担心上线后扛不住,心里没底. 8核(容器虚拟核, 可以认为是4个物理核, 以下都为虚拟核)时QPS 2.3万/s, 他单容器加到40核时, qps只有8万(按我看来, 这种单容器搞这么大核, 不太合理) 他的目标是能够大致对标原先的cpp服务, 40核物理核50万/s, 即对应到虚拟核40核, 25万/s, go能跑到15-20万即可(原先cpp数据读共享内存, go读redis).

以下代码均为精简过的示意代码, 与实际略有不同.

屡试不爽GOGC: 8万/s到14万/s

我大概了解了一下他的服务逻辑模型, 接收客户端UDP请求, 请求一次redis, 然后再单向发UDP包给其他机器.

服务的内存占用不大, 200m. 这种情况, 有一种屡试不爽的方法: 在main里面分配一个1024M不用的[]byte, 全局占着, 根据允许的内存情况, 然后把服务GOGC设置大一些.



我这里设置了300, 也就是正常跑的时候, 占用内存4G, 一测QPS从8万到了14万, gc消耗在火焰图中也基本看不见了. 3行代码为马化腾节省上百万, 离目标不远了. 足以看出go的gc在这种高QPS小内存情况下的损耗之大.

UDP问题

接下来跑一发火焰图. 有几处可以优化的地方. 我觉得到20万/s应该没啥问题. 其中有一点比较有意思, UDP单发的client调用, 占用CPU竟然比同样数量的redis调用tcp一来一回的还要多. 不是说UDP简单, 这咋比TCP还耗性能呢?

火焰图中显示占用了竟然达到30%的CPU时间.

RPC框架中UDP发包的代码精简了一下大概是这样. 

火焰图中的1对应net.DialUDP, 2对应udpconn.Write, 3对应conn.Close.

大致浏览了下代码, 其实这里主要是系统调用耗费了时间 DialUDP: SYSSOCKET创建socket fd, SYSFCNTL获取fd属性和设置fd为nonblock, SYSSETSOCKOPT设置socket fd的一些属性, SYSCONNECT绑定远端ip port, 最大头的还是poll.runtimepollOpen里把这个UDP fd放入到epoll中EPOLLCTLADD.

Write: 基本是SYSWRITE.

Close: 基本是SYSCLOSE.

由此看出, 这个代码发一次udp发包, 这么多系统调用. 最大的问题就是每次发包, 都创建一个实例, 然后用一下, 就关掉了. UDP实例能否复用?

UDP实例复用

其实代码思路倒不难, 就是对于同一个对端地址的udp实例, 使用一个sync.Pool保存, 这样同时对一个ip port的请求可以使用不同的实例, 比用一个实例减少锁等待.

不同的地址用一个sync.Map保存各个对应的sync.Pool. 使用时, 从sync.Map获取对应的sync.Pool, pool中有则使用, 没有则创建一个.

使用结束, 放回pool中.

sync.Pool的特性使得在GC时, 可以释放不再使用的fd.

结构体包装定义

Pool代码


这里其实会有一点内存泄露的味道, sync.Map中对端地址对应的sync.Pool实例本身没法释放. 不过这里问题不大, 对于服务端之间调用, 就算你对端节点有几万个, 我估计也就只有几M内存而已.

是否有并发问题, 串包问题?

对于这种并发网络编程, 最怕的就是并发不安全的操作并发了, 状态乱了, 或者串包. 初看起来没啥问题: 不同协程不会同时使用一个UDP实例.

但仔细一思考, 这样的使用方式还是存在一点局限性的, 大家都知道UDP是无状态的, 也就是说, 你用udp往ip1:p1发个包, 那么你本地也必然开了一个端口local:p2, 然后你不收回包了, 但服务端回了包.

接下来, 另一个协程如果也往ip1:p1发包, 正好也用了local:p2, 但它收回包, 这个时候上一次服务端的回包就被这个协程接收了, 导致串包了. 不复用UDP实例, 每次new, write, close, 这种情况都有可能偶尔发生. 那用池子的话, 更加加剧了这种情况. 所以

  1. UDP客户端要和服务端的行为一致, 服务端不回包的话, 客户端就只发不收. 服务端回包的话, 客户端就即发又收.

  2. 如果udp服务端回包, 客户端也不想收, 那么在一个客户端机器上, 不要对同一个对端udp服务, 存在收包和不收包的两种udp client模型.

经过压测, 的确没有串包问题.

UDP改进效果 17万/s

QPS从14万涨到了17万. 之前dial占了绝大部分, 现在已经很少了, close也很少了. 而write在现有代码调用下, 是没减少的.

其他处理

看了下火焰图, 还有其他一些地方可以优化一下.

  1. 日志精简

  2. 上报合并

  3. 客户端服务发现的select node性能需要优化

做了1,2步后, 基本上到QPS到20万问题不大, 后面我也没关注了. 当然正式环境跑的时候, 跑个一半多点的负载就行了.

关于UDP connect

可能没接触过UDP的人会觉得很奇怪, go的udp怎么会有dial呢? 其实底层调的是udp connect, 作用当然不是三次握手, 建立连接, UDP中调用connect内核仅仅把对端ip&port记录下来, 发包时无需指定对端addr, 使用 funcWrite(b[]byte)(int,error), 同一个实例后面多次发包都无需再做connect.

go里面也还有not connect udp, 使用net.ListenPacket创建, 然后发包时指定对端addr, 使用方法 WriteTo(p[]byte,addrAddr)(nint,err error), 这种情况下, 每次发包内核都要绑定对端addr, 发包完, 解绑. 多次发包, 都会进行绑定和解绑.

除此之外, 对于connected udp, 收包的时候也会减少应用层出现串包的问题. 因为它只收connect的对端ip的包返回给应用层.

总结

UDP以前用的多, 框架直接通过配置就能切换udp/tcp, 内网网络环境好, UDP用起来没啥问题, 不过现在用的不多了. 我不太熟, 做这个优化我也是现学的UDP.

抓大放小, 先从参数改起. 20行代码, 帮马化腾节省几百万. 


参考阅读



原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。


2021年GIAC调整到7月30-31日在深圳举行,点击阅读原文了解更多详情。


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存