简单介绍

codis architecture

Codis-proxy 是一个高可用的 redis 集群方案,目前 pg 起了 4 个 codis-proxy,每个的功能都是一样的,其中任何一个挂掉了,其它的实例可以承接其流量。目前了解的 codis-proxy 管理工具包括 codis-admin 和 codis-dashboard 进程,通过 codis-admin 可以给 codis-dashboard 或 codis-proxy 进程发送 RESTful API,从而进行状态内省或者执行管理命令。

以下是调研期间用到的命令:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
列举所有的目前起来的 codis-group 以及其下的 codis-server(pg 项目中即为 pika)
codis-admin --dashboard=172.25.4.4:36001 --list-group

列举所有的slot以及每个slot对应的 pika 地址
codis-admin --dashboard=172.25.4.4:36001 --slots-status

列举所有的codis-proxy及其相关状态
codis-admin --dashboard=172.25.4.4:36001 --proxy-status

对特定的codis-proxy调用内省方法,了解其内部如何进行转发的配置(我主要想了解 ForwardMethod 是同步还是半异步,pg 用的是 ForwardSync)
codis-admin --proxy=172.25.4.4:26001 slots

codis-proxy 如何实现pika集群代理

Codis-proxy 会连接后端所有的 pika 实例,并且接收客户端发过来的 redis 命令。codis-proxy 内部分了 1024 个 slot,并且给每个 pika 实例分配一定的 slot。以下是 pg 项目中 pika 实例与 slot 的映射关系:

1
2
3
4
0   - 255    172.25.4.25:6001  cvm_piggygo_global_sv_pika3
256 - 511    172.25.4.40:6021  cvm_piggygo_global_sv_pika7
512 - 767    172.25.4.37:6011  cvm_piggygo_global_sv_pika5
768 - 1023   172.25.4.29:6031  cvm_piggygo_global_sv_pika8

Codis-proxy 实现了 RESP (REdis Serialization Protocol) redis的序列化协议,参考 Redis Protocol specification – Redis 了解整个协议文档。codis-proxy 的主要工作就是解析客户端发过来的 redis 命令并将 key 路由到对应的 slot 后面的 pika 上去。

这里有个疑问是我们的键是均匀分布到每个 pika 实例上去的吗?会不会存在倾斜从而导致部分实例的负载高于其它实例?结论是key的分布是均匀的。这是由于 codis-proxy 使用的 CRC32 循环冗余哈希算法,CRC32 本质上是将数据转为一个用 byte 数组表示的大的整数去除以特定的4字节数的余数。那么对于我们的 key 的规律以 UID 结尾,并且 UID 是递增的,所以相当于这个大整数是不断递增的,得到的余数也是自增的,从而可以均匀分配到每个 slot 中去。以下是哈希算法源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func Hash(key []byte) uint32 {
   const (
      TagBeg = '{'
      TagEnd = '}'
   )
   if beg := bytes.IndexByte(key, TagBeg); beg >= 0 {
      if end := bytes.IndexByte(key[beg+1:], TagEnd); end >= 0 {
         key = key[beg+1 : beg+1+end]
      }
   }
   return crc32.ChecksumIEEE(key)
}

func (s *Router) dispatch(r *Request) error {
        hkey := getHashKey(r.Multi, r.OpStr)
        var id = Hash(hkey) % MaxSlotNum
        slot := &s.slots[id]
        return slot.forward(r, hkey)
}

测试的结果也是均匀的,这里用的是 pg 的分桶数据:

 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
func HashToConf(key string, confs []*HashConf) *HashConf {
    h := Hash([]byte(key))
    for _, c := range confs {
        if h >= c.MinSlot && h <= c.MaxSlot {
            return c
        }
    }
    return nil
}

confs := []*HashConf{
    {MinSlot: 0, MaxSlot: 255, Addr: "cvm_piggygo_global_sv_pika3"},
    {MinSlot: 256, MaxSlot: 511, Addr: "cvm_piggygo_global_sv_pika7"},
    {MinSlot: 512, MaxSlot: 767, Addr: "cvm_piggygo_global_sv_pika5"},
    {MinSlot: 768, MaxSlot: 1023, Addr: "cvm_piggygo_global_sv_pika8"},
}
stats := make(map[string]int, 4)
for i := 4878632; i < 8808112; i++ {
    c := HashToConf(fmt.Sprintf("GameData:%s", i), confs)
    if c != nil {
       stats[c.Addr]++
    }
}
for _, c := range confs {
    fmt.Printf("%s %d\n", c.Addr, stats[c.Addr])
}

测试结果是:

1
2
3
4
cvm_piggygo_global_sv_pika3 982370
cvm_piggygo_global_sv_pika7 982370
cvm_piggygo_global_sv_pika5 982370
cvm_piggygo_global_sv_pika8 982370

用这个方法算出每个key在哪个pika实例上,从而直接去那个pika实例测试拿到数据。从 prometheus 上看到的数据验证了key分布均匀的想法:4个实例的QPS几乎完全一致。

prometheus pika

这里提一点之前的一个优化思路经过测试是无效的。原本以为将 UID 拼在最前面,因为 pika 的底层 rocksdb 的 key 是有序的,如果玩家的所有key都在一起,可以利用操作的 IO 缓存,从而提高读取的效率。经过测试如果把 UID 放到最前面依然会导致这些 key 分配到不同的实例上,从而整个效果打了折扣,可能不会有大的收益。当然如果 key 都在一起那么 MGET 的效率应该会有一些提高,这个需要进一步压测才知道。

Codis-proxy 中的MGET 和 MSET 不是单条命令执行

我们之前根据 redis 的使用经验认定 MGET 和 MSET 都是单条执行的,要么全成功,要么全失败。但实际情况并不是这样的,甚至在 key 全部打到同一个实例时也不是单条执行的。直接分析代码:

 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
28
29
30
func (s *Session) handleRequestMGet(r *Request, d *Router) error {
   var nkeys = len(r.Multi) - 1
   var sub = r.MakeSubRequest(nkeys)
   for i := range sub {
      sub[i].Multi = []*redis.Resp{
         r.Multi[0],
         r.Multi[i+1],
      }
      if err := d.dispatch(&sub[i]); err != nil {
         return err
      }
   }
   return nil
}

func (s *Session) handleRequestMSet(r *Request, d *Router) error {
   var nblks = len(r.Multi) - 1
   var sub = r.MakeSubRequest(nblks / 2)
   for i := range sub {
      sub[i].Multi = []*redis.Resp{
         r.Multi[0],
         r.Multi[i*2+1],
         r.Multi[i*2+2],
      }
      if err := d.dispatch(&sub[i]); err != nil {
         return err
      }
   }
   return nil
}

这里的逻辑是把 MGET 把每个 key 拆成一条一条的单独的子请求dispatch到对应的实例上去,并在最后把所有从各个实例上收集到的数据合并返回给客户端。MSET 的逻辑也是类似的。所以最终是一个key占用了一条 MGET 命令。可以从 pika 的 monitor 验证。

 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
1634293336.809138 [172.25.4.14:50453] "MGET" "GameData:5937425"
1634293336.809172 [172.25.4.14:50453] "MGET" "GameData:7560593"
1634293336.809190 [172.25.4.14:50453] "MGET" "GameData:6819140"
1634293336.809217 [172.25.4.14:50453] "MGET" "GameData:8179163"
1634293336.809233 [172.25.4.14:50453] "MGET" "GameData:6886402"
1634293336.809270 [172.25.4.14:50453] "MGET" "GameData:7255116"
1634293336.809277 [172.25.4.4:39085] "MGET" "PropertyData:6694178"
1634293336.809307 [172.25.4.14:50453] "MGET" "GameData:7961837"
1634293336.810038 [172.25.4.8:13547] "MGET" "Board:4052642"
1634293336.810066 [172.25.4.8:13547] "MGET" "StealTargetCache:4052642"
1634293336.810292 [172.25.4.4:39085] "MGET" "PropertyData:4310990"
1634293336.810364 [172.25.4.14:50453] "MGET" "Board:8291369"
1634293336.810412 [172.25.4.14:50453] "MGET" "DLFive:20211015:7491064"
1634293336.810428 [172.25.4.14:50453] "MGET" "Board:7641464"
1634293336.810457 [172.25.4.14:50453] "MGET" "DLFive:20211015:8222697"
1634293336.810484 [172.25.4.14:50453] "MGET" "Board:7922718"
1634293336.810506 [172.25.4.14:50453] "MGET" "Board:7722158"
1634293336.810529 [172.25.4.14:50453] "MGET" "DLFive:20211015:7618670"
1634293336.810547 [172.25.4.14:50453] "MGET" "Board:5544658"
1634293336.811105 [172.25.4.14:50453] "MGET" "Board:8048897"
1634293336.811148 [172.25.4.14:50453] "MGET" "DLFive:20211015:8068411"
1634293336.811172 [172.25.4.14:50453] "MGET" "DLFive:20211015:8264152"
1634293336.811190 [172.25.4.14:50453] "MGET" "Board:8177948"
1634293336.811227 [172.25.4.14:50453] "MGET" "DLFive:20211015:3440258"
1634293336.811245 [172.25.4.14:50453] "MGET" "Board:5584851"
1634293336.811394 [172.25.4.31:38417] "MGET" "FriendList:8367281"

考察了953,757条monitor日志,发现没有一个 MGET 的 key 是多于1个的。我们需要重新审视我们的pika 压测脚本,MGET和MSET的数据是不是真的和线上真实环境一致。这里猜测 MGET/MSET速度更快的原因是 codis-proxy 将请求key路由到了不同的pika实例上,从而将提高了请求速度。并且 codis-proxy 做了一个优化就是请求pika并不是立即发出,而是等到此连接中的未发出的请求为0或者达到最大写入buffer时进行 Flush 发出请求。这应该就是 pipeline 吧,后面再看看redis的代码理解下。以下是相关的代码:

 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

func (p *FlushEncoder) NeedFlush() bool {
   if p.nbuffered != 0 {
      if p.MaxBuffered < p.nbuffered {
         return true
      }
      if p.MaxInterval < time.Since(p.Conn.LastWrite) {
         return true
      }
   }
   return false
}

func (p *FlushEncoder) Flush(force bool) error {
   if force || p.NeedFlush() {
      if err := p.Conn.Flush(); err != nil {
         return err
      }
      p.nbuffered = 0
   }
   return nil
}

// loopWriter 调用出
p.Flush(len(bc.input) == 0)

接下来可以做的事

  1. 对codis-proxy进行压测,而不是单单压测pika,得出与线上环境更接近的数据;
  2. 已经了解 pika MGET 单个 key 与 GET 单个 key 的耗时是一样的,并且 MGET 多个 key 与 GET 多个 key 耗时是一样的,在 pika 的内部 nemo 层将 MGET 转为了多个 GET 调用;
  3. 了解 rocksdb 对缓存的运用,哪种查询、写入key的方式可以更有效的利用缓存;
  4. 探讨为何继续增加 pika 不能提高系统的查询、写入 QPS,按理说是可以的;