返回日报条目

资料节译

配送地图事件保留与遗漏更新恢复设计

阅读原文

简短回答:为配送追踪地图保留一个短期、持久的事件窗口,让重连请求游标,并将快照加重放视为恢复契约。实时传送是性能路径;留存机制决定了手机休眠、隧道断网或浏览器进程重启后,系统能否保持正确。

我用 Python 构建 RAG 和智能体功能,所以我通常先测试那些看似平淡的边界情况:客户端带着旧游标回来时会发生什么?在顺畅演示中看起来实时的地图,如果留存策略只支持理想路径,仍可能显示一位落后十分钟的配送员。实用的设计是小型事件日志、明确的过期语义,以及衡量恢复能力而不只是中位延迟的评估测试工具。

配送服务应发布不可变的位置事件,并为每次配送(或每个地图分片)使用单调递增的序列号。事件可能包含 delivery_id、sequence、observed_at、粗略位置,以及诸如 en_route 或 delivered 这样的状态。地图网关会保留已渲染的最新序列号。重连时,客户端发送该游标;网关要么重放缺失的范围,要么返回一份新快照,随后发送更新的事件。

这种区别很重要。WebSocket 是一种传输方式,不是历史数据库。WebRTC 数据通道可以传输低延迟的应用数据,但 W3C 建议将应用层的排序、持久化和重放策略交由通道周围的系统处理。换句话说,重连行为需要由你来定义协议。

留存计时应从日志接受事件时开始,而不是从某个查看者收到事件时开始。分别保留 observed_at(设备时间)和 ingested_at(服务器时间);移动设备时钟会漂移,用设备时间计算过期可能会悄然删除近期更新。按 (delivery_id, sequence) 建立紧凑索引,即使许多客户都在查看同一座城市,也能让范围查询保持可预测。

下面是一个刻意写得很朴素的 Python 网关示例。它使用抽象存储,因此无需先选择消息代理或数据库,就能测试策略。关键在于响应形式:当游标位于留存窗口内时返回 replay,游标过旧时返回 snapshot,客户端已是最新状态时返回 noop。

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Protocol
@dataclass(frozen=True)
class LocationEvent:
    delivery_id: str
    sequence: int
    lat: float
    lon: float
    state: str
    ingested_at: datetime
class EventStore(Protocol):
    def newest_sequence(self, delivery_id: str) -> int: ...
    def oldest_sequence(self, delivery_id: str) -> int: ...
    def after(self, delivery_id: str, sequence: int) -> list[LocationEvent]: ...
    def snapshot(self, delivery_id: str) -> dict: ...
def recover(store: EventStore, delivery_id: str, cursor: int | None) -> dict:
    newest = store.newest_sequence(delivery_id)
    oldest = store.oldest_sequence(delivery_id)
    if cursor is None or cursor < oldest - 1:
        return {
            "kind": "snapshot",
            "state": store.snapshot(delivery_id),
            "cursor": newest,
        }
    if cursor >= newest:
        return {"kind": "noop", "cursor": newest}
    return {
        "kind": "replay",
        "events": [event.__dict__ for event in store.after(delivery_id, cursor)],
        "cursor": newest,
    }

oldest - 1 检查是我会在接入生产流之前纳入评估的一项内容。它可以防止不完整的重放伪装成完整地图。如果快照和重放与新事件同时发生竞争,服务器应返回快照游标,然后严格从该游标之后开始重放。客户端只有在事件序列号恰好等于下一个预期值时才应用事件;出现缺口就再次触发恢复。

让游标保持简单。

早期常见的一个错误是按行数留存:“保留最后 10,000 条更新。”这会让安静的郊区和体育场周边的用户体验不同。时间窗口加上最大字节预算更容易解释。例如,产品可以保留 15 分钟的精细位置数据,并在其他地方保留更长时间的粗粒度历史。具体时长应由产品和隐私决策确定,而不是采用某个通用的实时系统常数。

留存有三个彼此竞争的用途:重连、延迟读取和运维。应分别衡量。诸如“99% 的会话通过重放恢复”这样的重连 SLO,只有与最大离线时长和每次配送的事件速率一起考虑时才有意义。如果事件速率为每秒 2 个,预期的最坏离线间隔为 90 秒,那么查看者回来之前,单次配送可能产生 180 个事件。确定日志容量时,还要将活跃配送数、序列化开销和扇出因素纳入计算。

我会在服务旁边放一个小型重放评估器。它会模拟 5、30 和 120 秒的断连,然后检查渲染状态是否等于对事件进行基准折叠计算所得的状态。它还会检查重复传送、乱序帧,以及只比留存边界早一个序列号的游标。实际情况因人而异:真实的无线电断连往往成簇出现,而预发布环境里的百分位数无法预测地下车库中的网络表现。

留存策略应能通过指标看见:最旧的留存序列号、重放命中率、回退到快照的比例、每次配送占用的字节数,以及从写入到渲染的耗时。记录游标判定,但避免在常规应用日志中写入精确坐标。对配送标识符进行哈希并抽样记录,就足以将恢复失败关联到追踪,同时降低数据暴露。

衡量它。

大多数“地图慢”的报告其实是状态一致性问题。客户端可能成功重连,收到旧快照,然后又从第二个连接应用了较新的事件。没有统一的序列号门控,地图标记就会倒退。另一个常见情况是在应用持久化游标之前就确认消息;进程随后崩溃,就会引发重放风暴。应在应用事件后持久化游标,并让事件应用具备幂等性。

回填也可能让实时路径过载。为重放批次设置有界上限,并在批次之间让出执行权,避免单个移动客户端独占一个分片。如果超过上限,就返回快照。这是一种可控的权衡:地图重新绘制一次,而不是花几秒重建一条过时的轨迹。

在故障演练中,我会让测试配送员在序列号 410 处断开,并让生产者继续运行,直到序列号 590,同时让地图保持离线。客户端回来时,网关应返回 410 之后的所有事件,或者明确发送游标至少为 590 的快照。随后我注入一个重复的 511,将 532 和 533 调换顺序,并把测试时钟推进到留存边界之后。预期结果很简单:忽略重复事件,遇到缺口就请求恢复,游标过期则获取快照。这项演练还能捕捉一种微妙的竞态:如果快照在事件 590 提交时读到的是 589,响应必须标明自己的游标,这样客户端才能请求 590,而不会悄悄丢失该更新。这就是我把恢复评估器放在服务代码旁边的原因;显示已连接套接字的仪表板无法证明系统正确。

令牌范围也属于这里。重连令牌应只授权一个租户,以及特定的配送或地图视图,并且应在留存窗口之前过期。身份验证证明请求者是谁;游标证明他们已经看到了什么。两者解决的是不同问题。

当事件很小、预期离线间隔明显短于留存窗口,而且轨迹必须完整时,适合采用事件重放路径。当某次配送的更新量很大、查看者常在长时间离线后才回来,或者产品只需要显示当前位置标记时,适合采用快照路径。混合策略通常最不容易让人意外:重放有界数量的事件,随后回退到带有明确游标的快照。

需要注意的是,短期窗口不适用于取证历史、法律留存,或需要每个坐标的分析任务。为这些用途保留一个只追加、访问受控的归档,并让地图日志专注于恢复。如果只需简单地转交任务,就使用确认后删除消息的队列;如果需要多个消费者和确定性的回填,就选择可重放日志。没有一个留存时长能同时满足这两类需求。

发布前,我会在笔记本和 CI 中运行同一组检查:在每个边界条件下重连,验证序列号单调递增,使用注入测试的时钟让旧记录过期,并对快照回退进行负载测试。然后与产品和安全团队一起审查隐私说明。运维检查清单很简短:定义游标契约,记录过期规则,限制重放工作量,记录恢复指标,并演练分片恢复。只要其中任何一项含糊不清,地图就仍在靠运气运行。