一种支持索引与操作符的二进制 JSON 表示。

Postgres 的 jsonb 专为查询而生

PostgreSQL 提供两种 JSON 存储类型,它们承担的表示成本不同。json 验证并保留输入文本,jsonb 把文本转换成拆解后的二进制值。两者都能提取字段和路径。选择类型时,需要回答的是是否必须保留文本形态、是否反复处理文档,以及真实查询需要哪些算子,而不是简单判断能不能读取内部字段。 保留文本还是保留结构值 json 会保留无意义空白、对象键的顺序和重复对象键。处理函数仍需要解析这些文本,遇到重复键时,以最后一个值作为有效值。因此,存储保留了重复项,并不代表每个消费方都会得出同样解释。不要把重复键当成应用契约中有意义的数据结构,否则跨语言解析时很容易产生分歧。 jsonb 去掉无意义空白和对象键顺序,同一个对象键只保留最后一次出现的值。这里说的是对象:数组仍然保留元素顺序和重复元素。数组的包含判断有自己的匹配规则,不能因为包含查询把两个数组视为匹配,就反推存储时发生了排序或去重。表示、相等比较和包含关系是三个需要分别理解的问题。 这种二进制格式属于 PostgreSQL,并不是 BSON,也不是可跨系统使用的签名格式。jsonb 输出的文本没有承诺遵循某个外部 JSON 规范化算法。如果签名覆盖原始请求字节,应另外保留这些字节,例如使用 bytea,并针对该表示验证签名。json 保留的是接收后的 JSON 文本;客户端编码转换和协议处理可能已改变网络上的原始字节,因此不能把它当成任意请求的逐字节归档。 两种类型都支持路径提取 两者都支持用 -> 提取 JSON 值,用 ->> 提取文本,以及用 #>、#>> 读取嵌套路径。读取 json 内部字段时,不需要先把整篇文档强制转换成 jsonb。性能区别在于重新解析文本还是处理已经拆解的表示,而不是 json 缺少这些操作符。 jsonb 还支持结构包含、键存在判断和 GIN 等索引策略。从两种类型提取出的标量表达式,也可以按实际需要建立适当的表达式索引。能否使用索引仍取决于准确的表达式、算子和负载,选了 jsonb 不代表任意嵌套条件都会变快。经常参与关联、约束或排序的字段,往往更适合独立的有类型列,以明确数据契约并方便优化。 ...

十二月 4, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三步示意图,旁边是在失败路径上也归还每份借用的英文标题。

泄漏的连接会把连接池榨干

连接池泄漏首先是所有权问题:业务工作已经结束,但它借走的连接仍然处于借出状态。此时数据库可能仍然正常响应其他客户端,只有这个应用拿不到连接。应把归还资源视为操作契约的一部分,覆盖异常和提前返回,而不是只在成功路径末尾顺手清理一下。 一份借用应当有明确的主人和终点 SQLAlchemy Connection 表示从 Engine 所属池中取得的一次借用。关闭这个 Connection,通常是把底层 DBAPI 连接还回池里,而不一定真正断开数据库连接。归还借用与提交事务是两个独立决定:池可以运转正常,但业务修改已经回滚;池也可能耗尽,而底层数据库连接全部仍然存活。只检查连接是否活着,无法回答谁还占着它。 资源作用域应尽量短,但不能破坏事务要求的原子性。with engine.begin() 在代码块成功时提交,失败时回滚,并释放连接;with engine.connect() 管理的是连接作用域,不承诺自动提交写入,关闭时尚未结束的事务会被回滚。两者看起来都用了 with,但持久化语义不同,不能为了“统一写法”随意互换,否则资源不再泄漏了,数据却可能没有保存。 与当前事务无关的远程等待、用户交互,不应长时间占着事务和连接。这并不意味着可以把必须一起提交的变更拆开。若连接由调用方拥有,辅助函数不应私自关闭;若辅助函数自己借用,就应负责归还,或者在接口中明确把所有权交给谁。只有打开次数的指标,却说不清每份借用由谁结束,仍然没有定位真正的生命周期边界。 坏掉的样子 漏掉一次归还,就减少一份可用容量,后来者开始排队或超时。对于配置了有限容量和获取超时的 QueuePool,新调用并非一定永远挂住,而是可能在执行 SQL 之前就收到池超时。把上限调大可以暂时推迟复发,却也增加数据库压力;缺失的归还路径依然存在,不能算修复。 不过,“池满了”不是泄漏的充分证据。真实并发、长事务、数据库锁等待或应用自身死锁,都可能占住全部借用。数据库 CPU 很低也不决定原因,因为应用可能握着连接等待别的事情。需要把借出数量、获取等待、持有年龄、事务状态和仍在进行的业务工作关联起来,判断这些连接是否还处于合理的使用范围。 一个有说服力的泄漏复现,应证明已经结束的工作仍然保留借用,并且归还这些具体借用后容量恢复。不要依赖垃圾回收及时释放;对象何时被回收不是可靠的资源归还契约。Engine.dispose() 也不能强制收回仍然借出的连接,它处理的是池内已经空闲的连接。重启进程可能暂时清空症状,但不会改变下一次请求重新走进同一遗漏分支的事实。 快速验证 在 macOS 或 Linux 的空目录运行 python3 -m venv .venv,然后执行 .venv/bin/python -m pip install SQLAlchemy==2.0.52。把下面保存为 pool_probe.py,执行 .venv/bin/python pool_probe.py。本次使用 Python 3.14.4 和 SQLite 3.53.0,数据库文件只存在于脚本自己的临时目录,结束后自动清理,不会访问生产数据库。 ...

十一月 5, 2027 · 2 分钟 · XB Field Notes
先暂存写入、再在后台归并成有序段落的存储布局。

LSM 树吞下随机写入

B 树会在行所在的那一页上就地更新,于是「键随机到达」的写入模式就变成了「页随机写」的模式。LSM 树拒绝这笔交易:它只追加,把重写留到后面成批去做。这套设计里的其他部分——内存表、布隆过滤器、压实策略——全都从这一个拒绝里推出来。 支撑它的测量很容易复现。在同一张 20 万行的 sqlite 表上做同样 20 万次就地更新,配 2 MB 页缓存和一次持久提交:键按顺序到达时耗时 0.17 秒,打乱顺序到达时 2.60 秒——行完全一样、页数完全一样,差了 15.25 倍。多出来的时间花在散落的页重写,以及由此引发的缓存淘汰上。 先缓冲,再刷盘,再合并 写入先进预写日志保证持久性,再进内存表保证速度。内存表满了就作为一段不可变的有序段刷到磁盘;在 RocksDB 里这一段落在 level 0,而它下面的每一层只保存一段有序数据,且尺寸目标按指数增长。于是键层面随机的负载,落到磁盘上就变成了完全顺序的负载,无论应用在做什么,写路径都是一次大块的有序追加。 账单在读取路径上付。一次点查要考虑每一层,所以引擎给每段配了布隆过滤器,用来廉价地回答「不在这里」。在一次 40 万次写入、6 万个不同键的模拟里,2 万次点查平均探了 8.9 个段,但真正去读的只有 0.6 个:布隆过滤器在 177937 次探测里否掉了 166622 次,占 93.6%。查不存在的键时仍然要探 11.0 个段才能返回空——布隆过滤器让「未命中」变便宜,对「命中」则毫无帮助。 压实就是代价 写路径之所以便宜,是因为从来不做就地更新;代价是每个逻辑值都会被写不止一次。同一份模拟里,56000000 字节的逻辑数据变成了 149031040 字节的物理写入,来自 54 次刷盘和 4 次压实——写放大 2.66 倍,而且这还是在刻意放宽了压实预算的情况下。 ...

十一月 2, 2027 · 1 分钟 · XB Field Notes
周期性冲刷脏页,从而缩短重启所需的重做日志。

检查点为恢复建立起点

检查点记录一部分已经完成的工作,使数据库恢复不必把整个日志历史都视为待处理内容。具体操作取决于引擎:PostgreSQL 同步检查点要求的数据变更,建立重做起点;SQLite 的 WAL 模式则把符合条件的已提交页面帧从日志转移到主数据库文件。两者都不能简单当作事务提交的另一个名字。 坏掉的样子 已经请求检查点与检查点完整完成,是两个不同观察。SQLite 读者可能持有旧快照,阻止较新的日志帧写入主文件,或者阻止日志重置。被动检查点可以只做当前能够完成的部分,然后正常返回。若只看成功标志,不看日志总帧数与已完成帧数,就会漏掉尚未处理的工作,以及那个仍然阻止完成的读事务。 日志大小也要放在具体上下文中理解。文件可以继续复用原有空间,因此已分配大小不变时,检查点进度仍可能推进。PostgreSQL 完成检查点之后,也可能继续为归档或复制槽保留旧日志。为了让磁盘曲线变小而手动删除日志,会破坏恢复材料。应先核对引擎进度和保留义务,再判断文件大小究竟代表什么。 检查点间隔不是恢复时间的硬上限。重放速度、存储吞吐、写入形态以及实际崩溃位置,都会影响恢复耗时。更频繁的检查点可能减少未来重做,也可能增加平时写流量。把日志放到另一块设备,不会把所有数据页写入也搬过去,更不会使检查点的写盘成本自动消失。参数选择需要对应具体工作负载。 快速验证 把下面内容保存为 checkpoint_lab.py,使用普通的 python3 checkpoint_lab.py 运行,保持断言开启。脚本只使用标准库 SQLite 模块与私有临时文件。本次记录来自 Python 3.14.4 搭配 SQLite 3.53.0。比较结果时应同时查看运行时的引擎版本,因为 Python 版本与实际链接的 SQLite 版本并不是一回事。 import sqlite3 import tempfile from pathlib import Path with tempfile.TemporaryDirectory(prefix="xbcatm-checkpoint-") as directory: path = Path(directory) / "test.db" writer = sqlite3.connect(path, isolation_level=None, timeout=0.1) reader = sqlite3.connect(path, isolation_level=None, timeout=0.1) try: assert writer.execute("PRAGMA journal_mode=WAL").fetchone() == ("wal",) writer.execute("PRAGMA synchronous=FULL") writer.execute("PRAGMA wal_autocheckpoint=0") writer.execute("CREATE TABLE marker (id INTEGER PRIMARY KEY, value INTEGER)") writer.execute("INSERT INTO marker VALUES (1,10)") assert writer.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() == (0, 0, 0) reader.execute("BEGIN") assert reader.execute("SELECT value FROM marker").fetchone() == (10,) writer.execute("UPDATE marker SET value=20 WHERE id=1") assert writer.execute("SELECT value FROM marker").fetchone() == (20,) passive = writer.execute("PRAGMA wal_checkpoint(PASSIVE)").fetchone() blocked = writer.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() print("passive", passive) print("truncate_with_reader", blocked) assert passive[0] == 0 and passive[1] > passive[2] >= 0 assert blocked[0] == 1 and blocked[1] > blocked[2] assert Path(str(path) + "-wal").stat().st_size > 0 assert reader.execute("SELECT value FROM marker").fetchone() == (10,) reader.execute("COMMIT") completed = writer.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() print("truncate_after_reader", completed) assert completed == (0, 0, 0) assert Path(str(path) + "-wal").stat().st_size == 0 assert reader.execute("SELECT value FROM marker").fetchone() == (20,) print("fresh_reader", 20) finally: reader.close() writer.close() 初始截断检查点把值十写入主文件,并重置日志。随后一个读者显式开启事务,读取十;另一个连接在关闭自动检查点之后,把值二十提交。写者立刻就能读到二十,而旧读者仍然需要自己的原始快照。这个顺序用真实连接建立约束,不依赖线程恰好在某个微小时间窗口发生竞争。 ...

十月 17, 2027 · 1 分钟 · XB Field Notes
固定 SQL 结构接收绑定值,数据库仍可选择不同执行计划。

预备语句把取值与 SQL 结构分开

参数绑定为数据库中的取值提供一个明确位置。姓名里即使带有引号或分号,也仍然只是值,不会因此变成另一段查询条件。复用命名预备语句则是另一项优化:服务端能够在同一个会话内,跨多次执行保留语句信息。安全地绑定数据,并不要求应用为每一条查询都显式建立一个命名语句,也不要求先证明查询会频繁重复。 坏掉的样子 在 Psycopg 中,占位符 %s 属于驱动接口,不是让程序使用 Python 字符串格式化。值应作为 execute 的第二个参数传入,占位符不要加引号,只有一个位置参数时也需要单元素元组。如果先把值插入字符串,再调用一个支持参数化的接口,原本希望保护的边界早就已经被越过;接口名称本身不会替程序补救这次拼接。 占位符代表值,不代表表名、列名或关键字。动态标识符需要明确的结构选择:先用白名单限制支持的列,再用驱动的标识符组合功能正确引用选中的列名。升降序关键字也应由应用控制。分页的数量与偏移量可以直接绑定,同时仍须按产品要求验证范围。能在语法上接收一个整数,不代表允许请求任意大的页面或无限制消耗资源。 预备语句不会把第一次的执行计划永久钉死。PostgreSQL 区分根据本次参数生成的定制计划,以及跨不同参数复用的通用计划;自动选择时会考虑规划开销与估算执行成本。模式或统计信息变化也可能触发重新规划。因此,语句保持参数化时,访问路径仍可以变化。是否复用某种计划,与输入能否变成 SQL 语法,是不同的性质,不能混为一谈。 快速验证 准备安装了 Psycopg 3 的 Python 环境,把 PG_DSN 设置为可丢弃的 PostgreSQL 数据库连接。连接配置沿用已有安全提供方式。实测版本为 Psycopg 3.3.5 与 PostgreSQL 18.6。将下面代码保存为 Python 文件,在已设置该环境变量的终端执行。使用的是临时表,连接关闭后会自动消失: import os import psycopg from psycopg import sql with psycopg.connect(os.environ['PG_DSN'], autocommit=True, prepare_threshold=5) as conn: conn.execute('CREATE TEMP TABLE people (id integer PRIMARY KEY, label text NOT NULL)') conn.execute("INSERT INTO people VALUES (1,'alice'),(2,'bob')") query = 'SELECT id FROM people WHERE label=%s ORDER BY id LIMIT %s' hostile = "' OR true --" assert conn.execute(query, (hostile, 10), prepare=False).fetchall() == [] count = conn.execute('SELECT count(*) FROM pg_prepared_statements').fetchone()[0] assert count == 0 print('bound without named preparation:', count, 'named statements; no match') for value in ("O'Reilly", r'a\b', 'x; DROP TABLE people; --'): assert conn.execute('SELECT %s::text', (value,), prepare=False).fetchone() == (value,) for label, expected in (('alice', [(1,)]), ('bob', [(2,)])): assert conn.execute(query, (label, 10), prepare=True).fetchall() == expected assert conn.execute('SELECT count(*) FROM pg_prepared_statements').fetchone() == (1,) print('named preparation reused: alice=1, bob=2') def ordered(column, limit): if column not in {'id', 'label'}: raise ValueError('unsupported sort column') if type(limit) is not int or not 1 <= limit <= 100: raise ValueError('limit must be an integer from 1 through 100') statement = sql.SQL('SELECT id FROM people ORDER BY {} LIMIT %s').format( sql.Identifier(column)) return conn.execute(statement, (limit,), prepare=False).fetchall() assert ordered('label', 1) == [(1,)] for column, limit in [('label; DROP TABLE people', 1), ('id', 0), ('id', 101)]: try: ordered(column, limit) except ValueError: pass else: raise AssertionError('invalid request accepted') print('identifier allow-list and bound LIMIT checked') conn.execute('PREPARE plan_lab(text) AS SELECT id FROM people WHERE label=$1') conn.execute('SET plan_cache_mode=force_custom_plan') assert conn.execute("EXECUTE plan_lab('alice')").fetchall() == [(1,)] counters = "SELECT generic_plans,custom_plans FROM pg_prepared_statements WHERE name='plan_lab'" assert conn.execute(counters).fetchone() == (0, 1) conn.execute('SET plan_cache_mode=force_generic_plan') assert conn.execute("EXECUTE plan_lab('bob')").fetchall() == [(2,)] assert conn.execute(counters).fetchone() == (1, 1) print('same prepared statement: custom=1, generic=1') conn.execute('RESET plan_cache_mode') conn.execute('DEALLOCATE plan_lab') 第一项输出显示命名语句数量为零,而且看起来像注入的输入没有匹配任何记录。这次调用明确关闭命名预备,却保留参数绑定,证明安全边界在语句复用之前就存在。随后包含引号、反斜杠、分号的文本都原样返回,表仍可被后面的查询继续使用,字符串里写着删除命令也没有获得执行机会。 ...

十月 16, 2027 · 2 分钟 · XB Field Notes
只覆盖被某个谓词选中那一批行的索引。

部分索引只覆盖有用行

部分索引只为满足谓词的行保存索引项。需要的子集很小时,它可以缩小索引,但也可以表达只对这个子集生效的完整性规则。例如,同一个座位允许保留多条历史预订,却只能有一条当前有效预订。部分唯一索引可以把这条规则放在数据库边界上执行,而不只是提高某条查询的速度。 成员资格随谓词变化 CREATE INDEX ... WHERE active 只包含 active 为 true 的行,false 和 null 都不满足条件。是否允许 null 是独立的模型选择;下面实验把 active 定义为非空,使每条预订明确处于有效或无效状态。索引键也不必就是谓词中出现的列:这里用 seat 作为键,用 active 决定哪些行参与。 普通写入会改变成员资格。无效记录变成有效记录时,必须进入索引并接受检查;有效记录失效后,则离开当前有效子集。这个边界不是一旦建索引就永远固定的历史分界线。每次写入仍要判断谓词,状态切换也有维护成本,所以索引更小不意味着所有更新都免费,更不能无条件承诺写入一定更快。 唯一性可以只针对子集 在 seat 上建立 WHERE active 的唯一索引,会拒绝同一个座位出现两条有效记录,却允许任意多条无效历史记录。即使没有 SELECT 使用这个索引,数据库仍会执行唯一性检查。因此查询加速与数据完整性是保留索引的两种不同理由,不能只看扫描计数为零就把承担约束的索引删除。 应用先查“有没有”再插入,比数据库约束弱,因为另一个事务可能在检查与写入之间插入。唯一索引会在 PostgreSQL 中协调冲突写入,失败方需要处理唯一性错误或明确选择的冲突策略。下面夹具按确定顺序验证拒绝行为;并发协调属于数据库约束契约,不代表已经实现支付、扣款和跨服务预订的完整业务流程。 查询也要表达同一边界 规划器把部分索引用于访问路径时,必须能够证明查询条件蕴含索引谓词。仅按 seat 查询,并不能证明调用方只需要有效记录,因为历史记录也可能匹配。显式查询 active 记录才可能符合条件,但小表仍可能采用更便宜的顺序扫描。逻辑上相似的表达式,也只有在规划器能够识别其蕴含关系时才有用,不能仅凭人眼觉得相近。 ...

十月 15, 2027 · 2 分钟 · XB Field Notes
索引条目里包含满足某查询所需的全部列。

覆盖索引不必回表

覆盖索引包含某一条查询需要的值,PostgreSQL 有时可以通过 index-only scan 直接返回它们,避免访问对应的堆元组。这里的“有时”很关键:列是否齐全属于结构条件,执行时能否真正跳过堆访问,还取决于可见性信息。计划里出现 Index Only Scan,并不能单独证明没有访问表数据,更不能据此宣称任何写入状态下都能省掉堆读取。 覆盖实际返回的列 按 sku 查价格时,可以建立 sku 的 B-tree,并用 INCLUDE (price) 把价格作为附加值存进去。搜索由 sku 键引导,附加的 price 不会自动成为第二个搜索键,也不提供按价格排序的顺序。如果这是唯一索引,唯一性约束针对键列,而不是键与附加列的整体组合。这些区别决定它究竟能回答哪些查询。 查询后来增加返回列,就可能破坏覆盖前提。例如 description 不在索引中,执行器需要从其他位置取得它,通常就是堆表。随手改成 SELECT 星号,也可能把原本专门优化的查找变成另一种访问模式。应从实际过滤条件和投影列出发,结合表达式与可见性需求设计,而不是把全部字段都塞进一个预想中的万能覆盖索引。 并非所有索引方法都能还原 index-only 执行所需的完整值。B-tree 支持这种扫描,GIN 通常保存的是从原值提取的键,而不是完整原值,因此不支持 index-only scan。索引不仅要包含或能够还原所需数据,也要支持对应操作。其他数据库也使用“覆盖索引”这个名称,但不能据此把 PostgreSQL 的可见性机制直接套过去。 可见性映射决定是否访问堆 PostgreSQL 必须判断候选元组是否对当前 MVCC 快照可见,索引项没有保存全部所需可见性信息。Index-only scan 会检查堆页在 visibility map 中的 all-visible 位;该位已设置时,才能直接返回索引中的值,而不读取堆元组。这样省下的是堆访问,仍然需要读取索引和可见性映射,并不等于完全没有 I/O。 ...

十月 14, 2027 · 2 分钟 · XB Field Notes
优化器比较多种连接方案的估计成本再挑一份。

查询优化器在成本上押注

查询规划器根据预计工作量选择执行路径,并不会先把所有候选方案运行一遍、计时后再保留冠军。PostgreSQL 用统计信息和成本模型比较扫描、连接、排序等操作,因此选定的计划始终是建立在当前假设上的预测。诊断预测为何偏离实际,需要找出失效的具体假设,而不是把顺序扫描或嵌套循环当成天生有问题的节点。计划好不好,要放在完整查询的输入和输出要求中判断。 成本描述的是一个模型 PostgreSQL 的成本综合页面访问、元组处理、运算符计算等估计工作量,单位并不是毫秒。只需要拿到少量记录时,启动成本可能特别重要;必须产生完整结果时,总成本的意义更大。一个扫描如果已经提供了后面需要的顺序,就可能省掉排序,因此单独看来最便宜的扫描,不一定是整条查询最合适的输入。只比较一个节点会漏掉它给后续处理带来的影响。 规划搜索也有边界。数据库会考虑不同路径和连接顺序,而不是实际执行全部候选。连接关系很多时,PostgreSQL 可以启用遗传查询优化器来控制规划开销。可用索引、合法变换、配置与搜索选择共同限制了候选集合,所以不能把最后的选择描述为“对所有想象得到的实现都保证数学最优”。这种边界并不表示规划器不可信,只是说明它需要接受真实测量的检验。 坏掉的样子 统计刚刷新也可能估错。普通统计分别描述每一列,在组合两个过滤条件时可能假设它们相互独立,而实际两列总是一起变化。中间结果被低估后,反复查找看起来就比实际便宜,后续连接顺序也可能受到影响。如果模型本身没有表达这两列的关系,重复运行相同的分析不会凭空补上这部分知识。统计陈旧与统计种类不足,不能混成一种诊断。 扩展统计也只解决特定问题。函数依赖可以帮助估计与常量比较且彼此兼容的等值条件,但它不保存具体值之间的完整对应关系,也不是范围条件或任意跨列比较的一般证明。即使已经收集到很强的依赖,一组实际上互不兼容的常量仍可能被严重高估。因此验证中既需要兼容的正向情况,也需要一个不成立的组合来展示限制。 快速验证 在 PostgreSQL 18 的 psql 中执行下面的临时表实验。两列始终保存相同的值,一共有一百种不同值,每种重复一百次。所有修改都放在事务中,最后回滚会一并移除表和相应统计对象。 \set ON_ERROR_STOP on BEGIN; CREATE TEMP TABLE dimensions (a integer,b integer); INSERT INTO dimensions SELECT g%100,g%100 FROM generate_series(1,10000) AS g; ANALYZE dimensions; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT * FROM dimensions WHERE a=7 AND b=7; CREATE STATISTICS pg_temp.dimensions_dependency (dependencies) ON a,b FROM dimensions; ANALYZE dimensions; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT * FROM dimensions WHERE a=7 AND b=7; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT * FROM dimensions WHERE a=7 AND b=8; DO $$ BEGIN IF (SELECT count(*) FROM dimensions WHERE a=7 AND b=7) <> 100 OR (SELECT count(*) FROM dimensions WHERE a=7 AND b=8) <> 0 THEN RAISE EXCEPTION 'wrong correlated population'; END IF; END $$; ROLLBACK; 在建立扩展统计之前,PostgreSQL 18.6 对 a=7 AND b=7 估计一行,实际返回一百行。两个条件单独都选中百分之一的数据;把选择率相乘,就得到一万行中的万分之一,也就是一行。这套算术在条件独立时有意义,而夹具故意让两列完全相关,所以假设与数据并不相符。 ...

九月 17, 2027 · 1 分钟 · XB Field Notes