Go项目之Simple-Redis (2) 客户端

145 阅读6分钟

本项目完整地址 simple-redis

客户端连接

在上一篇文章中,Handler 在 Handle 方法中,对于一个新的连接 conn 要初始化一个**客户端与服务器连接的抽象 connect.Connection*。这个结构体定义在 redis/connection/conn.go 中。

Connection 结构体

Connection 的结构体定义如下,有一些字段需要说明:

  • sendingData: 用于优雅的结束连接。当结束连接时会等待,直到数据发送完成或者超时。
  • password:当配置了 password 后,当发送 auth 命令时就会修改这个字段,数据库服务器就会检查这个字段是否是正确的。
  • selectedDB: 当客户端发送 select 命令时,就会修改此字段的值,用于控制当前客户端处于哪个数据库中。
  • isMulti,queue,syntaxErrQueue,watching,TxID:与事务相关的字段。
  • subscribeChannels:记录客户端订阅的频道。
// Connection represents a connection with a client
type Connection struct {
   conn net.Conn
​
   // wait until finish sending data, used for graceful shutdown
   sendingData wait.Wait
​
   // lock while server sending response
   mu sync.Mutex
​
   // password may be changed by CONFIG command during runtime, so store the password
   password string
​
   // selected db
   selectedDB int
​
   isMulti        bool              // 表明是否在 multi 开启事务中
   queue          [][][]byte        // 事务中排队的命令
   syntaxErrQueue []redis.Reply     // 事务中的语法错误
   watching       map[string]uint32 // 正在WATCH的key值
   TxID           string            // 事务ID,在分布式事务中用到
​
   subscribeChannels map[string]struct{} // 订阅的频道
}

sync.Pool 对象复用

在创建一个新的客户端连接抽象时,会复用之前已经结束的客户端连接。之前废弃的连接保存在 sync.Pool 对象资源池 connPool 中(这是一个全局变量):

var connPool = sync.Pool{
   New: func() interface{} {
      return &Connection{}
   },
}

Write 写入数据

Write 方法用于写入数据,当写入时会 调用 c.sendingData.Add(1),结束时会调用 c.sendingData.Done()

// Write sends response to client over tcp connection
func (c *Connection) Write(bytes []byte) (int, error) {
   if len(bytes) == 0 {
      return 0, nil
   }
​
   c.sendingData.Add(1)
   defer func() {
      c.sendingData.Done()
   }()
​
   return c.conn.Write(bytes)
}

wait.Wait

sendingData 字段的类型为 wait.Wait,定义在 lib/sync/wait/wait.go 中。

wait.Wait 与 sync.GroupWait 的功能类似,只是在 sync.GroupWait 的基础上加入了超时的功能。不管是内部 wg 所有等待的事件结束(计数器归零)还是超时,都会停止等待。

// Wait a sync.WaitGroup with timeout
type Wait struct {
    wg sync.WaitGroup
}
​
​
func (w *Wait) WaitWithTimeout(timeout time.Duration) bool {
   done := make(chan struct{}, 1)
   go func() {
      defer close(done)
      w.Wait()
      done <- struct{}{}
   }()
​
   select {
   case <-done:
      return false // finish normally
   case <-time.After(timeout):
      return true // timeout
   }
}

Close 关闭连接

因为关闭连接时,需要等待将当前需要发送的数据发送完成后才可以关闭(优雅的关闭)。关闭流程如下:

  • 先调用 c.sendingData.WaitWithTimeout(10 * time.Second) 等待发送完成(或者超时)。
  • 重置内部的相关字段,然后调用 connPool.Put(c) 将对象放回对象池中,以便进行对象的复用

事务相关

Connection 结构体事务相关的方法都定义在 redis/connection/transaction.go 中。

isMulti,queue,syntaxErrQueue,watching,TxID 字段与事务相关。下面依次进行解释:

  • isMulti: 当客户端执行 multi 命令时,isMulti 会被置为 true,当执行 discard 或者 exec 命令时 isMulti 会被置为 false。
  • queue: 当 isMulti 为 true 时,客户端发送的所有命令都会被底层数据库服务器记录在 queue 命令队列中
  • syntaxErrQueue: 底层服务器会在 multi 时,对命令进行语法错误检查,如果发现了语法错误,则记录在这个队列中。当这个队列不为空时,说明在入队时发现语法错误,整个队列中的命令都会放弃执行
  • watching: 调用 watch 命令时,会将相关的 key 记录在这个 map 中。simple-redis 采用乐观锁的方式,其中键为被监视的 key,值为执行 wath 命令时 key 的版本号
  • TxID: 在集群模式下,会开启分布式事务。在分布式事务中,分布式事务协调者会利用 snowflake 算法生成一个事务 ID,用于唯一的标识这个分布式事务。

订阅发布相关

Connection 结构体订阅发布相关的方法都定义在 redis/connection/publish.go 中。

subscribeChannels 代表客户端订阅的频道,它实际上是一个集合,它记录了这个客户端所有的订阅频道名字。

redis.Connection 接口

connect.Connect 结构体实现了 redis.Connection 接口,这个接口定义在 interface/redis/conn.go 文件中。定义 redis.Connection 接口的原因在于:

在 AOF 持久化中,为了重写 AOF 持久化文件,在临时数据库中重放文件中的命令时,因为通用性(在数据库中执行时需要传入一个 redis.Connection 接口类型的值),需要用到一个虚假连接(conection.FakeConn,定义在 redis/conection/fake_conn.go 中)。

pipeline 客户端

集群模式或者命令行模式下需要用到 simple-redis 客户端,与 simple-redis 服务器进行通信。

pipeline 模式

通常 TCP 客户端的通信模式都是阻塞式的:客户端发送请求 -> 等待服务端响应 -> 发送下一个请求。因为需要等待网络传输数据,完成一次请求循环需要等待较多时间。

针对这种效率低的情景,可以不等待服务端响应直接发送下一条请求

TCP 协议会保证数据流的有序性,同一个 TCP 连接上先发送的请求服务端先接收,先回复的响应客户端先收到。因此不必担心混淆响应所对应的请求。

这种在服务端未响应时客户端继续向服务端发送请求的模式称为 Pipeline 模式。因为减少等待网络传输的时间,Pipeline 模式可以极大的提高吞吐量。

Pipeline 模式的 Godis 客户端需要至少有两个后台协程,分别是发送请求协程(写协程)读取响应协程(读协程) 。调用方通过 channel 向后台协程发送发送指令,并阻塞等待直到收到响应(或者超时)。

数据结构

Client

首先定义 Client 客户端,Client 客户端实现 pipeline 的核心在于两个通道

  • pendingReqs: 记录等待发送的请求,客户端调用 Send 命令向客户端发送请求时,请求在这个通道内排队等待写协程发送请求
  • waitingReqs: 记录等待服务器响应的请求,向服务器发送请求成功后将这个请求加入到这个通道中等待响应。当读协程收到一个服务器响应时就从通道中取出一个请求,此时一个完整的请求+响应完成。
type Client struct {
   conn        net.Conn      // 与服务器的tcp连接
   pendingReqs chan *request // 等待发送的请求
   waitingReqs chan *request // 等待服务器响应的请求
   ticker      *time.Ticker  // 发送心跳的计时器
   addr        string
​
   isCmdLine  bool // 标记是否是命令行客户端
   curDBIndex int  // 当前数据库
​
   status  int32 // 服务器状态(创建/运行/关闭)
   working *sync.WaitGroup
​
   keepalive time.Duration // 服务器存活检查时间
}

request

客户端请求 request 的结构如下,它代表一个从请求到响应的完整的客户端请求。

  • args:客户端向服务器发送的命令行参数。
  • reply:收到的返回值。
type request struct {
   id        uint64      // 请求id
   args      [][]byte    // 上行参数
   reply     redis.Reply // 收到的返回值
   heartbeat bool        // 标记是否是心跳请求
   waiting   *wait.Wait  // 调用协程发送请求后通过 waitgroup 等待请求异步处理完成
   err       error
}

启动/关闭客户端

Start 启动客户端

Client.Start 用于开启客户端,主要工作:

  • 开启两个协程,分别是写协程(用于发送数据)、读协程(用于读取响应)。
  • 若 client.keepalive > 0,则开启心跳发送协程,每 keepalive/2 秒发送一次心跳。
  • 将服务器状态变更为 running
// Start starts asynchronous goroutines
func (client *Client) Start() {
   go client.handleWrite()
   go client.handleRead()
​
   if client.keepalive > 0 {
      // 开启心跳
      client.ticker = time.NewTicker(time.Second * client.keepalive / 2) // 每 keepalive/2 秒发送一次心跳
      go client.heartbeat()
   }
​
   atomic.StoreInt32(&client.status, running)
}

Close 关闭客户端

关闭客户端时:

  • 将客户端的状态变更为 closed,停止用于发送心跳的计时器。
  • 关闭 Client.pendingReqs,阻止新的请求进入队列
  • 等待处理中的请求处理完成
  • 释放资源,包括关闭与服务器的连接(连接关闭后读协程会退出)、关闭等待响应的队列。
// Close stops asynchronous goroutines and close connection
func (client *Client) Close() {
   atomic.StoreInt32(&client.status, closed)
   if client.keepalive > 0 {
      client.ticker.Stop()
   }
   // stop new request
   close(client.pendingReqs)
​
   // wait working process stop
   client.working.Wait()
   // client.heartbeatWorking.Wait()
​
   _ = client.conn.Close()
   close(client.waitingReqs)
}

请求与响应

客户端完成一个请求的完整流程为:

  • 将请求发送到 Client.pendingReqs 通道中
  • 然后写协程会从 Client.pendingReqs 通道中取到请求发送给服务器,同时将请求发送到 Client.waitingReqs 后面等待读取响应。
  • 最后读协程从 Client.waitingReqs 中取出请求,读取响应将结果

Send 方法

Client.Send 方法用于发送命令,返回值为响应结果。其流程为:

  • 首先填请求结构体 request。
  • 接着将 request 挂在 client.pendingReqs 后面,等待写协程和读协程的依次处理。
  • 等待 request 响应结果,或者请求超时。
  • 若请求为 select 命令且未发生错误,会同步修改 client.curDBIdenx。
// Send sends a request to redis server
func (client *Client) Send(args [][]byte) redis.Reply {
   if atomic.LoadInt32(&client.status) != running {
      return reply.MakeErrReply("client closed")
   }

   request := &request{
      args:      args,
      heartbeat: false,
      waiting:   &wait.Wait{},
   }

   request.waiting.Add(1)
   client.working.Add(1)
   defer client.working.Done()

   client.pendingReqs <- request

   timeout := request.waiting.WaitWithTimeout(maxWait)
   if timeout {
      return reply.MakeErrReply("server time out")
   }
   if request.err != nil {
      return reply.MakeErrReply("request failed")
   }

   if _, ok := request.reply.(*reply.StandardErrReply); !ok && strings.ToLower(string(args[0])) == "select" {
      curDBIndex, _ := strconv.Atoi(string(args[1]))
      client.curDBIndex = curDBIndex
   }

   return request.reply
}

写协程

在启动客户端时,会再启动一个写协程,不断读取 client.pendingReqs 管道,用于向服务器发送请求

func (client *Client) handleWrite() {
   for req := range client.pendingReqs {
      client.doRequest(req)
   }
}

doRequest

真正的写逻辑在 doRequest 方法中。流程如下:

  • doRequest 方法会尝试向服务器发送命令消息,若失败则最多重试三次
re := reply.MakeMultiBulkStringReply(req.args)
bytes := re.ToBytes()

// 最多失败重试3次
var err error
for i := 0; i < 3; i++ {
   _, err = client.conn.Write(bytes)
   if err == nil || (!strings.Contains(err.Error(), "timeout") && // only retry timeout
      !strings.Contains(err.Error(), "deadline exceeded")) {
      break
   }
}
  • 若发送成功,则将 request 挂在 client.waitingReqs 管道后面,等待读协程去处理响应结果。
  • 若发送失败,则结束流程,读协程不会处理。
if err == nil {
   client.waitingReqs <- req
} else {
   req.err = err
   req.waiting.Done()
}

读协程

读协程不断接收服务器响应经过协议解析器解析之后的结果,若协议解析错误则会重连服务器 client.reconnect(),每收到一个(正常)响应就会调用 finishRequest 结束响应流程

func (client *Client) handleRead() {
   ch := parser.ParseStream(client.conn)
   for payload := range ch {
      if payload.Err != nil {
         status := atomic.LoadInt32(&client.status)
         if status == closed {
            return
         }
         client.reconnect()
         return
      }
      client.finishRequest(payload.Data)
   }
}

finishRequest

finishRequest 方法用于结束整个请求过程,流程如下:

  • 首先会从 client.waitingReqs 管道中取出一个请求。
  • 接着将服务器的响应结果填入 request.Reply。
  • 最后调用 request.waiting.Done() 结束请求。