ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

分布式系统中的幂等性设计与实践

分布式系统中的幂等性设计与实践

1. 从一次技术讨论引发的幂等性思考

那天下午的技术分享会上,我向林老师提出了困扰已久的问题:"为什么我们的支付系统总是出现重复扣款?"林老师没有直接回答,而是反问道:"你知道为什么软件系统中的二次请求无法避免吗?"这个问题像一把钥匙,打开了我对幂等性认知的新大门。

在软件开发中,幂等性是指同一个操作执行一次或多次,对系统状态的影响是相同的。这个概念看似简单,但在实际系统设计中却处处是陷阱。就像林老师举的例子:如果公司财务系统在发薪日重复执行了转账指令,员工可能会获得"双倍快乐",但公司财务就要面临"双倍痛苦"了。

2. 为什么幂等性如此重要?

2.1 网络世界的不可靠性

在分布式系统中,网络是不可靠的。客户端超时后重试是常见做法,但如果这个请求不是幂等的,就可能造成严重后果。以电商系统为例:

  • 用户点击"支付"按钮
  • 请求超时,客户端自动重试
  • 如果支付接口不幂等,用户可能被重复扣款

这就是为什么HTTP规范中,GET、PUT、DELETE被设计为幂等方法,而POST不是。在设计API时,我们需要根据操作性质选择合适的HTTP方法。

2.2 分布式系统的复杂性

现代系统普遍采用微服务架构,服务间的调用链可能很长。假设有这样的调用链:

前端 → 订单服务 → 支付服务 → 银行网关

任何一个环节都可能因超时而触发重试。如果没有幂等性保障,一笔支付可能在各个服务间产生不一致的状态。

3. 二次请求的来源与应对策略

3.1 前端重复提交

这是最常见的重复请求来源。用户可能因为:

  • 网络延迟导致多次点击
  • 提交后页面无反馈,误以为失败而重试

解决方案相对简单:

// 提交后禁用按钮 submitButton.disabled = true; // 或者跳转到结果页面 window.location.href = '/result';

3.2 系统自动重试

这类问题更难处理,主要包括:

  • 客户端自动重试机制
  • 中间服务器(如API网关)的重试策略
  • 消息队列的至少一次投递保证

根据"两将军问题"理论,这类问题在理论上无完美解决方案。工程实践中,我们只能接受"至少一次"投递,但通过幂等设计确保多次处理不会产生副作用。

4. 天然幂等的操作

4.1 创建资源的幂等性

某些操作天然具备幂等性。以银行开户为例:

public async Task HandleAsync(OpenAccountRequest request, CancellationToken token = default) { var account = new Account(request.AccountId, request.Balance); try { await _repository.InsertAsync(account, token); } catch (DuplicateKeyException) { // 忽略重复创建 } }

这种场景下,只需在数据库设置AccountId为唯一索引,重复请求会抛出异常被捕获处理。

4.2 查询类操作

所有只读操作都是天然幂等的,如:

  • 获取用户信息
  • 查询账户余额
  • 读取订单状态

这类操作不会改变系统状态,重复执行没有副作用。

5. 非幂等操作的解决方案

5.1 乐观锁方案及其局限

对于账户存取款这类非幂等操作,常见的解决方案是乐观锁:

public async Task UpdateAsync(Account account, decimal expectedBalance, CancellationToken token = default) { var sql = "UPDATE Accounts SET Balance = @Balance WHERE Id = @Id AND Balance = @ExpectedBalance"; // 执行SQL... }

但这种方案有两个明显问题:

  1. 灵活性差:需要预先知道哪些字段需要保护
  2. 无法区分重复请求和真实并发修改

特别是第二个问题可能导致严重错误。假设:

  1. 用户发起取款100元请求
  2. 请求超时重发
  3. 同时有人存入100元
  4. 第二次取款可能成功,导致账户错误

5.2 版本号方案

更完善的方案是引入版本号机制:

public async Task UpdateAsync(Account account, int expectedVersion, CancellationToken token = default) { var sql = "UPDATE Accounts SET Balance = @Balance, Version = @Version WHERE Id = @Id AND Version = @ExpectedVersion"; // 执行SQL... }

这个方案被广泛应用于各种系统中。以Grafana的数据源更新为例,其API明确要求提供version字段:

{ "id": 2, "uid": "tempo", "version": 18, // 其他字段... }

服务端实现类似这样:

if cmd.Version != 0 { updateSession = sess.Where("id=? and org_id=? and version < ?", ds.ID, ds.OrgID, ds.Version) } else { updateSession = sess.Where("id=? and org_id=?", ds.ID, ds.OrgID) }

这种方案的代价是在高并发场景下,很多请求会因为版本冲突被拒绝,但保证了数据的强一致性。

6. 基于事务的幂等控制

6.1 请求ID记录表

更彻底的解决方案是引入请求ID记录表:

public async Task UpdateAsync(Account account, Guid requestId, CancellationToken token = default) { await using var transaction = await connection.BeginTransactionAsync(token); try { // 先插入请求记录 await connection.ExecuteAsync("INSERT INTO RequestIds VALUES (@Id)", new {Id = requestId.ToString()}); // 再执行实际业务操作 await connection.ExecuteAsync("UPDATE Accounts SET Balance = @Balance WHERE Id = @Id", new {Id = account.Id, Balance = account.Balance}); await transaction.CommitAsync(token); } catch (Exception e) when (IsDuplicateKeyException(e)) { throw new DuplicateKeyException(); } }

这个方案的核心是:

  1. 为每个请求生成唯一ID
  2. 在事务中先记录请求ID
  3. 再执行业务操作
  4. 如果请求ID已存在,说明是重复请求

6.2 方案对比

方案优点缺点适用场景
乐观锁实现简单无法区分重复请求和真实并发低并发场景
版本号强一致性高并发时拒绝率高需要强一致性的场景
请求ID表准确识别重复请求需要额外存储金融等关键业务

7. 实际应用中的经验分享

7.1 幂等令牌的生成

生成请求ID的最佳实践:

  • 使用UUID v4或雪花算法
  • 客户端生成比服务端生成更好
  • 可以考虑结合用户ID和时间戳
// 使用Guid生成请求ID var requestId = Guid.NewGuid(); // 或者使用雪花算法 var requestId = Snowflake.NextId();

7.2 过期策略

请求记录需要设置合理的过期时间:

  • 太短可能无法覆盖所有重试
  • 太长会浪费存储空间

通常根据业务特点设置:

  • 支付系统:24小时
  • 订单系统:7天
  • 日志系统:可能不需要过期

7.3 分布式环境下的挑战

在分布式系统中实现幂等性还需要考虑:

  1. 分布式锁的性能问题
  2. 数据库唯一约束的局限性
  3. 跨服务调用的幂等传递

一个实用的模式是"预占-确认"两阶段:

  1. 预占阶段:检查并预留资源
  2. 确认阶段:实际执行业务操作

8. 常见问题与解决方案

8.1 重复请求与并发请求的区分

这是最难处理的问题之一。在实践中,我们可以:

  1. 对于明确的重试(如相同的请求ID),直接返回之前的结果
  2. 对于可能并发的请求(如转账到同一账户),使用更严格的锁机制

8.2 性能与一致性的权衡

高并发场景下,强一致性往往意味着性能下降。根据业务需求,可以考虑:

  1. 最终一致性:允许短暂不一致,通过补偿机制修复
  2. 读写分离:写操作强一致,读操作可以弱一致
  3. 分级处理:关键操作强一致,非关键操作可以放宽

8.3 事务失败的处理

当幂等控制本身失败时,应该:

  1. 明确错误类型(是重复请求还是系统错误)
  2. 提供足够的错误信息给客户端
  3. 记录详细的日志用于后续排查
catch (DuplicateKeyException ex) { _logger.LogWarning("Duplicate request detected: {RequestId}", requestId); throw new ApiException(409, "Duplicate request"); } catch (Exception ex) { _logger.LogError(ex, "Failed to process request"); throw new ApiException(500, "Internal server error"); }

9. 行业实践案例

9.1 支付宝的幂等设计

支付宝的接口设计遵循以下原则:

  1. 所有写操作接口必须幂等
  2. 使用商户订单号作为去重依据
  3. 交易结果查询接口保证幂等

9.2 AWS的幂等令牌

AWS很多API支持ClientToken参数:

POST /create-instance Content-Type: application/json { "InstanceType": "t2.micro", "ClientToken": "123e4567-e89b-12d3-a456-426614174000" }

服务端会记住这个令牌,重复请求返回相同结果。

9.3 Kafka的消息去重

Kafka生产者可以配置:

enable.idempotence=true transactional.id=my-transactional-id

这保证了:

  1. 单分区内消息不重复
  2. 跨分区事务支持

10. 测试幂等性的方法

10.1 单元测试

验证单个方法的幂等性:

[Fact] public async Task Deposit_ShouldBeIdempotent() { var account = new Account(Guid.NewGuid(), 100m); var requestId = Guid.NewGuid(); // 第一次存款 await _repository.DepositAsync(account, 50m, requestId); // 第二次相同请求 await _repository.DepositAsync(account, 50m, requestId); Assert.Equal(150m, account.Balance); // 余额应该是150,不是200 }

10.2 集成测试

模拟真实网络环境:

  1. 发送请求后立即断开连接
  2. 检查重试后的系统状态
  3. 验证数据一致性

10.3 混沌工程

使用工具如Chaos Mesh模拟:

  • 网络延迟
  • 服务中断
  • 消息重复

观察系统在这些情况下的行为是否符合预期。

11. 性能优化技巧

11.1 请求记录的存储优化

对于高频业务,可以:

  1. 使用Redis代替数据库存储请求记录
  2. 设置合理的过期时间
  3. 采用布隆过滤器减少存储压力
// 使用Redis记录请求ID var isNewRequest = await _redis.StringSetAsync( $"req:{requestId}", "1", TimeSpan.FromHours(24), When.NotExists);

11.2 批量操作的幂等处理

对于批量接口,可以:

  1. 为整个批次生成唯一ID
  2. 为每个子请求生成唯一ID
  3. 实现部分成功处理逻辑

11.3 缓存的应用

合理使用缓存可以:

  1. 缓存请求结果,快速响应重复请求
  2. 但要注意缓存一致性
  3. 建议设置较短的缓存时间
// 检查缓存中是否有该请求的结果 var cachedResult = await _cache.GetAsync<Response>($"result:{requestId}"); if (cachedResult != null) { return cachedResult; }

12. 架构层面的思考

12.1 CQRS模式下的幂等

在命令查询职责分离(CQRS)架构中:

  • 命令(Command)必须保证幂等
  • 查询(Query)天然幂等
  • 可以考虑为每个命令分配唯一ID

12.2 事件溯源的天然优势

事件溯源(Event Sourcing)架构:

  1. 每个状态变化都作为事件存储
  2. 相同事件重复应用不会改变最终状态
  3. 但需要处理重复事件的性能开销

12.3 Saga模式的挑战

在跨服务的Saga模式中:

  1. 每个补偿操作必须幂等
  2. 需要全局事务ID
  3. 实现复杂度较高

13. 新兴技术与幂等性

13.1 区块链的启示

区块链技术的特性:

  1. 交易一旦上链不可更改
  2. 每个交易有唯一哈希
  3. 这种设计天然防重复

可以借鉴的思路:

  • 使用密码学哈希作为请求指纹
  • 引入类似nonce的防重放机制

13.2 服务网格的助力

Istio等服务网格可以提供:

  1. 重试策略的集中管理
  2. 全局唯一的请求ID
  3. 跨服务的追踪能力

13.3 云原生的解决方案

云服务商提供的托管服务通常:

  1. 内置幂等性支持
  2. 提供全局唯一的请求ID生成
  3. 有完善的重试机制

如AWS的Step Functions就内置了任务去重功能。

14. 从幂等到数据一致性

幂等性最终是为了保证数据一致性。在实践中,我们还需要考虑:

  1. ACID与BASE的权衡
  2. CAP定理的约束
  3. 业务对一致性的实际需求

有时候,适当的最终一致性可能是更好的选择,特别是对于:

  • 用户体验敏感型应用
  • 高并发场景
  • 非关键业务操作

15. 个人实践心得

在多年的系统设计实践中,我总结了以下经验:

  1. 幂等性不是可选项,而是必选项
  2. 越早考虑幂等问题,后期成本越低
  3. 没有放之四海皆准的方案,需要根据业务特点选择
  4. 测试幂等性比实现幂等性更难
  5. 文档和注释同样重要,特别是对幂等设计的假设和约束

最后,记住林老师的话:"软件系统中的二次请求无法避免,但我们可以通过良好的设计,让它们不再成为问题。"这或许就是软件工程的艺术所在——在不可靠的环境中构建可靠的系统。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进