双份请求,双倍快乐

hello, 这是有态度马甲的第186篇原创口水文。有趣指数5颗星,有用指数5颗星。

😠😠本文是国外技术网站medium上点赞超过200+的翻译/笔记文,有关规避/解决幂等请求的思路指南。

1. 软件领域二次请求无法避免

我们生活的每时每刻都是独一无二的,事情/动作可能不会相同的形式再次发生。

在软件领域,同一动作请求并不总会只产生一次,这可能会带来一些问题: 想象你月底发薪,公司的转账指令错误的触发了2次,这是不是双倍快乐。

我总结:

二次请求的来源 能避免出现吗? 怎么避免出现?
前端的频繁点击提交 提交后置灰按钮/提交后切换页面/防误触来解决
客户端/中间服务器的重试动作 不能 -

根据双将军理论,即使A/B将军不断确认收到对方的上一条信息, 也没办法确保对方与自己达成(同一时间攻击的共识)。

两将军问题是无解的,间歇性重试是一种工程解。 (还有散弹打鸟)

:我们一直发送相同的服务请求,直到我们确定收到它(虽然可能会多次收到), 这就叫至少一次交付。

但是我们不希望被扣款两次,那我们就必须确保多次处理相同的请求不会改变最初的应用状态, 这是幂等请求的重点。

2. 某些请求天然幂等,你不需要做什么

想象你正在银行开户。

csharp 复制代码
public sealed class Account
{
    public Guid Id { get; }
    public decimal Balance { get; private set; }

    public Account(Guid id, decimal balance)
    {
        if (id == default)
            throw new InvalidOperationException("Account id must be provided");

        if (balance < 0)
            throw new InvalidOperationException("Balance cannot be negative");

        Id = id;
        Balance = balance;
    }
   
    // 取钱
    public void Withdraw(decimal amount)
    {
        if (amount < 0)
            throw new InvalidOperationException("Cannot withdraw negative amount");
        
        if (amount > Balance)
            throw new InvalidOperationException("Cannot withdraw more than existing balance");

        Balance -= amount;
    }

    // 存钱
    public void Deposit(decimal amount)
    {
        if (amount < 0)
            throw new InvalidOperationException("Cannot deposit negative amount");
        
        Balance += amount;
    }
}

前端发起的开户请求OpenAccountRequest是幂等的, 只需要在开户逻辑里面检查 数据表是不是存在这个AccountId

你甚至可在数据库设置AccountId为唯一索引,让重试动作爆出异常。

csharp 复制代码
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)
    {
        //Ignore
    }
}

对于存钱(WithDraw)取钱(Deposit)就不行了,如果因为网络原因而重试了2次存钱请求(deposit),岂不就是双倍快乐。

3. 乐观锁的介入一定合理吗?

一种处理重复请求的方式是质询实体的状态,严格意义来讲, 这个方案是来解决更大叙事背景(乐观锁)下的方案。

首先我们知道高并发场景下,有一个叫乐观锁的并发控制机制,乐观地认为数据在操作时不会冲突, 因此在操作前不加锁,在提交时检查数据是否被修改。

文中一开始: 让前端在请求时带上需要保护的Balance, 在更新时利用AccountId+原Balance来定位并更新账户。

csharp 复制代码
// 下面的前端DTO需要带上账户余额,(二次请求也是这个值)。
public sealed class DepositToAccountRequest
{
    public Guid AccountId { get; }
    public decimal Amount { get; }   // 操作金额
    public decimal AccountBalance { get; }

    public DepositToAccountRequest(Guid accountId, decimal amount, decimal accountBalance)
    {
        AccountId = accountId;
        Amount = amount;
        AccountBalance = accountBalance;
    }
}
csharp 复制代码
public async Task HandleAsync(DepositToAccountRequest request, CancellationToken token = default)
{
    var account = await _repository.GetAsync(request.AccountId, token) ?? 
                  throw new EntityNotFoundException();

    account.Deposit(request.Amount);

    await _repository.UpdateAsync(account, request.AccountBalance, token);
    
    
public sealed class AccountRepository : IAccountRepository
{
    //....

    public async Task UpdateAsync(Account account, decimal expectedBalance, CancellationToken token = default)
    {
        var sql = "UPDATE Accounts SET Balance = @Balance WHERE Id = @Id AND Balance = @ExpectedBalance";
        var sqlParams = new
        {
            Id = account.Id, 
            Balance = account.Balance,  // 新余额
            ExpectedBalance = expectedBalance  // 原余额
        };

        await using var connection = new SqlConnection(_connectionString);
        await connection.OpenAsync(token);
        
        var rowsAffected = await connection.ExecuteAsync(sql, sqlParams);
        if (rowsAffected == 0)
            throw new InvalidStateException();
    }

    //....
}

读者肯定也发现了:

① 这个方式不灵活,如果不是Balance,或者不只是Balance, 那么这个sql逻辑就得变化;

② 另一方面,这个方式归根到底不识别重复请求,不知道这是重复请求,还是底层的数据真的发生了变化。

想象你被触发了第二次取钱请求, 若此时刚好有人给你存了一笔钱(刚好等于你第一次取钱金额),促使你的第二次取钱请求成功了,这岂不是新的双倍悲伤。

所以文中提出了基于宏达叙事的正经方案: 状态版本 在前端DTO请求带上AccountVersion,每次更新时用AccoundId+原AccountVersion去定位、更新状态快照, 如果找不到说明实体状态已经变化,需要报错给到前端,让前端重新拉取做动作。

ini 复制代码
    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";
        var sqlParams = new
        {
            Id = account.Id, 
            Balance = account.Balance, 
            Version = account.Version,
            ExpectedVersion = expectedVersion
        };

        await using var connection = new SqlConnection(_connectionString);
        await connection.OpenAsync(token);
        
        var rowsAffected = await connection.ExecuteAsync(sql, sqlParams);
        if (rowsAffected == 0)
            throw new InvalidStateException();
    }

这种乐观锁的思想去解决幂等问题有一个小弊端, 因为乐观锁的思想本是针对并发控制,它解决了并发请求中的重复请求这一子集场景,但是带来的副作用就是高并发时,很多请求会被拒绝(重试请求会被拒绝,并发请求也会被拒绝),效率变低,但数据不一致问题没有了,双倍悲伤也不会有。

4. 用数据库事务包围 更简单、常规

你有一张表来存储 requestId的历史记录, 这个表保证requestId唯一。

那么通过事务: requestId先插入历史记录表、 实际的请求动作,便可以真实解决幂等问题, 这是真的幂等, 因为这个事务真正识别出了重复请求。

csharp 复制代码
public sealed class AccountRepository : IAccountRepository
{
    //....

    public async Task UpdateAsync(Account account, Guid requestId, CancellationToken token = default)
    {
        var requestSql = "INSERT INTO RequestIds VALUES (@Id)";
        var requestSqlParams = new 
        { 
            Id = requestId.ToString() 
        };

        var accountSql = "UPDATE Accounts SET Balance = @Balance WHERE Id = @Id";
        var accountSqlParams = new
        {
            Id = account.Id,
            Balance = account.Balance
        };

        await using var connection = new SqlConnection(_connectionString);
        await connection.OpenAsync(token);
        await using var transaction = await connection.BeginTransactionAsync(token);

        try
        {
            await connection.ExecuteAsync(requestSql, requestSqlParams);
        }
        catch (Exception e) when (IsDuplicateKeyException(e))
        {
            throw new DuplicateKeyException();
        }

        await connection.ExecuteAsync(accountSql, accountSqlParams);
        await transaction.CommitAsync(token);
    }

    //....
}

还可对上面的requestId历史记录表做优化,不用一直记录该id,弄一个进程周期性清理这个表。

总结

  1. 没有最佳的方式去处理幂等,只有最合适的。

  2. 有些业务天然幂等, 使用简单的全局唯一id就可以定位出二次请求。

  3. 如果你的实体更新的不频繁, 可以考虑使用基于乐观锁的版本状态来解决(总体上乐观锁是更宏达叙事的一个思路,在频繁更新场景下能处理幂等问题,但体验不佳,是一味猛药)。

  4. 更常见的幂等解决方式是:基于数据库的ACID事务理论,利用事务识别出二次请求,整个动作直接面向数据库, 是真正的实现了幂等语义。

🙏🏻🙏🏻🙏🏻

相关推荐
RemainderTime10 分钟前
Spring Boot脚手架集成Sa-Token实现生产级RBAC权限管理
java·spring boot·后端·系统架构
llz_1123 小时前
web-第二次课后作业
前端·后端·web
红尘散仙9 小时前
我把终端小说阅读器接上了 AI Agent:TRNovel 现在能用 skill 生成书源了
人工智能·后端·rust
卷毛的技术笔记11 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·人工智能·后端·python·spring·ai编程
会编程的土豆11 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
喵个咪11 小时前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
basketball61612 小时前
Go 语言从入门到进阶:4. 数组和MAP使用方法总结
开发语言·后端·golang
qq_25183645712 小时前
SpringBoot+Vue 共享电池柜管理系统 完整实现 前后端分离项目实战 完整代码
vue.js·spring boot·后端
zhangxingchao12 小时前
AI 大模型核心六:量化、Workflow 与 Agent、多轮 RAG
前端·人工智能·后端
IT_陈寒13 小时前
Vite打包时遇到的坑,原来问题出在这里
前端·人工智能·后端