各位开发者,各位架构师,大家好。
今天我们深入探讨分布式系统中的核心技术之一——Sharding(分片)。随着数据量的爆炸式增长和用户并发请求的不断攀升,单体数据库的垂直扩展能力终将触及天花板。无论是存储容量、I/O吞吐还是CPU处理能力,都面临着严峻的挑战。Sharding正是为了解决这些问题而生,它通过将数据水平拆分到多个独立的数据库实例上,从而实现系统的横向扩展。
然而,Sharding并非银弹。它的核心挑战在于如何设计一个高效、均衡、可维护的分片策略。其中,Range-based(基于范围)和Hash-based(基于哈希)是两种最常见、也最具代表性的分片策略。理解它们在热点处理、数据分布、扩容缩容以及查询效率上的本质差异,对于我们构建健壮的分布式系统至关重要。
Sharding 的基本概念与术语
在深入策略之前,我们先统一一些基本概念:
- Shard(分片):一个独立的数据库实例,存储了原始数据集合的一个子集。
- Shard Key(分片键/分区键):用于确定数据应该存储在哪一个分片的属性或字段。选择合适的分片键是分片设计的核心。
- Router/Proxy(路由层/代理层):一个中间件层,负责接收来自应用程序的请求,根据分片键将请求路由到正确的Shard,并聚合来自多个Shard的结果(如果需要)。
- 元数据服务(Metadata Service):存储分片键与Shard映射关系的服务,例如,哪些数据范围或哪些哈希值区间对应哪个Shard。
- Rebalancing(数据再平衡):当系统负载不均、存储容量不足或需要增减Shard时,将数据从一个Shard移动到另一个Shard以重新达到均衡状态的过程。
Sharding的目的是将一个巨大的逻辑数据库分解成多个小而易于管理的物理数据库,每个Shard可以独立运行在不同的服务器上,从而分散压力,提高整体系统的吞吐量和可用性。
Range-based Sharding:有序的世界
Range-based Sharding,顾名思义,是根据分片键的“范围”来划分数据。它将分片键的值域划分为若干个连续的、不重叠的区间,每个区间映射到一个特定的Shard。
工作原理
假设我们有一个用户表,以user_id作为分片键。Range-based Sharding可能会这样划分:
user_id在 [1, 100000) 的数据存放在 Shard Auser_id在 [100000, 200000) 的数据存放在 Shard Buser_id在 [200000, 300000) 的数据存放在 Shard C- …依此类推
应用程序通过路由层发送查询请求,路由层根据请求中的user_id,查找元数据服务,确定该user_id所属的范围,进而将请求转发到对应的Shard。
示例代码:一个简化的Range-based Shard Router
class RangeBasedShardRouter:
def __init__(self, shard_ranges):
"""
初始化路由,shard_ranges 是一个列表,每个元素是 (min_val, max_val, shard_id)
例如: [(0, 100000, 'shard_01'), (100000, 200000, 'shard_02')]
"""
# 为了高效查找,可以考虑使用bisect模块或构建一个红黑树等数据结构
# 这里我们用一个简单的排序列表进行演示
self.shard_ranges = sorted(shard_ranges, key=lambda x: x[0])
print(f"Router initialized with ranges: {self.shard_ranges}")
def get_shard_id(self, shard_key):
"""
根据分片键获取对应的Shard ID
"""
for min_val, max_val, shard_id in self.shard_ranges:
if min_val <= shard_key < max_val:
return shard_id
return None # 未找到匹配的Shard
def add_shard(self, new_min_val, new_max_val, new_shard_id):
"""
模拟添加新的Shard。在实际系统中,这会涉及数据迁移和范围重定义。
这里仅演示路由规则的更新。
"""
# 实际操作会更复杂,需要处理范围重叠、合并等情况
# 假设这里添加的范围是全新的,或者已经处理了原范围的拆分
self.shard_ranges.append((new_min_val, new_max_val, new_shard_id))
self.shard_ranges.sort(key=lambda x: x[0]) # 保持有序
print(f"Added shard: {new_shard_id} for range [{new_min_val}, {new_max_val})")
print(f"Updated ranges: {self.shard_ranges}")
def query(self, shard_key, query_details):
shard_id = self.get_shard_id(shard_key)
if shard_id:
print(f"Routing query for key {shard_key} to {shard_id} with details: {query_details}")
# 实际会调用Shard的API执行查询
else:
print(f"Error: No shard found for key {shard_key}")
def query_range(self, start_key, end_key, query_details):
"""
模拟范围查询,这是Range-based Sharding的优势。
"""
affected_shards = set()
for min_val, max_val, shard_id in self.shard_ranges:
# 检查当前Shard的范围是否与查询范围有交集
if max(min_val, start_key) < min(max_val, end_key):
affected_shards.add(shard_id)
if affected_shards:
print(f"Routing range query [{start_key}, {end_key}) to shards: {affected_shards} with details: {query_details}")
# 实际会向这些Shard发送查询请求,并聚合结果
else:
print(f"No shards affected by range query [{start_key}, {end_key})")
# 初始化路由器
initial_ranges = [
(0, 100, 'shard_A'),
(100, 200, 'shard_B'),
(200, 300, 'shard_C')
]
router = RangeBasedShardRouter(initial_ranges)
# 模拟查询
router.query(50, "SELECT * FROM users WHERE id = 50")
router.query(150, "SELECT * FROM users WHERE id = 150")
router.query(280, "SELECT * FROM users WHERE id = 280")
router.query(350, "SELECT * FROM users WHERE id = 350") # 找不到
# 模拟范围查询
router.query_range(70, 120, "SELECT * FROM users WHERE id BETWEEN 70 AND 120")
router.query_range(10, 290, "SELECT * FROM users WHERE id BETWEEN 10 AND 290")
# 模拟扩容:在shard_B和shard_C之间插入一个新的shard_D
# 这意味着原有的shard_C的范围会缩小,或者原有的shard_B的范围会缩小
# 假设我们拆分 shard_C (200, 300) 为 (200, 250) 和 (250, 300)
# 然后将 (250, 300) 映射到新的 shard_D,并调整原 shard_C 的范围
print("n--- Simulating adding a new shard (rebalancing required) ---")
# 在实际中,这会是一个复杂的数据迁移和元数据更新过程
# 这里我们直接修改路由规则来模拟效果
router.shard_ranges = [
(0, 100, 'shard_A'),
(100, 200, 'shard_B'),
(200, 250, 'shard_C'), # shard_C 的范围被调整
(250, 300, 'shard_D') # 新的 shard_D
]
router.shard_ranges.sort(key=lambda x: x[0])
print(f"Ranges after rebalancing: {router.shard_ranges}")
router.query(220, "SELECT * FROM users WHERE id = 220 after rebalance") # 应该到 shard_C
router.query(270, "SELECT * FROM users WHERE id = 270 after rebalance") # 应该到 shard_D
优势
- 范围查询效率高:这是Range-based Sharding最显著的优点。由于数据是按范围有序存储的,对于“查询所有在某个时间段内创建的用户”或“查找所有价格在X到Y之间的商品”这类范围查询,路由层通常只需要查询一个或少数几个相关的Shard,而无需扫描所有Shard。这大大减少了查询的I/O和网络开销。
- 数据局部性:具有相似分片键值的数据会被存储在同一个或相邻的Shard上。这对于一些批处理、数据分析或需要按序访问数据的场景非常有益。
- 理解和管理直观:对于开发者来说,理解数据是如何分布的比较直观,因为它是基于自然顺序的。
劣势
- 热点问题(Hotspots):这是Range-based Sharding最致命的缺点。如果某个范围的数据写入或读取请求特别频繁,那么负责该范围的Shard就会成为整个系统的瓶颈。
- 写入热点:例如,如果以时间戳作为分片键,所有新产生的数据(最近的时间戳)都会集中写入到同一个Shard,导致该Shard的写入压力过大。用户ID通常也是递增的,新注册的用户会集中在最新的ID范围,导致对应的Shard成为写入热点。
- 读取热点:某个特定范围的数据突然变得非常流行(例如,某个热门新闻事件、某个爆款商品),导致大量读取请求涌向该Shard。
- 数据分布不均:如果分片键值的分布不是均匀的,某些范围可能包含大量数据,而另一些范围则数据稀疏,导致一些Shard存储的数据量远超其他Shard,形成“胖Shard”和“瘦Shard”。这会造成存储资源和计算资源的不均衡利用。
- 扩容与缩容复杂:当需要增加新的Shard(扩容)或移除旧的Shard(缩容)时,必须重新定义范围边界,并进行大量的数据迁移。这个过程通常是复杂、耗时且具有中断性的,需要精心的规划和执行。例如,要拆分一个Shard,你需要将它的一部分数据移动到新的Shard,并更新路由表的元数据。
- 元数据管理开销:路由层需要维护一份关于分片键范围与Shard之间映射关系的元数据表。这个表本身需要高可用和一致性保证。
处理热点问题的策略
尽管Range-based Sharding存在热点问题,但并非无法缓解:
- 预分片(Pre-splitting):在系统上线前,根据预估的数据增长趋势,提前将数据范围划分为足够多的Shard,即使初始时某些Shard是空的,也能为未来的增长做好准备。
- 动态范围分裂(Dynamic Range Splitting):当某个Shard的数据量或负载达到阈值时,将其范围动态地一分为二,并将其中一半的数据迁移到新的Shard。这需要复杂的自动化管理系统。
- 结合其他策略:例如,在主分片键(如时间戳)的基础上,引入次分片键(如用户ID的哈希值),形成复合分片策略。
Hash-based Sharding:分散的世界
Hash-based Sharding通过对分片键应用一个哈希函数,将哈希结果映射到特定的Shard。其核心思想是尽可能地将数据均匀、随机地分散到所有可用的Shard上。
工作原理
假设我们仍以user_id作为分片键。Hash-based Sharding的工作方式是:
shard_id = hash(user_id) % num_shards
其中,hash()是一个哈希函数(例如MD5、SHA-1,或者简单的Python内置hash()),num_shards是当前系统中的Shard总数。
示例代码:一个简化的Hash-based Shard Router (基础版)
class SimpleHashBasedShardRouter:
def __init__(self, num_shards):
self.num_shards = num_shards
print(f"Router initialized with {num_shards} shards.")
def get_shard_id(self, shard_key):
"""
根据分片键获取对应的Shard ID
"""
# 使用Python内置的hash函数,并取模
return hash(shard_key) % self.num_shards
def query(self, shard_key, query_details):
shard_id = self.get_shard_id(shard_key)
print(f"Routing query for key {shard_key} to shard_{shard_id} with details: {query_details}")
# 实际会调用Shard的API执行查询
def query_range(self, start_key, end_key, query_details):
"""
模拟范围查询。在简单的Hash-based Sharding中,这通常需要向所有Shard发送请求。
"""
print(f"Routing range query [{start_key}, {end_key}) to ALL shards (fan-out) with details: {query_details}")
# 实际会向所有Shard发送查询请求,并聚合结果。这是效率低下的原因。
# 或者,如果应用程序知道特定范围可能落在哪些Shard,可以优化,但这通常很难。
# 初始化路由器
router_simple_hash = SimpleHashBasedShardRouter(num_shards=3)
# 模拟查询
router_simple_hash.query(50, "SELECT * FROM users WHERE id = 50") # 50 % 3 = 2 -> shard_2
router_simple_hash.query(150, "SELECT * FROM users WHERE id = 150") # 150 % 3 = 0 -> shard_0
router_simple_hash.query(280, "SELECT * FROM users WHERE id = 280") # 280 % 3 = 1 -> shard_1
router_simple_hash.query(350, "SELECT * FROM users WHERE id = 350") # 350 % 3 = 2 -> shard_2
# 模拟范围查询
router_simple_hash.query_range(100, 200, "SELECT * FROM users WHERE id BETWEEN 100 AND 200")
print("n--- Simulating adding a new shard (rebalancing disaster without Consistent Hashing) ---")
# 假设我们从3个shard扩容到4个shard
# 这将导致几乎所有数据的映射关系发生变化,需要大规模数据迁移
# 例如:
# 原来 50 % 3 = 2
# 现在 50 % 4 = 2 (碰巧没变)
# 原来 150 % 3 = 0
# 现在 150 % 4 = 2 (变了)
# 原来 280 % 3 = 1
# 现在 280 % 4 = 0 (变了)
# ...
# 几乎所有数据都需要重新计算并迁移
router_simple_hash_new = SimpleHashBasedShardRouter(num_shards=4)
router_simple_hash_new.query(150, "SELECT * FROM users WHERE id = 150 after rebalance") # 应该到 shard_2
router_simple_hash_new.query(280, "SELECT * FROM users WHERE id = 280 after rebalance") # 应该到 shard_0
优势
- 数据分布均匀:哈希函数的目标就是将输入值尽可能均匀地映射到输出空间。因此,Hash-based Sharding能够很好地将数据分散到所有Shard上,避免Range-based Sharding中常见的“胖Shard”和“瘦Shard”问题,有效利用存储和计算资源。
- 有效缓解热点问题(单键):如果某个特定的分片键(例如,某个用户的ID)成为热点,其请求只会集中到它所属的单个Shard。而其他不相关的热点分片键则会被均匀地路由到不同的Shard。这与Range-based Sharding中整个“范围”成为热点的情况不同。Hash-based Sharding将相关数据打散,避免了数据局部性带来的热点集中效应。
- 扩容/缩容的改进(通过一致性哈希):虽然简单的
key % num_shards方法在扩容时会导致几乎所有数据重新映射,但通过引入一致性哈希(Consistent Hashing)算法,Hash-based Sharding在增减Shard时可以大幅减少数据迁移量,通常只需要迁移1/N的数据(N为Shard总数)。
劣势
- 范围查询效率低:这是Hash-based Sharding最大的劣势。由于数据是根据哈希值分散存储的,逻辑上连续的数据在物理上可能位于不同的Shard。因此,执行范围查询(例如
WHERE user_id BETWEEN X AND Y)通常需要路由层向所有的Shard发送请求(“扇出”),然后将结果在路由层进行聚合、过滤和排序。这会导致大量的网络I/O和路由层的处理负担。 - 无数据局部性:相关的、或者逻辑上应该在一起的数据,会被分散到不同的Shard上。这使得某些依赖于数据局部性的操作变得复杂或低效。
- 简单的哈希函数在扩容时是灾难性的:正如上面的示例所示,如果使用
hash(key) % num_shards这种简单的取模哈希,当num_shards发生变化时,几乎所有数据的哈希结果都会改变,导致系统需要进行大规模的数据迁移,成本极高,几乎不可接受。这是引入一致性哈希的根本原因。
一致性哈希(Consistent Hashing)
为了解决简单哈希在扩容/缩容时的数据迁移问题,一致性哈希被广泛应用于Hash-based Sharding。
工作原理:
一致性哈希将整个哈希空间(通常是一个环形结构)均匀地分配给所有存储节点(Shard)。每个节点在哈希环上占据多个随机的“虚拟节点”(Virtual Nodes)位置,以保证负载均衡。当一个数据键需要存储时,其哈希值会落在哈希环上的某个点。数据会被存储到环上顺时针方向遇到的第一个节点上。
当增加一个新节点时,它只会“窃取”环上顺时针方向紧邻其上一个节点的一部分数据,而不会影响其他节点。同样,移除一个节点时,它负责的数据会顺时针转移到下一个节点。这样,数据迁移量被限制在1/N的比例。
示例代码:简化的Consistent Hashing
import hashlib
import bisect
class ConsistentHashRouter:
def __init__(self, num_replicas=3):
"""
num_replicas: 每个物理Shard在哈希环上的虚拟节点数量,用于提高负载均衡
"""
self.num_replicas = num_replicas
self.ring = [] # 存储 (hash_value, shard_id)
self.nodes = set() # 存储实际的shard ID
def _hash(self, key):
""" 使用MD5哈希函数 """
return int(hashlib.md5(str(key).encode('utf-8')).hexdigest(), 16)
def add_shard(self, shard_id):
"""
添加一个Shard到哈希环。
每个Shard对应 num_replicas 个虚拟节点。
"""
if shard_id in self.nodes:
print(f"Shard {shard_id} already exists.")
return
self.nodes.add(shard_id)
for i in range(self.num_replicas):
virtual_node_key = f"{shard_id}-{i}"
hash_val = self._hash(virtual_node_key)
# 使用bisect插入到有序列表中,保持环的有序性
bisect.insort_left(self.ring, (hash_val, shard_id))
print(f"Shard {shard_id} added. Ring size: {len(self.ring)}")
def remove_shard(self, shard_id):
"""
从哈希环中移除一个Shard。
"""
if shard_id not in self.nodes:
print(f"Shard {shard_id} not found.")
return
self.nodes.remove(shard_id)
# 移除所有属于该Shard的虚拟节点
self.ring = [(h, s) for h, s in self.ring if s != shard_id]
print(f"Shard {shard_id} removed. Ring size: {len(self.ring)}")
def get_shard_id(self, shard_key):
"""
根据分片键获取对应的Shard ID。
"""
if not self.ring:
return None # 没有可用的Shard
key_hash = self._hash(shard_key)
# 查找哈希环中第一个大于或等于key_hash的节点
# bisect_left 返回插入点索引
idx = bisect.bisect_left(self.ring, (key_hash, ''))
# 如果到了环的末尾,则回到开头
if idx == len(self.ring):
idx = 0
return self.ring[idx][1] # 返回对应的Shard ID
# 初始化一致性哈希路由器
router_ch = ConsistentHashRouter(num_replicas=3)
# 添加初始Shard
router_ch.add_shard('shard_A')
router_ch.add_shard('shard_B')
router_ch.add_shard('shard_C')
# 模拟查询
print("n--- Initial Queries ---")
for i in range(10):
key = f"user_{i}"
shard = router_ch.get_shard_id(key)
print(f"Key '{key}' maps to {shard}")
# 模拟扩容:添加一个新的Shard
print("n--- Adding Shard D ---")
router_ch.add_shard('shard_D')
# 再次查询,观察数据映射的变化
print("n--- Queries after adding Shard D ---")
for i in range(10):
key = f"user_{i}"
shard = router_ch.get_shard_id(key)
print(f"Key '{key}' maps to {shard}")
# 模拟缩容:移除一个Shard
print("n--- Removing Shard A ---")
router_ch.remove_shard('shard_A')
# 再次查询
print("n--- Queries after removing Shard A ---")
for i in range(10):
key = f"user_{i}"
shard = router_ch.get_shard_id(key)
print(f"Key '{key}' maps to {shard}")
通过一致性哈希,Hash-based Sharding在应对扩容缩容时变得更加优雅和高效,这也是许多现代分布式系统(如Cassandra、DynamoDB、Riak)采用的核心机制。
比较分析:Range-based vs. Hash-based
理解了两种策略的工作原理和优缺点,我们来做一个全面的对比。
| 特性 | Range-based Sharding | Hash-based Sharding (含一致性哈希) |
|---|---|---|
| 数据分布 | 依赖分片键的自然分布,容易不均匀(胖瘦Shard) | 倾向于均匀分布,避免胖瘦Shard |
| 热点问题 | 高风险。特定范围的数据(如最新数据、热门数据)易形成写入/读取热点。 | 低风险(针对单键)。单个热点键的请求集中在一个Shard,但不同热点键分散。整体负载更均衡。 |
| 范围查询 | 高效。通常只需访问一个或少数几个Shard。 | 低效。通常需要扇出到所有Shard并聚合结果。 |
| 点查询 | 高效。直接路由到目标Shard。 | 高效。直接路由到目标Shard。 |
| 数据局部性 | 强。逻辑上相关的数据(在同一范围)存储在一起。 | 弱。逻辑上相关的数据可能分散在不同Shard。 |
| 扩容/缩容 | 复杂且高成本。需要大量数据迁移,可能涉及服务中断。 | 相对简单(通过一致性哈希)。只需迁移少量数据(1/N),服务中断风险低。 |
| 管理复杂度 | 需要维护范围-Shard映射的元数据,处理范围分裂/合并。 | 需要维护哈希环和虚拟节点映射,管理相对抽象。 |
| 分片键选择 | 必须是具有自然顺序且分布相对均匀的键(如时间戳、ID)。 | 任何具有高基数(唯一性)的键都可,无需自然顺序。 |
| 应用场景 | 时间序列数据、日志数据、地理位置数据、有序ID的用户数据。 | 用户数据(按ID查找)、键值存储、高并发读写、需要高可用和弹性伸缩的系统。 |
热点处理的本质差异
- Range-based Sharding 的热点源于数据访问模式的集中性。如果某个数据范围的访问频率远高于其他范围,那么负责该范围的Shard就会成为热点。这通常发生在时间序列数据(最新数据)、递增ID(新用户)或特定业务事件(热门商品)上。它的热点是“区域性”的。
- Hash-based Sharding(尤其是一致性哈希)设计上是为了将数据均匀分散,从而避免这种“区域性”热点。它能很好地处理单个热点键的问题,因为不同的热点键会被哈希到不同的Shard。然而,它并非万能,如果某个Shard恰好承载了大量不同的、但同时活跃的热点键,或者哈希函数分布不完美,或者一个Shard的物理资源与其他Shard不匹配,仍然可能出现“热点Shard”。但通常,这种热点是由于整体负载过高或资源不均,而不是分片策略本身的集中性导致。
扩容与缩容的本质差异
- Range-based Sharding 的扩容本质上是重新划分边界和数据搬迁。例如,一个Shard承载的范围过大,需要将其拆分为两个更小的范围,并将其中一个范围的数据移动到新的Shard。这个过程需要精确的元数据更新和数据一致性保证,通常需要在低峰期进行,或者通过双写、影子读等复杂机制实现不停机迁移。
- Hash-based Sharding 的扩容(尤其是使用一致性哈希)本质上是调整哈希环上的映射关系。当一个新节点加入时,它只是从环上顺时针的“邻居”节点那里“接管”一部分哈希空间的数据。这个过程的数据迁移量是可控的,且可以通过增量迁移实现,对整体系统的冲击较小。
混合策略与高级考量
在实际系统中,纯粹的Range-based或Hash-based Sharding可能无法满足所有需求。因此,常常会采用混合策略或更复杂的方案:
- 复合分片键(Composite Shard Keys):使用多个字段组合作为分片键。例如,先按
tenant_id进行Range分片,再在每个tenant_id内部按user_id进行Hash分片。这样可以保证一个租户的所有数据都在一个或几个Shard上,同时避免租户内部的数据热点。 - 目录式分片(Directory-based Sharding):不依赖于分片键的计算逻辑,而是维护一个集中的查找表(Directory Service),明确记录每个分片键或分片键范围对应哪个物理Shard。这种方式提供了极大的灵活性,可以动态调整映射关系,但引入了一个中心化的管理服务,需要保证其高可用和性能。
- 二次索引(Secondary Indexes):Sharding通常只支持基于分片键的高效查询。对于非分片键的查询,可能需要构建全局的二次索引或进行全Shard扫描。全局二次索引本身也是一个复杂的分布式系统问题。
- 数据复制与高可用:无论采用何种分片策略,每个Shard本身通常都会是一个高可用的集群(主从复制、多副本等),以确保数据的持久性和服务的连续性。
设计 Sharding 的实践建议
选择合适的分片策略是构建可伸缩系统的关键一步。以下是一些实践建议:
- 深入理解业务和查询模式:这是选择分片键和策略的起点。你的应用程序最频繁的查询是点查询还是范围查询?是否存在天然的业务维度可以作为分片键?是否存在数据倾斜的潜在风险?
- 选择稳定的、高基数的分片键:分片键一旦选定,通常很难更改。它应该具有足够高的唯一性(高基数),以确保数据能够均匀分散。同时,它应该是业务中经常用于查询的字段。
- 规划热点处理:无论选择哪种策略,都应提前考虑热点问题。Range-based需要预留足够的空间和动态调整机制;Hash-based需要确保哈希函数和一致性哈希的实现足够健壮。监控是发现和解决热点的第一步。
- 提前规划扩容和缩容:系统迟早需要扩容。设计时就应该考虑数据迁移的复杂性、对业务的影响以及自动化运维的可能性。一致性哈希在这一方面具有明显优势。
- 不盲目追随潮流,选择最适合的:没有“最好”的分片策略,只有“最适合”的分片策略。Range-based对于时间序列、日志等有序数据,并且范围查询是主要操作的场景可能更为合适。Hash-based对于键值存储、用户数据等点查询为主,且需要高度弹性伸缩的场景更为优越。
总结
Range-based Sharding以其有序性和范围查询的天然优势,适用于数据具有自然顺序且范围查询频繁的场景,但必须警惕潜在的热点问题和扩容复杂性。Hash-based Sharding则通过数据分散实现了良好的负载均衡和热点缓解,尤其是在一致性哈希的加持下,为系统的弹性扩容提供了强大支撑,但牺牲了范围查询的效率。在实际应用中,我们常常需要根据业务特性和数据访问模式,权衡利弊,甚至结合两者所长,构建出最适合自身需求的分片方案。