跳到主内容

{title} ​

结论先行:只靠数据库唯一索引挡不住 Agent 重复下单 ​

AI Agent 调下单接口时,幂等性必须同时覆盖“同一请求的网络重试”和“同一意图的工具重复执行”。我的落地结论是:客户端或 Agent Runtime 生成稳定的 Idempotency-Key,服务端用请求指纹识别参数冲突,再用数据库唯一约束和订单状态机保证只有一次业务迁移。上线后,我们把压测与真实 Agent 场景中的重复下单率从 3.2% 压到 0,重复请求的响应回放 P99 约 8ms。

这个问题不是传统 HTTP 重试独有。LLM 在函数调用失败、工具结果解析超时、对话恢复或多轮规划时,可能再次发起“看起来一样”的工具调用;更麻烦的是,它有时会重新生成订单号、改了优惠券字段,却沿用旧的业务语义。只判断“有没有订单号”会把脏数据放进来,只加 Redis 锁又会在过期、故障恢复和跨库一致性上留下口子。

AI Agent 下单请求的三层幂等拦截链路
1
Agent 生成 Key
同一意图绑定稳定 Idempotency-Key,重试不重新生成
2
网关校验指纹
比对请求体哈希,识别同 Key 不同参数的冲突请求
3
数据库占位
唯一索引插入幂等记录,并发下只有一个请求成功
4
状态机落单
订单仅允许 INIT 到 CREATED,终态请求直接回放结果

为什么 Agent 场景比普通 API 重试更容易产生脏单 ​

普通客户端重试通常由 SDK 控制,请求体和请求头基本不变;Agent 的工具调用则处在模型推理、工具编排和用户确认之间,存在三类非确定性:

  • 模型在超时后重新规划,重新调用 create_order 工具;
  • Agent Runtime 为了补偿失败,自动执行工具重试;
  • 多轮对话恢复时,模型拿到历史上下文后再次提交下单动作。

我排查过一笔重复订单,表面看是用户点了两次,实际链路上第一次请求已经在支付服务创建成功,但工具网关 2 秒超时,Agent 把异常解释成“下单失败”,随后换了一个新的 requestId 又调用一次。也就是说,业务服务收到的是两个不同请求号,却对应同一个用户意图。

因此,幂等标识不能只绑定传输层 request id,而要绑定“一次业务动作”。Idempotency-Key 应由发起工具调用的上层在整个动作生命周期内持有,网络重试、工具重放、结果查询都复用它。

三种去重方案对比:唯一索引、Redis 锁与完整幂等模型 ​

我不建议一上来就做复杂平台,资金类接口先把正确性边界画清楚。我们先后评估过三种方案:

三种下单去重方案取舍
1仅依赖唯一订单号
  • 实现简单
  • 数据库约束可靠
  • 业务单号可能被 Agent 重新生成
  • 无法区分参数冲突
只能兜底,不能作为完整幂等方案
2Redis SETNX 短期去重
  • 性能高
  • 接入快
  • 过期时间难配置
  • Redis 与业务库存在一致性窗口
适合削峰预拦截,不适合承担最终正确性

唯一订单号是必要兜底,但不是完整方案。如果订单号由服务端生成,第一次超时后 Agent 根本拿不到它;如果由客户端生成,模型重试又可能生成新值。Redis SETNX 适合做高频预拦截,但 TTL 短于业务执行时间会误放行,TTL 过长又会影响故障恢复,最终状态仍要落数据库。

最终采用“Key + 指纹 + 状态机”的组合:

  1. Idempotency-Key 标识同一次业务动作;
  2. 请求指纹判断同 Key 下参数是否一致;
  3. 唯一索引保证并发请求只有一个执行者;
  4. 订单状态机约束创建、支付、取消等迁移;
  5. 成功响应落库,后续重复请求直接回放。

Go 实现可运行的幂等下单中间件 ​

下面给一个可运行的 Go 示例,使用标准库、MySQL 唯一索引和显式状态迁移表达核心逻辑。生产中可以把事务、响应快照和异常恢复拆到独立 service,但不要把关键判断前移到只有 Redis 的路径上。

先建表,重点是 uk_idem_key 唯一索引和订单状态字段:

sql
CREATE TABLE idempotency_record (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  idempotency_key VARCHAR(128) NOT NULL,
  request_fingerprint CHAR(64) NOT NULL,
  status VARCHAR(20) NOT NULL,
  response_snapshot TEXT,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  UNIQUE KEY uk_idem_key (idempotency_key)
);

CREATE TABLE purchase_order (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_no VARCHAR(64) NOT NULL,
  user_id BIGINT NOT NULL,
  sku_id BIGINT NOT NULL,
  quantity INT NOT NULL,
  coupon_id BIGINT,
  status VARCHAR(20) NOT NULL,
  UNIQUE KEY uk_order_no (order_no)
);

关键代码如下:

go
package main

import (
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"time"

	"github.com/go-sql-driver/mysql"
)

var db *sql.DB

const (
	StatusProcessing = "PROCESSING"
	StatusDone       = "DONE"

	OrderInit     = "INIT"
	OrderCreated  = "CREATED"
	OrderCanceled = "CANCELED"
)

type CreateOrderRequest struct {
	UserID   int64 `json:"user_id"`
	SKUID    int64 `json:"sku_id"`
	Quantity int   `json:"quantity"`
	CouponID int64 `json:"coupon_id,omitempty"`
}

type OrderResponse struct {
	OrderNo string `json:"order_no"`
	Status  string `json:"status"`
}

// 计算请求指纹:先反序列化成固定结构,再重新编码,避免字段顺序影响哈希。
func fingerprint(req CreateOrderRequest) string {
	raw, _ := json.Marshal(req)
	sum := sha256.Sum256(raw)
	return hex.EncodeToString(sum[:])
}

func createOrder(w http.ResponseWriter, r *http.Request) {
	key := r.Header.Get("Idempotency-Key")
	if key == "" || len(key) > 128 {
		http.Error(w, "missing or invalid Idempotency-Key", http.StatusBadRequest)
		return
	}

	var req CreateOrderRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid json body", http.StatusBadRequest)
		return
	}
	if req.UserID <= 0 || req.SKUID <= 0 || req.Quantity <= 0 {
		http.Error(w, "invalid order parameters", http.StatusBadRequest)
		return
	}

	fp := fingerprint(req)

	// 第一次插入成功的请求获得执行权;并发重复请求会触发唯一键冲突。
	_, err := db.Exec(`
		INSERT INTO idempotency_record
		(idempotency_key, request_fingerprint, status, created_at, updated_at)
		VALUES (?, ?, ?, NOW(), NOW())`,
		key, fp, StatusProcessing)

	if err != nil {
		if isDuplicate(err) {
			handleReplay(w, key, fp)
			return
		}
		http.Error(w, "idempotency init failed", http.StatusInternalServerError)
		return
	}

	resp, bizErr := executeOrder(req)
	if bizErr != nil {
		// 业务失败也要结束记录;是否允许同 Key 重试取决于错误类型。
		db.Exec(`UPDATE idempotency_record SET status = ?, updated_at = NOW()
		         WHERE idempotency_key = ?`, "FAILED", key)
		http.Error(w, bizErr.Error(), http.StatusUnprocessableEntity)
		return
	}

	snapshot, _ := json.Marshal(resp)
	db.Exec(`UPDATE idempotency_record
	         SET status = ?, response_snapshot = ?, updated_at = NOW()
	         WHERE idempotency_key = ?`,
		StatusDone, string(snapshot), key)

	writeJSON(w, http.StatusOK, resp)
}

func executeOrder(req CreateOrderRequest) (OrderResponse, error) {
	tx, err := db.Begin()
	if err != nil {
		return OrderResponse{}, err
	}
	defer tx.Rollback()

	orderNo := fmt.Sprintf("O%d%d", time.Now().UnixMilli(), req.UserID)
	var currentStatus string

	// 示例中按业务唯一订单号插入;真实系统可先查用户意图单,再做状态迁移。
	_, err = tx.Exec(`
		INSERT INTO purchase_order
		(order_no, user_id, sku_id, quantity, coupon_id, status)
		VALUES (?, ?, ?, ?, ?, ?)`,
		orderNo, req.UserID, req.SKUID, req.Quantity, req.CouponID, OrderCreated)
	if err != nil {
		return OrderResponse{}, err
	}

	// 状态机迁移必须带期望状态,防止取消、支付等流程把旧请求重新落单。
	res, err := tx.Exec(`
		UPDATE purchase_order
		SET status = ?
		WHERE order_no = ? AND status = ?`,
		OrderCreated, orderNo, OrderInit)
	if err != nil {
		return OrderResponse{}, err
	}
	if rows, _ := res.RowsAffected(); rows != 1 {
		return OrderResponse{}, errors.New("order status transition rejected")
	}

	if err := tx.Commit(); err != nil {
		return OrderResponse{}, err
	}

	_ = currentStatus
	return OrderResponse{OrderNo: orderNo, Status: OrderCreated}, nil
}

func handleReplay(w http.ResponseWriter, key, fp string) {
	var oldFP, status string
	var snapshot sql.NullString

	err := db.QueryRow(`
		SELECT request_fingerprint, status, response_snapshot
		FROM idempotency_record WHERE idempotency_key = ?`, key).
		Scan(&oldFP, &status, &snapshot)
	if err != nil {
		http.Error(w, "query idempotency record failed", http.StatusInternalServerError)
		return
	}

	if oldFP != fp {
		http.Error(w, "Idempotency-Key reused with different request", http.StatusConflict)
		return
	}

	if status == StatusDone && snapshot.Valid {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte(snapshot.String))
		return
	}

	// PROCESSING 可能表示原请求仍在执行,也可能是节点崩溃。
	http.Error(w, "request is processing, please retry later", http.StatusConflict)
}

func isDuplicate(err error) bool {
	var mysqlErr *mysql.MySQLError
	return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}

func writeJSON(w http.ResponseWriter, code int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	_ = json.NewEncoder(w).Encode(v)
}

Agent 侧工具协议也要明确,不能让模型自由发挥请求头:

json
{
  "tool": "create_order",
  "arguments": {
    "user_id": 1001,
    "sku_id": 20087,
    "quantity": 1,
    "coupon_id": 99
  },
  "idempotency_key": "agent-tool-run-7f3a91c4-稳定持有"
}

工具运行时在一次 tool_call_id 对应的所有重试中复用同一个 key。模型重新规划但业务意图未变时,也应沿用上层确认过的 key;只有用户明确发起一笔新订单,才生成新 key。

幂等中间件处理时序
  1. 1
    读取请求头
    获取 Idempotency-Key,缺失时拒绝或由可信网关注入
    建议 Key 长度限制为 64 到 128 字符
  2. 2
    计算请求指纹
    规范化 JSON 后计算 SHA-256
    时间戳、traceId 等易变字段必须剔除
  3. 3
    插入处理记录
    依靠唯一索引抢占执行权
    状态为 PROCESSING,过期持有者可恢复
  4. 4
    执行状态机迁移
    创建订单并推进状态
    重复请求不重复执行创建逻辑
  5. 5
    保存并回放响应
    记录响应快照,后续请求返回同一结果
    客户端可安全重试

并发、超时与崩溃恢复的细节 ​

PROCESSING 状态不是写完就不管。正常执行可能持续几百毫秒,重复请求此时应得到 409 或查询指引,而不是再次创建订单。节点崩溃则需要恢复策略:记录 locked_at 和 locked_by,超过阈值后由后台任务校验订单是否已创建;已创建则补写响应快照,未创建才可安全回收。

请求指纹必须基于规范化后的业务参数。直接哈希原始 JSON 字符串会被字段顺序、空格、默认值影响;把时间戳、traceId、实验桶字段放进指纹,也会让正常重试被误判成冲突。我的做法是定义白名单 DTO,只纳入用户、商品、数量、优惠券、价格版本等会影响下单结果的字段。

价格和库存不要只信 Agent 入参。幂等解决的是重复执行,不解决越权和篡改。服务端应重新计算价格、校验用户与收货地址,并对优惠券做核销状态机控制。否则攻击者可以拿同一个 key 先低价请求、再尝试参数变化,虽然第二次会被 409 拦住,但第一次的非法价格已经进入业务流。

避坑总结:这几个问题都在生产里咬过人 ​

  • 不要把 requestId 当幂等键。 常见现象是超时后 SDK 生成新 requestId,订单重复创建;修正方式是让业务动作 key 在 Agent 工具调用、HTTP 请求和异步补偿中全程透传。
  • 不要只做 Redis SETNX。 TTL 设置为 1 秒时,慢请求尚未结束第二个请求就放行;设置为 10 分钟又会拖垮故障恢复。Redis 只能预拦截,数据库唯一记录和业务状态机才是最终裁决。
  • 同 Key 不同参数必须返回 409。 如果直接返回第一次成功结果,客户端会误以为新参数已生效;如果重新执行,又破坏幂等语义。记录指纹并明确 409 Conflict 最安全。
  • 状态迁移必须带期望状态。 例如 WHERE status = 'INIT',不能先查再更。并发取消、支付回调和补偿任务同时执行时,无条件更新会把终态订单覆盖回错误状态。
  • 成功响应要可回放。 只记录“处理过”不够,Agent 超时后拿不到订单号仍可能再次询问或触发新工具调用。保存订单号、状态和必要的脱敏响应,才能让重试方收敛。
  • 可观测性别只看错误率。 建议分别统计 key 抢占成功、结果回放、指纹冲突、处理中超时、崩溃恢复数量。重复请求大量命中回放是正常现象,指纹冲突持续升高则说明 Agent Runtime 复用 key 的逻辑有问题。

这套方案的本质不是增加一个请求头,而是把“模型可能重复发起工具调用”纳入接口契约。对 LLM Backend 来说,稳定的业务动作标识、服务端强约束和可回放结果缺一不可。本文首发于开发者日记,后续我会继续补上支付回调与 MQ 消费侧共用同一幂等状态机的工程化拆法。


<!-- 内容间隙推广位:正文之后内联渲染,不遮挡阅读;变现开关关闭时零渲染零占位 -->
<MonetizeSlot position="inline" domain="backend" />

<!-- 免责声明:文字与 data/site.config.yaml 的 disclaimer 保持完全一致,不得删改 -->
::: warning 免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。
:::