先说一个非常现实的问题:大多数做大数据的团队,在“资源”这件事上,过得其实挺拧巴的。白天业务高峰,数据量一上来,Spark、Flink任务排队排到天荒地老;到了晚上低峰期,集群资源又在那里躺着睡大觉,CPU和内存的利用率低到不好意思看监控。你要是问运维同学集群多大,他能告诉你多少个节点多少T内存;你要是问他这些资源到底被用掉了多少,他大概率会沉默。这个问题的本质,就是内存计算引擎的弹性伸缩能力跟不上业务节奏。
我这些年做了不少大数据平台和实时数仓的搭建与调优,说句实话,大部分集群的资源浪费率在40%以上。很多人一提弹性伸缩,就觉得是K8s加个HPA(Horizontal Pod Autoscaler)就完事了,但真落地的场景远没这么简单,尤其是以Spark、Flink为代表的内存计算框架,它们的伸缩逻辑和普通无状态微服务完全是两码事。这篇文章我从“为什么要弹”“到底弹什么”“怎么落地”以及“我踩过哪些坑”四个维度拆开来讲,尽量给到可参考的配置细节和排查思路。
1. 先说清楚:内存计算为什么非要谈弹性伸缩
1.1 从传统批处理的痛点说起
过去我们用MapReduce,任务的资源是静态分配的。你在提交任务时指定了10个TaskManager或者20个Executor,那么这20个进程就会一直占着集群的CPU和内存,哪怕你的数据量其实只需要5个进程就能跑完,剩下的15个也在那儿占着窝不干活。这种静态分配机制在离线跑批时代还能忍,反正每天凌晨固定时间跑,跑完就释放,资源利用率低点也就低了,没人会在凌晨3点去看监控曲线。
但到了内存计算时代,情况变了。Spark为什么比MapReduce快?因为它把中间结果放进内存,省掉了大量磁盘I/O和shuffle落盘。Flink为什么能做实时?因为它用内存维护状态(State),让算子能持续不断地处理无界流。这些引擎的性能优势,全是靠内存“堆”出来的。可问题也来了——内存是个稀缺又昂贵的资源,而且不同业务、不同时段对内存的需求差异极大。
举个例子,你有个数据服务部门,白天要跑十几个交互式查询,肯定希望Presto或者Trino能快速响应;到了晚上,又有一批T+1的离线任务需要大口径吃内存做Join、聚合。如果集群不能弹性伸缩,这两个场景就会互相抢资源。白天交互式查询抢不到内存导致超时,晚上批任务又把集群打到内存溢出。那怎么办?只能买更多的机器,然后继续忍受更大比例的资源浪费。
1.2 弹性伸缩到底“弹”的是什么
很多人有个误区,以为弹性伸缩就是“把节点数调多调少”。其实在内存计算领域,单纯的调节点数往往是最粗暴、见效最慢的办法。因为在大多数大数据引擎里,节点的增减意味着数据重分布、元数据变更,甚至需要重启集群。
真正的内存计算弹性伸缩,至少应该包含三个层面:
- 计算资源(Executor/TaskManager)的弹性:根据任务的积压量、处理延迟、CPU和内存使用率,动态增减计算进程的数量。
- 并行度(Parallelism)的弹性:一个作业内部,并行度不是写死不变的,而是能根据实时负载进行自适应调整。
- 内存占用的弹性:在堆内内存、堆外内存、RocksDB状态后端之间动态寻找平衡点,让内存既能满足实时计算状态存储,又不至于把节点内存耗尽。
简单来说,传统伸缩关心的是“我需要多少台机器”,内存计算的伸缩关心的是“这批任务在内存里到底怎么分布才算最优”。如果只盯着机器数量,就会忽略真正的性能瓶颈——内存的分配与释放是否跟上了数据流动的节奏。
我打一个比方,普通弹性伸缩像是商场里增减收银台的数量,人多了开窗口,人少了关窗口。而内存计算的弹性伸缩,更像是火锅店的配菜系统:调料台要随时补货(状态存储),后厨切菜的速度要跟上桌数(并行度调整),每桌的锅底大小要提前判断好(内存预估)——这几个环节是联动的,光加服务员解决不了涮肉太慢的问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 弹性伸缩的四个核心维度:设计决策比调参更重要
2.1 计算资源维度:从静态队列到动态配额
大多数公司的Yarn集群,历史上都采用“队列+静态资源”的模式。比如给实时计算团队划分一个queue,最多能用200个CPU、400G内存。这个配额是写死的,团队内任务再多也只能在配额内排队,配额外的资源即使空闲也用不了。
要做到弹性伸缩,首先就要打破这种静态配额思维。现在主流的方式是走Kubernetes化,Kubernetes天然支持资源配额(ResourceQuota)和资源池动态调整,可以让Spark/Flink作业以容器方式运行,容器的数量由上层调度器动态决定。
但Kubernetes化不是银弹。我看到过很多团队,把Spark作业塞进Pod后发现性能反而下降了。原因很简单:Spark Executor的JVM内存模型和容器内存之间,天然存在一层难以协调的“隔阂”。JVM堆内内存默认只占容器内存的一部分,如果配置不当,Pod的内存Limit设了8G,但JVM堆只有4G,剩下4G既不能被Spark使用,又无法被Kubernetes回收,白白浪费。
所以,资源维度的弹性伸缩,核心不是“容器化”这个动作,而是容器内存与JVM内存模型的统一规划。你要清楚地知道这个容器里哪些内存是给堆的,哪些是给堆外的,哪些是留给操作系统的。否则,弹得越猛,浪费得越狠。
当然,动态配额的确是资源弹性的一次解放。Yarn的Capacity Scheduler从3.x开始支持用弹性队列插件扩展出“按需分配”的EC(Elastic Capability),Kubernetes侧则通过动态资源池配合Cluster Autoscaler实现节点级别的自动扩缩容。这些工具的意义在于:让资源的分配从“人工预估”转向“系统按需”,后台无需再为某个团队手工调整几百行的XML配置。
2.2 执行粒度维度:Executor、Slot与并行度怎么联动
在Spark里谈弹性,绕不开Executor和Slot。一个Executor就是一个JVM进程,进程里包含多个Slot,每个Slot对应一个可执行的Task并发单元。并行度就是“同时有多少个Task在跑”,它决定了整个作业的资源利用效率。
静态提交时,spark.executor.instances设置几个就是几个,执行过程中不会再变。Spark 3.0之后引入了Dynamic Allocation,核心思路是:不需要在任务开始时把所有Executor都占满,而是根据Task队列的积压情况动态增减Executor数量。
动态分配有几个关键参数,配置时考虑清楚,否则容易适得其反:
spark.dynamicAllocation.enabled:开启动态分配功能,默认是关闭的。spark.dynamicAllocation.minExecutors和maxExecutors:分别定义至少保留和最多可以扩容到的Executor数量,这两个值决定了弹性伸缩的边界范围。spark.dynamicAllocation.initialExecutors:作业启动时,不一次性申请大量Executor,而是从一个小值(甚至1个)开始,根据后续需要逐步扩容。spark.dynamicAllocation.schedulerBacklogTimeout:核心参数,表示当Task积压时间超过该阈值时触发扩容。例如设置为5秒,意味着有Task等待超过5秒就增加一个Executor。spark.dynamicAllocation.executorIdleTimeout:核心参数,表示当Executor空闲时间超过该阈值时触发缩容。例如设置为60秒,意味着这个Executor空转超过60秒就会被回收释放。
从调度逻辑上看,Spark会每隔一段时间检查Task队列。如果积压严重,就按指数级速度增加Executor,5秒、10秒、20秒……直到达到maxExecutors;如果某Executor闲置超过10秒,就可以考虑释放。这种“指数扩容、逐渐缩容”的设计,是为了快速响应负载高峰,同时避免频繁抖动。
Flink同样有类似的机制,但Flink的弹性伸缩更多依赖作业重启后的状态恢复。Flink 1.17开始逐步完善了自适应并行度(Adaptive Parallelism)的机制,它可以基于算子繁忙程度动态调整并行度,而不需要任务重启。这点跟Spark的动态Executor原理不同,但目的是一致的:让并行度贴合数据流动速度,避免某些算子成为瓶颈。
2.3 内存维度:堆内堆外、执行与存储的内存博弈
到了内存计算的深层,你会发现弹性伸缩最终要“弹”的其实是内存的划分策略。Spark和Flink都会把内存分成执行内存(Execution/Shuffle)、存储内存(Storage/State)和保留内存(Reserved)。执行内存用于计算过程中数据的缓存、排序、聚合,存储内存用于持久化数据、维护状态。过去这两部分内存的比例几乎是写死的,比如Spark 2.x里spark.memory.fraction=0.6、spark.memory.storageFraction=0.5,意味着60%的JVM堆用于Spark管理,其中一半预留给存储。如果你有业务需要大量Shuffle,但另一个业务是大量缓存历史数据,这两个作业之间内存无法互通,就会出现“这边内存溢出了一边内存闲着”的困境。
现在的引擎都在尝试让内存“弹起来”。Spark 3.x引入了spark.memory.remote和堆外内存的灵活配置,Flink则通过taskmanager.memory.managed.fraction控制托管内存的比例。但从实际经验看,动态调整内存配比并不是引擎能自动完成的,它需要结合监控数据来判断:
- 如果观察到GC频繁且Full GC时间长,说明堆内执行内存不够,应该增加
spark.executor.memoryOverheadFactor或适当调大executor堆。 - 如果观察到磁盘shuffle严重,说明执行内存中的shuffle部分不足,需要调大
spark.shuffle.memoryFraction(在新版本中取消了这个参数,通过spark.memory.fraction间接控制)。 - 如果State存储频繁触发RocksDB的落盘,说明堆外或RocksDB块缓存需要调优,而不是一味增加整体JVM内存。
这里我推荐一个原则:能动态调整参数,就别启动时就写死;能通过状态后端分离存储,就别把所有内存都压在JVM堆里。比如Flink配合RocksDB状态后端时,状态本身是存储在堆外的RocksDB中的,而主要内存占用变成了RocksDB的BlockCache和WriteBuffer。此时堆内JVM内存主要给框架和算子用,堆外RocksDB才是大头。这种情况下,内存弹性伸缩的重点就从“调整JVM堆大小”变成了“动态调整RocksDB的BlockCache大小”,两者差别很大。
2.4 存储维度:Shuffle服务如何成为弹性瓶颈
还有一个经常被忽视的维度,是Shuffle服务。Spark的Shuffle过程需要将Map阶段的输出写到磁盘或内存,再由Reduce阶段拉取。在原生模式下,每个Executor的BlockManager既要保存自己的数据,又要响应其他Executor的数据拉取请求。当Executor数量动态伸缩时,Shuffle数据的位置在不断变化,非常容易导致拉取失败或者大量网络I/O。
这也是为什么很多机构开始落地Remote Shuffle Service(RSS)的原因,比如腾讯的Angel、Apache Uniffle(孵化中)。RSS将Shuffle数据从Executor中剥离出来,放到独立的集群中统一管理。好处在于:Executor的伸缩不再影响Shuffle数据的完整性;Executor缩容时,不再需要等待或复制大量Shuffle数据;Map和Reduce任务的资源可以独立伸缩。
我建议如果一个团队的Spark作业经常发生Executor动态缩容后任务重试的情况,考虑引入RSS。因为每一次Executor被回收,如果它上面还有未拉取完的Shuffle数据,就会导致下游任务一直等或者直接拉取失败。这种隐性故障在静态Executor下不明显,但一旦开启Dynamic Allocation,几乎一定会遇到。
内存计算的弹性伸缩要落地,这四个维度缺一不可。资源是容器级别的,执行粒度是作业级别的,内存是进程级别的,Shuffle是集群级别的。你把四个维度分别想清楚,才能更从容地判断:当前系统到底该扩什么、缩什么。
3. 落地方案与关键参数:从配置到实战
3.1 基于Kubernetes的动态资源分配
现在新搭建的平台,不少团队已经直接选择了Spark on Kubernetes。相比Yarn,Kubernetes的好处是它的弹性伸缩生态更成熟,有Cluster Autoscaler、HPA/VPA等配套组件,节点层面的伸缩几乎不用自己写代码。
但Spark on Kubernetes落地时,有几个细节值得注意。第一,不能让每个Executor都对应一个Pod中的随机调度,最好使用spark.kubernetes.allocation.driver.podTemplateFile等配置来定义Pod模板,统一管理内存、CPU、标签和亲和性。第二,NodeSelector或拓扑分布约束要提前设计好,否则波峰来临时新增的Pod会被调度到性能差异很大的节点上,导致同一个作业的Executor速度参差不齐。第三,日志收集要提前想好,Executor Pod销毁后日志日志就没了,建议用spark.kubernetes.executor.log.rollOverStrategy配合日志采集Agent,把executor日志统一送到Kafka或EFK。
这个时候,节点的自动伸缩配置要很慎重。Cluster Autoscaler的ScaleDownUtilizationThreshold建议设置在50%左右,太低会导致节点难以被回收,太高会导致频繁驱逐Pod。而实际生产中,扩容速度往往比缩容更关键,建议配置scale-down-unneeded-time为10分钟以上,避免因为短时波动就把节点缩掉,结果5分钟后又要扩容,造成徒劳的资源震荡。
3.2 Spark动态Executor的实战配置
下面给出一份我常用的Spark动态资源配置模板,适用于SQL批处理和ETL类作业,已在多个生产环境验证过稳定性。
properties复制spark.dynamicAllocation.enabled=true
spark.dynamicAllocation.shuffleTracking.enabled=true
spark.dynamicAllocation.shuffleTracking.timeout=300s
spark.dynamicAllocation.initialExecutors=2
spark.dynamicAllocation.minExecutors=2
spark.dynamicAllocation.maxExecutors=50
spark.dynamicAllocation.executorIdleTimeout=60s
spark.dynamicAllocation.schedulerBacklogTimeout=5s
spark.dynamicAllocation.sustainedSchedulerBacklogTimeout=5s
spark.executor.instances=2
spark.executor.cores=4
spark.executor.memory=8g
spark.executor.memoryOverhead=2g
spark.shuffle.service.enabled=true
几点说明:
- 我保留了
spark.executor.instances=2,目的是让作业初始就有一定的执行能力,不至于因为冷启动导致首个批次的延迟太高。 shuffleTracking.enabled=true非常关键,这是Shuffle跟踪机制,它可以让Spark在缩容Executor前先检查该Executor是否有未完成的Shuffle输出。如果没有开启,一旦缩容,Shuffle数据可能会丢失,导致Shuffle Fetch Failed,作业直接失败。schedulerBacklogTimeout与sustainedSchedulerBacklogTimeout的配合:前者用于快速扩容,后者用于持续积压时的缓慢扩容。设置成相同值,可以让扩容节奏更线性,不会突然增加太多Executor导致负载尖峰。
在Flink on Kubernetes场景,动态扩缩容的主要方式是修改parallelism.default后执行flink patch或重启作业。这种方式涉及到作业的Savepoint和恢复,如果担心状态恢复慢,建议要么把并行度设置为比实际数据峰值需求还高一档,要么使用Flink 1.19之后的自适应调度功能。自适应调度会为每个算子动态分配Slot,使得当TaskManager加入或退出时,尽量不重启整个作业。
3.3 Flink内存模型的弹性调节
Flink的内存配置比Spark更细致,因为流计算对稳定性要求更高。Flink中每个TaskManager进程内的内存由三个主要部分组成:
- JVM堆:用于框架、用户代码和TaskManager自身的算子状态。
- 托管内存(Managed Memory):主要用于RocksDB状态后端、内部排序和批量任务缓存。默认大小是进程总内存的0.4倍,可以通过
taskmanager.memory.managed.fraction调整。 - 直接内存(Direct Memory):用于网络缓冲和流式读写。
动态调整Flink内存时,重点观察两个指标:一个是taskmanager.memory.managed.used,如果该值长期接近total,说明托管内存吃紧,应该扩大managed占比或增加TaskManager数量;另一个是taskmanager.memory.jvm-overhead,如果JVM Overhead(用于JVM自身、线程栈等)总是被占满,说明进程总内存不够,而不是某个子模块不够。
经常有同学问:为什么我在Flink Web UI上看内存使用率还不到50%,作业却一直报Container exited?这种问题十有八九是process memory参数配置不当。Kubernetes的Pod 内存Limit必须大于Flink进程所有内存之和,否则会被OOM Killer杀掉。如果taskmanager.memory.process.size设为8g,而Pod的Limit也是8g,那这个作业极大概率启动后不久就被杀掉,因为JVM自身的开销永远不可能等于0。
建议的Flink TaskManager内存配比是:
| 配置项 | 建议值 | 说明 |
|---|---|---|
taskmanager.memory.process.size |
10g | 配置为Pod Limit的80%左右,预留出操作系统开销 |
taskmanager.memory.managed.fraction |
0.4(默认) | 使用RocksDB时保持默认即可 |
taskmanager.memory.jvm-overhead.fraction |
0.1 | 如果频繁OOM,先调这个值 |
taskmanager.memory.network.memory.min/max |
64m/1g | 网络缓冲,避免反压时内存暴涨 |
3.4 监控与容量评估:用nmon和Web UI做量化分析
说完了配置,再聊监控。弹性伸缩不是设置好参数就结束了,它的前提是你能准确看到内存到底用在哪、效果怎么样。这里提一个热词:nmon内存使用率计算。
nmon是IBM出品的性能监控工具,在Linux上很常用。它导出的CSV里,内存相关的关键字段有memtotal(总内存)、memfree(空闲内存)、memcached(页缓存)等。计算内存使用率的公式一般写成:
code复制内存使用率 = (memtotal - memfree - memcached - membuffer) / memtotal * 100%
为什么要减去cache和buffer?因为对于JVM类的大数据作业,操作系统的页缓存并不表示内存已经被应用程序“占用”,而是可以被随时回收的缓存,不能跟应用实际使用量混淆。如果不减这两个值,很多情况下会发现集群“内存使用爆满”,但实际上真正被应用占用的内存并没有那么多,容易误触发扩容,导致弹性伸缩反应过度。
除了nmon,Spark Web UI和Flink Web UI这两个工具也建议重视起来。Shuffle Read Size和Shuffle Write Size能在界面上直接看到,这两个指标与内存压力密切相关。如果发现Shuffle Write很大,说明上游数据量到下游前需要大量中间落盘,这时候内存弹性再大也挡不住磁盘I/O瓶颈,需要从并行度和数据分区方案入手,而不是单纯加内存。
另外,一套聚合监控面板是必须的。用Prometheus+Grafana把每个节点的nmon数据、Executor的GC次数、Flink TaskManager的Managed Memory用量、Spark的Shuffle数据拉取耗时全部放在一张图上。一个运营良好的大数据集群,每天的内存申请曲线应该是明显的“山峰状”,峰谷明显,而不是一条水平线。如果你看到集群内存申请率是一天24小时一条直线,大概率没有开启弹性伸缩,或者即使开了也没起作用。
4. 我踩过的坑:常见问题与排查实录
4.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 开启动态分配后作业比原来更慢 | Executor频繁扩缩导致调度抖动 | 调大executorIdleTimeout,禁止过于敏感的缩容 |
| Shuffle Fetch Failed频繁出现 | 缩容时Executor上还有未拉取的Shuffle数据 | 开启shuffleTracking.enabled,或引入RSS |
| Pod被杀报OOMKilled | 进程内存与Pod Limit配置不合理 | 调整spark.executor.memoryOverheadFactor,缩小process.size与Limit差值 |
| GC时间占比持续超过10% | JVM堆内执行内存不足 | 调大spark.executor.memory或者改用堆外存储(RocksDB) |
| 缩容后节点一直无法释放 | 集群中还有Local Storage或HDFS缓存占用 | 检查Node是否有非弹性数据,配合节点亲和性避免驱逐 |
4.2 三个高频坑的复盘
第一个坑是动态Executor开启后,Yarn或Kubernetes的资源出现“过山车效应”。我遇到过某平台在凌晨跑批时,资源申请瞬间从20个Executors跳到50个,把集群打满,然后不到10分钟又缩回20个,节点扩容和缩容根本来不及反应,调度器频繁陷入阻塞。排查后发现是spark.dynamicAllocation.schedulerBacklogTimeout设置太短,只有1秒。后台的任务稍微积累一下,每秒都会触发扩容判断,导致指数扩容被频繁触发。把这个值调到5~10秒之后,曲线稳定了很多。
第二个坑是应用了动态分配后,部分Executor因为长期空闲被回收,但它的本地存储(Local Storage)上有大量需要保留下来的Shuffle数据,导致收尾工作非常缓慢。这个问题的直接解法是开启spark.dynamicAllocation.shuffleTracking.enabled,让Spark在回收Executor前先确认Shuffle数据可以被其他Executor拉取。如果Shuffle量仍然很大,建议考虑RSS,或者将shuffleTracking.timeout设置得长一些,留出足够的数据迁移时间。
第三个坑是混合负载场景下,Spark作业的动态扩容会抢走Flink作业的内存,导致Flink作业反压。这不是某一个引擎的问题,而是整体调度策略的问题。建议在Kubernetes或Yarn层面给实时作业设置较大的资源权重或优先级,或者把实时作业和批作业调度到不同的节点池,从物理上隔离。否则,即便是完美的内存计算弹性伸缩,也无法应对不同优先级作业之间的资源竞争。
4.3 内存计算的整体部署策略:从热词“大数据集群部署策略”展开
关于“大数据集群部署策略”这个热词,我的经验是:弹性伸缩最好作为“第二层”手段,而不是“第一层”目标。第一层应该是合理的集群基座规划——在初始部署集群时,就要明确Core节点(常驻计算)与Task节点(弹性计算)的边界。Core节点承载NameNode、ResourceManager、HBase Master等重要组件,不参与弹性伸缩;Task节点专门负责跑Executor和TaskManager,可以随时扩缩容。同时,Task节点和存储分离,数据尽量放在HDFS或云OSS之上,这样弹性伸缩的节点不承担持久化数据的风险。如果一开始部署就搞全节点混部,后面做弹性伸缩时会发现,回收任何一台机器都可能导致元数据副本数不足,或者某些数据块变成单副本,风险极大。
还有一个被很多人忽略的部署细节,是机架感知(Rack Awareness)。弹性伸缩新增节点往往来自同一个批次或同一个可用区,如果不做机架感知,扩容后会出现大量数据副本堆积在同一机架,一旦机架故障,数据可用性大幅下降。部署策略应该是“弹性节点预注册、机架分散、数据副本自动再平衡”,这些基础工作做得越扎实,后续资源伸缩越安全。
4.4 面试视角扩展:怎么向别人讲清楚这件事
既然热搜词里还有“大数据面试题”,我也顺带从面试官的角度聊两句。面试中如果有人跟我说他做过内存计算的弹性伸缩,我会先问三个问题:
第一,你如何判断一个Spark作业是否真的需要弹性伸缩?很多人的答案都是“数据量大、任务跑得慢”,但更准确的判断方法是看曲线,延迟是否与数据输入量正相关、GC比例是否波动剧烈、是否存在大量时间在等待资源而非执行任务。
第二,动态Executor的缩容对Shuffle的影响是怎样的?能答出“需要ShuffleTracking”的人,说明真的踩过坑;能进一步说出“RSS可以解决这个问题的本质”的人,我会倾向于给高分。
第三,如果Flink作业遇到状态膨胀,你是选择增加内存还是调整状态后端?很多人会脱口而出“加内存”,但这其实不是好的答案。内存是有限的,持续增长的状态迟早会耗尽任何节点的内存。正确的思路是:选用RocksDB,开启增量检查点,对状态设置TTL,最后再考虑扩容。内存计算的弹性伸缩,应该是“业务状态在可控范围内动态使用内存”,而不是“无限给内存扩容来兜底”。
这四个问题想明白,内存计算弹性伸缩的轮廓基本就清楚了。即使面试官没有直接问,这些思考也会在具体问题的回答中体现出来,并让整体回答更有深度、更像实战过的人。
最后说一点我个人在实际操作中的体会。做弹性伸缩最难的,不是技术选型,也不是参数调优,而是你要接受“资源利用率不会永远处于高位”这件事。一个设计合理的集群,是要在“业务高峰时够用”“业务低谷时不浪费”之间寻找动态平衡,而不是为了追求某个好看的指标,让系统始终处于紧绷的状态。弹性伸缩的终极目标,是用最合理的资源,扛住最真实的业务波动。我见过很多团队,白天觉得资源不够,晚上资源大把空闲又不舍得释放,归根结底,问题往往不在资源本身,而在分配策略和伸缩机制上。我个人的建议是:从最小的动态Executor开始跑起来,多观察几轮业务高峰,再逐步放开伸缩边界,远比一次性配一个很大的伸缩范围要稳健。
