Skip to content

幂等性与安全性 ​

概念区分 ​

**幂等性(Idempotency)和安全性(Safety)**是 HTTP 规范中两个独立但相关的概念,理解它们的区别对于设计正确的 API 至关重要。

安全方法(Safe Methods) ​

定义 ​

根据 RFC 7231,安全方法是指那些不应该改变服务器状态的方法。换句话说,安全方法是只读操作。

特性 ​

  1. 不修改资源:安全方法不应该对服务器上的资源产生任何副作用
  2. 可缓存:响应可以被缓存以提高性能
  3. 可预取:浏览器可以预先执行安全请求
  4. 幂等:所有安全方法天然是幂等的

安全的 HTTP 方法 ​

方法是否安全是否幂等说明
GET✅ 是✅ 是获取资源,不修改
HEAD✅ 是✅ 是获取元数据,不修改
OPTIONS✅ 是✅ 是查询支持的方法,不修改
POST❌ 否❌ 否可能修改资源
PUT❌ 否✅ 是修改资源,但幂等
DELETE❌ 否✅ 是修改资源,但幂等
PATCH❌ 否⚠️ 视情况可能修改资源

核心区别 ​

安全性关注点:是否改变状态 ​

csharp
// ✅ 安全方法:只读取数据,不修改
[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetProduct(Guid id)
{
    var product = await _dbContext.Products.FindAsync(id);
    return Ok(product); // 仅返回数据,无副作用
}

// ❌ 不安全方法:会修改数据
[HttpPost]
public async Task<ActionResult<Product>> CreateProduct(CreateProductRequest request)
{
    var product = new Product { /* ... */ };
    _dbContext.Products.Add(product);
    await _dbContext.SaveChangesAsync(); // 有副作用:创建了新记录
    return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}

幂等性关注点:重复执行是否产生相同结果 ​

csharp
// 不安全但幂等:DELETE 会修改状态,但重复删除结果一致
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteProduct(Guid id)
{
    var product = await _dbContext.Products.FindAsync(id);
    if (product != null)
    {
        _dbContext.Products.Remove(product);
        await _dbContext.SaveChangesAsync();
    }
    return NoContent(); // 无论调用多少次,最终状态都是"已删除"
}

// 不安全且非幂等:POST 既修改状态,重复调用还产生不同结果
[HttpPost]
public async Task<ActionResult<Order>> CreateOrder(CreateOrderRequest request)
{
    var order = new Order { /* ... */ };
    _dbContext.Orders.Add(order);
    await _dbContext.SaveChangesAsync();
    return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
    // 每次调用都会创建不同的订单
}

四种组合类型 ​

1. 安全且幂等(Safe & Idempotent) ​

典型方法:GET、HEAD、OPTIONS

csharp
// 查询用户信息 - 安全且幂等
[HttpGet("{id}")]
public async Task<ActionResult<User>> GetUser(Guid id)
{
    var user = await _dbContext.Users.FindAsync(id);
    return Ok(user);
}

// 特性:
// - 不修改服务器状态(安全)
// - 重复调用返回相同结果(幂等)
// - 可以被浏览器、CDN 缓存
// - 可以被搜索引擎爬取

2. 不安全但幂等(Unsafe & Idempotent) ​

典型方法:PUT、DELETE

csharp
// 更新用户邮箱 - 不安全但幂等
[HttpPut("{id}/email")]
public async Task<IActionResult> UpdateEmail(Guid id, string email)
{
    var user = await _dbContext.Users.FindAsync(id);
    if (user == null) return NotFound();
    
    user.Email = email; // 修改了状态(不安全)
    await _dbContext.SaveChangesAsync();
    return NoContent();
}

// 特性:
// - 会修改服务器状态(不安全)
// - 重复调用结果一致(幂等)
// - 不能被缓存
// - 适合重试机制

3. 不安全且非幂等(Unsafe & Non-Idempotent) ​

典型方法:POST

csharp
// 创建订单 - 不安全且非幂等
[HttpPost]
public async Task<ActionResult<Order>> CreateOrder(CreateOrderRequest request)
{
    var order = new Order
    {
        Id = Guid.NewGuid(), // 每次生成不同的 ID
        UserId = request.UserId,
        Amount = request.Amount
    };
    
    _dbContext.Orders.Add(order);
    await _dbContext.SaveChangesAsync();
    return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}

// 特性:
// - 会修改服务器状态(不安全)
// - 重复调用产生不同结果(非幂等)
// - 需要额外的幂等性设计(如幂等键)

4. 安全但非幂等(理论上不存在) ​

注意:根据定义,所有安全方法都必须是幂等的。如果某个操作是非幂等的,那么它必然会产生副作用,因此不可能是安全的。

实际应用场景 ​

场景 1:搜索功能(安全且幂等) ​

csharp
// GET 请求进行搜索 - 安全且幂等
[HttpGet("search")]
public async Task<ActionResult<IEnumerable<Product>>> SearchProducts(
    [FromQuery] string keyword,
    [FromQuery] int page = 1,
    [FromQuery] int pageSize = 20)
{
    var query = _dbContext.Products.AsQueryable();
    
    if (!string.IsNullOrWhiteSpace(keyword))
    {
        query = query.Where(p => p.Name.Contains(keyword));
    }
    
    var total = await query.CountAsync();
    var products = await query
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .ToListAsync();
    
    Response.Headers.Add("X-Total-Count", total.ToString());
    return Ok(products);
}

// 多次搜索相同关键词返回相同结果
// GET /api/products/search?keyword=iPhone&page=1
// GET /api/products/search?keyword=iPhone&page=1
// GET /api/products/search?keyword=iPhone&page=1

为什么搜索应该是 GET 而不是 POST?

csharp
// ❌ 错误做法:使用 POST 进行搜索
[HttpPost("search")]
public async Task<ActionResult<IEnumerable<Product>>> SearchProductsPost(SearchRequest request)
{
    // 虽然功能相同,但违反了 HTTP 语义
    // - POST 暗示会修改资源
    // - 无法被浏览器缓存
    // - 不能被搜索引擎索引
}

// ✅ 正确做法:使用 GET 进行搜索
[HttpGet("search")]
public async Task<ActionResult<IEnumerable<Product>>> SearchProductsGet(
    [FromQuery] string keyword)
{
    // 符合 HTTP 语义
    // - 明确表示是只读操作
    // - 可以被缓存
    // - URL 可以分享和收藏
}

场景 2:计数器递增(不安全且非幂等) ​

csharp
// 文章浏览量统计 - 不安全且非幂等
[HttpPost("{id}/views/increment")]
public async Task<IActionResult> IncrementViewCount(Guid id)
{
    var article = await _dbContext.Articles.FindAsync(id);
    if (article == null) return NotFound();
    
    // 累加操作 - 每次调用都会改变状态
    article.ViewCount++;
    await _dbContext.SaveChangesAsync();
    
    return Ok(new { viewCount = article.ViewCount });
}

// 三次请求会产生不同的结果:
// POST /api/articles/123/views/increment -> ViewCount: 101
// POST /api/articles/123/views/increment -> ViewCount: 102
// POST /api/articles/123/views/increment -> ViewCount: 103

改进方案:使用 PUT 实现幂等更新

csharp
// 改进:使用 PUT 设置绝对值 - 不安全但幂等
[HttpPut("{id}/views")]
public async Task<IActionResult> SetViewCount(Guid id, int viewCount)
{
    var article = await _dbContext.Articles.FindAsync(id);
    if (article == null) return NotFound();
    
    // 设置为指定值 - 幂等操作
    article.ViewCount = viewCount;
    await _dbContext.SaveChangesAsync();
    
    return Ok(new { viewCount = article.ViewCount });
}

// 三次请求结果相同:
// PUT /api/articles/123/views { "viewCount": 100 } -> ViewCount: 100
// PUT /api/articles/123/views { "viewCount": 100 } -> ViewCount: 100
// PUT /api/articles/123/views { "viewCount": 100 } -> ViewCount: 100

场景 3:用户注册(不安全且需要幂等设计) ​

csharp
// 用户注册 - 默认非幂等,需要额外设计
[HttpPost("register")]
public async Task<ActionResult<User>> RegisterUser(RegisterRequest request)
{
    // 检查用户名是否已存在(业务层面的幂等性)
    var existingUser = await _dbContext.Users
        .FirstOrDefaultAsync(u => u.Username == request.Username);
    
    if (existingUser != null)
    {
        // 如果用户名已存在,返回已有用户信息(实现幂等)
        return Conflict(new { error = "Username already exists" });
    }
    
    // 创建新用户
    var user = new User
    {
        Id = Guid.NewGuid(),
        Username = request.Username,
        Email = request.Email,
        PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
        CreatedAt = DateTime.UtcNow
    };
    
    _dbContext.Users.Add(user);
    await _dbContext.SaveChangesAsync();
    
    return CreatedAtAction(nameof(GetUser), new { id = user.Id }, new 
    { 
        user.Id, 
        user.Username, 
        user.Email 
    });
}

// 通过数据库唯一约束保证幂等性
public class User
{
    public Guid Id { get; set; }
    
    [MaxLength(50)]
    [Unicode(false)]
    public string Username { get; set; } // 应该配置唯一索引
    
    [MaxLength(100)]
    public string Email { get; set; } // 应该配置唯一索引
    
    public string PasswordHash { get; set; }
    public DateTime CreatedAt { get; set; }
}

// DbContext 配置
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<User>()
        .HasIndex(u => u.Username)
        .IsUnique(); // 唯一索引保证幂等性
    
    modelBuilder.Entity<User>()
        .HasIndex(u => u.Email)
        .IsUnique();
}

安全性与缓存 ​

安全方法可以被缓存 ​

csharp
// 安全方法的响应可以被浏览器和 CDN 缓存
[HttpGet("{id}")]
[ResponseCache(Duration = 300)] // 缓存 5 分钟
public async Task<ActionResult<Product>> GetProduct(Guid id)
{
    var product = await _dbContext.Products.FindAsync(id);
    return Ok(product);
}

不安全方法不应被缓存 ​

csharp
// 不安全方法应该禁用缓存
[HttpPost]
[ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)]
public async Task<ActionResult<Order>> CreateOrder(CreateOrderRequest request)
{
    // ...
}

安全性与 CSRF 保护 ​

安全方法不需要 CSRF 保护 ​

javascript
// GET 请求(安全方法)不需要 CSRF token
fetch('/api/users/123')
  .then(response => response.json())
  .then(data => console.log(data));

不安全方法需要 CSRF 保护 ​

csharp
// POST/PUT/DELETE 需要 CSRF token 验证
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult<Order>> CreateOrder(CreateOrderRequest request)
{
    // ...
}
html
<!-- 前端需要包含 CSRF token -->
<form method="POST" action="/api/orders">
    <input type="hidden" name="__RequestVerificationToken" value="@token" />
    <!-- ... -->
</form>

最佳实践总结 ​

1. 严格遵循 HTTP 语义 ​

csharp
// ✅ 正确:使用 GET 进行查询
[HttpGet("{id}")]
public async Task<ActionResult<T>> Get(Guid id) { /* ... */ }

// ✅ 正确:使用 POST 进行创建
[HttpPost]
public async Task<ActionResult<T>> Create(T request) { /* ... */ }

// ✅ 正确:使用 PUT 进行完整更新
[HttpPut("{id}")]
public async Task<IActionResult> Update(Guid id, T request) { /* ... */ }

// ✅ 正确:使用 DELETE 进行删除
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(Guid id) { /* ... */ }

// ❌ 错误:使用 POST 进行查询
[HttpPost("get-user")]
public async Task<ActionResult<User>> GetUser([FromBody] GetUserRequest request) 
{ 
    /* ... */ 
}

2. 为不安全操作设计幂等性 ​

csharp
// 对于 POST 请求,强制要求幂等键
[HttpPost]
public async Task<ActionResult<T>> Create(
    [FromBody] T request,
    [FromHeader(Name = "Idempotency-Key")] string idempotencyKey)
{
    if (string.IsNullOrWhiteSpace(idempotencyKey))
    {
        return BadRequest("Idempotency-Key is required");
    }
    
    // 实现幂等逻辑
}

3. 文档化 API 的安全性和幂等性 ​

csharp
/// <summary>
/// 创建新订单
/// </summary>
/// <remarks>
/// 此操作是不安全且非幂等的。
/// 必须提供 Idempotency-Key 请求头来实现幂等性。
/// </remarks>
/// <param name="request">订单创建请求</param>
/// <param name="idempotencyKey">幂等键,用于防止重复提交</param>
/// <returns>创建的订单</returns>
/// <response code="201">订单创建成功</response>
/// <response code="409">幂等键已存在,返回已有订单</response>
[HttpPost]
[ProducesResponseType(typeof(Order), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<Order>> CreateOrder(
    [FromBody] CreateOrderRequest request,
    [FromHeader(Name = "Idempotency-Key")] string idempotencyKey)
{
    // ...
}

对比总结表 ​

维度安全性(Safety)幂等性(Idempotency)
关注点是否修改服务器状态重复执行是否产生相同结果
判断标准是否有副作用f(f(x)) = f(x)
典型方法GET、HEAD、OPTIONSGET、PUT、DELETE
缓存安全方法可缓存幂等方法可安全重试
CSRF不安全方法需保护无关
重试无关幂等方法可安全重试

总结 ​

  1. 安全性和幂等性是两个独立的概念:

    • 安全性关注是否改变状态
    • 幂等性关注重复执行的结果
  2. 所有安全方法都是幂等的,但幂等方法不一定是安全的

  3. 设计原则:

    • 查询用 GET(安全且幂等)
    • 创建用 POST(不安全且非幂等,需额外设计)
    • 替换用 PUT(不安全但幂等)
    • 删除用 DELETE(不安全但幂等)
  4. 实际应用:

    • 安全方法可以被缓存和预取
    • 不安全方法需要 CSRF 保护
    • 非幂等方法需要幂等键或其他机制来保证幂等性

下一章我们将探讨为什么需要幂等性,以及它在分布式系统中的重要性。

Released under the MIT License.