木鸟杂记

大规模数据系统

工程中的经典“意象”(一):滑动窗口

在古诗文中,我们以月喻离合、以水比时光、以柳寄别恨。在软件工程中,我们以管道贯通有无、以缓存平峰谷、以协议定不变。前者我们称为意象,《文心雕龙·神思》篇中写道:“独照之匠,窥意象而运斤”;后者我们叫做隐喻,《代码大全》(Code Complete)中将隐喻视为一种启发式方法(heuristic),用以在抽象与具象之间架设桥梁。

我们如此习惯于意象和隐喻,是因为“人类思维本质上是隐喻性的”(出自莱考夫和约翰逊在《我们赖以生存的隐喻》)。其特点是,将一类场景相关的上下文传神地压缩进一个词语中。比之于精确的算法,它模糊但强大,所谓 “运用之妙,存乎一心”。

我想通过一系列小文来收集我在实践中反复看到的一些有趣意象,本篇来聊聊滑动窗口

引子

前几天听罗振宇的历史播客《文明之旅》时,讲到故宫博物院的传世名画——北宋末年宫廷画家王希孟的《千里江山图》。它除了有名外,另外一个特点就是——特别长,几近十二米(1191.5 厘米 )!在中国古代书画中,这种"横长纵短、边展边看"的形式叫手卷(还有竖挂的立轴和分页的册页)。

手卷适合三两好友慢慢撵卷细品。这种前手展、后手收、只关注窗口内的“景色”的经典场景便正是软件工程中最经典的意象之一——滑动窗口。下面依时间线来聊聊我在接触编程后遇到的相关场景。

展卷窗口展卷窗口

作者:木鸟杂记 https://www.qtmuniao.com/2026/07/12/eng-metaphor-1-sliding-window/ 转载请注明出处

TCP 协议

最早听滑动窗口这个词是在大学上计算机网络这门课时。后面发现它“光荣”地忝列古法编程时代面试最爱考的“八股”中。其背景是,想在 TCP 这一层实现可靠传输,下面来铺垫一下其推导历程。

首先,序号机制是消息不重不漏的基石,在此之上,有一系列对消息“花样”保序的法子。

最简单的是“停等协议”。A 发个序号为 n 消息给 B,等 B 回 ack-n(表示“朕知道了”) 后,再发序号 n + 1 的消息。但如果 A 收不到就要停下来,无限地等。初学时甚为懵逼,为什么这么蠢的协议也要拿出来讲?后面意识到是我蠢了——这便是工程之道。面对新的问题,先试最简单粗暴的解决方案,不够用再依据实际情况来迭代。

GBN 协议:消息一条条发太磨叽了,一下发 N 个消息怎么样?在畅达的网络里这样做效率很高—— A 一股脑发过去之后,B 可以使用一条 ack-n 对这些消息全部确认。但凡网络不好点,一旦发送失败(通过超时未收到回信来定义),就回退到从没收到消息的那个序号再发一遍(此即名字由来,后退 N 协议,Go Back N)。

SR 协议:GBN 的假设是接收方 B 维持一个“全局低水位”——最小待接收的序号,如收到的消息不是这个序号一律拒绝。这确实粗暴,但不粗暴需要代价,比如接收方可以维护一个接收窗口(也就是一段存储),只要落在这个窗口内的就先存下来,但只有窗口最左边(序号最小)的消息到来之后,才可右移窗口。这不,窗口有了,滑动也有了,且滑动的前提便是——窗口最早消息的到来。以“手卷”画做比,几个好友共赏时,只要有一个人最前边的画还没看完,便不往后展卷。

所谓 SR (Selective Repeat,选择性重传)就是在维护有一个窗口的前提下,如果超时了发现窗口内还有某些消息没收到,便通过重传请求让 A 只传这些序号的消息,避免 GBN 协议中让已经收到的再传一次。

这里有一个隐含的假设,窗口大小是固定的,但工程实现中他当然可以不固定,网络通畅的时候,就调大一点;拥塞的时候,便调小一点。但这又涉及计网中另外一大块烦人的内容——流控算法拥塞避免了,这里就此打住。

LeetCode 题目

后来找工作时,不免会刷 LeetCode 题,有一大类题的解法是某种滑动窗口的变种,甚为有趣。这类题目经常是字符串处理,常用双指针来维护窗口。下面试举一二。

题目3:给定一个字符串 s,请你找出其中不含有重复字符最长子串的长度。

其关窍在于,我们能用双指针来不重不漏地遍历所有可能的最长子串。当然,这里是子串,而不是子序列,即必须连续。这是怎么做到的呢?

  1. 通过左指针遍历所有子串起点
  2. 右指针扩至最远直到出现重复字符
  3. 左指针右移直到窗口内重新不包含重复字符。

在这个窗口不断伸缩的过程,记下不包含重复字符的最长窗口长度即可。

那为什么右指针不需要回溯呢?因为“无重复”这件要求的自包含性,前一个固定左指针探索到的极右可以复用。

题目 76:给定两个字符串 s 和 t,长度分别是 m 和 n,返回 s 中的 最短窗口 子串,使得该子串包含 t 中的每一个字符(包括重复字符)。如果没有这样的子串,返回空字符串 **""

这个题 76 可视为题 3 的一种反向操作,只是维护的窗口视图变了:

  1. 通过左指针遍历所有 s 子串的起点
  2. 通过右指针走到极远,直到包含 t 中所有字符
  3. 然后左指针右移,直到窗口内不包含 t 全部字符。

当然,也得使用一个辅助字符计数器。

这两道题的共同特点都是,乍一看需要遍历所有子串,复杂度 O(n^2)。但细看就会发现,可以利用题目要求的性质,仅用双指针 + 一个计数器即可用 O(n) 的复杂度实现:

  1. 双指针界定窗口边界
  2. 计数器表达窗口视图
  3. 滑动窗口,修改视图,移步换景

滑动窗口实现的思想大抵如此:通过序号机制来维护一个单调滑动窗口,通过某种数据结构来维护窗口内的视图

WAL 和 Raft

后面我转行做分布式系统和数据库,亲历了一些更为精深的工程中使用滑动窗口的场景。

在 Raft 论文中,作者将分布式系统中的基石——共识协议,拆分成多机选主日志同步两个相对独立过程,以简化其前辈 Paxos 的理解难度(如果你对共识协议感兴趣,我之前写过很多文章介绍过)。在 Raft 的实现中,便潜藏着几个更精妙的滑动窗口。

同步窗口

Leader 会为每个 Follower 维护一个日志视图,确切的说是日志同步窗口,实现上也类似于双指针法:

  1. matchIndex 表示 Follower 和 Leader 已匹配上的日志序号,即“同步窗口” 左界
  2. len(log[]) 表示 Leader 的最新日志(悬置 0 号日志作为“哨兵”),即“同步窗口”右界

在网络通畅正常运行时,nextIndex = len(log[]) = matchIndex + 1,即 Leader 和每个 Follower 日志都能匹配上,如果 Leader 接收到新的日志条目,即 nextIndex 来了,就可以立即分发到所有 Follower。

但在 Leader 刚上任时,对每个 Follower 日志情况一无所知,会先将 matchIndex 设置为 0(悬置 0 号日志,不考虑 snapshot)、nextIndex = len(log[]) + 1。通过心跳要到 Follower 真实情况后,便向右滑动 matchIndex(如果有匹配),然后向左滑动 nextIndex (如果有分叉),直到重新收敛为 nextIndex = matchIndex + 1 。此时 [nextIndex, len(log[])) 便是要同步的日志条目窗口。

应用窗口

从整体 Raft 和应用层构成的整体协议栈考虑,除了同步窗口外,还有个应用窗口

这里插一嘴,此“日志”非彼日志(服务运行期间产生的用于调试或者审计的日志),此“应用”也非彼应用(应用程序)。

这里的日志是 WAL —— write ahead of log,写前日志,是数据库中的一个重要概念,可以大体翻译为“数据日志”,每个日志条目记录的是一个数据操作——对数据表的“增删改查”。有了这个日志,就可以从空库重建整个数据库。WAL 是连接 Raft 和存储系统的桥梁—— Raft 保证多机的日志一致,日志保证物化后的存储状态一致。

这里的应用是英文 apply 的直译,是一个动作,可以理解为“施加于”或者“作用于”,即将日志应用到状态机(也就是存储服务)。在 Raft 中,Leader 维护

  1. 一个全局的 commitIndex 表示已提交的日志位置
  2. 同时还有一个 lastApplied 表示已应用到状态机的位置,(lastApplied, commitIndex] 便是待应用窗口。

通常由一个应用线程来推进,不断将 lastApplied+1 处的日志应用到状态机,然后右移 lastApplied 指针,直到追上 commitIndex

Raft 中的这两个窗口很像你追我赶的生产者消费者模型。同步窗口由 Leader 写入 WAL 而生产,由向 Follower 的日志同步而消费;应用窗口由 commitIndex 的推进而生产,由应用到状态机而消费。

流式调度

最近在做大规模数据处理相关,好吧,也就是洗数据。为了并行处理数据,我们经常把一个逻辑上的数据集切分成一个个的数据块(block),编上序号,作为待处理的工件。而将相对较少的计算资源,进行池化,组成具有固定并发的处理池子。然后将数据块流式的发到资源池中进行处理。

这其中的滑动窗口就体现在,数据块的数量级通常在 10k ~ 100k 之间,而计算池的并发通常在 1k~10k 左右,因此处理时,就像将数据集拉长成了一个数据流,然后像做饺子皮时将面剂子切段一样,滑动地扔到资源池中。从反向角度考虑,资源池就是一个处理窗口,不断向前滑动。

和 TCP 一样,这里将数据块序号化也是为了容错——不重不漏地交付。在 TCP 中,是向对端交付原数据;而在数据处理中,则是想交付处理后的数据。因此,也是要依靠编号来追踪每个数据块的处理状态。

并且和网络拥塞避免算法一样,如果有一条多个处理环节组成的流水线,也需要考虑不同环节窗口大小调节问题。比如上游处理较快,下游处理速度跟不上,那上游可能就要自适应的缩小并发处理窗口。

小结

从我这一路走来的经历稍微提炼下,滑动窗口这个意象的主要特点可拆解为:“序号机制 + 有限视图 + 单调移动” 。其本质是以有限(窗口/内存)对无限(序列/数据流)的意象,是一种“以有涯随无涯”的壮美工程落地:

  1. 通过序号机制将无界数据分而治之
  2. 通过窗口机制以有限资源专注当下
  3. 通过滑动机制对一座大山渐次蚕食。

题图故事

闽南建筑典型特点,红砖白石双坡起,出砖入石燕尾脊闽南建筑典型特点,红砖白石双坡起,出砖入石燕尾脊


我是青藤木鸟,一个喜欢摄影、专注大规模数据系统的程序员,欢迎关注我的公众号:“木鸟杂记”,有更多的分布式系统、存储和数据库相关的文章,欢迎关注。 关注公众号后,回复“资料”可以获取我总结一份分布式数据库学习资料。 回复“优惠券”可以获取我的大规模数据系统付费专栏《系统日知录》的八折优惠券。

我们还有相关的分布式系统和数据库的群,可以添加我的微信号:qtmuniao,我拉你入群。加我时记得备注:“分布式系统群”。 另外,如果你不想加群,还有一个分布式系统和数据库的论坛(点这里),欢迎来玩耍。

wx-distributed-system-s.jpg