让一次锁等待有时间上限的可配参数

事务拿锁时别的请求会礼貌等待

等待锁可能是正常协调,也可能让连接和请求持续排队。PostgreSQL 的 lock_timeout 可以让调用方停止等待,但它不会解释持锁者为什么慢,更不会自动决定业务是否可以重试。应从操作能够接受的延迟出发设定预算,同时找到实际阻塞者;单纯延长超时或增加尝试次数,可能只是扩大同一条等待队列。 坏掉的样子 锁超时分别作用于每一次锁获取尝试,包括语句隐式申请的锁。它测量的是等待当前锁的时间,而不是整条语句的总运行时间。查询已经执行了一段时间,随后又申请一把锁,仍然可能触发这个限制。反过来,多次较短的等待也可能累计超过一个配置间隔。把锁超时理解成“只有语句刚开始时检查一次”,或把它当成整次查询的总截止时间,都会漏掉真实的等待范围。 statement_timeout 限制整条语句执行,其中也包括等锁。如果它更短,可能先于锁超时取消语句。空闲事务会话超时处理的则是另一种状态:事务还开着,但服务器正在等待客户端发下一条命令。卡在更新内部等待锁的后端通常仍是活动状态,等待事件属于锁;不能看到连接排队,就直接把所有等待者叫作空闲事务。真正闲着不提交的持锁者和正在等待的受阻者,需要分别定位。 快速验证 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。实验使用默认的零值锁超时,且没有其他会提前干预的超时设置。下面的毫秒参数只用于快速复现,不是生产建议值。甲先建立独立模式和计数表: CREATE SCHEMA timeout_demo; CREATE TABLE timeout_demo.counter (id integer PRIMARY KEY,n integer); INSERT INTO timeout_demo.counter VALUES (1,0); 甲在显式事务中把数字改成十,暂时不提交,也不回滚,以便继续持有目标行的锁: BEGIN; UPDATE timeout_demo.counter SET n=10 WHERE id=1; 乙开启事务,对同一行加一,并给取锁等待一百毫秒预算。为展示预期错误及后续事务状态,只在这一小段临时关闭遇错停止: ...

二月 16, 2028 · 1 分钟 · XB Field Notes
一个无视你从不查询行的索引

部分索引裁剪整列

并不是每一行都配得上一个索引槽位;部分索引让树尽量薄,正对查询从不关心的那些数据。多数表都有表结构假装看不见的偏斜:九成订单已关闭,几乎所有事件都已处理,标志列几乎处处为假。全量索引老老实实全记下来,每次写入都交维护费,保护的却是只碰一小片热数据的查询。部分索引不再为没人查的行付钱。 这笔交易拿精度换体积。索引更小、扫描更快、维护更便宜——但只有谓词与索引条件对得上的查询才能用它,其余统统回退到别的计划。所以部分索引是访问模式与存储的刻意配对,不是通用加速。只有热子集稳定、各查询对它的定义一致时才值得建。 只索引你要查的数据 部分索引带一个 WHERE 子句,不满足谓词的行根本不会进树。当大多数行很少被读时,树的体积因此更小,真正关心的那些行查找也更快。只给未关闭订单建的索引,可能比全量小一个数量级:更多内容装进缓存,每次查找碰更少的页,冷历史再怎么涨,计划依然走索引。 谓词从查询里挑,别从列统计里挑。正确的条件是每条重要查询本来就在重复的那一句:状态等于未关闭、删除时间为空。如果各查询切表的方式不同,一个部分索引伺候不了所有人——要么主导的那片胜出,要么建两个部分索引,每个都比全量瘦。定谓词之前,先让查询日志投票。 行更少、维护更省 条目越少,INSERT、UPDATE 还有 VACUUM 要碰的结构就越轻,磁盘占用也能肉眼可见地降下来。对写入频繁又大多是噪音的表,收益最明显。不满足谓词的插入整行跳过索引——没有页分裂风险,也不为它写日志;不改变谓词结果的更新同样跳过索引维护。对每秒吞噬几千冷写的表,省下的维护远超查询加速本身。 索引变小还会让清理更快:需要清理的页少了,膨胀无处藏身,表本来就要付的维护窗口跟着变短。上线后盯着它和全量等价物的体积比;部分索引一旦长到接近全量,说明谓词选出的早已不是少数,交易已经到期——删掉它,或重新定义切片。 部分索引还有一个容易漏掉的边界:规划阶段必须能证明查询条件蕴含索引谓词。把值写成预备语句参数,规划器无法对所有可能值提前证明,因此可能完全不用这个索引;不同写法的等价条件也未必能被识别。把热点条件固定成同一段查询构造,给带真实参数的 EXPLAIN 留一份回归样本,才能知道索引是在生产路径上工作,而不是只在建表演示里好看。 让语句与索引对得上 规划器通常只会为与部分索引谓词完全一致、或由其蕴含的查询选用它。所以查询里的过滤条件要照索引的写法写,规划器才能真正用得上。状态等于未关闭能命中条件相同的索引;状态属于两者之一则不行,哪怕人一眼能看出重叠。匹配器认的是逻辑蕴含,不是字面相似,而且设计上偏保守。 这份严格规定了一条纪律:全库统一热切片条件的写法。索引写的是删除时间为空,查询就不许写成删除时间等于空或包一层合并函数还指望走索引。把热条件收敛到一处——视图、作用域、查询构造器的片段——让每条语句继承逐字相同的谓词。执行计划是最终裁判:计划无视索引,说明两边谓词差在匹配器跨不过去的地方。 坏掉的样子 经典故障是谓词漂移:业务把"未关闭"重定义得多了一个状态,应用查询跟着改了,部分索引却悄悄对不上一半负载。不报错——计划一条一条退化成顺序扫描,延迟爬升,背锅的却找不到一次发布。上线时给核心查询加上计划断言,进持续集成或监控,断言计划里出现这个索引名。谓词漂移在发布当天就被抓住,而不是被发现的那个季度。 第二种故障是切太碎:一张表五个部分索引,各伺候一份报表,合计维护费超过当初要替换的全量索引。用索引统计视图审计:几乎零扫描的索引是删除候选,谓词重叠的是合并候选,合成一个更宽的部分索引。 快速验证 验证索引命中与体积收益: CREATE INDEX ON orders (created_at) WHERE status = 'open'; EXPLAIN SELECT * FROM orders WHERE status = 'open' ORDER BY created_at LIMIT 20; SELECT pg_size_pretty(pg_relation_size('orders_created_at_idx')); 计划必须走部分索引的索引扫描,体积应该是全量等价物的一小部分。 ...

二月 15, 2028 · 1 分钟 · XB Field Notes
依据分区键把整块分区从查询中排除

分区裁剪跳过查询不可能用到的整张分区

分区裁剪排除的是整张不可能包含匹配行的分区。它与在剩余分区内部利用索引寻找行不同,也不是按页工作的缓存技巧。PostgreSQL 可以在查询规划阶段完成排除,也可以等参数值在执行过程中可用后再裁剪。要判断它是否生效,需要查看实际访问的分区,而不是只看最终返回了多少行。 证明哪些分区不可能命中 声明式分区的边界来自分区键。把日期键限制在十月,可以证明九月和十一月与查询无关。裁剪依赖这些边界,而不是估计其他分区“可能很少匹配”。统计信息仍影响其他规划决策,但行数估计不准,不会让分区归属变成概率判断;这两个层面的信息应分开解释。 裁剪不要求先建索引。排除无关分区后,如果剩余分区的大部分行都需要读取,PostgreSQL 完全可能选择顺序扫描。所以,出现 Index Scan 并不是裁剪成功的判据。应先检查物理关系名和计划结构,再单独评价剩余每张表内部的访问方式。顺序扫描一张相关分区,仍然可能已经省掉其他分区的大量工作。 条件必须能对应分区键及其比较语义。把日期列转换成文本,可能隐藏直接的边界关系,即使某个字符串条件恰好仍选出相同行。应使用有类型的参数和直接表达目标区间的条件,不能假定分区键外面套任意表达式都保留裁剪能力,甚至不能默认它保留原来的日期语义。 参数也能在执行时触发裁剪 常量可以支持规划期裁剪,使计划只留下相关子表。预备语句的通用计划无法提前知道未来每次传入的参数值,但这不表示它必须扫描全部分区:执行初始化时已经知道的值,也可以用于执行期裁剪。看见通用计划中原本存在多个分支,还不足以判断最终都会读取。 EXPLAIN ANALYZE 用 Subplans Removed 报告初始化阶段排除的分区。另一些情况,例如嵌套循环内侧参数在执行期间变化,可以在实际执行过程中继续裁剪。分析这些场景时,需要查看循环次数和子计划是否运行。某个节点显示从未执行,也可能是外层没有输入等其他原因,不能脱离完整计划单独当成裁剪证据。 因此,不能把这些规则简化成“只要不是常量表达式,就会失去裁剪”。参数何时可用,以及表达式是否受支持,共同决定何时能够排除分区。定制计划和通用计划可能不同,但返回同样答案。真正值得确认的是:负载使用真实参数时,哪些分区实际被访问了。 坏掉的样子 只过滤无关字段,可能让所有分区仍然相关;把键转换成无法对应边界的表达式,也可能产生同样结果。调整分区布局前,应检查准确条件和 enable_partition_pruning 设置。手工添加相互矛盾的 CHECK 约束并不是修复方法;普通本地声明式分区会执行自己的边界规则,而基于 CHECK 的约束排除是另一种优化机制。 裁剪也不保证没有锁或规划成本可以忽略。执行初始化阶段排除的分区,在执行开始时仍然会被锁定。大型分区层级可能消耗规划时间和会话内的元数据内存,即使最后只扫描少数子表。应根据真实查询和维护模式选择层级,而不是追求尽可能多的分区数量。 最后,即使裁掉两个分区,第三个分区内部仍可能有几千条无关行。合适的局部索引可能帮助第二阶段。最终返回一百行,不证明只检查了一百行;缓冲命中也不等于物理磁盘读取。必须沿着计划节点看清扫描、过滤与输出之间的区别。 快速验证 把下面内容保存为 partition-pruning.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f partition-pruning.sql。脚本在事务内启用裁剪,并用通用预备计划展示初始化阶段的行为。临时分区上没有建立任何索引。 \set ON_ERROR_STOP on BEGIN; SET LOCAL datestyle='ISO, YMD'; SET LOCAL enable_partition_pruning=on; CREATE TEMP TABLE observations (day date,id integer) PARTITION BY RANGE(day); CREATE TEMP TABLE obs_sep PARTITION OF observations FOR VALUES FROM ('2026-09-01') TO ('2026-10-01'); CREATE TEMP TABLE obs_oct PARTITION OF observations FOR VALUES FROM ('2026-10-01') TO ('2026-11-01'); CREATE TEMP TABLE obs_nov PARTITION OF observations FOR VALUES FROM ('2026-11-01') TO ('2026-12-01'); INSERT INTO observations SELECT DATE '2026-09-01'+g%90,g FROM generate_series(0,8999) AS g; ANALYZE observations; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT count(*) FROM observations WHERE day>=DATE '2026-10-01' AND day<DATE '2026-11-01'; SET LOCAL plan_cache_mode=force_generic_plan; PREPARE by_day(date) AS SELECT count(*) FROM observations WHERE day=$1; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) EXECUTE by_day(DATE '2026-10-10'); EXECUTE by_day(DATE '2026-10-10'); EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT count(*) FROM observations WHERE day::text='2026-10-10'; DO $$ BEGIN IF (SELECT count(*) FROM observations WHERE day=DATE '2026-10-10') <> 100 OR (SELECT count(*) FROM observations WHERE day>=DATE '2026-10-01' AND day<DATE '2026-11-01') <> 3100 THEN RAISE EXCEPTION 'unexpected pruned result'; END IF; END $$; DEALLOCATE by_day; ROLLBACK; PostgreSQL 18.6 把常量十月区间规划为只扫描 obs_oct,读取三千一百条匹配行。通用日期查询报告 Subplans Removed 为二,返回一百行,但仍需在十月分区中过滤三千条其他记录。这同时展示了成功排除无关分区,以及相关分区内部尚未消除的工作量。 ...

二月 13, 2028 · 2 分钟 · XB Field Notes
一张逻辑表,若干物理分片。

表分区按范围或键拆分数据

范围分区把边界定义变成写入路由规则。PostgreSQL 按键值把行放到能够接受它的分区,无论这条数据准时到达,还是晚了几个月。任何查询获得裁剪收益之前,都需要先定义正确边界、使用匹配的时间类型,并安排缺失范围的处理流程。分区建表成功,只是这些条件中的一部分。 使用相邻的左闭右开范围 范围分区包含下界,不包含上界。九月可以从九月一日开始,一直到十月一日之前;十月正好从同一边界开始,双方不会重叠。不要靠猜测一个月最后可表示的时间戳来定义范围。相邻的左闭右开区间,不依赖时间字段究竟保留多少位小数,也能避免边界遗漏。 查询同样需要准确表达区间。大于等于下界、小于上界的条件,适合描述一个月的数据。BETWEEN 包含两个端点,因此,把下月第一瞬间写成其上界,可能把下个月的数据也查进来。分区路由只能执行数据布局规则,不能替一个请求了错误区间的条件修正业务含义。 分区键类型与时区也是定义的一部分。timestamptz 代表瞬间,不带时区的 timestamp 则代表没有对应时区解释的本地日期时间。示例为每个边界显式写出 UTC 偏移。如果业务月份按另一个时区划分,应根据那个时区的规则推导边界,而不是假定所有本地日期都对应相同的实际时长。 缺失范围是一种运维状态 没有分区接受新键值时,插入会报错,除非 DEFAULT 分区能够接住它。日历进入新月份,并不会让 PostgreSQL 自动创建下一张月表。应在写入需要之前创建范围,同时考虑迟到数据和旧日期修正。输入键不必单调增长,路由照样能够工作;关键是相应目标范围仍然存在。 DEFAULT 可以接收显式范围之外的值,减少立即发生的插入失败,但如果不检查默认表,也会掩盖分区管理遗漏。持续增长的默认分区可能混入多个互不相关的时间范围,让后续整理更困难。应让“有意保留的例外数据”与“忘记创建分区的日常数据”保持可辨认,不能把插入暂时不报错当成管理正常。 新增显式分区,会改变默认分区能够接受的值。如果新范围对应的行还留在 DEFAULT 中,PostgreSQL 会拒绝变更,而不会允许两处同时拥有这批数据。应先把这些行迁移到合适的暂存表或按规则处理,再挂载新分区。边界是被执行的数据规则,不是可以随意标错的说明标签。 坏掉的样子 下一月分区未创建时,部署可能恰好在跨月瞬间出现写入失败。补建分区又可能因为 DEFAULT 已经积累对应行而失败。这两种问题都属于路由和数据位置,不是规划器统计陈旧。应先检查被拒绝的键和当前边界,再决定操作,不能直接运行 ANALYZE 或改索引,希望它们修复缺失范围。 ATTACH 可能扫描候选表以验证边界,也可能检查默认分区中是否存在冲突数据。合适且已验证的约束能省去部分扫描,但操作仍需要锁,并要求表结构兼容。活跃表上迁移数据时,需要协调写入者和锁获取;一个隔离环境中的事务示例,不是完整的在线迁移操作方案。 是否允许空值,与范围选择是两回事。示例要求时间戳非空,避免缺失事件时间悄悄变成普通的例外数据。迁移后还应核对行数和标识;ATTACH 成功不能证明更早的复制步骤没有丢行或重复,数据核对不能省略。 快速验证 把下面内容保存为 partition-ranges.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f partition-ranges.sql。全部表都是临时表,整个实验最后回滚清除。 ...

二月 10, 2028 · 2 分钟 · XB Field Notes
保留每行身份,并分别附加唯一位置和允许并列的名次。

窗口函数在不合并组的前提下排名

排名函数为分区内的每一条输入行附加位置,不会把整个团队、部门或客户的数据压缩成一行聚合结果。真正需要选择的是并列的含义:每行占一个独立位置、允许并列且保留空位的竞赛名次,还是按不同分数组连续计数的名次。三种规则解决的问题不同,应先确定业务规则再写窗口。 三个问题对应三种排名 row_number() 从一开始给每行分配连续整数。如果两行的排序值完全相同,而窗口没有其他区分条件,它们的相对序号没有保证。需要恰好选出两位获胜者时,可以按分数降序,再按稳定且唯一的标识排序。这意味着同分时由标识决定谁占据剩余名额,不能把这个结果当成不影响业务的技术细节。 rank() 给同组并列行分配该组第一行的位置。分数为一百、九十、九十、八十时,名次依次为一、二、二、四。两位第二名占用了两个位置,因此下一档分数前面会留下空位。dense_rank() 则计算同分组的数量,对同一批数据给出一、二、二、三,不会因并列人数增加而跳号。 并列组由窗口 ORDER BY 中的全部表达式决定。如果把唯一标识也加入 rank 的窗口,即使本意只是稳定展示顺序,也会让原来的同分行不再并列。row_number 需要标识裁决、rank 需要保留同分时,应使用不同窗口定义。最终结果的展示顺序可以由外层 ORDER BY 控制,不必因此改掉排名所依据的并列关系。 明确分区与过滤时机 PARTITION BY 让每个组从头编号。不写分区条件时,窗口看到的所有输入行属于同一个分区。这和窗口帧是不同概念:排名依据分区顺序和并列组,不是对某段移动聚合帧计数。添加 ROWS 边界,不会把 rank 变成“这个边界内有多少行”的函数。 窗口看到的是 FROM、WHERE、分组和 HAVING 处理后剩余的行。因此,在同层查询中过滤,可能先删掉部分竞争者,再计算名次。如果需要先给所有人排名,再挑前几个位置,应在子查询或 CTE 中生成窗口值,然后到外层过滤。PostgreSQL 不允许直接把窗口函数放进 WHERE,这也提醒读者区分排名输入与排名输出。 “前两名”必须有明确含义。位置不大于二,每个分区最多返回两行;竞赛名次不大于二,会保留全部并列第二名,结果可能超过两行;密集名次不大于二,则选择前两个不同分数组。当第一档分数本身就有多人并列时,密集排名与竞赛排名的筛选结果也可能不同。这些是不同的结果集合,不是可以随意互换的性能写法。 坏掉的样子 row_number 缺少确定的并列裁决条件时,同分选手可能在重复查询中交换位置。反过来,在所有窗口都添加标识,又可能悄悄消除 rank 本来要保留的并列。测试时必须放入恰好卡在截止名次上的同分数据,不能只测每个分数都不同的情况。如果分数允许 NULL,还应明确空值顺序;PostgreSQL 降序默认把空值放前面,需要时应显式指定 NULLS LAST。 ...

二月 8, 2028 · 2 分钟 · XB Field Notes
经过调校、让繁忙表维持轻装的 autovacuum。

autovacuum 让高频写表保持轻装

繁忙表持续产生旧行版本,维护速度需要与这种产生速度匹配。降低自动回收门槛,可以让表更早具备被处理的资格,但“符合条件”并不等于“已经完成”。工作者仍需轮到这张表、获得所需锁,并在积压继续增长之前完成足够的有效清理。调优应观察这个完整循环,不能把一个参数值当成后台吞吐的保证。 坏掉的样子 普通更新和删除的触发条件,由固定门槛加上表行数估计乘比例因子构成;PostgreSQL 18 在启用上限时,还会应用最大触发门槛。插入触发清理、由事务年龄驱动的冻结,又有各自的运行原因。因此,只看一个死元组比例,会漏掉其他维护需求;每看见一次清理就断言更新删除门槛被触发,也可能误判真正原因。 把门槛调低,不能释放仍被旧快照需要的版本,也不能创造额外的磁盘吞吐。增加工作者或降低成本延迟,还可能加剧与前台查询的资源竞争。让死元组估计在每个瞬间都接近零,既不是通用的现实目标,也不能充分说明业务性能良好。真正需要维持的是可持续的清理能力、可接受的文件增长、请求延迟和维护成本。 快速验证 使用 PostgreSQL 18 可丢弃实例,确认自动回收与统计收集已启用、有可用工作者,并使用正常的一分钟数据库检查间隔。通过一个全新的 psql -X -v ON_ERROR_STOP=1 连接执行下面的脚本。它最多等待一百三十秒,核对真实自动执行;如果超时,应把这次观察记为失败,不能手动清理一次就冒充自动任务已经运行。 CREATE SCHEMA churn_demo; CREATE TABLE churn_demo.jobs (id integer PRIMARY KEY,n integer) WITH (autovacuum_enabled=false, autovacuum_vacuum_threshold=50, autovacuum_vacuum_scale_factor=0.2); INSERT INTO churn_demo.jobs SELECT g,0 FROM generate_series(1,10000) AS g; SELECT pg_stat_force_next_flush(); VACUUM (ANALYZE) churn_demo.jobs; UPDATE churn_demo.jobs SET n=1 WHERE id<=1000; SELECT pg_stat_force_next_flush(); SELECT n_live_tup,n_dead_tup,vacuum_count,autovacuum_count FROM pg_stat_user_tables WHERE relid='churn_demo.jobs'::regclass; SELECT 50+0.2*reltuples AS old_threshold,50+0.01*reltuples AS new_threshold FROM pg_class WHERE oid='churn_demo.jobs'::regclass; ALTER TABLE churn_demo.jobs SET (autovacuum_enabled=true,autovacuum_vacuum_scale_factor=0.01); DO $$ DECLARE deadline timestamptz := clock_timestamp()+interval '130 seconds'; BEGIN LOOP PERFORM pg_stat_clear_snapshot(); EXIT WHEN (SELECT autovacuum_count>0 FROM pg_stat_user_tables WHERE relid='churn_demo.jobs'::regclass); IF clock_timestamp()>deadline THEN RAISE EXCEPTION 'no automatic vacuum observed within the experiment budget'; END IF; PERFORM pg_sleep(0.25); END LOOP; END $$; SELECT pg_stat_clear_snapshot(); SELECT n_dead_tup,last_autovacuum IS NOT NULL AS ran,autovacuum_count>0 AS counted FROM pg_stat_user_tables WHERE relid='churn_demo.jobs'::regclass; SELECT count(*),count(*) FILTER (WHERE n=1) FROM churn_demo.jobs; DO $$ BEGIN IF (SELECT count(*) FROM churn_demo.jobs)<>10000 OR (SELECT count(*) FROM churn_demo.jobs WHERE n=1)<>1000 THEN RAISE EXCEPTION 'maintenance changed application data'; END IF; END $$; DROP SCHEMA churn_demo CASCADE; 测试表最初只关闭自己的常规自动回收,让初始化与受控更新先完成。手动清理并分析建立统计,同时清掉初始化插入积压,然后再更新一千行。显式统计刷新调用用于同步这个短实验,避免待发布的写入增量落在手动维护更新估计值之后;它不是建议在线业务频繁调用的调优开关。 ...

二月 4, 2028 · 1 分钟 · XB Field Notes
一个独立于任何自然值、专司身份的自动稳定 id。

代理键把身份与含义分开

代理主键把一行记录的身份与可能变化的业务值分开。客户修改邮箱时,订单仍引用同一个客户编号,而不必跟着邮箱一起改引用。这对于易变或较宽的业务键很有用,但稳定性来自数据库约束与应用规则,不是“自动生成”四个字本身。一个生成的数字不会天然不可修改、保密、全局唯一,也不能代替访问授权。 生成身份与约束唯一性 PostgreSQL identity 列从关联序列取得默认值。GENERATED ALWAYS AS IDENTITY 会拒绝普通显式赋值,GENERATED BY DEFAULT 则允许调用方提供值。即使使用 ALWAYS,导入时仍可通过 OVERRIDING SYSTEM VALUE 显式覆盖。两种形式都不会单独保证值唯一;需要拒绝重复标识时,仍须声明 PRIMARY KEY 或 UNIQUE 约束。 主键同时保证唯一与非空,并提供可供引用使用的索引。Identity 负责怎样生成默认值,约束负责哪些最终值允许存储,这两项职责不能混淆。导入时尤其如此:显式写入一个很大的编号,不会自动把序列推进到那个位置。若没有协调序列状态,后续生成值可能最终与导入行相撞,直到主键报错才暴露问题。 单独保留业务唯一规则 代理编号不会阻止相同业务实体重复出现。如果邮箱是唯一登录标识,应继续保留明确的唯一约束,并先决定规范化规则。普通 text 的 UNIQUE 使用该列的比较语义,不会暗中替应用处理大小写折叠、空白或各种邮箱等价规则。迁移之前就要明确这些行为,否则数据库认为不同的值,应用可能认为是同一账号,或者反过来。 子表如果不能出现悬空引用,就需要真实的外键约束。邮箱不是引用键时,修改它不会改变订单指向的客户,因为订单保存的是 id。这缩小了这一项修改的范围,却不能承诺所有业务改名都变成简单操作:搜索索引、审计记录、外部集成和缓存仍可能依赖邮箱,需要按实际调用链更新。数据模型只解决它真正覆盖的关系。 坏掉的样子 把序列编号当成创建顺序或提交顺序,在并发下会产生错误结果。多个事务可能先后取号却反向提交,失败语句也可能留下空洞。展示排序应使用明确的时间字段并补充稳定决胜字段,不能根据编号连续性推断是否丢单。顺序编号也容易被猜到;即使另加不透明的公开标识,授权仍必须检查请求者与资源之间的关系,不能靠“不容易猜”保护数据。 导入时覆盖 identity 是另一种陷阱。插入的显式编号远大于序列当前位置,序列仍可能继续从较小值生成,最终碰到已有主键。应在受控导入流程中协调序列状态,并排除或正确协调并发写入。另开一个会话查询 max(id) 再加一,不是并发安全的分配算法;两个调用方可能同时得到相同结果。 迁移还可能意外删掉原自然键承担的唯一规则。换键之前要检查重复值与 null,保留真正需要的业务唯一约束,并沿所有真实引用更新。也不必仅因代理键常见就给每张表增加一列和一个索引;稳定自然键如果已经准确表达契约,也可能是合适选择。决策依据应是业务值的可变性、引用范围和数据语义。 快速验证 把下面代码保存为 identity.sql,运行 psql -X -v ON_ERROR_STOP=1 -f identity.sql,连接一个可丢弃的 PostgreSQL 18 数据库。实验使用临时表并在末尾回滚,不依赖已有 users 表,也不需要读取真实用户邮箱。所有地址都采用保留的 example.test 域名,仅用于构造可重复的数据。 ...

二月 2, 2028 · 2 分钟 · XB Field Notes
数据变化后重新读一遍规划器的最新计划。

查询变慢时同时检查计划与等待

一条过去很快的查询,可以在数据、统计和访问计划都没有变化时变慢。另一个事务可能持有它需要的锁,存储缓存可能比上次更冷,应用也可能在语句进入 PostgreSQL 之前就开始等待。重新检查计划很有用,但它应当放在完整的耗时比较中:先区分时间是花在执行工作上,还是花在等待获得执行机会。不能从“现在更慢”直接推导出“数据与统计一定都变了”。 保留可比较的基线 记录语句形状、有代表性的参数值、返回行数、相关会话设置,以及应用是否使用预备语句。不同参数可能命中完全不同规模的数据。手工使用常量的查询,不一定复现预备语句采用的通用计划;不同角色或搜索路径,也可能改变实际对象与权限。因此,拿一条看起来相似的语句作为对照,并不保证比较条件相同。 尽量在变慢正在发生时观察活动。pg_stat_activity 可以显示一个状态为运行中的查询正在等待锁;运行中并不等于持续消耗处理器。pg_blocking_pids 列出当前阻塞这个后端的会话。查看其他角色活动需要适当权限,因此字段不可见不等于没有工作发生。交互观察最好使用新的自动提交查询,而不是在同一个长事务中不断读取已经缓存的监控快照。 坏掉的样子 不加判断就运行 ANALYZE,相当于把每次变慢都当成统计陈旧。它可能增加维护工作,却完全没有解除真正的阻塞。一张只有一行的表,即使新增索引,也不能释放其他事务持有的行锁。准确的行数估计和很小的顺序扫描,可以与很长的应用等待同时存在;这两种观察并不矛盾。 EXPLAIN ANALYZE 会真正执行语句,写入也不例外,而且测量本身同样可能被阻塞。从生产环境复制一条大范围更新,不是无害的检查。应先看等待情况和锁的拥有者,再选择安全夹具或副作用已经明确的语句来测量。看到某个进程编号出现在阻塞列表中,也不能立即终止不熟悉的事务,因为它可能正在完成另一个有效业务操作。 快速验证 打开三个新的 psql 会话,分别记作甲、乙、丙,连接同一个可丢弃的 PostgreSQL 18 数据库并使用相同角色,保留通常的自动提交行为。名为 query_recheck_demo 的模式必须尚不存在。务必区分会话:把所有代码块顺序粘贴到同一个连接,无法证明事务之间的阻塞。 在甲中建立夹具,然后开启事务,修改唯一一条记录但暂不提交。 \set ON_ERROR_STOP on CREATE SCHEMA query_recheck_demo; CREATE TABLE query_recheck_demo.items (id integer PRIMARY KEY,value integer); INSERT INTO query_recheck_demo.items VALUES (1,0); ANALYZE query_recheck_demo.items; BEGIN; UPDATE query_recheck_demo.items SET value=10 WHERE id=1; SELECT pg_backend_pid() AS blocker_pid; 在乙中发起带执行测量的更新。它会等待甲结束事务,或者等到六十秒的语句超时。 SET application_name='xbcatm_recheck_waiter'; SET statement_timeout='60s'; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) UPDATE query_recheck_demo.items SET value=value+1 WHERE id=1; 乙仍在等待且尚未超时时,在丙中执行观察查询。实验专用应用名称用于定位乙,不需要展示整条查询文本。 SELECT pid,state,wait_event_type,wait_event,pg_blocking_pids(pid) AS blockers FROM pg_stat_activity WHERE application_name='xbcatm_recheck_waiter'; 回到甲,撤销尚未提交的变更并释放锁,然后收集乙执行完毕后的计划。 ROLLBACK; 确认乙已经成功结束后,在丙中检查最终数据并清理夹具。 SELECT value FROM query_recheck_demo.items WHERE id=1; DROP SCHEMA query_recheck_demo CASCADE; PostgreSQL 18.6 实测中,乙的状态为运行中,等待类型是 Lock,具体等待事件是 transactionid,阻塞列表包含甲的进程编号。具体事件可能随争用过程变化,核心证据是乙确实被甲阻塞。甲回滚后,乙把原本的零加成一,最后查询返回一,清理语句移除了整个实验模式。 ...

一月 31, 2028 · 1 分钟 · XB Field Notes