标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

序列分配数值,不随事务回滚回收

PostgreSQL 序列分配数值,并不以调用方事务最终提交为前提。这让并发场景中的普通标识分配更实用,也意味着空洞是正常现象。一个序列号只说明发生过分配,不能证明对应行存在、事务已经提交,或所有更小的数值都已经使用。把取号与业务记录的生命周期分开,才能正确解释缺号和顺序。 原子分配也有前提 nextval 会原子推进序列。普通非循环序列在没有被重置到已用范围的情况下,能为并发调用方分配不同数值。这不代表实现完全不使用锁,也不代表任何管理操作都不会影响它。CYCLE 和显式重置都可能重新使用数值,因此,已存储行的唯一性仍应由表上的 PRIMARY KEY 或 UNIQUE 约束负责。 序列也有有限边界。非循环序列无法在范围内继续分配时会报错,不会自动扩张成无限整数。应明确选择底层类型和步长;高频标识接近配置上限时,要关注剩余容量。即使序列使用更宽的类型,也不会自动扩宽接收它的表字段,不能只检查分配器而忽略目标列的类型限制。 identity 和 serial 列通过序列提供默认值。序列负责分配,字段约束负责决定哪些行允许写入。显式导入的标识可能让分配器落后于表中数据。修复这种错位时,要考虑活跃写入者和已经预留的数值,不能在业务仍并发写入时,随意根据一次 max 查询执行 setval,然后假设风险已经消失。 空洞与顺序是不同属性 事务回滚不会收回 nextval 已分配的数值。取号后没有使用、插入中止,或 INSERT 走了 ON CONFLICT 分支,都可能产生空洞。删除表中一行也不会让序列倒退。因此,单凭编号缺失,不能认定记录丢失或序列损坏;需要结合事务和业务记录查明实际含义。 CACHE 会按会话预留一批数值。如果一个会话保留一到十,另一个保留十一到二十,外部观察的调用结果就可能是一、十一、二。会话结束时,未使用的预留值也可能被放弃。缓存提高分配效率,却会影响连续性和全局观察顺序;即使没有批量缓存,分配先后也不等于事务提交先后。 setval 对序列状态的修改不会因调用方回滚而撤销。不能把这一点推广到所有序列命令:ALTER SEQUENCE RESTART 是事务性的,并会阻止并发取号直到事务结束。其他会话还可能持有原先缓存的数值,所以重置活跃分配器不是安全的消除空洞方法。如果业务要求连续编号,应明确设计事务内分配与取消语义。这可能需要串行访问一个专用计数器,但不意味着必须锁住业务表中的每一行。不能把普通技术主键与这种业务编号混为一谈。 坏掉的样子 currval 保存指定序列的会话局部值,通常由 nextval 建立。本会话尚未建立这个值时,调用会报错;is_called 为真的 setval 也会修改该会话值。其他连接取过号,并不能为当前连接初始化它。使用连接池时,如果 nextval 与 currval 落到不同数据库会话,原本看似连续的两步操作就可能失败。 ...

四月 12, 2027 · 2 分钟 · XB Field Notes
标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

排序规则决定字符串怎么比、怎么排

文本排序是查询契约的一部分。按字节顺序排列的列表,可能不同于按语言习惯排列的列表;忽略大小写的比较,也可能改变唯一约束眼中的“重复名字”。PostgreSQL 不仅有数据库默认规则,还允许在列和表达式上指定排序规则。涉及业务标识、去重或翻页时,应把这些语义写清楚,而不是等不同环境返回不同结果后再猜原因。 坏掉的样子 选了一个语言区域,并不自动意味着不同字节的字符串会相等。确定性排序规则在语言比较相同时,还会使用字节来区分;非确定性规则则允许提供方把不同字节视为等价。对于 ICU,使用 und-u-ks-level2 并设置 deterministic=false,会忽略大小写但仍然区分重音。把这个组合描述为“大小写和重音都不区分”,会直接误导唯一性设计。 比较规则改变后,原先不同的值可能发生碰撞。因此,以新规则创建唯一索引,可能因为已有数据而失败。如果多个名字在新规则下相等,排序还需要明确的第二排序键。翻页时只按不区分大小写的名字排序,不能定义这些等价行之间谁在前谁在后。这里影响的是模式与查询行为,不只是页面上显示哪种字母风格。 快速验证 准备支持 ICU 的 PostgreSQL 18、一个 UTF-8 编码的可丢弃数据库,以及全新的 psql -X -v ON_ERROR_STOP=1 会话。创建排序规则需要对应数据库权限。如果服务器没有 ICU,初始化应明确失败,不要偷偷换成别的区域规则后继续把结果当作同一个实验。先创建忽略大小写的规则,再插入五个名字: CREATE SCHEMA collation_demo; CREATE COLLATION collation_demo.ci (provider=icu,locale='und-u-ks-level2',deterministic=false); CREATE TABLE collation_demo.names (id integer PRIMARY KEY,name text COLLATE "C"); INSERT INTO collation_demo.names VALUES (1,'a'),(2,'A'),(3,'á'),(4,'b'),(5,'B'); SELECT 'a' COLLATE "C" = 'A' AS c_equal, 'a' COLLATE collation_demo.ci = 'A' AS case_equal, 'a' COLLATE collation_demo.ci = 'á' AS accent_equal; 三个比较依次返回假、真、假。字节规则区分大小写;新的语言规则把小写与大写 A 视为相等,但仍区分没有重音和带重音的字母。非确定性开关决定是否使用字节打破比较平局,ICU 的区域与强度决定哪些语言差异会参与比较。删除这个开关,或者改变强度,都已经是在测试另一份契约。 ...

四月 4, 2027 · 2 分钟 · XB Field Notes
标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

INSERT ... ON CONFLICT 把竞态收进一条语句

INSERT … ON CONFLICT 让 PostgreSQL 在真正写入的语句内部处理插入冲突。它用明确的冲突策略和实际索引或约束,替代脆弱的“先查键是否存在,再决定插入”流程。但它不会把所有插入错误都变成更新成功,也不会替应用决定新旧数据应该如何合并。使用前,需要先确定唯一键和业务动作。 冲突目标定义处理范围 (name) 这样的目标,要求 PostgreSQL 推断匹配的唯一索引。表上必须实际存在适用的唯一性规则;写出 ON CONFLICT 并不会自动建立约束。如果键涉及表达式或部分索引,索引表达式和谓词也会影响匹配。目标没有匹配到索引时会报错,而不是悄悄改用另一种插入策略。可空业务键尤其需要注意:普通唯一索引把空值视为彼此不同,多次写入空键不一定冲突。若业务要求这些行相互冲突,应选择非空键,或明确采用符合需求的 NULLS NOT DISTINCT 语义。 DO UPDATE 必须指定目标,其裁决依据只能是受支持的非延迟唯一约束或唯一索引,排除约束不能作为 DO UPDATE 的冲突裁决依据。DO NOTHING 的处理范围更广,包括受支持的排除约束,也可以省略目标来处理适用的冲突规则。但这些能力都不会让外键、非空或检查约束失效,不能把它当成通用的错误忽略机制。 原子的插入或更新行为,解决的是并发争用同一键时的协调。语句仍可能等待其他事务持有的相关行或索引冲突,正常的事务错误也仍可能发生。原子性描述结果如何被协调,不保证完全无锁、不阻塞,也不意味着调用方永远不用处理事务级重试。 明确写出合并规则 目标表名或别名代表已有行,EXCLUDED 代表原本准备插入的行,其中也反映 BEFORE INSERT 触发器的相关影响。赋值为 EXCLUDED.total 是替换旧值;赋值为已有 total 加 EXCLUDED.total 是累加一次贡献。虽然语法相近,两者回答的业务问题不同,不能把其中一种默认当成“正确的 upsert”。 计数器直接在更新表达式中使用已有行,可以避免客户端先读旧值,再算一个可能过时的替换值。带版本的文档则可以用 WHERE 拒绝较旧版本。这个条件在找到并锁定冲突行后求值,因此,即使最终没有更新,它也可能与其他写入者争用锁。条件跳过不等于完全没有执行成本或并发影响。 ...

三月 25, 2027 · 2 分钟 · XB Field Notes
标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

读 EXPLAIN 估算时要带上执行上下文

EXPLAIN 描述 PostgreSQL 打算执行的计划,EXPLAIN ANALYZE 则真正执行它并补充测量结果。比较这两组数字之前,必须先理解每个数字究竟在数什么。表面上的估计偏差,既可能来自统计过时,也可能来自节点重复执行,或者父节点提前停止取行。行数吻合只能增强我们对模型某一部分的信任,不能证明这个计划已经是所有可能方案里最快的一个。 把节点放回执行上下文 估计的 rows 是节点输出行数,不一定是它实际检查的全部元组数。一个扫描可以检查几千条记录,过滤后只交出十条。启动成本与总成本使用规划器自己的单位,不是毫秒。父节点的成本已经包含子节点工作,因此把整棵树所有成本相加会重复计算。实际时间也通常包含子节点耗时,不能把它当成彼此独立的一组费用来求和。 对于重复执行的节点,实际行数和耗时按每次执行的平均值报告,执行次数由 loops 表示。一个实际输出两行、循环三次的节点,总共提供六行。过滤掉的行数同样按每轮平均值展示,四舍五入可能影响小数字。应当从底部扫描向上追踪,找到实际数据规模第一次明显偏离估计的位置,同时遵守这些执行边界,而不是挑出整棵树中最显眼的一对数字就下结论。 LIMIT、EXISTS 等上层操作还可能提前停止请求子节点。此时子节点的估计描述完整执行时可能产生的输出,实际值却只记录父节点真正取走的部分。这不自动意味着统计错误。排序也可能已经读完整个输入,却只向上交出所需的前几行;“输出少了”与“底层工作少了”不是一回事。 坏掉的样子 给写语句加上 EXPLAIN ANALYZE 会真的执行写入。外层事务回滚可以保护事务性的表变更,但不能撤销所有副作用,例如序列已经发出去的号码,或函数调用触发的外部行为。在执行测量前,应使用可丢弃的夹具,或者明确语句及其函数、触发器的完整副作用。不能因为输出长得像报告,就把它视为只读操作。 另一个常见误判是仅凭 BUFFERS 诊断磁盘。共享缓冲区命中表示没有发生 PostgreSQL 缓冲区未命中;报告中的读取仍可能由操作系统缓存满足,不能直接等同于设备读取次数,访问计数也不是不同数据块的数量。下面的临时表使用 local 缓冲区;计划中的 temp 块则指排序溢写等工作文件,不能因两者都叫“临时”就混为一谈。即使估计行数完全准确,仍可能存在锁等待、内存不足引发的临时文件、配置问题,或没有被选中的更合适访问路径。 快速验证 在 PostgreSQL 18 的 psql 中执行下面事务。它建立三个父记录、三十个子记录,分别观察重复的横向子查询和单独的前三名查询。数据全部放在临时表中,最后回滚清理,避免把演示对象留在业务表里。 \set ON_ERROR_STOP on BEGIN; CREATE TEMP TABLE parents (id integer PRIMARY KEY); CREATE TEMP TABLE children (parent_id integer,value integer); INSERT INTO parents VALUES (1),(2),(3); INSERT INTO children SELECT 1+(g-1)%3,g FROM generate_series(1,30) AS g; ANALYZE parents; ANALYZE children; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT p.id,c.value FROM parents p CROSS JOIN LATERAL ( SELECT value FROM children WHERE parent_id=p.id ORDER BY value DESC LIMIT 2 ) c; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT value FROM children ORDER BY value DESC LIMIT 3; SELECT p.id,c.value FROM parents p CROSS JOIN LATERAL ( SELECT value FROM children WHERE parent_id=p.id ORDER BY value DESC LIMIT 2 ) c ORDER BY p.id,c.value DESC; DO $$ BEGIN IF (SELECT array_agg(value ORDER BY parent_id,value DESC) FROM ( SELECT p.id AS parent_id,c.value FROM parents p CROSS JOIN LATERAL ( SELECT value FROM children WHERE parent_id=p.id ORDER BY value DESC LIMIT 2 ) c ) s) IS DISTINCT FROM ARRAY[28,25,29,26,30,27] THEN RAISE EXCEPTION 'wrong lateral result'; END IF; END $$; ROLLBACK; PostgreSQL 18.6 实测的横向查询采用嵌套循环。内层 Limit 显示实际行数为二、loops=3,因此整个连接输出六行。其下的扫描每轮返回十个符合条件的孩子,并过滤掉二十个,所以它在三轮里每轮都检查了三十条子记录。把这些数字放在一起读,才不会误以为整个查询只看过十行。 ...

三月 19, 2027 · 2 分钟 · XB Field Notes
标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

ANALYZE 更新规划器使用的分布估计

ANALYZE 更新的是 PostgreSQL 用来估算查询结果的统计模型。它不会重写查询、重建索引、回收死元组,也不承诺执行计划一定变快。它回答的具体问题是:规划器是否足够了解当前数据分布,能够合理比较不同访问路径?即使表里的有效行总数完全没变,一次批量状态更新也足以让原来的分布信息过时。先明确这个边界,才能判断刷新统计是否解决了眼前的问题。 统计描述的是什么 对于普通列,pg_stats 可以展示空值比例、常见值及其频率、不同值数量的估计和直方图等信息。规划器把它们与表的大小、成本参数一起使用。任务表中只有百分之一处于就绪状态,与百分之九十已经就绪,意味着完全不同的读取工作量。前者可能适合通过索引定位少量行,后者直接顺序读取整张表可能更合适。因此,“没有走索引”本身不等于优化失败,关键仍然是这个条件究竟会取出多少数据。 大表上的分析通常抽样,而不是逐行检查全部内容。更详细的统计需要更大的样本和更多工作,不能由“抽样”推导出“任何时候都很便宜”。即使业务数据没有变化,不同轮抽样产生的估计也可能略有波动。针对特定列提高统计目标,有助于更好地表达偏斜分布,但普通的单列统计不会因此自动学会所有列之间的关联。样本精度不足和模型缺少相关性信息,是两种不同的问题。 坏掉的样子 批量更新以后,旧的常见值频率可能严重低估过滤结果。规划器于是安排了实际需要重复很多次的嵌套循环,或者选择一个最终要读取大部分表内容的索引扫描。不过,查询本来就要返回大多数行时,顺序扫描是合理候选,不应该为了恢复过去的节点名称而强制索引。 还有一些变慢根本不是统计导致的。查询正在等待另一个事务释放锁时,重复分析不会把锁释放;统计刷新也不会消除应用连接池排队或凭空建立缺失的索引;所需顺序仍须通过排序或已有的有序路径提供。应当先找出计划中最早出现的有效行数偏差,再将它与真正耗时的环节对应起来,而不是把所有延迟都归结为统计陈旧。 快速验证 在可丢弃的 PostgreSQL 18 数据库中使用 psql 执行下面代码。实验把临时表放在事务里,最后回滚清理。这里特意使用临时表:自动清理进程不能分析其他会话的临时表,因此不会在两次受控观察之间悄悄刷新本实验的统计。 \set ON_ERROR_STOP on BEGIN; CREATE TEMP TABLE jobs (id integer,ready boolean,payload text); INSERT INTO jobs SELECT g,g<=100,repeat('x',100) FROM generate_series(1,10000) AS g; CREATE INDEX jobs_ready ON jobs(ready); ANALYZE jobs; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT id FROM jobs WHERE ready; UPDATE jobs SET ready=true WHERE id<=9000; SELECT most_common_vals,most_common_freqs FROM pg_stats WHERE schemaname=(SELECT nspname FROM pg_namespace WHERE oid=pg_my_temp_schema()) AND tablename='jobs' AND attname='ready'; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT id FROM jobs WHERE ready; ANALYZE jobs; SELECT most_common_vals,most_common_freqs FROM pg_stats WHERE schemaname=(SELECT nspname FROM pg_namespace WHERE oid=pg_my_temp_schema()) AND tablename='jobs' AND attname='ready'; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT id FROM jobs WHERE ready; DO $$ BEGIN IF (SELECT count(*) FROM jobs WHERE ready) <> 9000 THEN RAISE EXCEPTION 'data changed during analysis'; END IF; END $$; ROLLBACK; 在 PostgreSQL 18.6 的实测中,第一次查询通过 jobs_ready 估计并返回一百个就绪任务。接着的更新把就绪任务增加到九千个。再次分析之前,pg_stats 仍然给出 {f,t} 和 {0.99,0.01},说明统计模型没有学到这次分布变化。表中存活记录的总数仍然是一万,变化的是满足条件的比例。 ...

三月 12, 2027 · 1 分钟 · XB Field Notes
窗口帧主题封面:浅色网格上放置标题,旁侧是相连的观察、判断与行动三个框。

窗口框架决定一次聚合能看到哪些行

窗口分区决定哪些行属于同一组,窗口帧决定其中哪些行参与当前行的某个帧敏感计算。把两种边界混淆,可能让逐笔累计变成重复显示的小计,也可能让 last_value 返回当前值,而报表原本需要的是分区最后一行的值。应先说明计算要回答什么,再选择排序和帧范围。 默认范围取决于是否排序 PostgreSQL 的默认帧是 RANGE:从分区开头到当前行的最后一个并列行。窗口 ORDER BY 中全部表达式都相等的行,属于同一个并列组。因此,有排序时的默认累计和,会包含当前行的全部同值伙伴,即使其中某些行在最终展示结果里排在后面,也已经进入当前帧。 如果窗口没有 ORDER BY,所有行都是并列行,默认帧就覆盖整个分区。因此,sum(amount) OVER (PARTITION BY account) 会重复显示账户总额;添加排序后,计算就变成有顺序的累计结果。“默认总是整个组”和“默认每次恰好多加一行”都不能同时描述这两种情况,不能把其中一种当成通用规则。 窗口顺序与最终输出顺序是分开的。外层 ORDER BY 可以改变展示排列,而不改变帧。反过来,在 RANGE 窗口内部加入唯一标识,可能把原来的并列组拆开,改变计算结果;这不只是让页面看起来稳定,而是修改了语义,应当按需求审阅。 按行、按值域和按并列组 ROWS 计算窗口排序中的位置。从前两行到当前行,最多包含三行;靠近分区起点时还会更少。它不表示三天或三分钟。如果多个事件时间戳相同,而业务需要确定的逐事件顺序,就应加入合适的唯一标识,明确同一时刻的事件谁先谁后。 RANGE 使用排序值和并列边界。指定偏移量时,PostgreSQL 要求只有一个排序表达式,并使用兼容的偏移类型,例如时间戳可以配合 interval。基于时间值域的帧按时间差纳入事件,忙时可能包含很多行,闲时可能很少。GROUPS 则按并列组计数,让帧沿不同排序值对应的组移动,同时保留组内全部并列行。这三者表达的是不同的计量单位。 first_value、last_value、nth_value,以及作为窗口函数使用的聚合,都依赖帧范围。排名和 lag、lead 的定义则基于分区顺序,并非相同规则;缩小帧不会让 lag 忘记分区中的前一行。解释某个窗口函数前,应查看该函数的语义,不能把“只看到帧内的行”套用到所有函数上。 帧成员范围不会覆盖函数本身的空值规则。PostgreSQL 18 的 last_value 及相关取值函数采用 RESPECT NULLS,末行为空时结果也为空;sum 等聚合通常忽略空输入。应先确定报表要末行取值、最后一个非空值,还是数值最大值。这些需求不能仅靠把帧延长到分区末尾就互相替代,改窗口之前要把空值案例写入验收条件。 ...

二月 23, 2027 · 2 分钟 · XB Field Notes
标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

连接池用少量 Postgres 连接应付大量请求

连接池让许多应用请求在不同时间借用较少的 PostgreSQL 会话。复用能够摊薄建立连接的成本,也能限制同时借出的数量,但不会让已经过载的数据库自动变快。池本身又增加了一条队列,它的容量、等待时间和取消行为,都属于请求预算的一部分。应把这条队列作为服务行为来设计,而不是把它隐藏在一个“复用更快”的标签后面。 坏掉的样子 数据库后端处于 ClientRead,表示正在等待客户端发数据,并不表示应用请求正在等待借用连接。真正的池内等待者可能还没有数据库连接,因此服务器根本没有对应的后端可供观察。获取连接的等待应在池里测量,查询执行和锁等待应在服务器测量;把它们混成一个所谓数据库池等待事件,会掩盖实际瓶颈的位置。 归还连接也不保证抹掉所有会话状态。Psycopg 的连接上下文在正常退出时提交,在异常离开上下文时回滚;额外重置取决于配置。已经提交的会话级设置,可以继续留给下一个借用者,即使没有泄漏未结束的事务。临时对象和其他会话资源同样需要明确策略,不能因为使用了连接池,就假定所有内容都会自动恢复到初始状态。 快速验证 准备 Python 3.10 或更新版本、PostgreSQL 18 可丢弃数据库,以及允许创建实验模式的角色。原演练使用以下依赖;本轮审阅没有重新执行并发数据库实验。安装方式为:python3 -m pip install "psycopg[binary]==3.3.5" "psycopg-pool==3.3.1"。使用已有认证配置,将 PG_DSN 设置为该测试数据库的连接串。把程序保存为 pool-demo.py,在安装依赖的同一个 Python 环境中运行: import os import time from concurrent.futures import ThreadPoolExecutor import psycopg from psycopg_pool import ConnectionPool, PoolTimeout, TooManyRequests dsn = os.environ['PG_DSN'] with psycopg.connect(dsn, autocommit=True) as admin: admin.execute('CREATE SCHEMA pool_demo') try: admin.execute('CREATE TABLE pool_demo.counter (n integer)') admin.execute('INSERT INTO pool_demo.counter VALUES (0)') with ConnectionPool( dsn, min_size=1, max_size=1, max_waiting=1, timeout=3, open=True, kwargs={'application_name': 'pool_example'}, ) as pool: pool.wait(timeout=3) def queued_request(): try: with pool.connection(timeout=1): raise AssertionError('holder should still own the only connection') except PoolTimeout: return 'PoolTimeout' with ThreadPoolExecutor(max_workers=1) as workers: with pool.connection() as held: original_pid = held.info.backend_pid waiting = workers.submit(queued_request) deadline = time.monotonic() + 0.5 while pool.get_stats()['requests_waiting'] != 1: if time.monotonic() >= deadline: raise TimeoutError('request did not enter the pool queue') time.sleep(0.005) state = admin.execute( 'SELECT state,wait_event_type,wait_event ' 'FROM pg_stat_activity WHERE pid=%s', (original_pid,) ).fetchone() assert state == ('idle', 'Client', 'ClientRead'), state print('waiting_in_pool', 1, 'server', state) try: with pool.connection(timeout=0.1): raise AssertionError('queue limit was not applied') except TooManyRequests: print('queue_limit', 'TooManyRequests') assert waiting.result(timeout=2) == 'PoolTimeout' print('queued_request', 'PoolTimeout') with pool.connection() as conn: assert conn.info.backend_pid == original_pid conn.execute("SET application_name='previous_borrower'") try: with pool.connection() as conn: conn.execute('UPDATE pool_demo.counter SET n=99') raise ValueError('simulated request failure') except ValueError as error: assert str(error) == 'simulated request failure' with pool.connection() as conn: value = conn.execute('SELECT n FROM pool_demo.counter').fetchone()[0] setting = conn.execute('SHOW application_name').fetchone()[0] assert value == 0 and setting == 'previous_borrower' assert conn.info.backend_pid == original_pid print('same_backend', True, 'rollback_value', value, 'retained_setting', setting) conn.execute('RESET application_name') finally: admin.execute('DROP SCHEMA pool_demo CASCADE') 池里只有一条连接,最多允许一个请求排队。主线程借走连接但不执行查询,工作线程尝试借用它。池报告一个等待者以后,独立观察连接看到被借走的数据库后端正空闲等待客户端读取事件。两种状态可以同时存在:应用已经排队,数据库会话却没有正在执行的查询。这正好说明为什么不能用服务器的空闲状态代替池的等待指标。 ...

二月 13, 2027 · 2 分钟 · XB Field Notes
标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

按所需操作选择 B-tree 或哈希索引

PostgreSQL 默认的 B-tree 索引支持多种常见需求,包括等值、范围、有序访问和唯一性约束;哈希索引提供的则是更窄的等值访问路径。两者的选择应先从需要支持的运算符与数据约束开始,再进入性能测量。说哈希“只要是等值就一定更快”,或者规划器“几乎从来不会选它”,都是用未经负载验证的概括代替具体问题。先确认能力,再比较成本,顺序不能颠倒。 先检查能力,再比较耗时 B-tree 按运算符类和排序规则保留键的顺序,因此既能定位单个等值键,也可能支持有界范围与 ORDER BY,省掉额外排序。它还可以为 IS NULL 和 IS NOT NULL 提供索引访问。不过,存在索引不等于强制使用索引;查询需要读取很大一部分表时,规划器仍可能选择其他路径。这并不改变索引在语义上支持哪些操作。 文本模式另有条件。像 LIKE 'abc%' 这样由常量构成、锚定开头的前缀,可以利用合适的 B-tree 路径,但排序规则和运算符类会影响适用性。非 C 区域设置下,可能需要专门支持模式匹配的运算符类。开头带通配符的匹配,也不会因为创建了普通 B-tree 就自动变成高选择性的有序范围。不能把“支持部分前缀匹配”写成“所有 LIKE 都能一趟索引解决”。 PostgreSQL 哈希索引保存四字节哈希码,而不是完整列值。它支持等值,不保留键顺序,只支持单个索引列,也不能检查唯一性。对于较大值,较小的索引条目可能有利于某些等值负载,但读取堆记录进行复核、访问桶和溢出页仍有成本。数据结构的名字本身不能证明某次真实查询谁会胜出。 坏掉的样子 把 B-tree 换成哈希索引,可能失去原有的范围或有序访问路径,也会失去 IS NULL 的索引访问能力:PostgreSQL 哈希索引不支持这个条件。更严重的是,把承担唯一性要求的索引替换成哈希索引,因为后者根本无法提供对应约束。即使点查偶尔更快,也不能用性能收益替代数据完整性契约。 空值语义需要单独核对。默认唯一 B-tree 允许多个空值,因为唯一性检查把它们视为彼此不同。如果应用要求最多只能出现一个空值,应在唯一约束或索引上明确使用 NULLS NOT DISTINCT,而不能从“唯一”两个字直接推导。索引支持怎样访问,与它强制哪些数据规则,是关联但不同的两项决定。验证时必须分别看查询结果和约束行为。 ...

二月 7, 2027 · 2 分钟 · XB Field Notes