Openstack Swift Ring的实现分析

Openstack Swift Ring的实现分析

为了说明分布式文件系统的各个维度问题,将Swift的Ring做了横向拆解对比。本篇纵向整合Ring相关的内容,从分布式文件系统的管理流(集群管理)、数据流(数据定位)以及复制流(数据恢复)三个维度,结合Ring源代码分析其实现。

1. Ring的管理流

Ring的管理流指的是管理员通过Ring提供的ring-builder工具生成和改变Ring结构的过程,也就是在分布式文件系统-集群管理文章中提到的维护系统的逻辑模型、物理模型及逻辑模型到物理模型的映射关系。

1.1 Ring的模型定义

Ring结构中有几个关键的数据结构,如下所示:

Ring._devs
Ring._replica2part2dev
Ring.overload 
Ring.replicas 
Ring.part_power
....

Ring._devs定义了系统的物理拓扑结构,用于存储系统所有存储设备的的信息,每一个存储设备包括如下的信息。

===============================================================
id                 unique integer identifier amongst devices
index              offset into the primary node list for the partition
weight             a float of the relative weight of this device as compared to
                   others; this indicates how many partitions the builder will try
                   to assign to this device
region             -
zone               integer indicating which zone the device is in; a given
                   partition will not be assigned to multiple devices within the
                   same zone
ip                 the ip address of the device
port               the tcp port of the device
device             the device's name on disk (sdb1, for example)
meta               general use 'extra' field; for example: the online date, the
                   hardware description

replication_ip     -
replication_port   -
===============================================================

Ring._replica2part2dev 定义了逻辑模型到物理模型的映射的关系,它是一个数组的数组,如下所示:

  • 3个array表示的是Ring构建的是一个三备份的存储集群
  • 每个array的index表示的是partition_id
  • 同一个index对应的元素则为三个备份存储的dev_id
                         dev_id
                              |          
|   +--------|-----------------------------+
           | 0 | array([1, 3, 1, 5, 5, 3, 2, 1,.....])
replica    | 1 | array([0, 2, 4, 2, 6, 4, 3, 4,.....])
           | 2 | array([2, 1, 6, 4, 7, 5, 6, 5,.....])
|   +--------------------------------------+
                        0 1 2 3 4 5 6 7..............
     -------------------------------------->
             partition

Ring.replicas 表示该Ring为几备份,replicas的值会影响到_replica2part2dev数组纵向长度

Ring.overload 个人翻译理解为超售。Ring在给每个Partition分配不同备份时往往根据磁盘设备设置的权重来进行分配,但是为了满足unique-as-possible的原则(后面会详解),管理员通过调整overload参数使得Partition的备份能尽可能分配到不同的region/zone/node/dev上,这样即会导致某些存储设备出现存储能力超售的情况。

Ring.part_power 则决定该Ring总的Partition数量,为2^part_power个。

1.2 Ring的模型构建

Ring的模型定义好后,常常需要做一些运维操作去调整模型中的一些数据结构和参数。Ring提供了管理工具模块swift-ring-builder,支持Ring结构和元素的增删改查的操作,将其中会导致Ring模型重新构建即rebalance过程的操作列出如下所示:

# 添加磁盘 
swift-ring-builder <builder_file> add
  --region <region> --zone <zone> --ip <ip or hostname> --port <port>
  --device <device_name> --weight <weight>
# 删除磁盘
swift-ring-builder <builder_file> remove
  --region <region> --zone <zone> --ip <ip or hostname> --port <port>
# 调整磁盘的权重
swift-ring-builder <builder_file> set_weight
    --region <region> --zone <zone> --ip <ip or hostname> --port <port>
    --device <device_name> --meta <meta> --weight <weight>
# 调整Ring的备份数量
swift-ring-builder <builder_file> set_replicas <replicas>
# 调整Ring的overload值
swift-ring-builder <builder_file> set_overload <overload>[%]

以上的命令执行后,只会修改Ring中的如Ring._devs,Ring.overload和Ring.replicas等数据结构,不会导致Partition到Dev的映射关系即Ring._replica2part2dev模型数据发生改变。只有在运行了rebalance命令操作后,Ring才会根据修改的数值重新计算和分配Partition备份的位置,也就是更新_replica2part2dev模型数据。

swift-ring-builder rebalance

Ring rebalance是根据物理拓扑结构重新计算和分配partition备份位置的过程,即生成_replica2part2dev结构数据的过程,主要遵循以下原则:

  • 尽量根据存储设备的容量,即dev.weight分配partition数量,使得集群数据保持平衡。
  • 尽量使得partition的备份满足unique-as-possible,即备份尽量分布到不同的域中,如果一个swift集群有3个region,那么将3个备份分配到3个region即满足了unique-as-possible原则。
  • 不允许同一个partition的备份落在同一个磁盘上。

前两个原则是互斥的关系,如不加以外部干预,很难同时满足。两个原则的衡量指标可以用dispersion和rebalance表示,可以通过swift-ring-builder工具查看当前集群两个指标的值。dispersion越小说明集群的数据备份分布越满足unique-as-possible原则,rebalance越小说明集群数据分布越均衡。

# swift-ring-builder object.builder
object.builder, build version 22
16384 partitions, 3.000000 replicas, 1 regions, 1 zones, 13 devices, 12.51 balance, 0.00 dispersion
The minimum number of hours before a partition can be reassigned is 0 (0:00:00 remaining)
The overload factor is 0.00% (0.000000)
Ring file object.ring.gz is obsolete
Devices:   id region zone      ip address:port  replication ip:port  name weight partitions balance flags meta
            0      1    1 192.168.100.200:6000 192.168.100.200:6000     4 1000.00       3781    0.00       
            1      1    1 192.168.100.200:6000 192.168.100.200:6000     5 1000.00       3781    0.00       
            2      1    1 192.168.100.200:6000 192.168.100.200:6000     6 1000.00       3781    0.00       
            3      1    1 192.168.100.200:6000 192.168.100.200:6000     7 1000.00       3781    0.00       
            4      1    1 192.168.100.200:6000 192.168.100.200:6000     8 1000.00       3781    0.00       
            5      1    1 192.168.100.200:6000 192.168.100.200:6000     9 1000.00       3781    0.00       
            6      1    1 192.168.100.200:6000 192.168.100.200:6000    10 1000.00       3780   -0.02       
            7      1    1 192.168.100.150:6000 192.168.100.150:6000     1 1000.00       3876    2.51       
            8      1    1 192.168.100.150:6000 192.168.100.150:6000     2 1000.00       3875    2.49       
            9      1    1 192.168.100.150:6000 192.168.100.150:6000     3 1000.00       3875    2.49       
           10      1    1 192.168.100.150:6000 192.168.100.150:6000     4 1000.00       3876    2.51       
           11      1    1 192.168.100.150:6000 192.168.100.150:6000     5 1000.00       3876    2.51       
           12      1    1 192.168.100.150:6000 192.168.100.150:6000     6 1000.00       3308  -12.51       

以下结合例子和代码详细分析Ring的Reblance过程。假设系统的物理拓扑如下图所示,以三备份为例。在满足上文提到的rebalance或dispersion原则下,如何将总的partition数量(2^part_power)最优的分配到每一层节点(tier)是ring reblance过程做的事情。


因为过程比较繁琐细节,所以先小结如下,有个总体概念。

  • 第1-2步,主要根据rebalance和dispersion原则制定副本的分配计划replicas_plan,在此基础上计算出每个dev能够分配到的partition数量;
  • 第3-6步,主要将引起某些partition->replica映射关系需要改变的partition收集起来,保存在assign_parts,3-6步针对引起partition->replica映射关系发生改变的情况进行了分别判断和处理,包括ring的备份数replica值发生改变、回收已删除设备上的partition、收集没有满足dispertion原则的partition、回收overload磁盘设备上的partition。这几个步骤主要是对已经存在的ring的_replica2part2dev进行调整,如果是新建的ring这几个步骤可略过。
  • 第7步,主要将以上回收的partition列表即assign_parts,重新选择合适的dev分配给每一个partition->replica
  • 第8步,将计算结果保存为ring文件。

前方有点繁琐,如果实在看不下去就略了吧。

第一步,replica_plan = self._build_replica_plan() ,它调用_build_target_replicas_by_tier函数,结合存储设备的权重weight和unique-as-possible原则,以及overload参数等因素,计算以上物理拓扑树中每个tier节点的replica分配数目,返回的是<tier> => <target_replicas>映射关系,其中tier表示以上图中树的每个节点在ring topology结构中的位置所属关系,如r1z1-192.168.100.150/1 表示了存储设备号为dev1的节点,target_replicas表示每个tier分配到replica数目。上代码:

replica_plan = self._build_replica_plan()
    --------------------------------- 
                   ||
def _build_target_replicas_by_tier(self):
        weighted_replicas = self._build_weighted_replicas_by_tier()
        wanted_replicas = self._build_wanted_replicas_by_tier()
        max_overload = self.get_required_overload(weighted=weighted_replicas,
                                                  wanted=wanted_replicas)
        if max_overload <= 0.0:
            return wanted_replicas
        else:
            overload = min(self.overload, max_overload)
        self.logger.debug("Using effective overload of %f", overload)
        target_replicas = defaultdict(float)
        for tier, weighted in weighted_replicas.items():
            m = (wanted_replicas[tier] - weighted) / max_overload
            target_replicas[tier] = m * overload + weighted

为了说明这段逻辑,还是以一个例子比较容易说清,对于此物理拓扑结构根据代码计算出的值标注如图所示。


1.weighted_replicas 根据设置的dev_weight向上叠加计算每一层节点的分配到的副本比例,计算结果如红色数字

2. dispersed_replicas 根据unique-as-possible的原则自上而下的平均分配给每个topo节点replicas数量,即抛开权重因素让每一个节点都能够分配的replicas,最后向上向下取整得到dispersed_replicas(min,max),计算结果如蓝色数字

3. wanted_replicas 是在weight_replicas和dispersed_replicas做折衷计算选择,过程如下,计算结果标注在图中黄色数字

for t in tiers_to_spread:
   replicas = to_place[t] + (weighted_replicas[t] * rel_weight)
   #当计算的weighted_replicas小于dispersed_replicas最小值,尽量让
   #此tier节点能够分配到replicas,即dispersed_replicas的最小值
   if replicas < dispersed_replicas[t]['min']:
           replicas = dispersed_replicas[t]['min']
   #当计算的weighted_replicas大于dispersed_replicas最大值,取
   #dispersed_replicas的最大值
   elif (replicas > dispersed_replicas[t]['max'] and not device_limited):
           replicas = dispersed_replicas[t]['max']
   if replicas > num_devices[t]:
          replicas = num_devices[t]
   #当weighted_replicas位于dispersed_replicas(min,max)之间时,
   #则选weighted_replicas做为wanted_replicas
   to_place[t] = replicas

通过以上的表达式可以看出,当weight_replicas<min(dispersed_replicas)时候wanted_replicas的值取min(dispersed_replicas),尽量是让每个节点都能分到replicas。所以此过程其实是在满足rebalance原则的前提下尽可能的去兼顾dispersion原则的分配。

4.max_overload,根据计算当前每个tier的overload系数并找出集群的max_overload最大值计算公式max_overload=Max((wanted[tier] - weighted[tier]) / weighted[tier]),计算出当前集群的max_overload=2/3,数值标示图中橘黄色数字



5.target_replicas 则是根据以上的计算结果,结合管理员设置的overload参数计算最终的relicator数目

如果以上计算出的max_overload<0,那表明物理拓扑树的每个节点的wanted_replica都小于weighted_replica,那么直接将wanted_replica作为target_replicas即可。

如不满足则进入下一步计算集群的overload,其值取的是管理员设置的overload值和根据replica_plan计算出的max_overload两者之间的最小值,最后将overload的值带入公式计算target_replicas。

overload = min(Ring.overload, max_overload)
target_replicas= (wanted_replicas[tier] - weighted) / max_overload * overload + weighted

从target_replicas计算公式可以看出,replicas的分布主要由dispersion和rebalance两大原则的制约,同时给管理员提供了灵活的干预方法,即设置overload参数。

如果Ring.overload没有设置其默认值为0,那么target_replicas=weighted_replicas,那么集群则完美的符合了rebalance的原则,但没有满足dispersion的原则(unique-as-possible)

如果管理员将Ring.overload=max_overload,那么target_replicas=wanted_replicas,这样集群则完全满足了dispersion的原则,不符合rebalance的原则

在实际生产过程中,我们常常是以rebalance原则为先的,即根据物理磁盘的容量权重来决定备份的分布。但是在某一些场景下,比如一定要将数据的其中一个备份做异地的灾备,写到不同的域时可以通过调整overload的值满足unique-as-possible的原则。

第二步 self._set_parts_wanted(replica_plan)

这一步比较简单,就不展开了,主要是根据第一步计算出的replica_plan,将总的partition数量即replicas*parts按层按比例分配下去,最终反映到dev['partition_wanted']变量值,计算出每个存储设备dev需要的partition数量

for dev in self._iter_devs():
            if not dev['weight']:
                # With no weight, that means we wish to "drain" the device. So
                # we set the parts_wanted to a really large negative number to
                # indicate its strong desire to give up everything it has.
                dev['parts_wanted'] = -self.parts * self.replicas
            else:
                tier = (dev['region'], dev['zone'], dev['ip'], dev['id'])
                dev['parts_wanted'] = parts_by_tier[tier] - dev['parts']

第三步 _adjust_replica2part2dev_size(assign_parts)方法根据设置的备份数确定_replica2part2dev数组的长度,其中assign_parts是一个很重要的变量,是一个partition => [replicas]的map,用于记录变化的partition到replicas备份的映射关系。

assign_parts = defaultdict(list) # gather parts from replica count adjustment
self._adjust_replica2part2dev_size(assign_parts)

此方法主要考虑了以下两种情况:

1._replica2part2dev为None,那么说明这是第一次创建的ring,这时候遍历所有的 partirion,将每个partition的备份都添加到assign_parts中, assign_parts[part].append(replica),同时_replica2part2dev基本结构形成了,剩下dev的具体分配,如下所示

        dev_id
    |          
    |   +--------|-----------------------------+
         | 0 | array([NONE, NONE, NONE, .....])
replica  | 1 | array([NONE, NONE, NONE,.....])
         | 2 | array([NONE, NONE, NONE,.....])
    |   +--------------------------------------+
              0 1 2 3 4 5 6 7.....................
         -------------------------------------->
             partition

2._replica2part2dev不为None,那么如果对已存在的Ring备份数目进行了修改,增加备份则增加纵向数组的长度,并横向遍历partition将增加的备份append到assign_parts中,并填充dev设备号为NONE,如下图所示,将备份数由原来的3备份调整为4备份;减少备份则直接截断纵向数组长度,去掉多余备份即可。

       dev_id
    |          
    |   +--------|-----------------------------+
         | 0 | array([1, 3, 4, .....])
replica  | 1 | array([2, 4, 5,.....])
         | 2 | array([3, 1, 6,.....])
         | 3 | array([NONE, NONE, NONE,.....])
    |   +--------------------------------------+
              0 1 2 3 4 5 6 7.....................
         -------------------------------------->
             partition

第四步 _gather_parts_from_failed_devices这一步做的事情比较简单,就不赘述了。此方法主要针对管理员删除的磁盘设备上的partition,在_replica2part2dev结构中dev_id位置置为NONE,同时将影响到的partition更新到assign_parts:partition => [replicas]用于重新分配。

removed_devs = self._gather_parts_from_failed_devices(assign_parts)

第五步 _gather_parts_for_dispersion,主要收集没有满足dispersion原则,即同一个partition的不同备份没有尽量分散到集群不同域的partition,摘选部分主要代码说明。

  • 遍历partition的三个备份找到对应的物理存储设备dev_id
  • 从上至下遍历物理拓扑树到叶子节点dev,只要路径中有任意tier节点当前的replicas数量大于了replica_plan的值,说明数据没有完全打散分布,那么将其存入定义的临时数据结构undispersed_dev_replicas(dev,replica)中
  • 最后遍历undispersed_dev_replicas,将dev->replica映射关系转换为partition->replica的映射关系,并计入assign_parts,同时将_replica2part2dev中对应的partition-replica原有的dev_id置为NONE
def _gather_parts_for_dispersion(self, assign_parts, replica_plan):
      ......
      for replica in self._replicas_for_part(part):
                dev_id = self._replica2part2dev[replica][part]
                if dev_id == NONE_DEV:
                    continue
                dev = self.devs[dev_id]
      #从上至下遍历物理拓扑树到叶子节点dev,只要路径中有任意tier节点当前的replicas
      #数量大于了replica_plan的值,说明数据没有完全打散分布,那么将其存入定义的临时
      #数据结构undispersed_dev_replicas(dev,replica)中
                if all(replicas_at_tier[tier] <=
                       replica_plan[tier]['max']
                       for tier in dev['tiers']):
                    continue
                undispersed_dev_replicas.append((dev, replica))

       for dev, replica in undispersed_dev_replicas:
                ...... 
                if (self._last_part_moves[part] < self.min_part_hours and
                        not replicas_at_tier[dev['tiers'][-1]] > 1):
                    continue
                dev['parts_wanted'] += 1
                dev['parts'] -= 1
       #最后遍历undispersed_dev_replicas,将dev->replica映射关系转换为
       #partition>replica的映射关系,并计入assign_parts
                assign_parts[part].append(replica)
       #将_replica2part2dev中该partition和replica的dev_id置为空,等重新分配
                self._replica2part2dev[replica][part] = NONE_DEV
      ......

第六步 _gather_parts_for_balance,主要做的事情是回收overweight的存储设备上的partition。步骤如下:

  • 遍历物理拓扑树,当dev['parts_wanted']<0说明该dev是overweight的,则将其加入overweight_dev_replica数组中
  • 遍历overweight_dev_replica,对于不满足新的replica_plan的replica进行处理,将该partition-replica对应的dev_id置为NONE ,同时将此partition对应的replica添加到assign_parts,等待重新分配dev
def _gather_parts_for_balance(self, assign_parts, replica_plan):
.....
  #当从上至下到dev,当dev['parts_wanted']<0说明该dev是overweight的,则将其加入
  #overweight_dev_replica数组中去
  for replica in self._replicas_for_part(part):
                dev_id = self._replica2part2dev[replica][part]
                if dev_id == NONE_DEV:
                    continue
                dev = self.devs[dev_id]
                for tier in dev['tiers']:
                    replicas_at_tier[tier] += 1
                if dev['parts_wanted'] < 0:
                    overweight_dev_replica.append((dev, replica))
  #遍历overweight_dev_replica,对于不满足新的replica_plan的
  #overweight_dev_replica进行处理,将该partition的这个备份对应的dev置为NONE
  #同时将此partition对应的replica添加到assign_parts,等待重新分配dev
  for dev, replica in overweight_dev_replica:
                if self._last_part_moves[part] < self.min_part_hours:
                    break
                if any(replica_plan[tier]['min'] <=
                       replicas_at_tier[tier] <
                       replica_plan[tier]['max']
                       for tier in dev['tiers']):
                    continue
                dev['parts_wanted'] += 1
                dev['parts'] -= 1
                assign_parts[part].append(replica)
                self.logger.debug(
                    "Gathered %d/%d from dev %d [weight disperse]",
                    part, replica, dev['id'])
                self._replica2part2dev[replica][part] = NONE_DEV

第七步 _reassign_parts主要将以上收集到的assign_parts,即需要重新调整映射关系的partition->replica,进行重新分配dev,选取主要代码分析如下:

  • 首先生成tier2devs列表,内容是tier->[dev1,dev2....],收集每个tier包含的dev
  • 然后从replica_plan中选取还没有达到max replicas数目的tier,即最空的tier
  • 从tier2devs中找到对应的dev列表,选择最后一个dev
  • 将选择出的dev作为reassign_partition的新dev,更新_replica2part2dev对应位置的dev_id
assign_parts_list = list(assign_parts.items())
self._reassign_parts(assign_parts_list, replica_plan)
def _reassign_parts(self, reassign_parts, replica_plan):
    .....
    # 按每个tier收集其所有的dev,生成tier->[dev1,dev2....]列表
    for dev in available_devs:
        for tier in dev['tiers']:
            tier2devs[tier].append(dev)  # <-- starts out sorted!
                tier2dev_sort_key[tier].append(dev['sort_key'])
                tier2sort_key[tier] = dev['sort_key']
    # 给添加到assign_parts list中的partition->replica分配新的dev
    for part, replace_replicas in reassign_parts:
        .......
        for replica in replace_replicas:
                tier = ()
                depth = 1
                while depth <= max_tier_depth:
                    # 从replica_plan中选取还没有达到max replicas分配且最空的tier
                    candidates = [t for t in tier2children[tier] if
                                  replicas_at_tier[t] <
                                  replica_plan[t]['max']]

                    if not candidates:
                        raise Exception('no home for %s/%s %s' % (
                            part, replica, {t: (
                                replicas_at_tier[t],
                                replica_plan[t]['max'],
                            ) for t in tier2children[tier]}))
                    tier = max(candidates, key=lambda t:
                               parts_available_in_tier[t])
                    depth += 1
                #从这个tier中的dev列表中取最后一个dev
                dev = tier2devs[tier][-1]
                dev['parts_wanted'] -= 1
                dev['parts'] += 1
                for tier in dev['tiers']:
                    parts_available_in_tier[tier] -= 1
                    replicas_at_tier[tier] += 1
                # 将此dev作为需要重新分配dev的partition,replica的新dev
                self._replica2part2dev[replica][part] = dev['id']

第八步 将计算出的数据结构保存为Ring文件,管理员需要将生成的Ring文件同步到集群其它节点。

2. Ring的数据流

Ring的数据流主要指的是客户端如何通过计算和查找Ring找到数据的存放位置,即数据定位

哈希算法是最常见的用于数据定位和分布的方法,Swift采用的是改良型的一致性哈希算法,主要解决对象在节点间分布不均匀的问题,通过引入一层虚拟节点来解决问题,在说说分布式文件系统-数据定位文章中讲Swift部分对Swift的Hash算法做了分析比较。



从文件名到文件的物理存储位置,Ring对外提供了两个重要的方法完成逻辑模型到物理模型的映射关系计算,分别是get_part和_get_part_nodes方法。

第一步,逻辑模型计算

计算对象文件名(/account/container/object)MD5散列值,对该散列值的前 4 个字节进行右移操作得到分区索引号即PartitionID,移动位数由创建Ring时指定的part_power大小决定,part_shift=32-part_power。Ring对外

#self._part_shift = 32 - self.part_power
def get_part(self, account, container=None, obj=None): """
        Get the partition for an account/container/object.

        :param account: account name
        :param container: container name
        :param obj: object name
        :returns: the partition number
        """
        key = hash_path(account, container, obj, raw_digest=True) if time() > self._rtime:
            self._reload()
        part = struct.unpack_from('>I', key)[0] >> self._part_shift
        return part

第二步,物理模型计算

通过Ring在创建物理模型生成的replica2part2dev_id的数据结构中,可以查找分区索引号到设备列表的映射关系,根据第一步计算出的PartitionID查找对应的所有设备信息

def _get_part_nodes(self, part):
        part_nodes = []
        seen_ids = set() for r2p2d in self._replica2part2dev_id: if part < len(r2p2d):
                dev_id = r2p2d[part] if dev_id not in seen_ids:
                    part_nodes.append(self.devs[dev_id])
                    seen_ids.add(dev_id) return [dict(node, index=i) for i, node in enumerate(part_nodes)]

第三步 请求存储节点

根据物理模型计算得到的设备信息,向对应的存储节点请求数据。

通过以上描述可以看出,对于任意一个对象文件,我们都可以快速得到该对象文件所对应的存储节点。对于写操作,只需要将对象数据存储到通过上述模型计算得到的存储节点,对于读操作,通过上述模型计算即可知道文件存储位置。因为不需要存储每个对象和存储节点的映射关系,从对象存储层面的元数据存储也减少了很多,只需存储对象的名称,支持模拟文件功能列出目录对象功能。

3. Ring的复制流

Ring的复制流相关的内容,主要想整理的是Swift数据迁移的过程。当Ring发生rebalance后Swift的Replicator会根据最新的Ring进行partition备份的移动,即数据迁移复制。这篇文章写的有点长,感觉身体被掏空,需要缓一缓,这一部分内容先占坑待补充。

编辑于 2017-08-07