长连接网关技术专题石墨文档单机50万We

本文由石墨文档技术杜旻翔分享,原题“石墨文档Websocket百万长连接技术实践”,有修订。

1、引言

在石墨文档的部分业务中,例如文档分享、评论、幻灯片演示和文档表格跟随等场景,涉及到多客户端数据实时同步和服务端批量数据在线推送的需求,一般的HTTP协议无法满足服务端主动Push数据的场景,因此选择采用WebSocket方案进行业务开发。

随着石墨文档业务发展,目前日连接峰值已达百万量级,日益增长的用户连接数和不符合目前量级的架构设计导致了内存和CPU使用量急剧增长,因此我们考虑对长连接网关进行重构。

本文分享了石墨文档长连接网关从1.0架构演进到2.0的过程,并总结了整个性能优化的实践过程。

2、专题目录

本文是系列文章的第6篇,总目录如下:

《长连接网关技术专题(一):京东京麦的生产级TCP网关技术实践总结》

《长连接网关技术专题(二):知乎千万级并发的高性能长连接网关技术实践》

《长连接网关技术专题(三):手淘亿级移动端接入层网关的技术演进之路》

《长连接网关技术专题(四):爱奇艺WebSocket实时推送网关技术实践》

《长连接网关技术专题(五):喜马拉雅自研亿级API网关技术实践》

《长连接网关技术专题(六):石墨文档单机50万WebSocket长连接架构实践》(*本文)

3、v1.0架构面临的问题

这套长连接网关系统的v1.0版是使用Node.js基于Socket.IO进行修改开发的版本,很好的满足了当时用户量级下的业务场景需求。

3.1架构介绍

1.0版架构设计图:

1.0版客户端连接流程:

1)用户通过NGINX连接网关,该操作被业务服务感知;

2)业务服务感知到用户连接后,会进行相关用户数据查询,再将消息Pub到Redis;

3)网关服务通过RedisSub收到消息;

4)查询网关集群中的用户会话数据,向客户端进行消息推送。

3.2面临的问题

虽然1.0版本的长连接网关在线上运行良好,但是不能很好的支持后续业务的扩展。

并且有以下几个问题需要解决:

1)资源消耗:Nginx仅使用TLS解密,请求透传,产生了大量的资源浪费,同时之前的Node网关性能不好,消耗大量的CPU、内存;

2)维护与观测:未接入石墨的监控体系,无法和现有监控告警联通,维护上存在一定的困难;

3)业务耦合问题:业务服务与网关功能被集成到了同一个服务中,无法针对业务部分性能损耗进行针对性水平扩容,为了解决性能问题,以及后续的模块扩展能力,都需要进行服务解耦。

4、v2.0架构演进实践

4.1概述

长连接网关系统的v2.0版需要解决很多问题。

比如,石墨文档内部有很多组件(文档、表格、幻灯片和表单等等),在1.0版本中组件对网关的业务调用可以通过Redis、Kafka和HTTP接口,来源不可查,管控困难。

此外,从性能优化的角度考虑也需要对原有服务进行解耦合,将1.0版本网关拆分为网关功能部分和业务处理部分。

具体是:

1)网关功能部分为WS-Gateway:集成用户鉴权、TLS证书验证和WebSocket连接管理等;

2)业务处理部分为WS-API:组件服务直接与该服务进行gRPC通信。

另外还有:

1)可针对具体的模块进行针对性扩容;

2)服务重构加上Nginx移除,整体硬件消耗显著降低;

3)服务整合到石墨监控体系。

4.2整体架构

2.0版本架构设计图:

2.0版本客户端连接流程:

1)客户端与WS-Gateway服务通过握手流程建立WebSocket连接;

2)连接建立成功后,WS-Gateway服务将会话进行节点存储,将连接信息映射关系缓存到Redis中,并通过Kafka向WS-API推送客户端上线消息;

3)WS-API通过Kafka接收客户端上线消息及客户端上行消息;

4)WS-API服务预处理及组装消息,包括从Redis获取消息推送的必要数据,并进行完成消息推送的过滤逻辑,然后Pub消息到Kafka;

5)WS-Gateway通过SubKafka来获取服务端需要返回的消息,逐个推送消息至客户端。

4.3握手流程

网络状态良好的情况下,完成如下图所示步骤1到步骤6之后,直接进入WebSocket流程;网络环境较差的情况下,WebSocket的通信模式会退化成HTTP方式,客户端通过POST方式推送消息到服务端,再通过GET长轮询的方式从读取服务端返回数据。

客户端初次请求服务端连接建立的握手流程:

流程说明如下:

1)Client发送GET请求尝试建立连接;

2)Server返回相关连接数据,sid为本次连接产生的唯一SocketID,后续交互作为凭证:

{"sid":"xxx","upgrades":["websocket"],"pingInterval":xxx,"pingTimeout":xxx}

3)Client携带步骤2中的sid参数再次请求;

4)Server返回40,表示请求接收成功;

5)Client发送POST请求确认后期降级通路情况;

6)Server返回ok,此时第一阶段握手流程完成;

7)尝试发起WebSocket连接,首先进行2probe和3probe的请求响应,确认通信通道畅通后,即可进行正常的WebSocket通信。

4.4TLS内存消耗优化

客户端与服务端连接建立采用的wss协议,在1.0版本中TLS证书挂载在Nginx上,HTTPS握手过程由Nginx完成。为了降低Nginx的机器成本,在2.0版本中我们将证书挂载到服务上。

通过分析服务内存,如下图所示,TLS握手过程中消耗的内存占了总内存消耗的大概30%左右。

这个部分的内存消耗无法避免,我们有两个选择:

1)采用七层负载均衡,在七层负载上进行TLS证书挂载,将TLS握手过程移交给性能更好的工具完成;

2)优化Go对TLS握手过程性能,在与业内大佬曹春晖(曹大)的交流中了解到,他最近在Go官方库提交的PR,以及相关的性能测试数据。

4.5SocketID设计

对每次连接必须产生一个唯一码,如果出现重复会导致串号,消息混乱推送的问题。选择SnowFlake算法作为唯一码生成算法。

物理机场景中,对副本所在物理机进行固定编号,即可保证每个副本上的服务产生的SocketID是唯一值。

K8S场景中,这种方案不可行,于是采用注册下发的方式返回编号,WS-Gateway所有副本启动后向数据库写入服务的启动信息,获取副本编号,以此作为参数作为SnowFlake算法的副本编号进行SocketID生产,服务重启会继承之前已有的副本编号,有新版本下发时会根据自增ID下发新的副本编号。

于此同时,Ws-Gateway副本会向数据库写入心跳信息,以此作为网关服务本身的健康检查依据。

4.6集群会话管理方案:事件广播

客户端完成握手流程后,会话数据在当前网关节点内存存储,部分可序列化数据存储到Redis,存储结构说明如下图所示。

由客户端触发或组件服务触发的消息推送,通过Redis存储的数据结构,在WS-API服务查询到返回消息体的目标客户端的SocketID,再由WS-Gateway服务进行集群消费。如果SocketID不在当前节点,则需要进行节点与会话关系的查询,找到客端户SocketID实际对应的WS-Gateway节点,通常有以下两种方案(如下图所示)。

在确定使用事件广播方式进行网关节点间的消息传递后,进一步选择使用哪种具体的消息中间件,列举了三种待选的方案(如下图所示)。

于是对Redis和其他MQ中间件进行w次的入队和出队操作,在测试过程中发现在数据小于10K时Redis性能表现十分优秀。

进一步结合实际情况:广播内容的数据量大小在1K左右,业务场景简单固定,并且要兼容历史业务逻辑,最后选择了Redis进行消息广播。

后续还可以将WS-API与WS-Gateway两两互联,使用gRPCstream双向流通信节省内网流量。

4.7心跳机制

会话在节点内存与Redis中存储后,客户端需要通过心跳上报持续更新会话时间戳,客户端按照服务端下发的周期进行心跳上报,上报时间戳首先在内存进行更新,然后再通过另外的周期进行Redis同步,避免大量客户端同时进行心跳上报对Redis产生压力。

具体流程:

1)客户端建立WebSocket连接成功后,服务端下发心跳上报参数;

2)客户端依据以上参数进行心跳包传输,服务端收到心跳后会更新会话时间戳;

3)客户端其他上行数据都会触发对应会话时间戳更新;

4)服务端定时清理超时会话,执行主动关闭流程;

5)通过Redis更新的时间戳数据进行WebSocket连接、用户和文件之间的关系进行清理。

会话数据内存以及Redis缓存清理逻辑:

for{

select{

case-t.C:

varnow=time.Now().Unix()

varclients=make([]*Connection,0)

dispatcher.clients.Range(func(_,vinterface{})bool{

client:=v.(*Connection)

lastTs:=atomic.LoadInt64(client.LastMessageTS)

ifnow-lastTsint64(expireTime){

clients=append(clients,client)

}else{

dispatcher.clearRedisMapping(client.Id,client.Uid,lastTs,clearTimeout)

}

returntrue

})

for_,cli:=rangeclients{

cli.WsClose()

}

}

}

在已有的两级缓存刷新机制上,进一步通过动态心跳上报频率的方式降低心跳上报产生的服务端性能压力,默认场景中客户端对服务端进行间隔1s的心跳上报,假设目前单机承载了50w的连接数,当前的QPS为:QPS1=/1。

从服务端性能优化的角度考虑,实现心跳正常情况下的动态间隔,每x次正常心跳上报,心跳间隔增加a,增加上限为y,动态QPS最小值为:QPS2=/y。

极限情况下,心跳产生的QPS降低y倍。在单次心跳超时后服务端立刻将a值变为1s进行重试。采用以上策略,在保证连接质量的同时,降低心跳对服务端产生的性能损耗。

4.8自定义Headers

使用Kafka自定义Headers的目的是避免网关层出现对消息体解码而带来的性能损耗。

客户端WebSocket连接建立成功后,会进行一系列的业务操作,我们选择将WS-Gateway和WS-API之间的操作指令和必要的参数放到Kafka的Headers中,例如通过X-XX-Operator为广播,再读取X-XX-Guid文件编号,对该文件内的所有用户进行消息推送。

在KafkaHeaders中写入了traceid和时间戳,可以追中某条消息的完整消费链路以及各阶段的时间消耗。

4.9消息接收与发送

typePacketstruct{

...

}

typeConnectstruct{

*websocket.Con

sendchanPacket

}

funcNewConnect(connnet.Conn)*Connect{

c:=Connect{

send:make(chanPacket,N),

}

goc.reader()

goc.writer()

returnc

}

客户端与服务端的消息交互第一版的写法类似以上写法。

对Demo进行压测,发现每个WebSocket连接都会占用3个goroutine,每个goroutine都需要内存栈,单机承载连十分有限。

主要受制于大量的内存占用,而且大部分时间c.writer()是闲置状态,于是考虑,是否只启用2个goroutine来完成交互。

typePacketstruct{

...

}

typeConnectstruct{

*websocket.Conn

muxsync.RWMutex

}

funcNewConnect(connnet.Conn)*Connect{

c:=Connect{

send:make(chanPacket,N),

}

goc.reader()

returnc

}

func(c*Connect)Write(data[]byte)(errerror){

c.mux.Lock()

deferc.mux.Unlock()

...

returnnil

}

保留c.reader()的goroutine,如果使用轮询方式从缓冲区读取数据,可能会产生读取延迟或者锁的问题,c.writer()操作调整为主动调用,不采用启动goroutine持续监听,降低内存消耗。

调研了gev和gnet等基于事件驱动的轻量级高性能网络库,实测发现在大量连接场景下可能产生的消息延迟的问题,所以没有在生产环境下使用。

4.10核心对象缓存

确定数据接收与发送逻辑后,网关部分的核心对象为Connection对象,围绕Connection进行了run、read、write、close等函数的开发。

使用sync.pool来缓存该对象,减轻GC压力,创建连接时,通过对象资源池获取Connection对象。

生命周期结束之后,重置Connection对象后Put回资源池。

在实际编码中,建议封装GetConn()、PutConn()函数,收敛数据初始化、对象重置等操作。

varConnectionPool=sync.Pool{

New:func()interface{}{

returnConnection{}

},

}

funcGetConn()*Connection{

cli:=ConnectionPool.Get().(*Connection)

returncli

}

funcPutConn(cli*Connection){

cli.Reset()

ConnectionPool.Put(cli)//放回连接池

}

4.11数据传输过程优化

消息流转过程中,需要考虑消息体的传输效率优化,采用MessagePack对消息体进行序列化,压缩消息体大小。调整MTU值避免出现分包情况,定义a为探测包大小,通过如下指令,对目标服务ip进行MTU极限值探测。

ping-s{a}{ip}

a=时,实际传输包大小为:。

其中28由8(ICMP回显请求和回显应答报文格式)和20(IP首部)构成。

如果a设置过大会导致应答超时,在实际环境包大小超过该值时会出现分包的情况。

在调试合适的MTU值的同时通过MessagePack对消息体进行序列号,进一步压缩数据包的大小,并减小CPU的消耗。

4.12基础设施支持

使用EGO框架进行服务开发:业务日志打印,异步日志输出,动态日志级别调整等功能,方便线上问题排查提升日志打印效率;微服务监控体系,CPU、P99、内存、goroutine等监控。

客户端Redis监控:

客户端Kafka监控:

自定义监控大盘:

5、检查成果的时刻:性能压测

5.1压测准备

准备的测试平台有:

1)选择一台配置为4核8G的虚拟机,作为服务机,目标承载48w连接;

2)选择八台配置为4核8G的虚拟机,作为客户机,每台客户机开放6w个端口。

5.2模拟场景一

用户上线,50w在线用户。

单个WS-Gateway每秒建立连接数峰值为:1.6w个/s,每个用户占用内存:47K。

5.3模拟场景二

测试时间15分钟,在线用户50w,每5s推送一条所有用户,用户有回执。

推送内容为:

42["message",{"type":"xx","data":{"type":"xx","clients":[{"id":xx,"name":"xx","email":"xx

xx.xx","avatar":"ZgG5kEjCkT6mZla6.png","created_at":0,"name_pinyin":"","team_id":13,"team_role":"member","merged_into":0,"team_time":0,"mobile":"+xxxx","mobile_account":"","status":1,"has_password":true,"team":null,"membership":null,"is_seat":true,"team_role_enum":3,"register_time":0,"alias":"","type":"anoymous"}],"userCount":1,"from":"ws"}}]

测试经过5分钟后,服务异常重启,重启原因是内存使用量到超过限制。

分析内存超过限制的原因:

新增的广播代码用掉了9.32%的内存:

接收用户回执消息的部分消耗了10.38%的内存:

进行测试规则调整,测试时间15分钟,在线用户48w,每5s推送一条所有用户,用户有回执。

推送内容为:

42["message",{"type":"xx","data":{"type":"xx","clients":[{"id":xx,"name":"xx","email":"xx

xx.xx","avatar":"ZgG5kEjCkT6mZla6.png","created_at":0,"name_pinyin":"","team_id":13,"team_role":"member","merged_into":0,"team_time":0,"mobile":"+xxxx","mobile_account":"","status":1,"has_password":true,"team":null,"membership":null,"is_seat":true,"team_role_enum":3,"register_time":0,"alias":"","type":"anoymous"}],"userCount":1,"from":"ws"}}]

连接数建立峰值:1w个/s,接收数据峰值:9.6w条/s,发送数据峰值9.6w条/s。

5.4模拟场景三

测试时间15分钟,在线用户50w,每5s推送一条所有用户,用户无需回执。

推送内容为:

42["message",{"type":"xx","data":{"type":"xx","clients":[{"id":xx,"name":"xx","email":"xx

xx.xx","avatar":"ZgG5kEjCkT6mZla6.png","created_at":0,"name_pinyin":"","team_id":13,"team_role":"member","merged_into":0,"team_time":0,"mobile":"+xxxx","mobile_account":"","status":1,"has_password":true,"team":null,"membership":null,"is_seat":true,"team_role_enum":3,"register_time":0,"alias":"","type":"anoymous"}],"userCount":1,"from":"ws"}}]

连接数建立峰值:1.1w个/s,发送数据峰值10w条/s,出内存占用过高之外,其他没有异常情况。

内存消耗极高,分析火焰图,大部分消耗在定时5s进行广播的操作上。

5.5模拟场景四

测试时间15分钟,在线用户50w,每5s推送一条所有用户,用户有回执。每秒4w用户上下线。

推送内容为:

42["message",{"type":"xx","data":{"type":"xx","clients":[{"id":xx,"name":"xx","email":"xx

xx.xx","avatar":"ZgG5kEjCkT6mZla6.png","created_at":0,"name_pinyin":"","team_id":13,"team_role":"member","merged_into":0,"team_time":0,"mobile":"+xxxx","mobile_account":"","status":1,"has_password":true,"team":null,"membership":null,"is_seat":true,"team_role_enum":3,"register_time":0,"alias":"","type":"anoymous"}],"userCount":1,"from":"ws"}}]

连接数建立峰值:个/s,接收数据峰值:条/s,发送数据峰值:条/s,未出现异常情况。

5.6压测总结

在16核32G内存的硬件条件下:单机50w连接数,进行以上包括用户上下线、消息回执等四个场景的压测,内存和CPU消耗都符合预期,并且在较长时间的压测下,服务也很稳定。

测试的结果基本上是能满足目前量级下的资源节约要求的,我们认为完全可以在此基础上继续完善功能开发。

6、本文小结

面临日益增加的用户量,网关服务的重构是势在必行。

本次重构主要是:

1)对网关服务与业务服务的解耦,移除对Nginx的依赖,让整体架构更加清晰;

2)从用户建立连接到底层业务推送消息的整体流程分析,对其中这些流程进行了具体的优化。

2.0版本的长连接网关有了更少的资源消耗,更低的单位用户内存损耗、更加完善的监控报警体系,让网关服务本身更加可靠。

以上优化内容主要是以下各个方面:

1)可降级的握手流程;

2)SocketID生产;

3)客户端心跳处理过程的优化;

4)自定义Headers避免了消息解码,强化了链路追踪与监控;

5)消息的接收与发送代码结构设计上的优化;

6)对象资源池的使用,使用缓存降低GC频率;

7)消息体的序列化压缩;

8)接入服务观测基础设施,保证服务稳定性。

在保证网关服务性能过关的同时,更进一步的是收敛底层组件服务对网关业务调用的方式,从以前的HTTP、Redis、Kafka等方式,统一为gRPC调用,保证了来源可查可控,为后续业务接入打下了更好的基础。

7、相关文章

[1]WebSocket从入门到精通,半小时就够!

[2]搞懂现代Web端即时通讯技术一文就够:WebSocket、socket.io、SSE

[3]从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践

[4]抢票带来的启示:看我如何用Go实现百万QPS的秒杀系统(含源码)

[5]Go语言构建千万级在线的高并发消息推送系统实践(来自公司)

[6]跟着源码学IM(六):手把手教你用Go快速搭建高性能、可扩展的IM系统

学习交流:

-移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》

-开源IM框架源码:


转载请注明:http://www.aierlanlan.com/rzdk/4570.html