每个人都想要实时更新,直到他们意识到“快”和“正确”并不是一回事。在分布式系统中,事件可以以光速传输,但仍可能以错误的顺序到达。WebSockets 会断开并重连。消息代理会重新投递数据包。后台工作进程在超时取消的边缘进行竞速。结果呢?客户端可能会先看到事件 42,然后是事件 40,接着看到一个声称系统已达到事件 45 的快照。如果你正在构建长运行的 agent 工作流,这种混乱并非边缘情况,而是常态。在担心如何缩短几毫秒的交付延迟之前,先解决好事件的顺序问题。
“实时”背后的混乱现实
实时是一种传输属性。它描述的是数据包在导线中移动的速度,而不是它所讲述的故事是否有逻辑。长运行任务会放大每一个不一致性,因为它们跨越了较长的时间维度。一个模型训练任务、一个多步审批流或一个视频渲染流水线可能会在几分钟或几小时内发出数十个事件。在那个窗口期内,任何事情都可能出错。
消息代理可能会因为确认(acknowledgement)丢失而重试消息。负载均衡器可能会将两个事件路由到不同的网络路径,导致较新的事件先到达。工作进程可能在写入数据库后但在发布成功事件前挂掉,随后第二个工作进程接手任务并发出它自己的进度。如果你的前端假设最新的消息就是最真实的,它就会描绘出一个从未存在过的状态。用户会看到“已完成”标签闪烁回“处理中”,或者更糟,一个已取消的任务突然“复活”。没有顺序保障的速度,只是更高帧率下的混乱。
序列号才是真正的时钟
解决方案是使用由生产者生成的严格、单调递增的序列号。每一个改变状态的操作都会获得一个精确增加 1 的数字,既没有间隙,也没有回滚。这个数字必须与事件本身在同一个事务中持久化。如果数据库行更新了但序列号提交失败,则必须同时回滚两者。这能确保逻辑时间线与状态变更保持原子性。
事件 ID 仍然有用,但它们解决的是不同的问题。事件 ID 用于标识特定的负载,以便在消息代理重复投递同一条消息时进行去重。而序列号则告诉你该负载在因果链中的位置。它能暴露间隙,暴露顺序。时间戳两者都做不到。时钟会漂移,NTP 会回跳,虚拟机也会暂停。时间戳仅用于显示目的,例如“3 分钟前开始”,绝不要将其作为业务逻辑的排序键。
客户端应如何处理流
一旦生产者保证了单调递增的序列,消费者就会得到简单且明确的规则。如果传入的序列号小于或等于上一次应用的编号,则丢弃它。它要么是重复的,要么是过时的迟到者。如果序列号正好比上一次应用的编号大 1,则立即应用它。这是理想路径。如果序列号跳跃了,比如你预期是 12 但收到了 15,说明有东西丢失了。缓冲新事件,并请求服务器从下一个预期的序列号开始重放。不要猜测。不要试图跳过间隙并寄希望于它无关紧要。
终态必须被视为不可逆的。一旦任务被标记为已完成、失败或已取消,客户端应拒绝该操作后续的任何状态变更。这听起来显而易见,直到你遇到
