1 概况
1.1 设计特点
-
分1024个slot
-
zk保存拓扑.
-
go实现proxy, 无状态.
-
预分配: 1024个slot (单实例5G, 大约可以支撑5-10T规模)
-
平滑扩容.
- 5个组件:
- ZooKeeper
- Codis Redis (codis-server) 修改后的redis.
- Codis Proxy (codis-proxy) 实现proxy逻辑
- Dashboard 需要作为服务运行
- Codis Manager (codis-config) 作为工具运行, 调用dashboard api修改配置.
1.2 和redis-clueter对比
- 在以下方面很像
- 分slot
- redis单机引擎知道slot逻辑.
- 阻塞迁移
- 不同:
- 使用中心存储 保存路由, 而不是goisp协议.
- 使用proxy而不是客户端lib
1.3 Why not Twemproxy & 我的反驳
- 最大痛点:无法平滑的扩/缩容(Scale!!!!)
- 实际上, 可以通过迁移redis实例来做(不过确实很痛苦, 我们一般直接迁移整个集群
)
- 实际上, 可以通过迁移redis实例来做(不过确实很痛苦, 我们一般直接迁移整个集群
- 没有HA机制,没有容错能力
- 实际上, 通过外部工具来做, 效果不错.
- 修改配置需要重启服务
- 实际上, 在作出修改配置决定前, 服务已经出问题至少30s了, 重启花1s并没有问题.
1.4 迁移
- 把一个slot标记为pre-migrate
- 等待所有proxy确认
- 标记slot状态为migrating
- 外部工具不断发送slotmigrate给源redis, 每次一个key, 把这个slot中所有key迁移走
- 标记slot状态为online
notes:
- 一次迁移一个key, 原子, 阻塞(有的文档说: 我们每次只原子的迁走一个 key,不会把主线程 block 住, 这是不对的)
- 这里为了实现一致性, 降低了可用性(根据CAP, 在分布式系统中, 选择了C, A就会降低)
- 为什么说放弃了可用性呢? 假设我们在迁移, 迁移1个key需要1ms, 那么这个分片的qps就会降到1000qps以下了.
- proxy 也可能发起迁移命令.
- 迁移某个slot的过程中, proxy 会提前要求迁移这个key到目标分片.
-
必须保证不能同时有多个 slots 处于迁移状态 => 决定了很慢, 不能扩展到很大的集群(不过够用了)
1.5 优点
- 给redis一个扩容方案.
- proxy能利用多核, 单机性能比Twemproxy好.
1.6 问题
-
redis中每个key会多存一份(slots hash表), 如果key比较大, 很浪费redis内存(大约1.5倍
). - 阻塞式迁移, 为了一致性降低可用性.
- 对大set, 大list等大value不友好, 可能导致redis阻塞1-2s
-
主从切换不是自动, 需要手动操作(建议自动操作, 操作后人工后验检查)
-
不能并发迁移,影响集群规模.
-
直接把redis代码包进来了, 对redis的后续升级, bugfix等难以merge.
-
使用crc32做sharding, 不是很均匀, 不过分了slot, 所以没关系了.
- proxy
- pipeline 性能差(后面优化了, 尚未测试)
- mget/mset 都是串行执行, 性能较差.
-
需要一个部署工具.
2 使用&部署
DATE=`date +'%Y%m%d%H%M'` DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # clean & start zk cd ~/xredis/zookeeper-3.4.6 && ./bin/zkServer.sh stop rm /tmp/zookeeper -rf pkill -f codis-config cd ~/xredis/zookeeper-3.4.6 && ./bin/zkServer.sh start cd $DIR nohup ./bin/codis-config -c sample/config.ini dashboard & sleep 3 echo 'dash running' ./bin/codis-config -c sample/config.ini server add 0 localhost:2000 master ./bin/codis-config -c sample/config.ini server add 0 localhost:3000 slave ./bin/codis-config -c sample/config.ini server add 1 localhost:2001 master ./bin/codis-config -c sample/config.ini server add 1 localhost:3001 slave ./bin/codis-config -c sample/config.ini server add 2 localhost:2002 master ./bin/codis-config -c sample/config.ini server add 2 localhost:3002 slave ./bin/codis-config -c sample/config.ini server add 3 localhost:2003 master ./bin/codis-config -c sample/config.ini server add 3 localhost:3003 slave ./bin/codis-config -c sample/config.ini slot init ./bin/codis-config -c sample/config.ini slot range-set 0 511 0 online ./bin/codis-config -c sample/config.ini slot range-set 512 1023 1 online ./bin/codis-proxy -c sample/config.ini -L ./log/proxy.log --cpu=8 --addr=0.0.0.0:19000 --http-addr=0.0.0.0:11000
3 zk 设计
zk 设计:
/codis/db_{xx} {xx} means 产品名, 如: /codis/db_sync , /codis/db_applist /codis/db_{xx}/servers/group_{N}/{ server addr (e.g. 127.0.0.1:6379) } 存储真实的 redis 组 (主master、从slave), N为一个自定义的整数编号, in JSON, 内容包括服务器地址, 角色(master or slave)等信息 /codis/db_{xx}/slots/slot_{N}
举例, zk中存储的数据如下:
$ zk-shell 127.1:2181 Welcome to zk-shell (1.0.05) (CONNECTING) /> (CONNECTED) /> tree ├── zk │ ├── codis │ │ ├── db_test │ │ │ ├── migrate_manager │ │ │ ├── fence │ │ │ ├── servers │ │ │ │ ├── group_0 │ │ │ │ │ ├── localhost:2000 │ │ │ │ │ ├── localhost:3000 │ │ │ │ ├── group_1 │ │ │ │ │ ├── localhost:2001 │ │ │ │ │ ├── localhost:3001 │ │ │ ├── slots │ │ │ │ ├── slot_0 │ │ │ │ ├── slot_1 │ │ │ │ ├── slot_2 │ │ │ │ ├── slot_3 │ │ │ │ ├── ... │ │ │ │ ├── ... │ │ │ │ ├── slot_1023 │ │ │ ├── proxy | │ │ │ ├── proxy_1 │ │ │ ├── migrate_tasks │ │ │ ├── LOCK │ │ │ ├── actions │ │ │ │ ├── 0000000004 │ │ │ │ ├── 0000000010 │ │ │ │ ├── 0000000006 │ │ │ │ ├── 0000000008 │ │ │ │ ├── 0000000000 │ │ │ │ ├── 0000000002 │ │ │ ├── ActionResponse │ │ │ │ ├── 0000000004 │ │ │ │ ├── 0000000010 │ │ │ │ ├── 0000000006 │ │ │ │ ├── 0000000008 │ │ │ │ ├── 0000000000 │ │ │ │ ├── 0000000002
其中几种节点数据:
(CONNECTED) /> get zk/codis/db_test/servers/group_0/localhost:2000 {"type":"master","group_id":0,"addr":"localhost:2000"} (CONNECTED) /> get zk/codis/db_test/slots/slot_0 {"product_name":"test","id":0,"group_id":1,"state":{"status":"online","migrate_status":{"from":-1,"to":-1},"last_op_ts":"0"}} (CONNECTED) /> get zk/codis/db_test/proxy/proxy_1 {"id":"proxy_1","addr":"127.1:19000","last_event":"","last_event_ts":0,"state":"offline","description":"","debug_var_addr":"127.1:11000","pid":12438,"start_at":"2015-04-28 15:20:23.739459751 +0800 CST"}
4 代码
4.1 redis改动
ext/redis-2.8.13/
- 每个db增加N个slot. 每个slot里面是一个hash, 用于保存每个slot有哪些个key(导致每个key多存一份)
- key是raw_key, val 是crc(key).
-
dictadd/dictDel/dictResize的时候都要在每个slot里面操作
-
增加一系列命令(slotsxxx) slots.c
4.1.1 增加hash_slots
typedef struct redisDb { dict *dict; /* The keyspace for this DB */ dict *expires; /* Timeout of keys with a timeout set */ ... dict *hash_slots[HASH_SLOTS_SIZE]; } redisDb; initServer() { for (i = 0; i < HASH_SLOTS_SIZE; i ++) { server.db[j].hash_slots[i] = dictCreate(&hashSlotType, NULL); }
void dbAdd(redisDb *db, robj *key, robj *val) { sds copy = sdsdup(key->ptr); int retval = dictAdd(db->dict, copy, val); do { uint32_t crc; int slot = slots_num(key->ptr, &crc); dictAdd(db->hash_slots[slot], sdsdup(key->ptr), (void *)(long)crc); } while (0); ... }
增加了一些命令:
{"slotsinfo",slotsinfoCommand,-1,"rF",0,NULL,0,0,0,0,0}, {"slotsdel",slotsdelCommand,-2,"w",0,NULL,1,-1,1,0,0}, {"slotsmgrtslot",slotsmgrtslotCommand,5,"aw",0,NULL,0,0,0,0,0}, {"slotsmgrtone",slotsmgrtoneCommand,5,"aw",0,NULL,0,0,0,0,0}, {"slotsmgrttagslot",slotsmgrttagslotCommand,5,"aw",0,NULL,0,0,0,0,0}, {"slotsmgrttagone",slotsmgrttagoneCommand,5,"aw",0,NULL,0,0,0,0,0}, {"slotshashkey",slotshashkeyCommand,-1,"rF",0,NULL,0,0,0,0,0}, {"slotscheck",slotscheckCommand,0,"r",0,NULL,0,0,0,0,0}, {"slotsrestore",slotsrestoreCommand,-4,"awm",0,NULL,1,1,1,0,0},
4.1.2 阻塞迁移
static int slotsmgrt(redisClient *c, sds host, sds port, int fd, int dbid, int timeout, robj *keys[], robj *vals[], int n) { ... syncWrite(fd, buf + pos, towrite, timeout); syncReadLine(fd, buf1, sizeof(buf1), timeout) }
4.1.3 问题
- 它会记录每个slot有哪些key, 这就会导致key多存一份, 这在某些情况下带来的内存消耗大约是1.5倍.(详见后面测试)
- 而对redis来说, 空间很珍贵, 这里可以用时间换空间, 会更好.
-
阻塞迁移造成的短时拒绝服务(详见后面测试).
4.2 proxy
代码量大约6000:
------------------------------------------------------------------------------- Language files blank comment code ------------------------------------------------------------------------------- Go 42 975 171 5113 Javascript 7 84 99 596 HTML 4 48 24 588 JSON 3 0 0 52 CSS 3 2 4 11 Bourne Shell 2 3 0 6 ------------------------------------------------------------------------------- SUM: 61 1112 298 6366 -------------------------------------------------------------------------------
代码结构:
▾ pkg/ ▾ models/ # 对应zk中的几个结构. server_group.go slot.go action.go proxy.go ▾ proxy/ ▾ parser/ parser.go # 解析请求. ▾ redispool/ conn.go # 连接 redispool.go # 连接池 ▾ cachepool/ cachepool.go # 由后端名字到连接池的map. [127.0.0.1:2000] => redispool {conn1, conn2, conn3} ▾ group/ group.go # 简单包装. ▾ router/ ▸ topology/ # InitZkConn, watch helper.go # redis命令黑名单, isMulOp, PING, SELECT 几个命令的处理. mapper.go # mapKey2Slot (crc32 % 1024) session.go # 记录当前实例的ops, starttime. router.go # 主要proxy逻辑. multioperator.go # mget/mset/del 的实现.
- pkg/models定义几种角色, slot, server_group, proxy 和几种动作, action, 它是zk的的数据模型.
- pkg/proxy 是proxy逻辑的实现.
看一个获得所有ServerGroups的代码:
func ServerGroups(zkConn zkhelper.Conn, productName string) ([]ServerGroup, error) { var ret []ServerGroup root := fmt.Sprintf("/zk/codis/db_%s/servers", productName) groups, _, err := zkConn.Children(root) for _, group := range groups { groupId, err := strconv.Atoi(strings.Split(group, "_")[1]) g, err := GetGroup(zkConn, productName, groupId) ret = append(ret, *g) } return ret, nil }
4.2.1 actions
- codis 对每个topo变化的操作, 都会记录到actions, 同时对于某些action, 会要求每个proxy ack确保更新到最新拓扑.
- TODO: 如何实现确保ack
4.2.2 router.go
加载拓扑信息(一个slot指向那个group):
//use it in lock func (s *Server) fillSlot(i int, force bool) { slotInfo, groupInfo, err := s.top.GetSlotByIndex(i) # 从zk中获取slot信息 slot := &Slot{ slotInfo: slotInfo, dst: group.NewGroup(*groupInfo), groupInfo: groupInfo, } s.pools.AddPool(slot.dst.Master()) # 准备连接池 if slot.slotInfo.State.Status == models.SLOT_STATUS_MIGRATE { # 处理MIGRATE状态. //get migrate src group and fill it from, err := s.top.GetGroup(slot.slotInfo.State.MigrateStatus.From) slot.migrateFrom = group.NewGroup(*from) s.pools.AddPool(slot.migrateFrom.Master()) } s.slots[i] = slot } #如果状态是migrate 的slot, 发migrate命令 func (s *Server) handleMigrateState(slotIndex int, key []byte) error { ... err = WriteMigrateKeyCmd(redisConn.(*redispool.PooledConn), shd.dst.Master(), 30*1000, key) ... }
转发逻辑, 读一个请求, 向后端转发一个:
func (s *Server) redisTunnel(c *session) error { resp, err := parser.Parse(c.r) // read client request op, k, err := getOpKey(resp) i := mapKey2Slot(k) check_state: # 这里是一个循环来检查, 等待SLOT_STATUS_PRE_MIGRATE结束 s.mu.RLock() if s.slots[i] == nil { s.mu.Unlock() return errors.Errorf("should never happend, slot %d is empty", i) } //wait for state change, should be soon if s.slots[i].slotInfo.State.Status == models.SLOT_STATUS_PRE_MIGRATE { s.mu.RUnlock() time.Sleep(10 * time.Millisecond) goto check_state } s.handleMigrateState(i, k); //get redis connection redisConn, err := s.pools.GetConn(s.slots[i].dst.Master()) redisErr, clientErr := forward(c, redisConn.(*redispool.PooledConn), resp) } func (s *Server) handleConn(c net.Conn) { for { err = s.redisTunnel(client) client.Ops++ } } func (s *Server) Run() { log.Info("listening on", s.addr) listener, err := net.Listen("tcp", s.addr) for { conn, err := listener.Accept() go s.handleConn(conn) #起一个 } }
4.2.3 更新路由
proxy 会watch action 树下的变更, 有变化时 重新加载路由:
func (s *Server) OnGroupChange(groupId int) { log.Warning("group changed", groupId) for i, slot := range s.slots { if slot.slotInfo.GroupId == groupId { s.fillSlot(i, true) } } }
4.2.4 pipeline
上面代码是@ngaut同学pipeline优化前的代码,
一般来说, 实现pipeline可能存在下面两个问题, 不过测试发现:codis都没有问题
.
-
返回乱序:
get k1 k2 k3 k4 return v2 v1 v3 vj
原因是k1,k2发到不同后端, 如果其中一个后端很慢, 而先返回的后端就先写客户端, 就是这个错误.
测试发现codis没有这个问题, 但是看代码没有看懂为什么. 涉及到多个channel中传递消息(TODO).
-
乱序执行:
lpush lst 1 lpush lst 2 lpush lst 3 lpush lst 4 lpop return 2 1 3 4
第二种情况是发到同一个后端, 但是如果向同一个后端有多个连接, 就可能出这个问题.
某个连接上的请求先执行.
codis一个后端只有一个TaskRunner(一个连接), 所以应该不会出这个问题.
4.2.5 mget
逐一访问:
func (oper *MultiOperator) mgetResults(mop *MulOp) ([]byte, error) { results := make([]interface{}, len(mop.keys)) conn := oper.pool.Get() defer conn.Close() for i, key := range mop.keys { replys, err := redis.Values(conn.Do("mget", key)) for _, reply := range replys { if reply != nil { results[i] = reply } else { results[i] = nil } } } b, err := respcoding.Marshal(results) return b, errors.Trace(err) }
这是为了保证迁移过程中的一致性, 必须一个一个处理.
性能较差.
5 几个问题
5.1 slot内存
对于简单value(value大小1字节) slots内存占用:
keys 0 1000 10000 100000 1000000 --------------------------------------------------------------- codis-server 2519112 2678920 4073224 17449032 176095304 redis-server 908688 1019856 2078736 12356240 120496272
- 可以看出codis 内存占用大约是 原生redis 的1.5倍
. - 越长的key, 浪费的内存越多.
- 对于大value(复杂的hash, set结构), 浪费的内存会较少.
5.2 阻塞迁移大value
为了保证迁移的一致性, codis选择牺牲可用性, 迁移单个key是通过阻塞当前实例来实现的.
一个100w 字段的hset(内存中大约占70M), 迁移耗时:
本机: 1.85 s 同机房不同机器: 2.06s
1-2s的不响应对大多数业务来说, 还是可以接受的, 所以这个问题不是很严重.
但是一定要注意:
- 业务中不要出现1000w字段的hset, len=1000w的list之类.
- 如果一个集群跨机房部署, 数据传输时间会更长, 迁移时间也会更长.
测试代码:https://gist.github.com/idning/03f43b6789f14e1fe878
在proxy代码中, 给迁移一个key设置的超时是30s:
func (s *Server) handleMigrateState(slotIndex int, key []byte) error { ... err = WriteMigrateKeyCmd(redisConn.(*redispool.PooledConn), shd.dst.Master(), 30*1000, key) ... }
5.3 pipeline 性能
codis proxy对每个请求都是解析 => 找个连接发到后端 => 等待响应 => 发给客户端
这样就相当于不能使用pipeline, 而pipeline对要求高性能的case是非常重要的,
开pipeline的情况下, 单redis, 单线程client做简单set可以达到100w qps
.
5.3.1 性能问题
spinlock同学的测试:
https://github.com/wandoulabs/codis/issues/63
CONC PIPELINE CODIS-LATENCY REDIS-LATENCY 50 10 3.17 0.60 50 20 5.88 0.89 50 75 21.78 2.40p
@ngaut 同学在15年2月实现了pipeline的支持:https://github.com/wandoulabs/codis/pull/110
- 用go来实现pipeline的问题是:
- pipeline并不是一个命令, pipeline实际上是异步处理所带来的一个好处, 只是一种使用方法.
- 理论上, proxy/redis都不需要做任何事情来支持pipeline.
- 但是codis采用了逐一转发的方法来处理请求, 就需要对pipeline专门处理.
- 需要把go写成异步处理的形式, go 带来的编程模型简单的好处就没有了.
- 优化后, 对所有的请求统一使用同样的方法(proxy并不知道自己处在一个pipeline中)
pipeline 遇到slot迁移状态性能又是一个严重的问题.
5.4 使用时遇到的其它小问题
-
命令行支持:
只支持 ./bin/codis-config -c sample/config.ini dashboard 不支持 ./bin/codis-config dashboard -c sample/config.ini, 这个命令报一个非常奇怪的错误.
-
努棒性:
配置写错, 比如:
dashboard_addr=:8087
zk节点也能建成功, 而且不删, 需要等一段时间.
- 很多地方没有检查, 如果 add group 时写错参数.
- proxy 启动后为啥不直接是online呢?
- 代码上有些同名的函数, 比如NewServer一个Server是backendServer, 一个是proxy自己, 读的时候要小心.
6 redis-port
这是整个系统一个极大的亮点, 用于数据迁移, 比我们的redis-replay-aof好的是, 不需要到目标机器读aof文件.
实现原理: 作为一个假的 slave,挂在一个redis后面,然后将master的数据同步回来,sync 到 codis 集群上,
7 参考
-
设计文章:http://0xffff.me/blog/2014/11/11/codis-de-she-ji-yu-shi-xian-part-2/
-
codis design pdf
- 豌豆荚分布式REDIS设计与实现-刘奇
8 总结
-
非常赞的人&非常赞的项目
- 几个比较重要的问题:
- slot内存占用1.5倍
- redis阻塞迁移, 为了一致性降低可用性.
- 不是自动failover
- pipeline/mget性能
- 迁移状态是瞬态(在一个集群运行过程中, 迁移时间只占1/1000不到)
- 但是为了保持迁移状态, mget 性能做了牺牲, redis内存占用做了牺牲, 这不划算.
-
redis-port很赞.
每种方案都是适用的地方.
由 udpwork.com 聚合
|
评论: 0
|
要! 要! 即刻! Now!