news 2026/10/19 2:15:46

从零理解slotted page:数据库存储引擎的变长记录管理方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零理解slotted page:数据库存储引擎的变长记录管理方案

做数据库内核,或者偶尔翻过存储引擎代码的同行,对slotted page这个词应该都不陌生。简单说,这是一种在固定大小数据页里管理变长记录的经典方案:页头部放元信息,紧随其后是一排槽位,每个槽位对应一条记录,记录本体存放在页尾区域。它要解决的核心问题,是让记录在页内可以被随机访问,同时还能容忍删除、更新带来的位置漂移和数据碎片。这篇内容适合刚接触存储引擎原理的开发者、准备数据库内核方向面试的人,以及自己做迷你存储引擎练手的同学。我会从设计动机一直讲到手写实现、生产级细节和调试经验,尽量讲透。

1. 为什么数据页需要“槽位”而不是直接排布记录

1.1 定长记录时代的简单答案

最早期的存储设计其实没有slot这一说。如果每条记录的长度是固定的,比如100字节,那么在一个页里存放一堆记录就非常简单:页头记录一个“当前记录数”,第i条记录的起始位置直接用公式算出来,页基址 + 页头大小 + i * 记录长度。访问任意一条记录都是常数时间,遍历页内所有记录就是跑到第i条、第i+1条,线性往下走。这样的方案在定长记录场景里堪称完美,空间利用率高、实现简单、无需额外元数据。

问题在于现实世界的记录长度几乎一定是可变的。一个表里有整数、有字符串,字符串的内容长短不一,即便用定长的字符类型,也得预留最大长度,那空间浪费就很吓人。所以绝大多数存储引擎面对的输入是:页大小固定,记录长度不固定,访问模式要求随机。

1.2 变长记录直接把定长方案击穿

一旦记录变长,上述公式就失效了:你无法从下标直接算出第i条记录的位置,因为每条记录占用的字节数不一样。有人会想,那就让记录一条挨一条排放,从页头往页尾塞,每条记录头部再存一下自身的长度。这样确实能顺序遍历,但随机访问变成了需要从头扫的线性操作,代价极高。

更麻烦的是删除和更新。假设页内有[rec0, rec1, rec2]三条记录,删掉rec1之后,rec2前面就出现一个空洞。如果不做处理,下次扫描时得逐个跳过这个洞。如果做处理,把rec2整体往左搬,那么rec2的物理位置就变了,所有指向rec2的外部引用都必须同步修改。数据库里会有大量索引条目指向记录,这条成本根本承受不起。

1.3 增加一层间接:槽位数组的雏形

要解决“可变长度”和“物理位置会变动”这两个问题,一个很自然的做法是引入间接层:不直接让外部引用指向记录本体,而是先指向一个固定位置,这个固定位置里存记录的实际偏移。这就是slot的核心思想。

每个slot相当于一个“门牌号”,外部持有的是门牌号,不是实际坐标。记录可以在页内被搬来搬去,只需要更新门牌号上写的坐标,外部引用完全不用变。删除记录也不用急着搬动其他记录,把门牌号作废就行。这个设计把“逻辑身份”和“物理位置”彻底解耦了,slotted page所有的优势都从这层解耦中来。

2. slotted page到底长什么样

2.1 页内布局的三段式结构

一个典型的slotted page可以把内部分成三个区域:页头部、槽位数组、数据区。

页头部一般存放页的基本元数据,比如页号、页的校验和、空闲空间起始位置、当前槽位数量等等。槽位数组紧跟在页头部之后,向页中心方向生长。数据区则从页的最尾部开始,向页中心方向生长。页头和槽位数组是“从前往后”占地方,记录数据是“从后往前”占地方,两块区域相向而行,中间剩下的那段连续空间就是空闲区。

这种双向生长的设计有一个很大的好处:空闲空间永远是中间一整段连续的区间,不会像记录从前往后挨个排那样,在删除后出现若干离散的空洞。分配新空间时只需要从空闲区的尾部切一块走,记录搬移时也可以成片地整理。

2.2 槽位里装的是什么

每个槽位通常保存两个核心信息:记录数据在页内的偏移量,以及这条记录的长度。有的实现会在槽位里塞一些标志位,比如这个槽位是否空闲、是否为某类特殊记录等。

页内偏移量用2字节通常就够了:2字节无符号整数最大可以表示65535,8KB页也好、16KB页也好,偏移都不会越界。长度字段同样可以用2字节承载,因为单条记录的长度不会超过页大小。所以常见的槽位大小是4字节左右,一页8KB,就算全部用来放槽位,也足以支撑上千条记录。

我见过有些教学实现会把记录长度只存在记录头里,槽位只放偏移。这样做虽然省了槽位空间,但会让页扫描时的逻辑复杂很多。页本身是用来承载随机访问的,如果每次都要先跳到记录头再读长度,才能判断需要跳过多少字节,那扫描路径就退化成单向链表行为。槽位里同时存长度,扫描时可以完全不碰记录内容,直接从槽位数组里得知下一条要访问什么,效率高很多。

2.3 记录插入时发生了什么

插入一条新记录,步骤大致是:先检查页剩余空间是否足够存放记录数据加上一个槽位;如果足够,把数据拷贝到当前空闲区尾部,也就是让free space指针往前挪;然后找到一个空槽位,把记录偏移和长度写进去;最后更新页头部的槽位数量和空闲空间指针。

需要注意的是“找到一个空槽位”这个动作。如果页刚初始化,槽位数组是空的,直接追加一个新槽位即可。如果页已经用了一段时间,中间有些槽位被删除标记为空闲,插入时就应该优先复用这些空闲槽位,而不是无限扩张槽位数组。生产实现通常会维护一个空闲槽位链表,插入时从链表头部取一个位置。

2.4 删除、更新和compact之间的关系

删除一条记录,在slotted page里通常做的动作是:把对应的槽位标记为deleted或unused,并不真正去清空数据区的字节。这样做的理由是,物理清空数据成本高,而且后续可能还有事务需要访问旧版本数据。被删除的槽位就像是一块“僵尸空间”,对页头部的空闲空间指针没有任何贡献。

但是僵尸空间堆积太多,新插入的记录走free space,旧的僵尸空间一直没人用,页就会越来越“外紧内松”:空间明明很多,却总在报页满。这时候就需要compact回收。compact的过程,是把所有还在使用的记录数据搬到数据区的一侧,重新拼成连续的区块,然后更新所有有效槽位的偏移量,最后把空闲空间指针重新设置到新的边界上。compact做完,页内的碎片就被清理了,可分配空间变大。

这里有个细节值得注意:compact搬移记录时,源区域和目的区域是重叠的。如果从前往后搬,后面的记录没搬,数据就被覆盖了;所以正统做法是从后往前搬,或者先拷贝到临时缓冲再整体搬回。这种“源和目的重叠时的搬移方向”问题,是很多手写实现第一次跑崩的根源。

3. 手写一个迷你版slotted page

3.1 定义页结构和核心常量

口说无凭,我直接贴一个教学级实现的核心代码。这里的页大小我用4096字节,槽位用8字节(偏移量2字节、长度2字节、标志位2字节、保留2字节),实际工程上可以做得更紧凑,但教学场景8字节更清晰。

#include <stdint.h> #include <string.h> #define PAGE_SIZE 4096 #define PAGE_HEADER_SIZE 24 #define SLOT_SIZE 8 /* 槽位标志 */ #define SLOT_UNUSED 0x0000 #define SLOT_ALIVE 0x0001 #define SLOT_DEAD 0x0002 typedef struct PageHeader { uint16_t free_space_offset; /* 当前空闲区尾部,向页头方向偏移 */ uint16_t slot_count; /* 当前已分配槽位数(含unused/dead) */ uint16_t alive_slot_count; /* 存活记录数 */ uint16_t first_free_slot; /* 空闲槽位链表头 */ uint32_t page_id; uint32_t checksum; } PageHeader; typedef struct Slot { uint16_t offset; uint16_t length; uint16_t flags; uint16_t next_free; } Slot; typedef struct Page { uint8_t data[PAGE_SIZE]; } Page;

注意free_space_offset初始化为PAGE_SIZE,也就是指向页尾。数据从页尾开始分配,记录插入时这个值会往小走。槽位数组则从PAGE_HEADER_SIZE开始往后依次排。

3.2 初始化一个页

void page_init(Page *page) { memset(page, 0, PAGE_SIZE); PageHeader *hdr = (PageHeader *)page->data; hdr->free_space_offset = PAGE_SIZE; hdr->slot_count = 0; hdr->alive_slot_count = 0; hdr->first_free_slot = 0xFFFF; /* 没有空闲槽位 */ }

初始化看起来毫无技术含量,但它定义了一个非常关键的不变式:任何时刻,free_space_offset必须大于槽位数组占用的末尾,否则页就处于溢出状态,任何插入操作都不能再执行。

3.3 插入记录

插入前先判断有没有足够的空间:需要的数据空间是记录长度,槽位空间是8字节。如果槽位数组要新增槽位,槽位数组的末尾就会向页中心推进一段,这段空间也得算进剩余空间里。

int page_insert(Page *page, const uint8_t *rec, uint16_t rec_len) { PageHeader *hdr = (PageHeader *)page->data; uint16_t slot_array_end = PAGE_HEADER_SIZE + hdr->slot_count * SLOT_SIZE; /* 槽位数组末尾 + 记录数据长度 必须小于当前空闲区起点 */ if (slot_array_end + rec_len + SLOT_SIZE > hdr->free_space_offset) { return -1; /* 页满 */ } /* 从页尾方向切出记录空间 */ uint16_t new_offset = hdr->free_space_offset - rec_len; memcpy(page->data + new_offset, rec, rec_len); hdr->free_space_offset = new_offset; /* 找一个槽位:优先复用空闲槽位 */ uint16_t slot_idx; if (hdr->first_free_slot != 0xFFFF) { slot_idx = hdr->first_free_slot; Slot *slot = (Slot *)(page->data + PAGE_HEADER_SIZE + slot_idx * SLOT_SIZE); hdr->first_free_slot = slot->next_free; slot->offset = new_offset; slot->length = rec_len; slot->flags = SLOT_ALIVE; } else { slot_idx = hdr->slot_count; Slot *slot = (Slot *)(page->data + PAGE_HEADER_SIZE + slot_idx * SLOT_SIZE); slot->offset = new_offset; slot->length = rec_len; slot->flags = SLOT_ALIVE; slot->next_free = 0xFFFF; hdr->slot_count++; } hdr->alive_slot_count++; return slot_idx; }

这段实现里最需要留意的是空闲槽位链表。删除记录时把槽位丢进链表,插入时从链表头取一个复用。如果不做链表,每次插入都线性扫描所有槽位找空闲,随着槽位数增长,插入成本会越来越高。我见过一个演示项目在全表扫描压测时性能突然跳水,最后定位到是插入路径里有一个全槽扫描,就是这个原因。

3.4 删除记录

int page_delete(Page *page, uint16_t slot_idx) { PageHeader *hdr = (PageHeader *)page->data; Slot *slot = (Slot *)(page->data + PAGE_HEADER_SIZE + slot_idx * SLOT_SIZE); if (slot->flags != SLOT_ALIVE) { return -1; /* 重复删除 */ } slot->flags = SLOT_DEAD; hdr->alive_slot_count--; /* 把空闲槽位塞进链表头部 */ slot->next_free = hdr->first_free_slot; hdr->first_free_slot = slot_idx; return 0; }

删除不碰数据区里的字节,这符合很多数据库引擎的实际行为。数据字节仍然留在那个位置,直到compact或新覆盖才真正被复用。这种延迟回收的思路在后续章节还会展开。

3.5 更新记录

更新记录有两条路可以走。如果新记录长度比旧记录短,理论上可以在原位置覆盖,然后把多余空间当作碎片。但实际工程里更常见的策略是“先删后插”或“新版本另辟新地”:把旧槽位标记为dead,重新分配空间写入新版本,然后让新的槽位指向新数据。

“先删后插”会产生更多碎片,但实现简单且天然安全:不需要判断新旧长度关系,不需要考虑覆盖时是否踩到相邻记录。我在教学实现里通常演示这条路径,因为它的逻辑最直白,而把原地覆盖、空间收缩留成思考题。

3.6 compact碎片整理

compact是整个页内管理的核心操作。它把所有活跃记录搬移到数据区尾部,让空闲区重新变成一整段连续区域,同时更新每个槽位的偏移量。

void page_compact(Page *page) { PageHeader *hdr = (PageHeader *)page->data; uint8_t temp[PAGE_SIZE]; uint16_t from_tail = PAGE_SIZE; uint16_t old_slot_count = hdr->slot_count; /* 第一趟:按槽位顺序把所有活跃记录拷贝到临时区,从尾部往前放 */ for (uint16_t i = 0; i < old_slot_count; i++) { Slot *slot = (Slot *)(page->data + PAGE_HEADER_SIZE + i * SLOT_SIZE); if (slot->flags == SLOT_ALIVE) { from_tail -= slot->length; memcpy(temp + from_tail, page->data + slot->offset, slot->length); } } /* 第二趟:把新布局拷回页的数据区,并更新槽位偏移 */ uint16_t new_tail = PAGE_SIZE; for (uint16_t i = 0; i < old_slot_count; i++) { Slot *slot = (Slot *)(page->data + PAGE_HEADER_SIZE + i * SLOT_SIZE); if (slot->flags == SLOT_ALIVE) { new_tail -= slot->length; memcpy(page->data + new_tail, temp + new_tail, slot->length); slot->offset = new_tail; } } /* 重置空闲空间指针 */ hdr->free_space_offset = new_tail; }

这个版本用临时缓冲规避了重叠搬移的麻烦,教学上足够清晰。但你要知道,真实的compact实现不会用一整张临时页,那是内存拷贝开销极高的做法。生产代码通常直接做从后往前的原地搬移,或者用一小块固定大小的额外缓冲区分段搬,目的是减少内存搬运总量和避免分配临时大块内存。

compact触发时机也很有讲究。有些引擎在每次插入发现页满时先做一次compact,如果compact后还是不够才返回页满;有些引擎则统计页内碎片率,超过阈值时后台任务执行整理。前者简单,但写路径上偶发的高延迟会影响事务尖峰;后者机制复杂,但对延迟更友好。

4. 从教学版到生产级:几个绕不开的深化点

4.1 槽位的三种状态与空闲链表管理

教学版里我用SLOT_ALIVE和SLOT_DEAD两种状态,配合first_free_slot链表复用。生产级实现通常会再区分SLOT_UNUSED(从未使用)和SLOT_DEAD(曾经使用),原因是这两类槽位在占用的资源上有本质区别:unused表示槽位数组还没扩展到那个位置,dead表示槽位本身已经分配过了,但内容作废。

区分二者的价值在于槽位数组扩容策略。如果页内dead的槽位很多,插入时直接复用dead槽位就行;如果dead槽位耗尽,才需要考虑在槽位数组末尾追加新槽位。追加新槽位意味着槽位数组向页中心扩张,挤压数据区空间,所以这一步必须做剩余空间检查。很多溢出问题,根源就是没考虑到“新增一个槽位本身也是需要空间的”。

空闲槽位链表可以用两种方式维护:一种是在slot结构里存一个next_free字段,另一种是用offset字段的特殊值表示空闲关系。后者省字段,但调试时容易把空闲链表的next误读成数据偏移。除非对空间极度敏感,我建议用显式字段。

4.2 记录放不下怎么办:页内放门牌号,正文放别处

如果一条记录的长度比页剩余可用空间还大,插入直接失败显然不行。业界通用思路是:正常大小的记录直接放页里;超大记录把正文放到单独的存储区域,页内只保留一个“外溢指针”或“定位标识”,指向正文实际所在位置。

这种策略在实现slotted page时有一个额外的好处:槽位里的length字段记录的是“页内部分”的长度,不是整条记录的长度,扫描页时不需要加载外溢正文就能知道记录的存在。按这个思路去设计页扫描接口,性能和语义都会更干净。

大记录外溢带来的问题是,访问这类记录的延迟会明显上升,因为要先读槽位,再读外溢页,最后组合数据。所以有些系统会设置一个阈值,尽量让普通业务记录都塞进主数据页,只有超过阈值的长文本、长二进制才走外溢。threshold的设置有讲究:太小,外溢频繁,随机IO变多;太大,主页浪费空间严重。实际调优时我一般建议从页大小的三分之一左右起步,再根据真实负载的IO延迟和页利用率调整。

4.3 并发访问:先写数据,再发布槽位

多事务同时读写同一个页时,slotted page的并发保护通常分为两个层次:物理层面的页闩锁,以及逻辑层面的槽位发布顺序。闩锁保证同一时刻只有一个线程在修改页头部和槽位数组的元数据,这是写路径的最低要求。

发布顺序是个容易忽视的细节。插入一条记录时,如果先把数据写到数据区,再写槽位,那么另一个线程即使拿着页闩,也可能在槽位还没发布的间隙看到“数据区变了但槽位没变”的中间状态。正确的顺序是:先拷贝数据到数据区、更新free_space指针,最后才把槽位里的offset和length写进去。最后一个store是发布点,发布之前的任何修改都还不能被外部感知到。

把这个顺序用内存屏障或原子写对角度的语言来描述,就是:槽位字段的写入必须带release语义,读取槽位的路径必须带acquire语义。在追求极限性能的引擎实现里,这个细节直接决定了并发压测是毛刺满天飞还是平滑分布。

4.4 碎片整理的调优思路

compact虽然是清理碎片的好手段,但做一次的成本要高一些,尤其是页里存活记录很多、搬移数据量大的时候。所以生产级系统通常不会等页彻底满了才compact,而是设置一个“低水位标记”,比如页内空闲空间碎片总量超过页大小的四分之一,且存活记录占比低于某阈值,才触发整理。

另外,compact和并发之间要处理好。最稳妥的方法是拿写锁,把页短暂地“冻结”,不让任何读到中间状态。但这会让正在访问该页的读事务阻塞。更精细的做法是借助槽位本身的原子更新,让读路径每次读取时都重新验证槽位在不在有效范围内。读者在读之前拿到槽位里的offset,读数据时发现数据域的页头状态不一致,就重新读一次,这种乐观重试机制在某些存储引擎的页读取路径里确实存在。

如果你是在自己设计的存储引擎里做compact,我真心建议第一阶段就先用“整页写锁”最简单直接。理由很朴素:compact本来就是低频操作,不应该为了省这个锁而在读路径上引入大量的重试复杂度。先保证正确,再谈优化。

5. 调试与排查经验速查

5.1 常见问题清单

我在调试mini引擎时遇到过不少问题,整理成一个速查表,对排查类似问题会有实际帮助。

现象可能原因排查思路
插入时明明还有空间,却报页满free_space_offset与槽位数组末端计算错误,或者记录长度未加槽位大小打印free_space_offset、slot_count、PAGE_HEADER_SIZE,手工验证
扫描时读取记录内容错乱槽位里的offset指向了页头部区域二次加载页,逐个检查槽位offset是否落在数据区范围内
删除后页满率持续偏高compact未触发,或者compact没有重新计算free_space_offset检查删除路径是否只做了标记、没有调度compact
并发写入后数据页损坏槽位发布顺序不对,读者读到了半个槽位在读写路径两头加调试断言,确认写发布点后再允许读
页校验和频繁报警物理写入过程被中断,或者内存拷贝越界用页dump工具对比校验和,再看数据区末尾是否被相邻页覆盖

5.2 如何快速肉眼检查一个页

没有专门的工具时,想确认一个页是不是合法slotted page,可以从页头开始读:先看偏移0处的前8到16字节,确认free_space_offset和slot_count是不是合理值。free_space_offset应该在PAGE_HEADER_SIZE和PAGE_SIZE之间;slot_count乘以SLOT_SIZE加上PAGE_HEADER_SIZE的值,应该小于等于free_space_offset。

接着读第一个活动槽位,拿出它的offset,跳到那个位置,看看有没有可识别的记录头格式。如果槽位里的offset指向页头或者游离到页尾之外,基本可以断定页被写坏了。另外一个实用小技巧:在调试模式下给页区域填充一个特殊字节序列,比如0xA5,在compact前后分别打印页中该字节的分布,能非常直观地看到搬移是否完整。

5.3 踩过的一个真实教训

我在一个练手项目里曾经把记录长度存在记录头里,没有存在槽位中,当时觉得这样能省几字节。等写到扫描函数时问题就来了:扫描器需要通过槽位找到记录、读取记录头偏移量、再跳过这条记录。如果记录是碎片化的,扫描器就得频繁地在数据区和槽位之间来回跳跃,而且一旦记录头损坏,整个扫描链就断了。

后来我把length存入槽位,扫描路径立刻变简单了:只需要线性遍历槽位数组,检查每个槽位是不是active,如果是,直接用offset找到数据,用length知道数据范围。所有与记录内容无关的元信息都集中在槽位数组里,页内逻辑一下子清楚了。这个改动让我意识到一个设计原则:能被集中管理的元信息,就不要散落到记录本身去。

5.4 调试工具怎么搭

给slotted page写调试工具,我建议从三个接口做起:page_dump_stats(page)打印页头各字段;page_dump_slots(page)逐槽位打印索引、flags、offset、length;page_check_invariants(page)验证页内所有不变式,比如slot_array_end <= free_space_offset、所有active槽位的offset + length不超过PAGE_SIZE。

这套调试工具不需要很复杂,但在你写compact代码时能救命。我当时每写完一段搬移逻辑就跑一遍page_check_invariants,一旦断言失败就立刻缩小范围。不用担心频繁调用会影响性能,这是调试构建下才跑的逻辑,发布构建再注释掉就行。

最后分享一个个人习惯:在动手写一个slotted page之前,先把页格式画在一张纸上,用不同颜色标出页头、槽位数组、数据区和压缩方向。画画的过程中,很多人会突然意识到“哦,原来槽位数组和记录数据是相向生长的”。这个概念用代码看不明显,用图一眼就懂了。我后来给团队里新人讲数据库存储结构,也是建议他们先画图再上手调代码。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 2:15:31

ATECC608A硬件加密芯片实战:从选型到I2C驱动与密钥安全设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/19 2:13:48

自动求解角度图形题

自动求解角度图形题 输入&#xff1a; 线数n 给出的关系m&#xff08;角度&#xff09; 求解数量t n个 线上的交点数量a&#xff08;有编号&#xff09;要不要设为基准&#xff08;n行里有一个即可0非1需要&#xff09;&#xff1a;要的话输入其绝对度数&#xff08;》-181&…

作者头像 李华
网站建设 2026/10/19 2:13:14

PowerShell DSC实战:Windows自动化配置管理与消除配置漂移指南

前言&#xff1a;部署完成后的“配置漂移”挑战在前面的系列文章中&#xff0c;我们解决了操作系统“如何快速、标准化地安装到硬件上”的问题。然而&#xff0c;在系统交付并投入运行后&#xff0c;IT 运维团队往往会面临一个更棘手的问题&#xff1a;配置漂移&#xff08;Con…

作者头像 李华
网站建设 2026/10/19 2:13:14

怎么从一堆错题里自动找出共同知识点?错题透镜跨题共现检测

怎么从一堆错题里自动找出共同知识点&#xff1f;思路一句话&#xff1a;逐题归因只能拿到每道题的错因标签&#xff0c;把整批题目的标签两两做共现统计&#xff0c;覆盖度最高的那条错因链就是共同病根。本文按"问题定义、检测算法、代码实现、真实案例"四步拆解这…

作者头像 李华
网站建设 2026/10/19 2:12:18

初中数学同步练习与精品试卷合集:7-9年级上下册全覆盖,含53系列/学霸必刷题/中考复习/压轴题等45000+份资料

资源概览 这是一套系统而全面的初中数学教辅资源合集&#xff0c;主要服务于七年级至九年级的学生&#xff0c;同时也可作为初中数学教师备课和培训的参考资料。资源以人教版&#xff08;RJ&#xff09;教材为主线&#xff0c;覆盖了初一至初三三个学年、六个学期的完整教学进…

作者头像 李华
网站建设 2026/10/19 2:12:17

VM设置好的虚拟机能不能重新修改硬盘大小

能&#xff01; 博主刚装好虚拟机&#xff0c;然后就发现硬盘设置太小了&#xff0c;要进行扩容。然后博主就跟着这位博主大大的文章虚拟机扩容&#xff08;详解两种方法&#xff09;_虚拟机磁盘扩容-CSDN博客&#xff0c;执行方法一&#xff0c;非常之便捷。 这里博主在以上…

作者头像 李华