答案:HTTP请求方法与幂等性
为什么银行转账接口通常用 POST 而不是 PUT?(提示:考虑幂等性,如果转账请求超时重发会怎样?)
核心原因:POST 非幂等,PUT 幂等,而转账业务天然不适合幂等语义。
先理解幂等性:幂等操作是指执行一次和执行多次效果相同。比如 PUT 更新用户昵称为"波比",执行 1 次和 100 次,结果都是昵称变成"波比",这就是幂等。
但转账完全不同。假设鸣哥要从飞翔公司账户给供应商转账 10 万元:
- 执行 1 次:账户扣 10 万,供应商收到 10 万 ✓
- 执行 2 次:账户扣 20 万,供应商收到 20 万 ✗(灾难!)
如果转账接口用 PUT(幂等),网络超时后客户端自动重试,钱就被转了两遍。虽然可以用"请求唯一 ID + 服务端去重"来 hack,但这违背了 PUT 的幂等语义,属于用错误的 HTTP 方法表达业务。
POST 是非幂等的,语义就是"提交一次就处理一次"。超时重发时,服务端如果已经处理过第一次请求,第二次会再次处理——这正好符合银行的风控逻辑:每笔转账必须有独立的流水号,重复提交会被识别为重复交易并拒绝(通过业务层幂等,而非 HTTP 层幂等)。
# 银行转账接口设计(POST)
@app.route('/transfer', methods=['POST'])
def transfer():
req_id = request.headers.get('X-Request-Id') # 唯一请求 ID
amount = request.json.get('amount')
# 业务层幂等:检查 req_id 是否已处理
if redis.exists(f"transfer:{req_id}"):
return jsonify({"code": 0, "message": "已处理", "duplicate": True})
# 执行转账
result = bank_service.transfer(amount)
redis.setex(f"transfer:{req_id}", 3600, "done") # 标记已处理
return jsonify({"code": 0, "message": "转账成功"})
凌叔总结得好:"HTTP 方法的幂等性是语义约束,不是技术保证。银行用 POST 是因为转账业务本身非幂等,然后他们在业务层自己做幂等控制——这是架构的分层思想。"
靓晴要设计一个"点赞"功能,用 POST 还是 PUT 更合适?点赞两次应该算两次赞还是一次赞?
应该用 POST,但点赞两次应该算一次赞(即取消点赞),这需要业务层设计,不是 HTTP 方法能单独决定的。
先分析两种设计思路:
方案 A:POST(创建点赞记录)
POST /api/moments/9527/like HTTP/1.1
每次调用都创建一条点赞记录。点两次 = 两条记录 = 两个赞。这在某些场景下合理(比如雁姐发的年会照片,云吞和波比都可以点,每人只能点一次,但多人可以累积)。
方案 B:PUT(更新点赞状态)
PUT /api/moments/9527/like HTTP/1.1
{"liked": true}
幂等设计:无论调用多少次,最终状态都是"已点赞"。再发一个 {"liked": false} 就是取消点赞。
靓晴的实际选择:
飞翔公司朋友圈的点赞功能,靓晴最终选择了 POST + 业务层去重 的方案:
- 用户第一次点击,POST 创建点赞记录,图标变红,计数 +1。
- 用户第二次点击(同一用户、同一内容),服务端检测到已存在记录,删除该记录,图标变灰,计数 -1。
// 前端代码示意
async function toggleLike(momentId) {
const res = await fetch(`/api/moments/${momentId}/like`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
// data.action 可能是 "liked" 或 "unliked"
updateUI(data.action === 'liked');
}
# 后端代码示意
@app.route('/api/moments/<id>/like', methods=['POST'])
def toggle_like(id):
user_id = get_current_user_id()
existing = db.query("SELECT * FROM likes WHERE moment_id=? AND user_id=?", id, user_id)
if existing:
db.execute("DELETE FROM likes WHERE id=?", existing.id)
return jsonify({"action": "unliked", "count": get_like_count(id)})
else:
db.execute("INSERT INTO likes (moment_id, user_id) VALUES (?, ?)", id, user_id)
return jsonify({"action": "liked", "count": get_like_count(id)})
靓晴的设计哲学是:POST 表达"用户做了一个点赞动作",至于这个动作最终是创建还是删除,由业务层根据当前状态决定。这比单纯追求 HTTP 方法的幂等性更符合用户直觉——用户只想"点一下按钮",不关心底层是 INSERT 还是 DELETE。
假设凌叔写了一个自动重试机制:网络超时后自动重发请求。哪些方法是"重试安全"的?哪些需要额外处理?
重试安全的方法(幂等方法):
| 方法 | 是否重试安全 | 原因 |
|---|---|---|
| GET | ✓ 安全 | 获取资源,多次执行结果相同,不会修改数据。星宇查 10 次航班信息,数据库不会变。 |
| HEAD | ✓ 安全 | 和 GET 一样,只是不返回主体。 |
| OPTIONS | ✓ 安全 | 查询服务器支持的方法,纯查询。 |
| PUT | ✓ 安全 | 完整替换资源,多次替换结果相同。翼王更新公告标题为"年会通知",重试 100 次还是这个标题。 |
| DELETE | ✓ 安全 | 删除资源,第一次删除成功,后续返回 404 或 204,数据状态一致(都是已删除)。 |
非重试安全的方法(需要额外处理):
| 方法 | 是否安全 | 原因 |
|---|---|---|
| POST | ✗ 不安全 | 每次调用都可能创建新资源。波比重试报名,可能产生两条报名记录。 |
| PATCH | △ 视情况而定 | 如果是"绝对值更新"(如 {"status": "approved"})则幂等;如果是"增量更新"(如 {"balance": +100})则非幂等。 |
凌叔的重试机制设计:
import time
import requests
# 幂等方法:直接重试,无需额外处理
IDEMPOTENT_METHODS = {'GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'}
def safe_request(method, url, **kwargs):
max_retries = 3
for attempt in range(max_retries):
try:
resp = requests.request(method, url, timeout=5, **kwargs)
return resp
except requests.Timeout:
if method.upper() in IDEMPOTENT_METHODS:
# 幂等方法:直接重试
time.sleep(2 ** attempt) # 指数退避
continue
else:
# POST/PATCH 等非幂等方法:不能盲目重试
raise NonIdempotentRetryError(
f"{method} 请求超时,为避免副作用,禁止自动重试。"
f"请检查服务端是否已处理,或使用唯一请求 ID 查询状态。"
)
return resp
# 使用示例
# GET 请求超时会自动重试
resp = safe_request('GET', 'http://oa.feixiang.net/api/flights')
# POST 请求超时不会自动重试,需要业务层处理
# 正确做法:先查询状态,确认未处理后再决定是否重试
额外处理策略(以 POST 为例):
- 唯一请求 ID:波比提交报名前生成 UUID,服务端记录"已处理的请求 ID 集合"。超时后波比用同一个 ID 重试,服务端返回"已处理"。
- 先查询再重试:POST 超时后,先 GET 查询报名列表,如果已存在则无需重试。
- 状态机:服务端设计"处理中"状态,客户端超时后查询状态,如果是"处理中"则轮询等待,而非直接重发 POST。
凌叔在飞翔公司的微服务架构中,给所有非幂等请求都强制加上了 X-Idempotency-Key 首部,这是从多次生产事故中总结出的铁律。