群里有个朋友贴了张截图,问了我一个很有意思的问题:他在 Azure App Service 上部署了一个 .NET 应用,压测的时候 CPU 能冲到 90% 以上,但内存始终停留在 80% 左右,怎么压都上不去。他怀疑是不是租户隔离把内存限死了,又怀疑是不是 .NET 的 GC 在搞鬼。这个问题我前前后后遇到过不少次,今天把从外到内的原因一次性拆透,聊聊为什么 .NET 应用在 App Service 上内存很难"占用 100%"。
先说结论:这既不是你的代码写得不对,也不是 .NET 本身有什么内存上限,而是 App Service 的内存本身就是配额制,不是让你去占满宿主机物理内存;再加上 .NET 运行时的 GC 会按内存压力自动回收,你看到的"到了某个百分比就上不去了"其实是多层机制共同作用的结果。下面按层次展开说。
1. 先搞明白 App Service 的内存配额机制
1.1 不是宿主机的 100%,是给你这层的配额
App Service 底层跑在共享的多租户架构上,一台宿主机上不止你一家应用。为了避免某个应用把整台机器拖垮,平台给每个 App Service Plan 定义了明确的配额——包括 CPU 配额和内存配额。这个配额才是你应用的"天花板",而不是宿主机物理内存的 100%。
举个例子,常见的 P1v3 规格,官方给的配额是 8GB 内存。这 8GB 是给你的应用进程的可用额度,不是宿主机上所有内存的 8GB。宿主机的总内存可能大得多,系统要给 Windows 内核、沙箱运行时、其他租户以及各种监控代理留空间。如果你的应用能用满这 8GB 配额,已经是把这个规格榨干了,不可能指望任务管理器里看到宿主机 100% 被你的 w3wp.exe 吃掉。
如果你用的是共享层(Free / Shared)或者基础层,配额逻辑更复杂,还可能跟同一计划内其他 App 共享资源。但核心结论一样:App Service 的"内存"指标,是围绕配额来计算的百分比,不是宿主机物理内存的百分比。
1.2 门户里的"内存"到底在看什么
在 Azure 门户的 App Service "指标" 页面里,有一个很常用的指标叫 Memory working set。很多人直接看这个百分比,然后发现它到了某个值就再也不涨了,于是在群里问:是不是被限制了?
这里要澄清一下:working set 是进程当前驻留在物理内存中的那部分页面集合,不是进程累计分配的虚拟内存。对一个 .NET 进程来说,它申请了大量托管堆内存,但真正放进物理内存的只有被实际访问的部分,其余部分可以驻留在磁盘页面文件里,或者还没被系统换入。
更关键的是:App Service 的配额指标是 working set 与配额的比率,不是"宿主机内存使用率"。所以你会看到一种现象:进程的私有字节(Private Bytes)已经涨得很高了,但门户里的 Mem 百分比还是 70% 左右不动。这个在后面讲进程内存概念时再展开。
注意:不要用宿主机视角去理解 App Service 里的内存指标。在你的进程眼里,"内存"是配额;在宿主机的眼里,"内存"是整个物理机。这两个概念差了十万八千里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. .NET 侧的内存机制:GC 不会让你把内存用满
2.1 GC 的分代回收与内存压力感知
如果你真的一直给 .NET 进程塞对象,托管堆会无限增长吗?不会。.NET 的垃圾回收器(GC)有一套非常成熟的"内存压力(memory pressure)"感知机制,它在分配速率过高或内存吃紧时会启动回收,而不是等到物理内存耗尽才动手。
GC 是分代回收,分 Gen0、Gen1、Gen2 和 LOH(大对象堆)。新对象先进入 Gen0,小的对象回收频繁,大的对象(大于 85000 字节)直接进 LOH,通常不轻易回收。当应用接近配额上限时,GC 会观察到系统层面的内存压力变大,于是更激进地触发 Gen2 回收和 LOH 压缩,把不用的对象清掉,堆大小就会维持在某个稳定水位上,而不是一路涨到 100%。
这就是你看到的"内存到了 80% 就不再涨"的很常见的原因之一:GC 在帮你不让内存继续涨。你往堆里塞什么,它回收得也快,最后净增长趋近于零。这不是限制,是 .NET 自我保命的设计。
2.2 工作集、私有字节、虚拟大小:三个"内存"别搞混
我在排查这类问题时,经常发现大家把几个概念混在一起,导致方向完全跑偏。这里用生活化的方式区分一下:
- 工作集(Working Set):相当于"一个人当前真正坐在工位上占用的桌子面积"。桌上有东西算工作集,抽屉和柜子里没拿出来的不算。对进程来说,就是当前被物理内存支撑的页面集合。
- 私有字节(Private Bytes):相当于"这个人的工位加上名下的所有柜子"——包括还没用到的虚拟内存空间。进程只要向系统提交了内存,哪怕没访问,也会算进私有字节。
- 虚拟大小(Virtual Size):更夸张,相当于"你在楼里预定了所有可能用到的区域"——包括保留但尚未提交的地址空间。
在任务管理器里,你常看到的是"工作集(内存)";而在 .NET 性能计数器里,你经常关注的又是 #Bytes in all Heaps(托管堆当前占用的字节)和 Private Bytes。这导致一个经典误导:任务管理器里看着才 60% 内存,但代码里 GC.GetTotalMemory(false) 一测已经 4GB 了,或者反过来。
所以在 App Service 上判断"内存够不够",不要只看一个数字。你至少要同时看三样:工作集(对配额,决定是否触发配额限制)、私有字节(对进程,决定是否接近 32 位进程的虚拟地址上限)、托管堆大小(对应用逻辑,决定你的业务保留了多少对象)。
2.3 GC 模式与 32/64 位的影响
另一个容易被忽略的是 GC 模式。ASP.NET Core 在 App Service 上默认用的是 Server GC 还是 Workstation GC,取决于你的配置。Workstation GC 偏保守,回收频率高,堆不会膨胀得特别大;Server GC 会为每个逻辑 CPU 建一个托管堆,目标是把回收带来的停顿降到最低,代价是整体内存占用明显更高。
如果你的目的本来就是想"多吃内存、减少 GC 停顿",可以考虑在 web.config 或托管配置里显式开启 Server GC:
xml复制<configuration>
<runtime>
<gcServer enabled="true" />
</runtime>
</configuration>
注意这是个双刃剑:开了 Server GC 后,内存占用通常会更接近配额上限,如果某个时刻并发上来,GC 堆会铺得比 Workstation GC 宽不少,配额定得低的话反而更容易触发 500/502 经典错误。
64 位也要确认。老项目在 App Service 上默认 64 位,但有些历史原因导致 APP 被设为 32 位。32 位 .NET 进程虚拟地址空间默认只有 2GB(如果没开 LARGEADDRESSAWARE 就 2GB,开了约 4GB),哪怕配额给你 8GB,单个进程也没办法吃掉 8GB。遇到"内存怎么都上不去"且 Private Bytes 到 3GB 就封顶的情况,一定要先去检查平台位数。
3. 沙箱与宿主机的"隐藏内存"
3.1 App Service 沙箱的多租户隔离
App Service 的应用是跑在一个特殊沙箱里的,不只是普通进程那么简单。沙箱会对文件系统、网络、进程间通信等做一层隔离,同时平台还要在宿主上跑一些监控代理、日志收集和健康检查进程。这些系统开销虽然不是你应用的一部分,但会占宿主机内存。
也就是说,即使你在宿主机上打开任务管理器,看到的"可用内存"也不是你应用能用的全部。可用内存里,一部分被沙箱运行时预留着,一部分是系统缓存,还有一部分要留给同机其他租户的突发请求。配额制的意义就在于给每个租户划一个明确的水位线,防止一个应用影响整套物理机的稳定性。
3.2 Windows 系统的缓存与页表开销
这里还有一个容易被忽略的"隐形内存":Windows 系统的 Standby List(备用内存)和页面表(Page Table)。App Service 的实例本质上是 Windows 环境,系统为了加速文件访问会把大量文件页放在备用列表里,这些内存看起来是"已被占用",但实际随时可被应用抢占。而页面表本身要为进程的虚拟地址空间维护映射,一个动辄几十 GB 虚拟空间的 .NET 进程,页面表也会占用不小的物理内存。
所以你在宿主机层面看到"内存占用 90%",其中很可能有相当一部分是缓存和系统元数据,并不是你的应用在消耗。这也是为什么直接拿任务管理器的"整体内存占用率"去衡量 App Service 应用内存,是完全不靠谱的。
4. 实操:定位你的 .NET 应用到底能用到多少内存
4.1 用 Kudu 拿到进程级数据
App Service 自带一个隐藏入口叫 Kudu(SCM 站点),是排查这一类问题的第一站。打开方式是:
code复制https://<你的应用名>.scm.azurewebsites.net
进去之后顶部有 Process Explorer,可以直接看到 w3wp.exe(IIS 工作进程)的实时内存情况,包括 Working Set、Private Bytes、Virtual Size。
我压测时的观察习惯是:一边用工具压,一边盯 Kudu 里的 w3wp.exe。如果 Working Set 在某个数值附近反复横跳,且 Private Bytes 也在同步增长后又回落,那基本可以断定是 GC 在正常回收,应用还有余量;如果 Working Set 没有明显变化,但 Private Bytes 一直在涨,就要怀疑是有对象没释放,长期看会造成内存泄漏。
4.2 用 Application Insights 和计数器定位托管堆
另一个推荐是给 App 挂上 Application Insights,或者直接用 .NET 的诊断工具把 runtime counters 拉出来。.NET 环境下可以直接看这些计数器:
# Bytes in all Heaps:当前托管堆总大小,这个数字接近配额上限时说明你的业务对象确实很多。# Gen 0/1/2 Collections:回收次数,如果 Gen2 回收频繁,说明内存压力相当大。Large Object Heap size:大对象堆,这个如果持续增长,通常意味着有大的数组、byte[] 或者不可变字符串没释放。
之前帮人排查过一个场景:应用工作集稳定在 4GB 左右,但 LOH 已经 2.5GB,而且 Gen2 回收次数一路飙升。这种状态说明内存已经"很不健康"了,一旦请求峰值继续拉高,很容易触发配额上限然后重启。这时候就算你看到了"内存才 70%",也不能掉以轻心。
4.3 一个排查案例:压测到稳定点
我自己的一个项目,P1v3 是 8GB 配额。压测时工作集一路涨到 5.8GB 左右,然后稳稳停住。刚开始我也觉得是不是被限制了,但仔细观察后发现:GC 在 5.5GB 时会触发一次大回收,把堆从 5.8GB 压回 4.5GB,然后又涨回来,形成一个动态平衡。
算一下:进程本身(CLR 运行时、JIT 代码、GC 元数据)约 0.4GB,托管堆有效对象约 4.5GB,加上备用列表和页面缓存,8GB 配额里真正可让堆增长的空间其实是"8GB 减去进程开销和系统保留",大概 6.8GB 上下。压到 5.8GB 时,GC 已经判断内存压力偏大,开始高频回收了,所以你看到的工作集数值就是"平台能让你稳定到达的峰顶附近"。
如果我真的强行去挑战这 8GB 上限,方法也很粗暴:申请大量不被 GC 及时回收的对象,比如故意维持一个大静态缓存。这时候工作集会继续往上涨,冲到接近 8GB 时,App Service 会触发内存限制,然后返回 500/502,甚至直接杀进程回收。这才是真正的 100% 长什么样,代价是服务不可用。
5. 常见问题与排查技巧实录
5.1 为什么 Memory 指标到了 80% 就平了
这是最常见的问题。综合前面三点:
- 配额计算只针对工作集,工作集天然比提交的所有内存小。
- GC 看到内存压力后会主动回收,维持一个动态水位。
- 应用的业务形态决定了它有天然的内存天花板,比如并发请求有限、缓存对象有限。
如果代码没有明显问题,这个"80% 就平"的现象其实是健康的信号:平台在配额内给了你足够的余量,GC 也在干活。真正要担心的是两种极端:一是内存一直贴线 99%,随时可能重启;二是内存看起来不高,但频繁出现 OutOfMemory 或 502,那就说明内存里堆了太多未被统计的死对象(常见于 P/Invoke、非托管资源没释放)。
5.2 设置 MaxWorkingSet 或 ASP.NET 内存限制有没有用
有人会在代码里调用 Process.MaxWorkingSet 去压内存,或者在 web.config 里设置内存限制。说实话,在 App Service 上这套作用极其有限。
Process.MaxWorkingSet 调的是进程的工作集上限,而不是配额,对 App Service 的外层配额约束没有话语权。而且它只是告诉 Windows 这个进程最多占多少物理内存,进程如果 Active 页面超过这个值,系统会强制换页,可能导致性能大幅下降。你不是把内存"用满了",而是把它"换到磁盘了",这显然不是你要的效果。
5.3 内存高但 CPU 低怎么调整
遇到内存高、CPU 低的情况,通常是堆里堆积了大量缓存、Session、静态数据,或者有业务对象生命周期过长。建议优先做三件事:
- 把大对象拆小,避免频繁进 LOH。85000 字节以上的对象进大对象堆后回收成本很高,能拆就拆。
- 使用
MemoryCache或自定义 LRU 缓存时,要设一个合理的绝对过期时间和 SizeLimit,不能让缓存无限膨胀。 - 如果确实需要大量缓冲数据,考虑把所有业务状态外置到 Redis 或数据库,让 App Service 实例尽量无状态,这样水平扩展时内存压力天然分散。
5.4 真到内存配额抓狂时怎么办
当内存真的逼近配额,并且业务就是要大内存,不要只盯着代码优化,你还得算账:
- 扩大规格:从 P1v3 升到 P2v3、P3v3,内存配额翻倍,最直接但成本也在涨。
- 水平扩展:横向加实例,让多个实例分摊内存。这要求你的应用状态外置,Session 不能落本机,文件缓存也要统一放到外部存储。
- 开启自动缩放:基于内存指标做伸缩规则,比如内存超过 70% 就增加实例,低于 40% 再缩回去。
这几条路线可以组合,具体选哪种取决于你的业务形态和成本预期。但记住,扩容是手段,不是目的;一个内存持续增长的应用,扩容只是把爆炸时间延后。
写在最后
我在实际排查这类问题的过程中,最大的体会就是别跟"100%"较劲。App Service 上的内存指标本质是一个配额使用率,它告诉你的是"你离平台限制还有多远",而不是"你有多能占内存"。.NET 的 GC 会主动平衡堆大小,App Service 的沙箱和系统也有自己的开销,这些因素叠加在一起,就会形成你看到的"卡在 80%"。
真正该关心的是两件事:一是内存在长时间运行里是不是持续爬坡,是的话先查泄漏;二是压力测试下内存能不能稳定在一个水位,能稳定说明应用在这个规格下有足够的余量。
最后分享一个小经验:给 App Service 加一条内存告警,阈值设在配额的 75% 左右。大多数人发现问题时已经是 99% 出现重启了,等到了那一步,排查成本远高于提前设一条告警。内存这东西,理解了原理之后,其实就不可怕了——它只是你没有好好监控之前,觉得它很可怕而已。
