Codis源码Slot模块(Redis分布式解决方案3)

1,391 阅读8分钟

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会特殊处理,保证了数据的一致性。