ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

存储器分层全解:缓存、主存、外存如何协同工作?

2026/10/1 11:14:03 拓冰建站 浏览量
存储器分层全解:缓存、主存、外存如何协同工作? 各位做嵌入式、做后端、或者正在啃计算机组成原理的朋友应该都有过这种经历程序运行突然卡顿CPU占用率不高、内存看着也还剩不少但整个系统就像在等什么东西。这时候十有八九是存储系统在拖后腿。今天这篇存储器详解本质就是要把缓存、主存、外存这三层“仓库”的关系彻底讲透搞清楚数据到底是怎么在CPU和硬盘之间流转的以及我们写代码、配系统的时候怎么利用这些规律来提升性能。不管你是刚接触硬件的新人还是被存储性能问题折腾过的老手这篇文章都值得花十几分钟读完。1. 为什么存储器要分“缓存、主存、外存”三兄弟先理解“快”和“大”为什么不能兼得1.1 CPU太快存储太慢矛盾才是分层的根本原因很多人学存储器只记住了“CPU寄存器最快、缓存次之、内存再次、硬盘最慢”但没想明白一个问题为什么非得这么分层直接做一块又大又快的内存不行吗答案是成本和工艺决定的不可能三角速度、容量、价格三者不可兼得最多满足两个。CPU的寄存器用的是触发器电路速度可以跟CPU同频但成本高得吓人64个64位的寄存器就占了不小的芯片面积不可能做到几GB。主存用的DRAM动态随机存储器每bit只需要一个晶体管加一个电容便宜密度高但访问延迟在几十到上百纳秒比CPU时钟周期低于1纳秒慢了几百倍。外存的机械硬盘用磁记录SSD用NAND闪存密度更大、价格更低但延迟直接到毫秒级。这个矛盾在计算机体系结构里有个专门说法叫“存储墙”——CPU性能每年还在增长但内存延迟的改善非常缓慢两者之间的鸿沟越来越大。如果没有缓存这一层挡在中间CPU几乎每时每刻都在等内存整个系统的性能会被存储拖死。分层的本质就是用少量的高速存储挡住绝大部分访问把低速存储留给剩下的少部分访问。1.2 层次结构是用“概率”骗过性能瓶颈的既然速度差别这么大系统怎么还能跑得动靠的是局部性原理。程序在运行过程中访问的地址并不是完全随机的刚访问过的数据很可能马上再访问时间局部性当前数据附近的地址也大概率会被访问空间局部性。循环、顺序执行指令、数组遍历到处都是局部性的体现。基于这个规律缓存的设计思路很简单把最近用过的、以及它附近的数据复制一份放到高速层里。下次CPU要访问时先查高速层如果命中了就直接用只有不命中才往下层走。只要命中率足够高比如95%那么平均访问时间就非常接近高速层的速度。这里有个经典公式平均访问时间 命中率 × 缓存访问时间 未命中率 × (缓存访问时间 下层访问时间)假设缓存访问1纳秒主存访问100纳秒命中率95%平均时间就是 0.95 × 1 0.05 × 101 6纳秒接近缓存的速度。这就是分层的魔法——它没有改变任何硬件的物理速度只是靠概率把绝大多数访问“骗”到了快的那一层。理解了这一点后面讲的所有优化核心都是在想办法提高命中率。2. 缓存CacheCPU旁边的高速“便签本”性能的隐形裁判2.1 Cache的工作套路局部性原理什么时候凑效缓存全称Cache通常分L1、L2、L3三级。L1又分指令缓存和数据缓存空间极小几十KB但延迟只有几个周期L2有几百KB到几MBL3是多个核心共享的几十MB。从原理上看Cache就是一个很小很快的查找表CPU给一个地址Cache用它的索引字段去查组拿标志字段做比对判断数据在不在里面。这里要理解三个映射方式直接映射、全相联、组相联。直接映射是把内存地址按取模分到唯一一个缓存行硬件简单但容易冲突——两个频繁访问的地址映射到同一行就会互相踢掉命中率骤降。全相联是任何一个地址可以放入任意缓存行冲突最小但每次查找要并行比较所有行硬件成本极高。实际CPU用得最多的是组相联内存地址先按组索引分到某一组组内有多个缓存行可以做全相联查找折中了成本和命中率。比如常见的8路组相联冲突概率远低于直接映射硬件又不会爆炸。有一次我在排查一个性能问题时发现某个热点函数采用2的幂次大小作为数组步长去访问数据恰好和Cache的组数形成某种对齐关系导致大量的组冲突命中率掉到70%以下程序慢了近一倍。这就是映射策略带来的实践陷阱数组大小、步长选择在某些巧合下会和缓存组索引产生“共振”这是很多人没注意到的地方。2.2 写策略与多核一致性比看起来更麻烦的问题读缓存大家都好理解写操作就复杂了。CPU要写一个数据是只写缓存还是同步写主存两种经典策略写直达Write Through每次写都同时更新缓存和主存。实现简单一致性有保障但每一次写操作都要等慢速主存性能受损。写回Write Back只更新缓存标记该缓存行为“脏”Dirty等到这行被替换出去时再一次性写回主存。性能好但可能出现缓存和主存数据不一致的时间窗口。现代CPU基本都用写回策略因为写操作的比例不低每次都穿透到主存谁也扛不住。但写回带来的问题是多核场景下的缓存一致性——核0改了数据放缓存里了核1的缓存里还是旧值怎么办为了解决这个硬件实现了缓存一致性协议最典型的是MESI协议把缓存行状态分为修改Modified、独占Exclusive、共享Shared、失效Invalid四种。每次核心读、写、替换时都通过总线或片上网络广播消息其他核心收到后把自己的副本标记失效或更新。MESI在实践中最常见的坑是伪共享两个线程操作了不同的变量但这两个变量恰好落在同一个缓存行通常64字节里。核0修改变量A会把整行标记为Modified并通知核1失效核1修改变量B时又要把整行重新拿回来。两者明明各写各的却在缓存行层面互相拖累性能可能下降十倍不止。JAVA的LongAdder、并发框架里的padding填充就是为了解决这个问题。2.3 写代码时怎么“讨好”缓存真正能落地的优化手段缓存优化不是玄学有几个非常具体、可以直接操作的方向优先遍历连续内存。二维数组按行遍历比按列遍历快很多因为按行是连续内存提前把一行的数据都拉到缓存里了按列则每个元素跨行跳空间局部性完全被破坏。我做过一个简单的C语言测试同样是遍历1024×1024的int数组按行耗时大约3毫秒按列耗时超过20毫秒差距巨大。优化数据布局。把热数据频繁访问的字段放在结构体开头并且尽量在一个结构体里紧凑排布。冷热数据分开不要把只读一次的大字段塞进热点结构体里否则缓存行里加载了大量无用数据。避免伪共享。多线程场景下给独立的计数器变量各自补齐到64字节对齐确保它们不在同一个缓存行里。C语言可以用alignas(64)Java里可以用Contended注解。处理2的幂次陷阱。数组大小或步长尽量别用刚好和缓存组数相干的数值可以加一点padding偏移比如访问stride8192的数组时改成81921减少组冲突。3. 主存真正装程序“家底”的地方细节决定上限3.1 DRAM和SRAM都是RAM但性格完全不同很多人把RAM当成一个东西实际上主存用的DRAM和寄存器/缓存用的SRAM原理差别极大。SRAM是静态随机存储器靠触发器锁存数据只要通电数据就在不需要刷新速度极快但每个bit要6个晶体管面积大成本高。DRAM是动态随机存储器靠电容是否存在电荷来表示0和1电容会漏电所以必须周期性刷新一般每64毫秒要把所有行刷新一遍。好处是每个bit只要1个晶体管加1个电容密度可以做得非常大所以主存只能是DRAM。DRAM读数据时还有个“行激活”的过程先给出行地址Row激活整行数据到缓冲区再选出列Column返回。所以内存访问是有“粒度”的通常一次能读出几十甚至上百字节。这解释了为什么内存控制器喜欢顺序访问——顺序访问能连续命中同一行不需要频繁激活新行行缓冲命中率就高随机访问则频繁换行延迟急剧上升。数据库、文件系统里的“顺序读优化”底层就是在迎合DRAM的行缓冲机制。3.2 字节编址与存取单位的细节考试常考实践也重要存储器的编址方式是计算机组成原理里的高频考点也是最容易混淆的地方。字节编址就是每个存储单元对应一个字节地址不管CPU一次能存取多少位。比如某16位计算机CPU数据总线宽度16位但主存按字节编址、存取单位为16位——意思是地址一个个字节地走但每次内存访问最少取16位2字节。这种设计下一个16位数据可能占用两个连续字节地址CPU读取时要看这个数据是否对齐到2字节边界若没对齐可能要多读一次或处理器内部做额外处理。计算题套路很简单给定主存大小和编址方式算地址线根数、寻址范围、存储芯片的扩展方案。比如某机器按字节编址主存容量64KB则需要地址线 log2(65536) 16根能表示的地址范围是0x0000~0xFFFF。如果要求存取单位是16位而存储芯片是8位宽的就得用两片并联做位扩展。这类题看起来是理论实际上在做嵌入式硬件选型、设计存储器和CPU连接时完全用得上。还有一个实践点内存对齐。CPU访问对齐的数据通常是一次就完成不对齐的数据可能跨缓存行或内存行需要拆成两次访问并拼接。很多编译器会自动处理结构体对齐但如果你写协议解析、直接操作内存缓冲就必须自己留意对齐问题。3.3 虚拟内存主存不够时的“暗渡陈仓”以及它的代价物理主存是有限的但程序希望看到连续、巨大的地址空间这就有了虚拟内存机制。操作系统为每个进程维护一张页表把虚拟地址映射到物理地址。CPU发出的虚拟地址先经过MMU内存管理单元查页表得到物理地址后再去访问Cache和主存。页表查询本身很慢所以CPU里又加了一个TLB快表专门缓存最近用到的虚拟地址到物理地址的映射。TLB很小通常只有几十到几百项它的命中率直接影响了内存访问的速度。当程序按随机顺序访问大量分散的页面时TLB频繁缺失MMU要去主存里查多级页表性能断崖式下降。这个机制给实践的启发是内存友好型程序应该在访问模式上尽量集中于少数几个页面区域。像Redis这类高性能中间件把热数据聚集在一段连续内存里本质上是为了同时提高Cache命中率和TLB命中率。Linux下默认页大小是4KB访问大块数据时会频繁换页启用Linux Huge Pages比如2MB大页可以显著减少TLB缺失某些内存密集场景下性能提升20%~30%都很正常这也是数据库和虚拟化平台常见的调优项。4. 外存容量与持久化的“大后方”比你想的更讲究4.1 机械硬盘和固态硬盘谁更懂数据原理决定脾气外存这个说法比较老派其实覆盖的就是传统机械硬盘HDD和现代固态硬盘SSD再加上各种移动存储设备。两者的底层原理完全不同脾气差别非常大。机械硬盘内部是高速旋转的盘片一般5400转或7200转每分钟磁头在盘面上寻道读写数据。它的性能受物理运动限制寻道时间磁头移动到目标磁道一般是几毫秒到十几毫秒旋转延迟是盘片转半圈的时间。所以HDD的随机访问能力极差但顺序访问时因为不需要频繁寻道带宽其实还可以能到一两百MB/s。这就决定了机械硬盘的设计原则尽量减少寻道数据按顺序存放零散的小文件多了之后碎片化严重性能就崩了。SSD用的是NAND闪存没有机械运动随机读能做到几十微秒级别相比HDD快了上百倍。但闪存有几个硬伤写入前必须擦除擦除以块为单位每个存储单元的编程/擦除次数有限SLC约10万次MLC约1万次TLC约3000次QLC更少。所以SSD内部都有FTL层和磨损均衡算法负责逻辑地址到物理地址的映射、垃圾回收和磨损均衡。这也是为什么SSD掉电容易丢数据、长期高负载写入性能会衰减——那些都是擦写、GC的真实代价。4.2 接口和IOPS别只盯着容量看选硬盘或者存储设备的时候很多人只看容量忽略了接口协议。同样是SSD走SATA口还是NVMe口性能天差地别。SATA 3.0的理论带宽只有6Gbps实际能跑550MB/s左右就到顶了而NVMe走PCIe总线PCIe 4.0 x4的单向带宽接近8GB/s比SATA快了一个数量级。延迟也从SATA的几十微秒降到了NVMe的十几微秒。评价存储性能有一组惯用指标IOPS每秒输入输出次数代表随机小文件处理能力带宽代表大块数据传输能力延迟代表单个请求的耗时。HDD的随机IOPS通常只有一两百普通SATA SSD能到几千到几万高端NVMe SSD能到几十万甚至上百万。你如果拿高性能数据库比如MySQL的redo log写入对随机写IOPS要求就特别高这时候一定是NVMe SSD才扛得住。反过来单纯做视频素材存储顺序带宽更重要机械硬盘组RAID反而性价比很高。先想清楚自己的负载偏向随机还是顺序再选存储设备和接口这是正经的架构决策。4.3 移动硬盘要不要开启写入缓存一次真实的犹豫操作系统层面还有个经常被问到的问题移动硬盘要不要开启写入缓存Windows设备策略里通常默认“更好的性能”即启用写入缓存但要求安全删除硬件。原理是写入数据先进入系统内存缓存后台再异步刷到硬盘让用户感觉写盘更快。但如果中途拔盘缓存里的数据来不及落盘文件就损坏了。我的建议分两种情况。如果你只是偶尔拷文档、图片在Windows里选“快速删除”更安心代价是写入速度慢一些如果频繁拷贝大量视频素材、有UPS供电或设备固定不移动可以开启写入缓存换性能但拔盘前一定要走“安全删除硬件”流程。这个选择本质上是性能和数据安全之间的权衡没有绝对对错但一定要知道自己在冒什么风险。对机械硬盘来说开启写入缓存还能降低写入抖动因为它可以把零散写入合并成顺序写入减少寻道次数。5. 一次数据读取的旅程缓存、主存、外存到底怎么协作5.1 一条读请求的完整路径从寄存器到磁盘光讲概念容易飘我们跟住一次读取请求把三层存储的协作链路完整走一遍。假设CPU执行一条加载指令需要读虚拟地址0x0040_1234处的数据CPU把虚拟地址送给MMU和TLB。如果TLB命中直接得到物理地址如果不命中需要走页表查询把结果填入TLB。拿到物理地址后CPU去查L1缓存以物理地址的索引和标志匹配缓存行。L1没命中继续查L2、L3。三级缓存都没命中内存控制器访问DRAM把包含该数据的一整块内存读出来填充到各级缓存对应的缓存行里。如果数据所在的内存页不在物理主存中也就是缺页了这时MMU会抛出一个缺页异常操作系统介入把页表里记录的虚拟地址对应的磁盘位置读进来。磁盘外存上的数据先读到内存缓冲区页缓存然后更新页表映射再继续第1步重试指令。这条链路上每一层都有一项关键技术TLB加速地址翻译Cache缓存数据块页缓存缓存文件内容。它们配合起来让程序几乎感觉不到磁盘的存在。值得注意的是页缓存也属于主存的一部分外存数据先拷贝到页缓存CPU读文件时实际上读的是主存中的副本写文件时也是先写页缓存再异步刷盘。这个机制性能很好但也会带来断电丢数据的风险所以数据库都有fsync这类强制落盘的操作。5.2 DMA与缓冲让CPU少管闲事在外存和主存之间搬运大块数据如果每次都靠CPU一条条读写CPU会被完全占满。于是有了DMA直接内存访问控制器CPU只需要告诉DMA源地址、目的地址、传输长度DMA就自己接管总线把数据从硬盘控制器搬到主存搬完了再发中断通知CPU。现代的NVMe SSD甚至支持多队列和中断合并进一步降低CPU干预成本。这也是为什么高负载IO场景下CPU占用率依然可以很低——数据搬运根本不经过CPU执行指令而是硬件在偷偷干活。理解DMA对排查性能问题有意义如果你看到CPU占用很低但磁盘IO等待很高说明系统在做大量DMA传输如果CPU占用很高且IO不高可能问题反而出在中断处理或者系统调用的复制开销上。两种瓶颈的优化方向完全不同先分清再动手。5.3 业务系统里的“三级缓存”思维从硬件到软件的一脉相承硬件里的Cache/主存/外存分层思想在软件层面被反复套用。Spring的三级缓存解决Bean循环依赖本质是用不同生命周期的缓存实例来保存中间状态Redis缓存数据库热点数据本质是用高速内存挡住大量对慢速磁盘的直接访问浏览器缓存、CDN缓存本质是把静态资源放到离用户更近更快的地方。理解了硬件存储体系再看这些软件方案会豁然开朗——它们都是同一套“分层局部性”思路在不同尺度上的应用。反过来软件缓存治理也能借鉴硬件的经验要设淘汰策略类似缓存行替换要考虑写入一致性类似写直达/写回的选择重要数据不能只放缓存不落盘类似脏数据必须刷回主存。我见过不少团队把Redis当成“永久存储”用缓存一丢数据就炸本质上就是没搞明白缓存和外存的分工边界。6. 实践技巧观测、调优与避坑少走几年弯路6.1 先学会观测不量化就谈不上优化所有性能工作第一件事是观测别凭感觉。Linux系统上vmstat看内存换页和IO等待iostat看磁盘的带宽、IOPS、等待时间perf可以统计缓存缺失率和TLB缺失。Windows上除了任务管理器也有资源监视器和WPR/WPA。测一个程序的内存访问特征可以用perf stat -e cache-misses,cache-references,page-faults。如果cache-misses很高大概率代码的局部性很差如果page-faults高则是内存分配和访问模式的问题。个人最推荐的一套组合拳先用perf或vtune抓全局事件定位到热点函数再针对热点函数做代码改动改完务必重新用同样的数据测同样的事件做前后对比。没有基线数据的优化都是自嗨很可能你优化了一个不是瓶颈的环节整体毫无变化。6.2 数据结构与缓存的适配实操一个反直觉的例子举一个实际例子。假设结构体是这样的struct item { uint64_t id; // 8字节 char name[32]; // 32字节 uint32_t score; // 4字节 };大量的热点操作是遍历这些item更新score字段。按上面布局每个item大小为44字节编译器会按8字节对齐填充到48字节一个64字节的缓存行正好装不下两个item造成一定程度浪费。如果重新排列字段把score放到结构体前面并压缩name到一个更紧凑的缓冲区访问score时的缓存命中率会提高不少。这在处理亿级流量的排行场景里能节省相当可观的带宽。另一个反直觉的优化是用结构体数组SoA代替数组结构体AoS。比如有一个粒子系统每个粒子有位置、速度、颜色如果你只关心所有粒子的位置那么把三个字段分别放到三个独立数组里遍历时只需要顺序扫位置数组能连续加载到缓存里如果混在一个结构体数组里遍历位置时会把用不到的90%数据也搬进缓存白白浪费空间。这个改动在某些图形物理模拟里能让性能翻倍。6.3 几个容易被坑的场景来自真实事故的教训关于缓存一致性曾经有一次线上事故多线程统计接口调用量每个线程各写各的计数器最后合并求和。看起来没有任何锁竞争但性能反而比加锁还差。原因就是伪共享——那几个计数器挤在一个缓存行里互相同步失效。修复方式就是给每个计数器padding到64字节性能立刻恢复。关于虚拟内存遇到过内存泄漏排查系统内存看着还有几个GB但程序越来越慢。观测发现page-faults飙升原因是程序大量用malloc申请小块内存并频繁释放产生严重的内存碎片让TLB覆盖不住。改用内存池或大块预分配后就好了。内存分配策略会影响页表结构这在长期运行的服务里非常关键。关于外存写入缓存遇到过移动硬盘拔太快丢数据的用户也遇到过服务器意外断电导致页缓存里未落盘数据丢失的案例。解决办法在数据库场景下是配置sync_binlog1、innodb_flush_log_at_trx_commit1让关键日志每次提交都强制落盘普通PC上就是开启写入缓存时务必走安全弹出流程。关于32位与64位下的内存寻址组装老机器或者在嵌入式里估算主存地址范围时也要注意编址方式和地址线位数的匹配。一个8位地址总线的单片机最多只能访问256个字节扩展外部RAM就需要额外的端口来分时给行列地址这些在硬件设计时要算清楚。7. 结语存储器分层是一套“用空间换命中的哲学”我听到过一句话软件性能优化的尽头基本都是在跟存储器打交道。缓存命中率、虚拟内存页表、磁盘IO调度扒开看全是存储分层的游戏。做硬件的人选型时要算访存带宽和接口匹配做服务端的人要理解页缓存和刷盘机制写应用的人要注意数据局部性——大家殊途同归都是在跟这三级存储结构斗智斗勇。建议新手先把局部分层这盘棋搞明白再去看各种复杂框架的缓存设计思路会清晰很多。如果后面有机会我再把自己在具体项目里做过的一次缓存优化完整复盘一遍包括参数配置、代码改动和前后性能数据希望能给各位一个可复现的完整案例。