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