REST API 中客户端与 Server 并发时是否应更新 ETag
结论
应该更新。
只要 Server 的操作改变了客户端能够观察到的 cart 表示,例如修改 status、清空 items 或更新支付状态,就应生成新的 ETag。客户端之后若携带旧版本的 If-Match 发起修改,Server 应拒绝请求,避免客户端基于过期状态写入数据。
但更新 ETag 还不够。资源版本检查、超时判断和数据写入必须在 Server 端以原子操作完成,否则仍可能发生竞态。
为什么 Server 自己的修改也必须改变 ETag
ETag 标识的是资源表示的版本,与资源由谁修改无关。以下操作都会改变 cart 的表示:
- 客户端添加、更新或删除 item;
- Server 将
status改为TIMEOUT; - 支付回调将
status改为PURCHASED; - Server 在购买完成后清空
items。
只要客户端获取到的 cart 表示发生变化,对应的强 ETag 就应该随之变化。
例如,客户端最初读取:
GET /carts/123 HTTP/1.1
Host: api.example.com
Server 返回:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "cart-123-v7"
{
"id": "123",
"status": "ACTIVE",
"items": [
{
"productId": "p1",
"quantity": 1
}
]
}
随后,Server 检测到 cart 已经超时,并将状态改为 TIMEOUT。这时应保存或返回一个新版本,例如:
ETag: "cart-123-v8"
如果客户端仍使用旧版本添加 item:
POST /carts/123/items HTTP/1.1
Host: api.example.com
Content-Type: application/json
If-Match: "cart-123-v7"
{
"productId": "p2",
"quantity": 2
}
Server 应拒绝该请求:
HTTP/1.1 412 Precondition Failed
ETag: "cart-123-v8"
客户端可以重新获取 cart,再根据最新的 status 决定下一步操作。
推荐的处理流程
对于修改 cart 的请求,可以按以下流程处理:
- 客户端先通过
GET /carts/{id}获取当前资源及其ETag。 - 客户端修改 cart 或 item 时,在请求中携带
If-Match。 - Server 在同一事务或原子更新中完成以下操作:
- 检查 cart 是否已经超时;
- 必要时将状态改为
TIMEOUT; - 检查
If-Match是否与当前版本一致; - 检查当前状态是否允许修改;
- 执行写入并递增版本。
- 如果条件不满足,则不执行任何部分写入。
伪代码如下:
begin transaction
cart = load cart for update
if cart.status == ACTIVE and now >= cart.expiresAt:
cart.status = TIMEOUT
cart.version += 1
save cart
if request.ifMatch != etag(cart.version):
rollback
return 412 Precondition Failed
if cart.status != ACTIVE:
rollback
return 409 Conflict
add item
cart.version += 1
save cart
commit
return success with new ETag
实际实现中,也可以使用数据库的 compare-and-swap 更新,避免先读后写引发竞态:
UPDATE carts
SET version = version + 1
WHERE id = :cart_id
AND version = :expected_version
AND status = 'ACTIVE'
AND expires_at > CURRENT_TIMESTAMP;
如果受影响行数为 0,说明版本、状态或有效期中至少有一个条件已经变化,Server 不应继续添加 item。
ETag 不能代替业务状态检查
即使客户端没有获取最新的 ETag,Server 也必须自行验证 cart 是否仍允许修改,不能依赖客户端先读取到 TIMEOUT 状态。
例如,请求到达时已经超过 expiresAt,但后台任务还没有把数据库中的状态改为 TIMEOUT。此时,Server 仍应根据当前时间判定 cart 已失效,并拒绝添加 item。
这两个检查分别解决不同的问题:
If-Match:防止客户端基于旧版本覆盖新版本。- 状态和有效期检查:确保业务规则成立。
通常两者都需要。
状态码如何选择
常见情况可以这样处理:
412 Precondition Failed:客户端提供了If-Match,但 ETag 已经过期。428 Precondition Required:接口要求使用条件请求,但客户端没有提供If-Match。409 Conflict:ETag 可能仍然匹配,但 cart 当前状态不允许添加 item,例如已经TIMEOUT或PURCHASED。404 Not Found:cart 不存在。410 Gone:仅当 cart 已永久不可用,而且 API 明确采用这种资源生命周期语义时才考虑使用。单纯超时通常使用409更直观。
例如:
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
ETag: "cart-123-v8"
{
"type": "https://api.example.com/problems/cart-not-active",
"title": "Cart is no longer active",
"status": 409,
"detail": "Items cannot be added because the cart has timed out."
}
ETag 应如何生成
一种简单可靠的做法是为 cart 保存单调递增的版本号:
{
"id": "123",
"version": 8,
"status": "TIMEOUT"
}
对外可以编码为:
ETag: "cart-123-v8"
Server 不应直接信任客户端传来的版本号,而要将它与数据库中的当前版本进行比较。
也可以根据响应内容生成哈希值,但必须保证同一表示能稳定地产生相同结果,同时还要处理序列化顺序、压缩格式和不同内容协商结果。对于需要进行并发控制的业务资源,显式版本号通常更容易维护。
这里应优先使用强 ETag:
ETag: "cart-123-v8"
不要使用弱验证器进行写入并发控制:
ETag: W/"cart-123-v8"
If-Match 使用强比较,因此弱 ETag 不适合充当这里的乐观锁版本标识。
Last-Modified 是否足够
通常不建议只靠 Last-Modified 完成这种并发控制,原因包括:
- HTTP 日期通常只有秒级精度;
- 同一秒内可能发生多次修改;
- 不同节点的时钟可能存在偏差;
- 时间戳不一定能准确表示资源版本。
Last-Modified 仍可用于缓存验证,但强 ETag 和 If-Match 更适合检测写入冲突。
如果确实要使用时间条件,请求形式如下:
If-Unmodified-Since: Tue, 01 Sep 2026 08:00:00 GMT
不过,这种方式不如显式版本号稳妥。
子资源修改时是否更新 cart 的 ETag
这取决于 cart 表示中是否包含 item 信息。
如果 GET /carts/123 返回 items 列表,那么新增、修改或删除 item 都会改变 cart 的表示,因此父资源的 ETag 也应更新。修改单个 item 时,还可以同时维护该 item 自己的 ETag。
例如:
PUT /carts/123/items/p1 HTTP/1.1
If-Match: "item-p1-v3"
X-Cart-If-Match: "cart-123-v7"
不过,自定义条件头会增加客户端的复杂度。更常见的方案是明确规定,对 /carts/{id}/items 的写操作使用父 cart 的 ETag:
POST /carts/123/items HTTP/1.1
If-Match: "cart-123-v7"
API 文档需要说明,此处的 If-Match 针对的是 cart 聚合版本,而不是新建 item 的版本。
还要避免检查与写入之间的竞态
下面这种实现并不安全:
读取 cart
检查 ETag
检查未超时
执行其他操作
写入 item
在“检查未超时”和“写入 item”之间,后台任务可能已经将 cart 改为 TIMEOUT。因此,整个判断和写入过程必须通过以下一种方式保证原子性:
- 数据库事务与行锁;
- 带版本条件的
UPDATE; - compare-and-swap;
- 数据库约束或存储过程;
- 分布式存储提供的条件写入能力。
后台超时任务和支付回调也必须遵守相同的版本更新规则,不能绕过版本字段直接修改 cart。
REST 层面的说明
Server 因超时、支付结果或其他业务事件改变资源状态,并不违反 REST。REST 不要求资源只能由客户端修改。资源可以随时间、外部事件或后台处理发生变化。
系统需要保证:
- 资源状态转换符合业务约束;
- 客户端不会无意中覆盖更新后的状态;
- 条件检查与写入具有原子性;
- 每次客户端可观察到的资源变更都会产生新的版本标识。
因此,Server 将 cart 改为 TIMEOUT 或 PURCHASED 时,应更新 ETag。客户端修改资源时使用 If-Match,Server 则同时检查状态、有效期和版本。三者结合,才能完整处理这个并发问题。
备注:内容仅供参考。
