把一次旧版本覆盖拦下来
你和同事同时打开一份商户说明。两个人读到的都是 original:你先保存 A edited,同事稍后把自己那份保存为 B overwrites。如果接口接受后一次覆盖,你的内容就消失了。这里不用靠“操作足够快”碰运气,而是让每次保存说明:这份修改是从哪个版本出发的。
站内的 HTTP 请求与响应 已经解释过版本检查;HTTP 缓存实验 展示过读取时的版本验证。这个实验向前走一步:真的启动一个本机 HTTP 服务,让两个客户端读同一版本,再发送更新请求。
先认清三个不同结果
ETag 是服务端给表示附上的标签。更新请求通过 If-Match 带回旧标签;它使用强比较,所以 W/"v2" 不能当作强标签 "v2" 来匹配。条件不满足时不能执行该修改。本实验统一选择返回 412;规范还允许在服务器确定变更已经成功应用的特定情形返回成功,不能把实验策略当成所有接口的唯一行为。RFC 9110 §13.1.1
| 实验请求 | 响应 | 接下来做什么 |
|---|---|---|
不带 If-Match 更新 /note | 428 | 先读取,再携带该次读取的标签 |
带着过时标签更新 /note | 412 | 读取新内容,决定怎样合并 |
带着当前强标签更新 /note | 200 | 记录实际保存的内容与新标签 |
428 表示服务端要求条件请求。它是接口可以选择的规则,不是所有写接口都会自动启用;这种响应不能被缓存。本实验为所有响应设置 Cache-Control: no-store。RFC 6585 §3
运行一份会自行结束的实验
下载 Python 实验脚本,在下载目录打开终端:
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,不要只看程序没有报错。顺序部分会出现:
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 不够,如果失败请求已经改了数据,再补发失败响应,保护仍然是坏的。
比较和保存必须连在一起
脚本把“读当前标签、比较、替换内容、推进版本”放在同一把锁里。下面是其中的核心逻辑;完整输入检查在下载文件中:
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
留给自己的三次改动
- 修改 C、D 提交的文字,但保留同一个旧标签。写下两份状态、最后正文和最后标签,解释为什么不能要求固定客户端获胜。
- 模拟“冲突后只刷新标签,不合并正文”。先预测最终丢掉哪一段内容,再与真正合并的结果对照。
- 设计两台服务进程都能修改同一数据库行的版本。写出条件更新语句和影响行数的检查;说明单进程锁为什么不能覆盖这个场景。此题为设计练习,当前脚本没有启动数据库。
验收记录至少保留请求方法、路径、旧标签、响应状态,以及随后 GET 的正文。把“预测”和“实际”分两列;错误响应不应推进版本或改变资源。
这份实验验证到哪里
脚本已实际完成 18 次本机 HTTP 请求,检查旧标签拒绝、缺条件拒绝、弱标签拒绝、失败后状态不变、审阅后更新、并发竞争,以及允许盲写时的覆盖反例。资源只在单进程内存中存在,重启就丢失,标签只供本次服务生命周期的演示。
它没有数据库事务、多实例协调、登录授权或 TLS,也不实现完整条件头组合。Python 官方明确将 http.server 定位为不适合生产的基础服务;这里用它观察请求与结果。Python http.server