qinghuan
← 回到知识探索

后端现场 / 可下载小项目

订单已经发生,数据晚点才到:日报该怎样补算?

给报表助手做一个可运行的汇总器:区分发生与接收时间,发布可修订结果,按明确政策封存,并保留封存后才到的数据。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先决定报表回答哪个时间问题
  4. 同样七笔订单,先算两张可对照的表
  5. 来源说“进度到十了”,不等于旧数据绝不会再来
  6. 把临时、可修订和按政策封存写成三个状态
  7. 第一次报四十,第二次报九十:后者是新总额
  8. 封存后到的七十,不能悄悄丢掉或塞到今天
  9. 跑完整应用,再检查边界处有没有多算或漏算
  10. 助手引用数字时,也要带上结果的状态
  11. 记住这三点
  12. 别把例子用过头
  13. 自己试一小步
  14. 查原始资料
开始前,会这些就够了
  • 能读懂 Python 字典、类和简单断言
  • 知道按某个字段分组求和是什么意思
  • 先想清楚报表问的是订单发生时间还是系统接收时间

每天早上,助手给店长汇总昨天的订单。某笔订单昨天已经成交,数据却在今天才传到系统。按收到的日期算,它会挤进今天;按成交日期算,昨天的数字又需要修改。助手即使没有漏读任何一行,也可能把一个仍会变化的结果写成“昨日最终销售额”。

这个项目把一天缩成十分钟一个统计窗口,使用七笔虚构订单和两条明确的进度消息。你会得到两张口径不同的汇总表、一段有版本的发布历史,以及一份超过接纳期限的记录。重点是让“目前算了多少”和“按什么规则不再改”都能被检查。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本;制作时实测 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • late_order_report.py:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的观察记录
  • verification.json:对应源码的验证范围
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B late_order_report.py

解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 固定单源订单流与显式进度消息的离线规则;不代表真实消息队列、Flink 执行或线上订单报表。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

晚到的订单,还能补进这份报表吗?

一步处理一条消息:七笔虚构订单中穿插两条来源进度。先试迟到余量 5,再切到 0 比较;改变余量会从头开始。

已处理 0 / 9 条消息 · 来源进度 尚未报告

尚未处理消息。

先预测这笔订单属于哪个发生时间窗口,再处理下一条消息。

下一条:订单 A:第 2 分钟发生,第 3 分钟收到,金额 10

按发生时间累计的三个窗口

窗口含左端、不含右端。W ≥ 窗口结束 + L 时立即封存,等于门槛也算到期。

发生窗口当前金额状态已发布版本
[0, 10)— 尚无接纳记录临时累计尚未发布
[10, 20)— 尚无接纳记录临时累计尚未发布
[20, 30)— 尚无接纳记录临时累计尚未发布

“—”表示还没有接纳记录,不能据此认定实际金额为零。各版数字都是累计总额,不要相加。

封存后收到的旁路记录

目前没有因封存而旁路的订单;这不保证后续不会出现。

固定七笔订单的回看对照

这张表始终使用全部七笔原始订单,包含尚未处理及封存后才到的订单。它不是当前步骤已经知道的答案。

时间窗口按发生时间按接收时间
[0, 10)16010
[10, 20)60140
[20, 30)60130
全部合计280280

仅在浏览器内重放固定数据,不联网、不等待真实时间、不调用模型。W 是来源提供的进度估计,收到一笔新订单或读完序列都不会自动推进 W。L 限定的是进度余量,不是收到订单后等待多久;按政策封存也不证明所有事实已经齐全。

先决定报表回答哪个时间问题

每笔订单保存 occurred 和 received:前者是业务发生时间,后者是本系统收到这条记录的时间。订单 C 在第八分钟发生,第十四分钟才收到;它属于哪个窗口,要看报告究竟问“何时发生的订单”,还是“何时收到的数据”。两个问题都有用途,不能共用一个含糊的“时间”字段。

本例统一用同一虚构时钟上的非负整数分钟,金额为虚构的正整数单位。窗口大小为十,范围是 [0,10)、[10,20)、[20,30):左端包含,右端不包含。因此发生在第十分钟的订单进入第二格。真实日报还需先约定时区、日界线以及订单状态,这些转换不在这个小程序里。

Apache Beam 与 Flink 都区分事件时间和处理时间,并讨论乱序到达。这里的 received 是夹具明写的接收时刻,便于固定重现;它不是声称模拟了实际框架每个算子的处理时钟。代码只按输入顺序处理单一来源,每笔业务事件的编号都必须唯一。

同样七笔订单,先算两张可对照的表

先忽略封存政策,把已经收到的七笔原始订单全部拿来分组。下面第一张按 occurred,第二张按 received;编号列帮助你逐笔查出金额挪到了哪里。进度消息不是订单,不能参与求和。两张表采用相同的数据全集,所以总额必须同为 280。

第一格最明显:按发生时间是 160,按接收时间只有 10。第八分钟发生的 C,连同后来才收到的 E、G,都在接收口径下移到了后面的格子。总额一样只能说明这里没有凭空增加或减少金额,不能说明每个日期或窗口的业务归属一样。

这两张表是在夹具末尾回看全部已知原始订单得到的对照,不是前面时刻就能知道的答案。尤其 G 在第二十一分钟才到,系统第十五分钟首次发布时不能提前把它算进去。后文的政策汇总会有意与这份回看表不同,我们必须解释差额去向。

小实验 · text
按发生时间分组(同一批七笔原始订单)
window     orders       total
[00,10)    A,C,E,G        160
[10,20)    B,D             60
[20,30)    F               60
ALL                      280

按接收时间分组(同一批七笔原始订单)
window     orders       total
[00,10)    A               10
[10,20)    B,C,E,D        140
[20,30)    G,F            130
ALL                      280

怎样运行:这是夹具的两张对照表。第七节完整脚本会从同一批 Event 对象重新计算,并断言这六个分组值。

运行后对照结果
两表总额都为280,三个窗口分别为160/60/60与10/140/130。

来源说“进度到十了”,不等于旧数据绝不会再来

Report 除了接收 Event,还接收来源给出的 Progress。第十五分钟收到 Progress(10,15),意思是来源提供了事件时间进度 W=10;第二个参数才是收到这条进度的时刻。W 常被叫作水位线:它让系统有一个推进窗口的依据,不需要一直等待所有未来数据。

这里明确把 W 当作来源的进度估计。Flink 文档讨论的水位线预期仍可能被迟到事件打破;Beam 也把来源、水位与迟到处理联系起来。不能把“按当前估计可以往前走”改写成“事实保证十之前所有订单齐全”。本例随后送入旧订单,正是要让这条边界可见。

W 只由显式 Progress 推进,不取机器当前时间,也不因看见一笔较新的订单就自动跳过去。程序要求 W 不倒退,并在这套统一时钟夹具里要求 W 不超过收到进度的时刻。这个限制属于本例输入约定,不是所有系统必须使用的时间同步规则。

把临时、可修订和按政策封存写成三个状态

设窗口右端为 end,允许的迟到余量 L=5。本例规则写得很具体:W<end 时是 temporary,表示临时累计;end<=W<end+L 时是 revisable,已经发布但仍接受修订;W>=end+L 时是 sealed_by_policy,此后不再改这份窗口结果。每一个等号都属于右边的状态。

所以第一个窗口在 W=10 时进入可修订,在 W=15 时立即按政策封存。恰好等于十五也不能再接纳旧订单,不能理解成“超过十五才封”。窗口归属仍按发生时间决定:发生时刻等于十进入下一窗,与水位比较中的等号是两个不同判断。

这里的 L 比较的是事件时间领域的进度差,不是收到订单后等待五分钟,也不是启动一个五分钟睡眠。Kafka 的窗口文档提供了固定窗口与宽限期的概念参考;具体比较式是本练习声明的政策,不声称所有框架都采用同一个等号约定。

当 L=0,W 一到 end 就直接封存,不存在可修订区间。若一次进度跨过两个门槛,也直接发布封存状态。程序只给已经积累过数据的窗口发布结果,不把没有见过的窗口自动写成零;没有记录与确认金额为零,需要另外的覆盖证明。

第一次报四十,第二次报九十:后者是新总额

第一个窗口先收到 A=10、C=30,临时累计为 40。收到 W=10 后发布第一版:总额 40,可修订。第十六分钟又收到发生在第五分钟的 E=50,此时 W 仍是 10,尚未达到封存门槛,因此把同一个窗口更新为第二版,总额 90。

published 中的每条记录带窗口起止、revision、total、status、水位和发布时刻。total 是累计金额,不是本次增加量。下游展示报表时,要按窗口键用新版替换旧版;若把四十和九十再相加,就会把旧订单重复计入。版本历史用于解释变化,不是另一张待求和的订单表。

第二十分钟收到 W=15,第三版仍为 90,但状态改为按政策封存。重复收到相同进度不会再发布相同状态。代码通过断言核对三次发布的金额、状态和版本号;它没有把这些记录写进真实数据库,也没有实现远端消费者乱序更新时的版本保护。

封存后到的七十,不能悄悄丢掉或塞到今天

订单 G 在第九分钟发生,第二十一分钟才收到,金额 70。它应归第一个发生时间窗口,但此刻 W=15 已达到封存门槛。程序将完整事件、对应窗口、水位及拒绝原因保存在 side_records;首窗口保持 90。即使此前完全没见过这个旧窗口,也走同样的旁路记录。

这不表示 G 是假订单,也不表示它应自动改算为今天的业务。它只是没有进入这份政策限定的统计结果。真实产品可以另出调整报告、人工核对或按新政策重算;必须说明如何引用原报告。本练习只保留待处理记录,不自行实现补账或修改历史结算。

此时政策内三个窗口共存了 210,旁路金额为 70,两者相加正好等于全部已知订单的 280。这项守恒检查能发现本例漏记和重复计入,却不能证明来源还有没有尚未送来的订单。首窗封存额 90 与回看发生表的 160 不同,恰好说明“按政策结束”不是“所有事实永远齐全”。

跑完整应用,再检查边界处有没有多算或漏算

把下面代码保存为 late_order_report.py,或解压练习包后运行。Python 3.10 或更新版本即可,只用标准库,不联网、不调用模型。输入顺序直接列在 main:七笔订单夹着两条进度消息,没有后台计时器,也没有随机延迟。先手算两张表和三次发布,再对照运行日志。

脚本还检查发生时刻十的窗口归属、W=14 接纳而 W=15 拒绝、零余量直接封存、倒退进度拒绝、重复编号拒绝,以及只把接收时刻改成一百仍不推进 W。唯一编号是受控夹具约定;它不提供网络重试去重、退款冲正或真实订单状态变化的业务实现。

展开收起代码 · 小实验python · 218 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Offline, single-source order windows; Python 3.10+, standard library only.

Integer minutes share one fixture clock. Amounts are fictional integer units.
The source explicitly supplies watermark estimates; receipt time never advances
them automatically. Every event ID must be unique in this controlled input.
"""
from dataclasses import dataclass


@dataclass(frozen=True)
class Event:
    event_id: str
    occurred: int
    received: int
    amount: int


@dataclass(frozen=True)
class Progress:
    watermark: int
    received: int


@dataclass
class Window:
    total: int = 0
    revision: int = 0
    last_published_status: str | None = None


class Report:
    def __init__(self, size: int = 10, lateness: int = 5):
        if type(size) is not int or size <= 0:
            raise ValueError("size must be a positive integer")
        if type(lateness) is not int or lateness < 0:
            raise ValueError("lateness must be a nonnegative integer")
        self.size, self.lateness = size, lateness
        self.watermark = -1
        self.last_received = -1
        self.windows: dict[int, Window] = {}
        self.seen_ids: set[str] = set()
        self.published: list[dict] = []
        self.side_records: list[dict] = []

    def start_of(self, time: int) -> int:
        # Half-open windows: an event at 10 belongs to [10, 20), not [0, 10).
        return time // self.size * self.size

    def status(self, start: int) -> str:
        end = start + self.size
        if self.watermark >= end + self.lateness:
            return "sealed_by_policy"
        if self.watermark >= end:
            return "revisable"
        return "temporary"

    def publish(self, start: int, received: int) -> None:
        window = self.windows[start]
        window.revision += 1
        window.last_published_status = self.status(start)
        self.published.append({
            "start": start, "end": start + self.size,
            "revision": window.revision, "total": window.total,
            "status": self.status(start), "watermark": self.watermark,
            "received": received,
        })

    def accept(self, item: Event | Progress) -> None:
        if not isinstance(item, (Event, Progress)):
            raise TypeError("expected Event or Progress")
        if type(item.received) is not int or item.received < self.last_received:
            raise ValueError("received times must be nondecreasing integers")
        if item.received < 0:
            raise ValueError("fixture received time must be nonnegative")
        if isinstance(item, Progress):
            if (type(item.watermark) is not int
                    or not self.watermark <= item.watermark <= item.received):
                raise ValueError("fixture watermark must be monotone and <= received")
            self.last_received = item.received
            self.watermark = item.watermark
            for start, window in sorted(self.windows.items()):
                status = self.status(start)
                if status != "temporary" and status != window.last_published_status:
                    self.publish(start, item.received)
            return
        if not isinstance(item.event_id, str) or not item.event_id:
            raise ValueError("event ID must be a nonempty string")
        if item.event_id in self.seen_ids:
            raise ValueError("fixture event IDs must be unique")
        if (type(item.occurred) is not int
                or not 0 <= item.occurred <= item.received):
            raise ValueError("fixture occurrence must be between 0 and receipt")
        if type(item.amount) is not int or item.amount <= 0:
            raise ValueError("fixture amount must be a positive integer")

        self.last_received = item.received
        self.seen_ids.add(item.event_id)
        start = self.start_of(item.occurred)
        if self.status(start) == "sealed_by_policy":
            self.side_records.append({
                "event": item, "start": start, "end": start + self.size,
                "watermark": self.watermark,
                "reason": "received after the policy cutoff",
            })
            return
        window = self.windows.setdefault(start, Window())
        window.total += item.amount
        if self.status(start) == "revisable":
            self.publish(start, item.received)


def raw_totals(events: list[Event], field: str, size: int = 10) -> dict[int, int]:
    totals: dict[int, int] = {}
    for event in events:
        start = getattr(event, field) // size * size
        totals[start] = totals.get(start, 0) + event.amount
    return totals


def must_reject(action) -> None:
    try:
        action()
    except ValueError:
        return
    raise AssertionError("expected rejection")


def boundary_checks() -> None:
    edge = Report()
    edge.accept(Event("at-10", 10, 10, 1))
    assert 10 in edge.windows and 0 not in edge.windows

    boundary = Report()
    boundary.accept(Progress(14, 14))
    boundary.accept(Event("before-cutoff", 9, 15, 1))
    assert boundary.windows[0].total == 1
    assert boundary.published[-1]["status"] == "revisable"
    boundary.accept(Progress(15, 16))
    boundary.accept(Event("at-cutoff", 9, 17, 2))
    assert boundary.windows[0].total == 1 and len(boundary.side_records) == 1
    count = len(boundary.published)
    boundary.accept(Progress(15, 18))
    assert len(boundary.published) == count
    must_reject(lambda: boundary.accept(Progress(14, 19)))
    assert boundary.watermark == 15 and boundary.last_received == 18
    must_reject(lambda: boundary.accept(Event("at-cutoff", 9, 19, 2)))
    assert len(boundary.side_records) == 1

    zero = Report(lateness=0)
    zero.accept(Event("zero", 2, 3, 1))
    zero.accept(Progress(10, 10))
    assert zero.published[-1]["status"] == "sealed_by_policy"
    assert zero.published[-1]["revision"] == 1
    zero.accept(Event("zero-late", 3, 11, 2))
    assert zero.windows[0].total == 1 and len(zero.side_records) == 1

    idle = Report()
    idle.accept(Event("old", 2, 100, 1))
    assert idle.watermark == -1 and idle.status(0) == "temporary"
    assert idle.published == []
    empty = Report()
    empty.accept(Progress(15, 15))
    empty.accept(Event("unseen-old-window", 2, 16, 1))
    assert empty.windows == {} and len(empty.side_records) == 1
    must_reject(lambda: Report(lateness=-1))
    must_reject(lambda: Report(size=0))


def main() -> None:
    stream: list[Event | Progress] = [
        Event("A", 2, 3, 10),
        Event("B", 12, 12, 20),
        Event("C", 8, 14, 30),
        Progress(10, 15),
        Event("E", 5, 16, 50),
        Event("D", 16, 18, 40),
        Progress(15, 20),
        Event("G", 9, 21, 70),
        Event("F", 21, 22, 60),
    ]
    report = Report()
    for item in stream:
        report.accept(item)
    events = [item for item in stream if isinstance(item, Event)]
    occurred = raw_totals(events, "occurred")
    received = raw_totals(events, "received")
    assert occurred == {0: 160, 10: 60, 20: 60}
    assert received == {0: 10, 10: 140, 20: 130}
    print("Same seven raw orders, grouped two ways:")
    print("window     by_occurrence  by_receipt")
    for start in (0, 10, 20):
        print(f"[{start:02d},{start + 10:02d})    {occurred[start]:>3}            {received[start]:>3}")
    print("Policy publications (replace row by window, never add totals):")
    for row in report.published:
        print(f"r={row['received']:02d} W={row['watermark']:02d} "
              f"[{row['start']:02d},{row['end']:02d}) revision={row['revision']} "
              f"total={row['total']} {row['status']}")
    assert [(row["revision"], row["total"], row["status"]) for row in report.published] == [
        (1, 40, "revisable"), (2, 90, "revisable"), (3, 90, "sealed_by_policy")]
    print(f"At receipt=22: watermark={report.watermark}; current stored windows:")
    for start, window in sorted(report.windows.items()):
        print(f"[{start:02d},{start + report.size:02d}) total={window.total} {report.status(start)}")
    assert report.side_records[0]["event"].event_id == "G"
    side_total = sum(record["event"].amount for record in report.side_records)
    stored_total = sum(window.total for window in report.windows.values())
    assert sum(occurred.values()) == sum(received.values()) == stored_total + side_total == 280
    side = report.side_records[0]
    late = side["event"]
    print(f"Side record: {late.event_id} occurred={late.occurred} "
          f"received={late.received} amount={late.amount} W={side['watermark']}")
    print(f"Reason: {side['reason']}; sealed [00,10) remains 90")
    print(f"Conservation: stored={stored_total} + side={side_total} = raw=280")
    boundary_checks()
    print("PASS: half-open windows, equality cutoffs, revisions, side records, no clock-driven progress")


if __name__ == "__main__":
    main()

怎样运行:保存为 late_order_report.py,用 Python 3.10 或更新版本运行:python late_order_report.py。仅标准库;所有订单、时刻、金额均为虚构。 2026-10-13 已用 3.14.0 执行这份示例并通过自带断言;下方为当次输出。固定单源订单流与显式进度消息的离线规则;不代表真实消息队列、Flink 执行或线上订单报表。

运行后对照结果
Same seven raw orders, grouped two ways:
window     by_occurrence  by_receipt
[00,10)    160             10
[10,20)     60            140
[20,30)     60            130
Policy publications (replace row by window, never add totals):
r=15 W=10 [00,10) revision=1 total=40 revisable
r=16 W=10 [00,10) revision=2 total=90 revisable
r=20 W=15 [00,10) revision=3 total=90 sealed_by_policy
At receipt=22: watermark=15; current stored windows:
[00,10) total=90 sealed_by_policy
[10,20) total=60 temporary
[20,30) total=60 temporary
Side record: G occurred=9 received=21 amount=70 W=15
Reason: received after the policy cutoff; sealed [00,10) remains 90
Conservation: stored=210 + side=70 = raw=280
PASS: half-open windows, equality cutoffs, revisions, side records, no clock-driven progress

助手引用数字时,也要带上结果的状态

这份汇总器可以成为日报助手读取的数据层。助手若在第一版引用 40,应说明是可修订结果;引用第三版 90,则应说明统计口径和封存政策,并披露已记录的迟到差额。工具返回的状态字段有业务意义,不能为了让句子显得肯定,就把三种状态统一改写成“最终真实金额”。

运行停在接收时刻二十二,W 仍为十五,所以第二、第三窗口都保持临时状态。输入列表已经读完,并不等于来源宣布关闭;如果演示程序顺手把剩余窗口全部封存,就额外假设了一个根本不存在的结束信号。报告应保存这个尚未完成的状态。

交付脚本、完整日志、两张时间口径表,以及 G 的旁路记录,并写明单一来源、统一时钟、内存状态的限制。多个来源、闲置分区、断线恢复和持久化都需要进一步设计。本例验证的是确定的汇总控制流程,没有验证真实模型能否正确写日报,也没有证明真实订单数据完整。

记住这三点

  • 发生时间决定业务归属,接收时间决定何时看见;同一批订单可得到不同的分窗结果。
  • 水位提供推进依据,临时、可修订和按政策封存应有明确且包含等号的规则。
  • 发布修订总额应替换同窗口旧版;封存后到达的事件应保留去向,不能变成隐形差额。

别把例子用过头

  • 把水位、机器当前时刻或输入列表读完,当成所有历史订单已经齐全。
  • 把迟到余量误读成接收后等待多久,或漏写封存边界上的等号。
  • 相加多个版本的累计值,或者把封存后才收到的旧订单直接塞进当前业务日。

自己试一小步

如果店长要求更早看到结果,又希望多接纳一些迟到订单,你能分别改变发布时机和封存政策,并解释每次数字变化吗?

  1. 独立准备八笔订单和至少三条来源进度消息,保留同一批原始事件的发生时间表与接收时间表。
  2. 改变迟到余量,设计恰好到门槛前、门槛上、门槛后的事件,记录每次接纳和旁路原因。
  3. 写一条助手引用结果的句子,同时交付窗口状态、当前版本、统计口径和旁路金额,避免把未知补成确定。

做到这些,就算完成

  • 是否明确区分窗口右端、接纳期限和接收时刻,各个等号归哪边?
  • 每次修订能否按窗口替换旧累计值,封存后记录能否追回而不修改旧结果?
  • 政策内总额加旁路金额是否等于已知原始金额,同时承认这不能证明未知事件不存在?

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Beam 编程指南:水位、迟到数据与触发 (新标签页)↗Apache Beam · 英文

    阅读8.4 Watermarks and late data与9.1 Event time triggers;支持事件与处理时间区别、来源进度估计,以及迟到接纳和发布时机需明确配置。本文实现自定的教学政策。

    2026-10-13 已打开原始公开正文并核对所列段落;无需登录。本文仅运行原创标准库夹具,没有运行对应流处理框架或访问真实订单。

  • Flink 时间概念:事件时间与水位 (新标签页)↗Apache Flink · 英文

    阅读Notions of time与Event Time and Watermarks;支持乱序、进度预期和迟到事件仍可能出现的边界。多个输入的水位合并仅作为扩展问题,本文没有实现。

    2026-10-13 已打开原始公开正文并核对所列段落;无需登录。本文仅运行原创标准库夹具,没有运行对应流处理框架或访问真实订单。

  • Kafka Streams:TimeWindows与宽限期 (新标签页)↗Apache Kafka · 英文

    阅读ofSizeAndGrace与gracePeriodMs;支持固定大小半开窗口和基于流时间进度的宽限概念。具体W>=end+L比较是本例政策,未运行Kafka或声称所有框架边界相同。

    2026-10-13 已打开原始公开正文并核对所列段落;无需登录。本文仅运行原创标准库夹具,没有运行对应流处理框架或访问真实订单。