简单介绍

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几乎完全一致。

这里提一点之前的一个优化思路经过测试是无效的。原本以为将 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)
|
接下来可以做的事
- 对codis-proxy进行压测,而不是单单压测pika,得出与线上环境更接近的数据;
- 已经了解 pika MGET 单个 key 与 GET 单个 key 的耗时是一样的,并且 MGET 多个 key 与 GET 多个 key 耗时是一样的,在 pika 的内部 nemo 层将 MGET 转为了多个 GET 调用;
- 了解 rocksdb 对缓存的运用,哪种查询、写入key的方式可以更有效的利用缓存;
- 探讨为何继续增加 pika 不能提高系统的查询、写入 QPS,按理说是可以的;