适用场景与现象

账户流水按发生时间展示,每行需要显示“处理完这一笔后的累计变动”。测试数据中时间都不相同,结果一直正常;接入批量入账后,同一秒出现多笔流水,第一笔的余额竟然已经包含第二笔金额。最终合计正确,中间余额却不符合逐笔对账规则。

本文使用 SQLite 和 Python 标准库构造可重复现场。金额用整数分表示,累计值是查询范围内的净变动;若要展示真实余额,还要纳入期初余额。例子不代表真实生产事故。

最小复现:先看排序键是否重复

在 SQLite 客户端执行以下 SQL,或在 Python sqlite3 连接上执行:

CREATE TABLE ledger (
    id INTEGER PRIMARY KEY,
    account_id INTEGER NOT NULL,
    occurred_at INTEGER NOT NULL,
    amount_cents INTEGER NOT NULL
);
INSERT INTO ledger VALUES
    (1, 10, 1000, 100),
    (2, 10, 1000, 200),
    (3, 10, 1001, -50);

SELECT id, amount_cents,
       SUM(amount_cents) OVER (
           PARTITION BY account_id ORDER BY occurred_at
       ) AS running_cents
FROM ledger
ORDER BY account_id, occurred_at, id;

返回的累计值是 300、300、250,逐笔预期却是 100、300、250。这不是金额重复写入:流水仍只有三行,最终金额也相同。

排查顺序建议是:先按主键检查是否重复入账,再检查 JOIN 是否扩大行数,最后检查窗口定义。验证时间是否重复可以执行:

SELECT account_id, occurred_at, COUNT(*) AS row_count
FROM ledger
GROUP BY account_id, occurred_at
HAVING COUNT(*) > 1;

本例返回账户 10、时间 1000、行数 2。只有唯一时间的数据不能覆盖这个故障。

原因:输出排序与窗口边界是两回事

省略框架时,此处使用从分区起点到当前排序值及其同值行的默认范围。两笔流水的 occurred_at 相同,因此第一笔也会读到第二笔。SQLite 官方把这些排序值相同的行称为 peers。

外层 ORDER BY ... id 只改变展示顺序,不能改变 OVER (...) 内的计算。于是页面看起来逐笔有序,累计金额却按时间组跳变。

这也可能是正确的业务需求:如果每条记录都要展示“截至这一秒所有流水的合计”,两行同为 300 完全合理。先明确口径,再决定是否修复。

修复:同时固定业务顺序和逐行框架

逐笔余额必须有稳定的全序。本例约定同一发生时间按唯一流水 ID 排序;如果业务要求以记账序号为准,应使用真正的记账序号,而不是默认认为自增 ID 等于业务先后。

SELECT id, amount_cents,
       SUM(amount_cents) OVER (
           PARTITION BY account_id
           ORDER BY occurred_at, id
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS running_cents
FROM ledger
WHERE account_id = ?
ORDER BY occurred_at, id;

PARTITION BY 隔离账户;复合排序消除同时间歧义;ROWS 明确按行累计。参数 ? 交给驱动绑定,不能拼接账户输入。只改 ROWS 却不增加稳定的同时间排序键,仍没有定义同组流水谁先谁后。

可直接运行的回归验证

保存为 test_running_balance.py,运行 python -m unittest -v test_running_balance.py。它只操作内存数据库,不依赖线上账号或外部服务。

import sqlite3
import unittest


def read_balances(
    connection: sqlite3.Connection, account_id: int
) -> list[tuple[int, int]]:
    """按明确的流水顺序计算账户的累计净变动。"""
    return connection.execute(
        """
        SELECT id, SUM(amount_cents) OVER (
            PARTITION BY account_id
            ORDER BY occurred_at, id
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS running_cents
        FROM ledger WHERE account_id = ?
        ORDER BY occurred_at, id
        """,
        (account_id,),
    ).fetchall()


class RunningBalanceTests(unittest.TestCase):
    """覆盖同时间流水、账户边界及金额约束。"""

    def setUp(self) -> None:
        self.connection = sqlite3.connect(":memory:")
        self.addCleanup(self.connection.close)
        self.connection.execute(
            """
            CREATE TABLE ledger (
                id INTEGER PRIMARY KEY,
                account_id INTEGER NOT NULL,
                occurred_at INTEGER NOT NULL,
                amount_cents INTEGER NOT NULL
            )
            """
        )
        # 故意打乱插入顺序,避免测试暗中依赖输入顺序。
        self.connection.executemany(
            "INSERT INTO ledger VALUES (?, ?, ?, ?)",
            [(3, 10, 1001, -50), (2, 10, 1000, 200),
             (4, 20, 1000, 900), (1, 10, 1000, 100)],
        )

    def test_same_time_and_refund(self) -> None:
        self.assertEqual(
            read_balances(self.connection, 10),
            [(1, 100), (2, 300), (3, 250)],
        )

    def test_account_boundary(self) -> None:
        self.assertEqual(read_balances(self.connection, 20), [(4, 900)])

    def test_empty_account(self) -> None:
        self.assertEqual(read_balances(self.connection, 30), [])

    def test_zero_amount(self) -> None:
        self.connection.execute("INSERT INTO ledger VALUES (5, 10, 1002, 0)")
        self.assertEqual(read_balances(self.connection, 10)[-1], (5, 250))

    def test_null_amount_rejected(self) -> None:
        with self.assertRaises(sqlite3.IntegrityError):
            self.connection.execute(
                "INSERT INTO ledger VALUES (?, ?, ?, ?)", (5, 10, 1002, None)
            )
        self.assertEqual(len(read_balances(self.connection, 10)), 3)


if __name__ == "__main__":
    unittest.main()

验证时还应把函数里的窗口改回 PARTITION BY account_id ORDER BY occurred_at,保留外层排序。第一项测试应失败,实际值回到 [(1, 300), (2, 300), (3, 250)]。这说明测试捕捉的是中间余额错误,而非仅检查最终合计。

上线前容易遗漏的边界

  • 日期筛选会影响累计范围。WHERE occurred_at >= ? 在窗口计算前过滤历史流水;月度报表如果需要真实余额,必须补期初,或在子查询中计算完整累计后在外层截取展示范围。分页也需要先明确计算范围。
  • 金额字段不应允许 NULL,本例通过约束拒绝未知金额。整数分也有范围限制,应给单笔金额和最大累计金额设业务上限,避免整数求和溢出。
  • 回填旧流水或修改发生时间会改变后续累计值。这是动态查询的结果;审计账本若要求不可变余额,需要额外的记账规则和快照设计。
  • 可评估 (account_id, occurred_at, id) 索引,并用 EXPLAIN QUERY PLAN 观察实际计划。索引能否减少排序、是否值得增加写入开销,应在真实数据规模上测量,不以小样本耗时作为性能结论。

本次在 SQLite 3.50.4 上执行上述 5 项测试,全部通过;恢复原窗口定义后,同时间流水用例失败,其余 4 项通过,无执行错误。最小 SQL 现场也返回预期的错误累计值。Ruff 未安装,未执行格式和静态检查。本地验证覆盖 SQLite 内存查询的结果和约束,未验证生产 ORM、并发写入、磁盘负载、分页接口或其他数据库。迁移到其他数据库时应在目标版本重跑用例。

总结与参考

逐笔累计需要明确三件事:按什么账户分区、同时间流水如何排序、每行窗口读到哪里。对账检查既要核对最终合计,也要逐行核对业务主键及累计值。

参考 SQLite 窗口函数官方文档,重点查看框架定义、同值行和输出排序说明。