存储系统知识全景:从一块磁盘到分布式块存储

最近在看 AutoMQ 的源码,无意间被 WALUtil.isBlockDevice() 这个小函数勾起了一连串疑问:

  • 它怎么判断一个路径是不是"块设备"?
  • POSIX 是块存储的 API 吗?
  • 一块挂载的盘,能不能既给文件系统用,又给程序当裸块用?
  • 数据库说的"DIO"到底好在哪?什么时候反而该用 buffered IO?
  • JuiceFS、CubeFS、Ceph RBD 看起来都"分布式"、都能"挂载",差别到底在哪?

这些问题串起来其实是同一个主题------存储栈是怎么分层的。本文把这条主线从硬件一路讲到分布式存储,希望读完后你脑子里能有一张完整的全景图。


一、最底层:物理盘只认 LBA

很多人脑子里的画面是 "磁盘 = 文件夹和文件"。但这是软件给你的幻觉。真实的硬件根本不知道什么是文件

物理磁盘(HDD / SSD / NVMe)只能听懂三种话:

  • 请读 LBA=12345 处开始的 8 个扇区
  • 请写 LBA=67890 处的这堆字节
  • 请把这片区域擦除/discard 掉

LBA = Logical Block Address,逻辑块地址。把整块盘看成一个超大数组,每格 512B 或 4KB(一个"扇区/物理块")。所有上层概念------目录、文件、表、索引------最终都要被翻译成"对哪几个 LBA 做读写"。

这就是"块设备"在硬件层面的本质:按编号读写定长块


二、操作系统的两种设备抽象

Linux 把所有 IO 资源统一抽象成 /dev/ 下的特殊文件,分成两大流派:

|-----------------|---------------|----------------------------------------------------|------------|
| 抽象 | 访问方式 | 代表设备 | 适合 |
| 块设备 (block) | 按"块"随机寻址,有缓冲层 | /dev/sda/dev/nvme0n1/dev/loop0/dev/rbd0 | 存储 |
| 字符设备 (char) | 按字节流顺序读写,无缓冲 | /dev/tty/dev/random/dev/sg0 | 流式 IO、控制接口 |

一块 NVMe 盘同时会以两种节点暴露:

  • /dev/nvme0n1 → 块设备(普通 IO 走这里)
  • /dev/nvme0 → 字符设备(管理命令走这里,比如 nvme listnvme smart-log

NVMe 设备的命名规则:

  • nvme0 → 第 0 块 NVMe 控制器
  • n1 → 该控制器上的第 1 个 namespace(类比 SATA 的整块盘)
  • 分区:nvme0n1p1nvme0n1p2
  • 经过 LVM:/dev/mapper/VolGroup00-LogVol03(device-mapper 出来的虚拟块设备)

怎么验证一个路径是不是块设备

复制代码
ls -l /dev/nvme1n1
# brw-rw---- 1 root disk 259, 0 ... /dev/nvme1n1
#  ^ 这个 "b" 表示 block device

stat -c '%F' /dev/nvme1n1
# block special file

test -b /dev/nvme1n1 && echo yes || echo no

file /dev/nvme1n1
# /dev/nvme1n1: block special (...)

lsblk -f          # 显示每个块设备 + 上面的 fs + 挂载点

代码里的等价物(AutoMQ 的实现)就是 POSIX stat(2) 系统调用:

复制代码
posix.stat(path).isBlockDev();   // 检查 st_mode 是不是 S_IFBLK

这里顺便澄清一个常见误区:POSIX 不是块存储 API。 POSIX 是 IEEE 制定的可移植操作系统接口规范,定义的是文件、进程、线程、IO 等系统调用层接口(open/read/write/stat/fsync/mmap)。它既不专属于块存储,也不限于块存储 ------同样一套 open + read/write 既能用在块设备上,也能用在普通文件、socket、pipe 上。块存储是"存储的形态",POSIX 是"访问的接口",是两个维度的概念。


三、块设备和文件系统的关系

物理盘 → 块设备 → 文件系统 → 挂载点

这是核心的一张分层图:

复制代码
┌─────────────────────────────────────────────────────┐
│  应用 (open/read/write/...,POSIX API)               │
├─────────────────────────────────────────────────────┤
│  VFS  (统一抽象层)                                    │
├─────────────────────────────────────────────────────┤
│  文件系统  ext4 / xfs / btrfs ...    ← mkfs 创建     │
│  (把"块"组织成 inode/目录/文件)                       │
├─────────────────────────────────────────────────────┤
│  Page Cache  (内核缓存)                              │
├─────────────────────────────────────────────────────┤
│  通用块层 + IO 调度器                                 │
├─────────────────────────────────────────────────────┤
│  块设备 /dev/nvme0n1  ← 只认偏移量+长度的"块"          │
├─────────────────────────────────────────────────────┤
│  设备驱动 (NVMe / SCSI / virtio-blk)                 │
├─────────────────────────────────────────────────────┤
│  物理介质 (SSD / HDD / EBS ...)                      │
└─────────────────────────────────────────────────────┘

几个关键认知:

  1. 块设备本身不知道"文件"是什么。 它只提供"在偏移 X 处读/写 N 字节"的能力,不懂目录、不懂文件名、不懂权限。
  2. 文件系统是"建在块设备之上"的一层组织结构。 mkfs.xfs /dev/nvme0n1 这一步,本质是在裸盘的某些块里写入超级块、inode 表、空闲位图等元数据。之后这些"块"才被赋予了语义。
  3. mount 是把文件系统接入 VFS 目录树。 你访问 /data2/foo.txt 就被翻译成"读 nvme0n1 第 X 个块"。
  4. 同一块设备只能有一个文件系统 (不分区情况下)。要分多个 fs,要么先分区(nvme0n1p1nvme0n1p2),要么用 LVM 切成多个逻辑卷(每个逻辑卷又是一个块设备)。

块设备的"层叠"能力

块设备是可以层层套娃的:

复制代码
物理盘 → md RAID → LVM → dm-crypt → 文件系统

每一层套出来的都仍然是块设备:

|----------|----------------------------------|
| 层 | 出来的虚拟块设备 |
| md RAID | /dev/md0 |
| LVM | /dev/mapper/vg-lv0/dev/dm-0 |
| dm-crypt | /dev/mapper/luks-xxxx |
| Ceph RBD | /dev/rbd0 |
| Loop | /dev/loop0 |

四、块设备能怎么用:5 种主流玩法

把磁盘当块设备拿到手以后,上层有 5 种主要用法,从最常见到最特殊:

A. 上面建文件系统(99% 服务器的用法)

复制代码
mkfs.xfs /dev/nvme0n1 && mount /dev/nvme0n1 /data

应用通过 POSIX 文件 API 用,简单通用。

B. 当裸块设备直接读写(数据库 / 中间件)

不在上面建 fs,直接 open("/dev/nvme0n1") + pread/pwrite + O_DIRECT,自己定义磁盘上的格式。

为什么这么用?

  • 绕开 page cache:避免 double cache
  • 绕开 fs journal:避免双重日志写放大
  • 控制 IO 对齐:保证 4KB 对齐写
  • 简化崩溃恢复:自己定义日志/checkpoint

典型例子:Oracle ASM、MySQL 的 RAW partition、AutoMQ 的 EBS WAL。

C. swap 分区

复制代码
mkswap /dev/sdb2 && swapon /dev/sdb2

不是文件系统也不是数据,而是内核虚拟内存子系统的换页空间。

D. 作为物理卷给 LVM / RAID / dm-* 包装

复制代码
pvcreate /dev/nvme0n1 && vgcreate vg0 /dev/nvme0n1
mdadm --create /dev/md0 --level=10 --raid-devices=4 /dev/nvme[0-3]n1

提供:在线扩容、快照、加密、缓存分层、精简配置等高级能力。

E. 当虚拟机/容器的磁盘

整盘 passthrough 给 KVM、或切成多个 LVM 卷给 K8s Pod 当 PV。

还有"绕开块层"的高级路径

  • SPDK:用户态 NVMe 驱动,单核 10M+ IOPS
  • io_uring:异步、批量、共享内存环
  • DAX + 持久内存:mmap 后 CPU 直接 load/store,完全没有块 IO
  • 网络协议远端访问 :iSCSI、NVMe-oF、NBD 把远端的盘"伪装"成本机的 /dev/sdX

五、一个高频踩坑点:能同时挂载和裸用同一个块设备吗?

结论:不能(不安全)。 这是会数据损坏的高危操作。

文件系统挂载之后,内核里有一系列基于"我独占这块设备"的假设:

|--------------|----------------------|
| 层 | 缓存的内容 |
| Page Cache | 文件系统某些块的内容(脏页 + 干净页) |
| Buffer Cache | inode、目录项等元数据块 |
| Journal/WAL | 文件系统自己的日志正在按顺序写 |
| Allocator | 内存里维护着块/inode 分配位图 |

一旦绕过文件系统直接写裸块设备:

  • 写冲突:你写的位置可能正好是元数据/日志/某个文件的数据块,文件系统不知道,下次刷脏页时把你的数据覆盖回去 → 数据丢失;或者文件系统读到自己被改过的元数据 → fs 损坏。
  • 读不一致:直接读裸设备读到的是磁盘上的旧值,因为脏页还没刷下去。

正确做法是先分区或分卷,把同一块物理盘切成两个独立的块设备:一个给文件系统挂载,一个给程序裸用。AutoMQ 在生产部署里就是这么做的------给 WAL 单独留一块盘或一个分区。


六、DIO vs Buffered IO:关键的工程取舍

理解了块设备和文件系统,下一个核心问题是:应用怎么读写文件?

主要有两种模式:buffered IO(经过 page cache)和 Direct IO(绕过 page cache)。

路径对比

Buffered IO(默认模式):

复制代码
read(fd, buf, n)
   ↓
VFS → 文件系统 → Page Cache
                    ↓
               (如果 miss) DMA 从磁盘加载
                    ↓
               拷贝到用户 buf

Direct IO( O_DIRECT):

复制代码
pread(fd, buf, n, off)   // buf 必须 4K 对齐,n 必须 4K 倍数
   ↓
VFS → 文件系统 (不经 page cache) → 块设备
   ↓
DMA 直接读到用户 buf

完整对比表

|-----------------|---------------------|---------------------------|
| 维度 | Buffered IO | Direct IO |
| 是否经过 page cache | ✅ | ❌ |
| 对齐要求 | 无 | buf 地址、长度、偏移都要按块对齐(一般 4K) |
| 内存占用 | 高(系统会缓存大量数据) | 低(不占 page cache) |
| 小 IO 性能 | 高(合并、预读、命中) | 低(每次都要打盘) |
| 大顺序 IO 吞吐 | 受限于 page cache 拷贝开销 | 高(少一次内存拷贝) |
| 多进程共享读 | 共享一份缓存 | 各自打盘 |
| 写持久化 | 需要 fsync | 写完即在路上(持久还得 fsync) |
| 延迟稳定性 | 抖动大(cache miss、回写) | 更稳定 |
| 编程复杂度 | 简单 | 高(对齐、自管缓存) |

何时用 DIO

  • 应用自己有缓存(DB buffer pool、block cache),page cache 是浪费
  • 需要可预测的低延迟
  • 需要严格的写顺序和持久性语义
  • 数据"写多读少"或"读不重复",page cache 没收益

典型代表:Oracle、MySQL InnoDB、PostgreSQL、Ceph BlueStore、AutoMQ WAL。

何时用 Buffered IO(即默认)

  • 工作集小、热点重读多
  • 多进程/多消费者读同一份数据
  • 应用自己没缓存
  • 不想处理对齐问题

典型代表:Kafka 故意不用 DIO!Kafka 的访问模式是:

  • 写:批量顺序写入 → page cache → 异步刷盘,吞吐爆炸
  • 读:consumer 拉的多半是"刚写的数据" → 还在 page cache 里 → sendfile 零拷贝
  • 大量消费者读同一份数据 → 共享 page cache,内存只占一份

一句话总结

DIO 不是"高性能"的代名词,它是一种取舍 :用更复杂的编程换可预测的延迟和更精确的内存控制;buffered IO 用透明的页缓存换简单和热点性能。选 DIO 还是 buffered IO,取决于你的访问模式,不是取决于追求性能与否。


七、分布式存储:JuiceFS / CubeFS / Ceph RBD 的本质区别

聊完单机存储,再上一层就是分布式存储。这三个产品名字经常被一起提,但只有 Ceph 提供了原生块存储------其他两个本质都是分布式文件系统,被"当块用"是上层套娃。

先把"块/文件/对象"三种存储形态分清

|-----------------|------------------|-----------------------|---------------------------|
| 形态 | 接口语义 | 代表协议 | 例子 |
| 块存储 Block | 按 LBA 读写定长块 | iSCSI / NVMe-oF / RBD | EBS、ESSD、Ceph RBD |
| 文件存储 File | POSIX:目录/文件/权限 | NFS / SMB / FUSE | NAS、CephFS、JuiceFS、CubeFS |
| 对象存储 Object | HTTP put/get key | S3 / Swift | S3、OSS、MinIO |

注意:这三种形态不是介质属性,而是"对外暴露的接口"。 同一个存储集群(比如 Ceph)同时能开放三种接口。

三个产品的真实定位

|------------------------|------------------|---------------------------------------|---------------------------|--------------------|
| 系统 | 原生形态 | 块存储能力 | 元数据 | 数据存储 |
| Ceph | 对象存储 RADOS(底层) | ✅ RBD(原生块) + CephFS(文件) + RGW(S3) | RADOS 自带 | RADOS(OSD) |
| JuiceFS | POSIX 文件系统 | ❌ 没有原生块;只能"大文件 + loop"模拟 | 独立元数据引擎(Redis/TiKV/MySQL) | 对象存储(S3/OSS/MinIO) |
| CubeFS(前 ChubaoFS) | POSIX 文件 + 兼容 S3 | ❌ 没有原生块(社区主线) | Master + MetaNode | DataNode(多副本/EC) |

Ceph RBD:真·分布式块

  • 把一个虚拟块设备切成 4MB 的对象,分散到 RADOS 上
  • 客户端用 librbd 或内核 rbd.ko 把它映射成 /dev/rbd0
  • 你看到的就是普通 Linux 块设备,可以 mkfsO_DIRECT 裸用、快照、克隆、thin provisioning
  • 典型场景:OpenStack Cinder 云盘、K8s PV、虚拟机系统盘

JuiceFS:文件系统,不是块

  • 它本身只暴露 POSIX 文件接口(fuse 挂载、CSI、S3 网关),没有 /dev/jfsX

  • 数据走 chunk(写入文件按 64MB 偏移量拆分为 Chunk) → block(4MB) → S3,元数据走单独的引擎

  • 优点:容量随对象存储无限扩展,元数据快(Redis);适合 AI 训练数据、海量小文件

  • 缺点:当块用要走 fuse + loop 两层,延迟高,不适合数据库类强一致低延迟块场景

    应用(块I/O) → loop设备(块→文件转换) → FUSE(内核→用户态转发) → JuiceFS客户端(文件→Chunk/Block→对象存储) → S3+元数据引擎

CubeFS:大规模分布式文件系统

  • 前 ChubaoFS,定位 POSIX + S3
  • Master + MetaNode + DataNode 架构
  • 适合容器持久化卷(PVC,多 Pod 共享读写)、AI 大数据

选型原则

  • 要数据库 / 高 IOPS / 块语义(snapshot、thin clone) → Ceph RBD(或云厂商的 ESSD/EBS)
  • 要 K8s 多 Pod 共享读写大目录、数据湖、AI 训练 → JuiceFS / CubeFS(文件协议)
  • 要求 O_DIRECT、4K 对齐、稳定低延迟的"块设备" → 只能用本地 NVMe 或云 EBS 这种"真块",JuiceFS/CubeFS 当块用都不合适

八、把全部知识装进一张图

复制代码
┌──────────────────────────────────────────────────────────────┐
│                      硬件层                                   │
│   多块 NVMe / HDD  ←  设计目标: 并行通道 + 高 IOPS + 大容量    │
└─────────────────────────┬────────────────────────────────────┘
                          │
                  PCIe + NVMe / SAS / SATA
                          │
┌─────────────────────────▼────────────────────────────────────┐
│             OS 块设备层 /dev/nvmeXnY、/dev/sdX                │
│   字符设备旁路 (/dev/sgX, /dev/ng0n1) 用于管理命令             │
└─────────────────────────┬────────────────────────────────────┘
                          │ (可层层套娃: md RAID / LVM / dm-crypt)
                          │
   ┌─────────────────┬────┴──────────┬─────────────┬─────────────┐
   │                 │               │             │              │
┌──▼───────┐  ┌──────▼──────┐  ┌─────▼────┐  ┌─────▼────┐  ┌──────▼──────┐
│ 文件系统  │  │ 裸块直用    │  │ swap     │  │ VM 磁盘   │  │ 网络导出     │
│ ext4/xfs │  │ DIO + 自管  │  │ mkswap   │  │ KVM/容器 │  │ iSCSI/      │
│          │  │ 格式        │  │          │  │          │  │ NVMe-oF/RBD │
└────┬─────┘  └──────┬──────┘  └──────────┘  └──────────┘  └─────────────┘
     │               │
   ┌─┴────────┐   ┌──┴───────┐
   │buffered  │   │ Direct   │  ← 工程取舍,不是"先进/落后"
   │IO + page │   │ IO       │
   │  cache   │   │ O_DIRECT │
   │ (Kafka,  │   │ + 4K 对齐 │
   │  nginx,  │   │ + 自管   │
   │  HDFS)   │   │  缓存    │
   │          │   │ (DB)     │
   └──────────┘   └──────────┘
         ↑                ↑
         │                │
   绝大多数应用       少数主动选择的应用

                                              
                                              
   分布式存储集群(Ceph / JuiceFS / CubeFS)以三种形态对外暴露:
       Block (RBD)  /  File (CephFS, JuiceFS, CubeFS)  /  Object (S3)
       ↑                ↑                                  ↑
       真块             POSIX 文件                          HTTP 对象
       挂载成 /dev/rbd  挂载成 /mnt/...                     SDK 调用

十、总结:常见误区与正确认知

|-----------------------------|----------------------------------------------------------------------------|
| 常见说法 | 正确认知 |
| "POSIX 是块存储 API" | POSIX 是 OS 接口规范,覆盖文件/socket/pipe 等多种资源;块存储是介质形态。POSIX 既可以访问块也可以访问文件,不专属于谁。 |
| "DIO 比 buffered IO 性能好" | 是工程取舍。Kafka 故意用 buffered IO,因为它的访问模式让 page cache 收益巨大。 |
| "文件系统就是用来管文件的" | 文件系统是"块设备上的组织格式"。绕开它直接用裸块也是合法选择。 |
| "现代多 NVMe 服务器都是给数据库 DIO 用的" | 多 NVMe 是通用基础设施。绝大多数负载用的是 buffered IO,主动用 DIO 的只是数据库/存储引擎/中间件这一小撮。 |
| "JuiceFS / CubeFS 是分布式块存储" | 它们是分布式文件系统。真正提供原生块的是 Ceph RBD(以及云厂商的 EBS/ESSD)。 |
| "挂载着的盘还能再裸用" | 不能,会数据损坏。要么独占给文件系统,要么独占给程序,混用必须先分区或分卷。 |


结语

存储系统的复杂之处在于:每一层抽象都有"通用路径"和"绕过路径"

  • 不想要文件系统?可以裸块。
  • 不想要 page cache?可以 DIO。
  • 不想要内核块层?可以 SPDK。
  • 不想要本地盘?可以 NVMe-oF。

理解每一层是为什么存在解决了什么问题何时该绕过,就能在面对具体工程问题时做出正确判断------而不是迷信"DIO = 高性能"、"分布式 = 块"、"多 NVMe = 数据库专用"这些以讹传讹的说法。

希望这篇文章能成为你心里的一张"存储栈地图"。下次再看到一个 /dev/xxx、一行 df 输出、或者一个 O_DIRECT 标志,你都能瞬间把它定位到这张图的哪个位置。

相关推荐
铁皮哥1 小时前
【后端开发】RabbitMQ、RocketMQ、Kafka 怎么选?我从业务场景重新梳理了一遍
java·linux·数据库·分布式·kafka·rabbitmq·rocketmq
phltxy2 小时前
分布式链路追踪实战:Apache SkyWalking 从入门到精通
分布式·apache·skywalking
苍煜11 小时前
Kafka消息零丢失核心全解:生产者acks机制+消费者offset机制
分布式·kafka
何中应21 小时前
RabbitMQ集群搭建
分布式·rabbitmq
薪火铺子21 小时前
Redis 分布式锁与 Redisson 原理深度解析
java·redis·分布式·后端
skilllite作者1 天前
Deer-Flow 工作流引擎深度评测报告
java·大数据·开发语言·chrome·分布式·架构·rust
摇滚侠1 天前
Java 项目教程《黑马商城》微服务拆分 20 - 22
java·分布式·架构
乐之者v1 天前
Kafka 跨服数据同步
分布式·kafka
喜欢流萤吖~1 天前
分布式搜索引擎:Elasticsearch 从入门到实战
分布式·elasticsearch·搜索引擎