PCIe Gen 5 存储阵列优化:克服障碍以实现性能最大化

PCIe Gen 5 存储阵列优化:克服障碍以实现性能最大化

正如 NVMe 协议初次问世时一样,支持 PCIe Gen5 接口的服务器平台和存储设备的出现也带来了一层全新的复杂性和挑战。存储能力以如此惊人的速度增长,以至于当多个设备同时处于负载状态时,很难达到预期的性能水平------尤其是当这些设备组成阵列时。

对于日常专业用户来说,一两个存储设备可能就足够了,其指标可能高达 270 万 IOps 和 14GBps。然而,对于可能采用 8、12、16 甚至 24 个存储设备的企业和云服务提供商来说,他们的存储子系统存在着无法超越上一代硬件性能的风险。

那么,如何防止这种性能陷阱呢?关键在于精心设置服务器和软件,并有效利用硬件的能力。下面,我们基于亲身经验为您揭开这一谜团。

测试环境

我们的服务器平台由 Ingrasys 提供,搭载来自 KIOXIA 的 12 个存储设备。

设备规格

  • CPU: 双路 Intel® Xeon® Gold 6430
  • 内存: 2TB (DDR5 4800 64GB×32)
  • 操作系统: Oracle Linux 8.8 (内核 4.18.0-477)

系统拓扑

scss 复制代码
[root@localhost ~]# lstopo-no-graphics 
Machine (2016GB total)
  Package L#0
    NUMANode L#0 (P#0 1008GB)
    L3 L#0 (60MB)
      L2 L#0 (2048KB) + L1d L#0 (48KB) + L1i L#0 (32KB) + Core L#0
        PU L#0 (P#0)
        PU L#1 (P#64)
      L2 L#1 (2048KB) + L1d L#1 (48KB) + L1i L#1 (32KB) + Core L#1
        PU L#2 (P#1)
        PU L#3 (P#65)
      ...
    HostBridge
      PCIBridge
        PCI 01:00.0 (NVMExp)
          Block(Disk) "nvme0n1"
      PCIBridge
        PCIBridge
          PCI 03:00.0 (VGA)
    HostBridge
      PCIBridge
        PCI 49:00.0 (NVMExp)
          Block(Disk) "nvme1c1n1"
      PCIBridge
        PCI 4a:00.0 (NVMExp)
          Block(Disk) "nvme2c2n1"
      PCIBridge
        PCI 4b:00.0 (NVMExp)
          Block(Disk) "nvme3c3n1"
      PCIBridge
        PCI 4c:00.0 (NVMExp)
          Block(Disk) "nvme4c4n1"
    HostBridge
      PCIBridge
        PCI 5a:00.0 (NVMExp)
          Block(Disk) "nvme5c5n1"
      PCIBridge
        PCI 5b:00.0 (NVMExp)
          Block(Disk) "nvme6c6n1"
  Package L#1
    NUMANode L#1 (P#1 1008GB)
    L3 L#1 (60MB)
      L2 L#32 (2048KB) + L1d L#32 (48KB) + L1i L#32 (32KB) + Core L#32
        PU L#64 (P#32)
        PU L#65 (P#96)
      ...
    HostBridge
      PCIBridge
        PCI c8:00.0 (NVMExp)
          Block(Disk) "nvme7c7n1"
      PCIBridge
        PCI c9:00.0 (NVMExp)
          Block(Disk) "nvme8c8n1"
    HostBridge
      PCIBridge
        PCI d8:00.0 (NVMExp)
          Block(Disk) "nvme9c9n1"
      PCIBridge
        PCI d9:00.0 (NVMExp)
          Block(Disk) "nvme10c10n1"
      PCIBridge
        PCI da:00.0 (NVMExp)
          Block(Disk) "nvme11c11n1"
      PCIBridge
        PCI db:00.0 (NVMExp)
          Block(Disk) "nvme12c12n1"

初始性能 -- 1000 万 IOps

在启动存储阵列后,我们遇到了一个令人惊讶的问题:IOps 无法超过 1000 万。虽然这比市面上大多数硬件阵列要快,但仍然低于我们的预期。

在运行任何测试之前,我们已经确保应用了所有推荐的系统设置。对单个存储设备的性能测量显示,其单独运行时表现相当不错。

测试 1. 使用 Bash 脚本进行扩展性测试

为了找出限制性能的瓶颈,我们决定测试系统在增加存储设备时扩展性能的能力。为此,我们选择了一段定制的 Bash 脚本,旨在最大化存储系统的能力。下面是我们使用的脚本:

bash 复制代码
#!/bin/bash
# 定义全局参数模板
GLOBAL_PARAMS="
[global]
direct=1               # 绕过页面缓存进行 I/O
bs=4k                  # 块大小
ioengine=libaio        # 使用的 I/O 引擎 (libaio 是 Linux 原生的异步 I/O)
numjobs=4              # 启动的线程/作业数
iodepth=128            # 文件操作时同时保持的 I/O 操作数量
rw=randread            # 随机读取
rwmixread=100          # 读取操作所占比例百分比
norandommap=1          # 不预先生成随机映射
gtod_reduce=1          # 减少 gettimeofday() 系统调用次数
group_reporting        # 将结果作为一组报告,而不是单独报告
randrepeat=0           # 禁用随机数种子重复
runtime=120            # 测试持续时间(秒)
exitall=1              # 当同一组中的任一作业完成时,fio 终止所有作业
"
# 定义各驱动器的配置模板
DRIVE_PARAMS="
[file%d]
filename=/dev/nvme%dn1             # NVMe 设备路径
numa_cpu_nodes=%d                 # NUMA CPU 节点
numa_mem_policy=bind:%d           # NUMA 内存策略
"
# 创建用于配置文件的临时目录
mkdir -p ./temp_configs
# 循环创建每个驱动器数量的配置文件
for i in {1..12}; do
    # 创建配置文件
    CONFIG_FILE="./temp_configs/config_$i.fio"
    echo "$GLOBAL_PARAMS" > "$CONFIG_FILE"
    # 追加所需的驱动器配置
    for j in {1..12}; do
        if [ $j -le 6 ]; then
            printf "$DRIVE_PARAMS" $j $j 0 0 >> "$CONFIG_FILE"
        else
            printf "$DRIVE_PARAMS" $j $j 1 1 >> "$CONFIG_FILE"
        fi
        # 如果已添加所需数量的驱动器,则退出循环
        if [ $j -eq $i ]; then
            break
        fi
    done
done
# 对每个配置运行 fio 并收集结果
for i in {1..12}; do
    CONFIG_FILE="./temp_configs/config_$i.fio"
    OUTPUT_FILE="result_$i.json"
    fio "$CONFIG_FILE" --output-format=json --output="$OUTPUT_FILE"
done
# 清理
rm -r ./temp_configs

这段 Bash 脚本使用 fio 基准测试工具进行存储性能测试。脚本测试了多个驱动器在不同配置下的性能,并生成了性能指标。下面是脚本各部分的说明:

  • 全局参数: 首先定义了一组全局参数,这些参数在所有测试中保持一致,包括块大小(bs)、I/O 引擎(ioengine)、线程数(numjobs)等。
  • 驱动器特定参数: 接着定义了驱动器特定参数的模板,其中包含 NVMe 设备的路径(filename)和 NUMA 配置(numa_cpu_nodes 与 numa_mem_policy)。
  • 临时配置: 脚本创建了一个临时目录来保存为每个测试生成的 fio 配置文件。
  • 配置生成: 循环遍历 1 到 12 的数字,为每个数量生成一个配置文件,并在配置文件中追加相应数量的驱动器配置。
  • 基准测试: 使用生成的每个配置文件运行 fio 测试,并将结果保存为 JSON 格式。
  • 清理: 最后,脚本删除了包含配置文件的临时目录。

扩展测试结果

当我们同时使用 12 个存储设备时,性能降到了预期的一半以下。

对此我们感到担忧,于是尝试更新操作系统内核,甚至切换到不同的 Linux 发行版。尽管做出了这些调整,系统性能仍然没有改善。那么,隐藏的瓶颈究竟是什么?是不是系统内部存在缺陷?

测试 2. io_uring ------ 异步 I/O 操作接口

在寻找突破性能瓶颈的方法时,我们决定测试 io_uring 接口。

io_uring 是 Linux I/O 领域中一颗冉冉升起的新星。它在 Linux 5.1 中引入,旨在彻底改变异步 I/O 操作的处理方式。与旧的接口不同,io_uring 允许提交和完成 I/O 操作时无需进行系统调用,从而减少了系统调用带来的开销,提高了性能。

io_uring 的不同之处 在传统的如 libaio 设置中,每个 I/O 操作都需要进行一次系统调用。当你需要每秒处理数百万次 I/O 操作时,这些系统调用会成为性能负担。而 io_uring 则通过允许批量处理 I/O 请求和完成操作,能在一次操作中处理多个任务,从而优化性能。

首先,我们在内核中启用了 NVMe 轮询支持:

bash 复制代码
echo "options nvme poll_queues=4" >> /etc/modprobe.d/nvme.conf
dracut -f
reboot

针对 io_uring 调整脚本

我们的新脚本采用了 io_uring,这是一种现代且灵活的 I/O 处理方式,承诺提供高性能。以下是调整后的脚本:

bash 复制代码
#!/bin/bash

# 定义全局参数模板
GLOBAL_PARAMS="
[global]
direct=1               # 绕过页面缓存进行 I/O
bs=4k                  # 块大小
ioengine=io_uring      # 使用的 I/O 引擎 (相较于 libaio)
fixedbufs=1            # 如果 fio 执行直接 I/O,Linux 会为每个 I/O 调用映射页面,完成后释放它们。此选项设置后,将在 I/O 开始前预先映射页面。
registerfiles=1        # 使用此选项,fio 将向内核注册使用的文件集合,以提升性能。
sqthread_poll=1        # 此选项专为 Linux 中的 io_uring 引擎设计,指示 fio 将完成事件的轮询工作交由专用内核线程处理。虽然这可能在某些情况下提高性能,但实际影响取决于具体的工作负载和系统配置。
numjobs=4              # 启动的线程/作业数
iodepth=128            # 同时保持的 I/O 操作数
rw=randread            # 随机读取
rwmixread=100          # 读取操作比例百分比
norandommap=1          # 不预先生成随机映射
gtod_reduce=1          # 减少 gettimeofday() 系统调用
group_reporting        # 以组形式报告结果
randrepeat=0           # 禁用随机数种子重复
runtime=120            # 测试持续时间(秒)
exitall=1              # 同组中任一作业完成时,fio 终止所有作业
"

# 定义各驱动器的配置模板
DRIVE_PARAMS="
[file%d]
filename=/dev/nvme%dn1             # NVMe 设备路径
numa_cpu_nodes=%d                 # NUMA CPU 节点
numa_mem_policy=bind:%d           # NUMA 内存策略
"

# 创建用于配置文件的临时目录
mkdir -p ./temp_configs

# 循环创建每个驱动器数量的配置文件
for i in {1..12}; do
    # 创建配置文件
    CONFIG_FILE="./temp_configs/config_$i.fio"
    echo "$GLOBAL_PARAMS" > "$CONFIG_FILE"

    # 追加所需的驱动器配置
    for j in {1..12}; do
        if [ $j -le 6 ]; then
            printf "$DRIVE_PARAMS" $j $j 0 0 >> "$CONFIG_FILE"
        else
            printf "$DRIVE_PARAMS" $j $j 1 1 >> "$CONFIG_FILE"
        fi

        # 如果已添加所需数量的驱动器,则退出循环
        if [ $j -eq $i ]; then
            break
        fi
    done
done

# 对每个配置运行 fio 并收集结果
for i in {1..12}; do
    CONFIG_FILE="./temp_configs/config_$i.fio"
    OUTPUT_FILE="result_$i.json"
    fio "$CONFIG_FILE" --output-format=json --output="$OUTPUT_FILE"
done

# 清理
rm -r ./temp_configs

第二个脚本与第一个非常相似,但主要区别在于全局参数的设置和 I/O 引擎的选择:

  1. I/O 引擎: 第一个脚本使用的是 libaio(Linux 原生异步 I/O),而第二个脚本选择了 io_uring,这是一种更现代的选择,特别适合异步 I/O 操作。
  2. 针对 io_uring 的额外标志:
    • fixedbufs=1:在进行直接 I/O 时预先映射页面。
    • registerfiles=1:向内核注册使用的文件集合以提升性能。
    • sqthread_poll=1:将完成事件的轮询工作交由专用内核线程处理。

io_uring 测试结果

不幸的是,当驱动器数量较多时,性能实际上出现了下降。这是我们未曾预料到的挫折。

存储阵列的性能仍然未达到预期。

测试 3. NVMe 轮询模式

就在我们以为接近解决方案时,系统给我们出了个难题。在设置了 hipri=1 选项以利用 NVMe 的轮询 I/O 模式(该功能旨在最小化 I/O 完成延迟)后,我们遇到了意想不到的错误。我们的 fio 测试中不断出现 "Operation not supported" 错误:

ini 复制代码
[root@localhost ~]# ./1by1P.sh 
fio: io_u error on file /dev/nvme1n1: Operation not supported: read offset=1418861584384, buflen=4096
fio: io_u error on file /dev/nvme2n1: Operation not supported: read offset=1745590550528, buflen=4096
...

这让人感到困惑,因为这些操作对于 NVMe 驱动器来说本应不成问题。

为了解决这个问题,我们检查了 NVMe 设备的 io_poll 参数:

csharp 复制代码
[root@localhost ~]# cat /sys/block/nvme1n1/queue/io_poll
0

这表明设备 nvme1n1 没有启用 I/O 轮询功能。更令人沮丧的是,系统拒绝我们更改此参数:

perl 复制代码
[root@localhost ~]# echo 1 > /sys/block/nvme1n1/queue/io_poll
-bash: echo: write error: Invalid argument

经过数小时的调试,我们发现了一个奇怪的情况:我们可以通过 NVMe 的本地多路径配置中一个隐藏路径来更改 io_poll 设置:

echo 1 > /sys/block/nvme1c1n1/queue/io_poll

奇怪的是,这一更改似乎没有传递到主要的块设备上。于是,我们大胆地采取了以下措施:

我们完全禁用了 NVMe 原生多路径。为此,我们在启动参数中添加了 nvme-core.multipath=N。重启后,错误信息消失,设备开始响应配置更改。

改进后的测试结果

在获得对 NVMe 设置的全新控制后,我们迫不及待地查看了新的性能图表。结果非常显著,性能指标大幅提升,图表呈现出近乎线性的趋势。

系统微调

接下来,我们决定进一步挑战极限。我们通过设置 nvme poll_queues=24 并应用一些其他未详述的调优措施来增加轮询队列数量。

虽然设备级别的性能图表已经令人印象深刻,但整个存储阵列的总体性能却没有改善!

此外,需要注意的是,这些设置会导致非常高的 CPU 资源消耗以换取高性能。

我们当然对结果不满足,因为我们的目标不仅是让单个驱动器达到高性能,而且在奇偶校验阵列中也能实现这种性能。我们的关键目标始终如一:达到基础性能的 90-95%。接下来,我们将解释如何解决这一难题。

测试 4. SPDK

获得新见解后,我们决定探索另一条提升性能的途径:SPDK(存储性能开发工具包)。

我们使用如下命令启动了 SPDK 设置:

ini 复制代码
HUGEMEM=16384 HUGE_EVEN_ALLOC=yes  /root/spdk/scripts/setup.sh 

接着,我们编写了一个特定的脚本:

bash 复制代码
#!/bin/bash
# 基础 fio 配置文件
base_fio_config="spdk_base.cfg"
# 输出目录,用于存储 JSON 格式的结果
output_dir="fio_results"
# 如果输出目录不存在,则创建它
mkdir -p $output_dir
# 循环 1 到 12,逐一添加驱动器
for i in {1..12}; do
  # 生成一个临时 fio 配置文件
  temp_fio_config="spdk_temp_${i}.cfg"
  
  # 将基础 fio 配置复制到临时配置文件中
  cp $base_fio_config $temp_fio_config
  
  # 将驱动器部分追加到临时配置文件中
  for j in $(seq 1 $i); do
    echo "[test${j}]" >> $temp_fio_config
    echo "filename=Nvme${j}n1" >> $temp_fio_config
    
    # 根据驱动器索引分配正确的 NUMA 节点
    if [ $j -le 6 ]; then
      echo "numa_cpu_nodes=0" >> $temp_fio_config
      echo "numa_mem_policy=bind:0" >> $temp_fio_config
    else
      echo "numa_cpu_nodes=1" >> $temp_fio_config
      echo "numa_mem_policy=bind:1" >> $temp_fio_config
    fi
  done
  # 使用临时配置运行 fio,并将结果存储为 JSON
  LD_PRELOAD=/root/spdk/build/fio/spdk_bdev /root/fio/fio --output-format=json --output=$output_dir/result_${i}.json $temp_fio_config
  
  # 删除临时 fio 配置文件
  rm $temp_fio_config
done

以及一个旨在充分发挥 SPDK 能力的 fio 基础配置:

ini 复制代码
[global]
ioengine=spdk_bdev
bs=4k
spdk_json_conf=config.json
thread=1
group_reporting=1
direct=1
time_based=1
ramp_time=0
norandommap=1
rw=randread
iodepth=256
numjobs=2
gtod_reduce=1
runtime=120	
exitall

最终的性能图表非常出色,显示出完全线性的趋势。

在 SPDK 中实现 RAID

您可能还记得,我们团队有自己定制的 RAID 实现,专门针对 SPDK 设计。我们当然不会错过测试它的机会。

我们配置了两种不同的 RAID 设置:

  • 使用所有可用存储驱动器构成的单一阵列。
  • 将驱动器分布到不同 NUMA 节点上形成两个阵列。

接着,我们对这两种配置进行了测试。

第一个测试:

ini 复制代码
[global]
ioengine=spdk_bdev
bs=4k
spdk_json_conf=RAIDconfig.json
thread=1
group_reporting=1
direct=1
time_based=1
ramp_time=0
norandommap=1
rw=randread
iodepth=128
numjobs=32
runtime=120
randrepeat=0
gtod_reduce=1
[test]
filename=raid5

第二个测试:

ini 复制代码
[global]
ioengine=spdk_bdev
bs=4k
spdk_json_conf=RAIDconfig2.json
thread=1
group_reporting=1
direct=1
time_based=1
ramp_time=0
norandommap=1
rw=randread
iodepth=128
numjobs=16
runtime=120
randrepeat=0
gtod_reduce=1
[test]
numa_cpu_nodes=0
numa_mem_policy=bind:0
filename=raid5
[test2]
numa_cpu_nodes=1
numa_mem_policy=bind:1
filename=raid52

我们测试了 RAID 5 和 RAID 6,条带大小设为 64k,同时确保等待阵列初始化完成。在创建两个阵列时,我们确保每个阵列的所有驱动器都属于同一 NUMA 节点。

SPDK RAID (xiRAID) 测试结果

单阵列配置下,我们达到了 2940 万 IOps;双阵列配置下达到了 3060 万 IOps。同时,RAID 5 与 RAID 6 的性能并无显著差异。

这比最初在未经调优系统上的测试结果提高了 3 倍,即使按我们的高标准来看,这也是一个非凡的成就。

测试 5. libaio 与内核空间中中断合并

征服了用户空间后,我们将注意力重新转向内核空间。是否能在坚持使用标准 libaio 库的情况下实现卓越性能?

此前针对中断合并的调优(在上一代硬件上效果不明显)在我们的新 PCIe 5 驱动器上成为了一个改变游戏规则的关键。对不熟悉该概念的人来说,中断合并将多个中断组合在一起以减少总体频率,从而在 CPU 使用和 I/O 延迟之间取得平衡。

中断合并的具体设置取决于您的工作负载和设备规格。其核心思想是将多个中断合并以减少总数。值设定过高可能会延迟 I/O,从而降低性能,而设定过低则可能导致 CPU 使用率过高。

例如,如果您想将聚合时间设置为 50µs,聚合阈值设置为 10,可以使用如下命令:

nvme set-feature /dev/nvme0n1 -f 0x08 -v 0x320a

中断合并结果

性能图表显示的结果非常有趣。虽然未达到 SPDK 的水平,但与使用轮询的 io_uring 相当。

内核空间中的阵列性能

在应用了新的设置后,我们设计了 fio 配置文件来测试我们的 RAID 阵列。

与 SPDK 测试类似,我们创建了一个或两个 RAID 5 和 RAID 6 阵列。当创建两个阵列时,我们将它们分布在不同的 NUMA 节点上。

阵列 1:
ini 复制代码
[global]
bs=4k
direct=1
iodepth=128
numjobs=128
ioengine=libaio
rw=randread
gtod_reduce=1
group_reporting=1
norandommap=1
randrepeat=0
random_generator=tausworthe64
[file]
filename=/dev/xi_verec
阵列 2:
ini 复制代码
[global]
bs=4k
direct=1
iodepth=128
numjobs=64
ioengine=libaio
rw=randread
gtod_reduce=1
group_reporting=1
norandommap=1
randrepeat=0
random_generator=tausworthe64
[file]
filename=/dev/xi_verec1
numa_cpu_nodes=0
numa_mem_policy=bind:0
[file2]
filename=/dev/xi_verec2
numa_cpu_nodes=1
numa_mem_policy=bind:1

测试结果

单阵列配置下,性能达到 2100 万 IOps。 两个分别位于不同 NUMA 节点的阵列,性能达到了 2420 万 IOps。 与 SPDK 的 RAID 测试类似,我们没有看到 RAID 5 和 RAID 6 之间有显著的性能差异。

总结

我们的存储性能探索之旅可谓非同寻常。通过精心调优中断合并,我们在内核空间中成功将 RAID 阵列的性能提高了一倍以上;而在用户空间中借助 SPDK,我们实现了三倍的性能提升。无论是内核空间还是用户空间,都为性能优化提供了广阔的可能。

下图展示了在不同 I/O 引擎和设置下,12 个驱动器的原始驱动器性能差异。在这种配置下,我们的测试结果实现了性能翻倍。

下图显示了在各种系统设置下 RAID 5 和 RAID 6 的性能表现。

最终,我们在用户空间中超过了 3000 万 IOps,而在操作系统内核空间中接近了 2500 万 IOps。这是我们研究工作的一个优秀结论,对于一个功能完善的阵列来说,这一成果堪称创纪录。

我们还附上了 mdraid 在基础设置下 RAID 5 和 RAID 6 性能的对比数据。我们将在下一篇文章中提供更详细的比较。

关键建议

根据我们的经验,以下是对那些希望优化存储性能的用户的关键建议:

  1. 禁用原生多路径: 如果不需要,请禁用以避免潜在的瓶颈或问题。
  2. 选择轮询或中断合并: 根据您的工作负载和硬件能力进行选择,两者都能显著影响性能。
  3. 关键设置: 请务必对以下参数进行精细调优:
    • nr_requests:可同时进行的 I/O 请求数量。
    • 轮询模式: 选择经典轮询或混合轮询。
    • 混合轮询: 如果工作负载允许,混合轮询可在低延迟和降低 CPU 使用之间取得最佳平衡。
  4. 拥抱 SPDK: 如果您的场景允许在用户空间运行,SPDK 能够提供极大的性能提升。
  5. 选择进阶 RAID 方案: 我们自研的 xiRAID 实现是市场上最先进的解决方案之一,提供了最前沿的性能和功能。

在使用 xiRAID 时,使用中断合并至关重要,它可以在性能和 CPU 负载之间创造出最佳平衡。

敬请期待

请继续关注我们的下一篇文章,在那篇文章中我们将对调优系统下的两种 RAID 阵列实现进行详细比较。

通过遵循这些指南并理解不同系统组件之间的相互作用,我们希望您也能在您的部署中实现突破性的存储性能。

博客文章来源:xinnor.io/blog/pcie-g...

相关推荐
dtzly3 小时前
0x04.若依框架微服务开发(含AI模块运行)
微服务·云原生·架构
AliPaPa3 小时前
🫵🏻回答我!什么是 SWC!look my eyes!tell me why so fast? why baby why?
前端·webpack·架构
山海不说话3 小时前
从零搭建微服务项目Pro(第6-1章——Spring Security+JWT实现用户鉴权访问与token刷新)
spring boot·后端·spring·spring cloud·微服务·架构
桂月二二4 小时前
云原生服务网格:微服务通信的神经中枢革命
微服务·云原生·架构
Goboy5 小时前
从零到一,实现图像识别实践教学
后端·程序员·架构
Hacker_LaoYi8 小时前
网络安全与七层架构
web安全·架构·php
拾忆,想起11 小时前
Nacos命名空间Namespace:微服务多环境管理的“秘密武器”如何用?
java·运维·spring boot·spring cloud·微服务·架构
爱学习的张哥16 小时前
UDP协议栈之整体架构处理
单片机·架构·udp
啾啾Fun17 小时前
[微服务设计]3_如何构建服务
运维·微服务·架构