ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Storm窗口机制实战:滚动窗口、滑动窗口与调优避坑指南

Storm窗口机制实战:滚动窗口、滑动窗口与调优避坑指南 做实时计算这一行最难啃的骨头通常不是单条数据的处理而是“把一段时间内的数据放在一起算”的问题。比如实时统计每分钟订单金额、过去五分钟接口失败率、最近一小时每个用户的加购次数如果只是一条Tuple进来处理一条你得到的永远是瞬时的结果回答不了任何“最近N分钟”的问题。很多人从离线Hive切到Storm后第一反应就是自己开一个List缓存再写一个定时器线程手动维护窗口。折腾一两个星期后你会踩遍分布式环境下的坑缓存要加锁、多实例重复计算、Worker重启丢状态、堆积后不知道先清谁。Apache Storm在0.10版本之后正式提供了一整套Windowing机制把窗口如何划分、何时触发、如何清理都封装进了WindowedBolt。你只需要关注两个核心决定用滚动窗口还是滑动窗口窗口内的计算逻辑怎么写。这篇文章是我在生产环境里使用Storm Windowing做实时风控和业务指标统计的实战记录会把滑动窗口、滚动窗口的运行原理、API用法和踩坑点一次说清楚。适合正在调研Storm窗口方案或者已经用起来但对内部行为不够了解、调参总是靠猜的读者。1. 为什么实时处理必须要有窗口机制1.1 从滑动窗口算法思想说起滑动窗口这个词大多数人第一次见到是在算法题里。一个数组一个长度固定的窗口窗口从左往右滑每移动一步就求窗口里的最大值、最小值或者中位数。数据结构课上的滑动窗口是“有限数组上的索引区间”数据全都在边界明确窗口滑过去以后数据不会变。实时流处理里的窗口虽然名字一样但逻辑完全不是一回事。数据流是无限且无序的你不知道下一个Tuple什么时候到也没法确定“这一分钟的数据”是否已经全部到达。你需要的是一个人为划定的事件集合边界把时间轴切分成一段一段或者让一个固定大小的集合不断向前滚动。Storm Windowing做的事情就是把“集合边界”和“集合内容”这两个问题一次性帮你解决掉。有一个朴素的滑动窗口滤波模型可以帮你理解这件事传感器持续产生数值每来一个新值就把窗口里最旧的值挪出去然后重新计算平均值。Storm的窗口也类似区别在于传感器滤波的窗口是“每来一个新值就滑一次”而流式窗口通常按固定步长滑动并且窗口里承载的是业务Tuple而不是单个数值。1.2 滚动窗口、滑动窗口、会话窗口的区别先统一一下术语后面所有内容都基于这三个基本类型。滚动窗口Tumbling Window最简单也最像离线批处理。它把时间或者数量切成固定大小的区块区块之间没有重叠。比如每5分钟一个窗口处理10:00:00到10:05:00的数据然后再处理10:05:00到10:10:00的数据每一笔数据只会落进一个窗口。这类窗口适合做固定粒度的报表比如小时报表、分钟级监控指标因为计算结果天然没有重复。滑动窗口Sliding Window则有一个“窗口长度”和一个“滑动间隔”长度大于等于间隔时相邻窗口就会重叠。比如窗口长度5分钟每1分钟滑动一次那么每个发射出去的窗口都包含最近5分钟的数据但是相邻两次发射的窗口里有4分钟的数据是重叠的。滑动窗口适合做“最近N分钟”类的趋势分析因为结果刷新频率可以比窗口粒度更细。会话窗口Session Window按活动间隙切分比如用户连续操作超过30分钟无事件就认为一次会话结束。Storm对滚动窗口和滑动窗口有最直接的API支持会话窗口在Storm原生窗口里没有开箱即用的实现一般需要结合业务逻辑手动做分区和触发。这里不展开实践中如果确实需要会话窗口建议评估Flink这类原生支持Session的框架而不是在Storm里硬撑。窗口类型是否重叠典型场景触发方式滚动窗口否每分钟交易额、每小时UV统计到达窗口长度即触发滑动窗口是最近5分钟接口平均延迟每隔一段间隔触发一次会话窗口是按活动周期用户连续操作行为聚合空闲超过阈值触发1.3 时间语义处理时间和事件时间窗口总归要有一个“边界”概念那么边界到底按什么时间算这不是一个可以含糊的问题。处理时间Processing Time是Tuple进入窗口Bolt时所在机器的系统时间。它实现简单实时性好但有一个致命问题上游数据一旦发生网络延迟、批式读取、或者Kafka分区消费积压数据真正被处理的时间已经偏离了数据产生的时间。你统计10:00:00到10:01:00的订单结果一条产生于10:00:59的订单因为消息队列抖动延迟了30秒才到达被算进了10:01:00到10:02:00这个窗口。对于监控报警来说这30秒的偏移可能完全改变你的故障判断。事件时间Event Time是数据本身携带的业务时间戳。它更符合业务真实情况但实现复杂度高你必须从Tuple里提取时间戳还要处理乱序数据判断一个窗口是否真的“到齐”了。Storm的做法是允许你在窗口Bolt上配置TimestampField并设置一个Lag容忍值去平衡“等数据”和“出结果”之间的矛盾。我在实际项目中用过两种时间语义后结论很明确只要业务对时间属性有要求一律上事件时间只有纯粹做系统内部健康检查、且数据链路很短时才考虑处理时间。窗口机制本身并不会替你选时间语义它只是提供了选择的空间。2. Storm Windowing的整体设计与核心API2.1 WindowedBolt 到底改变了什么在Storm里普通Bolt处理的是单条Tuple实现的是execute(Tuple input)。而窗口Bolt实现的是execute(TupleWindow inputWindow)接到的不是一条数据而是一个已经按照某种规则收集好的Tuple集合。这种设计本质上是在Bolt外面套了一层“收集器”。Storm官方在BaseWindowedBolt这个类上做了封装把窗口管理单独抽成了一个WindowManager组件。当你的Bolt继承BaseWindowedBolt以后流入该Bolt的Tuple并不会直接进入业务代码而是先被注册到窗口管理器里由TriggerPolicy触发策略决定什么时候生成一个TupleWindow再由EvictionPolicy驱逐策略决定什么时候把窗口内的过期数据清掉。这样做的好处是业务代码和窗口生命周期完全解耦。你写的execute(TupleWindow window)函数不需要关心窗口里这条数据是什么时候来的、要不要删除只需要在每次被回调时把当前窗口内的数据算一遍。以我自己的开发体验来说从普通Bolt切到窗口Bolt最难适应的点是不能再假设每个Tuple都会立刻触发一次下游逻辑窗口Bolt可能攒了很久才回调一次也可能频繁回调完全取决于窗口配置。调试时要改变思维模式从“追踪单条数据”变成“检查窗口内容”。2.2 三种窗口配置方式窗口配置有两种路径一种是在Topology的Builder阶段配置一种是在BaseWindowedBolt子类里用with开头的方法配置。前者更灵活后者代码看起来更内聚。滚动窗口用tumblingWindowTopologyBuilder builder new TopologyBuilder(); builder.setSpout(order-spout, new OrderSpout(), 2); BaseWindowedBolt countBolt new OrderCountBolt() .withTimestampField(ts) .withLag(new BaseWindowedBolt.Duration(10, TimeUnit.SECONDS)); BoltDeclarer tumblingDeclarer builder.setBolt(order-count, countBolt, 2); tumblingDeclarer.tumblingWindow(new BaseWindowedBolt.Duration(60, TimeUnit.SECONDS)); tumblingDeclarer.fieldsGrouping(order-spout, new Fields(userId));滑动窗口用slidingWindow第一个参数是窗口长度第二个参数是滑动间隔BoltDeclarer slidingDeclarer builder.setBolt(order-avg, avgBolt, 2); slidingDeclarer.slidingWindow( new BaseWindowedBolt.Duration(30, TimeUnit.SECONDS), new BaseWindowedBolt.Duration(10, TimeUnit.SECONDS)); slidingDeclarer.fieldsGrouping(order-spout, new Fields(userId));如果窗口不是按时间而是按Tuple数量来切也可以用BaseWindowedBolt.Count来定义长度。比如每100条数据一个滚动窗口slidingDeclarer.slidingWindow( new BaseWindowedBolt.Count(100), new BaseWindowedBolt.Count(20));tumblingWindow和slidingWindow也可以直接写在BoltDeclarer上Storm 1.x的写法是支持链式调用的。不过我习惯拆成变量因为这样可以避免链式过长导致fieldsGrouping作用对象不清晰。还有一种是BaseWindowedBolt内部直接覆写getComponentConfiguration但一般不需要这么做。在Topology阶段配置最大的好处是同一个窗口Bolt可以在不同的Topology里复用窗口参数完全由部署环境决定。2.3 TupleWindow 接口里有什么execute(TupleWindow window)是窗口Bolt的核心回调。这个接口里最常用的几个方法get(): 返回当前窗口内所有Tuple的列表。getNewTuples(): 返回从上一次窗口触发后新增的Tuple。getExpiredTuples(): 返回本次即将从窗口移除的Tuple。getStartTimestamp()/getEndTimestamp(): 返回窗口的时间边界只在时间窗口里有意义。实现窗口聚合时最直观的写法是遍历get()public void execute(TupleWindow window) { double sum 0.0; for (Tuple tuple : window.get()) { sum tuple.getDoubleByField(amount); } collector.emit(new Values(window.getEndTimestamp(), sum)); }但这种写法在大窗口下性能很差因为你每次都要把窗口内所有Tuple重新遍历一遍。更高效的做法是利用getNewTuples()和getExpiredTuples()做增量聚合这个我会在第三章重点讲。另外要注意getStartTimestamp()的最小粒度。Storm的时间窗口边界计算方式不一定和你的业务时间对齐。如果你期望窗口边界是整分钟而数据时间戳是毫秒级那么getStartTimestamp()可能是10:00:00.000也可能是10:00:00.500取决于Storm内部的时间片对齐逻辑。实践中如果下游报表需要精确到分钟建议自己根据getEndTimestamp()重新计算业务边界不要盲信接口返回值。2.4 触发策略和驱逐策略是一对搭档窗口Bolt能运行起来核心是两个策略的配合。触发策略解决“什么时候发一次窗口”。TimeTriggerPolicy就是每隔一定滑步时间比如10秒检查一次是否到了发射时刻CountTriggerPolicy则是判断当前缓存里的Tuple数量是否达到阈值。驱逐策略解决“什么时候把过期数据丢掉”。时间窗口下TimeEvictionPolicy会删除时间戳早于窗口左边界的数据数量窗口下CountEvictionPolicy会限制缓存里最多保留多少个Tuple。很多对窗口原理不熟悉的开发者会以为触发的同时应该清空窗口。实际上在滑动窗口里触发和驱逐是独立发生的。窗口长度30秒、滑动间隔10秒触发可能是每10秒一次但是驱逐是等数据超龄以后再逐步移除。触发时你可以访问完整的30秒窗口内容驱逐则保证内存里不会堆积一分钟以前的数据。这两个策略配合Storm才能在保证窗口语义的同时控制单Bolt内存占用。3. 窗口滑动的内部运行机制3.1 事件缓存与时间戳包装窗口Bolt收到Tuple以后第一步不是直接丢进某个List而是包装成一个带时间戳的Event对象。这个时间戳的来源有两种如果你配置了withTimestampField(ts)就会从Tuple的ts字段提取如果没有配置Storm会使用当前处理时间。这段逻辑藏在WindowManager里它维护着一个有序的事件缓存。每来一个事件就插入到缓存尾部同时更新内部的时间状态。实际上这个缓存并不是一个物理上的窗口数组至少在我的经验认知里它更像是一个按时间线排列的“活数据区”。窗口只是这个数据区在某个触发瞬间的视图。理解这一点很重要因为它直接影响你对内存的认知。窗口长度30秒、滑动间隔10秒并不代表内存里会同时存在三个独立的30秒数据副本而是同一份数据在三个时刻被窗口视图引用。所以窗口重叠并不会“翻三倍内存”真正的内存占用取决于窗口长度内有多少Tuple而不是滑动次数。3.2 窗口触发的基本过程我们以时间滑动窗口为例把过程拆解一下。假设窗口长度60秒、滑动间隔20秒。Tuple按事件时间流入后WindowManager持续把它们加入事件缓存。当距离上一次发射已经过去20秒TimeTriggerPolicy就会触发一次。触发的动作是构建一个TupleWindow然后把当前缓存里满足时间范围的事件列表交给你的execute(TupleWindow window)方法。你的业务代码运算完毕后本次触发流程结束。在这个过程中数据并不会被删除。后续新的Tuple继续进来20秒后又一次触发这时生成的TupleWindow里除了有新的数据还包含上一个窗口尾部那些“还没过期”的数据这就是滑动窗口重叠的直接体现。滚动窗口实际上可以看作是滑动间隔等于窗口长度的一种特例。每次触发时窗口内的数据恰好是上一批发来的所有数据发完以后这批数据已经超龄很快被驱逐策略清出缓存所以不会重叠。3.3 增量聚合让大窗口不再可怕很多人第一次用Storm窗口时会在窗口Bolt里保存一个聚合值然后用getNewTuples()加、getExpiredTuples()减。这么做能大幅减少计算量。比如要计算最近30秒每个用户的订单总额。窗口每10秒滑动一次如果每次都遍历全部30秒的Tuple一天下来计算量会非常可观。改成增量聚合你可以这样写public void execute(TupleWindow window) { for (Tuple tuple : window.getNewTuples()) { currentSum tuple.getDoubleByField(amount); } for (Tuple tuple : window.getExpiredTuples()) { currentSum - tuple.getDoubleByField(amount); } collector.emit(new Values(window.getEndTimestamp(), currentSum)); }但要提醒一句这个写法只有在窗口事件缓存和业务缓存一致时才是对的。如果你在Bolt里记录了聚合值又额外用了get()去处理数据很容易出现重复加或者漏减。我踩过的坑是在启用withTimestampField之后一部分乱序Tuple会先被缓存到窗口里等它到达时可能已经“过期”了会被列入getExpiredTuples()。如果你没有在它刚进入时用getNewTuples()加一次却在它过期时减一次聚合值就会变成负数。更稳妥的做法是在窗口Bolt内部维护一个MapObject, Double aggregateByKey每次处理getNewTuples()按key累加处理getExpiredTuples()按key减然后对Map里所有key做一次遍历发射。只要逻辑集中在增量更新上不用再去get()里手工对账就不会出问题。3.4 和自己手写缓存相比到底赢了什么我在之前的公司见过很多手动实现窗口的方案一个静态ConcurrentHashMap按分钟存数据一个ScheduledThreadPool定时清扫一个业务Service定时聚合。表面上看也能跑但有几个问题很难绕开第一Worker并行度大于1时每个Bolt实例各自维护一份缓存你没法保证同一个用户的分钟级统计在多个实例间是完整的除非按userId做fieldsGrouping但很多人一开始根本不会想到。第二窗口的触发时机和缓存清理是两套逻辑手写代码很容易出现“统计时还没清下次统计又重了”或者“清理线程抢在聚合之前把数据删了”的数据竞争问题。第三Worker重启后缓存全丢。Storm窗口虽然也是内存态但WindowedBolt的触发和驱逐逻辑是经过长期验证的至少不会有并发问题。你如果自己维护还要考虑锁粒度一加锁吞吐就下来了。这不是说Storm窗口是银弹它同样没有持久化窗口状态Worker重启依然会丢窗口缓存。但它至少把“窗口划分与触发”这一层屏蔽掉了让你只关注聚合逻辑这就已经省掉了一大半麻烦。4. 实战用滚动窗口和滑动窗口做订单实时统计4.1 场景与数据格式下面用订单流来演示。订单Spout输出的Tuple包含三个字段ts订单创建时间单位毫秒userId用户IDamount订单金额统计口径有两个一是按用户统计每5分钟滚动窗口的订单总额二是按用户统计最近30秒、每10秒滑动一次的订单总额。两个结果都输出到下游数据库或告警服务。4.2 滚动窗口实现先定义一个窗口Bolt继承BaseWindowedBoltpublic class FiveMinuteSumBolt extends BaseWindowedBolt { private OutputCollector collector; Override public void prepare(MapString, Object topoConf, TopologyContext context, OutputCollector collector) { this.collector collector; } Override public void execute(TupleWindow window) { double sum 0.0; for (Tuple tuple : window.get()) { sum tuple.getDoubleByField(amount); } collector.emit(new Values(window.getEndTimestamp(), sum)); } Override public void declareOutputFields(OutputFieldsDeclarer declarer) { declarer.declare(new Fields(windowEndTs, sumAmount)); } }在Topology里配置为5分钟滚动窗口并且使用事件时间BaseWindowedBolt fiveMinBolt new FiveMinuteSumBolt() .withTimestampField(ts) .withLag(new BaseWindowedBolt.Duration(15, TimeUnit.SECONDS)); BoltDeclarer tumblingDeclarer builder.setBolt(five-min-sum, fiveMinBolt, 2); tumblingDeclarer.tumblingWindow(new BaseWindowedBolt.Duration(5, TimeUnit.MINUTES)); tumblingDeclarer.fieldsGrouping(order-spout, new Fields(userId));这里withLag(15秒)的含义是为了容忍网络乱序窗口结束以后仍然等待15秒内的迟到Tuple但等待期不会无限延长。你如果完全不设Lag窗口边界一到就触发乱序数据会大量丢失。4.3 滑动窗口实现滚动窗口的Bolt同样可以用在滑动窗口里因为TupleWindow.get()对两者来说是兼容的。但为了对比我再写一个简单版本public class ThirtySecondSlidingBolt extends BaseWindowedBolt { private OutputCollector collector; Override public void prepare(MapString, Object topoConf, TopologyContext context, OutputCollector collector) { this.collector collector; } Override public void execute(TupleWindow window) { double sum 0.0; for (Tuple tuple : window.get()) { sum tuple.getDoubleByField(amount); } collector.emit(new Values(window.getStartTimestamp(), window.getEndTimestamp(), sum)); } }注意这里输出增加了一个windowStartTs字段因为滑动窗口必须让下游知道当前结果对应的是哪一个时间区间否则两个相邻窗口的结果几乎一样下游很难识别。在Topology里配置BaseWindowedBolt slidingBolt new ThirtySecondSlidingBolt() .withTimestampField(ts) .withLag(new BaseWindowedBolt.Duration(15, TimeUnit.SECONDS)); BoltDeclarer slidingDeclarer builder.setBolt(thirty-sec-sliding, slidingBolt, 2); slidingDeclarer.slidingWindow( new BaseWindowedBolt.Duration(30, TimeUnit.SECONDS), new BaseWindowedBolt.Duration(10, TimeUnit.SECONDS)); slidingDeclarer.fieldsGrouping(order-spout, new Fields(userId));这里有一个非常容易误解的地方滑动窗口的结果里同一笔订单会被多个窗口包含。比如10:00:00到10:00:30的窗口包含了一个用户的订单A10:00:10到10:00:40的窗口同样包含订单A。如果你下游再做一次“订单总额累加”会把同一笔订单重复计算。这不是窗口Bug而是滑动窗口的固有语义。如果业务方希望得到“每人最近30秒的新增订单”那就必须在窗口内容里做去重或者接受重叠统计的结果。4.4 结果验证与调试技巧配置写完了怎么确认窗口真的按预期运行我一般会先做一个“统计发射次数”的验证。对于滚动窗口5分钟一个窗口运行一小时应该有12次发射。对于滑动窗口30秒窗口、10秒滑动间隔运行一小时应该有360次发射。如果数量对不上第一时间检查tumblingWindow和slidingWindow参数是否写反了。我见过太多人把slidingWindow(30s, 10s)误解成“窗口长度10秒滑动30秒”实际正好相反。还可以在Bolt里临时打印window.get().size()观察窗口内Tuple数量是否符合预期。例如如果每秒约100条订单30秒窗口每次触发时size应该接近3000如果只有500说明大部分Tuple被Lag拒之门外或者时间戳字段解析失败事件时间全部落在窗口边界之后。这种打印调试法虽然土但在窗口类问题上比看日志效率高得多。5. 常见问题与排查技巧实录5.1 问题速查表下面这张表是我在实际运维中总结的基本上覆盖了Storm窗口使用中最常见的几类问题。症状可能原因排查思路窗口一直不触发Count窗口没达到条数Time窗口没有新数据推进时间检查是Count触发还是Time触发给Spout造持续数据验证结果里大量重复Tuple滑动窗口间隔小于窗口长度重叠导致确认业务是否接受重叠下游按窗口ID去重窗口结果明显偏小乱序Tuple被丢弃Lag设置太小增大Lag打开LateTupleStream观察迟到量Worker内存OOM窗口内Tuple过多逐出策略没生效检查窗口长度与数据速率匹配度使用增量聚合窗口发射频率异常高滑动间隔设置太小多个窗口触发策略叠加核对slidingWindow参数顺序事件时间窗口边界乱ts字段解析失败时间单位不统一打印getEndTimestamp与实际时间对比5.2 迟到数据到底该不该收事件时间窗口最让人头疼的就是“窗口已经发射了数据才到”。这里有两个维度一是你愿不愿意等二是等了以后怎么处理。withLag决定了窗口发射后还有多大一个“迟到缓冲区”等待被关闭。比如窗口长度5分钟withLag(15秒)那么它的真实触发时间大概是窗口结束时间点往后延迟15秒给最后一批乱序数据一个机会。调大Lag可以降低数据丢失但也会让窗口结果的实时性变差。你不可能既要求“窗口一结束立刻出结果”又要求“把所有的迟到数据都收进来”这两个目标天然冲突。如果你的业务对准确性要求很高我建议把LateTupleStream显式配出来而不是隐式丢弃迟到数据。配置方法是BaseWindowedBolt lateBolt new ThirtySecondSlidingBolt() .withTimestampField(ts) .withLatencyDelay(new BaseWindowedBolt.Duration(15, TimeUnit.SECONDS)) .withLateTupleStream(late-order);然后在Bolt的declareOutputFields里额外声明一个late-order流。迟到数据会走这个流下游可以单独做修正统计。我在一个交易场景里就是用这种方式做“基础实时结果”和“延迟补偿结果”两层输出既保证了监控面板的实时刷新又让最终对账能够校正。5.3 窗口内存与预聚合时间窗口不限制Tuple总条数如果上游数据量很大窗口内可能会缓存几十万甚至上百万条Tuple。最直接的解决办法是在进入窗口Bolt之前先做一轮预聚合把窗口Bolt这一层的Tuple规模降下来。比如你要统计每个用户每30秒的订单总额完全可以在上游用一个普通Bolt按userId聚合到秒级输出(userId, minuteTs, amount)然后再进入窗口Bolt。窗口Bolt只需要对秒级结果做累加Tuple数量会减少一到两个数量级。这个“先明细后窗口”的模式是我在所有大窗口场景里都会优先考虑的方案。如果窗口Bolt本身必须接收明细建议在execute(TupleWindow window)里不要直接用window.get()遍历而是一边遍历一边只提取计算所需的字段尽早释放Tuple引用。Tuple对象在Storm里本身有生命周期管理但你在自己的List里保存了引用它就没法被回收。5.4 触发频率异常与Worker心跳问题还有一个比较隐蔽的坑窗口Bolt长时间不触发并不是因为没有数据而是因为用了Count窗口但数据稀疏永远凑不够窗口条数。比如某个低频告警事件一天只有几十条你配置100条触发一次那窗口可能几个月都不发射一次。这种场景必须用时间窗口不能用数量窗口。另一个和Worker心跳有关的问题是如果窗口Bolt的execute(TupleWindow window)里计算太重比如遍历上百万条Tuple或者调用外部存储处理时间超过Worker心跳超时就会被Nimbus判定为“卡死”从而重启Worker。窗口任务比普通任务更容易出现这个情况因为窗口发射时是批量的峰值处理压力远大于均值。我的习惯是往execute(TupleWindow window)里加的每一个外部依赖都必须做超时保护并且提前估算好最长执行时间是否在Worker心跳阈值以内。最后说两个我自己摸索出来的实用习惯。第一窗口调参不要凭感觉。先确定业务能容忍的最大结果延迟再倒推Lag和窗口长度。比如监管报表要求每分钟颗粒度且允许1分钟延迟那窗口长度设60秒Lag最多设30秒如果要求实时监控窗口长度可以设30秒Lag只设5秒。第二增量聚合不要一上来就做。数据量不大时每次触发老老实实遍历get()反而更安全因为增量聚合一旦漏了一个加或减错误会永久累积排查成本极高。等到你发现全量遍历已经拖慢Topology再改成用getNewTuples()加、getExpiredTuples()减的增量模式并且一定要配合测试水位数据验证正确性。Storm的Windowing机制并不复杂但它和普通Bolt编程的思维差异很大。你真的把它当成“时间切片器”来用就不会被滑动窗口的重叠和乱序吓到反过来如果只是套了一个窗口API却搞不清楚触发和驱逐的逻辑调参的时候会特别痛苦。希望这篇文章能让你少走一些我走弯路。
返回列表