Hi, I'm zorro.
版权声明: 本文章内容在非商业使用前提下可无需授权任意转载、发布。 转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。
微博:https://weibo.com/orroz/ 博客:https://zorrozou.github.io/ 微信公众号:Linux系统技术
在生产环境中,软RAID和LVM是Linux存储方案的重要组成部分。很多人知道”RAID5性能不好”、”LVM条带化可以提升性能”,却说不清楚为什么,也不知道该从哪些维度进行调优。本文从内核源码出发,结合drivers/md/目录下的实现,系统地梳理Device Mapper(DM)框架、LVM条带化、软RAID5/6的IO路径和性能关键点,帮助读者真正理解这些机制并做出有依据的优化决策。
本文分析的内核版本为tkernel5(基于Linux 6.6)。
Linux的LVM和软RAID(md)虽然面向用户的工具链不同(lvm2 vs mdadm),但在内核IO路径上都依赖同一个框架:Device Mapper(DM)。
DM的核心思想是:将一个虚拟块设备的IO请求,通过”映射表(mapping table)”转换后分发给一个或多个底层真实块设备。这个转换过程由各种target(目标模块)实现,常见的有:
| target类型 | 实现文件 | 用途 |
|---|---|---|
linear |
dm-linear.c |
线性映射,LVM基本块 |
striped |
dm-stripe.c |
条带化,LVM条带卷 |
raid |
dm-raid.c |
软RAID,底层封装md |
thin |
dm-thin.c |
精简置备 |
cache |
dm-cache-target.c |
块缓存 |
从IO路径的角度看,一个写请求进入虚拟块设备后的流程如下:
用户进程 write()
↓
VFS / 页缓存
↓
通用块层(submit_bio)
↓
dm_submit_bio() ← DM框架入口
↓
target->map() ← 例如 stripe_map()
↓
bio重定向到底层设备
↓
底层块设备驱动
关键函数是dm_submit_bio(),它从dm.c接收bio,查找映射表,调用对应target的.map回调,完成地址转换后将bio提交给底层设备。整个过程是同步的、无锁的(通过RCU保护映射表),这使得DM框架本身的开销非常小。
/* dm.c: DM的bio入口 */
static void dm_submit_bio(struct bio *bio)
{
struct mapped_device *md = bio->bi_bdev->bd_disk->private_data;
...
map = dm_get_live_table_fast(md); /* RCU读锁,极低开销 */
...
/* 调用target的map函数完成地址转换 */
r = dm_map_bio(tio);
...
}
理解了这个框架,我们就可以分别看LVM条带化和软RAID的具体实现。
LVM条带卷对应的target是striped,实现在drivers/md/dm-stripe.c。其核心数据结构是:
struct stripe_c {
uint32_t stripes; /* 条带数量(底层设备数) */
int stripes_shift; /* stripes的log2,用于快速取模 */
sector_t stripe_width; /* 每个底层设备分配的扇区数 */
uint32_t chunk_size; /* chunk大小,单位:扇区 */
int chunk_size_shift; /* chunk_size的log2,用于快速计算 */
...
struct stripe stripe[]; /* 每个底层设备的描述 */
};
当一个bio到来时,stripe_map()通过stripe_map_sector()完成扇区到底层设备的映射:
static void stripe_map_sector(struct stripe_c *sc, sector_t sector,
uint32_t *stripe, sector_t *result)
{
sector_t chunk = dm_target_offset(sc->ti, sector);
sector_t chunk_offset;
/* 计算chunk内偏移 */
if (sc->chunk_size_shift < 0)
chunk_offset = sector_div(chunk, sc->chunk_size);
else {
chunk_offset = chunk & (sc->chunk_size - 1); /* 位运算,快速路径 */
chunk >>= sc->chunk_size_shift;
}
/* 计算目标stripe编号 */
if (sc->stripes_shift < 0)
*stripe = sector_div(chunk, sc->stripes);
else {
*stripe = chunk & (sc->stripes - 1); /* 位运算,快速路径 */
chunk >>= sc->stripes_shift;
}
...
}
这里有一个很有意思的优化细节:当chunk_size和stripes数量都是2的幂时,内核用位运算(&和>>)代替除法(sector_div)来完成取模和除法运算。这就是chunk_size_shift和stripes_shift这两个字段的意义——在条带化配置时预计算好移位量,在IO热路径中就不需要做昂贵的除法了。
这也给我们提供了第一个调优建议:
建议1:创建LVM条带卷时,条带数量(
-i)和chunk大小(-I)都应该使用2的幂。例如使用2、4、8块盘条带化,chunk大小使用64KB、128KB、256KB,而不是3块盘、96KB这样的非2的幂配置。非2的幂会导致内核在每次IO时执行除法操作,对高IOPS场景产生显著的额外开销。
dm-stripe.c实现了stripe_io_hints():
static void stripe_io_hints(struct dm_target *ti,
struct queue_limits *limits)
{
struct stripe_c *sc = ti->private;
unsigned int chunk_size = sc->chunk_size << SECTOR_SHIFT;
blk_limits_io_min(limits, chunk_size); /* 最小IO = 1个chunk */
blk_limits_io_opt(limits, chunk_size * sc->stripes); /* 最优IO = 1个完整条带 */
}
io_opt(optimal IO size)告诉上层文件系统:每次IO的最优大小是chunk_size × 条带数,即一个完整的RAID条带大小。如果IO大小等于这个值,每个底层设备都能被均匀地访问一次,并发度最高,效率最优。
建议2:创建文件系统时,
stripe_unit和stripe_width参数应匹配LVM条带配置。以ext4为例,如果创建了4块盘、chunk=128KB的条带卷:
# chunk_size=128K, stripes=4, stripe_width=512K mkfs.ext4 -E stride=32,stripe_width=128 /dev/vg/lv_stripe # stride = chunk_size / block_size = 128K / 4K = 32 # stripe_width = stride * 条带数 = 32 * 4 = 128这样ext4在分配block group和做预读时就会按条带对齐,避免跨条带的读-改-写。
由于dm-stripe本身没有奇偶校验计算,IO路径极短,适合对写性能要求高、不需要冗余保护的场景,如:
软RAID是Linux存储性能优化中最复杂的话题,因为它涉及奇偶校验计算、stripe cache管理、RMW/RCW两种写策略的选择,以及多线程并发控制。
理解软RAID5性能,必须先理解stripe cache。
RAID5的写操作有一个”写洞”问题:如果一次写操作不是整条stripe的完整写(Full Stripe Write),就必须先读出老数据(Read-Modify-Write,简称RMW),或者读出其他数据块计算新奇偶校验(Reconstruct Write,简称RCW),然后才能写入新数据和更新后的奇偶校验。无论哪种方式,都引入了额外的读IO,这正是RAID5写性能差的根本原因。
为了减少这种额外的读操作,内核实现了stripe cache。其核心数据结构是stripe_head:
/* raid5.h中的stripe_head,描述一个stripe的缓存状态 */
struct stripe_head {
struct hlist_node hash; /* 在hash表中的位置 */
struct list_head lru; /* LRU链表 */
struct r5conf *raid_conf;
short generation;
sector_t sector; /* 这个stripe对应的起始扇区 */
short pd_idx; /* P盘(奇偶校验盘)编号 */
short qd_idx; /* Q盘(RAID6第二奇偶校验盘)编号 */
...
atomic_t count; /* 引用计数 */
spinlock_t stripe_lock;
...
struct r5dev dev[]; /* 每个成员盘的状态和数据buffer */
};
每个stripe_head缓存一个完整stripe的数据,包含所有成员盘(数据盘+奇偶校验盘)对应的内存页面。当多个写请求命中同一个stripe时,它们可以在内存中合并,最终只需一次完整条带写,避免了RMW开销。
stripe cache的大小通过/sys/block/md0/md/stripe_cache_size控制,对应的内核实现:
/* raid5.c */
static ssize_t
raid5_show_stripe_cache_size(struct mddev *mddev, char *page)
{
...
return sprintf(page, "%d\n", conf->max_nr_stripes);
}
static ssize_t
raid5_store_stripe_cache_size(struct mddev *mddev, const char *page, size_t len)
{
...
/* 每个stripe_head需要为每个成员盘分配一个内存页 */
/* 对于N盘RAID5,每个stripe_head消耗N个page */
}
stripe cache大小的影响:
建议3:根据工作负载调整stripe_cache_size。
默认值通常为256个stripe。对于写密集型工作负载(如数据库WAL、日志写入),建议增大到4096甚至8192:
echo 4096 > /sys/block/md0/md/stripe_cache_size注意这会消耗内存:每个stripe_head约消耗
成员盘数 × PAGE_SIZE的内存。5盘RAID5,8192个stripe需要约160MB内存。
当一次写操作覆盖整个stripe的所有数据块时,就是Full Stripe Write(FSW)。内核中的判断逻辑:
static bool is_full_stripe_write(struct stripe_head *sh)
{
/* 所有数据盘都被覆盖 = 完整条带写 */
BUG_ON(sh->overwrite_disks > (sh->disks - sh->raid_conf->max_degraded));
return sh->overwrite_disks == (sh->disks - sh->raid_conf->max_degraded);
}
FSW的特点是:不需要读取任何旧数据,直接计算新的奇偶校验值,然后写入所有盘。这是RAID5的最优写路径,写性能接近无RAID的情况。
内核使用pending_full_writes计数器跟踪正在进行中的FSW,并在调度器中给予优先处理:
/* raid5.c中的调度逻辑 */
if (atomic_read(&conf->pending_full_writes) == 0))
/* 没有FSW时,可以处理其他类型的写请求 */
建议4:尽量让应用程序以stripe大小的倍数进行写操作,触发Full Stripe Write。
stripe大小 = chunk_size × 数据盘数。例如5盘RAID5(4数据+1奇偶),chunk=512KB,则stripe大小=2MB。数据库的写入单位(checkpoint、redo log块)若能对齐到stripe大小,将极大提升RAID5写性能。
当写操作不是FSW时,RAID5有两种策略:
RMW(Read-Modify-Write):
new_parity = old_parity XOR old_data XOR new_dataRCW(Reconstruct Write):
内核根据修改的数据块数量选择策略:
/* raid5.c: 选择RMW还是RCW */
/*
* 如果修改的数据盘数 <= 数据盘总数/2,使用RMW(读更少的块)
* 否则使用RCW(重建更高效)
*/
为了减少写开销,内核实现了批量写(batch write)机制,将来自同一个”stripe组”中连续stripe的FSW合并处理:
/* raid5.c */
/* 只有全条带写且没有任何其他操作的新stripe才可以加入batch */
static bool stripe_can_batch(struct stripe_head *sh)
{
return test_bit(STRIPE_BATCH_READY, &sh->state) &&
!test_bit(STRIPE_BITMAP_PENDING, &sh->state) &&
is_full_stripe_write(sh);
}
static void stripe_add_to_batch_list(struct r5conf *conf,
struct stripe_head *sh, struct stripe_head *last_sh)
{
...
/* 只有sector地址与chunk边界对齐时才能batch */
if (!sector_div(tmp_sec, conf->chunk_sectors))
...
}
batch write将多个连续stripe的写操作合并为一个大的写操作,减少奇偶校验计算的次数和磁盘写操作的次数,对顺序写性能提升显著。
Linux块层有一个plug/unplug机制——先”塞住”IO不提交,积攒一批后再一起提交(unplug),目的是增加IO合并机会。RAID5专门实现了自己的plug机制:
/* raid5.c */
struct raid5_plug_cb {
struct blk_plug_cb cb;
struct list_head list; /* 积攒的stripe列表 */
struct list_head temp_inactive_list[NR_STRIPE_HASH_LOCKS];
};
static void raid5_unplug(struct blk_plug_cb *blk_cb, bool from_schedule)
{
struct raid5_plug_cb *cb = container_of(
blk_cb, struct raid5_plug_cb, cb);
/* 将所有积攒的stripe一次性提交处理 */
...
dispatch_bio_list(&tmp);
}
当上层以plug方式提交IO时,RAID5会把相关stripe积攒在raid5_plug_cb的链表里,等到unplug时一起处理,这样同一个stripe上的多个IO就有机会合并,尽量触发FSW。
建议5:应用层使用
fio测试时加上--io_submit_mode=offload或使用libaio/io_uring批量提交,可以给RAID5更多的IO合并机会,提升顺序写性能。
为了在多核环境下提高stripe cache的并发访问性能,内核使用了分段哈希锁(sharded hash lock):
/* raid5.h */
#define NR_STRIPE_HASH_LOCKS 8
#define STRIPE_HASH_LOCKS_MASK (NR_STRIPE_HASH_LOCKS - 1)
struct r5conf {
...
struct hlist_head *stripe_hashtbl;
spinlock_t hash_locks[NR_STRIPE_HASH_LOCKS];
...
};
8个哈希锁将stripe按sector地址分散到不同的锁桶,减少多线程并发访问时的锁竞争。对于高并发随机写场景,这是一个重要的可扩展性设计。
RAID5有多种奇偶校验分布算法,通过/sys/block/md0/md/layout查看:
/* raid5.c: 算法选择 */
int algorithm = previous ? conf->prev_algo : conf->algorithm;
switch (algorithm) {
case ALGORITHM_LEFT_ASYMMETRIC: /* 左不对称,奇偶校验从左轮转 */
case ALGORITHM_RIGHT_ASYMMETRIC: /* 右不对称 */
case ALGORITHM_LEFT_SYMMETRIC: /* 左对称,Linux默认 */
case ALGORITHM_RIGHT_SYMMETRIC: /* 右对称 */
case ALGORITHM_PARITY_0: /* 奇偶校验固定在盘0 */
case ALGORITHM_PARITY_N: /* 奇偶校验固定在最后一盘 */
}
Linux默认使用left-symmetric(左对称)布局,这种布局下每个stripe的数据块在多个成员盘上的分布更均匀,顺序读时可以最大化磁盘并发度。
建议6:保持默认的
left-symmetric算法,这是多年实践证明在大多数场景下性能最优的选择。
上面分析了RAID5的内部机制,这里汇总所有可调参数。
# 查看
cat /sys/block/md0/md/stripe_cache_size
# 修改(需要根据内存和工作负载调整)
echo 4096 > /sys/block/md0/md/stripe_cache_size
RAID重建(sync)和正常IO共享相同的IO带宽。可以通过以下参数控制重建速度,避免在业务高峰期重建占用过多IO:
# 最低重建速度(KB/s)
cat /proc/sys/dev/raid/speed_limit_min
# 最高重建速度(KB/s)
cat /proc/sys/dev/raid/speed_limit_max
# 临时降低重建速度,保证正常业务
echo 10000 > /proc/sys/dev/raid/speed_limit_max # 限制为10MB/s
chunk_size是RAID5性能调优中最重要的参数,在创建时确定,不可在线修改:
# 创建RAID5时指定chunk大小(512KB)
mdadm --create /dev/md0 --level=5 --chunk=512 \
--raid-devices=5 /dev/sd{b,c,d,e,f}
顺序读场景下,增大预读可以显著提升吞吐量:
# 查看当前预读设置(单位:512字节扇区)
blockdev --getra /dev/md0
# 设置预读为8MB
blockdev --setra 16384 /dev/md0
在实践中,经常会看到”LVM on RAID”或”RAID on LVM”两种组合方式,它们的性能特征有所不同。
文件系统
↓
LVM(提供逻辑卷)
↓
md软RAID(提供冗余)
↓
物理磁盘
这种方式的好处是LVM可以自由管理RAID阵列上的空间。但需要注意LVM的chunk_size要与RAID的chunk_size对齐,否则LVM的条带化会打乱RAID层的IO对齐,引发大量RMW。
文件系统
↓
md软RAID(内嵌于dm-raid)
↓
LVM PV
↓
物理磁盘
使用dm-raid(LVM RAID)时,LVM直接管理RAID配置,两层之间不存在对齐问题,推荐这种方式。
创建LVM RAID5卷:
# 创建RAID5逻辑卷(4数据盘+1奇偶校验盘)
lvcreate --type raid5 -i 4 -I 512k -L 100G -n lv_raid5 vg_name
在做任何调优前,先建立基准线:
# 1. 顺序写测试(验证FSW效率)
fio --name=seq_write --rw=write --bs=4M --size=10G \
--filename=/dev/md0 --direct=1 --numjobs=1 \
--ioengine=libaio --iodepth=32
# 2. 随机写测试(最能体现RAID5痛点)
fio --name=rand_write --rw=randwrite --bs=4K --size=10G \
--filename=/dev/md0 --direct=1 --numjobs=8 \
--ioengine=libaio --iodepth=64
# 3. 混合读写(模拟数据库场景)
fio --name=mixed --rw=randrw --rwmixread=70 --bs=8K --size=10G \
--filename=/dev/md0 --direct=1 --numjobs=4 \
--ioengine=libaio --iodepth=32
通过/proc/mdstat和/sys/block/md0/md/下的计数器监控RAID5的内部状态:
# 查看RAID状态
cat /proc/mdstat
# 查看stripe cache命中情况
cat /sys/block/md0/md/stripe_cache_size
# 监控raid5线程状态
cat /proc/sys/dev/raid/speed_limit_max
本文从源码角度梳理了Linux软RAID和LVM的核心IO路径和性能关键点,主要结论如下:
DM框架开销极小,使用RCU和位运算优化了热路径,框架本身不是性能瓶颈
LVM条带化性能好,但chunk_size和条带数应使用2的幂,并让文件系统参数与之对齐
RAID5适合顺序写,不适合随机小写——这不是配置问题,而是RAID5算法的本质决定的。对于随机小写密集的场景,应考虑使用硬件RAID(带写缓存)、RAID10,或上层软件RAID(Ceph、ZFS)
speed_limit_max保护正常业务IO希望本文能帮助大家建立起从源码到实践的理解框架,在遇到具体问题时知道从哪里入手分析。
大家好,我是Zorro!
如果你喜欢本文,欢迎在微博上搜索”orroz”关注我,地址是:https://weibo.com/orroz
大家也可以在微信上搜索:Linux系统技术 关注我的公众号。
我的所有文章都会沉淀在我的个人博客上,地址是:https://zorrozou.github.io/
欢迎使用以上各种方式一起探讨学习,共同进步。