SQLite 累计窗口:时间相同的两条记录,ROWS 和 RANGE 为什么算得不同

10-01 4阅读

同一时刻的记录要不要一起算进去

库存流水里,同一时刻可能有两笔入库。一张逐笔明细希望第一笔之后就显示余额,另一张看板却希望同一时刻的所有记录都显示该时刻结束后的余额。两种累计都合理,但计算口径不同。只写“按时间求累计和”,不足以决定应该采用哪一种窗口。

本文使用四条整数数量变化:时间十先增加三,再增加七;时间二十增加五,再减少二。逐笔累计应是三、十、十五、十三;按时间整组累计则是十、十、十三、十三。先手算这两列,再读 SQL,重复时间带来的差异就能直接被看见。

在同一次查询里核对四种结果

下面通过 Python 标准库连接内存数据库,不会读取或修改现有文件。运行时 SQLite 需支持窗口函数,即三点二十五及以上版本。编号设为唯一主键,时间和变化量都不允许为空;插入顺序故意打乱,避免程序碰巧依赖数据进入表的先后。

SQLite 累计窗口:时间相同的两条记录,ROWS 和 RANGE 为什么算得不同

AI生成概念示意图,非真实界面

import sqlite3

assert sqlite3.sqlite_version_info >= (3, 25, 0)
data = [(4, 20, -2), (2, 10, 7), (1, 10, 3), (3, 20, 5)]
query = """
SELECT id, tick, delta,
       SUM(delta) OVER (
         ORDER BY tick, id
         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS by_row,
       SUM(delta) OVER (
         ORDER BY tick
         RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS by_time,
       SUM(delta) OVER (ORDER BY tick) AS by_default,
       SUM(delta) OVER (
         ORDER BY tick, id
         RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS by_unique_key
FROM movement
ORDER BY tick, id
"""
expected = [(1, 10, 3, 3, 10, 10, 3),
            (2, 10, 7, 10, 10, 10, 10),
            (3, 20, 5, 15, 13, 13, 15),
            (4, 20, -2, 13, 13, 13, 13)]
db = sqlite3.connect(':memory:')
try:
    db.execute('CREATE TABLE movement('
               'id INTEGER PRIMARY KEY, tick INTEGER NOT NULL, '
               'delta INTEGER NOT NULL)')
    db.executemany('INSERT INTO movement VALUES (?, ?, ?)', data)
    actual = db.execute(query).fetchall()
    assert actual == expected
    print('id tick delta rows range default range_unique')
    for row in actual:
        print(*row)
    db.execute('DELETE FROM movement')
    db.executemany('INSERT INTO movement VALUES (?, ?, ?)', reversed(data))
    assert db.execute(query).fetchall() == expected
    print('reversed insertion: same results')
finally:
    db.close()

输出中 rows 是三、十、十五、十三,range 和 default 都是十、十、十三、十三。最后一列 range_unique 又变成逐笔结果。程序还反转插入顺序并重算,断言两次查询完全一致;这检查的是当前查询已经写明的排序规则,不是数据库承诺保留插入顺序。

窗口边界与排序键共同决定累计范围

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 按窗口中的行位置取范围。要让每一行的累计余额稳定,窗口排序写成时间加唯一编号。只有时间时,两条同时间记录谁先参与累计没有被明确约定;在最终输出那里补编号排序,也不能倒过来修正窗口内部的计算顺序。

本文的 RANGE 窗口从分区开头延伸到当前排序值,并把与当前行排序键相同的记录一起纳入。窗口只按时间排序,所以两个时间十的结果都包含三与七。这里讨论的是这一种累计边界;不要据此把所有带前后偏移的 RANGE 窗口都理解为同一规则。

省略框架时,SQLite 默认采用从开头到当前行的 RANGE,并包含当前行的同键记录。因此只写 SUM(delta) OVER (ORDER BY tick),在本例中与明确写出的 range 列相同。看到重复余额时,先检查默认框架,而不是立即去重或认定查询重复执行。

给 RANGE 的排序加入唯一编号,会让每行的整组排序值都不同,同时间记录便不再互为同键记录。range_unique 的结果因此与本例的 ROWS 相同。编号不是只让显示更整齐的附加项:放在窗口内部时,它可能直接改变业务分组含义。

把累计口径放进验收样本

窗口里的排序控制计算,查询最外层的 ORDER BY 控制输出展示。示例两层都明确书写,方便比较各列。如果实际报表按仓库分别累计,还需要在窗口里加入仓库分区,并在最终展示中安排仓库顺序;漏掉分区可能把不同仓库的流水累计到一起。

减少二这一笔让累计从十五降到十三,也提醒我们累计和并不保证递增。筛选时间范围时同样要先确定口径:只累计筛选后的区间,还是带上区间开始前的余额。普通 WHERE 会先改变参与窗口计算的记录,不能把它只当成显示过滤器。

保留同时间两笔、负向变化和乱序插入这三类样本。业务若要求按真实先后逐笔累计,编号也必须代表已约定的顺序,不能因为唯一就假定它等于发生时间。先明确同一时刻内部如何排序,再选择框架,报表结果才有可解释的含义。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。