标题卡旁有三个方框,分别标注 Observe、Decide 和 Act。

物化答案需要新鲜度与成本预算

物化答案把查询结果存下来,减少重复计算。这个决定同时涉及成本与语义:复用能省下多少工作,读者又能接受哪一刻的源数据?本文重点讨论这份预算。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() 初始东区总量为三十,西区为五。更新一条源记录后,实时东区总量变为三十七,保存答案仍为三十,合成的源版本也随更新前进。这个版本标记由实验显式控制,不是追踪任意上游变化的通用方案。实际系统必须定义何时推进源版本,以及它代表的范围,才能把数字用于判断覆盖是否满足要求。 ...

十月 16, 2026 · 2 分钟 · XB Field Notes
文章标题旁排列着由箭头相连的 OBSERVE、DECIDE、ACT 方框,下方有圆形指针和引导线。

锁与租约驯服并发写入

锁与租约用于协调共享资源访问,但具体故障行为取决于实现。本文关注互斥写入,锁本身也可以有共享模式;租约则给有效权限增加期限。熟悉的名字或一个超时值,都不能单独证明延迟写入者在交接之后不会破坏数据。设计前应明确保护什么资源、由谁执行拒绝,以及失败之后如何结束所有权。 锁有明确的生命周期 持有者崩溃后,锁并不一定永远留下。PostgreSQL 会话级咨询锁会在会话结束时释放,事务级咨询锁会在事务结束时释放。远程连接已经坏掉,服务器却尚未发现,可能导致等待持续一段时间,但这与完全没有清理机制不同。等待获取锁的超时,也不同于已经取得的所有权自行到期,不能把两种时间限制混为一谈。 单进程内互斥锁可能很合适,但死锁和线程停顿仍然存在。跨进程操作也可能更适合操作系统锁或数据库锁,而不是应用租约。应根据资源、所有权范围与恢复语义选择,不能套用“只要分布式就必须有租约”的规则,也不能因为线程属于同一进程就假定持有者永远不会失去进展。 咨询锁只约束遵守协议的参与者。忽略锁的代码仍可能访问它本来有权限触碰的资源。数据库对数据操作强制执行的锁,与外部工作者约定的主身份,具有不同的执行点。需要明确究竟哪个组件阻止冲突操作,不能仅依赖每个调用方在出错、暂停和重启之后都继续自觉遵守规则。 租约允许稍后交接 协调者可以授予截止时间之前的权限、接受及时续约,并在过期后允许另一轮获取。这样能够在持有者消失后恢复,而不必等它主动释放。然而,过期不会停止已经暂停的进程、撤回已经发送的请求,也不会取消另一项服务正在执行的写入。权限失效与旧工作实际结束之间,仍然可能存在很长的间隔。 每次写入前检查所有权,仍留下检查与效果之间的窗口。围栏协议让资源端参与,拒绝过时的获取代次。如果独立资源只记住自己已经见过的最大令牌,那么旧令牌是在更新令牌到达该资源后才被识别为过时;这个机制本身不会在所有租约刚过期的瞬间就知道它已经无效。不能把收到新代次后的拒绝,夸大为实时租约校验。 续约和释放也需要携带每次获取独有的身份。拥有者名称可能复用:工作者 B 上一次获取留下的延迟释放,不能释放 B 后来取得的新租约。Redis 按值比较后删除的模式展示了这种获取身份检查,但随机所有权值并不自动具有大小顺序,也不能直接当成严格递增的围栏号。 续约存在不确定性 续约余量应根据调度延迟、网络延迟、暂停以及协议的时钟假设选择。每个 TTL 的三分之一续一次,可以是一项运维选择,却不是正确性定理。十秒租约也不保证服务一定在十秒内完全恢复;协调者不可用、重试、调度和资源恢复都会增加额外时间,应分别测量这些等待,而不是把 TTL 直接写成故障恢复上限。 续约超时意味着结果可能未知,不一定证明协调者拒绝续约。一旦无法建立安全权限,持有者应停止发起新的工作;但尝试取消,不证明已经在途的效果也停下了。重新获取必须取得新身份,资源端仍需执行相应的旧请求拒绝规则。否则客户端看起来已经恢复正常,却可能仍有旧工作迟到并覆盖结果。 坏掉的样子 旧任务可以在另一持有者开始后恢复执行,如果存储接受旧请求,它仍然可能覆盖新数据。客户端检查尤其怕发生在检查刚通过之后的暂停。实验应在真正执行效果的位置重现迟到写入,而不只是观察客户端计时器报过期,否则最重要的写入竞争仍未被覆盖。 时钟比较需要明确模型。墙钟跳变、走时速率差异与不同时间原点,都可能破坏截止时间计算。本地时钟偏快不必然延长租约;若双方比较同一个绝对截止值,偏快反而可能使它提前过期。单调经过时间可以避免墙钟跳变,却不自动消除所有协议时序假设。缩短 TTL 也不是普遍补救办法,还可能增加误判与反复交接。 续约循环静默失败、TTL 太短、遗漏释放和延迟重复释放影响不同状态转换,应分别测试。每轮获取都保存对应令牌,不要把可复用的拥有者字符串当作充分授权。应用取消与租约失效同样是两种操作,需要分别确认它们对已有工作的影响,而不能一条日志写着取消就宣布所有效果已经停止。 快速验证 保存为 lease.py,运行 python3 lease.py。顺序模型把协调者和资源合在一个对象里,使用手动推进、不可后退的时钟。在每次模型写入时,所有权、期限与代次和写入本身一起检查。这个契约故意强于只记录已收到令牌的独立资源,因此不能把模型的立即过期检查当成普通分布式围栏系统已经提供的功能。 class LeaseLostError(RuntimeError): pass class LeaseStore: """Serial, co-located coordinator and resource; one manual clock.""" def __init__(self): self.holder, self.expiry, self.fence, self.data = None, 0, 0, None self.clock = 0 def observe(self, now): if now < self.clock: raise ValueError("clock moved backwards") self.clock = now def acquire(self, owner, ttl, now): self.observe(now) if ttl <= 0: raise ValueError("TTL must be positive") if self.holder is None or now >= self.expiry: self.holder, self.expiry = owner, now + ttl self.fence += 1 return self.fence return None def valid(self, owner, token, now): return self.holder == owner and token == self.fence and now < self.expiry def renew(self, owner, token, ttl, now): self.observe(now) if ttl <= 0: raise ValueError("TTL must be positive") if not self.valid(owner, token, now): return False self.expiry = now + ttl return True def release(self, owner, token, now): self.observe(now) if not self.valid(owner, token, now): return False self.holder = None return True def write(self, owner, token, value, now): self.observe(now) if not self.valid(owner, token, now): raise LeaseLostError("expired, wrong owner, or stale acquisition") self.data = value def rejected(store, owner, token, now): before = store.data try: store.write(owner, token, "forbidden", now) except LeaseLostError: assert store.data == before else: raise AssertionError("invalid write accepted") store = LeaseStore() a = store.acquire("A", 5, 0) assert a == 1 store.write("A", a, "a-1", 2) assert store.renew("A", a, 5, 4) and store.expiry == 9 store.write("A", a, "a-2", 8) rejected(store, "A", a, 9) # Equality is expired. assert not store.renew("A", a, 5, 9) b = store.acquire("B", 5, 10) assert b == 2 store.write("B", b, "b-1", 11) rejected(store, "B", a, 12) rejected(store, "A", a, 12) assert store.acquire("C", 5, 12) is None assert not store.release("A", a, 12) assert store.release("B", b, 12) new_b = store.acquire("B", 5, 13) assert new_b == 3 assert not store.release("B", b, 13) assert not store.renew("B", b, 5, 13) store.write("B", new_b, "new-b", 14) assert store.release("B", new_b, 14) c = store.acquire("C", 5, 14) assert c == 4 store.write("C", c, "c-1", 15) assert store.data == "c-1" for operation in (lambda: store.acquire("D", 0, 15), lambda: store.renew("C", c, -1, 15), lambda: store.write("C", c, "backwards", 14)): try: operation() except ValueError: pass else: raise AssertionError("invalid TTL or backwards time accepted") print("PASS: renewal, expiry, fencing, same-owner reacquisition, and input guards") 预期出现 PASS 行。续约把截止时间从五推进到九,恰好等于九就已过期;错误拥有者与旧令牌都不能改动数据。B 随后用新令牌重新获取,携带旧令牌的迟到释放和续约均失败。零或负 TTL、后退时间也被拒绝。这些负例明确检查了交接和同名重新获取,而不是只验证正常释放路径。 ...

十月 4, 2026 · 2 分钟 · XB Field Notes
关于面向查询的索引的标题卡,配有箭头连接的通用 Observe、Decide、Act 面板。

索引服务的是查询,不是算法

索引的理由可以来自访问需求,也可以来自完整性规则。前者例如取某个客户最近五笔订单,后者例如禁止重复订单编号。即使查询统计中只有前者表现为频繁读取,两者也都是真实用途。索引设计应先记录这些用途并测量代价,而不是收集看起来有趣的数据结构,也不是看到扫描次数很少就删除。约束承担的正确性职责,不能用某条读取查询是否选中它来代替判断。 描述完整查询 只知道过滤条件,还不足以描述一个负载。还应记录排序、数量限制、输出列、参数分布和执行频率。订单编号索引已经提供编号顺序,但为了找到某个客户的五条记录,可能先检查大量其他客户的订单。以等值过滤的客户列开头,再接所需顺序的复合 B-tree,可以先缩小搜索范围,再沿该客户的近期条目读取。 这与分别建立两个单列索引并假设效果相同不同。位图组合可能帮助过滤,却不会为最终输出保留索引中的顺序。覆盖索引只有在可以省去堆可见性检查时,才真正避免相应的堆访问;否则即便计划名为 index-only scan,仍会访问堆。条目变宽也会占用更多空间并增加维护。只有真实查询和测得的堆访问确实证明必要时,才考虑加入覆盖列,不能把“多包含一点将来可能有用”当成充分理由。 坏掉的样子 查询用了索引,仍然可能做很多无效工作。沿主键倒序读取,再丢弃成千上万条其他客户记录,当然属于索引扫描,但过滤发生在条目选择之后。应看 Index Cond、过滤掉的行数、循环次数和缓冲区访问,而不是把出现索引名字当成成功标准。节点名称只能告诉我们采用了哪条路径,不能单独证明路径足够精确。 为所有想象中的查询都建立索引,会增加存储和维护。具体写入成本取决于索引列、谓词与更新行为,不能说每次更新都以完全相同方式改写所有索引。不过,读取收益仍然需要与目标负载中的插入、更新、清理工作和缓存压力比较。不能只展示一条查询更快,就忽略它给高频写入带来的实际代价。 删除同样需要证据。idx_scan 只描述某个统计窗口中的观察,不证明索引没有用途。主键或唯一索引即使很少承担读取,也可能持续保护数据规则。低频报表、运维查询和引用关系,还可能有短时间采样看不到的需求。因此考虑移除前,应先检查约束与依赖,而不是对一个计数设定机械的删除阈值。 快速验证 在 PostgreSQL 18 的 psql 中执行下面受控例子。它建立五万条临时订单,均匀分配给二百五十个客户。主键从头到尾保留,最后回滚移除夹具。 \set ON_ERROR_STOP on BEGIN; CREATE TEMP TABLE orders (id integer PRIMARY KEY,customer_id integer,total integer); INSERT INTO orders SELECT g,g%250,g*2 FROM generate_series(1,50000) AS g; ANALYZE orders; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT id,total FROM orders WHERE customer_id=7 ORDER BY id DESC LIMIT 5; CREATE INDEX orders_customer_recent ON orders(customer_id,id DESC); EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT id,total FROM orders WHERE customer_id=7 ORDER BY id DESC LIMIT 5; SELECT id,total FROM orders WHERE customer_id=7 ORDER BY id DESC LIMIT 5; DO $$ BEGIN IF (SELECT array_agg(id ORDER BY id DESC) FROM ( SELECT id FROM orders WHERE customer_id=7 ORDER BY id DESC LIMIT 5 ) s) IS DISTINCT FROM ARRAY[49757,49507,49257,49007,48757] THEN RAISE EXCEPTION 'changed result ordering or membership'; END IF; END $$; DO $$ BEGIN INSERT INTO orders VALUES (1,999,0); RAISE EXCEPTION 'duplicate primary key accepted'; EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'primary key still protects identity'; END $$; ROLLBACK; 此前 PostgreSQL 18.6 实测记录中,初始计划倒序扫描 orders_pkey。它最终返回所需五行,但先因为客户条件过滤掉一千二百三十九行。主键确实提供了顺序,却没有直接定位目标客户的范围。因此,一个结果正确的计划,仍然可能执行本来可以避免的过滤。这也说明“没有额外排序”不足以证明完整访问已经最合适。 ...

九月 10, 2026 · 1 分钟 · XB Field Notes
观察、决策、行动三步示意图,旁边是为所有副本的每个连接池编制预算的英文标题。

连接上限守护共享数据库

连接上限约束的是某个资源所有者的需求,不会自动约束共享数据库的所有应用。真正有用的预算,是每台后端可能接收到的物理连接总和,其中也包括连接池里暂时空闲的连接。先确认后端可供普通业务使用的名额,再分配到所有调用方与可能重叠的部署状态,而不是给每个服务随手填一个相同数字。 先数清有多少池,再决定每个池多大 对有限配置的 SQLAlchemy QueuePool,同时连接数上限是 pool_size + max_overflow,但这个数字只属于一个池。各自创建独立 QueuePool 的 Engine 才有独立上限;多个工作进程、多个副本,还会继续放大总数。应按真实池计数,派生的 Engine 视图可能仍然共用原池。如果配置相同,可用副本数乘每副本进程数,再乘每进程池数和单池上限;不同调用方的配置不同,则应逐项相加,不能假设它们共享同一份限额。 部署预算应使用最大重叠数量,而不是滚动更新结束后的就绪副本数。新副本已经建连时,旧副本可能仍在排空业务,也可能还保留空闲连接。后台任务、迁移工具、监控连接和其他服务也必须进入清单。不同数据库服务器需要各自计算预算;把主库、其他目标的连接数合成一个看起来宽裕的总量,会掩盖真正受限的后端。 PostgreSQL 的 max_connections 是服务器总上限,并不全部属于某个应用。没有预留名额权限的普通角色,应当为 superuser_reserved_connections 和 reserved_connections 都留出空间;后者提供给具有 pg_use_reserved_connections 权限的角色。还要扣除其他普通客户端和额外运维余量,不应让日常流量依赖紧急预留容量才能运行。这里区分的是权限与预算,不能仅凭连接名字叫“运维”就认定它可以使用某种预留名额。 坏掉的样子 服务完全遵守每个本地池的上限,仍然可能把数据库用满,因为实际池数量超过了预算表里的假设。若允许无限溢出,有限预算更是直接失去依据。反过来,上限过小也可能让应用在后端仍有余量时排队;看到这种等待,并不能直接断定必须增大数据库服务器的总上限。 更多连接不会创造额外 CPU、加速存储,也不会消除事务之间的锁冲突。PostgreSQL 的部分资源分配还直接取决于配置的连接上限,调高它也有资源代价。应同时观察获取连接的等待、实际物理连接数、活跃工作量和事务时长。连接数量受到约束,不代表吞吐一定提高,也不保证服务之间公平,更不能保证锁等待变短。 外部连接池代理会再增加一层计数边界。PgBouncer 的会话池化让服务器连接随客户端会话保留;事务池化则在事务结束后归还服务器连接。因此客户端连接数不能直接当成后端会话数。预算清单需要包含代理的服务器侧分配与池化模式。事务模式会改变部分会话特性的使用前提,引入它不是只修改一个容量数字,还需要核对应用是否依赖那些特性。 快速验证 在 macOS 或 Linux 的空目录执行 python3 -m venv .venv,然后执行 .venv/bin/python -m pip install SQLAlchemy==2.0.52。把代码保存为 pool_probe.py,运行 .venv/bin/python pool_probe.py。本次验证使用 Python 3.14.4 和 SQLite 3.53.0。这里的三个真实连接池位于同一个进程,共用临时本地数据库文件,并没有创建三个生产副本。 ...

九月 5, 2026 · 3 分钟 · XB Field Notes
观察、决策、行动三步示意图,旁边是限制查询并检查恢复的英文标题。

查询超时给坏查询的代价上限

查询超时限制的是数据库工作中的某个阶段,不会自动覆盖连接池排队,也不保证远端已经取消执行或事务清理已经结束。要保护共享容量,必须说清由哪个组件计时、它到点会中断什么,以及错误返回之后还剩下什么状态。只有一个名字叫 timeout 的配置,不能构成完整的成本边界。 说清这只钟从何时开始,管哪段工作 PostgreSQL 的 statement_timeout 从命令到达服务器开始计时,直到服务器完成处理。它不是调用方整个请求的期限:此前获取连接池借用的等待,以及服务器完成之后客户端本地处理结果的时间,不在同一计时范围内。锁等待也可能消耗语句时间,因此“还没有真正开始做有用的查询工作”,不代表语句预算尚未开始使用。 lock_timeout 的含义更窄,分别约束每次尝试获取锁的等待,而语句超时也可以中断不在等锁的执行工作。事务中空闲会话的超时又是另一条边界:它针对已经打开事务、却正在等待客户端下一条查询的会话,并不是当前仍在执行的语句。把这些数值设成一样,并不会让它们获得相同作用;应先理解它们观察的是哪一种状态。 PostgreSQL 18 还提供 transaction_timeout,在显式或隐式事务超过预算时终止会话,可以约束由许多短语句组成的长事务。预备事务不受这个超时限制。它仍然是服务端的一条边界,不能代替调用方完整期限;增加一个配置项,也不能省略连接恢复与事务结果检查。 驱动参数也需要同样认真地阅读。Python 的 sqlite3.connect(timeout=...) 设置的是遇到数据库锁时最多等多久,不是所有 SQL 的执行总期限。不同驱动里都叫“超时”的参数,可能分别针对建连、锁等待或读取;只复制名称和数值,可能让真正昂贵的查询仍然完全没有执行时间限制。 坏掉的样子 调用方停止等待时,数据库仍可能继续执行。即便 PostgreSQL 的取消请求已经成功发出,也不能保证它实际改变了什么,因为原命令可能早已处理完成。需要观察命令结果与事务结果,不能把“捕获到超时异常”或“发出了 cancel”直接当成所有远端工作均已停止的证明,更不能据此断定是否可以安全重放有副作用的操作。 语句时间限制也不会退还已经花掉的资源。清理、事务恢复仍可能耗时,先前的操作也可能已有各自结果。连接归还池中之前,应完成所需恢复并核对状态,而不是在异常被捕获后立刻当作干净连接继续借出。如果报表契约要求完整结果,半份结果就不能因为“总比没有好”而自动变成成功,除非产品本来就明确允许这种部分结果。 阈值应从工作负载和请求预算出发,同时给连接获取、响应传输与恢复留下空间。把 p99 简单乘固定倍数,不是普遍成立的安全规则:百分位描述的是已经观察到的时长,并不能定义哪些业务请求本来就合理。PostgreSQL 文档也提醒,不应不加区分地在全局配置语句超时,因为那会影响所有会话;交互查询与明确安排的长时分析任务可能需要不同策略。 快速验证 把代码保存为 query_probe.py,运行 python3 query_probe.py。示例只使用标准库中的 SQLite,本次环境是 Python 3.12.14 和 SQLite 3.53.1。两条连接共享脚本自己的临时数据库文件,不连接 PostgreSQL,也不使用生产凭据。短时长与回调频率用于快速展示边界,不是推荐直接上线的默认参数。 ...

九月 2, 2026 · 2 分钟 · XB Field Notes
标题卡写着“按不变量选择事务隔离”,配有标注 OBSERVE、DECIDE、ACT 的三个相连方框。

按业务不变量选择事务隔离

选择隔离级别,应从操作必须维持的不变量出发。一致快照很有用,但两个事务可能分别作出看似合理的决定,却在一起提交后得到错误结果。PostgreSQL 的可重复读保持事务读取快照稳定,仍允许部分序列化异常。可串行化则增加依赖检测,拒绝无法解释为任何串行执行顺序的并发组合,同时要求应用处理相应重试。关键是知道业务依赖哪项保证,而不是单纯选择一个听起来更强的名称。 稳定视图与正确决策分开判断 假设有两名值班医生,要求至少一名留岗。每名医生只有在看到可用人数大于一时才允许离开。两个可重复读事务可以都看到两人,然后分别把不同医生标记为离岗。它们没有覆盖彼此的同一行,因此都可能提交,最后值班人数变成零,即使每个事务都使用了内部稳定的快照。 这叫写偏差,不是对同一行发生丢失更新。分别检查每个医生布尔字段是否合法,并没有表达“总人数至少为一”的全局规则。只保护自己正在修改的那一行,也会漏掉两个决定之间的联系。适用的序列化协议需要覆盖整个不变量,而不只是其中某个局部字段。 具体数据库实现也很重要。PostgreSQL 把读未提交映射为读已提交;它的可重复读会阻止幻读,尽管 SQL 标准允许这一等级存在幻读。这些特性仍不会消除写偏差。因此,应根据引擎文档解释隔离名称,不能假定所有数据库同名级别的实现完全一样,更不能把某一种异常被排除当成所有并发问题都解决。 坏掉的样子 把可串行化理解为数据库实际每次只执行一个事务,会误导性能判断。PostgreSQL 仍并发运行事务并跟踪依赖,某个事务可能在执行途中或提交时收到 40001。应用需要用新的读取重做完整决定,不能只重新提交,也不能继续使用被拒绝快照中计算出来的值。否则虽然换了一次事务,决策前提仍然是旧的。 可串行化也不能修复一个独自运行时就违反业务规则的事务。所有相关写入路径都必须遵守既定契约;如果另外一条不受限制的更新允许最后一名医生离岗,不变量仍会被破坏。更高隔离提供并发保证,不会自动实现尚未编写的业务逻辑。实验中的条件判断与隔离级别必须一起成立。 快速验证 在同一个可丢弃的 PostgreSQL 18 数据库中,打开甲、乙两个新的 psql -X -v ON_ERROR_STOP=1 会话,使用相同角色,显式事务外保留正常自动提交,并选用尚未使用的模式名称。严格遵守下面顺序;先让两边读取重叠,才能建立要观察的竞争决策。 在甲中创建初始值班表。 CREATE SCHEMA isolation_demo; CREATE TABLE isolation_demo.doctors (id integer PRIMARY KEY,on_call boolean NOT NULL); INSERT INTO isolation_demo.doctors VALUES (1,true),(2,true); 先在甲中开始事务并读取,再在乙中执行对应操作。两边人数都应为二。 BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; 在甲中更新第一名医生,再在乙中更新第二名。每条更新也会在自己的快照中检查人数规则。 UPDATE isolation_demo.doctors SET on_call=false WHERE id=1 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; UPDATE isolation_demo.doctors SET on_call=false WHERE id=2 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; 先提交甲,再提交乙。两边结束后,在甲中查看已经失效的不变量并重置值班表。 COMMIT; \set ON_ERROR_STOP off COMMIT; \echo commit_state :SQLSTATE \set ON_ERROR_STOP on SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; UPDATE isolation_demo.doctors SET on_call=true; 使用可串行化重复这组重叠决策:开始甲、开始乙、更新甲、更新乙、提交甲,最后尝试提交乙。 BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; UPDATE isolation_demo.doctors SET on_call=false WHERE id=1 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; UPDATE isolation_demo.doctors SET on_call=false WHERE id=2 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; COMMIT; \set ON_ERROR_STOP off COMMIT; \echo commit_state :SQLSTATE \set ON_ERROR_STOP on 在甲中检查人数,在乙中以新事务重做完整决定,最后在甲中核对并清理。 SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; UPDATE isolation_demo.doctors SET on_call=false WHERE id=2 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; COMMIT; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; DROP SCHEMA isolation_demo CASCADE; PostgreSQL 18.6 实测中,可重复读的两个提交都成功,可用人数变成零。在受控的可串行化顺序下,甲提交成功,乙提交返回 40001,仍有一名医生留岗。代码只在需要观察的提交周围关闭遇错停止,让 psql 能打印 SQLSTATE;如果出现其他错误,不能把它当成预期的序列化拒绝。 ...

八月 23, 2026 · 2 分钟 · XB Field Notes
文章标题旁的三个相连面板,依次标注观察、决策与行动。

游标分页从明确边界继续读取

键集分页从客户端上一页实际收到的最后一个排序键继续。它不再从当前结果开头数过多少行,而是把候选行与一个具体边界比较。因此,信息流顶部新插入一条记录时,后面的页不会仅因位置整体后移就跟着错开一行。但这种方法不会冻结整个数据集,也不承诺后续每次请求都读取同一份记录。 坏掉的样子 单独使用时间戳,通常无法给出唯一边界。即使时间精度很细,多行也可能拥有相同创建时间。严格比较会跳过尚未返回的并列记录,包含等号的比较又可能重复已经展示的行。正确做法是增加一个唯一的决胜字段,并在排序与继续查询的谓词中始终使用完整键,不能一边按两个字段排序,另一边只保存其中一个。 排序字段可修改时,会产生另一类问题。已经展示过的行可能移到游标后面,于是再次出现;尚未展示的行可能移到游标前面,于是后续翻页一直看不到它。删除记录、改变过滤条件的匹配状态,也会改变下一次请求能够返回的内容。比较边界保持不变,并不能让持续变化的数据具有不可变快照的性质。 偏移分页也有明确用途:它表达当前查询结果中的编号位置,适合需要页码的界面。但在下一次偏移位置之前插入记录,可能造成重复;删除记录则可能造成遗漏。无论哪一种分页,只因为接口返回字段叫作“一页”,都不会自动获得跨请求的一致快照。需要先把产品希望保证的事情说清楚。 快速验证 把 SQL 保存为 cursor.sql,连接可丢弃的 PostgreSQL 数据库,运行 psql -X -v ON_ERROR_STOP=1 -f cursor.sql。确认实验模式 article_cursor 没被使用,并保持自动提交。遇错即停,避免建模式失败后继续操作已有对象,甚至执行最后的清理。记录中的运行版本为 PostgreSQL 18.6。实验特意让每个时间戳对应两行,先保存实际返回的第一页,再向顶部插入新行,最后修改两个已有排序键: CREATE SCHEMA article_cursor; SET search_path=article_cursor,public; SET TIME ZONE 'UTC'; CREATE TABLE feed (id bigint PRIMARY KEY,published_at timestamptz NOT NULL); INSERT INTO feed VALUES (1,'2026-01-01 10:00:00+00'),(2,'2026-01-01 10:00:00+00'), (3,'2026-01-01 11:00:00+00'),(4,'2026-01-01 11:00:00+00'), (5,'2026-01-01 12:00:00+00'),(6,'2026-01-01 12:00:00+00'); CREATE INDEX feed_order ON feed (published_at DESC,id DESC); CREATE TEMP TABLE first_page AS SELECT id,published_at FROM feed ORDER BY published_at DESC,id DESC LIMIT 3; SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM first_page; SELECT published_at AS cursor_time,id AS cursor_id FROM first_page ORDER BY published_at,id LIMIT 1 \gset SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM feed WHERE published_at<:'cursor_time'::timestamptz; PREPARE after_cursor(timestamptz,bigint) AS SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM ( SELECT id,published_at FROM feed WHERE (published_at,id)<($1,$2) ORDER BY published_at DESC,id DESC LIMIT 3 ) AS page; INSERT INTO feed VALUES (7,'2026-01-01 13:00:00+00'); SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM ( SELECT id,published_at FROM feed ORDER BY published_at DESC,id DESC LIMIT 3 OFFSET 3 ) AS page; EXECUTE after_cursor(:'cursor_time'::timestamptz,:cursor_id); UPDATE feed SET published_at='2026-01-01 09:00:00+00' WHERE id=6; UPDATE feed SET published_at='2026-01-01 14:00:00+00' WHERE id=3; EXECUTE after_cursor(:'cursor_time'::timestamptz,:cursor_id); DEALLOCATE after_cursor; DROP TABLE first_page; DROP SCHEMA article_cursor CASCADE; 第一个数组是 {6,5,4},因此游标来自编号四的时间戳与编号。代码先把真正返回的第一页保存到临时表,再从它取得边界,而不是另外查询一次正在变化的原表来猜测客户端看到了什么。在读写交错时,这个区别直接决定后续边界是否对应上一份响应。 ...

八月 19, 2026 · 2 分钟 · XB Field Notes
关于vacuum 与表空间复用的标题卡,配有 OBSERVE、DECIDE、ACT(观察、判断、行动)面板。

vacuum 让增长中的数据库不虚胖

在多版本存储里,删除只是记录变化、而不立刻释放空间。回收这些空位需要维护工作,一旦被跳过,代价就会悄悄累积。 删除不等于已经回收 UPDATE 或 DELETE 会留下旧行版本,直到相关快照都不再需要它们。长事务可以在其他会话持续修改数据时推迟这个时刻,但长事务本身不会凭空让一张没有写入的表膨胀。提交删除、旧版本不再可见、空间得到回收,是不同边界。不能只看删除语句成功,就认定这些页面已经可以重新分配。 普通 VACUUM 通常只是让表文件内部的空间可供新行复用,不会缩小文件。末尾完全空闲的页面有时能够截断;一般压缩则需要 VACUUM FULL 一类重写操作,付出额外空间和更强锁的代价。清理成功但文件大小不变,并不意味着失败。要把活数据、内部可复用空间以及真正归还操作系统的空间分别观察,才不会对正常状态反复执行昂贵重写。 清理与统计是不同任务 autovacuum 由启动进程和多个工作进程组成,并非一名常驻 worker 按固定时刻清完所有表。它根据活动估计和年龄相关规则决定是否执行 VACUUM 或 ANALYZE。ANALYZE 独立收集规划器分布统计,普通 VACUUM 不能替代它。插入驱动的工作与防回卷工作也说明,死元组数量并不是维护的唯一触发条件,不能因为暂时没有大量删除就忽略事务年龄。 基于代价的延迟,会在估计 I/O 工作累计到一定程度后暂停一段时间。代价上限不是“一次调用最多删掉多少行,达到后就退出”。阈值影响资格,worker 是否有空影响调度,存储能力和限流则影响推进速度。排查需要先判断卡在哪个阶段,不能把各种迟缓都归结为某个比例系数太大,也不能把模拟程序里的清理额度当成真实数据库参数。 坏掉的样子 第一类问题是产生旧版本的速度超过回收速度。连续几个观察窗口中,死元组估计与关系体积不断上涨。应结合已经完成的运行、取消情况、worker 可用性、I/O 与写入速率检查。资源确实成为瓶颈时,额外硬件可能有帮助;但重启数据库本身不会让仍需保留的版本变得可回收。在相同 I/O 上限下,降低阈值或增加 worker 数量,也不保证最终吞吐更高。 第二类问题是清理在跑,却因为旧快照或保留的 xmin 而无法回收特定版本。干预之前要检查事务及复制状态。事务开始得很早只是线索,不能证明它持有这次受影响的可见性边界。结束应用事务或删除复制槽可能中断工作,甚至让消费者需要重建,因此应先找到负责人、确认保留目的与恢复要求,而不是为了让监控数字下降就直接清除对象。 第三类问题是仅凭一个 n_dead_tup 值或一条慢计划断定膨胀。统计数量是估计,页面布局、可见性、选择率和统计过时都可能改变读取成本。应比较趋势、vacuum 报告和真实执行计划。文件较大但内部空间可复用,可能是健康稳态;死元组较少,也不排除版本保留或冻结问题。不能让单一计数替代完整因果判断。 ...

八月 16, 2026 · 2 分钟 · XB Field Notes