在前面的文章中,提到过非功能性需求决定了架构。 今天我们再来考虑一下另外两个非功能性需求:性能和可用性。
前言
关于性能,其实并不是只有我们这个消息推送系统独有的问题。
对于所有的开发者而言,都多多少少会处理过性能相关的问题,比如后端为了减少数据库查询提高并发引入的缓存中间件,如
redis
;
又或者如前端一次性渲染大量数据的时候,如果让用户体验更加流畅等。
本文会针对 WebSocket
应用场景下去思考一些可能出现的性能问题以及可行的解决方案。
性能
对于性能,有几个可能导致性能问题的地方:
连接数
连接数过多会导致占用的内存过多,因为对于每一个连接,我们都有两个协程,一个读协程,一个写协程;
同时我们的 Client
结构体中的 send
是一个缓冲通道,它的缓冲区大小也直接影响最终占用的内存大小。
比如,我们目前的创建 Client
实例的代码是下面这样的:
1 | client := &Client{hub: hub, conn: conn, send: make(chan Log, 256), uid: uid} |
我们在这里直接为 send
分配了 256
的大小,如果 Log
结构体比较大的话,
它占用的内存就会比较大了(因为最终占用内存 = 连接数 *
sizeof(Log)
* 256)。
在实际中,我们一般没有那么多等待发送的消息,这个其实可以设置为一个非常小的值,比如
16; 设置为一个小的值的负面影响是,当 send
塞满了 16 条
Log
的时候,发送消息的接口会阻塞:
1 | func send(hub *Hub, w http.ResponseWriter, r *http.Request) { |
所以这个数值可能需要根据实际场景来选择一个更加合适的值。
代码本身的问题
比如,我们的代码中其实有一个很常见的性能问题,就是
string
跟 []byte
之间直接强转:
1 | // writePump 方法里面将 string 转 []byte |
至于原因,可以去看看此前的一篇文章《深入理解
go unsafe》 的最后一小节,
简单来说,就是这个转换会产生内存分配,而内存分配会导致一定的性能损耗。而通过
unsafe
就可以实现无损的转换。
除了这个,其他地方也没啥太大的问题了,因为到目前为止,我们的代码还是非常的简单的。
互斥锁
为了保证程序的并发安全,我们在 Hub
中加了一个
sync.Mutex
,也就是互斥锁。 在代码中,被
sync.Mutex
的 Lock
保护的代码,在同一时刻只能有一个协程可以执行。
1 | // 推送消息的接口 |
对于上面这种只读的操作,也就是没有对 map
进行写操作,我们依然使用了 sync.Mutex
的
Lock()
来锁定临界区。 这里存在的问题是,其实我们的
hub.userClients
是支持并发读的,只是不能同时读写而已。
所以我们可以考虑将 sync.Mutex
替换为
sync.RWMutex
,这样就可以实现并发读了:
1 | // 推送消息的接口 |
这样做的好处是,当有多个并发的 send
请求的时候,这些并发的 send
请求并不会相互阻塞; 而使用
sync.Mutex
的时候,并发的 send
请求是会相互阻塞的,也就是会导致 send
变成串行的,这样性能无疑会很差。
除此之外,我们在 Hub
的 run
方法中也使用了
sync.Mutex
:
1 | case client := <-h.register: |
也就是说,我们将 Client
注册到 Hub
的操作也是串行的。 对于这种场景,其实也有一种解决方法就是分段
map
, 也就是将 clients
和
userClients
这两个 map
拆分为多个
map
, 然后对于每一个 map
都有一个对应的
sync.Mutex
互斥锁来保证其读写的安全。
但如果要这样做,单单分段还不够,我们的 register
和
unregister
还是只有一个,对于这个问题, 我们可能需要将
register
和 unregister
也分段,最后在
run
方法里面起多个协程来进行处理。
这个实现起来就很复杂了。
其他
由于我们的 Hub
中还有
MessageLogger
、错误处理、认证等功能,
在实际中,如果我们有将其替换为自己的实现,可能还得考虑自己的实现中可能存在的性能问题:
1 | type Hub struct { |
可用性
这里主要讨论的是集群部署的情况下,应用存在的一些的问题以及可行的解决方案。关于具体部署上的细节不讨论。
要实现高可用的话,我们就得加机器了,毕竟如果只有一台服务器的话,一旦它宕机了,服务就完全挂了。
由于我们的 WebSocket
应用维持着跟客户端的连接,在单机的时候,客户端连接、推送消息都是在一台机器上的。
这种情况下并没有什么问题,因为推送消息的时候,都可以根据
uid
来找到对应的 WebSocket
连接,从而给客户端推送消息。
而在多台机器的情况下,我们的客户端可能跟不同的服务器产生连接,这个时候一个比较关键的问题是:
如何根据 uid
找到对应的 WebSocket
连接所在的机器?
如果我们推送消息的请求到达的机器上并没有消息关联的
WebSocket
连接,那么我们的消息就无法推送给客户端了。
对于这个问题,一个可行的解决方案是,将 uid
和服务器建立起关联,比如,在用户登录的时候, 就给用户返回一个
WebSocket
服务器的地址,客户端拿到这个地址之后,跟这个服务器建立起
WebSocket
连接,
然后其他应用推送消息的时候,也根据同样的算法将推送消息的请求发送到这个
WebSocket
服务器即可。
总结
最后,再简单回顾一下本文的内容:
- 具体来说,我们的系统中会有下面几个可能的地方会导致产生性能问题:
- 连接数:一个连接会有两个协程,另外每一个
Client
结构体也会需要一定的缓冲区来缓冲发送给客户端的消息 - 代码上的性能问题:如
string
跟[]byte
之间转换带来的性能损耗 - 互斥锁:某些地方可以使用读写锁来提高读的并发量,另外一个办法就是使用分段
map
配合互斥锁 - 系统本身预留的扩展点中,用户自行实现的代码中可能会存在性能问题
- 连接数:一个连接会有两个协程,另外每一个
- 要实现高可用就得将系统部署到多台机器上,这个时候需要在
uid
和服务器之间建立起某种关联,以便推送消息的时候可以成功推送给客户端。