9月23日投酵母,10月12日把酒从沉淀上虹吸进玻璃发酵罐。前后十九天,正好落在配方说的“一到三周”里。日历是对的。但这依然不是它被移动的理由。

打开网易新闻 查看精彩图片

每一份发酵配方都附带时间估计:主发酵一到三周,二次发酵再来几周,然后陈放几个月。这些数字不算错,它们是大量批次平均出来的结果,用途只有一个——做计划。它告诉你什么时候该备好一个干净的发酵罐,什么时候该腾地方,什么时候别再安排周末。

它做不到的是告诉你这一批已经好了。酵母不读配方。它的工作速度由温度、糖分、营养、酸度、具体菌株和具体水果决定,而这些配方作者一个都没见过。配方给的是预报,发酵给的是证据。只有后者有资格移动这桶酒。两者碰巧一致,比如这次,那叫巧合,不叫授权。

酿酒人真正在用的规则很简单:连续几天取样,当同一个读数连续三次出现,发酵才算结束。这条规则好在它不是什么——不是一个日期,也不是单次读数。一次测量只告诉你发酵到了哪,不告诉你它还在不在动。它是被一个时间窗口撑住的证据。时间是测试的一部分,但只是让证据保持不动的那个空间。

梨酒的折射仪读数里有两处值得细看。第一处是早期的平台期:大部分糖在两天内就没了,随后读数整整五天卡在6.0。如果只找“不动”,那看起来很像终点。它不是。发酵在减速,不是停止,接下来一周它继续往下爬,最后才真正稳住。两次相同的读数是暂停,不是判决——这正是规则要求三次的原因。

第二处是我没有停在三次。到10月10日,我已经拿到连续三个5.6,规则满足了。我还是继续测,一直等到八天里五次读数一致。

然后换桶本身又送来一份证据。主发酵罐里的酒比玻璃罐能装的多,与其倒掉,我把它灌进了比重计筒:完全不同的仪器,测完全不同的物理性质。折射仪在做过酒精校正后给出约1.004,比重计说1.002。两个独立仪器,足够接近,第二个还顺带确认了我信了三周的校正公式确实在干活。

这件事是碰巧发生的,但它又不算碰巧。比重计需要大样本,一加仑的批次里,日常取样太贵。而在状态切换的那一刻,酒本来就要被移动,同样的样本成本为零。切换,正是更可信的证据变便宜的时候。

很多系统里,状态只是数据库里的一个字符串,切换就是一条 UPDATE 语句。这条语句不要求任何东西真的被发货了。它是个标签,任何有写权限的人都能以任何理由贴上:一个定时任务在付款两天后把订单标成已发货,因为通常如此;一条发布流水线把分支上的东西推上去,因为今天是周二;一个客服工具翻个开关让工单消失。

这些都是“配方说两三周所以换桶”的软件版本。大多数时候它甚至是对的,就像我的日历。切换按计划发生,看板更新,系统开始断言一个没人验证过的状态。

修法并不复杂。切换应该带一个守卫:一个基于证据、必须为真的条件,满足之后状态才能变。“已发货”应该要求承运商扫描记录,“已发布”应该要求定义可发布的那组检查,“主发酵完成”应该要求读数稳定。状态机的职责是在证据不在时拒绝这次切换,无论日历怎么坚持;并且在日历碰巧正确的那些天,也照样拒绝。

有意思的地方在这里:“三次一致读数”是完成的信号,但一个停滞的发酵同样会产生三次一致读数。蜂蜜酒在这方面名声很差。蜂蜜几乎没有天然缓冲能力,发酵推进时 pH 一路下滑,过了某个点酵母就减速甚至彻底停工,往往离干型还差得远。从折射仪的角度看,停滞的蜂蜜酒和完成的蜂蜜酒长得一模一样:一个不再移动的数字。

我那桶柳兰蜂蜜酒离这个问题只有几天。它的 pH 从3.5起步,头四天掉到3.1,之后基本停在那里。看校正后的那一列:计算器现在声称这桶酒含的糖比零还少,这不可能。那是模型被推到了设计范围之外,也提醒人一件事——推导出来的数字可以自信地断言一件物理系统做不到的事。如果我的守卫是“校正白利度小于等于零”,两天前我就该换桶了。

原始读数讲的是另一个故事:它们还在缓慢下降。酒还没静下来,守卫就不满足,校正数字说什么都没用。等它真的静下来,问题就变成停在哪。我用的71B菌株通常标注耐受约14%酒精度,而估算值已经超过13%。一桶在酵母极限附近停下、该消耗的糖也消耗完的酒,大概率是完成了;一桶早早停下、糖还有剩、pH 又低的酒,大概率是卡住了。

这桶蜂蜜酒已经用一个小插曲教过我一次。蜂蜜酒的酵母需要在早期分阶段加营养,教科书把最后一次添加绑在“三分之一糖降点”上,也就是大约三分之一糖被消耗的时刻。我按天数排了三次添加。碰巧最后一次正好落在糖降点附近。有时候运气就是会来。但那就是运气:日程不知道糖在哪,如果这桶酒慢上几天,同一个日历就会在错误的时间喂它。

换句话说,“完成”的守卫不能只是“稳定”,必须是“稳定在一个完成发酵该在的位置”。这意味着守卫需要一个关于预期终态的模型,而不只是一个检测运动的测试。它还必须在输入互相矛盾时知道该信哪个。

软件里到处是同样的歧义。一个批处理任务的进度计数器一小时没动,它要么完成了,要么挂了。队列深度停在零,要么全处理完了,要么消费者死了。复制延迟为零,可能是副本追上了,也可能是它不再收到任何东西可追。每一种情况下,“什么都没变”都是与两种相反状态同时相容的证据,只检查静止的切换,迟早会选错那一个。

梨酒日志里10月12日那一行,实际写的是:上午11:10,5.6 白利度,比重计 1.002,移入玻璃罐。切换和支撑它的证据在同一行上。

这比新状态本身更重要。几个月后如果酒出了问题,“10月12日换桶”几乎什么都告诉不了我。“在五次稳定读数之后换桶,并由第二台仪器确认”告诉我当时信了什么、依据是什么、以及我有没有可能被一次停滞骗了。

系统也能从这个习惯里受益。状态变更不该只是覆盖上一个状态,它应该被记成一个事件:原状态是什么,变成了什么,什么证据满足了守卫,以及是谁或什么做的决定。这样当前状态就是你能解释的东西,而不只是能读出来的东西。当“已发货”被证明是错的,你能看出是守卫写坏了、证据坏了,还是守卫压根没被检查过。

这不意味着时间无关紧要。它有三个正当角色。时间可以许可一次切换:有些切换无论证据如何都不该早于最小间隔发生,比如退款到账前的等待期,或者装瓶前必须的最短陈放。这里时间是和证据并列的前置条件,不是替代品。

时间可以创造证据产生的条件。三次读数规则之所以成立,是因为读数分散在几天里。窗口内的稳定需要一个窗口。梨酒那个假平台正是例子:相隔五天的两次读数看起来是静的,只有更多时间才证明它们不是。

时间可以提出一个问题。如果一桶发酵在早该结束之后很久还没结束,那值得查一查。但注意超时实际告诉你的:有东西不正常。它不告诉你这桶完成了,也不告诉你这桶失败了。

最后这一点是软件最常出错的地方。超时经常被直接接到终态上:三十秒没响应就是失败,三天没更新就是完成。超时是“你需要去看一眼”的正当证据,它很少是“你会看到什么”的证据。超时应该产生一个问题,而不是一个答案。

时间不该独自做的,是引发切换。十九天到了,不是关于这桶梨酒的事实。

把日程当预报。用它做计划。别让它移动状态,哪怕它是对的。给每一个重要的切换装上守卫:如果一个状态能靠写一个字符串到达,它迟早会因为错误的理由被到达。测试预期终态,而不只是静止——稳定和完成是两个不同的断言,分不清它们的守卫总有一天会把停滞宣布为成功。证据互相矛盾时,知道该信哪个:推导值可以自信地不可能,原始观测和独立仪器是抓住它的方式。把证据和切换记在一起:新状态是结论,把理由留在它旁边。让超时去提问,不去回答:把它们路由到“需要关注”,而不是“完成”或“失败”。

梨酒现在在玻璃罐里,慢慢澄清。10月12日是它移动的那天。那天碰巧是周一。那也不是理由。