使用 goroutine 模拟 redis IO 模型
在 redis 6.0 之后,redis 引入了 IO 线程的概念,但是并没有改变原有的单线程执行事务的模式。这是因为网络 IO 和协议解析在一次请求耗时中的占比是非常大的。使用单线程的模型可以简化应用层数据结构的设计,从而使代码更加便于维护。
而 go 语言中,同样可以利用 goroutine 和 channel 来模拟 redis 的请求处理方式,使用单一事务协程和多个协议解析协程的方式来处理客户端请求。这种处理方式不仅能够借助 go 语言的特点,还能够结合传统 reactor 事务模式的特点,使得代码更易于维护。这种模式下,网络 IO 可以分为 Accept Loop, IO Loop, Event Loop,分别完成建立新连接,解析客户端命令,处理客户端请求的功能。
抽象客户端连接
由于 goroutine 不具备传统线程的特点,不能够直接从外部来改变协程的状态。可以将每一个客户端连接所对应的协程抽象为一个 Client Struct,也就是一个 Context 进行管理。
1 | type Client struct { |
每一个 Client Struct
都对应了一个 net.Conn
实例,并运行在一个单独的 goroutine 上。结构体内部存储与该连接相关的所有信息,当不同循环之间需要通信时,可以使用 chan *Client
进行通信,减少数据的拷贝。
Accept Loop
Accept Loop 主要执行 reactor 模型中 Acceptor 对象的功能,监听被动套接字,新连接抽象为Client Struct
,并开启协程来处理读写请求。
1 | // 处理新连接 |
IO Loop
IO Loop 主要负责处理 socket 的读写,客户端请求的解析工作。
1 | func IOLoop(cli *Client) { |
Event Loop
Event Loop 处理 IO Loop 发送的活跃事件以及定时事件。
1 | func EventLoop() { |
优缺点
优点
- 事件流程较为清晰,事件的处理并不存在并发。
- 可以减少应用层临界资源的竞争。
- 可以保证所有事件是串行执行的。
缺点
- 使用管道传递主业务数据,会造成一定的性能损耗。
- 该模式不适合单个事件执行时间过长的场景。
redis 场景下的对比
在保证协议解析部分,数据库部分(除读写锁)相同的情况下,测试直接使用 goroutine 解析并执行命令(使用读写锁保证互斥)、使用单独的 goroutine 执行命令、redis-server 三者性能。redis-benchmark,测试环境为 MacOS Ventura 13.0,8 G 内存,双核四线程。
1 | 使用单独 goroutine 串行执行命令 |
经过多次对比,使用单独 goroutine 串行执行命令时,每秒钟执行的命令数比直接使用 goroutine 解析并执行命令多 3000 次左右,并且后者并没有执行 aof 刷盘、更新服务器状态等时间事件。而 redis-server 的每秒请求数保持在 76000 左右,远远高于 go 语言版本。
性能瓶颈
使用 pprof 工具获取压力测试阶段的调用图。可以看到,syscall.read 和 syscall.write 占据了绝大多数的 CPU 时间片,网络 IO 是主要的性能瓶颈。
经过查阅资料,go 官方的 net 库性能较差,尝试选用 gnet 网络库更换 net 库。更换后,进行同样的测试,性能有较大的提升。
1 | 使用 gnet 网络库 |