问题的起点非常具体:一个前端应用在调用创建订单的 API 时,因为网络抖动导致了超时。用户的本能反应是重试,结果后端数据库里出现了两条完全相同的订单记录。这是一个典型的由于非幂等 API 设计导致的生产事故。最初的修复方案是在前端生成一个 UUID 作为请求标识,但很快发现,依赖客户端的方案在复杂场景下并不可靠,例如不同客户端实现不一,或者恶意用户可以轻易绕过。我们需要一个更健壮的、服务端驱动的幂等性保证机制。
初步构想与技术选型
核心思路是将幂等性控制的责任完全收到服务端。一个完整的请求生命周期,从网关到应用服务,都需要被纳入考量。
请求唯一标识: 不能信任客户端。最可靠的标识生成点是流量入口,也就是我们的反向代理 Nginx。Nginx 可以为每个进入的请求生成一个唯一的
request_id
,并将其作为 Header 注入到上游请求中。幂等性处理层: 这应该在应用层,即 Go Echo 框架中以中间件的形式实现。这个中间件的职责是拦截带有特定幂等键的请求,检查该键是否已被处理。
状态存储: 需要一个高速的、共享的存储来记录幂等键的处理状态。Redis 是不二之选。它的原子操作 (如
SETNX
) 和过期时间 (TTL) 特性,天然适合这个场景。处理流程:
- Nginx 接收请求,生成
X-Request-Id
并透传给 Echo 服务。 - 客户端在发起
POST/PUT/PATCH
等非幂等操作时,在 Header 中附加一个自己生成的X-Idempotency-Key
。 - Echo 的幂等中间件捕获这两个 Header。
- 中间件使用
X-Idempotency-Key
作为 Redis 的键。 - 检查 Redis 中此键的状态:
- 不存在: 这是一个新请求。立即以 “in-progress” 状态写入 Redis 并设置一个较短的 TTL(防止进程崩溃导致死锁)。然后继续执行业务逻辑。业务逻辑执行成功后,将请求的完整响应(状态码、Header、Body)序列化后存入 Redis,并设置一个更长的 TTL。
- 存在,状态为 “in-progress”: 说明有相同请求正在处理,可能由并发导致。直接返回冲突错误(例如
409 Conflict
)。 - 存在,状态为 “completed”: 说明请求已成功处理。直接从 Redis 中读取缓存的响应并返回给客户端,不再执行业务逻辑。
- Nginx 接收请求,生成
这个流程覆盖了正常请求、重复请求和并发请求三种核心场景。
sequenceDiagram participant Client participant Nginx participant EchoApp as Echo Middleware participant Redis participant Handler as Business Logic Client->>+Nginx: POST /orders with X-Idempotency-Key Nginx->>+EchoApp: Forward request with X-Request-Id & X-Idempotency-Key Note over EchoApp, Redis: Check idempotency key in Redis EchoApp->>+Redis: GET idempotency_key Redis-->>-EchoApp: Key does not exist Note over EchoApp, Redis: Key is new, lock it EchoApp->>+Redis: SETNX idempotency_key "in-progress" (with short TTL) Redis-->>-EchoApp: OK EchoApp->>+Handler: Process request Handler-->>-EchoApp: Business logic successful (e.g., order created) Note over EchoApp, Redis: Cache the final response EchoApp->>+Redis: SET idempotency_key '{"status": 201, "body": "...", "headers": "..."}' (with long TTL) Redis-->>-EchoApp: OK EchoApp-->>-Nginx: 201 Created Response Nginx-->>-Client: 201 Created Response %% Retry Scenario Client->>+Nginx: POST /orders with same X-Idempotency-Key (Retry) Nginx->>+EchoApp: Forward request EchoApp->>+Redis: GET idempotency_key Redis-->>-EchoApp: Key exists with cached response Note over EchoApp: Key found, serve from cache EchoApp-->>-Nginx: Cached 201 Created Response Nginx-->>-Client: Cached 201 Created Response
Nginx 层:注入唯一请求 ID
这是整个链路的第一环。我们修改 Nginx 的配置,利用其内置变量 $request_id
,这是一个32位的十六进制随机字符串,足以保证唯一性。
nginx.conf
的相关 http
或 server
块配置:
# /etc/nginx/nginx.conf
http {
# ... other settings
# 定义日志格式,加入 $request_id
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'req_id=$request_id';
access_log /var/log/nginx/access.log main;
server {
listen 80;
server_name api.example.com;
location / {
# 向上游代理时,添加 X-Request-Id 头
proxy_set_header X-Request-Id $request_id;
# 其他代理设置
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://backend_app; # 指向你的 Go Echo 应用
}
}
upstream backend_app {
server 127.0.0.1:8080; # Go Echo 应用监听的地址和端口
}
# ... other settings
}
这个配置不仅将 X-Request-Id
传递给了后端,还将其加入了访问日志。这在排查问题时至关重要,我们可以通过一个 ID 串联起从网关到应用内部的所有日志。
Go Echo 层:幂等中间件的实现
这是系统的核心。我们将创建一个 IdempotencyMiddleware
。
项目结构:
.
├── go.mod
├── go.sum
├── main.go
├── middleware
│ └── idempotency.go
└── store
└── redis.go
1. Redis 存储层封装
首先,我们需要一个简单的 Redis 客户端封装。
store/redis.go
:
package store
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
var Rdb *redis.Client
// InitRedis 初始化 Redis 客户端连接
func InitRedis(addr string, password string, db int) {
Rdb = redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := Rdb.Ping(ctx).Result()
if err != nil {
panic("Failed to connect to Redis: " + err.Error())
}
}
2. 核心中间件 idempotency.go
这是最复杂的部分,包含了锁、状态管理和响应缓存的逻辑。
middleware/idempotency.go
:
package middleware
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/redis/go-redis/v9"
"yourapp/store" // 替换为你的项目路径
)
const (
IdempotencyKeyHeader = "X-Idempotency-Key"
InProgress = "in-progress"
InProgressTTL = 5 * time.Second // 防止进程崩溃导致永久锁
CompletedTTL = 24 * time.Hour // 缓存已完成响应的时长
)
// CachedResponse 用于存储完整的 HTTP 响应
type CachedResponse struct {
StatusCode int `json:"status_code"`
Headers http.Header `json:"headers"`
Body []byte `json:"body"`
}
// responseWriter 捕获响应体
type responseWriter struct {
http.ResponseWriter
body *bytes.Buffer
}
func (rw *responseWriter) Write(b []byte) (int, error) {
rw.body.Write(b)
return rw.ResponseWriter.Write(b)
}
// IdempotencyMiddleware 返回一个 Echo 中间件函数
func IdempotencyMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// 只对 POST, PUT, PATCH, DELETE 方法应用幂等性检查
method := c.Request().Method
if method != http.MethodPost && method != http.MethodPut && method != http.MethodPatch && method != http.MethodDelete {
return next(c)
}
idempotencyKey := c.Request().Header.Get(IdempotencyKeyHeader)
if idempotencyKey == "" {
// 对于需要幂等性的操作,如果客户端没有提供 key,则直接拒绝
// 也可以选择跳过,取决于业务策略
return c.JSON(http.StatusBadRequest, map[string]string{"error": "X-Idempotency-Key header is required"})
}
ctx := c.Request().Context()
// 1. 检查 Redis 中是否存在该键
cachedVal, err := store.Rdb.Get(ctx, idempotencyKey).Result()
if err != nil && err != redis.Nil {
// Redis 出错,服务降级,直接处理请求但有重复风险
c.Logger().Errorf("Redis error on GET idempotency key '%s': %v", idempotencyKey, err)
return next(c)
}
if err == nil { // Key 存在
if cachedVal == InProgress {
// 2a. 请求正在处理中,返回冲突
return c.JSON(http.StatusConflict, map[string]string{"error": "Request with this key is already in progress"})
}
// 2b. 请求已完成,返回缓存的响应
var cachedResp CachedResponse
if err := json.Unmarshal([]byte(cachedVal), &cachedResp); err != nil {
c.Logger().Errorf("Failed to unmarshal cached response for key '%s': %v", idempotencyKey, err)
// 数据损坏,降级处理
return next(c)
}
// 恢复 Header
for key, values := range cachedResp.Headers {
for _, value := range values {
c.Response().Header().Add(key, value)
}
}
c.Response().WriteHeader(cachedResp.StatusCode)
_, err := c.Response().Write(cachedResp.Body)
return err
}
// 3. Key 不存在,是新请求。加锁。
// SETNX 是原子操作,成功返回 true
locked, err := store.Rdb.SetNX(ctx, idempotencyKey, InProgress, InProgressTTL).Result()
if err != nil || !locked {
c.Logger().Errorf("Failed to acquire lock for key '%s': %v, locked: %v", idempotencyKey, err, locked)
// 获取锁失败,可能是并发,返回冲突
return c.JSON(http.StatusConflict, map[string]string{"error": "Failed to process request due to a concurrent attempt"})
}
// 捕获响应
originalWriter := c.Response().Writer
writer := &responseWriter{
ResponseWriter: originalWriter,
body: bytes.NewBuffer(nil),
}
c.Response().Writer = writer
// 执行下游处理器
err = next(c)
// 恢复原始 writer
c.Response().Writer = originalWriter
// 4. 业务逻辑处理完成,缓存响应
if err == nil && c.Response().Status >= 200 && c.Response().Status < 300 {
// 只缓存成功的响应
respToCache := CachedResponse{
StatusCode: c.Response().Status,
Headers: c.Response().Header().Clone(),
Body: writer.body.Bytes(),
}
cachedData, marshalErr := json.Marshal(respToCache)
if marshalErr != nil {
c.Logger().Errorf("Failed to marshal response for caching, key '%s': %v", idempotencyKey, marshalErr)
// 即使缓存失败,请求也已经成功了,所以不返回错误给客户端
} else {
// 写入缓存并设置长 TTL
setErr := store.Rdb.Set(ctx, idempotencyKey, cachedData, CompletedTTL).Err()
if setErr != nil {
c.Logger().Errorf("Failed to cache response for key '%s': %v", idempotencyKey, setErr)
}
}
} else {
// 5. 如果业务处理失败,或者响应码不是 2xx,则删除锁,允许客户端重试
delErr := store.Rdb.Del(ctx, idempotencyKey).Err()
if delErr != nil {
c.Logger().Errorf("Failed to release lock on failed request for key '%s': %v", idempotencyKey, delErr)
}
}
return err // 返回原始的 handler 错误
}
}
}
关键设计点解释:
- 方法过滤: 幂等性只对改变资源状态的
POST
,PUT
,PATCH
,DELETE
有意义。GET
天然是幂等的,无需处理。 - 响应捕获: 为了缓存完整的响应,我们必须替换
c.Response().Writer
,捕获所有写入的数据,然后在处理完成后再将其恢复。这是一个关键技巧。 - 错误处理: 如果 Redis 出现问题,我们选择服务降级,即继续处理请求。这会失去幂等性保证,但保证了服务的可用性。这是一个权衡,在某些对幂等性要求极高的场景(如支付),可能会选择直接返回 500 错误。
- 失败重试: 如果业务逻辑执行失败(
err != nil
或非 2xx 响应),我们必须删除in-progress
锁,这样客户端才能使用相同的幂等键进行重试。
3. 在 main.go
中集成
main.go
:
package main
import (
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/google/uuid"
appMw "yourapp/middleware" // 替换为你的项目路径
"yourapp/store" // 替换为你的项目路径
)
type CreateOrderRequest struct {
ProductID string `json:"product_id"`
Quantity int `json:"quantity"`
}
type Order struct {
ID string `json:"id"`
ProductID string `json:"product_id"`
Quantity int `json:"quantity"`
CreatedAt time.Time `json:"created_at"`
}
func main() {
// 初始化 Redis
store.InitRedis("localhost:6379", "", 0)
e := echo.New()
// 标准中间件
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// 应用幂等中间件
// 应该放在 Logger 之后,业务逻辑之前
e.Use(appMw.IdempotencyMiddleware())
// 模拟一个创建订单的 API
e.POST("/orders", func(c echo.Context) error {
reqID := c.Request().Header.Get("X-Request-Id")
c.Logger().Infof("Processing request with ID: %s", reqID)
var req CreateOrderRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
// 模拟耗时的业务逻辑,比如数据库操作
time.Sleep(2 * time.Second)
// 模拟一个随机的业务失败,以测试失败重试逻辑
if time.Now().Unix()%10 < 3 { // 30% 概率失败
c.Logger().Errorf("Simulating a business logic failure for req ID: %s", reqID)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create order in database"})
}
order := Order{
ID: uuid.New().String(),
ProductID: req.ProductID,
Quantity: req.Quantity,
CreatedAt: time.Now(),
}
return c.JSON(http.StatusCreated, order)
})
e.Logger.Fatal(e.Start(":8080"))
}
前端验证:一个简单的 UnoCSS 页面
为了验证我们的后端实现,我们需要一个能发送带幂等键并能处理重试的前端页面。我们使用 UnoCSS 来快速构建界面,因为它不需要构建步骤,可以直接通过 CDN 使用。
index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Idempotency Test Client</title>
<script src="https://cdn.jsdelivr.net/npm/@unocss/runtime"></script>
<style>
/* UnoCSS Preset */
[un-cloak] { display: none; }
</style>
</head>
<body class="bg-gray-100 font-sans p-8">
<div un-cloak class="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">API Idempotency Test</h1>
<div class="mb-4">
<label for="productId" class="block text-sm font-medium text-gray-700">Product ID</label>
<input type="text" id="productId" value="prod-12345" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="mb-6">
<label for="quantity" class="block text-sm font-medium text-gray-700">Quantity</label>
<input type="number" id="quantity" value="1" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500">
</div>
<button id="createOrderBtn" class="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Create Order
</button>
<div class="mt-6">
<h2 class="text-lg font-semibold">Logs:</h2>
<pre id="logs" class="mt-2 p-4 bg-gray-800 text-white rounded-md text-sm whitespace-pre-wrap h-64 overflow-y-auto"></pre>
</div>
</div>
<script>
const productIdInput = document.getElementById('productId');
const quantityInput = document.getElementById('quantity');
const createOrderBtn = document.getElementById('createOrderBtn');
const logsContainer = document.getElementById('logs');
function log(message) {
const now = new Date().toLocaleTimeString();
logsContainer.textContent += `[${now}] ${message}\n`;
logsContainer.scrollTop = logsContainer.scrollHeight;
}
// UUID v4 generator
function uuidv4() {
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
createOrderBtn.addEventListener('click', async () => {
const idempotencyKey = uuidv4();
log(`Starting new order creation with Idempotency-Key: ${idempotencyKey}`);
const body = {
product_id: productIdInput.value,
quantity: parseInt(quantityInput.value, 10),
};
const headers = {
'Content-Type': 'application/json',
'X-Idempotency-Key': idempotencyKey,
};
const sendRequest = async (attempt) => {
log(`Attempt #${attempt}: Sending POST /orders...`);
try {
const response = await fetch('/orders', {
method: 'POST',
headers,
body: JSON.stringify(body),
});
const responseBody = await response.json();
log(`Attempt #${attempt}: Received status ${response.status}`);
log(`Response body: ${JSON.stringify(responseBody, null, 2)}`);
if (response.ok) {
log("Order created successfully.");
} else if (response.status === 409) {
log("Conflict detected. Another request is in progress or completed.");
} else {
log(`Request failed with status ${response.status}.`);
}
} catch (error) {
log(`Attempt #${attempt}: Network error or fetch failed: ${error.message}`);
}
};
// Simulate two quick, identical requests to test idempotency
await sendRequest(1);
setTimeout(() => sendRequest(2), 500); // Second request fires half a second later
});
</script>
</body>
</html>
将这个 HTML 文件放在一个可以被 Nginx 服务的目录中,或者直接用 Go Echo 启动一个静态文件服务。点击 “Create Order” 按钮,观察网络请求和日志输出。你会看到:
- 第一个请求发出,后端开始处理(2秒延迟)。
- 第二个请求(使用相同的幂等键)在 500ms 后发出。
- 如果第一个请求仍在处理中,第二个请求会立即收到
409 Conflict
。 - 第一个请求处理完成后,返回
201 Created
。 - 此后,任何使用相同幂等键的请求都会立即从缓存中收到
201 Created
响应,且内容与第一次完全相同,后端日志不会显示”Processing request…”。
方案的局限性与未来迭代
这套方案在大多数场景下足够健壮,但仍有其边界和可优化之处:
- Redis 依赖: Redis 成为一个关键的单点。如果 Redis 集群宕机,幂等性保证会失效。需要为 Redis 搭建高可用架构。
- 存储成本: 如果写操作非常频繁,
CompletedTTL
设置得过长,Redis 的内存占用会持续增长。需要有合理的 TTL 策略或定期清理机制。 - 非原子性操作窗口: 在
SETNX
成功后,到next(c)
执行完成并写回最终结果之间,如果应用进程被强制杀死,in-progress
锁会一直存在直到InProgressTTL
过期。这个 TTL 是一个可用性与一致性之间的权衡。 - 请求体比较: 当前方案只认
X-Idempotency-Key
。一个更严格的实现可能会对请求体(Body)进行哈希,并与幂等键一起作为复合键,以防止客户端用同一个键发送不同内容的请求。但这会增加复杂性和计算成本。
未来的迭代可以探索使用分布式锁(如 Redlock 算法)来增强 in-progress
锁的可靠性,或者将幂等性检查下沉到更靠近数据层的服务,以实现跨应用的幂等性保证。