幂等性与安全性
概念区分
**幂等性(Idempotency)和安全性(Safety)**是 HTTP 规范中两个独立但相关的概念,理解它们的区别对于设计正确的 API 至关重要。
安全方法(Safe Methods)
定义
根据 RFC 7231,安全方法是指那些不应该改变服务器状态的方法。换句话说,安全方法是只读操作。
特性
- 不修改资源:安全方法不应该对服务器上的资源产生任何副作用
- 可缓存:响应可以被缓存以提高性能
- 可预取:浏览器可以预先执行安全请求
- 幂等:所有安全方法天然是幂等的
安全的 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、OPTIONS | GET、PUT、DELETE |
| 缓存 | 安全方法可缓存 | 幂等方法可安全重试 |
| CSRF | 不安全方法需保护 | 无关 |
| 重试 | 无关 | 幂等方法可安全重试 |
总结
安全性和幂等性是两个独立的概念:
- 安全性关注是否改变状态
- 幂等性关注重复执行的结果
所有安全方法都是幂等的,但幂等方法不一定是安全的
设计原则:
- 查询用 GET(安全且幂等)
- 创建用 POST(不安全且非幂等,需额外设计)
- 替换用 PUT(不安全但幂等)
- 删除用 DELETE(不安全但幂等)
实际应用:
- 安全方法可以被缓存和预取
- 不安全方法需要 CSRF 保护
- 非幂等方法需要幂等键或其他机制来保证幂等性
下一章我们将探讨为什么需要幂等性,以及它在分布式系统中的重要性。