数据库因表很小而按顺序把每一行都读一遍。

小表上全表扫描反而赢

索引是一场下注:你先付出写入与存储的代价,换来读取时能跳过表的大部分。可当一条查询真的需要表的大部分时,这场下注就输了——引擎会为每一行都下一次 B 树,而不是把那些页按顺序读一遍。 优化器给两条路线都算了账,所以真正有意思的问题不是"原则上谁更快",而是交叉点落在哪里。表小并不等于工作量小,你在 1000 行时看到的计划,也不是 1000 万行时该期待的计划。 真正的货币是页,不是行 顺序扫描按物理顺序读表的页,一页能拿到很多行。PostgreSQL 用 seq_page_cost(默认 1.0)对 random_page_cost(默认 4.0)来给这件事定价:非顺序取到的页按顺序页的四倍计价,而把 random_page_cost 相对 seq_page_cost 调低,正是让优化器开始偏好索引扫描的那个开关。文档也指出,如果整个数据库都能装进缓存,把两者设成相等是合理的。 机械盘上一次随机寻道以毫秒计,而顺序读完一整条磁道几乎免费;NVMe 把这个差距压到微秒级,但页数本身并没有变,所以代价模型里「页数」这一项始终在。既然货币是页,行数就是一个很差的规模代理。同样是 1000 行的两张表:4 字节负载的那张是 12 页,4000 字节负载的那张是 1010 页。在 sqlite 3.53 上配 2 MB 页缓存,命中约 90% 行的宽谓词查询在窄表上 0.02 ms 跑完,在宽表上要 1.30 ms——行数完全一样,差了 65 倍。只有其中一张算"小"。 ...

九月 16, 2027 · 2 分钟 · XB Field Notes
一份支持前导键范围与后列查找路径的多列有序目录。

组合索引贴合多列的查询

复合 B-tree 索引先按第一个键排序,再在第一个键相同的组内按下一个键排序。这种元组顺序可以让“某个租户的一段时间”很容易定位。应理解的是条件能缩小多少索引扫描范围,而不是禁止查询后面的列,也不是要求 WHERE 中的条件必须按建索引的顺序书写。这几个概念混在一起,会导致错误的索引设计。 排序的是键,不是 SQL 条件 对于 (tenant, happened) 索引,tenant 等值条件加 happened 范围条件描述了树中的一小段。PostgreSQL 可以定位这一段,而不用检查所有租户。即使把时间条件写在 WHERE 的前面、租户条件写在后面,逻辑上的合取关系也没有改变。数据库定义中的键顺序与查询文本的书写顺序,是两件不同的事情,不能拿文本位置判断索引是否可用。 通常,前导列的等值条件和第一个非等值键上的范围条件,决定必须扫描的区间。更后面的键条件仍可在索引中检查,即使不能继续缩小一个简单连续区间,也可能减少访问堆表的次数。调换索引键会改变这些机会,但不意味着某种排列在所有查询中都完全无用。需要结合查询实际要求的范围和返回列判断。 后面的键仍可能有用 PostgreSQL 18 在合适时可以使用 B-tree skip scan:如果遍历缺少等值条件的前导键取值,并在每组内重新搜索,比读取大部分索引更便宜,就可能采用这种路径。只有少数几个租户时,仅过滤 happened 也可能通过各租户分组完成查找;前导键不同值很多时,这种重复搜索可能太贵,规划器便可能选择顺序扫描或其他索引。 即使没有合算的 skip scan,某些只涉及后面键的条件仍可能扫描复合索引。问题是成本、覆盖范围与选择性,不是 SQL 是否合法,也不是索引必然被忽略。不能把“最左前缀”这种便于记忆的说法扩展成绝对禁令,尤其在目标数据库明确支持其他访问路径时。版本和真实计划必须优先于口号。 把排序和工作负载一起考虑 固定 tenant 后,后面的 happened 顺序可以支持该租户的时间排序。但跨多个租户时,索引先按租户分组,不会直接提供全局时间顺序。Bitmap 扫描也不保留索引顺序,仍可能需要单独排序。因此应看完整计划,而不是看到索引包含排序列就断言不会发生 Sort。LIMIT 对尽快返回前几行的需求也会改变最合适的路径。 ...

九月 15, 2027 · 2 分钟 · XB Field Notes
主库喂给一个承接查询的只读副本。

只读副本服务查询需求

只读副本允许一份数据库服务查询,另一份数据库处理写入。报表、搜索或其他适合的读取,可以由此离开写入节点,但把连接路由到某个地址只是设计的一部分。应用还必须说明返回结果需要包含哪些已经提交的变更。本文采用 PostgreSQL 物理热备库,它在正常恢复模式下可以读取,但不能接受普通业务写入。 坏掉的样子 异步复制下,成功写入之后立刻查询副本,仍然可能读到旧值。用户刚修改账户并刷新页面,之前的内容却又出现了。此时副本可能连接健康、日志接收持续推进,只是相关提交还没有被重放。如果新鲜度检查只看接收位置、不看回放位置,就检查了错误的完成边界。 即使回放已经推进,旧事务快照仍然可能影响结果。可重复读事务本来就需要在恢复继续进行时保留自己的视图。等待副本应用某次写入,不会顺便刷新这个事务。因此,要实现写后读到本次修改,既需要足够的回放进度,也需要查询使用在这个进度条件满足之后取得的新快照,两个条件缺一不可。 新鲜度承诺也需要限定范围。包含这个用户较早的一次写入,不等于保证其他地方不存在更新的事务。若读取余额是为了批准下一笔扣款,仍然必须在写入路径中进行事务性核对。副本提供的查询容量,不能替代保护业务动作的并发控制,也不能使一个只读结果天然变成后续写入的有效授权。 快速验证 在 Linux 环境中以非超级操作系统账户运行脚本,并准备 PostgreSQL 18 服务端和客户端程序。本次使用 PostgreSQL 18.6。脚本建立私有主备集群,通过 Unix 套接字连接,不打开网络监听。一个命名管道保持读会话存活,让实验能够明确控制事务快照边界,无需额外安装数据库驱动。 #!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-read-replica.XXXXXX) mkdir "$lab/socket" primary=$lab/primary standby=$lab/standby reader_pid='' reader_open=0 cleanup() { if test "$reader_open" = 1; then exec 3>&-; fi if test -n "$reader_pid"; then wait "$reader_pid" || :; fi for cluster in "$standby" "$primary"; do if test -f "$cluster/postmaster.pid"; then pg_ctl -D "$cluster" -m fast -w stop >/dev/null fi done } trap cleanup EXIT initdb -D "$primary" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null cat >> "$primary/postgresql.conf" <<CONF listen_addresses = '' unix_socket_directories = '$lab/socket' port = 56432 wal_level = replica wal_keep_size = '64MB' CONF pg_ctl -D "$primary" -l "$lab/primary.log" -w -t 15 start >/dev/null export PGHOST=$lab/socket PGPORT=56432 PGDATABASE=postgres PGUSER=$(id -un) export PGCONNECT_TIMEOUT=2 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' CREATE TABLE account (id integer PRIMARY KEY,balance integer); INSERT INTO account VALUES (1,100); SQL pg_basebackup -D "$standby" -R -X stream -c fast -w cat >> "$standby/postgresql.conf" <<CONF port = 56433 hot_standby = on CONF pg_ctl -D "$standby" -l "$lab/standby.log" -w -t 15 start >/dev/null mkfifo "$lab/reader.sql" PGPORT=56433 PGAPPNAME=replica_snapshot psql -X -v ON_ERROR_STOP=1 -qAt <"$lab/reader.sql" >"$lab/reader.log" 2>&1 & reader_pid=$! exec 3>"$lab/reader.sql" reader_open=1 printf '%s\n' 'BEGIN ISOLATION LEVEL REPEATABLE READ;' 'SELECT balance FROM account WHERE id=1;' >&3 attempt=0 while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT EXISTS (SELECT FROM pg_stat_activity WHERE application_name='replica_snapshot' AND state='idle in transaction' AND backend_xmin IS NOT NULL)")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || exit 1 sleep 0.1 done PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_wal_replay_pause()' >/dev/null attempt=0 while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_get_wal_replay_pause_state()='paused'")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || exit 1 sleep 0.1 done psql -X -v ON_ERROR_STOP=1 -qAt -c 'UPDATE account SET balance=80 WHERE id=1' target=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_current_wal_lsn()') printf 'primary_balance ' psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT balance FROM account WHERE id=1' printf 'paused_replica_balance ' PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT balance FROM account WHERE id=1' for attempt in 1 2 3 4 5; do test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_last_wal_replay_lsn()>='$target'::pg_lsn")" = f sleep 0.1 done echo fresh_read_gate_timed_out PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_wal_replay_resume()' >/dev/null attempt=0 while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_last_wal_replay_lsn()>='$target'::pg_lsn")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 150 || exit 1 sleep 0.1 done test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT balance FROM account WHERE id=1')" = 80 echo fresh_snapshot_after_replay_80 printf '%s\n' 'SELECT balance FROM account WHERE id=1;' 'COMMIT;' >&3 exec 3>&- reader_open=0 wait "$reader_pid" reader_pid='' test "$(cat "$lab/reader.log")" = "$(printf '100\n100')" echo old_repeatable_read_snapshot_still_100 printf 'lab_directory %s\n' "$lab" 初始余额是一百。备库上的可重复读事务先执行一次查询,再保持打开。脚本核对这个后端确实处于事务内空闲状态并持有快照,随后请求暂停日志回放,并等待实际暂停。主库把余额提交为八十,捕获提交之后的日志位置,作为这次读取需要达到的进度目标。 ...

七月 12, 2027 · 3 分钟 · XB Field Notes
两个事务按一种顺序取得编号,却按另一种顺序提交。

顺序标识会透露分配规律

顺序标识让相邻的值很容易被猜到。看到第四千号订单,观察者就知道可以从附近的编号继续探测;持续收集编号,也可能看出分配活动的大致变化。但这不代表系统恰好有四千张订单,不代表每个编号都对应成功购买,也不代表最大编号最后提交。想得出这些结论,还需要序列本身没有记录的业务事实。 坏掉的样子 PostgreSQL 序列取值不会跟随普通事务回滚而退回。一次最终放弃的插入,也可能永久消耗一个号码。删除记录、缓存预留区间、显式修改序列以及多个并发写者,都会进一步拉开计数器与当前行数的距离。因此,用最大编号充当客户数或销售数量,即使没有攻击者参与,也会生成错误的经营报表。号码可以提供线索,却不能直接当作精确统计结果。 分配顺序同样不等于提交顺序。事务甲可能先取得较小编号,随后停在那里;事务乙取得较大编号后,反而先完成提交。如果消费者把递增主键当作完整的已提交事件流,就可能先处理较大的行并推进位置,而较小的行此时还不可见。等它后来提交,消费者已经越过了它。数字主键不能直接替代变更捕获位置,也不能证明所有业务事件的先后关系。 随机标识让逐个递增枚举变得困难,却不会自动实施访问控制。UUID 仍可能出现在日志、分享链接或其他接口返回值里,拿到它的人仍须通过对象权限检查。第四版 UUID 使用随机位;第七版在开头放置毫秒时间戳,目标之一是支持时间排序,因此不能把它的前缀说成随机位,也不能把其中的时间当作数据库提交时间。降低可猜测性与确认谁有权读取,是两项不同职责。 快速验证 准备一个可丢弃的 PostgreSQL 18 数据库,打开两个管理员 psql 会话,分别称为甲和乙。下文提取 UUID 信息的函数要求此版本。确认实验模式名和两个角色名尚未使用,初始保持自动提交,隔离级别采用通常的读已提交。先在乙中建立数据: CREATE SCHEMA article_ids; CREATE ROLE article_id_alice NOLOGIN; CREATE ROLE article_id_bob NOLOGIN; SET search_path=article_ids,public; CREATE TABLE objects ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, public_id uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE, owner text NOT NULL, label text NOT NULL ); 在甲中取出第一个编号,但暂时不要提交: ...

五月 29, 2027 · 2 分钟 · XB Field Notes
两张表由一条约束相连;该约束拒绝存下失效的引用。

外键让引用保持诚实

外键让关系在普通数据库写入中得到强制校验。任务可以引用已有项目,即使导入脚本或新应用路径忘了提前检查,数据库也能拒绝不存在的项目标识。这条约束定义的是引用完整性,并不决定某个业务操作选中的项目是否正确。明确这个范围,才能既利用数据库保证,也不把业务判断错误地交给外键。 把关系写准确 引用键指向父表中适用的唯一列,通常是主键。多条子记录可以引用同一父行,因此,只有外键并不代表一对一关系,也不要求每个父行必须拥有子记录。如果业务还需要唯一性或其他规则,应单独声明,不能从“存在引用”推导出实际上没有约束的基数关系。 是否允许空值,是另一项决定。单列外键通常允许 SQL NULL,表示没有引用;每个子记录都必须有父记录时,应另外声明 NOT NULL。对于复合键,PostgreSQL 默认的 MATCH SIMPLE 在任意引用列为空时,就允许该行不执行匹配;MATCH FULL 则要求全部为空或全部匹配。因此,只填写一部分的复合引用,在默认模式下不一定无效。 键的作用域也必须准确。如果标识只在某个租户内部唯一,就应引用适当的“租户与标识”组合键,不能默认其他租户里存在相同编号就算正确。即使引用范围定义正确,外键仍不是授权检查:某个项目确实存在,也可能不属于当前用户或当前工作流应操作的对象。 父行删除需要生命周期规则 NO ACTION 是 PostgreSQL 默认的删除动作。在通常的立即检查模式下,删除仍被引用的父行会失败,除非语句结束时约束依然满足。如果约束声明为可延迟,NO ACTION 可以推迟检查;RESTRICT 则不允许延迟这项删除限制。两者不能在任何场景下都当成同义词,迁移或批量操作尤其需要检查其差别。 CASCADE 会在父行删除时自动删除引用它的子行,适合没有独立生命周期的组成部分,但删除范围可能沿多层关系扩张。SET NULL 保留子行并清空引用,前提是空值及其他约束允许这样做。SET DEFAULT 也必须留下有效引用,不能因为设置了默认值就绕开完整性规则。 ON UPDATE 则单独规定被引用键变化时的动作。更新级联会把新键复制到引用行,并不代表覆盖子记录其他无关字段。应根据所有权与保留要求选择动作。为了让报错消失,就把限制删除改为级联,可能把本来有价值的完整性失败变成意外数据删除;约束动作不是单纯的语法偏好。 坏掉的样子 应用先查父行是否存在,仍可能与另一个事务删除父行发生竞态。数据库约束会协调真正写入,因此,应把它作为关系的权威校验,而不是只依赖更早的读取。高权限操作禁用执行,或迁移尚未验证旧数据,是不同状态,不能默认它们拥有已经完整启用并验证的外键保证。 写入成本也取决于负载。删除父行可能需要查找大量子行、获取锁并执行级联。PostgreSQL 会为适用的被引用唯一键提供索引,但不会自动给子表引用列建立索引。需要检查这些访问路径和删除关系图的规模。关系有声明,也不保证优化器一定消除关联,更不代表所有 JOIN 都变成低成本操作。 快速验证 把下面内容保存为 foreign-key-actions.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f foreign-key-actions.sql。实验用临时表分别表示任务、随项目存在的文件和需要保留的历史,并选择不同删除动作。末尾回滚会清除全部对象和修改。 ...

五月 25, 2027 · 2 分钟 · XB Field Notes
把查询计划画成从顺序扫描经索引到结果的路线,并高亮慢的那一步。

EXPLAIN 计划揭示查询路径

慢查询首先是一个路线问题。EXPLAIN 逐节点画出规划器选择的路径,让你针对真正耗时的步骤去修,而不是凭猜测。 把计划当作路线来读 EXPLAIN 输出是一棵树:根节点是最终答案,真正的活是子节点干的。每个节点有操作名、行数估算和成本,真正值得盯的是离群点——行数估算远大于实际返回数的大扫描,或者与实际执行差距巨大的估算。读树的顺序很关键:要顺着成本最大的节点找,而不是盯着模式里第一个出现的那层。把 EXPLAIN 和 EXPLAIN ANALYZE 并排看,能直接看到规划器「猜的」和真实「跑出来」的差距,慢查询多半就藏在那里。 统计信息决定路线 有索引只是「有了选项」,不等于「会走到那里」。规划器是根据观测到的统计量——行数、可空性、列值分布——来选路线,所以一次 autovacuum、一次大量导入、一次缓存命中率变化,都可能让同一条 SQL 改道。卡在热点路径上的查询,应该在数据画像变化时重新分析,而不是只在建的时候看一次。统计信息过期时,一个 ANALYZE 有时比一顿索引微调改写更能改变计划。把计划当成需要拿真实 EXPLAIN 数据反复复核的证据,而不是背下来就完事的快照。 确认路线真的变了 把计划塞进改动循环:先留下基线 EXPLAIN ANALYZE,加索引或用改写后的 SQL,确认路线真的移动了再宣布成功。如果目标节点还是那个大的扫描、成本结构没变,那这改动无论听起来多顺,都只是表面功夫。经验再丰富,也不如前后两份计划来得实在;真实的执行耗时、取行数也要一并记录。新计划生效后,把前后对比放进合入请求里,让这次收益可追溯、可复现。 坏掉的样子 统计信息过期会让规划器走错路。一次大批量导入之后,如果很久没有跑过 ANALYZE,规划器可能还以为表里只有几千行,而实际已经有几百万行,于是继续选择只在小数据量下才划算的索引或嵌套循环。EXPLAIN ANALYZE 会露出马脚:估算行数与实际行数差出数量级。先跑 ANALYZE 再对比这两个数字,确认路线是基于新鲜画像选出来的,而不是急着加索引。数据分布变化之后不复核计划,再合理的索引也可能建在错误的假设上。 预编译语句会把某一次参数的计划复用到所有参数上。围绕高区分度的首个参数缓存下来的计划,对它很快,对匹配半张表的值却很慢;而你用 EXPLAIN 拿字面量测出的计划,往往并不是线上真正绑定的那个值。排查时要把区分度最高和最低的两个极端值都测一遍,并确认到底命中了定制计划还是通用计划,再下结论。只测一个最顺的值,就等于只看了一半的路线图。 隐式转换会悄悄让索引退休。拿 varchar 列直接和数字比较、排序规则不一致,或者在列上套 LOWER、日期截断,规划器只能全表扫描再过滤,索引明明存在却用不上。症状很固定:Seq Scan 上被过滤掉的行数远大于最终返回行数。修法是把调用处的类型改对,或者补一个匹配表达式的函数索引,让写法和索引站在同一边。类型不对时加再多索引,也只是给用不上的路线添摆设。 只看 cost 是最后一个陷阱。cost 是无量纲的规划估算,不是执行耗时;估算最便宜的计划,实际可能打出天文数字的共享缓冲命中,或者把排序溢出到临时文件。务必用 EXPLAIN (ANALYZE, BUFFERS) 确认,把实际耗时、buffers、被过滤掉的行数和 cost 并排读,几个数字一致向好,才能说这条路线真的健康。脱离 buffers 谈 cost,和脱离实际行数谈估算一样,都是在看路线的广告而不是路况。 ...

五月 21, 2027 · 2 分钟 · XB Field Notes
绑定文本保持为 SQL 取值,具体匹配方式仍由所选运算符决定。

查询参数守住取值与 SQL 的边界

参数化查询通过不同的接口参数传递数据库指令与实际取值。数据库把指令解释为 SQL,再把各个参数用于需要值的位置。这种边界既适用于 SQLite 这样的嵌入式引擎,也适用于客户端连接服务端的数据库;它不依赖一次网络交互,更不要求应用显式管理命名的服务端语句。重点在真实调用如何传值,而不是有没有某个名为“预编译”的包装函数。 坏掉的样子 字符串拼接会拆掉这条边界。姓名中的一个引号可能提前结束原本的字面量,后面的文本就有机会变成条件或注释。仅仅禁用分号无法修复问题,因为攻击者可以在不追加第二条语句的情况下,改变同一条查询的含义。同样,一个接口即使拒绝一次执行多条语句,也仍然需要正确绑定参数,不能把这个限制当作注入防护已经完成的证据。 绑定可以防止值变成 SQL 语法,但所选运算符仍会按自身规则解释这个值。对于 LIKE,百分号匹配一段字符,下划线匹配一个字符。因此,一个经过绑定的搜索串,仍可能比用户期望的普通文本搜索匹配更多记录。这属于模式语义的问题,不说明绑定失效,却说明“输入不会执行成 SQL”还不等于“搜索符合产品要求”。 另外一些错误也不会造成注入,却会给出错误结果。把逗号分隔的文本交给一个 IN 占位符,它仍是一个标量,不会自动展开成列表。把列名当作值绑定,得到的仍是文本,不是列引用。因此,类型检查、允许的查询结构与业务权限,都需要在绑定之外独立成立。 快速验证 直接运行下面的完整 Python 脚本,只需要标准库 sqlite3。它创建内存数据库作为样本,完成断言后关闭连接。记录中的运行环境为 Python 3.14.4 与 SQLite 3.53.0。故意保留的脆弱查询仅用于比较三个虚构用户的结果,不连接任何真实业务数据: import sqlite3 with sqlite3.connect(':memory:') as db: db.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)') db.executemany('INSERT INTO users VALUES (?, ?)', [(1, 'Alice'), (2, 'A_1'), (3, 'Ab1')]) payload = "' OR 1=1 --" # Deliberately vulnerable comparison, only against this in-memory fixture. unsafe = "SELECT id FROM users WHERE name='" + payload + "' ORDER BY id" assert sorted(db.execute(unsafe).fetchall()) == [(1,), (2,), (3,)] assert db.execute('SELECT id FROM users WHERE name=?', (payload,)).fetchall() == [] print('concatenated: 3 rows; bound: 0 rows') for value in ("O'Reilly", r'a\b', 'x; DROP TABLE users; --'): assert db.execute('SELECT ?', (value,)).fetchone() == (value,) assert db.execute('SELECT count(*) FROM users').fetchone() == (3,) print('quote, backslash, semicolon: literal round trips') assert db.execute('SELECT id FROM users WHERE name LIKE ? ORDER BY id', ('A_1',)).fetchall() == [(2,), (3,)] literal = 'A_1' escaped = literal.replace('!', '!!').replace('%', '!%').replace('_', '!_') assert db.execute("SELECT id FROM users WHERE name LIKE ? ESCAPE '!' ORDER BY id", (escaped,)).fetchall() == [(2,)] assert db.execute('SELECT id FROM users WHERE name=?', (literal,)).fetchall() == [(2,)] print('bound LIKE: 2 rows; escaped LIKE and equality: 1 row') assert db.execute('SELECT id FROM users WHERE id IN (?)', ('1,3',)).fetchall() == [] assert db.execute('SELECT id FROM users WHERE id IN (?,?) ORDER BY id', (1, 3)).fetchall() == [(1,), (3,)] assert db.execute('SELECT ? FROM users LIMIT 1', ('name',)).fetchone() == ('name',) assert db.execute('SELECT id FROM users ORDER BY id LIMIT ? OFFSET ?', (1, 1)).fetchall() == [(2,)] print('IN needs separate values; bound column is a literal; LIMIT/OFFSET bind') db.close() 拼接查询返回三个编号,绑定查询面对相同输入则没有任何匹配。恶意输入中的注释还会删掉原先追加的排序部分,因此断言先对脆弱查询的结果排序,再比较编号集合;实验没有依赖未声明顺序的查询恰巧按插入顺序返回。这也展示了注入会改变原指令的多个部分,不只是多匹配一行那么简单。 ...

五月 17, 2027 · 2 分钟 · XB Field Notes
几次写入要么一起提交,要么作为一体回滚。

事务让提交是原子的

事务让相关数据库变更一起提交,或者一起放弃。如果一次操作要求扣减一个账户、增加另一个账户,并写入流水,那么这三步就应属于同一个决定。PostgreSQL 通过 BEGIN、COMMIT 和 ROLLBACK 提供这个边界,但应用仍须把正确的语句放进去。原子执行不会自动发现漏掉的入账,也不会替业务补出一个没有编写的校验。事务保护的是被明确纳入其中的工作,而不是开发者脑中尚未落实的意图。 决定一个工作单元包含什么 普通 psql 在没有显式事务块时使用自动提交,每条语句各自处于一个事务里。因此,扣款可能已经成功提交,之后的流水插入才失败。显式分组改变了失败边界:其他事务看不到这组操作尚未提交的表数据中间状态;回滚直接丢弃其事务性变更,而不是依赖应用事后再发几条补偿更新来还原。 原子性与隔离性、持久性并不是同一件事。如果隔离级别或加锁协议不足,一组完整提交的操作仍可能基于错误的并发判断。数据库确认提交后的持久保障,也受到实际持久化配置影响。仅在代码里找到 BEGIN,还不能回答这些问题。同样,PostgreSQL 的每条语句本来就运行在事务中,读取也不例外;显式读事务可以用于重复读取快照或事务局部设置,不能一概视为无用开销。 坏掉的样子 PostgreSQL 中未处理的 SQL 错误通常会让显式事务块进入失败状态。客户端必须整体回滚,或者回滚到适用的早期保存点,才能恢复正常执行。关闭客户端的遇错停止功能,并不会修复服务器端已经失败的事务。如果把这种连接直接还给连接池,下一个请求可能接着遇到失败状态,而不是从干净边界开始。 服务器明确拒绝提交,与提交确认在网络中丢失,是不同证据。如果连接在等待 COMMIT 回复时断开,客户端可能不知道数据库是否已先完成提交。盲目重发支付可能导致重复操作。持久的操作标识和结果核对用于处理这种不确定性;普通回滚语义不能证明一个暂时无法联系的服务器究竟完成了什么。不能把所有客户端报错都解释成“肯定什么也没发生”。 快速验证 在可丢弃的 PostgreSQL 18 数据库中打开新的 psql -X -v ON_ERROR_STOP=1 会话,运行下面代码。临时表让这些账户编号只属于本会话。代码仅在两个刻意制造的失败周围关闭遇错停止,以便观察 SQLSTATE,随后立即重新开启。 \set ON_ERROR_STOP on CREATE TEMP TABLE accounts (id integer PRIMARY KEY,balance integer CHECK(balance>=0)); CREATE TEMP TABLE ledger (id integer PRIMARY KEY,amount integer CHECK(amount>0)); INSERT INTO accounts VALUES (1,100),(2,100); BEGIN; UPDATE accounts SET balance=balance-20 WHERE id=1; UPDATE accounts SET balance=balance+20 WHERE id=2; INSERT INTO ledger VALUES (1,20); COMMIT; SELECT id,balance FROM accounts ORDER BY id; BEGIN; UPDATE accounts SET balance=balance-30 WHERE id=1; \set ON_ERROR_STOP off INSERT INTO ledger VALUES (1,30); \echo duplicate_error :SQLSTATE SELECT balance FROM accounts WHERE id=1; \echo aborted_error :SQLSTATE \set ON_ERROR_STOP on ROLLBACK; SELECT id,balance FROM accounts ORDER BY id; SELECT count(*) AS ledger_rows FROM ledger; DO $$ BEGIN IF (SELECT array_agg(balance ORDER BY id) FROM accounts) IS DISTINCT FROM ARRAY[80,120] OR (SELECT count(*) FROM ledger) <> 1 OR (SELECT amount FROM ledger WHERE id=1) IS DISTINCT FROM 20 THEN RAISE EXCEPTION 'partial transfer survived'; END IF; END $$; DROP TABLE ledger,accounts; 第一个事务提交后,余额是八十和一百二十,流水表有一行。第二个事务又扣减三十,接着尝试复用流水编号一。PostgreSQL 18.6 返回唯一性错误 23505;紧跟着的普通读取返回 25P02,证明即使客户端继续发送命令,服务器中的事务也已经处于失败状态。这个阶段看不到正常读取结果,不能误以为失败只影响那一条插入。 ...

五月 12, 2027 · 1 分钟 · XB Field Notes