1.概述
本文主要分析下codis的slot模块。主要包括slot分配、准备迁移、执行迁移、迁移完成四个阶段,以及在数据迁移过程中对应slot的读写如何处理。文章可能比较长。
2.实现机制
对于slot的管理,是在topom完成的,扩缩redis-server节点的时候,需要为redis-server分配slot并制订迁移计划,topom定时去处理迁移计划,执行迁移逻辑。
3.源码解析
Slot分配、制定迁移计划
其实就是在扩缩容之后,如何将slot平均分配个对应的server节点。一旦涉及到重新分配,就需要进行slot的数据迁移。所以应该尽量减少slot的迁移。制定一个合理的迁移计划。
首先我们来看slot的分配,codis提供自动分配和手动分配两种方式,自动分配就是对当前slot以及server进行平均分配。手动分配则是自己指定一部分slot到目标server。
SlotsRebalance(自动balance)
这个方法就是对slot rebalance。比如1024个slot。平均分配给2个server,那么每个server应该负载512个slot。如果新加两个server,则平均每个负责256个slot,这时候就需要进行slot迁移。方法比较长,我们分开来看。
1.整个方法执行需要用到下面的数据。
- assigned,存储了当前group已经被分配的slot数量。
- pendings,存储了当前group可被迁出的slot集合(多出平均值的部分)。
- moveout,存储了当前group需要被迁出(入,如果是负数)的slot数量
- docking,存储所有可被迁移的slot id。
var (
assigned = make(map[int]int)
pendings = make(map[int][]int)
moveout = make(map[int]int)
docking []int
)
slot的七种状态,ActionNothing为未在迁移过程。其余的都是迁移流程中的状态。整个迁移过程围绕这些状态展开。
const (
ActionNothing = ""
ActionPending = "pending"
ActionPreparing = "preparing"
ActionPrepared = "prepared"
ActionMigrating = "migrating"
ActionFinished = "finished"
ActionSyncing = "syncing"
)
2.接下来,就是填充对应信息。首先就是迭代ctx.slots:
如果slot状态不为models.ActionNothing,代表迁移过程中,目标tragetId(groupId)的assigned++
lowerBound为每个group应该负责的slot数量。
如果状态为models.ActionNothing(未在迁移状态中),并且当前slot的GroupId不为0(说明已被分配给某个group),而且对应GroupId已被分配的数量小于平均值(lowerBound),则把当前slot默认分配个当前group,否则将其加入到当前group可被迁移的map(pendings)
//计算当前group有效数量
var groupSize = func(gid int) int {
return assigned[gid] + len(pendings[gid]) - moveout[gid]
}
for _, m := range ctx.slots {
if m.Action.State != models.ActionNothing {
assigned[m.Action.TargetId]++
}
}
var lowerBound = MaxSlotNum / len(groupIds)
// don't migrate slot if groupSize < lowerBound
for _, m := range ctx.slots {
if m.Action.State != models.ActionNothing {
continue
}
if m.GroupId != 0 {
if groupSize(m.GroupId) < lowerBound {
assigned[m.GroupId]++
} else {
pendings[m.GroupId] = append(pendings[m.GroupId], m.Id)
}
}
}
groupSize 这个方法用来计算当前group被分配的slot数量,在整个分配过程会调整对应map的数据,因此groupSize会一直变,直到负载均衡。这个计算方式也很简单,其实就是已被确认分配+可被迁出-确认迁出的数量。
3.初始化一个红黑树,并自定义比较器,左边的节点对应groupsize小与右边
var tree = rbtree.NewWith(func(x, y interface{}) int {
var gid1 = x.(int)
var gid2 = y.(int)
if gid1 != gid2 {
if d := groupSize(gid1) - groupSize(gid2); d != 0 {
return d
}
return gid1 - gid2
}
return 0
})
for _, gid := range groupIds {
tree.Put(gid, nil)
}
// assign offline slots to the smallest group
for _, m := range ctx.slots {
if m.Action.State != models.ActionNothing {
continue
}
if m.GroupId != 0 {
continue
}
dest := tree.Left().Key.(int)
tree.Remove(dest)
docking = append(docking, m.Id)
moveout[dest]--
tree.Put(dest, nil)
}
遍历所有未在迁移过程的slot(并且对应group为0,未被分配),加入docking,代表可被分配。 并取一个groupSize最小的group。将其moveout--。moveout代表可以被迁出的数量,当前group数量不足,所以--,代表要迁入的数量。后面会将docking的slot分配个这个group。当然不是现在。
这里其实就会改变groupsize的大小。主要是要cover所有情况。
4.处理红黑树,需要将拥有slot多于平均值的group分配给小于平均值的group。 其实整个过程围绕逻辑上的groupSize(就开头的那个方法),一直在调整groupSize。
for tree.Size() >= 2 {
from := tree.Right().Key.(int)
tree.Remove(from)
if len(pendings[from]) == moveout[from] {
continue
}
dest := tree.Left().Key.(int)
tree.Remove(dest)
var (
fromSize = groupSize(from)
destSize = groupSize(dest)
)
if fromSize <= lowerBound {
break
}
if destSize >= upperBound {
break
}
if d := fromSize - destSize; d <= 1 {
break
}
moveout[from]++
moveout[dest]--
tree.Put(from, nil)
tree.Put(dest, nil)
}
如果pendings等于moveout数量,代表已经该group已经均衡。
否则将右边补给左边的,通过moveout调整。注意这里有个upperBound以及lowerBound,为了避免死循环。因为如果group数量大于slot数量,lowerBound为0。就会出现问题。
5.这个逻辑主要是处理需要被迁出的group。将其可以被迁出的slot加入到docking。这些slot从pending提供。
for gid, n := range moveout {
if n < 0 {
continue
}
if n > 0 {
sids := pendings[gid]
sort.Sort(sort.Reverse(sort.IntSlice(sids)))
docking = append(docking, sids[0:n]...)
pendings[gid] = sids[n:]
}
delete(moveout, gid)
}
sort.Ints(docking)
6.后面就是将docking中的slot分给所需要的group即可。
这里其实只是将slot的m.Action.TargetId执行对应group。并且更新状态为models.ActionPending(等待被迁移)。
m.Action.Index为迁移顺序,当前最大被迁移的slot的index=+1
m.Action.State = models.ActionPending
m.Action.Index = ctx.maxSlotActionIndex() + 1
m.Action.TargetId = plans[sid]
迁移后,最终只是将slot的targetId进行了指定。也就是仅仅给slot制定了迁移计划,至于何处真正处理我们直接看下面方法。
执行迁移
ProcessSlotAction
这个方法是在topom启动的时候通过协程循环调用。主要就是遍历所有slot,对需要处理的slot进行迁移处理,并更新slot的action。同样我们分开来看这个方法。
1.每次循环处理都会有限制:
- marks的key为groupid。如果当前groupid已经有slot需要process,则不会再选属于那个group的slot
- plans的key为slotId,如果slot被选中,则不会再被选第二次。也就是这个方法每执行一次,每个slot只会被操作一次。也就意味着一次调用,针对一个slot最多改变一次状态。
2.accept和update主要就是控制上面条件的方法。在s.SlotActionPrepareFilter中调用。s.SlotActionPrepareFilter则是根据条件筛选slot。
3.for循环结束条件 首先选择slot的原则是根据slot的action.index,这个变量每次操作都是自增的(slot被分配的时候赋值)。
- 如果plans数量(在SlotActionPrepareFilter被选出的slot数量)超过最大并行配制(100)。
- 按照index如果发现有slot的状态为pending,perpaing或者prepared,则直接break
- 如果集群中没有需要迁移的slot也跳出执行 这里可能就是对非pending,perpaing或者prepared的slot做优化。batch执行逻辑。
var (
marks = make(map[int]bool)
plans = make(map[int]bool)
)
var accept = func(m *models.SlotMapping) bool {
if marks[m.GroupId] || marks[m.Action.TargetId] {
return false
}
if plans[m.Id] {
return false
}
return true
}
var update = func(m *models.SlotMapping) bool {
if m.GroupId != 0 {
marks[m.GroupId] = true
}
marks[m.Action.TargetId] = true
plans[m.Id] = true
return true
}
var parallel = math2.MaxInt(1, s.config.MigrationParallelSlots)
for parallel > len(plans) {
_, ok, err := s.SlotActionPrepareFilter(accept, update)
if err != nil {
return err
} else if !ok {
break
}
}
4.处理被选中的slot。先不着急看如何处理,先来看看s.SlotActionPrepareFilter方法。
for sid, _ := range plans {
fut.Add()
go func(sid int) {
log.Warnf("slot-[%d] process action", sid)
var err = s.processSlotAction(sid)
if err != nil {
status := fmt.Sprintf("[ERROR] Slot[%04d]: %s", sid, err)
s.action.progress.status.Store(status)
} else {
s.action.progress.status.Store("")
}
fut.Done(strconv.Itoa(sid), err)
}(sid)
}
SlotActionPrepareFilter
这个方法主要就是对slot的选取,选取条件就是上面描述的。只不过每次选择到一个是slot,会进行状态切换。
选取规则上面说了,所以那块代码就不看了,主要是函数嵌套比较绕,没意思。
选到slot之后会执行下面代码。主要就是状态的处理。
switch m.Action.State {
case models.ActionPending:
defer s.dirtySlotsCache(m.Id)
m.Action.State = models.ActionPreparing
if err := s.storeUpdateSlotMapping(m); err != nil {
return 0, false, err
}
fallthrough
case models.ActionPreparing:
defer s.dirtySlotsCache(m.Id)
log.Warnf("slot-[%d] resync to prepared", m.Id)
m.Action.State = models.ActionPrepared
if err := s.resyncSlotMappings(ctx, m); err != nil {
log.Warnf("slot-[%d] resync-rollback to preparing", m.Id)
m.Action.State = models.ActionPreparing
s.resyncSlotMappings(ctx, m)
log.Warnf("slot-[%d] resync-rollback to preparing, done", m.Id)
return 0, false, err
}
if err := s.storeUpdateSlotMapping(m); err != nil {
return 0, false, err
}
fallthrough
case models.ActionPrepared:
defer s.dirtySlotsCache(m.Id)
log.Warnf("slot-[%d] resync to migrating", m.Id)
m.Action.State = models.ActionMigrating
if err := s.resyncSlotMappings(ctx, m); err != nil {
log.Warnf("slot-[%d] resync to migrating failed", m.Id)
return 0, false, err
}
if err := s.storeUpdateSlotMapping(m); err != nil {
return 0, false, err
}
fallthrough
case models.ActionMigrating:
return m.Id, true, nil
case models.ActionFinished:
return m.Id, true, nil
default:
return 0, false, errors.Errorf("slot-[%d] action state is invalid", m.Id)
}
models.ActionPending状态直接切换到models.ActionPreparing。
models.ActionPreparing->models.ActionPrepared以及models.ActionPrepared->models.ActionMigrating会调用s.resyncSlotMappings方法。主要是通知proxy slot的状态变化。proxy会根据状态做逻辑处理。
所以执行完之后,状态会改变成models.ActionMigrating,并返回。
resyncSlotMappings
这里会调用proxy的FillSlots方法。该方法前面其实分析过,proxy会重置对应slot的信息,并且为其创建连接。
在这里调用的时候slot的slot.MigrateFrom和slot.MigrateFromGroupId会被赋值,代表是要迁移的slot。
slot.BackendAddr = ctx.getGroupMaster(m.Action.TargetId)
slot.BackendAddrGroupId = m.Action.TargetId
slot.MigrateFrom = ctx.getGroupMaster(m.GroupId)
slot.MigrateFromGroupId = m.GroupId
这里核心就是告知所有proxy和redis建立连接,等待后续迁移。 此时slot的对应的连接已经变更。
对于正在迁移的slot请求会被包装成SLOTSMGRT-EXEC-WRAPPER命令发送。后文说。
processSlotAction
继续回到上面逻辑,slot选完后,会调用这个方法处理。 该方法其实就是对该slot的每一个db进行数据迁移操作。 db通过info命令的keyspace解析获取。
迁移分为同步和异步两种方式
- SLOTSMGRTTAGSLOT-ASYNC
- SLOTSMGRTTAGSLOT 每次随机迁移一个key
迁移完成后调用SlotActionComplete更新状态为迁移完成。并且调用resyncSlotMappings方法通知proxy,数据迁移完成。
具体的迁移细节就不多做研究。其实就是对key的转移。但是一个关键的点就是迁移的原子性。codis对于大key会进行分片迁移。
迁移过程中对客户端请求的处理
同步迁移
如果当前正在迁移对应slot,则调用d.slotsmgrt去帮助迁移该key,成功后处理该key的请求。
func (d *forwardSync) process(s *Slot, r *Request, hkey []byte) (*BackendConn, error) {
//如果正在迁移,查询这个key是否迁移完成
if s.migrate.bc != nil && len(hkey) != 0 {
if err := d.slotsmgrt(s, hkey, r.Database, r.Seed16()); err != nil {
log.Debugf("slot-%04d migrate from = %s to %s failed: hash key = '%s', database = %d, error = %s",
s.id, s.migrate.bc.Addr(), s.backend.bc.Addr(), hkey, r.Database, err)
return nil, err
}
}
r.Group = &s.refs
r.Group.Add(1)
return d.forward2(s, r), nil
}
异步迁移
func (d *forwardSemiAsync) process(s *Slot, r *Request, hkey []byte) (_ *BackendConn, retry bool, _ error) {
if s.backend.bc == nil {
log.Debugf("slot-%04d is not ready: hash key = '%s'",
s.id, hkey)
return nil, false, ErrSlotIsNotReady
}
if s.migrate.bc != nil && len(hkey) != 0 {
resp, moved, err := d.slotsmgrtExecWrapper(s, hkey, r.Database, r.Seed16(), r.Multi)
switch {
case err != nil:
log.Debugf("slot-%04d migrate from = %s to %s failed: hash key = '%s', error = %s",
s.id, s.migrate.bc.Addr(), s.backend.bc.Addr(), hkey, err)
return nil, false, err
case !moved:
switch {
case resp != nil:
r.Resp = resp
return nil, false, nil
}
return nil, true, nil
}
}
r.Group = &s.refs
r.Group.Add(1)
return d.forward2(s, r), false, nil
}
调用d.slotsmgrtExecWrapper,包装成SLOTSMGRT-EXEC-WRAPPER命令请求目标迁移节点。
SLOTSMGRT-EXEC-WRAPPER处理:
- 如果key不存在,返回moved为true
- 如果key存在,moved为false
- 并且是写命令,则会判断当前key是否正在迁移,如果正在迁移并且不允许阻塞客户端,则返回错误,待proxy重试。
- 如果是读命令正常处理
如果moved为true,则会请求原节点。
3.总结
整个slot的分配还是比较有趣的。第一就是对slot的分配算法,以及迁移的实现。slot的迁移依赖状态机实现。对于不同的状态,执行不同的事情。主要迁移操作还是由topom去执行。但是在开始迁移时,会同步所有的proxy更新当前slot的状态为正在迁移。如果正在迁移,proxy会特殊处理,保证了数据的一致性。