用 Idempotency-Key 与数据库唯一约束实现订单接口幂等并缓存响应

举报
yd_217513249 发表于 2026/10/22 00:24:27 2026/10/22
【摘要】 本文说明如何在 REST 订单接口中使用 Idempotency-Key 请求头与数据库唯一约束实现幂等:首次请求执行业务并缓存状态码与响应体,重复请求直接重放首次响应,同键不同请求体则拒绝。示例使用 Flask 与 SQLite,包含幂等表、订单唯一约束、请求指纹与并发边界说明,可直接运行验证。

用 Idempotency-Key 与数据库唯一约束实现订单接口幂等并缓存响应

问题场景

用户点击“提交订单”后,客户端超时重试、用户重复点击、网关重发,都可能让同一个订单请求到达服务端两次。只在客户端禁用按钮不可靠:请求可能已经到达服务端,只是响应丢失。若服务端不做幂等,就会创建两笔订单。

常见做法是让客户端为每次“逻辑提交”生成一个唯一的 Idempotency-Key 请求头。服务端第一次看到该键时执行下单,并把响应缓存下来;之后同一个键再次到达时,不再重复执行,而是直接返回首次响应。数据库层的唯一约束作为最后防线,防止幂等记录异常时仍然写入重复订单。

方案设计

服务端维护一张幂等表,主键为 Idempotency-Key。每条记录保存请求指纹、处理状态、首次响应的状态码与响应体。

请求进入后:

  1. 计算请求指纹,用于判断同一个键是否对应同一个请求体。
  2. 尝试插入幂等记录,状态为 in_progress。
  3. 插入成功,说明是首次请求:执行业务写入,缓存响应,把状态更新为 completed,提交事务。
  4. 插入冲突,说明键已存在:读取已有记录。指纹不一致返回 422;状态未完成返回 409;已完成则原样重放缓存的响应。
  5. 订单表的 idem_key 唯一约束确保即使幂等逻辑被绕过,也不会产生重复订单。

示例把幂等记录插入、订单写入、响应缓存放在同一个数据库事务中。这样不会出现“订单成功但幂等记录失败”的中间状态;代价是同一个键的并发请求会短暂等待写锁。

最小可运行示例

使用 Flask + SQLite,仅用于演示思路。

import hashlib
import json
import sqlite3
import time

from flask import Flask, g, jsonify, request

app = Flask(__name__)
DB_PATH = "idempotency_demo.db"

def get_db():
    if "db" not in g:
        g.db = sqlite3.connect(DB_PATH, isolation_level=None)
        g.db.row_factory = sqlite3.Row
        g.db.execute("PRAGMA busy_timeout=5000")
    return g.db

@app.teardown_appcontext
def close_db(_exc):
    db = g.pop("db", None)
    if db is not None:
        db.close()

def init_db():
    db = sqlite3.connect(DB_PATH)
    db.executescript(
        """
        CREATE TABLE IF NOT EXISTS idempotency_keys (
            idem_key TEXT PRIMARY KEY,
            fingerprint TEXT NOT NULL,
            status TEXT NOT NULL CHECK (status IN ('in_progress', 'completed')),
            response_status INTEGER,
            response_body TEXT,
            created_at INTEGER NOT NULL
        );

        CREATE TABLE IF NOT EXISTS orders (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            idem_key TEXT NOT NULL UNIQUE,
            user_id TEXT NOT NULL,
            sku TEXT NOT NULL,
            quantity INTEGER NOT NULL,
            created_at INTEGER NOT NULL
        );
        """
    )
    db.commit()
    db.close()

def make_fingerprint(user_id, payload):
    normalized = json.dumps(
        {"user_id": user_id, "payload": payload},
        sort_keys=True,
        ensure_ascii=False,
        separators=(",", ":"),
    )
    return hashlib.sha256(normalized.encode("utf-8")).hexdigest()

@app.route("/orders", methods=["POST"])
def create_order():
    idem_key = request.headers.get("Idempotency-Key", "").strip()
    if not idem_key or len(idem_key) > 128:
        return jsonify(error="缺少或非法的 Idempotency-Key"), 400

    payload = request.get_json(silent=True)
    if not isinstance(payload, dict):
        return jsonify(error="请求体必须是 JSON 对象"), 400

    # 演示代码从请求体取用户。生产环境应从鉴权上下文获取。
    user_id = str(payload.get("user_id", "anonymous"))
    sku = str(payload.get("sku", ""))
    try:
        quantity = int(payload.get("quantity", 1))
    except (TypeError, ValueError):
        return jsonify(error="quantity 必须是整数"), 400
    if not sku or quantity <= 0:
        return jsonify(error="sku 和 quantity 不合法"), 400

    fingerprint = make_fingerprint(user_id, payload)
    db = get_db()
    now = int(time.time())

    try:
        db.execute("BEGIN IMMEDIATE")
        db.execute(
            "INSERT INTO idempotency_keys(idem_key, fingerprint, status, created_at) "
            "VALUES(?, ?, 'in_progress', ?)",
            (idem_key, fingerprint, now),
        )
    except sqlite3.IntegrityError:
        db.execute("ROLLBACK")
        row = db.execute(
            "SELECT fingerprint, status, response_status, response_body "
            "FROM idempotency_keys WHERE idem_key = ?",
            (idem_key,),
        ).fetchone()
        if row is None:
            return jsonify(error="幂等键状态异常,请重试"), 409
        if row["fingerprint"] != fingerprint:
            return jsonify(error="Idempotency-Key 已用于不同的请求体"), 422
        if row["status"] != "completed":
            return jsonify(error="相同请求正在处理中,请稍后重试"), 409
        return app.response_class(
            response=row["response_body"],
            status=row["response_status"],
            mimetype="application/json",
            headers={"Idempotent-Replay": "true"},
        )
    except sqlite3.OperationalError:
        return jsonify(error="数据库繁忙,请稍后重试"), 503

    try:
        cur = db.execute(
            "INSERT INTO orders(idem_key, user_id, sku, quantity, created_at) "
            "VALUES(?, ?, ?, ?, ?)",
            (idem_key, user_id, sku, quantity, now),
        )
        body = json.dumps(
            {"order_id": cur.lastrowid, "status": "created"},
            ensure_ascii=False,
            separators=(",", ":"),
        )
        db.execute(
            "UPDATE idempotency_keys "
            "SET status = 'completed', response_status = ?, response_body = ? "
            "WHERE idem_key = ?",
            (201, body, idem_key),
        )
        db.execute("COMMIT")
        return app.response_class(response=body, status=201, mimetype="application/json")
    except Exception:
        db.execute("ROLLBACK")
        raise

if __name__ == "__main__":
    init_db()
    app.run(host="127.0.0.1", port=5000, debug=True)

运行方法

安装 Flask 并启动:

python -m pip install flask
python app.py

第一次提交:

curl -i -X POST http://127.0.0.1:5000/orders -H "Content-Type: application/json" -H "Idempotency-Key: demo-key-0001" -d '{"user_id":"u_1001","sku":"SKU-A","quantity":2}'

会得到 201 和类似 {"order_id":1,"status":"created"} 的响应。重复执行同一条命令,会得到相同的响应体,并多出 Idempotent-Replay: true 头,数据库不会新增订单。

换一个请求体但复用同一个键:

curl -i -X POST http://127.0.0.1:5000/orders -H "Content-Type: application/json" -H "Idempotency-Key: demo-key-0001" -d '{"user_id":"u_1001","sku":"SKU-B","quantity":1}'

会返回 422,因为同一个键不能对应不同的请求内容。

边界与限制

幂等键的作用域:示例用 idem_key 作为全局主键,简单但不够安全。多租户系统应使用 UNIQUE(user_id, idem_key),并在读取幂等记录时带上用户条件,避免跨用户猜测键读到别人的响应。用户标识必须来自鉴权上下文,不能来自请求体。

请求指纹:只应包含影响业务语义的字段。若把时间戳、随机数等无关字段纳入指纹,正常重试会被误判为不同请求。指纹不一致时返回 422,提示客户端同一键不能用于不同请求体。

并发与等待:单事务方案下,同一个键的并发请求会等待写锁。超过 busy_timeout 会返回 503。若业务处理时间较长,可以拆成两阶段:先提交 in_progress 记录,再执行业务,最后更新为 completed。两阶段需要额外处理进程崩溃后 in_progress 记录的过期清理或接管,复杂度更高。

响应缓存:缓存的状态码和响应体应设置保留期,定期清理,避免幂等表无限增长。不要缓存超大响应或包含敏感信息的响应。

数据库唯一约束是最后防线:即使幂等表因异常丢失记录,orders.idem_key UNIQUE 仍能阻止同一键写入第二笔订单。但要注意,示例中订单插入冲突会回滚并抛出异常,生产代码应捕获该情况并返回可预期的错误或查询已有订单。

幂等键不能替代其他机制:它不替代用户鉴权、库存扣减的原子性、支付回调验签、限流等。客户端应为每个新的逻辑请求生成新键,重试同一请求时复用旧键。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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