本文转载自微信公众号《码农桃花源》,作者风云是她。转载本文请联系码农桃花源公众号。最近一直在优化行情推送系统,有很多优化经验分享给大家。性能上最明显的改进是延迟。单节点8万客户端时,时延从1500ms优化到40ms。下面是内网模拟客户端的压测数据。对于订阅客户端数不太持久的测试,弱网下单机8w个客户端是没问题的。目前采用kubenetes部署方案,可以灵活扩展。架构图push-gateway是一个pushgateway,有几个功能:第一点是鉴权;第二点是接入多种协议。我们这里实现了websocket、grpc、grpc-web、sse。支持;第三点是实现策略调度和亲和绑定。push-server是一个推送服务,维护订阅关系,监听mq的新消息,然后推送到网关。问题一:并发map操作带来的锁竞争和延时推送服务需要维护订阅关系,一般以嵌套map结构为代表,从而导致并发map竞争带来的锁竞争和高延迟问题。//xiaoui.c??c{"topic1":{"uuid1":client1,"uuid2":client2},"topic2":{"uuid3":client3,"uuid4":client4}...}已经按照business有4张图,但是订阅关系是嵌套的。直接加锁会阻塞其他协程,阻塞会造成高延迟。锁定地图应该很快,为什么会被挡住?上面我们说了map是用来存储topic和clientlist的订阅关系的。我push的时候,一定要拿到topic的所有clients,然后一个一个的发送通知。(这里的send不是io.send,而是chansend,每个client绑定一个bufferedchan)解决方法:在每个业务中划分256个map和读写锁,使锁粒度降低到1/256。除了这种方法,也有尝试将clientlist放到一个新的slice中返回,但是造成了GC压力,经测试不可取。//xiaoui.c??csync.RWMutexmap[string]map[string]client已改为m*shardMap。shardMap分段图的库已经推送到github[1]。如果你有兴趣,你可以看看。问题2:将串行消息通知改为并发模式简单来说,我们在push服务中维护了某个topic和1w个clientchan的映射,收到mqchan的topic消息后通知1w个client。client的chan本身有很大的buffer,sent函数也是使用selectdefault来避免阻塞。但实际上,串行发送chan需要很多时间。对于channel的底层,goready等待channel的goroutine,push到runq。下面是我写的benchmark[2],可以比较serial和concurrent的耗时对比。mac下效果不是太明显,因为maccpu的频率更高,在server下效果明显。串口通知,获取所有client的chan,然后发送。for_,notifier:=rangenotifiers{s.directSendMesg(notifier,mesg)}并发发送,这里使用协程池避免morestack的消耗,使用sync.waitgroup实现异步等待。//xiaorui.ccnotifiers:=[]*mapping.StreamNotifier{}//convslicefor_,notifier:=rangenotifierMap{notifiers=append(notifiers,notifier)}//optimize:directmapstructtaskChunks:=b.splitChunks(notifiers,batchChunkSize)//concurrentsendchanwg:=sync.WaitGroup{}for_,chunk:=rantaskChunks{chunkCopy:=chunk//slicereplicawg.Add(1)b.SubmitBlock(func(){for_,notifier:=rangechunkCopy{b.directSendMesg(notifier,mesg)}wg.Done()},)}wg.Wait()根据线上监控性能,延迟从200ms减少到30ms。可以在这里进行更深入的优化。小于5000个client的可以直接串行调用,否则并发调用。问题三:定时器过多导致cpu开销增加。市场推送中有很多心跳检测和任务时间控制,都依赖定时器。Go在1.9之后将单个timerproc改为多个timerproc,减少了锁竞争,但是四堆数据结构的时间复杂度依然复杂,高精度带来的树和锁操作依然频繁。因此,这里使用时间轮来解决上述问题。数据结构改为简单的循环数组和map,时间精度弱化到秒级,时间差在业务上是可以接受的。Golang时间轮的代码已经推送到github[3]。timewheel的很多方法都兼容golangtime的原生库。有兴趣的可以看看。问题四:多协程读写chan会出现sendclosedpanic的问题。解决方法很简单,就是不直接使用channel,而是封装一个trigger。client关闭的时候并没有主动关闭chan,而是在trigger中关闭ctx,然后直接删除topic和trigger的映射。//xiaoui.c??c//触发结构typeStreamNotifierstruct{GuidstringQueuechaninterface{}closedint32ctxcontext.Contextcancelcontext.CancelFunc}func(sc*StreamNotifier)IsClosed()bool{ifsc.ctx.Err()==nil{returnfalse}returntrue}..问题5:提高grpc的吞吐性能。grpc是基于http2协议实现的,http2本身实现了流复用。一般来说,内网的两个节点可以使用一个连接来运行完整的网络带宽,而不会出现性能问题。但是用golang实现的grpc会存在各种锁竞争问题。如何优化?多开grpc客户端,避免锁竞争的冲突概率。经过测试,qps提升很明显,从8w提升到20w左右。可以参考之前写的grpc性能测试[4]。问题六:减少协程的数量。有的朋友认为等待事件的协程多了也没关系。它只是占用内存。协程无法调度,不会消耗运行时性能。这种说法是错误的。虽然不能得到调度,看起来只是占用内存,但是对于GC会有很大的开销。因此,不要开启过多的空闲协程,比如协程池很大。在push架构中,不仅push-gateway到push-server的几个连接就够了,几十个stream就够了。我们实现大量的消息在十几个流中运行,然后调度通知。在golanggrpcstreaming的实现中,每个streaming请求都需要一个协程来等待事件。因此,共享流通道也可以减少协程的数量。问题七:GC问题使用sync.Pool缓存频繁创建的结构。以前有些业务的缓存是用list链表存储的。当不断更新新数据时,会不断创建新对象,从而影响GC。因此,使用可重用的循环数组来实现热缓存。不要害怕后记中的陷阱,只需填写即可。参考文献[1]github:https://github.com/rfyiamcool/ccmap/blob/master/syncmap.go[2]基准测试:https://github.com/rfyiamcool/go-benchmark/tree/master/batch_notify_channel[3]github:https://github.com/rfyiamcool/go-timewheel[4]测试:https://github.com/rfyiamcool/grpc_batch_test
