qinghuan
← 三个动手实验

QINGHUAN / 动手查证 / 2026.10.30

两个人同时保存:用 HTTP 条件请求拦住旧版本覆盖

启动一个本机 HTTP 服务,亲手比较 428、412 与成功更新,再验收两个并发写入只能成功一个。

把一次旧版本覆盖拦下来

你和同事同时打开一份商户说明。两个人读到的都是 original:你先保存 A edited,同事稍后把自己那份保存为 B overwrites。如果接口接受后一次覆盖,你的内容就消失了。这里不用靠“操作足够快”碰运气,而是让每次保存说明:这份修改是从哪个版本出发的。

站内的 HTTP 请求与响应 已经解释过版本检查;HTTP 缓存实验 展示过读取时的版本验证。这个实验向前走一步:真的启动一个本机 HTTP 服务,让两个客户端读同一版本,再发送更新请求。

先认清三个不同结果

ETag 是服务端给表示附上的标签。更新请求通过 If-Match 带回旧标签;它使用强比较,所以 W/"v2" 不能当作强标签 "v2" 来匹配。条件不满足时不能执行该修改。本实验统一选择返回 412;规范还允许在服务器确定变更已经成功应用的特定情形返回成功,不能把实验策略当成所有接口的唯一行为。RFC 9110 §13.1.1

实验请求响应接下来做什么
不带 If-Match 更新 /note428先读取,再携带该次读取的标签
带着过时标签更新 /note412读取新内容,决定怎样合并
带着当前强标签更新 /note200记录实际保存的内容与新标签

428 表示服务端要求条件请求。它是接口可以选择的规则,不是所有写接口都会自动启用;这种响应不能被缓存。本实验为所有响应设置 Cache-Control: no-store。RFC 6585 §3

运行一份会自行结束的实验

下载 Python 实验脚本,在下载目录打开终端:

文中示例 · powershell
python --version
python .\http-conditional-writes.py

脚本仅使用标准库,支持 Python 3.10 及以上语法;制作时实际使用 Python 3.14.0 执行。它只监听 127.0.0.1,由操作系统选择空闲端口,完成后关闭自己启动的服务。不要加 -O,因为脚本使用断言检查结果。

这是实际的本机 HTTP 请求,不是只调用一个比较函数。服务端使用 ThreadingHTTPServer,每个客户端连接用 HTTPConnection 发送请求、读取完整响应并关闭连接。Python 服务端文档与客户端文档可以对照这些调用。

结果以 JSON 输出。先找 passed: true,再逐项看 requests,不要只看程序没有报错。顺序部分会出现:

文中示例 · text
A reads                    200   "v1"   original
B reads                    200   "v1"   original
A writes                   200   "v2"   A edited
B stale write              412   —      stale or weak validator...
read after conflict        200   "v2"   A edited
missing condition          428   —      read /note and resend...
weak condition             412   —      stale or weak validator...
B reviewed merge           200   "v3"   A edited + B addition

关键证据是冲突后的那次读取:内容仍是 A edited,标签仍是 "v2"。仅看 412 不够,如果失败请求已经改了数据,再补发失败响应,保护仍然是坏的。

比较和保存必须连在一起

脚本把“读当前标签、比较、替换内容、推进版本”放在同一把锁里。下面是其中的核心逻辑;完整输入检查在下载文件中:

文中示例 · python
with note.lock:
    if tags and (tags[0].startswith("W/") or tags[0] != note.etag()):
        result = (412, b"stale or weak validator; reread before merging", None)
    else:
        note.body = body
        note.revision += 1
        result = (200, body, note.etag())

假如拆成两次独立加锁,C 可以先检查通过,D 也检查通过,然后 C 写入、D 覆盖。检查动作本身都“正确”,组合起来仍然失败。因此脚本另外让 C、D 携带相同的 "v3" 并发提交,断言结果必须是一份 200 和一份 412,最后内容必须属于成功者。谁先成功不固定,验收不能写死 C 获胜。

为了看见反例,脚本还放了一个独立的 /unguarded 资源,明确允许不带条件保存。A、B 依次覆盖后,两次请求都成功,最后只剩 B 的内容。这说明问题出在保存规则,而不是 HTTP 连接是否发送成功。

不要把失败重试成悄悄覆盖

遇到 412 后,先重读,再检查自己原本要改的字段是否与别人冲突。示例中的 A edited + B addition 是人为审阅后的合并结果。若程序只偷偷换上最新标签,然后原封不动地重发旧正文,仍然会覆盖刚读到的新内容,只是这次条件恰好通过了。

也不要把 If-Match: * 当作任意版本的替代品:它检查的是当前表示存在,未绑定你曾经读取的那个版本。下载脚本只接受单个带引号的 ASCII 标签及其弱标签形式;星号、标签列表、重复的条件字段和其他条件头都会被明确拒绝,不能用它验证完整 HTTP 解析器。RFC 9110 §13.1.1

成功 PUT 保存的是收到的原始字节,没有重排、裁剪或格式转换,因此示例返回的新标签对应刚保存的内容。实际接口若转换了上传内容,成功 PUT 能否直接返回验证器还需遵守相应条件。RFC 9110 §9.3.4

留给自己的三次改动

  1. 修改 C、D 提交的文字,但保留同一个旧标签。写下两份状态、最后正文和最后标签,解释为什么不能要求固定客户端获胜。
  2. 模拟“冲突后只刷新标签,不合并正文”。先预测最终丢掉哪一段内容,再与真正合并的结果对照。
  3. 设计两台服务进程都能修改同一数据库行的版本。写出条件更新语句和影响行数的检查;说明单进程锁为什么不能覆盖这个场景。此题为设计练习,当前脚本没有启动数据库。

验收记录至少保留请求方法、路径、旧标签、响应状态,以及随后 GET 的正文。把“预测”和“实际”分两列;错误响应不应推进版本或改变资源。

这份实验验证到哪里

脚本已实际完成 18 次本机 HTTP 请求,检查旧标签拒绝、缺条件拒绝、弱标签拒绝、失败后状态不变、审阅后更新、并发竞争,以及允许盲写时的覆盖反例。资源只在单进程内存中存在,重启就丢失,标签只供本次服务生命周期的演示。

它没有数据库事务、多实例协调、登录授权或 TLS,也不实现完整条件头组合。Python 官方明确将 http.server 定位为不适合生产的基础服务;这里用它观察请求与结果。Python http.server

先判断,再解释

三个问题,检查是否真的理解

这些是概念判断题。下载实验后还要运行代码,核对实际输出。

已答对 0 / 3

1. 甲和乙读到同一个版本。甲已保存成功,乙还拿旧标签更新。教学服务器应怎么做?
2. 当前资源存在。把旧版本标签换成 If-Match: *,能继续保护“我读过的那个版本”吗?
3. 服务端先比对标签,释放锁,然后才写入内容。两个请求都可能在写入前通过比较。怎样补齐?