最近在看 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 list、nvme smart-log)
NVMe 设备的命名规则:
nvme0→ 第 0 块 NVMe 控制器n1→ 该控制器上的第 1 个 namespace(类比 SATA 的整块盘)- 分区:
nvme0n1p1、nvme0n1p2 - 经过 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 ...) │
└─────────────────────────────────────────────────────┘
几个关键认知:
- 块设备本身不知道"文件"是什么。 它只提供"在偏移 X 处读/写 N 字节"的能力,不懂目录、不懂文件名、不懂权限。
- 文件系统是"建在块设备之上"的一层组织结构。
mkfs.xfs /dev/nvme0n1这一步,本质是在裸盘的某些块里写入超级块、inode 表、空闲位图等元数据。之后这些"块"才被赋予了语义。 - mount 是把文件系统接入 VFS 目录树。 你访问
/data2/foo.txt就被翻译成"读 nvme0n1 第 X 个块"。 - 同一块设备只能有一个文件系统 (不分区情况下)。要分多个 fs,要么先分区(
nvme0n1p1、nvme0n1p2),要么用 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 块设备,可以
mkfs、O_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 标志,你都能瞬间把它定位到这张图的哪个位置。