news 2026/10/16 15:11:02

Flink Task数据交换机制深入剖析:从Buffer到背压的完整链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Flink Task数据交换机制深入剖析:从Buffer到背压的完整链路

搞Flink的人,十有八九都经历过这样一个阶段:程序跑起来了,数据也进来了,但一旦并发上到几百、上千,背压指标一路上升,整个流任务开始抖,日志里全是超时,但代码一行都没改。这时候你去看任务拓扑,Task和Task之间的数据是怎么传的,很多人其实说不清楚。里面到底谁负责把数据放到下一个算子,又是谁在控制数度,是通过内存拷贝还是直接扔给网络,背压又是怎么一路传回去的。这篇内容就是要把这些东西一层层剥开,用工程视角把Flink Task的数据交换机制讲清楚,适合正在做Flink开发和调优、以及准备系统梳理Flink知识体系的人。

我要讲的不是那种“把数据从上游发到下游”的抽象解答,而是落到真实任务调度、内存缓冲区、网络流控、Channel策略这些实际会遇到的点,并且会把排障思路和常用参数也一并带上。读完你就能回答三类问题:同一个TaskManager里的两个Task是怎么交换数据的?跨节点传输到底经历了哪几步?背压指标异常时,应该去看哪几个地方。

1. 先从数据流问题说起

1.1 为什么Task之间的数据交换会被单独拿出来讲

Flink作业跑起来以后,整个DAG被拆成一系列ExecutionVertex,每个Vertex对应一个或多个并行子任务,也就是Task。这些Task并不是孤立的,上游Task计算结果要交给下游Task继续算,于是就会在Task之间形成一条数据通道。麻烦的是,上游和下游可能在同一个进程里,也可能在不同的机器上,甚至可能分属于不同的资源管理任务。不同的情况,数据交换的路径和开销完全不一样。

很多人写业务代码时完全不用关心这层,反正DataStream API里用了keyBy、broadcast、rebalance这些算子,框架会自动接好。但一旦你开始调优,问题就来了:为什么parallelism增大后吞吐没线性增长?为什么一个key所在的分区数据特别多,把邻接的Task拖垮了?为什么Netty的堆外内存一直涨,反复GC?这些问题的根因全都指向数据交换机制。不理解底层,就只能不断盲目加资源,根本解决不了。

所以我建议,哪怕你是业务开发出身,也值得花时间把这套机制的前半部分吃透:数据以什么粒度在Task之间移动,内存缓冲区在中间扮演什么角色,流控是怎么发生的,以及数据被送往哪个下游Task是由谁决定的。这几块搞明白,再去看Flink官方文档里的内存模型和背压章节,会顺畅很多。

1.2 数据交换发生在哪个环节:边、分区、算子链

在Flink中,两个逻辑算子之间由一条边(Edge)连接。经过优化器处理之后,这条边可能是算子链内的边,也可能是算子链之间的边。链内的数据交换不经过序列化,数据在同一个Task的内部算子图中直接以Java对象引用传递,速度最快,开销基本为零。链之间的交换,也就是Task与Task之间的交换,才是最需要关注的。

Task与Task之间交换数据,从生产侧看由ResultPartition管理输出结果,从消费侧看由InputGate接收并分发数据。ResultPartition内部又按并行下游Task数量拆成多个ResultSubpartition,每个下游Task对应一个分区。可以打一个比方:上游Task就像一个主播,它把直播画面复制成多份分别推给不同频道的机房,每个频道对应一个下游Task;推送时能不能发、发多少数据,还需要看每个机房反馈的剩余容量。

这里容易忽略的是“数据分区”概念。数据不是一股脑塞给所有下游,而是每条记录都经过ChannelSelector选择,决定去往某个具体分区。正是这种一个个分区独立传输、独立流控的方式,让下游某一个Task卡住时,只影响对应的上游分区,其他分区还能继续跑。这是Flink在分布式流处理里比较讲究的地方,也是后面排查数据倾斜的线索来源。

1.3 先分清三种边界:算子间、节点内、节点间

数据交换大致可以分成三层来看。第一层是算子链内部的传递,前面说过了,属于同一个Task,不跨线程不跨内存,完全可以忽略开销。第二层是同一个TaskManager内不同Task之间的传递,它们共享同一个JVM和堆外内存池,交换路径不需要走网络,但仍然要经过ResultPartition和InputGate,会产生序列化和反序列化,不过相比跨节点的开销小得多。第三层是跨TaskManager,也就是跨节点传递,需要经过Netty网络栈,涉及Socket连接、网络缓冲区、序列化字节流、反序列化,开销最大。

这三层边界在优化时非常重要。比如你配置了taskmanager.network.memory.min/max网络内存,只有节点间和节点内的交换都会用到网络缓冲区,而算子链内部根本不会申请这些Buffer。又比如你把两个耗时算子放到同一个Slot共享组里,则可能让它们运行在同一个TaskManager中,从而把一部分跨节点传输变成节点内传输,降低延迟。但这也不是无脑做,如果上游算子本来吞吐就很低,把它跟高负载算子放在一起,反而挤占资源。理解边界之后,调度和资源分配才能做得有根据。

2. 关键通道:Buffer与MemorySegment

2.1 缓冲区不是抽象概念,而是一块块内存

数据结构上,Flink交换数据的最小单位是Buffer,Buffer的内部载体叫做MemorySegment,默认通常是32KB大小的堆外内存块。每个Buffer由MemorySegment加上一些描述信息组成,比如开始位置、有效数据长度等。在传输层,Buffer就是Netty中能够直接发包的数据块;在上层API里,每条记录会通过Serializer被写入Buffer,一个Buffer可能包含一条或多条记录,取决于数据大小和序列化结果。

需要特别注意,这些内存并不是散着用的,而是由一个称为NetworkBufferPool的公共池统一管理。TaskManager启动时会根据网络内存配置预先分配一批MemorySegment,各Task再从这个公共池里申请属于自己的LocalBufferPool。这样设计的目的是能精确控制网络内存占用,避免频繁new数组带来的GC压力和碎片。实际调优时,如果网络内存不够,常见表现是java.io.IOException: Insufficient number of network buffers,那就是公共池里已经没有MemorySegment可用了。

2.2 记录在Buffer里怎么组织和切分

一条记录要送入Buffer,需要经过序列化。Flink的序列化器会先把当前记录按照类型信息写入一个临时输出流,再拷贝到Buffer。如果单条记录长度超过Buffer默认大小,还能拆成多个Buffer片段,接收端依靠数据记录头部的长度信息和对齐机制,把片段重新拼接成完整记录。这个过程并不像看起来那么简单,因为Buffer之间可能还存在属于其他记录的数据。

我排查过几次反序列化异常,头部长度错位往往出现在上游升级了Avro或JSON Schema而下游没同步升级的场景。所以有个经验:如果你动了序列化相关配置或者上游数据类型定义,务必先小流量验证,别直接全量重启,否则会看到各种奇奇怪怪的IllegalStateException或EOFException。序列化器版本一致性,是数据交换正常工作的前提。

2.3 为什么要用Buffer池而不是频繁申请内存

很多初学的人会问,为什么Flink不直接new一个byte[]用来传输,非要搞一个Buffer池出来。核心原因是并发场景下的内存可控性。流处理任务里,每个Task可能同时缓冲几百上千条等待发送的数据,如果不加以限制,那么所有TaskManager的内存都会在瞬时高峰被打满,轻则大量GC,重则触发OOM和任务崩溃。

Buffer池同时是信用机制的基础。后面会讲Credit流控,如果内存是动态申请而非池化,那么上游根本不知道下游到底还能收多少数据,就没法做逐通道的流控。可以说,Buffer池既是一道内存安全阀,也是分布式流量控制的支撑结构。所以调参时要记住两点:一是网络内存要按真实吞吐量预留,太小会频繁背压或报错,太大会挤占托管内存;二是如果看到某个Task的Buffer使用率长期满负荷,就应该优先排查下游消费能力,而不是无脑调大内存。

3. 两种基本传输路径:本地直传与网络传输

3.1 本地直传:同进程内的零拷贝与引用复用

当上下游Task位于同一个TaskManager时,数据交换路径明显缩短。上游ResultSubpartition写完Buffer后,并不需要交给Netty发送。Flink会直接将Buffer引用传递给下游Task的InputChannel,下游拿到这个引用后可以直接读取其中的数据。整个过程不会发生一次Socket写,也不会把数据从堆外复制到堆内再复制出去。

这里有个关键机制叫Buffer回收。上游写完一个Buffer,把引用交给下游后,该Buffer的引用计数加一,当下游完成数据处理并释放引用,计数减一,归零时Buffer会被归还给对应的LocalBufferPool,以便继续复用。这种引用传递方式可以最大程度减少内存拷贝,但同时也要求开发者在写自定义算子时不能随意持有数据对象,否则会阻塞Buffer回收,最终导致网络内存耗尽。这是一个很容易踩的坑,自定义Source或自定义算子里把Data对象放到成员变量里不释放,时间一长任务就卡住。

3.2 网络传输:上游推送的底层逻辑

跨TaskManager的传输路径会明显拉长。上游Task发出的数据,经过ResultPartition写入Buffer后,由TaskManager的Netty发送端把Buffer放入网络连接中发送。这里Flink使用的底层传输通道是Netty,每个TaskManager之间可能维护多个连接,为了节省连接资源,多个Job、多个子分区会复用小连接。数据发送到对端后,对端的Netty接收端把数据写入对应的InputChannel,下游Task再从InputChannel中读取并反序列化。

这里涉及一个长期被误解的问题:Flink用的是推模式还是拉模式?从TCP发送看,上游基于Credit授权主动推送数据;而Credit本身又由下游根据自身Buffer消费情况不断向上申请。因此本质上可以理解成一种“下游按需放行的推模式”,既有推的实时性,又有拉的保护性。这种设计比纯拉模式延迟更低,比纯推模式更稳定。对比Spark早期基于拉取的Shuffle机制,Flink在流式场景下能获得更低的端到端延迟,原因就在这里。

3.3 混合部署时的传输路径选择

一个实际Job中,上下游并行度可能很大,比如上游10个并发,下游10个并发,那么理论上会形成100个执行通道,但每个通道根据调度结果,有的走本地,有的走远程。Flink在任务调度时会尽可能把上下游分布在同一TaskManager,以减少远程传输,当然能否成功,还要看集群资源和Slot分配。某次我遇到一个Job,发现几乎所有远程传输都集中在同一对TaskManager之间,导致单机网络流量跑到近千兆,而其他节点几乎空闲。定位后发现是资源调度时,大量Slot被分配到同一台机器。

解决办法有很多:调整任务调度策略,让Task更均匀地分布;或者通过将相关算子放到同一个Slot共享组中,强制它们同域运行。也有人用CoLocationGroup让迭代体上下游绑定在同一节点。这种调度优化在实际集群里很有价值,毕竟网络传输的代价远高于同进程内Buffer引用交换。好,既然是概述,不深入调度算法,但至少你要明白传输路径不是固定死的,可以通过共享组、调度策略等干预。

4. 流控与背压:数据交换的自动调节阀

4.1 Credit-based流控到底控的是什么

Flink从1.5起默认使用基于Credit的反压机制。每个远程InputChannel会以固定周期向上游对应的ResultSubpartition发送信用额度,告诉对方“你还可以发多少个Buffer给我”,每个Buffer对应一个Credit。上游只有拿到Credit才能发送对应数量的Buffer,发了一个,信用就减一。如果下游处理速度变慢,它能发给上游的Credit数量就会变少,上游逐渐拿不到新Credit,于是自动停发。

Credit机制保护的正是接收端内存。如果没有Credit限制,上游会无限往对端网络缓冲区里塞数据,接收端网络内存很快被打满,Netty缓冲区堆积,最终出现内存溢出或大面积输入阻塞。这个机制在跨节点传输时尤为重要,因为网络延迟不稳定,光靠TCP自身拥塞控制并不能完全匹配流处理对细粒度流控的要求。

4.2 背压如何从下游反向传回上游

背压的传导顺着数据流向的反方向扩展。假设下游某个Sink写入外部系统很慢,它的Task处理不过来,InputChannel逐渐攒满Buffer,LocalBufferPool也被耗尽。Netty接收端因此停止向这个InputChannel写数据,对端相应ResultSubpartition发送的Buffer无法被接收端接受,请求积压,上游发送端拿不到Credit,于是暂停发送。再往上游,上游Task因为OutputBuffer池满,处理完当前记录后阻塞在写输出这一步,自然放慢处理速度。

这条传导链可以一直追溯到Source端。所以你会发现,当外部系统性能抖动时,整个数据管道都会变慢,源头出现反压。这不是Bug,而是流处理框架的自我保护。很多做实时数仓的人对此深有体会:HBase或Kafka写不进去了,整个Flink任务端到端延迟变大,但不会崩掉。反过来说,如果某个中间算子没有正确传递背压(比如使用了无界缓存或异步io带来的链上缓冲),就会形成“中间缓冲、源头崩溃”的隐患。

4.3 背压在实际排障中的表现与判断

Flink Web UI上每个Task会有BackPressured时间百分比,这个数值是指在采样周期内检测到输出Buffer被阻塞或输入通道满的时间比例。如果长期高于50%,说明这条链路存在能力瓶颈。结合指标来看,最直观的是outPoolUsage和inPoolUsage。如果下游Task的输入Buffer池使用率很高,说明下游消费速度跟不上;如果上游Task的输出Buffer池使用率很高,则可能是下游的反压信号已经传回来了。

排障时我一般先确认背压起点在哪里,也就是哪一层Task的BackPressured最明显。如果这个Task的输入侧指标还好,输出侧已经满载,问题就在它自身逻辑或下游;如果输入侧就满载,则它的上游很可能已经背压。用这种逐层收缩的方式,定位瓶颈比直接看平均指标快得多。也提醒一句,快速序列化测试和吞吐压测不能只看极端情况,要结合反压持续时间,有些任务只是瞬时波峰导致短暂反压,不影响整体SLA。

5. 连接器机制:ChannelSelector如何决定每条数据去哪

5.1 几种分布模式的工作方式

数据路由到哪个下游Task,由ChannelSelector负责,DataStream API层面体现为不同的连接模式。最常见的Forward是上游和下游的一对一连接,数据只传给固定的下游Task,通常出现在map之后接map且并行度一致的情况,配合算子链可以做到零序列化。其次是最典型的Hash分区,靠keyBy触发,对每条记录的key做哈希后映射到下游某个分区,保证相同key进入同一个Task。

还有Broadcast,把每条记录复制一份发给所有下游Task,适合广播维度表之类的场景。Rebalance则轮流分发,把数据按下游并发数做Round-Robin,常用于缓解数据倾斜;但注意Rebalance是要付出序列化和分区成本的,不是无脑用。最后还有Rescale,先整组再轮询,能在减少数据跨节点传输的基础上做局部均衡,适合上下游并行度成倍数关系的场景。这几种模式的实现背后都对应不同的ResultSubpartition的写入方式,也决定了Buffer发送的目标。

5.2 不同模式对数据交换性能的影响

从数据交换角度看,Forward模式下如果两个Task正好在同一个TaskManager且能算子链化,那么几乎不产生成本和延迟;如果算子链被断开,则每个Task都要走一次序列化和Buffer交换,性能明显下降。Hash模式基本无法算子链化,因为数据记录跨分区流动,必须在Task边界重新分发。Broadcast则意味着上游每产生一块Buffer,就要复制多份,对网络和内存压力最大。

所以你在设计Job的时候,能链内合并的密集计算尽量链内合并,能用Forward模式解决的就不必用Rebalance。同时也要知道,并行度不一致也会打断Forward模式,自动退化成Rebalance。很多任务某条链路突然变慢,查看拓扑发现某个地图操作下游并行度从8变成了4,那就只能做分区重排,性能自然下来。这种机制性问题,加并行度未必能解决,关键要看连接模式是否与计算模式匹配。

5.3 如何根据场景选择正确的连接模式

我这里给一个基本判断思路。如果你的需求是让每个key的数据只进同一个下游Task做聚合,Hash模式不可避免,那么重点放在key分布是否均匀上,可以提前对倾斜key加盐或做两级聚合。如果你的场景更关注均衡负载而不是key相关性,Rebalance或Rescale更合适。Broadcast要慎用,尤其是在下游Task数很多时,因为数据复制N份,成本线性上升。

实际生产中我见过有人把一个大维表通过Broadcast广播到50个下游Task,结果每次维表更新都产生大量网络流量,最后改为定期将维表加载到Redis再通过异步查询,压力立刻降下来。这说明连接模式不是孤立概念,它和业务数据特征、资源拓扑是绑在一起的。要根据数据量、并行度、是否需要保序和去重来决定,并且要在监控上观察各分区的数据量偏差,再回头调整策略。

6. 实操中的参数与排查清单

6.1 内存与Buffer相关参数

先整理几个日常用得最多的参数,表格形式看更清楚:

参数默认值作用
taskmanager.memory.network.min64MB网络内存下限
taskmanager.memory.network.max64MB网络内存上限
taskmanager.memory.network.fraction0.1网络内存占堆外总内存比例
taskmanager.memory.framework.heap.size128MB框架堆内存
taskmanager.memory.framework.off-heap.size128MB框架堆外内存
taskmanager.network.request-backoff.initial100ms申请Buffer初始退避时间
taskmanager.network.request-backoff.max10000ms申请Buffer最大退避时间
taskmanager.network.memory.max-buffers-per-channel20单个Channel可申请的最大Buffer数
taskmanager.network.memory.buffers-per-channel2每个Channel初始Buffer数
taskmanager.network.memory.required-buffers-per-gate无每个InputGate最少保留Buffer数

调参时记住一个原则:先看监控曲线再动数值,不要一上来就把fraction从0.1调到0.3。如果网络内存不足,日志中会出现“Insufficient number of network buffers”或类似提示,此时可以先算一个大概值。比如目标网络吞吐是每条消息1KB、单条链路上期望缓冲1万个消息,就至少需要约10MB的Buffer池;注意这是单个Task维度,还要乘上下游并行度相关数量。最简单的做法是把网络内存从默认值往上加,同时观察任务运行时的Network Memory使用率,保留约20%的余量。

6.2 网络传输相关参数

网络传输层面比较常用的设置包括Netty相关参数和发送端批量合并行为。Flink默认开启taskmanager.network.netty.transport.type对应自动选择,依赖本地可用特性,通常会用Epoll或KQueue来提升性能。另外有taskmanager.network.netty.sendReceiveBufferSize默认0,表示由Netty自行管理,调太大反而可能浪费内存。

还有一点容易被忽略:跨节点发送时,Flink会对Buffer做批量处理。taskmanager.network.batch-size默认16384字节,意思是发送端会尝试攒够一定字节数再提交给网络层发送,而不是每个Buffer立刻发。这样能有效减少小包造成的网络开销,但会引入几毫秒内的额外延迟。如果你的任务对延迟极其敏感,可以把batch-size调小,反过来,高吞吐批处理场景可以加大一些。不要盲目改,建议先在压测环境验证。

网络出现大量重传或者连接频繁关闭时,还有一个方向是调整Netty线程模型相关参数,例如taskmanager.network.netty.server.backlog以及客户端BOOTSTRAP重试次数。这些参数通常不用动,只有在大规模集群和跨机架场景下出现连接建立超时才需要关注。排障时先看系统层丢包,再看Netty异常日志,否则调参数等于是白费功夫。

6.3 常见问题速查表

把排障中反复遇到的几类问题整理成一个速查表,方便你应对真实任务。

现象可能原因排查与解决
日志提示Insufficient number of network buffersLocalBufferPool或全局NetworkBufferPool不足上调taskmanager.memory.network.max或fraction,观察使用曲线
单个Task背压严重,但下游负载不高数据倾斜,某个Key进入同一个下游用keyBy前查看分区大小,使用加盐、预聚合或Rebalance
所有Task背压持续高,整体时延增大Sink或某个慢算子成为瓶颈逐步回溯背压起点,检查Sink外部存储写入速度
堆外内存持续增长,GC老年代上涨Netty缓冲区堆积,或Buffer回收不及时检查自定义算子是否持有Data对象引用,检查Credit授予是否正常
重启任务后需要较长时间从上次进度恢复状态大且并发重试,网络传输恢复慢开启增量检查点,调整任务恢复策略,不要依赖全量恢复
部分分区无数据输出,在下游出现空窗口上游数据分布不均或连接模式为Broadcast误解检查ChannelSelector和上游key分布,必要时加计数确认数据量

这张表只是起点,实际场景还更容易遇到“跨版本升级后序列化异常”这类问题。建议在任何Job升级时,先做校验和对比,确认新旧序列化结果一致再滚动发布。Flink的TypeInformation被错误设置时,也会导致序列化行为异常,特别是自定义POJO和Tuple混用,排查起来尤其隐蔽,最佳预防方式是在测试环境打印每个算子进出数据的前几条记录,做字段级别的数据审计。

6.4 我的一些排障心得

写到最后,分享几个我在实际项目中养成的习惯。数据交换相关的问题,八成以上都不是突发故障,而是逐步恶化的。所以最重要的不是事后救火,而是日常监控。我给每个重要任务都配上网络内存使用率、背压时间比例、记录处理速率三类指标,告警阈值设在报表明细能支撑さらに细粒度追踪的位置。出现异常时,先看背压方向,再看Buffers使用率,能定位大部分问题。

另外一个经验是:数据处理逻辑里尽量不要写自定义序列化代码。遇到过几次自定义序列化器在并发环境下不满足线程安全要求,导致同一个Task里多个下游连接同时使用序列化器时出现数据错乱。后来统一采用Flink自带的TypeSerializer,复杂度低,兼容性也好。Data交换机制本身是稳定的,大多数Bug都源于我们附加进去的可变状态和对象生命周期管理不当。

最后再补一个细节

如果你打算深入研究源码,建议从ResultPartition、InputGate和Credit-Based几个类入手。理解两个问题:一个Buffer从产出到被消费的引用计数变化,以及一个网络请求从发出到收到Credit确认的完整循环。搞懂这两条链路,Flink Task的数据交换机制基本就打通了。我自己当初也是这么啃下来的,前面还是会碰壁,但坚持一段时间后,再看背压、再看吞吐问题,会有一种“原来如此”的通透感。

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

Android城市选择器:自绘WheelView实现省市区三级联动

简介:面向Android开发者的城市选择器组件,界面模仿美团App,基于高德地图API实现地理定位,适用于电商、旅游、社交、生活服务等需要地区选择的项目,尤其适合希望快速集成城市列表与定位功能的团队或个人开发者。资源以Z…

作者头像 李华
网站建设 2026/10/16 15:09:20

AI系统权限控制实战:从认证授权到配额审计的完整方案

先说个场景:你花了大力气把大模型能力接进业务系统,上线第一天,运营同学拿它一口气生成了几千张图,财务月底看到账单直接懵了。这是我在实际项目里见过的真实问题,根子就一个——AI系统权限控制没做好。整套体系要管的…

作者头像 李华
网站建设 2026/10/16 15:07:59

FLIP动画技术详解:从浏览器渲染原理到工程级实践

FLIP 动画这几年几乎成了前端动画绕不开的话题,核心名字却简单得有些反直觉:First、Last、Invert、Play。它解决的问题特别直白——让一个改变尺寸、位置的 DOM 元素,动画跑起来又顺又不卡。但光看效果不过瘾,真要在项目里落地&am…

作者头像 李华
网站建设 2026/10/16 15:06:33

医院安全运营的大模型落地:从告警研判到人机协同闭环

医院的安全运营,一直是安全行业里比较特殊的一类场景。业务科室凌晨三点还在跑批,影像系统几十个T的数据说走就走,设备科新接的呼吸机连的还是老交换机,信息科手里却只有三四个安全运维人员。这种环境下,想靠堆人力和传…

作者头像 李华
网站建设 2026/10/16 15:02:41

Excel合并单元格后内容不居中?从原理到批量处理一次解决

合并单元格后内容不居中,这个事儿看着小,但几乎每个用Excel的人都栽过跟头。有人辛辛苦苦把表头合并好了,结果字死死贴在左下角,怎么看怎么别扭;有人点了“合并后居中”,换台电脑打开又变了样;还…

作者头像 李华
网站建设 2026/10/16 15:00:22

力扣1046最后一块石头的重量:C++优先队列与大小堆实战解析

力扣1046这道“最后一块石头的重量”,我常把它当作面试里考察大小堆和优先队列的入门练兵场。题目本身很直白:你有一堆石头,每次从里面拎出最重的两块,按规则粉碎一轮,剩下的差再丢回去,直到剩一块或全部消…

作者头像 李华