Redis集群最佳实践

jopen 9年前

今天我们来聊一聊Redis集群。先看看集群的特点,我对它的理解是要需要同时满足高可用性以及可扩展性,即任何时候对外的接口都要是基本可用的并具备一定的灾备能力,同时节点的数量能够根据业务量级的大小动态的伸缩。那么我们一般如何实现呢?
Redis集群最佳实践

集群的实现方式

说到集群的实现,我会想到两种方式

  • 第一种是去中心化的集群
    • 整个集群是由一组水平的节点构建
    • 通过给各个节点分配不同的角色实现相互配合,并同步各自状态
    • 对外提供单调的接口
    • 整个集群依靠内部的同步机制来进行伸缩和容错
    • 实现复杂
    </li> </ul>

    这种集群,常见的有Zookeeper以及以MongoDB为代表的大部分NoSQL服务,包括Redis官方集群3.0 Cluster也是属于这种类型。

    • 第二种集群是基于Proxy的集群(反向代理)
      • 引入一个Proxy中间件来管理整个集群,托管后端节点
      • 通过Zookeeper这种第三方组件实现集群的数据和状态同步
      • Proxy本身能够水平扩展,并方便实现auto-balance
      • 实现简单
      • 需要保证Proxy本身的高可用性
      </li> </ul>

      这种方式有人会称之为伪集群,毕竟需要依靠第三方组件来实现集群化,但从整体架构上来看,这种实现方式确实可以满足集群的标准,即满足高可用性和可 扩展性,它的优点是实现简单,并且通过Proxy去维护集群的状态要比去中心化的方式更加方便,这也是为什么我选择Codis而不是官方Cluster的 原因。下面会详细介绍。

      Redis集群——Codis2.0

      那么具体到Redis的集群实现,目前最流行的应该是推ter开源的Twemproxy,再就是近年官方推出的Redis 3.0 Cluster。今天我要介绍的是来自豌豆荚开源的Codis,它是一套基于Proxy模式的Redis集群服务,Codis目前的版本是2.0。

      • 相比Twemproxy,Codis能够提供动态的sharding,即无缝扩展redis的节点,省去了人工数据迁移的成本以及down掉服务带来的风险。另外,这二者都是基于Proxy模式的集群构建,性能上并不存在太大的差别。
      • 相比Redis Cluster,Codis的优势在于:首先它基于Proxy的代理模式能够无缝兼容所有的redis client;而Redis Cluster则需要使用配套的client sdk来替换我们之前的程序,因为这种模式下客户端需要维护集群相关的信息。其次,基于Proxy的集群,使得我们可以更加清晰的掌握整个集群的状态,因 为Codis将所有的操作命令和集群拓扑结构都同步在Zookeeper中,而Zookeeper的集群也是我们常用并熟悉的,这也是我非常青睐 Codis的一个原因。

      Codis主要的关键技术我认为有三点:

      • Pre-sharding
        • Codis Proxy在Zookeeper维护了1024个slots,并建立了每个slot和后端redis group的路由表
        • 一个redis group包括一组master/slave的redis实例
        • 通过crc32(key)%1024计算出每个key对应的slot编号,然后查询路由表即可得到每个key对应的具体redis实例,从而打通数据代理
        • 这样redis实例可以从一开始的单台节点扩展到最大1024个节点,按照目前机器的配置基本可达到无限扩展
        </li>
      • Zookeeper
        • Codis中所有的运维操作命令都通通过zookeeper同步到每一个Proxy中,包括slot的迁移、Group的变化等。此外,pre-sharding的路由信息也存放在zookeeper中。这些使得Codis Proxy能够水平扩展并协同工作
        • zookeeper还能够被用来做Proxy的服务发现和负载均衡。后面我们会再讲到
        • </ul> </li>
        • 动态迁移
          • 动态迁移是指不用down掉服务也能够将redis key平滑的迁移到另一个group上
          • 传统的redis迁移,我们可能会想到通过对先有Redis挂载slave,将存量数据热备到新增节点,然后改变slot和group的路由表, 将一部分数据切到新机器上。然而这种方式很难保证节点切换中的数据一致性,如果要保证这一点只能做通过停服来做静态sharding,之前的 Twemproxy就只能这么做
          • 实际上Codis实现的动态扩容是通过在官方的Redis Server中植入MIGRATE命令来实现的,并确保了该操作的原子性
          • 由于真正执行迁移的是通过额外的工具codis-config来实现,所以不用担心会影响Proxy正常处理请求的性能
          • 迁移slot的操作会通过zookeeper同步给Proxy用于快速感知,即如果在slot迁移中,对应的key发生了操作,Proxy会强制执行一次SLOTSMGRTTAGONE命令将这个key数据单独做一次迁移
          • </ul> </li> </ul>

            以上的三条保证了Codis能够满足高可用性和可扩展性的标准。

            关于Codis的使用和性能测试,请转到他们的主页——https://github.com/wandoulabs/codis。本文主要从架构和源码上对Codis进行介绍。

            Codis Proxy 2.0源码解析

            下面我们一起解读一下Codis Proxy 2.0的源码。

            以下内容推荐在电脑上阅读。

            由于Codis是由Go语言编写的,这也是非常吸引我了解的一点,Go语言天生的高并发特性非常适合写这种高并发的接入层/中间件服务,codis 代码正是运用了go routine的简洁高效,再配合channel做数据同步,sync做状态同步,整体代码还是比较简单明了的。

            具体的Go语法可以参考https://golang.org,很值得去学习,特别是用惯了C/C++和python的同学,Go语言在开发和运行效率上的兼顾一定会让你觉得心旷神怡。

            在了解Codis代码之前,我还是先解释一下go routine这个概念。我们了解以下几点:

            • routine可以解释为协程,类似于python中的greenlet(https://greenlet.readthedocs.org)
            • 协程可以看做是微小的线程,内存开销极小且由程序自己来进行调度,从而能最大化的利用CPU时间
            • 而标准的线程是由操作系统来统一调度,线程栈消耗一般在1-8M,这样在高并发的情况下二者的性能差异可想而知。

              go routine也是被先天植入go语言之中,因此用它来编写并发程序再适合不过了。

            好了,下面我们来看Codis Proxy的代码。

            Codis Proxy代码结构比较清晰,整个程序基本上就是在不同的go routine之间同步各种数据和状态,只要抓住几个关键的go routine流程,再结合Proxy的架构就能够很清晰的明白了。
            下面我对Codis Proxy 2.0的程序架构做了模块化的展示。

            • 红色箭头代表集群和外部的连接
            • 黑色箭头属于内部连接
            • 蓝色模块代表go routine
            • 绿色模块代表程序中的模块和函数
            • 紫色模块代表监听的事件

            Redis集群最佳实践

            结合以上的架构图,我们可以很清晰的知道Codis Proxy的工作流程。
            下面对关键代码做进一步的讲解。

            1、初始化Proxy Server对象

            Codis Proxy在初始化时会构建一个Server的对象,并第一时间向zookeeper注册自己。

            type Server struct {
            conf *Config //Proxy配置,包括proxy id、name、zk的地址、timeout参数、redis授权信息等
            topo *Topology //用于访问ZooKeeper的对象,顾名思义,能够从zk获取整个集群的拓扑结构
            info models.ProxyInfo //封装Proxy的基本信息,包括id、addr等
            groups map[int]int //存放slot和group的映射,index表示slot id,当slot对应group发生变化时,
            proxy会根据此映射对slot做reset,即调用fillSlot
            lastActionSeq int //同步序列号,这个类似于版本号同步协议,用于同步zookeeper中的操作命令,比如slot迁移
            evtbus chan interface{} //这个channel用于从zookeeper获取最新的操作指令
            router *router.Router //路由对象,1、设置并维护slots的后端连接 2、dispatch客户端请求到后端redis
            listener net.Listener //tcp socket listener,用于监听并accept客户端的连接请求
            kill chan interface{} //Proxy收到SIGTERM信号时会激活该channel,然后清理zk的状态并正常退出
            wait sync.WaitGroup //go routine的同步对象,用于主线程同步go routine的完成状态
            stop sync.Once // Proxy Close时一次性清理所有资源,包括client以及slot的后端连接
            }

            之后主线程通过go routine创建第一个协程G1,开始工作。
            而主线程会调用wait.Wait(),等待G1的完成,只有在Proxy意外退出或是主动发送mark_offline时整个程序才会结束。G1在调用 Serve方法之后,首先会check自己在zk的状态是否是online,然后才能开始工作。注意,在Codis2.0中,主线程会自动调用 Codis-config来使自己上线,不再需要手动的去markonline。check成功之后,G1会向zookeeper注册actions节点 的watch,这样就可以用来实时感知zookeeper中的操作命令了,包括slot迁移,group的变化等。之后G1会初始化各个slot的后端连 接,紧接着再创建一个routineG2,用于handle客户端的连接,即承担接入redis客户端的工作。而G1自己会调用loopEvent,通过 select监听zookeeper中的操作命令以及kill命令。

            注意,Go中的select要比Unix的select调用强大很多,只是名字一样罢了,我想底层应该是采用epoll的实现方式

            2、handleConns处理客户端连接

            好了,现在G1和G2都进入了各自的Loop中高效的运转了。我们看一下G2的代码。

            <br />func (s *Server) handleConns() {
            ch := make(chan net.Conn, 4096)
            defer close(ch)

            go func() {
            for c := range ch {
            x := router.NewSessionSize(c, s.conf.passwd, s.conf.maxBufSize, s.conf.maxTimeout)
            go x.Serve(s.router, s.conf.maxPipeline)
            }
            }()

            for {
            c, err := s.listener.Accept()
            if err != nil {
            return
            } else {
            ch <- c
            }
            }
            }

            这段代码用于处理客户端的接入请求,想起我们之前用C写的epoll单线程回调,这个看起来是不是很简洁呢^_^这就是go routine的魅力,可以抛弃繁琐的回调。

            OK,下面我们继续进入G2这个协程,如代码所示。

            • 这里会实时的accept客户端的redis连接,并为每一个连接N单独创建一个协程G2N用于request/response(参考上面的架构图)。
            • G2N会运行在 loopReader中,实时的从socket读取client的请求,并按照RESP(Redis Protocol)的协议进行解码,接着调用 handleRequest进行请求分发。对于部分命令比如MSET/MGET,codis是做了特殊处理的,原因在于批量处理的key可能分布在不同的 redis实例上,所以在codis这里需要将不同的key dispatch到不同的后端,得到响应之后再统一打包成Redis Array返回给客户端。
              此外,G2N会额外再创建一个routine G2NW,用于向client回写请求的数据结果,并运行在loopWriter中。参考G2N的主要逻辑代码如下。

            <br />func (s *Session) Serve(d Dispatcher, maxPipeline int) {
            var errlist errors.ErrorList
            defer func() {
            if err := errlist.First(); err != nil {
            log.Infof("session [%p] closed: %s, error = %s", s, s, err)
            } else {
            log.Infof("session [%p] closed: %s, quit", s, s)
            }
            }()

            tasks := make(chan *Request, maxPipeline)
            go func() {
            defer func() {
            s.Close()
            for _ = range tasks {
            }
            }()
            if err := s.loopWriter(tasks); err != nil {
            errlist.PushBack(err)
            }
            }()

            defer close(tasks)
            if err := s.loopReader(tasks, d); err != nil {
            errlist.PushBack(err)
            }
            }

            其中loopWriter即为G2NW协程所运行的函数栈。

            3、命令分发(反向代理/Dispatch)

            下面说一下Dispatch

            • G2N在收到客户端请求的key时,会查看key相关的slot信息,通过查询路由表来获取对应后端redis实例的连接,后端连接的托管是放在backend.go这个模块中的。
            • router模块在初始化slots的时候,维护了一个backend的连接池,当有redis key的请求过来时,会将请求打包成Request对象然后再分发给该slot对应的后端连接,要注意的是归属同一个redis group的slot就会复用同一个后端连接。
            • router通过将Request对象dispatch到后端连接监听的同步队列channel中,以此来解决并发控制的问题。
            • 具体的每一个BackendConnection都会各自运行两个routine:
              • 一个执行loopWrite,不断的获取从router dispatch过来的Request对象,然后Encode成RESP格式发送给后端的redis实例
              • 另一个routine用于Decode从Redis实例返回的结果,并通过调用setResponse方法来告知前端的G2NW,这里setResponse代码如下:
              </li> </ul>

              <br /> func (bc *BackendConn) setResponse(r *Request, resp *redis.Resp, err error) error {
              r.Response.Resp, r.Response.Err = resp, err
              if err != nil && r.Failed != nil {
              r.Failed.Set(true)
              }
              if r.Wait != nil {
              r.Wait.Done()
              }
              if r.slot != nil {
              r.slot.Done()
              }
              return err
              }

              可以看到这里会调用wait.Done和slot.Done来通知前端的routine。这两个Done的区别在于,wait.Done用于同步请 求的处理完毕状态,而slot.Done用于同步该slot的状态,因为当Codis在收到slot迁移指令时需要调用fillSlot对slot进行重 置,而此操作需要等待对应slot上的所有代理请求处理完毕之后才能进行。

              这里涉及到Go语言sync模块的内容,具体可以参考https://golang.org/pkg/sync/#WaitGroup

              由于篇幅有限,整个Codis Proxy2.0的代码先介绍到这里,读者可以结合上面的架构图对代码做进一步的了解。我们不难发现,整个代码都是由go routine、channel、sync来构建,这也是go语言并发编程的核心概念。

              Codis Proxy Auto-balance

              因为Codis是基于Proxy模式构建的集群,这就要求我们必须保证Proxy组件的高可用性,换句话说,我们需要做好Proxy组件的auto-balance和服务发现。推荐一个解决方案如下:

              • 我们可以搭建N个Codis Proxy来分担负载,每个Proxy的id和addr不同即可。
              • Codis Proxy的服务发现,可以通过监听zookeeper来完成。
              • 对于pytho开发者,我实现了一个pycodis的组件,用于python连接codis proxy,大致原理是监听zookeeper中proxy节点的状态,如果某台proxy挂掉了,可以及时的调整连接池,保证client每次获取到的 都是最新并可用的连接,不需要修改client配置和重启,同时也能够保证每台Proxy的负载均衡。这主要得益于Codis使用Zookeeper来进 行状态同步,也就天生具备了服务发现的优势。

              以上对了Redis集群——Codis2.0做了大致的介绍,也是我认为目前最可靠的redis集群方案之一,并且这种集群的实现架构也是值得我们 其他系统借鉴的。当然这种Proxy的方式还是存在一些先天缺陷,比如很难支持事务和批量操作,但我想对于大部分应用场景来说它的支持已经足够了。

              好了,期待Codis下一次的更新吧。另外,如果各位看官有更好的实践,欢迎赐教,期待和大家一起交流和探讨。


              来自: http://jimhuang.cn/?p=402#rd&sukey=6bdd2d01817422c5d43d51bb6f35586e9f7613b3a58a6b2856be46612954526a11dd5b1e6086348a648cf90a1e08db66