redigo 连接池原理解析

717 阅读5分钟

在日常开发中,执行一条redis命令基本分为三步:建立连接,执行命令和销毁连接,连接的建立与销毁过程存在开销,如果每一次的执行都重新创建连接,执行完后销毁,那必然对性能造成一定的影响。另外,如果客户端无限的创建连接,当redis连接数达到上限时,会导致redis不可用。因此,基于性能和可靠性的考虑,使用连接池来对连接进行复用和管理。

在golang中,我们常用的一种redis连接池开源库是redigo,redigo连接池的基本使用如下,

使用

import (
   "context"

   redigo "github.com/gomodule/redigo/redis"
)

func main() {
   address := "127.0.0.1:6379"
   password := "foobared"
   // 1.创建连接池
   pool := redigo.Pool{
      Dial: func() (redigo.Conn, error) {
         // tcp url 连接
         rawURL := "redis://" + address
         return redigo.DialURL(rawURL, redigo.DialPassword(password))
      },
      DialContext:     nil,
      TestOnBorrow:    nil,
      MaxIdle:         0,
      MaxActive:       0,
      IdleTimeout:     0,
      Wait:            false,
      MaxConnLifetime: 0,
   }
   // 2.获取连接
   conn, err := pool.GetContext(context.Background())
   if err != nil {
      return
   }
   // 4.关闭连接
   defer conn.Close()
   // 3.执行命令
   _, err = conn.Do("SET", "a", "1")
   if err != nil {
      return
   }
}

原理解析

redigo连接池实现的基本原理

一个连接池的基本实现由以下部分组成:

  1. 创建连接池(初始化配置参数)。
  2. 根据配置参数提前创建指定数量的连接。
  3. 当获取连接时,直接从连接池中得到一个连接。如果连接池没有空闲连接,且连接数最大活跃连接数,创建一个新的连接;如果达到最大,则设定一定的超时时间,来获取连接。
  4. 基于连接执行命令。
  5. 释放连接(此时的释放连接,并非真正关闭,而是将其放入空闲队列中)。
  6. 释放连接池对象(服务停止、维护期间,释放连接池对象,并释放所有连接)。 redigo连接池的使用主要围绕Get和Close两个方法,整个过程如下图所示,

截屏2023-02-07 12.08.13.png

连接池的创建

redigo中的连接池的结构如下,


type Pool struct {
   Dial func() (Conn, error) // 建立连接的方法,使用方通过设置改方法,可以自定义连接方式
   DialContext func(ctx context.Context) (Conn, error) // 与Dial类似
   TestOnBorrow func(c Conn, t time.Time) error // 获取连接时的测试连接是否有效的方法
   MaxIdle int // 最大的空闲连接数
   MaxActive int // 最大的活跃连接数
   IdleTimeout time.Duration // 连接的最大空闲时间,超过了这个时间的空闲连接,会主动释放
   Wait bool // 当连接数达到了MaxActive时,从连接池中获取连接是否进行等待
   MaxConnLifetime time.Duration // 连接的最大存活时间

   mu           sync.Mutex    // mu protects the following fields
   closed       bool          // set to true when the pool is closed.
   active       int           // the number of open connections in the pool
   initOnce     sync.Once     // the init ch once func
   ch           chan struct{} // limits open connections when p.Wait is true
   idle         idleList      // idle connections
   waitCount    int64         // total number of connections waited for.
   waitDuration time.Duration // total time waited for new connections.
}

基于建造者模式,创建新连接

创建连接池的同时提前创建指定数量的连接,这是一种的懒加载的方式。redigo并没有设计懒加载,而是每次根据使用方设置的Dial或DialContext来创建新连接。 redigo提供了如下建立连接的方式,

    func DialURL(rawurl string, options ...DialOption) (Conn, error)
    func DialContext(ctx context.Context, network, address string, options ...DialOption) (Conn, error)

DialURL本质上是通过提供遵循redis scheme uri格式的入参对DialContext进行了一层封装,主要做这几件事:

  1. 校验rawurl
  2. 从rawurl解析获取以下参数:
  • address
  • userName
  • password
  • database
  • useTLS
  1. 调用DialContext

DialOption是golang中建造者模式的一种常见写法,使用方传入Dialxxx方法来设置实际连接中的一些可选参数,这些参数如下,

type dialOptions struct {
	readTimeout         time.Duration
	writeTimeout        time.Duration
	tlsHandshakeTimeout time.Duration
	dialer              *net.Dialer
	dialContext         func(ctx context.Context, network, addr string) (net.Conn, error)
	db                  int
	username            string
	password            string
	clientName          string
	useTLS              bool
	skipVerify          bool
	tlsConfig           *tls.Config
}

DialContext方法发起真实的连接,建立连接后,通过bw来发送命令,br读取返回结果

	c := &conn{
		conn:         netConn,
		bw:           bufio.NewWriter(netConn),
		br:           bufio.NewReader(netConn),
		readTimeout:  do.readTimeout,
		writeTimeout: do.writeTimeout,
	}

获取连接池中的空闲连接

redigo中的空闲连接的管理基于链表idleList的数据结构实现, idleList提供了三个方法:

  • pushFront 插入头节点
  • popFront 弹出头节点
  • popBack 弹出尾节点 具体数据结构如下,

type idleList struct {
	count       int
	front, back *poolConn
}

type poolConn struct {
	c          Conn
	t          time.Time
	created    time.Time
	next, prev *poolConn
}

func (l *idleList) pushFront(pc *poolConn) {
	pc.next = l.front
	pc.prev = nil
	if l.count == 0 {
		l.back = pc
	} else {
		l.front.prev = pc
	}
	l.front = pc
	l.count++
	return
}

func (l *idleList) popFront() {
	pc := l.front
	l.count--
	if l.count == 0 {
		l.front, l.back = nil, nil
	} else {
		pc.next.prev = nil
		l.front = pc.next
	}
	pc.next, pc.prev = nil, nil
}

func (l *idleList) popBack() {
	pc := l.back
	l.count--
	if l.count == 0 {
		l.front, l.back = nil, nil
	} else {
		pc.prev.next = nil
		l.back = pc.prev
	}
	pc.next, pc.prev = nil, nil
}

当客户端使用pool.Get或pool.GetContext方法时,并不是直接创建连接,分为两种情况处理:

  1. idleList不为空:
  • 若连接池配置了空闲连接的超时时间idleTimeout,从后往前遍历idleList,若遍历到的节点空闲时间已超过idleTimeout, popBack弹出该节点,并释放该连接;否则退出遍历。
  • popFront弹出idleList的头部节点,若满足以下两个条件,则返回该连接供客户端使用,否则,释放该连接,创建新连接。
    • 配置了TestOnBorrow,判断该连接还是否有效;未配置,默认满足
    • 配置了MaxConnLifetime时,判断该连接从创建到现在的时间是否超过MaxConnLifetime;未配置,默认满足
  1. idleList为空,创建新连接

基于连接执行命令

执行命令时,我们主要是使用conn.Do方法,Do方法依赖于DoWithTimeout,主要步骤如下,

  • 设置写deadline,writeCommand将命令写入缓冲区
  • Flush将写缓冲区内数据写入连接
  • 设置读deadline,readReply从读缓存区获取返回结果 其中,writeCommand和readRsp基于RESP协议对数据进行处理和解析。 具体的代码实现如下,

func (c *conn) DoWithTimeout(readTimeout time.Duration, cmd string, args ...interface{}) (interface{}, error) {
	c.mu.Lock()
	pending := c.pending
	c.pending = 0
	c.mu.Unlock()

	if cmd == "" && pending == 0 {
		return nil, nil
	}

	if c.writeTimeout != 0 {
		c.conn.SetWriteDeadline(time.Now().Add(c.writeTimeout))
	}

	// 写入bw
	if cmd != "" {
		if err := c.writeCommand(cmd, args); err != nil {
			return nil, c.fatal(err)
		}
	}

	// flush到buf
	if err := c.bw.Flush(); err != nil {
		return nil, c.fatal(err)
	}

	var deadline time.Time
	if readTimeout != 0 {
		deadline = time.Now().Add(readTimeout)
	}
	c.conn.SetReadDeadline(deadline)

	if cmd == "" {
		reply := make([]interface{}, pending)
		for i := range reply {
			r, e := c.readReply()
			if e != nil {
				return nil, c.fatal(e)
			}
			reply[i] = r
		}
		return reply, nil
	}

	var err error
	var reply interface{}
	for i := 0; i <= pending; i++ {
		var e error
		if reply, e = c.readReply(); e != nil {
			return nil, c.fatal(e)
		}
		if e, ok := reply.(Error); ok && err == nil {
			err = e
		}
	}
	return reply, err
}

连接的释放

在客户端使用conn.Close()方法关闭连接时,实际并不是真的直接释放连接,具体操作为:

  1. 如果连接池没有close并且该连接没有close,使用pushFront将该连接插入idleList头部;否则释放该连接,活跃连接数-1
  2. 如果idleList的连接总数超过最大空闲连接数maxIdle,弹出尾部节点,释放该连接,活跃连接数-1