各位同仁,下午好!
今天,我们将深入探讨一个在高性能键值存储领域中看似简单却异常高效的模型——Bitcask。你可能会好奇,在B-树、LSM-树等复杂且久经考验的数据结构占据主流的今天,为什么像Bitcask这种基于“仅追加日志 + 内存索引”的存储模型,会成为许多高频键值(KV)库,尤其是需要极高写入和读取吞吐量的场景下的首选?我们将从其核心原理出发,剖析其设计哲学、运作机制、优势所在,以及它所面临的挑战。
1. Bitcask存储模型概览
Bitcask,最初由Basho Technologies为分布式数据库Riak设计并开源,其核心思想是“一切皆文件,一切皆追加”。它将所有的数据写入操作都转换为对磁盘上日志文件的顺序追加,同时维护一个完全驻留在内存中的哈希表作为键的索引。
这个模型的核心目标是最大化磁盘的顺序I/O性能,并利用内存的极速访问来提供快速的键查找能力。它在设计上做了权衡,牺牲了某些通用性(例如范围查询)以换取在特定工作负载下的卓越性能。
让我们首先拆解Bitcask存储模型的两个核心组成部分:
- 数据文件(Data Files):这些是磁盘上的日志文件,所有的数据(键和值)都以追加的方式写入这些文件。一旦一个数据文件达到预设大小或经过一定时间,就会关闭并创建一个新的数据文件进行写入。旧的数据文件成为只读文件,等待后续的合并(Compaction)操作。
- 键目录(Key Directory / Index):这是一个完全驻留在内存中的哈希表。它存储着每个键的最新值在磁盘上的物理位置信息。这个位置信息通常包括:数据文件ID、值在文件中的偏移量、值的大小以及可选的时间戳。
2. Bitcask的核心运作机制
要理解Bitcask为何高效,我们必须深入其读、写、删除以及恢复的运作细节。
2.1 数据文件的组织
Bitcask的持久化存储由一系列按时间顺序编号的数据文件组成,例如 00001.data, 00002.data 等。每个文件都是一个简单的二进制日志,按以下格式存储键值对:
| 字段 | 长度 (字节) | 描述 |
|---|---|---|
CRC |
4 | 整个条目(从 timestamp 到 value)的CRC32校验和 |
timestamp |
4 | 写入时的Unix时间戳 |
key_size |
4 | 键的字节长度 |
value_size |
4 | 值的字节长度 |
key |
key_size |
实际的键数据 |
value |
value_size |
实际的值数据 |
示例代码:数据文件条目结构
import struct
import time
import zlib
# 定义数据文件条目的格式
# CRC: 4 bytes (unsigned int)
# timestamp: 4 bytes (unsigned int)
# key_size: 4 bytes (unsigned int)
# value_size: 4 bytes (unsigned int)
# key: variable length
# value: variable length
# struct format string for fixed-size header parts
# ! for network byte order (big-endian)
# I for unsigned int (4 bytes)
HEADER_FORMAT = "!IIII" # CRC, timestamp, key_size, value_size
HEADER_SIZE = struct.calcsize(HEADER_FORMAT)
class BitcaskEntry:
def __init__(self, key: bytes, value: bytes, timestamp: int = None):
self.timestamp = timestamp if timestamp is not None else int(time.time())
self.key = key
self.value = value
self.key_size = len(key)
self.value_size = len(value)
self.crc = 0 # Will be calculated before writing
def serialize(self) -> bytes:
# Step 1: Serialize key and value
payload = self.key + self.value
# Step 2: Assemble the part that CRC covers
crc_payload = struct.pack(
"!II", self.timestamp, self.key_size, self.value_size
) + payload
# Step 3: Calculate CRC
self.crc = zlib.crc32(crc_payload) & 0xffffffff # Ensure 32-bit unsigned
# Step 4: Assemble the full entry
full_entry = struct.pack(
HEADER_FORMAT, self.crc, self.timestamp, self.key_size, self.value_size
) + payload
return full_entry
@classmethod
def deserialize(cls, data: bytes) -> 'BitcaskEntry':
if len(data) < HEADER_SIZE:
raise ValueError("Data too short to contain header.")
crc, timestamp, key_size, value_size = struct.unpack(HEADER_FORMAT, data[:HEADER_SIZE])
# Verify CRC
payload_offset = HEADER_SIZE
crc_payload = struct.pack("!II", timestamp, key_size, value_size) +
data[payload_offset:] # This part could be tricky if data is partial
# For full verification, we'd need the *entire* entry's bytes, not just a slice.
# This example assumes 'data' is the full entry for simplicity of CRC check.
# In a real system, you'd read (key_size + value_size) bytes after header.
calculated_crc = zlib.crc32(struct.pack("!II", timestamp, key_size, value_size) +
data[HEADER_SIZE:HEADER_SIZE + key_size + value_size]) & 0xffffffff
if crc != calculated_crc:
# In a real system, this would indicate data corruption
# print(f"CRC mismatch! Expected {crc}, Got {calculated_crc}")
pass # Or raise an error
key_start = HEADER_SIZE
key_end = key_start + key_size
value_start = key_end
value_end = value_start + value_size
key = data[key_start:key_end]
value = data[value_start:value_end]
entry = cls(key, value, timestamp)
entry.crc = crc # Store original CRC for consistency if needed
return entry
2.2 内存键目录(Key Directory)
键目录是一个哈希表,它将每个键映射到一个 ValuePos 结构体。这个结构体包含了读取该键对应值所需的所有信息:
# ValuePos 结构体
class ValuePos:
def __init__(self, file_id: int, offset: int, size: int, timestamp: int):
self.file_id = file_id # 存储该值的日志文件ID
self.offset = offset # 值在文件中的起始偏移量
self.size = size # 值的字节长度
self.timestamp = timestamp # 写入时的时间戳 (用于处理过期和冲突)
# 内存中的键目录 (简化表示)
# key_directory = {
# b"my_key_1": ValuePos(file_id=1, offset=123, size=45, timestamp=1678886400),
# b"my_key_2": ValuePos(file_id=2, offset=567, size=89, timestamp=1678886405),
# ...
# }
这个键目录是Bitcask性能的关键。它允许在O(1)的平均时间复杂度内定位任何键的物理位置,而无需进行磁盘查找。
2.3 写入操作 (Put)
当客户端执行 Put(key, value) 操作时,Bitcask会执行以下步骤:
- 序列化数据:将键和值以及相关的元数据(时间戳、大小、CRC)序列化成一个数据条目。
- 追加写入:将这个数据条目顺序追加到当前活跃的数据文件(active data file)的末尾。
- 更新索引:在内存中的键目录中,使用键作为哈希表的键,创建一个新的
ValuePos对象(包含当前活跃文件ID、写入偏移量、值大小和时间戳),并更新哈希表。如果键已经存在,旧的ValuePos会被新的覆盖。 - 持久化 (可选/异步):为了确保数据持久性,通常会在一定数量的写入后或定时调用
fsync()将文件缓冲区的内容刷写到磁盘。在高吞吐量场景,这可能异步进行或批量进行,以避免阻塞写入路径。
示例代码:Put操作
import os
import fcntl # For posix_fadvise and flock on Unix-like systems
class BitcaskStore:
def __init__(self, data_dir: str, max_file_size: int = 10 * 1024 * 1024): # 10MB
self.data_dir = data_dir
os.makedirs(data_dir, exist_ok=True)
self.max_file_size = max_file_size
self.key_directory = {} # type: dict[bytes, ValuePos]
self.active_file_id = 0
self.active_file = None # type: os.FileIO | None
self.active_file_offset = 0 # Current write offset in active file
self.file_handles = {} # type: dict[int, os.FileIO] # For read-only files
self._load_key_directory() # On startup, rebuild index from data files
def _get_file_path(self, file_id: int) -> str:
return os.path.join(self.data_dir, f"{file_id:05d}.data")
def _open_active_file(self):
if self.active_file:
self.active_file.close()
# Optionally, mark the old file as read-only or close it properly for compaction
# self.file_handles[self.active_file_id] = self.active_file # If we want to keep it open for reads
self.active_file_id += 1
new_file_path = self._get_file_path(self.active_file_id)
self.active_file = open(new_file_path, "ab") # "ab" for append binary
self.active_file_offset = os.fstat(self.active_file.fileno()).st_size # Get current size for offset
def _ensure_active_file(self):
if not self.active_file or self.active_file_offset >= self.max_file_size:
self._open_active_file()
def put(self, key: bytes, value: bytes):
self._ensure_active_file()
entry = BitcaskEntry(key, value)
serialized_entry = entry.serialize()
# Record current offset before writing
current_offset = self.active_file_offset
self.active_file.write(serialized_entry)
self.active_file_offset += len(serialized_entry)
# Update in-memory index
self.key_directory[key] = ValuePos(
file_id=self.active_file_id,
offset=current_offset + HEADER_SIZE + entry.key_size, # Value starts after header and key
size=entry.value_size,
timestamp=entry.timestamp
)
# For simplicity, we are not fsyncing here. In a real system,
# fsync would be crucial for durability, potentially batched.
# self.active_file.flush()
# os.fsync(self.active_file.fileno())
# --- Index Loading (simplified for demonstration) ---
def _load_key_directory(self):
# Find all data files
data_files = sorted([f for f in os.listdir(self.data_dir) if f.endswith(".data")])
if not data_files:
self._open_active_file() # No files, start a new one
return
# Determine the latest file ID
last_file_id = int(data_files[-1].split('.')[0])
self.active_file_id = last_file_id
# Rebuild index by iterating through all data files
for fname in data_files:
file_id = int(fname.split('.')[0])
file_path = self._get_file_path(file_id)
# For read-only files, keep handles open for efficient reads
if file_id < self.active_file_id:
self.file_handles[file_id] = open(file_path, "rb")
else: # The active file
self.active_file = open(file_path, "ab+") # Open for append and read
self.active_file_offset = os.fstat(self.active_file.fileno()).st_size
current_file = self.active_file if file_id == self.active_file_id else self.file_handles[file_id]
current_offset = 0
while True:
header_bytes = current_file.read(HEADER_SIZE)
if not header_bytes: # EOF
break
try:
crc, timestamp, key_size, value_size = struct.unpack(HEADER_FORMAT, header_bytes)
except struct.error:
# Malformed header, potentially truncated file
# In a real system, robust error handling and recovery would be needed
print(f"Warning: Malformed header in {fname} at offset {current_offset}. Stopping read for this file.")
break
# Read key and value
key_and_value_size = key_size + value_size
key_value_bytes = current_file.read(key_and_value_size)
if len(key_value_bytes) < key_and_value_size:
# Truncated entry
print(f"Warning: Truncated entry in {fname} at offset {current_offset}. Stopping read for this file.")
break
key = key_value_bytes[:key_size]
# Update index with the latest entry for this key
self.key_directory[key] = ValuePos(
file_id=file_id,
offset=current_offset + HEADER_SIZE + key_size, # Value starts after header and key
size=value_size,
timestamp=timestamp
)
current_offset += HEADER_SIZE + key_size + value_size
print(f"Key directory loaded with {len(self.key_directory)} entries.")
2.4 读取操作 (Get)
当客户端执行 Get(key) 操作时:
- 内存查找:首先在内存中的键目录中查找
key。 - 获取位置:如果找到,将获得一个
ValuePos对象,其中包含file_id、offset和size。 - 磁盘读取:根据
file_id打开对应的(或已打开的)数据文件,然后seek到offset位置,读取size字节的数据。这个数据就是我们所需的值。 - 返回:将读取到的值返回给客户端。
示例代码:Get操作
def get(self, key: bytes) -> bytes | None:
value_pos = self.key_directory.get(key)
if not value_pos:
return None
file_id = value_pos.file_id
offset = value_pos.offset
size = value_pos.size
# Get the correct file handle
if file_id == self.active_file_id and self.active_file:
target_file = self.active_file
elif file_id in self.file_handles:
target_file = self.file_handles[file_id]
else:
# File might have been closed or not loaded (e.g., after compaction)
# In a real system, you might reopen it or handle this more gracefully
print(f"Error: File handle for file_id {file_id} not found.")
return None
# Seek and read
# Note: Using seek and read directly can be slow if not buffered.
# For high performance, mmap or buffered I/O is preferred.
current_pos = target_file.tell() # Save current position
target_file.seek(offset)
value = target_file.read(size)
target_file.seek(current_pos) # Restore position (important for active file)
if len(value) != size:
# This indicates a read error or truncated file
print(f"Warning: Read {len(value)} bytes, expected {size} for key {key}. Data corruption?")
return None
return value
def close(self):
if self.active_file:
self.active_file.close()
for fh in self.file_handles.values():
fh.close()
self.key_directory.clear()
self.file_handles.clear()
print("Bitcask store closed.")
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
# Usage example
if __name__ == "__main__":
store_path = "./my_bitcask_store"
# Clean up previous store for fresh start
import shutil
if os.path.exists(store_path):
shutil.rmtree(store_path)
with BitcaskStore(store_path, max_file_size=1024 * 5) as store: # Small max_file_size for quick file rotation
print("--- Initializing and Writing ---")
store.put(b"name", b"Alice")
store.put(b"age", b"30")
store.put(b"city", b"New York")
store.put(b"occupation", b"Engineer")
store.put(b"name", b"Bob") # Overwrite name
store.put(b"email", b"[email protected]")
store.put(b"country", b"USA")
print("n--- Reading ---")
print(f"Name: {store.get(b'name').decode()}") # Should be Bob
print(f"Age: {store.get(b'age').decode()}")
print(f"City: {store.get(b'city').decode()}")
print(f"Email: {store.get(b'email').decode()}")
print(f"Non-existent key 'gender': {store.get(b'gender')}")
print("n--- Current Key Directory (simplified) ---")
for k, v_pos in store.key_directory.items():
print(f"Key: {k.decode()} -> File: {v_pos.file_id}, Offset: {v_pos.offset}, Size: {v_pos.size}")
# Reopen the store to demonstrate index recovery
print("n--- Reopening store to demonstrate index recovery ---")
with BitcaskStore(store_path, max_file_size=1024 * 5) as reopened_store:
print(f"Name after reopen: {reopened_store.get(b'name').decode()}")
print(f"Age after reopen: {reopened_store.get(b'age').decode()}")
print(f"Email after reopen: {reopened_store.get(b'email').decode()}")
2.5 删除操作 (Delete)
Bitcask的删除操作同样是追加写入的。当客户端执行 Delete(key) 操作时:
- 写入墓碑:Bitcask会在当前活跃文件中写入一个特殊的“墓碑”(tombstone)条目。这个条目的键是待删除的键,但值通常为空或有一个特殊的标记来指示这是一个删除操作。
- 更新索引:从内存键目录中移除该键。
仅仅写入墓碑并移除索引并不能立即回收磁盘空间。实际的磁盘空间回收需要通过后续的合并(Compaction)过程来完成。
2.6 崩溃恢复 (Crash Recovery)
Bitcask的崩溃恢复机制非常简单:
- 当系统启动时,它会遍历所有的数据文件(从最早到最新)。
- 对于每个文件中的每个条目,它会读取键、时间戳、偏移量和大小。
- 如果遇到一个键,它会用最新的条目信息(根据时间戳和文件ID判断)更新内存中的键目录。如果遇到墓碑,则从键目录中移除该键。
这个过程确保了内存中的键目录总是反映磁盘上数据的最新状态。由于数据文件是仅追加的,这个过程是幂等的且相对快速。
3. Bitcask的优势:为什么是高频KV库的选择?
现在我们来回答核心问题:为什么这种看似简单的“仅追加日志 + 内存索引”模型,会成为许多高频KV库的首选?其优势主要体现在以下几个方面:
3.1 极高的写入吞吐量
- 纯顺序写入:所有写入操作都是对当前活跃数据文件的顺序追加。磁盘的物理特性决定了顺序写入的性能远高于随机写入。磁头无需频繁寻道,只需持续移动,这使得磁盘能够以接近其理论带宽的速度进行写入。
- 最小的写入放大:除了数据本身,Bitcask只写入一个小的头部信息。相较于B-树等需要频繁修改内部节点、导致大量随机I/O和页面分裂的数据结构,Bitcask的写入放大非常小。
- 并发写入友好:虽然单个文件在写入时可能需要锁定,但Bitcask可以通过维护多个活跃文件或更细粒度的锁来支持并发写入。例如,可以有多个写线程各自拥有一个文件句柄,向不同的活跃文件追加写入。
3.2 极低的读取延迟和高读取吞吐量
- 内存索引,O(1)查找:键目录完全驻留在内存中,这意味着对键的查找是内存级别的操作,速度极快(平均O(1))。
- 单次磁盘寻道:一旦在内存中找到键的位置,读取操作只需要一次磁盘寻道(如果文件尚未在文件系统缓存中)和一次顺序读取。这消除了B-树等结构中多级索引查找可能导致的多次随机磁盘I/O。
- 文件系统缓存友好:由于数据文件是顺序写入和顺序读取的,操作系统文件系统缓存能很好地预读和缓存热点数据,进一步提高读取性能。
3.3 优秀的崩溃恢复能力
- 简单且快速:如前所述,崩溃恢复仅涉及遍历数据文件并重建内存索引。由于数据文件是不可变的(一旦写入完成),这个过程非常健壮且易于理解和实现。不需要复杂的日志回滚或数据结构校验。
- 数据一致性:Bitcask的日志结构天然保证了数据的一致性。即使在崩溃发生时,磁盘上的文件也只包含完整的或部分写入的条目,不会出现中间状态。未完成的条目在索引重建时会被忽略。
3.4 易于理解和实现
- 设计简洁:Bitcask的核心思想非常直观:追加数据,内存索引。这使得它的实现代码量相对较小,易于理解和维护。
- 减少复杂性:避免了B-树、LSM-树等复杂的平衡、分裂、合并逻辑,从而减少了潜在的bug和性能调优的复杂性。
3.5 预测性性能
- I/O模式可预测:写入总是顺序的,读取在命中内存索引后也是单次寻道和顺序读取。这种可预测的I/O模式使得Bitcask在各种负载下都能提供相对稳定的性能,避免了B-树在重度写入下可能出现的性能抖动(如树的频繁重平衡)。
- 没有随机写入:随机写入是磁盘性能杀手。Bitcask完全避免了随机写入,这是其高性能的基石。
4. Bitcask的挑战与局限性
尽管Bitcask在特定场景下表现出色,但它并非没有缺点。理解这些局限性对于正确选择存储模型至关重要。
4.1 内存使用限制
- 索引必须驻留内存:这是Bitcask最显著的限制。键目录必须完全加载到内存中。这意味着它不适合键数量极其庞大(例如,数十亿甚至数万亿个键)或键本身非常大的场景,因为内存成本会变得不可接受。
- 值大小的影响:虽然键目录只存储键和值的元数据,但如果键本身很大,也会占用大量内存。
4.2 磁盘空间管理:合并(Compaction)的必要性
由于数据是仅追加写入的,旧的值不会被原地更新,而是写入新的条目。删除操作也只是写入墓碑。这意味着:
- 存在大量过时数据:磁盘上会累积大量无效(已被新值覆盖或已删除)的数据。
- 空间无法自动回收:不进行处理,数据文件会无限增长,最终耗尽磁盘空间。
为了解决这个问题,Bitcask引入了合并(Compaction)机制:
- 选择待合并文件:Bitcask会定期选择一组旧的、不再活跃的数据文件进行合并。选择标准通常基于文件中的无效数据比例(垃圾数据比例)。
- 遍历并重写有效数据:合并过程会遍历这些选定的文件,对于每个键值对条目:
- 检查内存中的键目录:如果该条目对应的键在键目录中仍然存在,并且该条目是该键的最新版本(通过文件ID和offset判断),则说明它是有效数据。
- 写入新文件:将这些有效数据条目顺序写入到一组新的数据文件(称为“提示文件”或“合并文件”)中。
- 更新索引:在写入新文件时,会同步更新内存中的键目录,将这些键的
ValuePos更新为新文件中的位置。 - 如果条目对应的键在键目录中不存在(已被删除)或不是最新版本(已被覆盖),则该条目被视为垃圾数据,不会被写入新文件。
- 删除旧文件:一旦所有有效数据都被重写到新文件,并且键目录更新完成,就可以安全地删除旧的(被合并的)数据文件,从而回收磁盘空间。
合并的成本:合并是一个I/O密集型操作,需要读取旧文件并写入新文件。它会消耗I/O带宽和CPU资源。因此,合并策略需要精心设计,以避免对在线服务造成过大影响。合并通常在后台异步进行。
4.3 不支持高效的范围查询
Bitcask的键目录是哈希表,它天然不支持范围查询(例如,“查找所有以‘user_’开头的键”或“查找所有在某个数字范围内的键”)。哈希表只能进行精确键查找。如果应用需要范围查询,Bitcask不是一个合适的选择,或者需要额外构建辅助索引(例如,基于B-树或跳表的索引)。
4.4 无法处理超大值
虽然Bitcask可以存储任意大小的值,但如果值非常大(例如,几GB),每次读取都需要从磁盘加载整个值。这会占用大量的I/O带宽和内存,并可能导致高延迟。对于超大值,通常会考虑将其存储在对象存储中,而Bitcask只存储指向这些对象的引用。
4.5 潜在的写放大 (Compaction)
尽管常规写入的写放大很低,但合并操作会引入写放大。当旧文件被合并时,有效数据会被重新写入新文件。如果系统中存在大量过期或删除的数据,合并操作可能需要重写大部分数据,导致较高的写放大。
5. 与其他存储模型的简要对比
为了更好地理解Bitcask的定位,我们将其与两种常见的存储模型进行简要对比:
| 特性 | Bitcask | B-树 | LSM-树 |
|---|---|---|---|
| 数据组织 | 仅追加日志文件 | 页式结构,平衡树 | 分层结构,内存MemTable + 磁盘SSTables |
| 写入模式 | 纯顺序写入 | 随机写入(节点更新) | 顺序写入(MemTable刷盘为SSTable) |
| 读取模式 | 内存索引 + 单次磁盘寻道 | 多次随机磁盘寻道(索引节点) | 多层查找(MemTable, SSTables),可能多次磁盘寻道 |
| 内存需求 | 键目录必须完全驻内存(高) | 索引节点部分驻内存(中) | MemTable驻内存(中) |
| 空间回收 | 合并(Compaction)回收废弃空间 | 块回收(Block Reclaim),有时有碎片 | 合并(Compaction)回收废弃空间和版本 |
| 范围查询 | 不支持(需额外索引) | 高效支持 | 较好支持(SSTable有序) |
| 写放大 | 低(常规写入),中高(合并时) | 中高(页面分裂,平衡) | 中高(多层合并) |
| 复杂性 | 简单 | 中等复杂 | 高 |
| 典型应用 | 高吞吐量KV存储,日志存储,索引存储 | 关系型数据库,通用KV存储 | 高写入吞吐量KV存储,时间序列数据库 |
Bitcask在纯顺序写入和单次磁盘寻道读取方面表现卓越,但其内存索引的限制和缺乏范围查询能力是需要注意的。LSM-树通过分层合并解决了B-树的随机写入问题,但代价是读取可能需要查找多个SSTable,且合并的复杂性和写放大通常高于Bitcask。
6. 实际应用与相似概念
Bitcask模型在工业界有广泛的应用和影响:
- Riak KV:这是Bitcask最著名的应用,Riak正是通过Bitcask作为其本地存储引擎,实现了高可用、高吞吐的分布式键值存储。
- Redis AOF (Append Only File):Redis的AOF持久化机制与Bitcask有异曲同工之妙,都是将操作日志顺序追加写入文件,用于恢复数据。虽然AOF不是一个完整的KV存储引擎,但其“仅追加”的设计思想是相似的。
- LevelDB/RocksDB (LSM-tree):尽管LSM-树比Bitcask更复杂,但其最底层的SSTable文件也是不可变的、按键有序的日志文件。新的数据总是写入新的MemTable,然后刷盘为新的SSTable,这体现了“数据不可变,追加写入”的核心思想。可以说,Bitcask是这种思想的一个更纯粹、更简化的实现。
- Kafka Logs:Kafka的分布式日志系统也是基于顺序追加写入日志文件的原理,以实现高吞吐量和持久性。
7. 总结
Bitcask存储模型以其“仅追加日志 + 内存索引”的简洁设计,在高频键值存储领域中独树一帜。它通过最大化磁盘的顺序I/O性能,并利用内存的极速查找能力,为特定工作负载提供了卓越的写入和读取吞吐量、低延迟以及鲁棒的崩溃恢复能力。
然而,这种模型并非万能药。它在内存使用、空间回收(合并的复杂性)以及范围查询方面存在明显的局限性。因此,在选择Bitcask或类似模型时,必须仔细评估应用的工作负载特性,确保其优势能够充分发挥,而其局限性不会成为瓶颈。理解这些权衡是构建高性能、可扩展存储系统的关键。