外观
{title}
结论先行:只靠数据库唯一索引挡不住 Agent 重复下单
AI Agent 调下单接口时,幂等性必须同时覆盖“同一请求的网络重试”和“同一意图的工具重复执行”。我的落地结论是:客户端或 Agent Runtime 生成稳定的 Idempotency-Key,服务端用请求指纹识别参数冲突,再用数据库唯一约束和订单状态机保证只有一次业务迁移。上线后,我们把压测与真实 Agent 场景中的重复下单率从 3.2% 压到 0,重复请求的响应回放 P99 约 8ms。
这个问题不是传统 HTTP 重试独有。LLM 在函数调用失败、工具结果解析超时、对话恢复或多轮规划时,可能再次发起“看起来一样”的工具调用;更麻烦的是,它有时会重新生成订单号、改了优惠券字段,却沿用旧的业务语义。只判断“有没有订单号”会把脏数据放进来,只加 Redis 锁又会在过期、故障恢复和跨库一致性上留下口子。
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 与业务库存在一致性窗口
适合削峰预拦截,不适合承担最终正确性
推荐
3Key 加指纹加状态机
- 语义完整
- 可并发抢占
- 支持结果回放
- 需要建表和改造接口
- 业务状态流转要收敛
资金类下单接口的推荐方案
唯一订单号是必要兜底,但不是完整方案。如果订单号由服务端生成,第一次超时后 Agent 根本拿不到它;如果由客户端生成,模型重试又可能生成新值。Redis SETNX 适合做高频预拦截,但 TTL 短于业务执行时间会误放行,TTL 过长又会影响故障恢复,最终状态仍要落数据库。
最终采用“Key + 指纹 + 状态机”的组合:
Idempotency-Key标识同一次业务动作;- 请求指纹判断同 Key 下参数是否一致;
- 唯一索引保证并发请求只有一个执行者;
- 订单状态机约束创建、支付、取消等迁移;
- 成功响应落库,后续重复请求直接回放。
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读取请求头获取 Idempotency-Key,缺失时拒绝或由可信网关注入建议 Key 长度限制为 64 到 128 字符
- 2计算请求指纹规范化 JSON 后计算 SHA-256时间戳、traceId 等易变字段必须剔除
- 3插入处理记录依靠唯一索引抢占执行权状态为 PROCESSING,过期持有者可恢复
- 4执行状态机迁移创建订单并推进状态重复请求不重复执行创建逻辑
- 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 免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。
:::