物化答案需要新鲜度与成本预算
物化答案把查询结果存下来,减少重复计算。这个决定同时涉及成本与语义:复用能省下多少工作,读者又能接受哪一刻的源数据?本文重点讨论这份预算。PostgreSQL 原生物化视图还有自己的生命周期和前置条件,不能因为存储结果读起来很快,就自动给它附加新鲜度承诺。快且过时的答案,是否仍然适合某个业务问题,需要提前决定。 比较真正被省下的工作 普通视图保存查询定义,读取物化结果则可能避免重新执行连接与聚合,不过保存后的关系仍然需要合适访问路径。刷新只是转移工作,不是消灭工作。评估重复复用的成本时,要包含计算、临时空间、索引维护、调度,以及失败刷新消耗的资源。若每个用户只读一次,维护一份额外结果可能没有收益;若很多读者反复问相同问题,才更可能值得。 回本次数应由刷新成本除以每次读取节省的成本得到,不能直接取实时读取与缓存读取的耗时比。假设刷新为十六点四一毫秒、直接读取为十五点二八六毫秒、保存后读取为零点零三九毫秒,那么两次读取就足以支付一次刷新。这里的数值只是算术输入,不是本轮测量结果。更换查询、缓存状态或刷新频率后,结论也会改变,不能把某个漂亮倍率当作通用收益。 汇总粒度同样需要选择。按天保存可能方便重算某些日期,但迟到数据、更正、删除和分组键变化,都必须找到全部受影响分区。PostgreSQL 标准刷新仍然重跑定义查询;人工维护的汇总表则需要自己实现正确的增量协议。给一张表取名“物化”,不会自动处理这些依赖,也不会让只修补一半数据的结果变得正确。 把答案与证据一起发布 刷新成功的完成时间,不一定是答案代表的源数据时刻。长查询可能读取较早快照,上游采集本身也可能落后。除了任务成功状态,还应记录报表真正关心的源覆盖范围或版本。刷新失败时,不能让成功标记前进,而公开的数据仍留在旧状态。否则监控会把旧答案误报为刚刚更新,读者也无法从界面分辨两者。 配置调度间隔,并不等于强制最大陈旧时间。启动延迟、锁等待、失败任务和长时间计算都会增加不确定性。数值误差也不能在没有限制每次变化幅度的情况下,简单用写入速率乘以时间来约束;一次大额更正就能显著改变总和。应该明确过时结果是否仍有用、是否必须显示年龄,以及何时应停止提供该答案。 坏掉的样子 PostgreSQL 不带并发选项的原生刷新取得 ACCESS EXCLUSIVE,可能阻塞读者。并发刷新允许普通 SELECT,但仍然取锁,并要求视图已经填充,以及存在使用普通列、覆盖全部行的合格唯一索引。EXCLUSIVE 也不是“只挡写入”的简单规则:它与多种锁模式冲突,包括带行锁的读取。同一视图同时仍只能执行一个刷新,不能靠重复启动任务增加刷新吞吐。 SQLite 事务或 WAL 模式的观察,不能证明 PostgreSQL 的上述锁语义。先建影子表再交换名字,也不是原生刷新的通用替代品;依赖、权限、对象身份和事务边界都要处理。每个引擎的结论应附着在对应证据上,并把代表性读者的并发测试,与保存值是否正确的检查分开。一次单会话成功不能被描述成读者在刷新期间始终无等待。 快速验证 把程序保存为 materialized_budget.py,用 Python 3 执行。标准库示例在 SQLite 内存库中用普通表维护汇总,明确不调用 PostgreSQL 原生物化视图命令。单个连接验证结果陈旧、事务发布和刷新失败,不提出并发读取或性能结论。程序最后的回本断言只检验给定成本的算术,也不会把机器实际运行时间混入这些输入。 import sqlite3 # SQLite has no native materialized-view command: this is an explicit cache table. db = sqlite3.connect(":memory:", isolation_level=None) db.executescript(""" CREATE TABLE sales (region TEXT NOT NULL, amount INTEGER NOT NULL); CREATE TABLE source_version (generation INTEGER NOT NULL); INSERT INTO source_version VALUES (1); INSERT INTO sales VALUES ('east',10),('east',20),('west',5); CREATE VIEW live_totals AS SELECT region,sum(amount) total FROM sales GROUP BY region; CREATE TABLE saved_totals (region TEXT PRIMARY KEY,total INTEGER NOT NULL); CREATE TABLE refresh_state (generation INTEGER NOT NULL); INSERT INTO refresh_state VALUES (0); """) def refresh(fail=False): db.execute("BEGIN") try: generation = db.execute("SELECT generation FROM source_version").fetchone()[0] db.execute("DELETE FROM saved_totals") if fail: raise RuntimeError("injected failure after deleting old rows") db.execute("INSERT INTO saved_totals SELECT * FROM live_totals") db.execute("UPDATE refresh_state SET generation=?", (generation,)) db.execute("COMMIT") except Exception: db.execute("ROLLBACK") raise def rows(relation): assert relation in {"saved_totals", "live_totals"} return db.execute("SELECT * FROM " + relation + " ORDER BY region").fetchall() refresh() assert rows("saved_totals") == [("east",30),("west",5)] db.executescript("""BEGIN; UPDATE sales SET amount=17 WHERE region='east' AND amount=10; UPDATE source_version SET generation=2; COMMIT;""") assert rows("live_totals") == [("east",37),("west",5)] assert rows("saved_totals") == [("east",30),("west",5)] try: refresh(fail=True) except RuntimeError: pass else: raise AssertionError("injected refresh should fail") assert rows("saved_totals") == [("east",30),("west",5)] assert db.execute("SELECT generation FROM refresh_state").fetchone()[0] == 1 refresh() assert rows("saved_totals") == rows("live_totals") assert db.execute("SELECT generation FROM refresh_state").fetchone()[0] == 2 # Illustrative costs, not timings measured by this program. refresh_ms, direct_ms, cached_ms = 16.41, 15.286, 0.039 assert refresh_ms + 2 * cached_ms < 2 * direct_ms assert refresh_ms + cached_ms > direct_ms print("old answer survived failed refresh; successful refresh published generation 2") print("illustrative break-even: 2 reads, not the direct/cached speed ratio") print("SQLite", sqlite3.sqlite_version) db.close() 初始东区总量为三十,西区为五。更新一条源记录后,实时东区总量变为三十七,保存答案仍为三十,合成的源版本也随更新前进。这个版本标记由实验显式控制,不是追踪任意上游变化的通用方案。实际系统必须定义何时推进源版本,以及它代表的范围,才能把数字用于判断覆盖是否满足要求。 ...