PAYATHON 2026

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 的请求,可以按以下流程处理:

  1. 客户端先通过 GET /carts/{id} 获取当前资源及其 ETag。
  2. 客户端修改 cart 或 item 时,在请求中携带 If-Match。
  3. Server 在同一事务或原子更新中完成以下操作:
    • 检查 cart 是否已经超时;
    • 必要时将状态改为 TIMEOUT;
    • 检查 If-Match 是否与当前版本一致;
    • 检查当前状态是否允许修改;
    • 执行写入并递增版本。
  4. 如果条件不满足,则不执行任何部分写入。

伪代码如下:

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 则同时检查状态、有效期和版本。三者结合,才能完整处理这个并发问题。

备注:内容仅供参考。