REST API 乐观并发控制:用 ETag 与 If-Match 避免更新覆盖

举报
yd_240403232 发表于 2026/10/22 01:37:55 2026/10/22
【摘要】 在 REST API 中,多个客户端同时更新同一资源时容易发生覆盖。本文介绍如何用 ETag 响应头和 If-Match 请求头实现乐观并发控制,冲突时返回 412 Precondition Failed。给出基于 Flask 的最小可运行示例,并说明运行方法、边界与限制。

问题场景

假设有一个物品管理接口,两个客户端几乎同时读取同一物品,然后分别提交更新。客户端 A 先把名称改为“A”,客户端 B 稍后把名称改为“B”。如果服务端不做检查,B 的请求会覆盖 A 的修改,A 的更新丢失。这类问题称为“更新丢失”。

在 REST API 中,常用乐观并发控制解决:服务端为资源表示生成一个版本标识(ETag),客户端更新时通过 If-Match 带上读取时的版本标识。服务端只在标识匹配时才执行更新,否则返回 412 Precondition Failed,让客户端重新读取后重试。

方案:ETag 与 If-Match

  • GET 响应头带 ETag: "1",其中 "1" 是当前版本。
  • 客户端 PUT 时带 If-Match: "1"。
  • 服务端比较 If-Match 与当前 ETag:匹配则更新并生成新 ETag;不匹配返回 412。
  • 如果资源不存在,返回 404;如果请求缺少 If-Match,可根据业务返回 428 Precondition Required,要求客户端先获取 ETag。

ETag 可以是版本号、内容哈希或数据库版本列。关键是每次资源表示变化时 ETag 必须变化,且生成过程要稳定。

最小可运行示例

使用 Python Flask 和内存存储演示。安装 Flask:

pip install flask

创建 app.py:

from flask import Flask, request, jsonify

app = Flask(__name__)

# 模拟存储:资源 id -> {'name': str, 'version': int}
STORE = {
    'item-1': {'name': '初始名称', 'version': 1}
}

def make_etag(record):
    # 强 ETag,用双引号包裹版本号
    return '"' + str(record['version']) + '"'

def get_record(item_id):
    return STORE.get(item_id)

@app.route('/items/<item_id>', methods=['GET'])
def get_item(item_id):
    record = get_record(item_id)
    if not record:
        return jsonify({'error': 'not found'}), 404
    resp = jsonify({
        'id': item_id,
        'name': record['name'],
        'version': record['version']
    })
    resp.headers['ETag'] = make_etag(record)
    return resp

@app.route('/items/<item_id>', methods=['PUT'])
def update_item(item_id):
    record = get_record(item_id)
    if not record:
        return jsonify({'error': 'not found'}), 404

    if_match = request.headers.get('If-Match')
    if not if_match:
        return jsonify({'error': 'If-Match header required'}), 428

    current_etag = make_etag(record)
    # 支持 If-Match: * 和多个 ETag
    if if_match.strip() != '*':
        candidates = [e.strip() for e in if_match.split(',')]
        if current_etag not in candidates:
            return jsonify({
                'error': 'precondition failed',
                'current_etag': current_etag
            }), 412

    payload = request.get_json(silent=True)
    if payload is None or 'name' not in payload:
        return jsonify({'error': 'invalid payload'}), 400

    # 更新并递增版本
    record['name'] = payload['name']
    record['version'] += 1
    new_etag = make_etag(record)

    resp = jsonify({
        'id': item_id,
        'name': record['name'],
        'version': record['version']
    })
    resp.headers['ETag'] = new_etag
    return resp

if __name__ == '__main__':
    app.run(debug=True)

运行:

python app.py

验证与并发冲突

  1. 获取资源及其 ETag:
curl -i http://127.0.0.1:5000/items/item-1

响应中会包含 ETag: "1"。

  1. 正常更新:
curl -i -X PUT http://127.0.0.1:5000/items/item-1 \
  -H 'Content-Type: application/json' \
  -H 'If-Match: "1"' \
  -d '{"name":"新名称"}'

返回 200,ETag 变为 "2"。

  1. 模拟并发覆盖:假设两个客户端都读取到 ETag "2"。客户端 A 先提交:
curl -i -X PUT http://127.0.0.1:5000/items/item-1 \
  -H 'Content-Type: application/json' \
  -H 'If-Match: "2"' \
  -d '{"name":"A 的修改"}'

更新成功,版本变为 3。客户端 B 仍用旧 ETag "2" 提交:

curl -i -X PUT http://127.0.0.1:5000/items/item-1 \
  -H 'Content-Type: application/json' \
  -H 'If-Match: "2"' \
  -d '{"name":"B 的修改"}'

此时返回 412 Precondition Failed,响应体包含当前 current_etag。客户端 B 应重新 GET,合并后再用新 ETag 重试。

边界与限制

  • 内存存储仅用于演示:重启后数据丢失。生产环境应使用数据库,并通过原子条件更新实现乐观锁,例如 UPDATE items SET name = ?, version = version + 1 WHERE id = ? AND version = ?,然后检查受影响行数。
  • ETag 生成策略:用版本号简单可靠,但需要保证每次变更递增;用内容哈希可避免无变化时版本膨胀,但要注意字段顺序和序列化稳定性。弱 ETag(如 W/"...")不适合用于 If-Match 的强比较。
  • If-Match 语义:If-Match: * 表示只要资源存在即可更新;多个 ETag 用逗号分隔。服务端应处理空白和大小写。
  • 412 与 428:缺少 If-Match 时返回 428 可强制客户端参与并发控制,但并非所有客户端都熟悉该状态码。412 是前置条件失败的标准响应。
  • 并发原子性:示例在单进程内工作。多实例部署时,检查与更新之间不能有竞态,必须依赖数据库事务、版本列或分布式锁。
  • 缓存与代理:ETag 也用于缓存验证,中间代理可能影响缓存行为,但 If-Match 是端到端头,通常不会被修改。
  • 幂等性与重试:PUT 本身幂等,但条件更新冲突时返回 412,客户端应重新读取、合并、重试,而不是盲目覆盖。
  • 不能解决所有并发问题:例如 ABA 问题(版本号递增可缓解)、删除与更新竞态。需要根据业务补充资源不存在、已删除等状态处理。

小结

ETag 与 If-Match 是 REST API 中实现乐观并发控制的轻量方案。核心是:GET 返回版本标识,PUT 携带 If-Match,服务端只在匹配时更新,否则返回 412。示例代码可直接运行验证,生产环境则需结合数据库条件更新和合适的重试策略。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。