轮询 = 违约:一次 3 分 27 秒的改判
前面写过一篇《这条 Append 再也没回来》:waitCommit 等一个永远不来提交,结论一句话——耐久回执必须由持有写入权的线程当场给出,等后台”总会处理”是赌一个不存在的生产者。
这篇讲它反面的那件事:订阅面。同一份 EntryLog,现在住着两种等待——一种必须自驱动,一种严禁自驱动;两条纪律前后脚写进同一个文件。而在它们写成之前,我们差点给”轮询”发了合法身份。
把三处凑合摊开
盘点订阅面的时候,账面是这样的:TierLog 的跟订是 Task.Delay(50ms) 轮询配增量拉取;Queue 只有 DequeueAsync 拉取,没有任何推送面;TierVarKv 的 watch 是内联迭代器,一查三个违约——漏缝合事件、订阅泄漏、没有续传游标。
同一天我们起了契约规范,把产品面逐条对表,订阅写成第 1.6 条。第一次落笔,条款里有一句:
Log 跟订 = 轮询形态合法(无订阅中枢,cursor 数据面共享;非推送不违约)。
3 分 27 秒后,这句话被删了。
第二次落笔把整段改写:Queue / Log 订阅必须推送化——“订阅实时性必备,轮询 = 凑合”;三形态订阅共享同一骨架;对表清单里新立一行:轮询 = 违约。
改判的理由不是实现变了,是判据错了。第一次落笔,判据是”现在的实现是什么”——“轮询也能用,不算违约”;第二次落笔换成了”订阅在语义上承诺了什么”:订阅存在的理由就是实时性——用轮询实现的订阅,是打着订阅名义的查询。“现在能跑”不是设计依据,只是既成事实。
骨架:推信号,拉数据
改判之后,剩下的是把三处凑合收进一个骨架。核心一句话:
通知面只推”有新”,本体一律走既有拉取原语——投递与一致性语义不复制。
翻译成结构,是”信号推送 + 数据面拉取”两个平面:
- 通知面:微秒级的”有新”信号,发布点在可见性动作完成的时刻——KV 是提交 / 换绑点,Log 是提交边界推进点,Queue 是 enqueue 提交点 + 可见性到期点。只报”有”,不搬数据;
- 数据面:事件本体永远走已有的拉取路径——KV 记录面游标、Log 的记录游标、Queue 的
DequeueAsync本体。ack、可见性、at-least-once 这些投递状态机一份也不复制。
为什么坚持两平面?因为”推送”最省事的写法是顺手把数据塞进推送——然后你就有了第二套投递路径:第二套重试、第二套顺序保证、第二套去重。信号是廉价的(丢了大不了下一轮拉取兜底),副本是昂贵的(它必须和第一份数据永远一致)。推送是一封信,不是一份副本。
三个订阅形态共享”泵形态四要件”:① 信号源在可见性动作点发布;② 缝合点必备;③ 独立泵生命周期(禁止内联迭代器——消费方驱动的补扫算形态违约);④ 续传语义(KV = 地址游标,Log = 位置游标,Queue 无历史重放、由缝合试拉承担)。
缝合点:最容易丢的那条事件
四要件里最不直观的是第二条,值得单独讲——它是那种”看起来不必要、删掉测试也绿、跑久了丢事件”的设计。
订阅注册的瞬间,中枢在锁内捕获一个发布水位:水位之前是历史(走补扫),水位之后是实时(走通道)。问题在水位自己身上:如果一条事件恰好”在注册前发布、而水位恰好等于它”,它不在补扫的开区间里,也没进通道——永久丢失。缝合点就是专门补这一条的:订阅泵起来后,先对水位地址做一次单点探测。
而单点探测本身还有个坑(一次判例从这来的):水位可能指向一个没有记录的地址(它兼作”还没发布过”的哨兵和起点),直接按位置取键会 fail-fast——枚举端异常收束,消费方感知为丢帧。修法是先”Try 探测”再取全量:未命中 = 没有已发布事件,静默跳过。回归测试后来钉了三项:订阅后写恰一次、混排无重无漏、游标重订 32 次。
变长键那个 watch 还有个等价形态:字节键的 ring 没有单点探测 API,缝合改成”从水位闭起点扫描、首条地址 == 水位即命中”。而它三个违约里”漏缝合事件”的实体根因其实更朴素——重启恢复后忘记调用水位锚定,订阅历史被隔在补扫够不到的地方。缝合点丢的是事件,缺的是一行调用。
泵化落地:等待原语的两副面孔
十号这天,三个形态的泵化一气收完。最值得讲的是先在底座落的新原语:提交推进通知(commit-advance waitable)——EntryLog 上挂一个等待注册表,提交通界在推进点锁内 drain 唤醒全部等待者;没有等待者时,推进点的额外成本是零;提交失败路径同步 fail 全部等待者,防永挂。
它的说明里有一句话,是专门写给隔壁那个 WaitForCommitAsync 看的:
★ 无自驱动(与 WaitForCommitAsync 的本质差异):本原语无目标地址——“边界未推进”的等待是正确语义(末页未满 / 无新写入 = 无新提交),提交边界由写入方 waitCommit/显式提交/提交流驱动,等待方不补提交。
这就是开头说的两副面孔,合起来是一条完整的纪律:
| 执行面(等耐久回执) | 订阅面(等边界推进) | |
|---|---|---|
| 等待语义 | “我的数据落盘了没” | “有没有新东西” |
| 等待方纪律 | 必须自驱动(亲手推过耐久线) | 严禁自驱动(不补提交) |
| 违背的后果 | 永挂(那条 Append 再也没回来) | 语义污染(订阅方自己制造事件) |
判据只有一条:等的是”自己的活被完成”,驱动它就是你的责任;等的是”别人的事发生”,替它干活就是破坏。 同一个文件里两个等待原语,写法几乎一样,纪律完全相反——因为它们在语义上是两种东西。
泵化实测还抓到一个小陷阱,顺手记在这:唤醒返回的”新边界”不能直接当游标用。事件驱动只保证”你醒的时候,边界越过了你要的位置”,不保证”这段数据已经被你消费”——(旧界, 新界] 必须先试拉消费,唤醒返回值只当信号看。直接赋值,就丢一段。
泵不是循环:边界、断连、收口
写泵最容易滑向”一个 while 循环加 await”。但订阅要面对三件循环给不了的东西:
弃流。 旧形态是单个 async IAsyncEnumerable,把有界补扫和无界推送缝进一个状态机,消费方是唯一的驱动者。于是埋了一个语言层的雷:MoveNextAsync 在途(等待中、未取消)时 DisposeAsync,编译器生成的迭代器守卫直接抛 NotSupportedException——消息是空的,栈全是生成代码帧。“弃流”这种再正常不过的操作,在旧形态下是一条不可靠路径。
新形态是”订阅流对象 + 自有泵任务”:Watch() 调用即注册(锁内捕获水位,不依赖消费方开始枚举);泵任务负责补扫 → 缝合 → 实时三段串行,写入单一输出通道(单写者,保序);消费方的枚举器是显式的(快路径同步结算零分配,慢路径经桥接排干)。弃流的收口顺序因此变成结构问题而不是语言问题:先唤醒在途等待、排干、再注销订阅——Dispose 任意时刻安全、幂等,语言层守卫从结构上不可及。
断连。 慢订阅者的通道写满,中枢只能做一件事:把它从订阅表摘掉,通道以”已断连”收束——异常文案自带补救:“凭已见最大事件地址重订阅续传”。弃流没 Dispose 的订阅也靠这条容量线自愈。有界通道 + 断连 > 无界通道 + 反压:前者丢的是订阅者(它自己会回来),后者丢的是写路径的自治。
单读者。 订阅流是单读者契约,第二个枚举者直接拒绝——把”并发消费同一个游标”的用法拦在门口,好过在运行时给它现编语义。
验收:把”不挂死”写成断言
泵化收尾时,订阅契约的测试模板落了 9 项(三个产品 × 三条事实):
- 无重无漏:订阅前写两条、订阅后写两条,四条全到且游标唯一——缝合点、补扫、实时段交界处一条不重一条不漏;
- 取消必落地:在途等待取消后必须落地——“挂死 = 违约”。注意断言的是”落地”而不是”抛什么异常”:各产品收口形态不同(泵是取消异常、跟订是干净结束),契约只约束”不挂死”;
- 续传不重放:消费两条后重订,已消费的不得重投;Queue 没有重放语义,映射成”重订阅不重投已确认”。
外加两条实时性门禁,把”轮询 = 违约”变成可回归的锚:Log 投递延迟断言 < 40ms(对照退役轮询的 50ms 间隔下界);Queue 从发信号到投递 < 400ms(对照 30 秒的兜底空拉)。Queue 侧还有个说明:可见性到期在单机形态没有独立事件源,用兜底空拉承接——兜底不是投递路径,投递永远由信号或消费驱动。
收尾:一条可迁移的判据
如果只能留一句给未来的自己:
通知面只推”有新”,数据面永远走拉取——推送是一封信,不是一份副本。
还有,写”等待”之前先想清楚它在等谁:等自己的活被完成,驱动它是你的责任;等别人的事发生,替它干活就是破坏。
至于那 3 分 27 秒——它现在是我们设计文档里最有价值的一页。改判不是反复,是把判据从”现在是什么”换成”应该承诺什么”的代价;这个代价,越早付越便宜。
本文事实来自 KernLab.Tier 的公开聚合面统一契约规范(订阅契约与判例条款、三形态订阅与泵形态四要件)、平台完备性总纲的订阅波次与实现计划任务书、EntryLog 提交推进通知原语、TierLog 跟订 / Queue 消费订阅 / TierVarKv Watch 三形态的泵化实现、订阅契约参数化测试套件(三产品 × 三项)与实时性门禁断言。
评论