幂等性是 REST API 的一个关键概念,可确保系统的可靠性和一致性。幂等操作可以重复多次,而不会更改初始 API 请求之外的结果。此属性在分布式系统中尤其重要,因为网络故障或超时可能会导致重复请求。
在 API 中实现幂等性有几个好处:
它可以防止意外的重复操作
它提高了分布式系统的可靠性
它有助于处理网络问题并正常重试
在本周的期刊中,我们将探讨如何在 ASP.NET Core API 中实现幂等性,以确保您的系统保持稳健可靠。
什么是幂等性?
在 Web API 的上下文中,幂等意味着发出多个相同的请求应具有与发出单个请求相同的效果。换句话说,无论客户端发送同一请求多少次,服务器端效果都应该只发生一次。
关于 HTTP 语义的 RFC 9110 标准提供了我们可以使用的定义。以下是它对幂等方法的描述:
如果使用该方法的多个相同请求对服务器的预期效果与单个此类请求的效果相同,则认为该请求方法是“幂等的”。
在本规范定义的请求方法中,PUT、DELETE 和安全请求方法 [(GET、HEAD、OPTIONS 和 TRACE) — 作者注] 是幂等的。
- RFC 9110(HTTP 语义),第 9.2.2 节,第 1 段
但是,以下段落非常有趣。它阐明了服务器可以实现不适用于资源的“其他非幂等副作用”。
…幂等属性仅适用于用户请求的内容;服务器可以自由地单独记录每个请求,保留修订控制历史记录,或为每个幂等请求实现其他非幂等副作用。
- RFC 9110(HTTP 语义),第 9.2.2 节,第 2 段
实现幂等性的好处不仅限于遵守 HTTP 方法语义。它显著提高了 API 的可靠性,尤其是在网络问题可能导致重试请求的分布式系统中。通过实施幂等性,可以防止由于客户端重试而发生的重复操作。
哪些 HTTP 方法是幂等的?
几种 HTTP 方法本质上是幂等的:
GET, : 在不修改服务器状态的情况下检索数据。HEAD
PUT:更新资源,无论是否重复,都会产生相同的状态。
DELETE:删除多个请求具有相同结果的资源。
OPTIONS:检索通信选项信息。
POST本身并不是幂等的,因为它通常会创建资源或处理数据。重复请求可能会创建多个资源或触发多个操作。POST
但是,我们可以为使用自定义逻辑的方法实现幂等性。POST
注意:虽然请求不是天生的幂等的,但我们可以将它们设计为幂等的。例如,在创建之前检查现有资源可确保重复请求不会导致重复的操作或资源。POSTPOST
在 ASP.NET Core 中实现幂等性
为了实现幂等性,我们将使用涉及幂等性键的策略:
客户端为每个操作生成一个唯一密钥,并在自定义标头中发送该密钥。
服务器检查之前是否见过此键:
对于新密钥,请处理请求并存储结果。
对于已知键,返回存储的结果而不重新处理。
这可确保重试的请求(例如,由于网络问题)在服务器上仅处理一次。
我们可以通过组合 an 和 来实现控制器的幂等性。现在,我们可以指定将幂等性应用于控制器终端节点。AttributeIAsyncActionFilterIdempotentAttribute
注意:当请求失败(返回 4xx/5xx)时,我们不会缓存响应。这允许客户端使用相同的幂等密钥重试。但是,这意味着失败的请求后跟具有相同键的成功请求将成功 - 请确保这符合您的业务需求。
[AttributeUsage(AttributeTargets.Method)]
internal sealed class IdempotentAttribute : Attribute, IAsyncActionFilter
{
private const int DefaultCacheTimeInMinutes = 60;
private readonly TimeSpan _cacheDuration;
public IdempotentAttribute(int cacheTimeInMinutes = DefaultCacheTimeInMinutes)
{
_cacheDuration = TimeSpan.FromMinutes(minutes);
}
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
// Parse the Idempotence-Key header from the request
if (!context.HttpContext.Request.Headers.TryGetValue(
"Idempotence-Key",
out StringValues idempotenceKeyValue) ||
!Guid.TryParse(idempotenceKeyValue, out Guid idempotenceKey))
{
context.Result = new BadRequestObjectResult("Invalid or missing Idempotence-Key header");
return;
}
IDistributedCache cache = context.HttpContext
.RequestServices.GetRequiredService<IDistributedCache>();
// Check if we already processed this request and return a cached response (if it exists)
string cacheKey = $"Idempotent_{idempotenceKey}";
string? cachedResult = await cache.GetStringAsync(cacheKey);
if (cachedResult is not null)
{
IdempotentResponse response = JsonSerializer.Deserialize<IdempotentResponse>(cachedResult)!;
var result = new ObjectResult(response.Value) { StatusCode = response.StatusCode };
context.Result = result;
return;
}
// Execute the request and cache the response for the specified duration
ActionExecutedContext executedContext = await next();
if (executedContext.Result is ObjectResult { StatusCode: >= 200 and < 300 } objectResult)
{
int statusCode = objectResult.StatusCode ?? StatusCodes.Status200OK;
IdempotentResponse response = new(statusCode, objectResult.Value);
await cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(response),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = _cacheDuration }
);
}
}
}
internal sealed class IdempotentResponse
{
[JsonConstructor]
public IdempotentResponse(int statusCode, object? value)
{
StatusCode = statusCode;
Value = value;
}
public int StatusCode { get; }
public object? Value { get; }
}
注意:在检查和设置缓存之间有一个小的争用条件窗口。为了实现绝对一致性,我们应该考虑使用分布式锁模式,尽管这会增加复杂性和延迟。
现在,我们可以将此属性应用于我们的控制器操作:
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpPost]
[Idempotent(cacheTimeInMinutes: 60)]
public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
{
// Process the order...
return CreatedAtAction(nameof(GetOrder), new { id = orderDto.Id }, orderDto);
}
}
最少 API 的幂等性
要使用 Minimal API 实现幂等性,我们可以使用 .IEndpointFilter
internal sealed class IdempotencyFilter(int cacheTimeInMinutes = 60)
: IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
// Parse the Idempotence-Key header from the request
if (TryGetIdempotenceKey(out Guid idempotenceKey))
{
return Results.BadRequest("Invalid or missing Idempotence-Key header");
}
IDistributedCache cache = context.HttpContext
.RequestServices.GetRequiredService<IDistributedCache>();
// Check if we already processed this request and return a cached response (if it exists)
string cacheKey = $"Idempotent_{idempotenceKey}";
string? cachedResult = await cache.GetStringAsync(cacheKey);
if (cachedResult is not null)
{
IdempotentResponse response = JsonSerializer.Deserialize<IdempotentResponse>(cachedResult)!;
return new IdempotentResult(response.StatusCode, response.Value);
}
object? result = await next(context);
// Execute the request and cache the response for the specified duration
if (result is IStatusCodeHttpResult { StatusCode: >= 200 and < 300 } statusCodeResult
and IValueHttpResult valueResult)
{
int statusCode = statusCodeResult.StatusCode ?? StatusCodes.Status200OK;
IdempotentResponse response = new(statusCode, valueResult.Value);
await cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(response),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(cacheTimeInMinutes)
}
);
}
return result;
}
}
// We have to implement a custom result to write the status code
internal sealed class IdempotentResult : IResult
{
private readonly int _statusCode;
private readonly object? _value;
public IdempotentResult(int statusCode, object? value)
{
_statusCode = statusCode;
_value = value;
}
public Task ExecuteAsync(HttpContext httpContext)
{
httpContext.Response.StatusCode = _statusCode;
return httpContext.Response.WriteAsJsonAsync(_value);
}
}
现在,我们可以将此终端节点过滤器应用于我们的 Minimal API 终端节点:
app.MapPost("/api/orders", CreateOrder)
.RequireAuthorization()
.WithOpenApi()
.AddEndpointFilter<IdempotencyFilter>();
前两个实现的替代方案是在自定义中间件中实现幂等逻辑。
最佳实践和注意事项
以下是我在实现幂等性时始终牢记的关键事项。
缓存持续时间很棘手。我的目标是在不保留过时数据的情况下覆盖合理的重试窗口。合理的缓存时间通常从几分钟到 24-48 小时不等,具体取决于您的具体使用案例。
并发可能很痛苦,尤其是在高流量 API 中。使用分布式锁的线程安全实现效果很好。当同时收到多个请求时,它可以控制事情。但这应该是罕见的。
对于分布式设置,Redis 是我的首选。它非常适合作为共享缓存,在所有 API 实例之间保持幂等性一致。此外,它还处理分布式锁定。
如果客户端将幂等性密钥重新用于不同的请求正文,该怎么办?在这种情况下,我返回一个错误。我的方法是对请求正文进行哈希处理,并使用幂等键存储它。当收到请求时,我会比较请求正文的哈希值。如果它们不同,我将返回一个错误。这可以防止滥用幂等密钥并保持 API 的完整性。
在 REST API 中实现幂等性可以提高服务的可靠性和一致性。它确保相同的请求产生相同的结果,防止意外的重复并妥善处理网络问题。
虽然我们的实施提供了一个基础,但我建议您根据自己的需求进行调整。专注于 API 中的关键操作,尤其是那些修改系统状态或触发重要业务流程的操作。
通过采用幂等性,您可以构建更强大且用户友好的 API。
如果你喜欢我的文章,请给我一个赞!谢谢