高质量服务端之路(二)—Redis集群最佳实践

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

集群的实现方式

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

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

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

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

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

Redis集群——Codis2.0

那么具体到Redis的集群实现,目前最流行的应该是Twitter开源的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个节点,按照目前机器的配置基本可达到无限扩展
  • Zookeeper
    • Codis中所有的运维操作命令都通通过zookeeper同步到每一个Proxy中,包括slot的迁移、Group的变化等。此外,pre-sharding的路由信息也存放在zookeeper中。这些使得Codis Proxy能够水平扩展并协同工作
    • zookeeper还能够被用来做Proxy的服务发现和负载均衡。后面我们会再讲到
  • 动态迁移
    • 动态迁移是指不用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数据单独做一次迁移

以上的三条保证了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
  • 绿色模块代表程序中的模块和函数
  • 紫色模块代表监听的事件

Codis Proxy模块

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

1、初始化Proxy Server对象

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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的主要逻辑代码如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    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代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    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下一次的更新吧。另外,如果各位看官有更好的实践,欢迎赐教,期待和大家一起交流和探讨。

关于猫头鹰团队

武汉很少有优秀的互联网公司,但是:

  • 有很多湖北籍/华中籍的在一线城市/一线互联网公司打拼的优秀人才,他们因为家庭,房价等各种原因,期望回到武汉。
  • 不少优秀人才回到武汉后,因为没有互联网土壤,因为理念的不一致,处境很尴尬,甚至很多人生活所迫又重新回到一线城市。
  • 另外一种情况是,很多武汉的本地公司,他们又希望能够拥抱互联网,又找不到真正懂互联网的优秀人才。

我们希望通过各种方式,团聚湖北籍/华中籍的优秀互联网人才,首先打造一家真正的互联网公司,最终改变整个武汉互联网的从业环境, 更多细节请“查看原文”后点击这里

长按关注猫头鹰技术公众号(mtydev)

img

留言

本站总访问量

留言