0%

Golang 搭建 WebSocket 应用(七) - 性能、可用性

在前面的文章中,提到过非功能性需求决定了架构。 今天我们再来考虑一下另外两个非功能性需求:性能和可用性。

前言

关于性能,其实并不是只有我们这个消息推送系统独有的问题。 对于所有的开发者而言,都多多少少会处理过性能相关的问题,比如后端为了减少数据库查询提高并发引入的缓存中间件,如 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
2
3
4
5
6
func send(hub *Hub, w http.ResponseWriter, r *http.Request) {
// ... 其他代码
// 如果 send 满了,下面这一行会阻塞
client.send <- messageLog
hub.pending.Add(int64(1))
}

所以这个数值可能需要根据实际场景来选择一个更加合适的值。

代码本身的问题

比如,我们的代码中其实有一个很常见的性能问题,就是 string[]byte 之间直接强转:

1
2
3
4
// writePump 方法里面将 string 转 []byte
if err := c.conn.WriteMessage(websocket.TextMessage, []byte(messageLog.Message)); err != nil {
return
}

至于原因,可以去看看此前的一篇文章《深入理解 go unsafe》 的最后一小节, 简单来说,就是这个转换会产生内存分配,而内存分配会导致一定的性能损耗。而通过 unsafe 就可以实现无损的转换。

除了这个,其他地方也没啥太大的问题了,因为到目前为止,我们的代码还是非常的简单的。

互斥锁

为了保证程序的并发安全,我们在 Hub 中加了一个 sync.Mutex,也就是互斥锁。 在代码中,被 sync.MutexLock 保护的代码,在同一时刻只能有一个协程可以执行。

1
2
3
4
5
6
7
8
9
// 推送消息的接口
func send(hub *Hub, w http.ResponseWriter, r *http.Request) {
// ... 其他代码
// 从 hub 中获取 client
hub.Lock()
client, ok := hub.userClients[uid]
hub.Unlock()
// ... 其他代码
}

对于上面这种只读的操作,也就是没有对 map 进行写操作,我们依然使用了 sync.MutexLock() 来锁定临界区。 这里存在的问题是,其实我们的 hub.userClients 是支持并发读的,只是不能同时读写而已。

所以我们可以考虑将 sync.Mutex 替换为 sync.RWMutex,这样就可以实现并发读了:

1
2
3
4
5
6
7
8
9
// 推送消息的接口
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 变成串行的,这样性能无疑会很差。

除此之外,我们在 Hubrun 方法中也使用了 sync.Mutex

1
2
3
4
5
case client := <-h.register:
h.Lock()
h.clients[client] = true
h.userClients[client.uid] = client
h.Unlock()

也就是说,我们将 Client 注册到 Hub 的操作也是串行的。 对于这种场景,其实也有一种解决方法就是分段 map, 也就是将 clientsuserClients 这两个 map 拆分为多个 map, 然后对于每一个 map 都有一个对应的 sync.Mutex 互斥锁来保证其读写的安全。

但如果要这样做,单单分段还不够,我们的 registerunregister 还是只有一个,对于这个问题, 我们可能需要将 registerunregister 也分段,最后在 run 方法里面起多个协程来进行处理。 这个实现起来就很复杂了。

其他

由于我们的 Hub 中还有 MessageLogger、错误处理、认证等功能, 在实际中,如果我们有将其替换为自己的实现,可能还得考虑自己的实现中可能存在的性能问题:

1
2
3
4
5
type Hub struct {
messageLogger MessageLogger
errorHandler Handler
authenticator Authenticator
}

可用性

这里主要讨论的是集群部署的情况下,应用存在的一些的问题以及可行的解决方案。关于具体部署上的细节不讨论。

要实现高可用的话,我们就得加机器了,毕竟如果只有一台服务器的话,一旦它宕机了,服务就完全挂了。

由于我们的 WebSocket 应用维持着跟客户端的连接,在单机的时候,客户端连接、推送消息都是在一台机器上的。 这种情况下并没有什么问题,因为推送消息的时候,都可以根据 uid 来找到对应的 WebSocket 连接,从而给客户端推送消息。

而在多台机器的情况下,我们的客户端可能跟不同的服务器产生连接,这个时候一个比较关键的问题是: 如何根据 uid 找到对应的 WebSocket 连接所在的机器? 如果我们推送消息的请求到达的机器上并没有消息关联的 WebSocket 连接,那么我们的消息就无法推送给客户端了。

对于这个问题,一个可行的解决方案是,将 uid 和服务器建立起关联,比如,在用户登录的时候, 就给用户返回一个 WebSocket 服务器的地址,客户端拿到这个地址之后,跟这个服务器建立起 WebSocket 连接, 然后其他应用推送消息的时候,也根据同样的算法将推送消息的请求发送到这个 WebSocket 服务器即可。

总结

最后,再简单回顾一下本文的内容:

  • 具体来说,我们的系统中会有下面几个可能的地方会导致产生性能问题:
    • 连接数:一个连接会有两个协程,另外每一个 Client 结构体也会需要一定的缓冲区来缓冲发送给客户端的消息
    • 代码上的性能问题:如 string[]byte 之间转换带来的性能损耗
    • 互斥锁:某些地方可以使用读写锁来提高读的并发量,另外一个办法就是使用分段 map 配合互斥锁
    • 系统本身预留的扩展点中,用户自行实现的代码中可能会存在性能问题
  • 要实现高可用就得将系统部署到多台机器上,这个时候需要在 uid 和服务器之间建立起某种关联,以便推送消息的时候可以成功推送给客户端。