Accept 中 text/html;q=0、*/*;q=1 排除 HTML,而 JSON 的质量值仍为 1。更宽泛的通配范围不会覆盖具体排除。

Accept 系列头让服务器协商应答

内容协商是在服务器能够实际生成的表示之间进行选择。请求偏好只是选择的输入,并不能证明响应已经满足这些偏好,客户端仍需检查收到的表示。Vary 记录影响复用的请求维度,不负责命名最后选中的格式、语言或压缩方式。把选择输入与响应结果混为一谈,会让排查从一开始就看错字段。 分开看待不同维度 请求偏好 所选表示的响应元数据 Accept Content-Type Accept-Language Content-Language Accept-Encoding 实际施加内容编码时的 Content-Encoding 这些维度相互独立,请求 JSON 不等于请求英文或 gzip。Content-Language 描述内容所面向受众的语言,不保证每个字符都属于某一种语言。如果服务器只有英文 HTML 和中文 JSON,不能把分别算出的“最优格式”和“最优语言”拼成根本不存在的中文 HTML。应先列清实际可用的完整组合,再定义选择规则;偏好不能凭空增加服务端的生成能力。 质量值范围是零到一,省略时为一,最多允许三位小数。零表示不可接受,不是“稍微不喜欢”。对媒体类型,一个候选的质量值由匹配它的最具体范围决定。显式的 text/html;q=0 不会被更宽泛的 */*;q=1 解禁。如果只是找整串字段里最大的数字,或者拿逗号前第一项,就会丢掉“哪个范围约束哪个候选”的关系,得到看似按权重处理、实际上违反排除条件的结果。 坏掉的样子 缺失某个偏好字段通常表示在该维度上没有偏好,并不等同于显式拒绝。RFC 9110 允许服务器在没有可接受表示时返回 406,也允许忽略该字段,因此默认响应可能仍然无法被客户端使用。承诺严格协商的 API 必须自己明确这项策略。客户端可以表达排除条件,却不能仅靠发出这个字段,强迫所有现网服务器都执行严格拒绝逻辑。 各类字段还存在不同的匹配语义。Accept 使用媒体范围,语言协商需要约定语言标签匹配方式,Accept-Encoding 则有 identity、字段缺失和空值的专门规则。通用列表解析器不会自动补齐所有业务策略。特别是不能把缺失字段悄悄变成一个空集合,再推导出“客户端拒绝一切”。应用选择默认值与解析器返回没有匹配项,是两个需要明确区分的事实。 相同权重也没有一条跨所有服务器通用的顺序规则。应用应让并列时的选择可预测,并实际测试。完整的语言偏好还可能帮助识别用户,因此记录全部偏好档案、把所有可能字段都放进 Vary,并非天然合理。Vary 只处理匹配问题,缓存资格、新鲜度和授权边界仍需各自的规则,不能因为增加了这个字段就认定内容可以共享。 具体程度、排除与候选顺序决定解析结果;端点仍需单独决定如何把无匹配转换成 HTTP 响应。 快速验证 准备 Python 3.11 或更新版本及 POSIX shell,在临时环境安装本次验证的解析器,再用 "$accept_lab/venv/bin/python" 运行 Python 代码。安装需要外网,实验本身不发请求、不启动服务器。它验证的是 Werkzeug 3.1.8 的候选选择行为,不是假装复现任意浏览器或 CDN 的完整实现。 ...

十一月 23, 2027 · 2 分钟 · XB Field Notes
文章的新消息含 id 和 note。旧二进制转发者可以保留未知 note;把旧消息对象转为 JSON 只输出已知字段,因而丢掉 note。最终解析成功也可能已经发生信息损失。

Protobuf 兼容性会在 JSON 边界发生变化

一次 schema 变更可能适用于二进制消息,却不适用于同一服务的 JSON 网关。Protobuf 二进制格式按编号识别字段,ProtoJSON 通常暴露字段名称。因此应把生产方、消费方和中间转换组成兼容矩阵,而不是只给变更贴一个“向后兼容”的标签。编译成功,或两个端点互相读懂,并不能证明所有转发环节都保留了它们交换的信息。 新增字段是否安全,取决于表示形式 使用全新编号增加字段,通常属于二进制线上安全变更。旧解析器可以保留未知字段,同时继续读取熟悉的内容;新版解析器读取旧数据时,则得到相应默认值或缺失状态。缺失是否符合业务要求仍是另一项决定,不能在旧写方还无法提供新值时,就让新读方强制依赖它。格式接受能力与功能实施能力必须分开验收。 ProtoJSON 的规则不同。解析器默认拒绝未知字段,所以旧 JSON 消费方可能拒绝带有新增属性的消息。配置忽略未知字段,可以让解析继续,却不会为后续消费方保留被丢弃的信息。这是明确允许信息损失的策略,不是二进制未知字段转发的等价实现。应先确认新字段是否只是补充说明,还是会影响业务动作的必要输入。 保持编号而修改字段名,能很好地展示这个差别。二进制读方仍然可以按编号取得数值,但生成代码的访问器和默认 JSON 属性会变化,旧属性对新 JSON 解析器可能变成未知字段。json_name 选项同样参与这个契约。发布前应列出实际输出与接受的名称,而不是只比较编号表,便宣布改名完全没有代价。 跟踪中间转换,不只比较两个端点 旧消息对象能够在内部保留未知二进制数据,即便公开字段接口无法描述它们。把这个对象序列化成 JSON 时,只输出已知字段,未知信息就会丢失。遍历已知字段并填入一个新消息,也会产生类似损失。需要保留未知数据时,应优先使用面向整个消息的复制接口,并用测试明确验证保留结果,而不是依赖表面上相同的字段值。 这也不同于序列化字节稳定。等价消息在不同版本或实现间,不一定产生完全相同的二进制输出。比较时应检查所需值与存在状态,不应要求相同字段顺序,除非协议另外定义了规范表示。对重新序列化结果签名或计算身份摘要,需要单独、明确的规则;某一次小样本字节相等,并不能替所有版本建立这种规则。 字段编号退役后应永久保留,并把删除的名称加入 reserved,阻止 schema 中意外复用。但名称保留声明不会让运行时 JSON 解析器继续接受旧属性。deprecated 标记也不会自动删除字段,它更不会清除旧写方、已保存记录或下游依赖。真正删除之前,需要知道哪些对象还在使用它,以及怎样完成迁移。 坏掉的样子 网关可能接受一条新的二进制请求,经过旧 schema 的 JSON 对象再输出,从而悄悄抹掉新增指令。末端消费方仍可解析成功,却因缺失而进入默认分支。另一条路径则是严格旧 JSON 解析器直接拒绝整条消息。需要同时检查拒绝与静默省略,两者的运行症状不同,却可能来自同一个表示转换边界。 也要区分 schema 兼容与生成代码、运行时版本兼容。即便线上格式没有变化,新生成代码也可能要求受支持的特定运行时组合。应遵循供应方支持规则,并测试仍有意义的最老读方、延迟消息和回滚路径。几行把类型名称放进字典的自制检查器,不能证明真实解析器、代码生成或运行时的这些性质,更不能替代跨版本测试。 ...

十一月 19, 2027 · 2 分钟 · XB Field Notes
携带同一键的三次尝试汇聚到一笔已存订单;多次尝试保持同一行动身份。

幂等键是客户端的承诺

幂等键命名的是一次预期行动,不是一次网络投递尝试。服务支持这项契约时,客户端在结果不明确后重试同一个行动,应该继续发送同一个键。每次重试都生成新键,实际是在告诉服务器“这是另一次行动”,因此完全可能合理地产生第二份业务效果。客户端必须先把行动身份与尝试次数分开。 创建一次,在后续尝试中保留 在第一次发送之前生成键,并把它与操作参数、目标一起保存。如果后台任务可能在客户端重启后恢复,任务记录也需要保留这个身份。仅把键放在临时网络连接或一次调用栈中,无法可靠地把后续尝试关联回原来的意图,恢复程序也不应临时猜一个替代值。 真正的新行动应该使用新键,即使参数与之前的购买完全一样。仅用正文哈希无法区分“再买一次同样的东西”和“重试第一次购买”。键与请求指纹承担不同职责:键标记行动,指纹检查是否发生了不兼容的复用。把二者合并,既可能漏去重,也可能错误阻止合理的新操作。 键的格式和作用域应遵循服务提供方的契约。Stripe 建议使用 UUIDv4 等随机标识,并提醒不要把敏感数据放进键中。键不是认证凭据,拥有某个键也不能代替访问已存结果所需的授权。不要把 bearer 凭据、邮箱或个人信息塞进用于关联请求的标识。 接收方必须真正实现这份承诺 只加一个 Idempotency-Key 请求头,不会自动实现去重。服务端必须在明确契约下识别它,说明比较哪些参数、保留哪些结果、执行尚未完成时如何处理重复请求,以及记录会保存多久。这些都是服务行为,不能从客户端看见一个字段名就推断已经具备。 Stripe API v1 记录的是首次进入执行阶段的请求状态码与正文,包括 500 响应。如果参数验证失败,或与正在执行的请求发生冲突,而端点执行尚未开始,则不会保存对应幂等结果。因此,“重复请求必须等待第一份结果”并不是所有 API 都必须采用的通用规则,调用方要处理实际的在途冲突语义。 Stripe API v2 则可能继续尝试失败或部分失败的工作,并返回更新后的结果;不能把 v1 的原响应保存规则直接套到 v2。不同提供方也不保证逐字重放响应。EC2 文档说明,成功重试不会再次执行行动,但返回结果可以包含更新后的创建状态等信息。RFC 9110 定义的是预期效果;具体服务是否复用原始响应字节,是叠加在其上的另一层契约,不能把两者当成同一要求。 让键的含义保持稳定 同一个键被配上不兼容的参数时,应按照服务明确的错误规则处理,而不是静默创建另一项操作,或覆盖原先保存的结果。JSON 字段顺序、默认值、数字表示和省略字段,都可能影响比较。一个小例子能规范化某种输入,不代表已经解决任意正文的语义等价问题。 下面的实验只接受一个商品字符串和一个正整数量。将这两个字段排序,定义了它自己的比较规则,并不是通用 JSON 等价算法。示例用 409 表示参数冲突,也是测试接口的政策,不是每一家幂等 API 都保证返回的状态码。 ...

一月 20, 2027 · 3 分钟 · XB Field Notes
Go 实验的写计数在 GET 与 HEAD 后仍为零;两次 PUT new 后增至二;65 字节 PUT 被 64 字节上限拒绝后仍为二。

HTTP 方法在描述意图

URL 指认目标资源,请求方法描述客户端希望源站执行的操作。服务器、缓存、代理、爬虫和重试客户端都会读取这个动词,因此方法不是路径上的装饰,而是关于预期效果的一份承诺。只有当承诺诚实时,外围设施才能做出安全的缓存、预取、重试和监控决定。同一个 URL 配上不同方法,对下游来说就是不同请求。 安全与幂等不是一回事 安全方法的定义是“本质上只读”:GET 和 HEAD 都不应要求源站改变目标资源状态。服务器仍可能写访问日志、计费或更新观测计数,但这些附带动作不改变方法的安全语义。幂等则表示,把完全相同的请求发送多次,服务器的预期效果与发送一次相同。PUT 和 DELETE 在协议上具有幂等意图,可是第一次请求可能已经到达而响应在途中丢失,客户端仍需结合版本、条件头和业务结果判断是否重试。 POST 通常表示创建从属资源或执行一次动作;它不会因为当前处理器恰好没有重复副作用,就自动变成幂等。PATCH 表示部分修改,也不天然保证重放安全。OPTIONS 用来询问通信选项,TRACE 有专门的诊断用途。具体资源允许哪些方法由源站决定;已知但不支持的方法应返回 405,并用 Allow 说明可用集合。未知方法则可能得到 501,不能把两个状态码混成一个“失败”。 让动词帮助基础设施判断 GET 与 HEAD 是否可缓存,要结合响应的缓存规则,但它们可被预取、重复读取,正是因为请求意图是只读。若把登出、购买或删除藏在 GET 地址后面,浏览器预取、链接扫描器、爬虫或缓存都可能触发不该发生的动作。重试库可以优先考虑幂等请求,却仍需检查认证、期限、请求体能否重放、条件版本和服务端是否已经提交。幂等不是分布式事务,也不保证客户端永远只看见一份响应。 PUT 请求创建或替换指定目标的状态,重复请求保持相同预期效果;DELETE 移除目标 URI 与其当前功能的关联,不保证物理删除底层数据。本例中条目变成不存在,第一次可能返回 204,后续可能返回 404。若服务端要选择新 ID 并创建对象,用 POST 加幂等键或单独的占位记录更准确,不能仅为了方便重试就假装它是 PUT。API 文档应同时写清方法、状态码、前置条件、版本冲突和重试策略,不能只写一个“请求成功”。 幂等性约束请求的预期效果,不要求响应文本、访问日志或修订历史完全一致。附带记录中的时间戳变化,不会自动破坏幂等性;但重复执行请求明确要求的购买或递增,是另一回事。重试需保留正文与适用的 If-Match 条件。幂等也不防止并发覆盖:无条件重放旧值,仍可能覆盖其他写入者刚提交的更新,必要时应核对条件失败后的权威状态。 ...

十月 24, 2026 · 3 分钟 · XB Field Notes
租户、操作和行动键组成示例的作用域身份。业务效果与结果记录在同一数据库一起提交;保留期内的匹配请求重放已存结果。

幂等键归一到一次逻辑行动

一个幂等键只能在服务定义的作用域与保留窗口内标记一次逻辑行动。重复同一段字符串,不是放之四海皆准的“恰好执行一次”保证。服务需要把身份关联到正确的主体与操作,协调竞争中的请求,并保存足够权威的信息,以便稍后认出已经处理的行动。键只是入口,真实保证取决于这条处理链。 图示依据 SQLite 实验,不是通用 API 契约。作用域与保留期由服务定义。未知不等于缺失;数据库原子性不覆盖外部副作用,也不承诺永久恰好一次。 作用域是身份的一部分 如果全局只按客户端提交的键查找,就可能把不同租户的行动合并,甚至把别人的已存结果返回给当前调用方。查找或重放之前仍须完成认证与授权。应把可信作用域、稳定的操作标识、客户端行动键一起作为查找身份,并保留各组件边界,避免直接拼接字符串产生歧义。 究竟哪些维度属于作用域,要由 API 契约决定。EC2 明确区分地域级与可用区级幂等;按其文档规则,相同客户端令牌在不同地域或可用区可能允许执行不同的行动。因此,把重试切换到另一个地域,并不自动保留全局去重保证,不能只检查令牌文本是否一致。 请求含义也需要约束。同一个作用域内,原键配上不同参数时,必须按照已经声明的冲突规则处理。下面的实验只对一个正整数量生成比较表示,不比较任意 HTTP 正文,不执行认证,也不认为对正文取个哈希,就能自动推断业务语义是否相同。 让业务效果与结果记录一起提交 先查记录、再独立写入,会出现竞争:两个工作进程都没看见旧记录,于是都执行了业务。如果两份业务效果已经发生,之后只有一个结果记录插入成功,那么结果表上的唯一键本身并不能消除之前的重复效果。需要保护的是业务修改与记忆结果之间的完整边界。 两者都位于同一个事务数据库时,可以放在一个事务中提交。示例先执行 SQLite BEGIN IMMEDIATE,再查询记录,并在同一事务里写入业务行与结果记录。SQLite 同时只允许一个写事务,因此竞争连接会被串行处理;复合主键也明确表达了去重所依赖的身份组成。 事务保护止于这个数据库。外部支付、邮件发送或消息投递,不会因为本地事务失败就自动撤销;这些效果需要自己的端到端投递与去重契约。把整个流程命名为“恰好一次”,并不能凭空补出跨越多个系统的原子性,评审时必须沿真实副作用检查边界。 保留期也是正确性边界 如果旧请求仍可能回来,而记住它的键已经过期,那次请求就可能重新执行。Stripe API v1 文档明确说明,记录清理后的同键请求会成为新请求;其至少二十四小时的保留说明属于该提供方,并不是所有服务通用的计时器,更不能解释成永久去重。 恢复窗口应与保留期一致,还要区分临时去重记录和长期业务唯一性规则。删除结果记录,不会撤销原来的订单。如果业务禁止这笔订单在任何时候被重新创建,就需要一个比短期重试缓存更持久的权威约束,不能把希望都放在键还没过期这件事上。 恢复较旧的记录数据库、清空状态或切换到独立存储,也可能忘记已经完成的行动。恢复方案应保留业务数据与操作记录之间的关系,不能只证明各自的文件还能读取,却没有验证它们是否对应同一批已提交事实。 坏掉的样子 提交成功后丢失响应,会让客户端不知道结果,但不会自动删除已存记录。提交前事务失败,则应同时不留下业务效果与成功记录。这是两种不同情况,需要不同证据,不能把“客户端看到了错误”直接等同于“服务器没有执行”。 数据库竞争、存储不可用或未知结果,也不能被当成“查不到旧记录”。看到操作仍在执行,不应自动放行第二份业务效果。需要明确重复请求是等待、得到执行中响应,还是走另一条已有文档的恢复路径;成功结果重放只是可能状态之一,不覆盖所有中间阶段。 快速验证 保存为 scope_check.py,用 Python 3.10 以上版本执行 python3 scope_check.py。未改动的实验已于 2026-10-18 在 Python 3.12.14、SQLite 3.53.1 上重新运行。标准库代码在私有临时目录创建数据库,结束后清理;每次尝试都打开自己的连接。租户与操作参数代表已经完成身份验证和路由解析的测试输入,不是客户端请求头的替代品。 ...

十月 12, 2026 · 3 分钟 · XB Field Notes
重复 PUT first 到 /item 保留一个保存值,状态从 201 变为 204;重复 POST same 到 /events 则创建两个条目并返回不同 Location。

HTTP 方法表达的是必须实现的契约

HTTP 方法描述客户端希望对目标资源执行的动作,客户端和中间层会据此判断抓取、缓存或重试方式。选择一个常见动词只是开始,应用还必须实现这个动词承诺的行为。把破坏性操作命名成 GET,并不会让它自动安全。可用的接口说明还需要交代资源模型、表示格式、授权和错误条件,不能认为方法名称已经替代了所有业务文档。 分清安全性与幂等性 安全方法具有基本只读的请求语义。GET 取得表示,HEAD 获取相应响应的元数据,却不携带响应内容。访问日志等附带效果,不会让一次读取在协议意义上变成不安全操作。但因为爬虫跟随 GET 链接就扣费,是另一回事:这个请求要求的业务动作本身改变状态,违反了安全方法的预期。判断时要看请求的预期目的,而不是要求服务器任何计数器都绝对不能变化。 幂等性关心同一个请求重复执行后的预期服务器效果。PUT 根据提交的表示替换目标资源状态;DELETE 移除目标与当前功能之间的关联。重复执行它们,预期效果应与一次执行相同,返回码、日志条数和耗时则可能不同。幂等性不能让请求自动获得权限,也不能保证不会受到其他并发写入影响。协议性质不是对任意业务实现的免费保护。 POST 请求目标资源按自身语义处理提交内容,创建资源只是常见用法,并不是全部定义。POST 通常不具备幂等保证,但应用可以另行提供明确的去重契约。PATCH 表达局部修改,具体性质取决于补丁格式和操作内容;重复“设成某个值”与重复“再增加一次”并不相同。不能看到方法名就认定所有局部修改都可以无条件重放。 坏掉的样子 重试组件如果把任何超时都当成可以重复 POST 的理由,就可能重复创建事件或扣款。即使使用幂等方法,也要理解并发与资源当前状态;条件请求可以防止覆盖已经被别人改过的表示。方法语义帮助定义动作,不会替代业务前置条件或事务规则。一次响应丢失,并不自动说明服务器没有执行原操作,重试判断必须考虑这个不确定窗口。 GET 也不代表所有缓存都可以自动重用任意响应,状态、保鲜指令、认证与表示选择仍然影响复用。GET 和 HEAD 的请求内容没有通用定义的语义,有些实现或中间层会拒绝它。不能因为本地处理器接受了读取请求中的正文,就认定这种查询方式在所有代理和客户端之间都能互通。设计公共接口时,应核对真实调用链的处理能力。 对于服务器认识、但目标不允许的方法,可以返回四零五,并用 Allow 指明支持的方法。这个能力列表不是授权凭据,也不同于 CORS 许可响应头。OPTIONS 可以说明通信选项,却不会因此让另一个浏览器来源自动有权读取响应。方法支持、账号授权和浏览器跨源限制,各自解决不同层面的问题,不能用同一个字段替代它们。 对比条目保持相同状态与事件集合持续增长。HEAD 报告对应 GET 的长度,不携带该正文。 快速验证 将脚本保存为 methods.py 后执行 python3 methods.py。它使用标准库启动临时回环服务,提供一个条目资源和一个创建事件的端点。请求通过真实 HTTP 连接依次发送,断言同时检查响应与最终应用状态。这是一个小范围的方法契约实验,不是通用生产接口,没有接入外部账号或数据库,也没有修改现有站点。 ...

十月 8, 2026 · 2 分钟 · XB Field Notes
条件 PUT 把 blue 改成 green,随后响应丢失;再次携带旧 If-Match 在实验中得到 412,而保存值仍为 green。

HTTP 幂等让重试变安全

HTTP 幂等关注的是重复同一个请求时,服务器上预期发生的效果,而不是每次响应是否一模一样。服务器可能已经执行修改,却在客户端收到答案之前断开连接;这时重复请求可能合理,但前提是操作的真实语义允许重复,并且客户端仍遵守有界重试政策。不能把“可以重复”扩大成所有失败都应该立即重试。 相同效果,不代表相同响应 RFC 9110 用多次相同请求与单次请求的预期服务端效果来定义幂等。PUT、DELETE 和安全方法都属于幂等方法。第一次成功 PUT 可能创建资源并返回 201,再次发送则可能替换已有资源并返回 204;先删除存在的资源,再删除已经不存在的资源,也可以返回不同状态码,但最终都让目标保持不存在。 “安全”是另一种性质:客户端没有请求改变状态。GET、HEAD、OPTIONS、TRACE 被定义为安全方法,但允许附带访问日志等行为,因此不是说服务器任何地方都绝对不能写入。PUT 和 DELETE 可以符合幂等语义,却不是安全方法;把两个概念混用,会影响自动抓取与重试策略。 方法名称需要实现来兑现 把一个每次递增的接口命名为 PUT,不会让重复递增自动变成幂等。服务器需要真正兑现公开的方法语义。同样,用 GET 删除指定对象违反安全方法的约定,预取器或爬虫可能在没有删除意图时触发它。不能因为请求行上写着某个词,就跳过对业务状态变化的检查。 POST 通常不保证幂等,但某个具体 POST 操作可以通过应用层契约去重;它的用途也不局限于创建资源。自动重放之前需要理解实际契约,不能断言所有 POST 都不能重试,也不能反过来认定所有使用幂等方法名称的接口都已经正确实现。 天然可重复的写入与记住操作键,解决的是相关但不同的问题。用指定表示替换资源,是表达希望得到的状态;凭借幂等键重放订单创建,则是在识别某次特定行动。两者都不意味着每一个内部函数、访问日志或辅助步骤只会执行一次。 前置条件处理的是竞争修改 幂等本身不能阻止覆盖另一个客户端在两次请求之间写入的新内容。重复无条件替换,可能把自己的旧值重新盖到别人的新值之上。通过强实体标签与 If-Match,可以要求目标仍然是之前观察到的表示,才允许执行这次修改,而不是只比较方法名称。 条件重试也可能在原修改已经成功后失败:第一次写入完成却丢了响应,原先的标签已经不匹配。实验在这种情况下返回 412。RFC 9110 也允许服务器在判断所要求的修改已经应用时返回成功,因此不能把实验的一个选择当成所有服务必须采用的行为。 遇到结果不明确,应重新核对当前资源,或查询相应操作记录。一个状态码不足以还原多个写入者的全部先后历史;读到期望值,也不自动证明它只可能由自己那次请求产生。 坏掉的样子 盲目重试会放大负载、耗尽截止期限、重复不受保护的计费副作用,或覆盖中间写入。需要限制尝试次数和总耗时,保留原来的目标与请求意图,并遵守适用的服务端等待提示。幂等既不是并发控制方案,也不是无限重试的许可。 还应区分预期资源修改与附带日志、版本历史。出现两条诊断记录,本身不能证明业务效果发生了两次;反过来,两份看起来相同的成功响应,也不能证明只执行了一次业务行动。判断重复效果,需要读取有权威性的业务状态,而不是仅比对响应文本。 幂等比较预期状态效果。前置条件防止覆盖中间修改,但成功响应丢失后,旧条件同样可能失效。 快速验证 保存为 methods_check.py,用 Python 3.10 以上版本执行 python3 methods_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。代码会向自己创建的回环 HTTP 服务发送真实请求,端口由系统分配,完成后停止服务;所有业务状态都留在测试进程内存中,不访问线上系统。 ...

八月 26, 2026 · 3 分钟 · XB Field Notes
已保存页 6、5、4 从边界 (11:00,4) 接到后续 3、2、1。原文实验中,顶部插入在原排序键未变时保持这一关系。

游标分页从明确边界继续读取

键集分页从客户端上一页实际收到的最后一个排序键继续。它不再从当前结果开头数过多少行,而是把候选行与一个具体边界比较。因此,信息流顶部新插入一条记录时,后面的页不会仅因位置整体后移就跟着错开一行。但这种方法不会冻结整个数据集,也不承诺后续每次请求都读取同一份记录。 坏掉的样子 单独使用时间戳,通常无法给出唯一边界。即使时间精度很细,多行也可能拥有相同创建时间。严格比较会跳过尚未返回的并列记录,包含等号的比较又可能重复已经展示的行。正确做法是增加一个唯一的决胜字段,并在排序与继续查询的谓词中始终使用完整键,不能一边按两个字段排序,另一边只保存其中一个。 排序字段可修改时,会产生另一类问题。已经展示过的行可能移到游标后面,于是再次出现;尚未展示的行可能移到游标前面,于是后续翻页一直看不到它。删除记录、改变过滤条件的匹配状态,也会改变下一次请求能够返回的内容。比较边界保持不变,并不能让持续变化的数据具有不可变快照的性质。 偏移分页也有明确用途:它表达当前查询结果中的编号位置,适合需要页码的界面。但在下一次偏移位置之前插入记录,可能造成重复;删除记录则可能造成遗漏。无论哪一种分页,只因为接口返回字段叫作“一页”,都不会自动获得跨请求的一致快照。需要先把产品希望保证的事情说清楚。 完整元组避免插入造成的偏移漂移与时间并列遗漏。可变排序键仍会重复或漏行。依据原文实验结果,不代表快照保证或性能测试。 快速验证 把 SQL 保存为 cursor.sql,连接可丢弃的 PostgreSQL 数据库,运行 psql -X -v ON_ERROR_STOP=1 -f cursor.sql。确认实验模式 article_cursor 没被使用,并保持自动提交。遇错即停,避免建模式失败后继续操作已有对象,甚至执行最后的清理。记录中的运行版本为 PostgreSQL 18.6。实验特意让每个时间戳对应两行,先保存实际返回的第一页,再向顶部插入新行,最后修改两个已有排序键: CREATE SCHEMA article_cursor; SET search_path=article_cursor,public; SET TIME ZONE 'UTC'; CREATE TABLE feed (id bigint PRIMARY KEY,published_at timestamptz NOT NULL); INSERT INTO feed VALUES (1,'2026-01-01 10:00:00+00'),(2,'2026-01-01 10:00:00+00'), (3,'2026-01-01 11:00:00+00'),(4,'2026-01-01 11:00:00+00'), (5,'2026-01-01 12:00:00+00'),(6,'2026-01-01 12:00:00+00'); CREATE INDEX feed_order ON feed (published_at DESC,id DESC); CREATE TEMP TABLE first_page AS SELECT id,published_at FROM feed ORDER BY published_at DESC,id DESC LIMIT 3; SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM first_page; SELECT published_at AS cursor_time,id AS cursor_id FROM first_page ORDER BY published_at,id LIMIT 1 \gset SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM feed WHERE published_at<:'cursor_time'::timestamptz; PREPARE after_cursor(timestamptz,bigint) AS SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM ( SELECT id,published_at FROM feed WHERE (published_at,id)<($1,$2) ORDER BY published_at DESC,id DESC LIMIT 3 ) AS page; INSERT INTO feed VALUES (7,'2026-01-01 13:00:00+00'); SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM ( SELECT id,published_at FROM feed ORDER BY published_at DESC,id DESC LIMIT 3 OFFSET 3 ) AS page; EXECUTE after_cursor(:'cursor_time'::timestamptz,:cursor_id); UPDATE feed SET published_at='2026-01-01 09:00:00+00' WHERE id=6; UPDATE feed SET published_at='2026-01-01 14:00:00+00' WHERE id=3; EXECUTE after_cursor(:'cursor_time'::timestamptz,:cursor_id); DEALLOCATE after_cursor; DROP TABLE first_page; DROP SCHEMA article_cursor CASCADE; 第一个数组是 {6,5,4},因此游标来自编号四的时间戳与编号。代码先把真正返回的第一页保存到临时表,再从它取得边界,而不是另外查询一次正在变化的原表来猜测客户端看到了什么。在读写交错时,这个区别直接决定后续边界是否对应上一份响应。 ...

八月 19, 2026 · 2 分钟 · XB Field Notes