标题卡旁有三个方框,分别标注 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 方框,下方有圆形指针和引导线。

懒加载延后暂时不需要的内容

懒加载把暂时用不到的工作推迟到更合适的时机。对于 HTML 图片,loading="lazy" 允许浏览器按照自己的策略,等图片接近视口时再发起请求。长文章因此可能减少初始传输量,但这个属性只是调度输入,并不保证每一张尚未进入屏幕的图片都绝对不会下载。能否节省请求,需要观察实际浏览器行为。 让浏览器调度普通图片 原生方式直接使用真实的 src 或 srcset,不必把地址隐藏在自定义属性里,也不必仅为基础懒加载编写滚动处理器。加载距离由浏览器决定,还可能随实现及连接条件调整。作者不能通过 loading 属性直接配置原生距离阈值。使用 IntersectionObserver 的应用方案有另一套控制方式,同时也增加了程序需要承担的调度和兼容责任。 首屏必需的图片应保持及时加载,尤其是主要展示图。如果对决定最大可见内容的图片使用延迟加载,真正有用的画面可能更晚出现。反过来,为刻意延迟的图片添加 preload,也可能提前触发下载,抵消预期节省。应根据图片角色和实测结果选择,而不是给页面所有资源统一贴上同一种策略。 明确填写图片原始 width 和 height,可以让浏览器在下载之前预留宽高比例。响应式样式仍然能够缩小实际宽度,并自动保持高度比例。缺少有效尺寸时,图片最初可能几乎不占空间,既影响哪些内容被认为接近视口,也可能在图片到达后把正文突然推开。图片无法显示时,准确的替代文字仍然是阅读体验的一部分,不能只顾请求数量。 坏掉的样子 只等页面 load 事件结束,可能漏掉尚未完成的懒加载图片。浏览器可以在远处图片未下载完时结束页面加载事件,因此应分别检查图片完成状态、自然尺寸、服务端请求及滚动后的真实显示。请求已经失败与请求尚未开始是不同状态;两者最初都可能没有解码后的像素,不能只凭一项尺寸判断根因。 没有编写页面脚本,不等于在浏览器中禁用了脚本。MDN 说明,作为防跟踪措施,图片的这种延迟行为需要浏览器启用脚本。普通静态 HTML 无须额外编写 JavaScript 就可以使用,但把浏览器脚本整体关闭之后,不能继续假定请求时机完全相同。应按站点实际支持的环境验证,不宣称所有配置都能获得一样的节省。 loading 属性只作用于支持它的元素,不能直接控制任意 CSS 背景图。content-visibility 主要用于跳过部分渲染工作,也不承诺相关网络请求全部延后。快速滚动与慢连接组合时,原生调度仍可能来不及让图片及时到达。评估中需要查看空白占位持续多久以及布局是否稳定,不能把初始请求减少当成用户体验必然改善。 快速验证 本轮复核没有重新运行 HTTP 服务或浏览器滚动检查;上述 Chrome 结果属于此前记录的运行。 将下面代码保存为 lazy-images.py,运行 python3 lazy-images.py。Python 3 命令验证本地页面和两张图片都能返回成功响应,但输出中的 native_loading_tested_by_cli 明确为假。普通 HTTP 客户端读取 HTML,并不会执行浏览器图片调度器,因此该命令本身不能证明已经发生原生懒加载。 ...

十月 14, 2026 · 2 分钟 · XB Field Notes
资源预加载主题封面:浅色网格上放置标题,旁侧是相连的观察、判断与行动三个框。

预加载让关键资源提前被发现

预加载在资源的普通消费者发现它之前,先告诉浏览器这个资源可能很快需要使用。由 JavaScript 创建的图片,或必须等样式表加载并解析后才发现的字体,都可能因此更早开始获取。它提供的是提前下载的机会,不能推导成所有提示资源都获得全局最高优先级,更不能保证添加之后每个页面都一定变快。 发现、获取与使用是三个阶段 现代浏览器通常能够提前扫描 HTML 中的资源,因此图片写在后面,不代表必须等主解析器逐行走到那里才开始发现;图片位于首屏之外,也不直接证明发现时间很晚。添加提示之前,应查看真实请求发起者和时间。如果资源原本就及时开始获取,额外提示可能收益很小,却与更重要的工作竞争带宽。 rel=preload 且 as=image 的 link 被处理时,会按图片目的发起获取,但不会自动插入 img 元素。同样,预加载样式表不等于应用样式,预加载脚本也不会自动执行脚本。实际消费者仍需要通过正常机制请求或使用该资源。下载提前了,却没有后续使用,只会增加开销,不能算作页面已经完成渲染。 复用依赖请求信息兼容。地址、as 表达的资源用途,以及相关的 CORS 和凭据模式,需要与最终消费者一致。字体预加载通常即使位于同源,也要配置 crossorigin,使模式匹配实际字体请求。media 适合控制条件加载,却不是每一个字体提示都必填的属性。响应式图片还需要匹配真实选择结果,不能仅凭猜测预加载最大的一张,然后假定浏览器肯定使用它。 完整性元数据又增加一条复用条件。HTML 的消费算法会比较消费者的完整性元数据与预加载条目;不兼容的元数据可能阻止消费,即使两个哈希单独看都能描述该资源。更新资产时应同步审阅完整性配置。本图片实验没有覆盖这些检查,因此不能据此断言带完整性校验的脚本预加载必定具有相同复用结果;地址匹配不是全部条件。 坏掉的样子 设计调整后,旧提示很容易留下来:页面已经移除主图,head 中的 preload 却继续下载。另一个常见问题是消费者使用不同查询参数或请求模式,导致原本期待的复用没有发生。检查时既要数请求,也要核对最终真正使用的地址。图片能够显示,只说明某次加载成功,不能证明提前下载的那份资源被复用了。 预加载过多资源,还会竞争带宽、连接和处理能力。提前发现不是暂停浏览器其他优先级规则的命令。对可选交互资源的提示,也可能让从不点击该控件的用户承担流量成本。应明确这笔取舍,并在真实受众的测试中移除成本高于收益的提示,而不是把所有资产都提前下载当作默认优化。 很快的本地实验不能建立最大内容绘制或真实响应速度已经改善的结论。性能比较需要页面、缓存状态、设备和网络条件具备可比性。资源时间记录能够说明请求何时开始,但不能单独解释整个用户体验变化,更不能把两次非受控访问的耗时差全归因于一个属性。 快速验证 把下面代码保存为 preload-image.py,使用 Python 3 执行 python3 preload-image.py。命令通过 HTTP 检查三个页面变体和一张图片的可达性,并明确输出 browser_preload_tested_by_cli 为假。服务端检查 HTML 字符串,并没有运行浏览器的资源调度过程,不能据此宣称预加载已经发生。 ...

十月 6, 2026 · 2 分钟 · XB Field Notes
观察、决策与行动三块面板,旁边的标题说明重定向链增加请求。

重定向链增加后续请求

客户端跟随重定向时,需要先读取 Location,解析目标地址,再发出后续请求,才能取得需要的表示。几个单独看来合理的规则经常组合成长链:规范主机名、切换到 HTTPS、补齐末尾斜线、迁移旧文章。检查时应从用户实际访问的旧地址出发,沿完整路径观察,因为每一个中间环节都会增加依赖。 请求次数不等于连接次数 实际从网络读取的新重定向通常增加一次顺序发生的请求与响应交换。客户端要等前一个响应给出目标,才能继续访问,所以它会推迟最终内容的获取。但“再发请求”不等于“必须重新建立 TCP 连接”。同源 HTTP/1.1 可以复用持久连接,HTTP/2 和 HTTP/3 则有各自的连接与流机制。切换主机可能额外触发域名解析或建连,是否真的发生还取决于已有连接和缓存。 浏览器内部升级和缓存重定向也会改变网络轨迹。HSTS 可能直接把 HTTP 地址升级为 HTTPS,不必先请求明文入口;永久重定向也可能已经存储在缓存。因此,不能为每一跳规定固定耗时,更不能直接断言三个重定向就让整页加载慢三倍。图片、脚本及渲染都可能占据更多时间,必须结合真实瀑布图判断关键路径。 状态码首先表达语义。301 和 302 的历史处理允许把 POST 改为 GET,307 与 308 要求保留方法,303 则指向另一个资源的检索。永久性与可缓存性同样需要考虑。为了把请求列表变短而统一改成永久跳转,可能让客户端长期记住一次错误配置,即使服务器随后修复也不能立即消除影响。 Location 可以使用相对引用,必须结合当前请求地址解析后,再判断目标源和路径是否符合预期。只记录原始响应头可能掩盖问题:相同的相对值从不同目录返回,最终会指向不同位置。故障记录应同时保留原始值和解析结果。 坏掉的样子 两个规则可能互相反向,例如一个规则添加斜线,另一个又删除斜线。分别检查两个端点都能收到响应,并不能证明访问最终会结束。客户端通常有跳转次数上限,但触及上限依然意味着用户任务失败。修改共享规则后,需要记录访问过的目标,并覆盖具有代表性的旧地址、带参数地址及不同入口。 中间地址失效时,最终页面即使健康也无法保证整条路径可达。可以把内部链接更新为最终规范地址,并在语义允许时让历史地址直接跳过去。修改过程中仍要保留有业务意义的查询参数和编码,不能因为少一跳就丢失筛选条件。也不要接受完全不受约束的用户目标地址,或把身份凭据无条件转发到另一个源。 抓取程序同样需要处理额外请求,但重定向链并不能换算成固定的搜索排名损失。搜索引擎有独立的处理规则和上限,本地实验没有测量这些行为。可报告的是跳转数量、是否循环、最终状态及目标是否一致;这些证据比对排名作没有依据的保证更适合指导修复。 快速验证 将下面程序保存为 redirect-chain.py,运行 python3 redirect-chain.py。程序使用 Python 3 创建 HTTP/1.1 回环服务器,并始终使用同一个 HTTPConnection。每个响应都有明确长度,客户端完整读完后可以继续复用连接。响应设置 no-store,使实验观察实际网络交换,而不是依赖缓存里的跳转记录。 ...

十月 4, 2026 · 2 分钟 · XB Field Notes
浅色网格上显示文章标题,旁侧是依次标注 Observe、Decide、Act 的三个相连方框。

直方图保留延迟分布的形状

平均值可能让一个令人不舒服的服务看起来很快。如果九十五次请求各耗时五十毫秒,另外五次耗时两秒,平均延迟也只有一百四十七点五毫秒。直方图能够在不保存每条原始请求的情况下,保留更多关于这部分慢请求的信息。但它保存的是分桶后的分布近似,不是全部原始观测,也不会天然提供精确的分位数。理解这个区别,才能避免把计算输出当成重新测得的真实延迟。 先看桶里究竟统计了什么 在 Prometheus 经典直方图中,桶的边界标签表示小于或等于该上界的全部观测。计数具有累积性:一条五十毫秒的请求,既进入一百毫秒桶,也进入所有更大的桶。正无穷桶覆盖全部观测,计数和总和组件则分别记录样本数量与累计耗时。因此,直接把所有累积桶相加会重复统计请求,不能把结果当成真实访问量。 相邻累积桶相减,可以得到互不重叠区间内的观测数量,但仍然不知道每条请求在该区间中的具体位置。边界兼容的直方图可以先把对应桶的计数相加,再估计总体分位数。直接平均多个实例已经算出的分位数,无法恢复合并后的分布,即使各实例自己的分位数都计算得很精确,也不存在这种简单的总体换算关系。 分辨率决定答案能有多精细 对于常见的正数经典桶,分位数函数先定位目标排名所在的桶,再假定该区间内的观测均匀分布,执行线性插值。真实延迟却可能集中在桶的某一侧,所以返回值是与桶宽相关的估计,并不是被重新找回的实际观测。应该围绕业务真正关心的运行区间设置边界,而不是把大部分精度浪费在与目标无关的范围内。 如果桶边界正好位于三百毫秒目标上,就可以直接统计达到该延迟要求的请求数。这通常比观察一个插值得出的高分位数是否恰好低于阈值,更贴近事件型目标的定义。不过,经典直方图每增加一条边界,都会为每组标签组合增加桶序列。现代 Prometheus 也支持原生直方图,当前文档建议在埋点及相关系统支持时优先考虑;标准指数型原生桶采用不同的插值方式,下面的实验特意只讨论经典桶。 坏掉的样子 上层桶过宽时,少量观测移动就可能让尾部估计出现看起来很大的跳跃。经典分位数计算还要求存在正无穷桶,遗漏它可能使结果无效。如果目标排名落在最后的无限桶中,Prometheus 会返回前一个有限桶的上界,而不是编造一个无限延迟。上述输出都不能告诉你最慢那条请求的准确耗时,追查单次异常仍需要更细的观测材料。 空样本总体没有可解释的观测分位数。跨实例桶边界不匹配、同一指标混入不同单位,或者累积计数出现不合法的下降,也会破坏结果含义。按时间查询累计计数器时,应先计算合适的增量或速率,再聚合实例,使重置得到正确处理。另外,全请求分布也要说明结果范围:快速失败可能降低表面延迟,却同时让服务变得更不可用,不能仅凭曲线变快就宣布改善。 快速验证 保存为 histogram-shape.py,使用 Python 3 执行。程序先从人工构造的原始样本计算精确的最近秩高分位数,再分别根据两套经典桶估计相同分位点。精简插值函数只适用于这里的正数、非空样本,并断言选中的桶有有限边界;它用于展示桶内假设,不替代 Prometheus 对所有特殊输入和边界情况的完整实现。 from math import ceil, inf from statistics import mean values = [0.05] * 95 + [2.0] * 5 coarse = [0.1, 0.3, 1.0, 5.0, inf] fine = [0.1, 0.3, 1.0, 2.0, 5.0, inf] def cumulative(bounds): return [sum(value <= bound for value in values) for bound in bounds] def estimate_p99(bounds): counts = cumulative(bounds) rank = 0.99 * counts[-1] lower, previous = 0.0, 0 for upper, count in zip(bounds, counts): if count >= rank: assert upper != inf and count > previous return lower + (upper - lower) * (rank - previous) / (count - previous) lower, previous = upper, count raise AssertionError('rank not covered') exact = sorted(values)[ceil(0.99 * len(values)) - 1] counts = cumulative(coarse) increments = [counts[0]] + [right - left for left, right in zip(counts, counts[1:])] assert counts == [95, 95, 95, 100, 100] assert increments == [95, 0, 0, 5, 0] and sum(increments) == 100 assert exact == 2.0 and abs(estimate_p99(coarse) - 4.2) < 1e-12 assert abs(estimate_p99(fine) - 1.8) < 1e-12 print(f"mean={mean(values):.4f}s; raw nearest-rank p99={exact:.1f}s") print(f"coarse cumulative counts={counts}; sum_of_buckets={sum(counts)}") print(f"classic linear p99: coarse={estimate_p99(coarse):.1f}s; fine={estimate_p99(fine):.1f}s") print(f"at or below 0.3s={counts[1]}/{counts[-1]}") 原始样本的百分之九十九分位数为两秒,粗桶却给出四点二秒,因为有关观测只被定位在一秒到五秒之间。增加两秒边界后,估计变成一点八秒。在这组数据中,较窄的桶确实减少了误差,但仍然没有等于真实样本分位数,说明提高分辨率不会凭空恢复原始数值。原记录还将两项估计与 Prometheus 3.5.1 的实际分位数函数做了核对,在万亿分之一的数值容差内一致。排障时应先查看所在区间和边界,再解释分位数的小幅变化。 ...

九月 20, 2026 · 2 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调多路复用不改变 TCP 的有序交付。

HTTP/2 消除 HTTP 响应顺序阻塞

这里的“消除队头阻塞”,指解除同一条连接上的 HTTP 响应顺序约束,而不是消除 TCP 队头阻塞。较晚发起的响应可以在较早的处理函数仍然等待时完成,但共享的传输容量、连接级流控和应用瓶颈,依然可能把不同流的进度关联起来。先说明这一层边界,才能区分应用协议的顺序约束与底层传输等待。 分清两种排队 HTTP/1.1 本来就支持持久连接,并非一个请求必须新建一条连接。多个请求可以顺序复用套接字;流水线还允许同时存在多个未完成请求,服务器也可以并行处理安全方法,但同一条连接上的响应必须按照请求顺序发送。因此,第一份响应过慢,会延迟后续响应的交付。客户端经常通过多开连接缓解这个限制。 HTTP/2 给帧关联流标识,在同一条连接上交错传递消息。服务器可以发送已经准备好的响应,不必先完成另一份无关响应。这是 HTTP 层的独立推进能力,不代表每个处理函数获得独占处理器、数据库、带宽或无限调度机会,也不意味着应用的共享资源自动变成互不影响。 TCP 仍然提供一个有序字节流。某段字节缺失时,后面已经收到的字节可能必须等待补齐缺口,才能交付应用,其中也可能包含其他 HTTP/2 流的帧。此前已经交付的字节并不会被收回。QUIC 则在每条流内保持有序,不要求不同流之间统一排序;不能把它解释成普通流数据可以任意乱序交付,也不能据此忽略共享的拥塞控制和流控预算。 并发仍然受到预算约束 对端通过 SETTINGS_MAX_CONCURRENT_STREAMS 限制活动流数量。DATA 帧还同时消耗流级和连接级的流控额度。每条流拥有单独窗口,不等于无限独立:连接额度耗尽时,其他流的 DATA 也可能停住。接收方如何消费正文、何时更新窗口,以及共享工作池、锁和上游连接是否紧张,都会改变实际推进情况。 应检查所测那一跳真正协商出的协议。浏览器到代理使用 HTTP/2,不能证明代理到源站也是如此;TLS 握手成功也不足以证明使用了 HTTP/2。Go 配置自定义 TLS 参数后,可能需要 ForceAttemptHTTP2 才会尝试 HTTP/2,但最终证据仍是响应协议和协商出的 h2,而不是配置字段已经存在。 坏掉的样子 快请求先完成,可能只是客户端另开了一条 TCP 连接;单连接 HTTP/1.1 客户端则可能在请求抵达服务器之前,就已经让它等待。应同时核对服务端接受的连接数量和客户端获取连接的事件,再判断排队发生在哪里。仅凭两条访问日志的结束时间,无法区分这些情况。 增加流并发量,不能修复过载数据库,也不能修复未读数据造成的共享窗口紧张。应记录正文完成情况、活动流、共享资源等待以及逐跳协议。不要把每次慢请求都称为 TCP 队头阻塞,更不要把升级协议直接写成已经实测的页面提速;性能结论需要独立的负载与网络测量支持。 ...

九月 15, 2026 · 3 分钟 · 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
观察、决策、行动三步示意图,旁边是限制查询并检查恢复的英文标题。

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

查询超时限制的是数据库工作中的某个阶段,不会自动覆盖连接池排队,也不保证远端已经取消执行或事务清理已经结束。要保护共享容量,必须说清由哪个组件计时、它到点会中断什么,以及错误返回之后还剩下什么状态。只有一个名字叫 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