REST API 乐观并发控制:用 ETag 与 If-Match 避免更新覆盖
【摘要】 在 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
验证与并发冲突
- 获取资源及其 ETag:
curl -i http://127.0.0.1:5000/items/item-1
响应中会包含 ETag: "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"。
- 模拟并发覆盖:假设两个客户端都读取到 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)