在日常开发中,执行一条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连接池实现的基本原理
一个连接池的基本实现由以下部分组成:
- 创建连接池(初始化配置参数)。
- 根据配置参数提前创建指定数量的连接。
- 当获取连接时,直接从连接池中得到一个连接。如果连接池没有空闲连接,且连接数最大活跃连接数,创建一个新的连接;如果达到最大,则设定一定的超时时间,来获取连接。
- 基于连接执行命令。
- 释放连接(此时的释放连接,并非真正关闭,而是将其放入空闲队列中)。
- 释放连接池对象(服务停止、维护期间,释放连接池对象,并释放所有连接)。 redigo连接池的使用主要围绕Get和Close两个方法,整个过程如下图所示,
连接池的创建
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进行了一层封装,主要做这几件事:
- 校验rawurl
- 从rawurl解析获取以下参数:
- address
- userName
- password
- database
- useTLS
- 调用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方法时,并不是直接创建连接,分为两种情况处理:
- idleList不为空:
- 若连接池配置了空闲连接的超时时间idleTimeout,从后往前遍历idleList,若遍历到的节点空闲时间已超过idleTimeout, popBack弹出该节点,并释放该连接;否则退出遍历。
- popFront弹出idleList的头部节点,若满足以下两个条件,则返回该连接供客户端使用,否则,释放该连接,创建新连接。
- 配置了TestOnBorrow,判断该连接还是否有效;未配置,默认满足
- 配置了MaxConnLifetime时,判断该连接从创建到现在的时间是否超过MaxConnLifetime;未配置,默认满足
- 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()方法关闭连接时,实际并不是真的直接释放连接,具体操作为:
- 如果连接池没有close并且该连接没有close,使用pushFront将该连接插入idleList头部;否则释放该连接,活跃连接数-1
- 如果idleList的连接总数超过最大空闲连接数maxIdle,弹出尾部节点,释放该连接,活跃连接数-1