示意图对照直接遍历表行,以及沿非覆盖索引相邻条目前进并回表获取负载。绘制五行仅为示意,不是实验行数或耗时结果。

小表上全表扫描可能更快

索引用存储和写入成本,换取减少读取工作的机会。表很小,或者谓词选中大部分数据时,全表扫描可能更快,但行数与选择性都不能单独决定胜负。查询需要哪些列、排序要求、缓存状态、行宽和可用访问路径,都会改变比较结果。首先明确实际查询必须返回什么,再判断哪种访问方法做了更多有用工作。 看清两条路径实际做什么 B 树范围扫描并不是每匹配一个索引条目,都重新从根节点下降。定位范围起点后,可以沿相邻条目继续前进。非覆盖索引还可能需要回表取得缺少的列;宽谓词下,这部分工作可能成为主要成本。覆盖索引能够省去这些读取,因此,即使查询命中多数行,索引也仍有可能更划算,不能把宽谓词与索引无用直接画等号。 页很重要,但不是唯一成本。PostgreSQL 默认的 seq_page_cost 与 random_page_cost 是优化器使用的相对估计,不是毫秒,也不是每返回一行就机械收取一次的费用。缓存、数据相关性、CPU 工作、位图扫描与仅索引扫描,都影响计划选择。SQLite 普通表把行放在按 rowid 组织的 B 树中,与 PostgreSQL 堆表存储不同,不能直接把一种引擎测得的性能比例套到另一种引擎。 SQLite 的 PRAGMA page_count 返回整个数据库文件的页数,包括索引和其他对象,不能当作某个指定表的独立页数。逻辑页遍历也不保证磁盘上的连续物理读取。比较时应保持统计对象范围一致,记录页大小、模式、负载宽度和缓存条件,不能只报告行数,再把其他成本差异全部归因于扫描或索引。 保留历史测量的适用范围 此前笔记记录了 SQLite 3.53、二兆字节页缓存下的结果。宽谓词的扫描与索引时间,在一千行时为 0.04 与 0.05 毫秒,在五万行时为 1.79 与 6.61 毫秒,在百万行时为 44.37 与 531.18 毫秒。同一百万行表的高选择性查询,索引为 0.61 毫秒,扫描为 17.88 毫秒。相同行数的窄、宽负载样本还分别记录了十二与一千零十页,以及 0.02 与 1.30 毫秒。 ...

九月 16, 2027 · 2 分钟 · XB Field Notes
在租户列在前的 B-tree 中,时间值只在各租户组内排序。tenant 等于 2 且 happened 在 100 到 120 时,仅选中租户 2 中连续的 102、106、110、114、118。

组合索引贴合多列的查询

复合 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
回放覆盖与新快照两道所需条件汇合,得到余额 80。

只读副本服务查询需求

只读副本允许一份数据库服务查询,另一份数据库处理写入。报表、搜索或其他适合的读取,可以由此离开写入节点,但把连接路由到某个地址只是设计的一部分。应用还必须说明返回结果需要包含哪些已经提交的变更。本文采用 PostgreSQL 物理热备库,它在正常恢复模式下可以读取,但不能接受普通业务写入。 坏掉的样子 异步复制下,成功写入之后立刻查询副本,仍然可能读到旧值。用户刚修改账户并刷新页面,之前的内容却又出现了。此时副本可能连接健康、日志接收持续推进,只是相关提交还没有被重放。如果新鲜度检查只看接收位置、不看回放位置,就检查了错误的完成边界。 即使回放已经推进,旧事务快照仍然可能影响结果。可重复读事务本来就需要在恢复继续进行时保留自己的视图。等待副本应用某次写入,不会顺便刷新这个事务。因此,要实现写后读到本次修改,既需要足够的回放进度,也需要查询使用在这个进度条件满足之后取得的新快照,两个条件缺一不可。 新鲜度承诺也需要限定范围。包含这个用户较早的一次写入,不等于保证其他地方不存在更新的事务。若读取余额是为了批准下一笔扣款,仍然必须在写入路径中进行事务性核对。副本提供的查询容量,不能替代保护业务动作的并发控制,也不能使一个只读结果天然变成后续写入的有效授权。 回放覆盖不会刷新已有快照。100 与 80 是待运行验证的 PostgreSQL 夹具值,不是容量基准。 快速验证 在 Linux 环境中以非超级操作系统账户运行脚本,并准备 PostgreSQL 18 服务端和客户端程序。原文报告使用 PostgreSQL 18.6;本轮源文审阅未重跑夹具。脚本建立私有主备集群,通过 Unix 套接字连接,不打开 TCP 监听。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
两条会话时间线显示甲先分配 1 却后提交;乙后分配 2 却先提交,在甲提交前仅看见 2。原文实验,未重跑。

顺序标识会透露分配规律

顺序标识让相邻的值很容易被猜到。看到第四千号订单,观察者就知道可以从附近的编号继续探测;持续收集编号,也可能看出分配活动的大致变化。但这不代表系统恰好有四千张订单,不代表每个编号都对应成功购买,也不代表最大编号最后提交。想得出这些结论,还需要序列本身没有记录的业务事实。 坏掉的样子 PostgreSQL 序列取值不会跟随普通事务回滚而退回。一次最终放弃的插入,也可能永久消耗一个号码。删除记录、缓存预留区间、显式修改序列以及多个并发写者,都会进一步拉开计数器与当前行数的距离。因此,用最大编号充当客户数或销售数量,即使没有攻击者参与,也会生成错误的经营报表。号码可以提供线索,却不能直接当作精确统计结果。 分配顺序同样不等于提交顺序。事务甲可能先取得较小编号,随后停在那里;事务乙取得较大编号后,反而先完成提交。如果消费者把递增主键当作完整的已提交事件流,就可能先处理较大的行并推进位置,而较小的行此时还不可见。等它后来提交,消费者已经越过了它。数字主键不能直接替代变更捕获位置,也不能证明所有业务事件的先后关系。 随机标识让逐个递增枚举变得困难,却不会自动实施访问控制。UUID 仍可能出现在日志、分享链接或其他接口返回值里,拿到它的人仍须通过对象权限检查。第四版 UUID 使用随机位;第七版在开头放置毫秒时间戳,目标之一是支持时间排序,因此不能把它的前缀说成随机位,也不能把其中的时间当作数据库提交时间。降低可猜测性与确认谁有权读取,是两项不同职责。 分配、提交顺序、行数与授权是不同事实。所属策略改变访问结果时 UUID 没有变化。仅依据原文实验,本次未重跑。 快速验证 准备一个可丢弃的 PostgreSQL 18 数据库,打开两个管理员 psql 会话,分别称为甲和乙。这里要求第十八版,是因为使用了 uuidv7() 以及第七版 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
项目 1 的删除策略树显示任务 10 阻止删除、文件 20 被删除、历史 30 保留并清空引用。这是策略结果对照,不是受阻删除同时产生的效果。

外键让引用保持诚实

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

五月 25, 2027 · 2 分钟 · XB Field Notes
同尺度成本条对照估算时索引 25 与顺序 18,000,以及给定实际选择率时索引 25,204 与顺序 18,000。数值来自玩具公式,不是测得耗时。

EXPLAIN 计划揭示查询路径

EXPLAIN 展示数据库为语句选择的执行计划,可以帮助定位昂贵工作与错误估算。不过,慢请求也可能涉及锁等待、网络传输或计划之外的应用行为。应利用计划检验一个明确假设,而不是认定每次性能问题都需要换扫描方式或增加索引。计划是诊断证据的一部分,不是整条请求链路的完整时间账本。 读计划树时避免重复计数 在 PostgreSQL 中,叶节点通常扫描关系,上层节点负责连接、排序、聚合或修改行。上层也会做真实工作,不能说全部工作都在叶子。上层成本包含子节点成本,把所有显示的 cost 相加会重复计算。rows 估算表示节点输出的行数,不一定是它检查过的全部行;过滤可以造成输入与输出差距,这本身并不能证明规划错误。 普通 EXPLAIN 只有估算;EXPLAIN ANALYZE 会真正执行语句并补充观察。loops 大于一时,actual rows 与时间通常是每次执行的平均值,要结合循环次数解释。比较估算时,应对照同一节点的实际输出,而不是无关的整表规模。buffers 也包含子节点活动,并不是去重后的页面计数,不能把所有层的数字直接相加当作总读取量。 统计信息影响选择 行数、分布、相关性、查询结构与成本设置都会影响规划。ANALYZE 更新统计信息,但行数估算严重错误,并不唯一指向统计过期。抽样误差、相关谓词、缺少扩展统计和参数选择率,也可能产生偏差。适当时刷新相关统计,再看差距是否真正改善,不要把一次较大的估算偏差当作已经完成根因诊断。 规划器也不是看见刚刚测到的缓存命中率变化,就自动决定改道。effective_cache_size 等设置表达缓存假设;真实缓存冷热可以改变执行时间,即使计划完全相同。比较结果时,应记录数据规模、参数、相关设置和测量条件。查询需要大部分表数据时,顺序扫描可能正是合理选择,不能把出现 Seq Scan 当成索引失效的通用判据。 坏掉的样子 预编译语句要按具体版本解释。PostgreSQL 自动策略可以比较定制计划与通用计划,并不是简单把首次参数的计划固定给所有后续值。应使用有代表性的参数执行 EXPLAIN EXECUTE,检查计划里出现的是常量还是参数占位符。不同数据库产品的缓存机制不同,不能把某一个产品的解释直接套到另一个实现。 函数、类型转换和排序规则,可能让某个索引无法匹配谓词,但不代表一定强制全表扫描。表达式索引可以支持 lower(column),一些跨类型运算也仍可使用索引。PostgreSQL 遇到不支持的 varchar 与整数比较时,还可能直接拒绝语句,而不是静默转换后扫描。决定改写之前,应检查真实运算符、类型和索引条件,避免凭语法外观推断执行路径。 EXPLAIN ANALYZE 不是无副作用的查看命令。写入、触发器、锁和被调用函数都会实际执行;事务回滚也无法撤销所有效果,例如已经消费的序列值或外部动作。应使用受控数据及合适的测试环境,即使 SELECT 也可能调用具有副作用的函数。诊断前评估负载,不能因为命令以 EXPLAIN 开头,就自动视为生产环境里的安全读取。 ...

五月 21, 2027 · 2 分钟 · XB Field Notes
绑定的 LIKE 模式 A_1 分叉匹配 A_1 与 Ab1,因为下划线匹配一个字符。本地 SQLite 内存实验。

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

参数化查询通过不同的接口参数传递数据库指令与实际取值。数据库把指令解释为 SQL,再把各个参数用于需要值的位置。这种边界既适用于 SQLite 这样的嵌入式引擎,也适用于客户端连接服务端的数据库;它不依赖一次网络交互,更不要求应用显式管理命名的服务端语句。重点在真实调用如何传值,而不是有没有某个名为“预编译”的包装函数。 坏掉的样子 字符串拼接会拆掉这条边界。姓名中的一个引号可能提前结束原本的字面量,后面的文本就有机会变成条件或注释。仅仅禁用分号无法修复问题,因为攻击者可以在不追加第二条语句的情况下,改变同一条查询的含义。同样,一个接口即使拒绝一次执行多条语句,也仍然需要正确绑定参数,不能把这个限制当作注入防护已经完成的证据。 绑定可以防止值变成 SQL 语法,但所选运算符仍会按自身规则解释这个值。对于 LIKE,百分号匹配一段字符,下划线匹配一个字符。因此,一个经过绑定的搜索串,仍可能比用户期望的普通文本搜索匹配更多记录。这属于模式语义的问题,不说明绑定失效,却说明“输入不会执行成 SQL”还不等于“搜索符合产品要求”。 另外一些错误也不会造成注入,却会给出错误结果。把逗号分隔的文本交给一个 IN 占位符,它仍是一个标量,不会自动展开成列表。把列名当作值绑定,得到的仍是文本,不是列引用。因此,类型检查、允许的查询结构与业务权限,都需要在绑定之外独立成立。 绑定保护 SQL 语法,LIKE 仍解释通配符。列表、标识符和页大小政策分别有自己的契约。本地 SQLite 断言,不代表生产保障。 快速验证 直接运行下面的完整 Python 脚本,只需要标准库 sqlite3。它创建内存数据库作为样本,完成断言后关闭连接。记录中的运行环境为 Python 3.12.14 与 SQLite 3.53.1。故意保留的脆弱查询仅用于比较三个虚构用户的结果,不连接任何真实业务数据: 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,)] assert db.execute('SELECT id FROM users ORDER BY id LIMIT ?', (-1,)).fetchall() == [(1,), (2,), (3,)] assert db.execute('SELECT id FROM users ORDER BY id LIMIT ?', (0,)).fetchall() == [] print('IN needs separate values; bound column is a literal; LIMIT/OFFSET bind') print('negative LIMIT removes the cap; zero LIMIT returns no rows') db.close() 拼接查询返回三个编号,绑定查询面对相同输入则没有任何匹配。恶意输入中的注释还会删掉原先追加的排序部分,因此断言先对脆弱查询的结果排序,再比较编号集合;实验没有依赖未声明顺序的查询恰巧按插入顺序返回。这也展示了注入会改变原指令的多个部分,不只是多匹配一行那么简单。 ...

五月 17, 2027 · 2 分钟 · XB Field Notes
账户 A 扣款、账户 B 入账和流水写入共享事务边界。COMMIT 一起保留三项事务性写入,ROLLBACK 一起撤销;应用负责纳入必需的写入。

事务让提交是原子的

事务让相关数据库变更一起提交,或者一起放弃。如果一次操作要求扣减一个账户、增加另一个账户,并写入流水,那么这三步就应属于同一个决定。PostgreSQL 通过 BEGIN、COMMIT 和 ROLLBACK 提供这个边界,但应用仍须把正确的语句放进去。原子执行不会自动发现漏掉的入账,也不会替业务补出一个没有编写的校验。事务保护的是被明确纳入其中的工作,而不是开发者脑中尚未落实的意图。 第二次转账失败后,应恢复已提交余额与流水行数。COMMIT 回复丢失则属于另一种不确定状态,须核对实际结果。此概念图不增加运行验证证据。 决定一个工作单元包含什么 普通 psql 在没有显式事务块时使用自动提交,每条语句各自处于一个事务里。因此,扣款可能已经成功提交,之后的流水插入才失败。显式分组改变了失败边界:其他事务看不到这组操作尚未提交的表数据中间状态;回滚直接丢弃其事务性变更,而不是依赖应用事后再发几条补偿更新来还原。 原子性与隔离性、持久性并不是同一件事。如果隔离级别或加锁协议不足,一组完整提交的操作仍可能基于错误的并发判断。数据库确认提交后的持久保障,也受到实际持久化配置影响。仅在代码里找到 BEGIN,还不能回答这些问题。同样,PostgreSQL 的每条语句本来就运行在事务中,读取也不例外;显式读事务可以用于重复读取快照或事务局部设置,不能一概视为无用开销。 坏掉的样子 PostgreSQL 中未处理的 SQL 错误通常会让显式事务块进入失败状态。客户端必须整体回滚,或者回滚到适用的早期保存点,才能恢复正常执行。关闭客户端的遇错停止功能,并不会修复服务器端已经失败的事务。如果把这种连接直接还给连接池,下一个请求可能接着遇到失败状态,而不是从干净边界开始。 服务器明确拒绝提交,与提交确认在网络中丢失,是不同证据。如果连接在等待 COMMIT 回复时断开,客户端可能不知道数据库是否已先完成提交。盲目重发支付可能导致重复操作。持久的操作标识和结果核对用于处理这种不确定性;普通回滚语义不能证明一个暂时无法联系的服务器究竟完成了什么。不能把所有客户端报错都解释成“肯定什么也没发生”。 快速验证 在可丢弃的 PostgreSQL 18 数据库中打开新的 psql -X -v ON_ERROR_STOP=1 会话,运行下面代码。临时表让这些账户编号只属于本会话。代码仅在两个刻意制造的失败周围关闭遇错停止,以便观察 SQLSTATE,随后立即重新开启。 ...

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