1457 字
7 分钟
客户端放弃不等于服务端回滚

客户端断开连接,服务端那边的创建不会跟着停。我的判断是:调用方能取消的只有自己的等待,服务端已经建出来的东西,得服务端自己去删。

三处「我以为取消了」

一晚多出 49 个#

生产集群的节点在一个晚上被占满。查下来是平台自己的测试套件留下的:一晚 49 个沙箱,没有调用方在用,也没人删,其中 26 个的成因没查清。

建沙箱是同步请求。引擎要等 pod 分到机器、拿到 IP 才返回沙箱编号,最长等 300 秒;节点的 pod 名额用满时,排队时间就接近这个数。调用方设的超时比它短,客户端先断开,服务端那边照建,编号无处返回。调用方既不知道它存在,也没有按请求编号查创建结果的接口。

这些沙箱活到寿命上限才消失。这段时间一直占着 pod 名额,让后面的请求更容易超时。

50 毫秒就放弃,16.4% 照样建成#

实验集群上登记了 50 台 KWOK 模拟节点(只在 Kubernetes 里存在,不下载镜像也不跑容器),把量级拉到千级。客户端照常握手、照常把请求发完,只把「等响应」这一段设成 50 毫秒,模拟请求已经完整送到、SDK 等不及断开。放弃后再等 20 到 30 秒,用 watch 数实际建出来多少。

建法放弃数放弃后仍建成比例
直接建 pod97524925.5%
经开源引擎建97716016.4%

两组各跑 1 次。两边唯一的差别是写进去的东西:一边直接写 pod,一边写引擎自己的沙箱对象,再由它的控制器去建 pod。差出来的这 9 个百分点,我的理解是多一跳就多一个地方能发现连接已经断了。少掉的这些具体卡在哪一步,我没查。

这一跳也把后果放大了:引擎的沙箱对象漏一个,控制器照样把下面的 pod 建出来,删的时候也要连着底下的 pod 一起收。

这组数第一次测错过。最早把超时设成 30 毫秒、并且连 TLS 握手一起算进去,927 次在握手阶段就断了,只建出 4 个。看着像好消息,其实是请求根本没送到服务端。「客户端放弃」得说清在哪一步放弃,数才能用。

收到报错,也不代表没建成#

第二种:引擎接口返回了错误,沙箱其实已经建好。实验集群上遇到过,按「失败了就不用管」处理,这一个就留在那儿。

第三种在删除这一侧。有一轮批量删 1000 个 pod,底层存储卡了 17.7 秒,调用 20.1 秒后返回 500,实际只删掉 2 个。补发一次,剩下 998 个 9.1 秒删完。

连接断了、错误码回来了、删除发出去了——这三件事说的都是客户端这边发生了什么。服务端那边到底留下了什么,还得自己去数。

补偿要三件一起做#

幂等键:同一个请求编号只建一次。做法是把编号映射成对象名字,重名就撞 AlreadyExists;撞上就说明这个请求已经建过了,把原来那个拿回来,不再建第二个。

放弃之后自己再删一次:在实验原型上人为插 2 秒延迟把这段窗口拉宽,试了 28 次,每次都删掉了。

定期对账:拿集群里实际存在的沙箱,和自己记的那份该有的清单比一遍,多出来的删掉。前两条漏下的只能靠它。原型最初的清理规则是「内存里的记录不是已删除就当在用」,对 16 个没人要的沙箱一个也没删。服务重启后内存里没有记录,这些沙箱对它来说等于不存在。改成对账之后,另一轮的 15 个都在 29.8 到 38.2 秒内删掉。

引擎回了报错的那种也归到这里,按「可能已经建成」去删一次。

清理本身不慢:按标签删 274 个 pod,2.7 秒删完;经引擎建的那 183 个,对象 1.6 秒消失,底下的 pod 由垃圾回收收尾,8.45 秒收完。垃圾回收自己有速度上限,每秒 14 到 23 个。

代价是对账本身会误删。跑 3 份服务、滚动升级 3 次,200 个沙箱里有 9 个被服务自己删掉了:接手的那一份拿只能用一次的凭证去初始化沙箱内的执行进程,被拒,它就当这个沙箱已经被改过,清理掉。把这一步改成「已经有别的副本初始化过就算成功」,再测 266 个,一个都没误删。同一轮里另有 10 个是另一种原因:调用方没等满服务端给的等待时间,用同一个请求编号重发,服务端没认出这是同一次请求,把它当成没人要的回收了。

幂等键不只是服务端的事。服务端按编号认领,客户端重发也得带上同一个编号,缺哪一头都对不上。

那份清单也得写进存储,不能只留在服务进程的内存里,否则重启之后负责对账的这一边自己先空了。

没测到的部分:放弃后仍建成那两组各只跑 1 次;补一次删除、对账、滚动升级这三项只在实验原型上验过,正式服务上没测;三种失效里「引擎回了报错但已建成」只遇到过,没有统计它的发生率。