redigo

服务对接上述存储是比较常见的。通常我们会在github上寻找star比较多的,或者在mysql和redis的官网找推荐sdk。当面临sdk报错或者我们的角色变为服务提供方的时候,sdk就是免不了的一部分。之前没有细致的读redis的sdk。对于sdk包含的内容,认为有如下几部分:

  • 协议封装
  • 连接池管理
  • api提供

实际上也没错,sdk确实包含这几部分,但涉及到代码设计层面,就不能balabala光吹牛逼了。废话到此,带来redigo这个项目的像素级分析。一起真正体验下别人的设计思想。

各文件具体工作,不做具体描述,文件按功能性划分,有连接池,有提供给上层的api,有对接redis服务的部分等。

pool.go

var _ ConnWithTimeout = (*activeConn)(nil)

ConnWithTimeout是在redis.go中定义的接口,上面代码的作用是在编译的时候验证当前文件的activeConnerrorConn是否实现了该接口。Conn接口代表和redis server之间的连接,业务层使用的主体。

type Pool struct {
	Dial func() (Conn, error)
	TestOnBorrow func(c Conn, t time.Time) error
    
	MaxIdle int
	MaxActive int
	IdleTimeout time.Duration
	Wait bool
	MaxConnLifetime time.Duration

	chInitialized uint32 // set to 1 when field ch is initialized

	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
	ch     chan struct{} // limits open connections when p.Wait is true
	idle   idleList      // idle connections
}

每个文件的方法都是围绕着一个核心结构体设计的,结构体搞明白,整体设计基本也就差不多了。当然细枝末节也很重要,里面也会隐藏很多设计技巧。上面结构体中的注释被我清除掉了,防止篇幅过长。我主要将我觉得有意思的细节,比较直观的字段就不描述了。自己看注释。

TestOnBorrow中Borrow比较精确的描述了业务和Pool的关系,就是你可以从PoolConn但之后你要还,否则Pool就会满,返回错误。使用时机是在获取连接时,判断是否需要提前验证连接,但不推荐使用,如果这个验证是网络请求,那相当于redis的访问量double了,没必要,况且redis请求返回错误,业务本身也需要应对这种情况。但这里面有个细节,就是业务光处理错误不行,Pool自己也要感知到这个错误,并进行修正,这个机制留着后续补上

Waitch是组合使用的,就是当MaxActive达到限制时,让业务阻塞在ch上,但又不能长时间阻塞,所以get方法允许上层传入ctx可以结束阻塞。但这里要是我自己设计这块逻辑,有以下几个想法:

  • pool区分空闲队列和活跃队列,这样直接加lock,获取活跃队列长度。不好,连接对象在两个队列间移动需要lock的地方比较多,这直接影响业务获取连接的速度。
  • 只维护活跃连接的数量,利用active属性,这个field存的就是当前活跃连接数,每次进行两个值的比较,判断是否连接数已满。如果需要阻塞功能,在增加ch就行。这个方法我觉得和作者的就比较接近了,不用维护队列。那么Wait到底设计用来干什么。

仔细读了下get方法和Wait才知道是我理解有些混乱了。作者判断连接数已满就是使用的数量之间进行比较。而为了给上层提供阻塞获取连接的功能才增加了Wait字段。该字段是属于配置范畴,下面分两个情况描述下:

  • Wait==false,获取不到连接直接返回ErrPoolExhausted,逻辑简单,先从空闲列表拿,没有就创建,创建之前判断是否连接已满。
  • Wait==true,需要初始化MaxActive个令牌(这里使用chan数组实现),只要调用get的goroutine拿到令牌,就可以从空闲列表拿,没有空闲,可以直接创建。注意,这里不需要判断连接已满,因为令牌的数量和MaxActive相等。这个时候虽然active仍旧计数,但获取连接这事上,已经没有用了。

lazyInit方法俗称“懒加载”,但你有没有想过这个方法为什么要在get的时候进行,而不放在Pool的初始化方法中。我觉得是因为,作者让Pool对象采取了一种比较灵活的初始化方式,上层可以直接用利用结构体进行初始化,NewPool方法也被标记过期(可能是因为配置的增多),没有明确的初始化方法。那么lazyInit就有用武之地了,该方法放置的位置也让功能更内聚。其实sdk中也见过,对于这种配置项较多的核心struct,怎么封装配置项,能让上层调用方便。

IdleTimeout意思本身比较简单,但我见过sdk不管理空闲连接的,程序启动时直接初始化好,只有当连接出错,再补充连接进来;也可以通过单独的线程检查空闲队列,不过不好,需要使用lock,周期性的影响get效率。放在get中好,在操作空闲队列前,检查每个队列尾巴就行,因为尾巴就是最老的,这块又涉及到空闲队列的设计,首先你需要列出所有可能的操作,然后推算出每个操作的使用频率,然后优化使用频率高的操作。这块思维尽在写算法时考虑到,但真正涉及到生产环境下,程序的设计时,比较少雕琢。

put核心方法之一,看似简单,如果要是我设计,就想到lock下,然后把连接放到空闲队列,当然空闲队列可能也满了,这个时候就有设计技巧了,空闲队列满:

  • 直接放弃加入队列,但实际上当前队列中的连接都比较老,在固定时间内淘汰的空闲连接就会比较多。
  • 插入在头部,注意是在固定的位置插入,在固定的位置淘汰,让队列天然有序,可以让get方法中淘汰空闲连接的速度更快。

还有一件事,要处理,就是Pool是有Close操作的,需要进行资源的回收,但你还连接的时候,可能已经关闭了。

Get方法,逻辑很简单,调用get获取底层连接,用activeConn包装一下返回。但设计思维没那么简单,细读注释:

// This method always returns a valid connection so that applications can defer
// error handling to the first use of the connection.

为什么要推迟错误处理到第一次对连接的实际使用,我觉得是由于代码的洁癖导致的。go的连接对象,一般是需要defer conn.Close()的,该方法返回同样需要,但是当返回值包含errorConn时,会有一个可能出错的情况,就是err!=nil时,到底是不是需要释放Conn,这个时候一般的sdk不需要做这个操作,既然出错就不用处理了,但总有误导的嫌疑,而该sdk直接不给你这个机会。使用errorConn包装一下,才有了你看到的使用方法:

conn := pool.Get()
defer conn.Close()

想当简洁,对吧。当然,对于后续的连接实际操作,不可避免的err判断又会出现,但确实减少了点工作量。

Do方法,的下面两条语句,卡住我半天:

ci := internal.LookupCommandInfo(commandName)
ac.state = (ac.state | ci.Set) &^ ci.Clear

因为internal包中的命令列表只有事务以及Pub/Sub相关的命令,也就是说activeConn中的state只和以上两个功能相关,无状态命令的state一直为0。第二个语句的意思就是在state中只保留ci.Set中包含的状态为,其他位清空。在Close中会清理掉会做当前连接的回收操作。具体可以参考:http://www.redis.cn/topics/pubsub.htmlhttp://www.redis.cn/topics/transactions.html

mu的使用都是经过仔细斟酌的,看下下面的代码:

p.mu.Lock()

	// Prune stale connections at the back of the idle list.
	if p.IdleTimeout > 0 {
		n := p.idle.count
		for i := 0; i < n && p.idle.back != nil && p.idle.back.t.Add(p.IdleTimeout).Before(nowFunc()); i++ {
			pc := p.idle.back
			p.idle.popBack()
			p.mu.Unlock()
			pc.c.Close()
			p.mu.Lock()

pc.c.Close()之前会Unlock,之后再进行Lock,也就是对于pc的Close操作不进行保护,可以看到pc是局部变量,pc在获取idle的尾巴时是线程安全的,这就保证Close这种耗时操作的并发性能。这块值得学习。

conn.go

该sdk中对于conn进行了一些抽象,为什么进行这些抽象,让我来深入挖掘下。

dialOptions包含在创建redis连接时,能进行的各项配置,这里主要关注配置类对象参与到初始化时的形态。要是我设计,options这种对象是一个允许包外访问的struct,应用初始化好,传入New方法,直接用即可。但作者没有使用这种方法,而是首先声明了一个允许包外访问的struct如下:

type DialOption struct {
	f func(*dialOptions)
}

func Dial(network, address string, options ...DialOption) (Conn, error) {
	do := dialOptions{
		dialer: &net.Dialer{
			KeepAlive: time.Minute * 5,
		},
	}
	for _, option := range options {
		option.f(&do)
	}

包含一个一个方法,方法传入dialOptions的指针,然后针对每一个配置项,搞一个修改该项的方法。等Dial时统一调用一边,初始化好配置对象,然后再使用。为什么这么用?我觉得这里有点像java对于程序封装的理念,对象内部通过set接口允许上层修改某个属性,这样所有修改操作在最下层有一个集合点,可以进行一些控制。还有如果设置属性过程不单单是进行值的传递,类似下面这种:

func DialKeepAlive(d time.Duration) DialOption {
	return DialOption{func(do *dialOptions) {
		do.dialer.KeepAlive = d
	}}
}

其实我觉得解释的稍有勉强,这块逻辑作为和应用直接对接的部分,直接影响应用的代码形态,所以无论从易用性还是灵活性来讲,通过方法一定比通过属性要好有点。

conn会被pool使用poolConn包装下,打包的时候增加上创建时间,并放到双向链表里,而poolConn又被activeConn打包,附带对上层pool对象的指针,和状态(在上文描述过,仅用于存在上下文的连接的回收)。如果我设计,conn就一个struct,实现所有交互方式,交互方式在抽象出对应用的接口,过期等策略我可能直接放在conn对象里做,不会包装对象。显然包装对象带来的是对象的易于理解,程序的可读性有提升。还有一点,对象的设计上,越是下层的对象功能应该越单一,这样能给上层带来更大的灵活度。当我们要构建一个新的pool叫newPool时,这个pool可能在算法上进行了很大的优化,但是conn实际上不会有变化,那么包装这件事的作用就凸显了,newPool可能已在不影响之前代码的情况下增加到sdk中。

conn我上面解释的仅仅是一些程序设计结构上的事,围绕该对象实现的实际是和redis交互的协议。大体上就是发送和接收,根据redis的协议拼字符串,同时进行一些类型转化的事情。这件事也是考验功力的地方。

pending是未收到reply的请求数量,看代码是为了让SendReceive同步设计的变量,因为访问redis是区分读写两个过程的。但因为可能出现不同步的情况,也有相应的兼容措施。

这边blog嘎然而止,后续的看到漂亮的代码再分享。

redigo
Share this