限流的非正式用途 - 解决重复提交问题


问题

在业务应用程序开发中,经常遇到用户重复提交的问题。

比如有一个报名的表单,如果用户不小心连续点击了提交按钮多次,数据库中就可能产生多条报名记录;再或者正常提交后,因为网络或者服务端的原因,前端没有及时收到提交结果,则用户可能认为自己没有提交成功,然后再次甚至多次提交,数据库中也可能产生多条此用户的报名记录。

这个例子中的情况还不会对业务造成多大影响,如果是涉及到资源增减的场景,比如账户、库存等的操作,可能就比较麻烦了。

对于解决问题的办法,你可能会说,前端提交后锁住按钮不就可以了嘛!确实能够解决一部分问题。为什么是一部分呢?因为调用者可能绕过前端界面,直接访问后端服务。那你也可能会说,在服务端加上判断不就可以了嘛!

好的,我们先来看一下怎么判断:

在上面报名的例子中,我们假设用户的身份是用手机号来区分的,那么服务端判断是否重复提交的时候,可以用一条SQL查一下:

select count(*) from table where mobile = 'xxx'

如果查询出的数量大于0,我们就认为已经报名过,从而中断程序的执行,返回错误。

我们再来看两个例子:

  • 员工提交报销,一不小心提交了两次,这时服务端或许可以通过 “用户Id+提交时间” 的方式来判断。

  • 给用户发放积分的处理,第一次请求超时了,又重试了一次,这时服务端或许可以通过 “用户Id+事务Id” 的方式判断是否重复提交。

还有很多的重复提交的场景,我们也都可以通过在服务端增加类似重复判断的方式来解决。但是每次都要编写这些大体类似的业务判断逻辑,软件开发不是经常说: Don't repeat yourself 吗?

再者增加判断也不能完美解决问题,为什么呢?因为后端服务一般是多线程处理的,甚至可能是分布式的,只是写个判断的逻辑还不够,还要处理数据一致的问题,这个就有点技术含量了。

有什么通用的简单办法吗?

方案

问题归纳

这里先来看下服务端可能收到重复请求的场景,我归纳如下:

  • 前端把关不严,用户 “提交中” 时没有禁用提交按钮,导致用户多次点击,向服务端发起多次请求。
  • 调用者直接访问服务,因程序错误(如死循环)或者攻击行为(如重放攻击),导致对同一业务多次发起服务请求。
  • 程序重试,可能会在前端或者后端的代码中使用重试逻辑,发生某些异常或者超时的时候自动重试,导致一次业务多次请求服务。
  • 多线程或分布式环境下,加了重复判断,但是因为数据一致性问题导致判断失效,业务被重复处理。

前两种场景比较好理解,这里不做过多说明。重点说明一下后两种是如何发生的。

先来看重试导致的重复提交,客户端第一次请求后没有正常收到返回,判断超时后,再次发起第二次业务请求,此时服务端执行了两次相同的业务处理。

WX20211130-221617@2x

再来看多线程环境下的重复提交,线程1访问数据库查询数据,然后判断没有提交过,在线程1写入数据前,线程2也来访问数据库查询数据,然后判断也没有提交过,于是线程1和线程2都向数据库写入相同的数据。

WX20211130-221644@2x

限流方案

现在到重点了,限流为何能够应用到解决重复提交的问题?

重复提交满足限流的基本要素

关于限流,这里定义如下几个基本要素(个人总结):

  • 限流有一个针对的目标,比如限制IP、限制用户等。

  • 限流有一个时间周期,比如1秒之内、1分钟之内等。

  • 限流有一个对应时间周期的阈值,比如每秒10次、每分钟100次等。

再回到重复提交上,我们可以分析得出:

  • 重复提交可以通过某些数据进行识别,这个就可以看作是限流目标。

  • 重复提交天然的存在一个时间纬度,可以对应到限流的时间周期上。

  • 重复提交即提交一次之后继续提交,可以使用限流的阈值进行控制,并固定阈值为1。

看着有戏,再通过两个例子验证下:

报名重复提交问题

  • 限流目标:手机号
  • 限流周期:从用户首次提交到报名结束

手机号可以从报名信息中提取,用户第一次提交时会使用手机号创建一条限流计数记录,报名结束之前,用户再次提交时,限流计数超过1,从而触发限流逻辑,向调用方返回错误;用户报名结束后再次提交,服务可能已经关停,或者前端已关停入口,后端也有报名截止时间的判断,即使还可以提交,已经没有什么意义,对业务没有影响。

员工提交报销问题

  • 限流目标:员工Id + 提交分钟数
  • 限流周期:从用户提交 到 其后的1分钟之内

员工Id可以从会话中提取到,提交分钟数可以用 yyyyMMddHHmm 表达,用户第一次提交报销时会创建 “用户Id + 提交分钟数” 的限流计数记录,用户在1分钟内再次提交时,限流计数会超过1,触发限流处理逻辑,向调用方返回错误;用户1分钟后再次提交时,会创建新的限流计数记录,同时不会触发限流处理逻辑,可以正常提交。

从以上分析不难看出,重复提交可以满足限流的几个基本要素,那限流可以解决所有重复提交的问题吗?

限流用于重复提交的限制

不过你也许已经发现,这里有一个隐含的假设:所有第一次业务提交都得到了正确的处理。所以限流计数1才能代表已经提交过一次。这在实际运行中很难保证,因为限流计数和业务处理往往不在一个事务中,限流计数一般更靠前一些,所以限流计数可能没问题,但是业务处理并没有成功,比如超时、断网、宕机等基础设施问题,甚至是业务条件不满足等业务逻辑问题。那么限流又要被一棍子打死了吗?

在遇到比较棘手的问题的时候,我经常想之前是否出现过呢?

在网络论坛比较流行的年代,发帖或者回帖后,都会先进入到一个数秒的倒计时跳转页面,倒计时结束后再跳转到正常的页面。

这不就是一种限流并有效防止了重复提交的方式嘛!这个设计给到的一个启示就是:系统可以在很短的一个时间之内,通过限流这种低成本的方式,限制用户的重复操作,正常用户可能不会感觉到或者只有轻微的影响,但却很大程度上能够避免重复提交带来的数据问题,也可以屏蔽某些恶意行为。

基于这个认识,我们再来看下前文提到的几个重复请求场景:

  • 前端把关不严,导致用户多次点击,向服务端发起多次请求。

    服务端可以对某一个用户的提交使用短时间跨度的限流,比如5秒1次,正常用户填写1个表单耗费的时间应在5秒以上,假如用户在5s内又提交了,则前端可以根据服务端返回的错误码提示用户,并跳转到提交结果查询页面,用户可以看到自己的提交结果。如果第一次提交真的没有处理成功,则用户可以再重新填写提交表单,因为这时距离第一次提交超过了5s,因此用户不会被限流。因为绝大部分提交都应该是正常的,所以这种概率比较小,但是也给了补救的机会,用户可能会抱怨几句。

    这里也可能出现服务端处理过慢,查询结果的时候查不到的问题,解决这个问题或许可以在服务端设置一个尽可能短的超时时间,在前端多查询几次,其出现的概率一般不高,而且也可以通过技术手段降低。

  • 直接访问接口时,因程序错误或者攻击行为,导致同一业务多次发起服务请求。

    服务端可以对同一个访问者的提交使用短时间跨度的限流,比如5秒1次,如果触发限流,同时给予一个限流惩罚,30秒内都不能提交,还可以对这个限流惩罚时间采用指数递增的方式。这样可以尽量降低外部程序异常行为对服务的影响,同时调用方正常处理后又能自动恢复正常。

    在某些接口中可能会定义时间戳、验证码、SessionId之类的参数,也可以把它们加到限流目标中,用以准确识别重复提交。

  • 程序重试导致重复提交,发生某些异常或者超时的时候自动重试,导致一次业务多次请求服务。

    这可能是个设计问题。应该避免在中间服务发起提交行为的重试操作,因为很多的业务处理可能都不是幂等的,中间服务的重试行为因为访问者看不到,所以很可能被忽略掉,从而导致数据问题。如果需要重试,应该仅在业务的发起处进行重试,发起者应该清楚重试逻辑可能导致的问题,并尽量降低影响。

    可以在最上层服务引入限流处理,选择合适的限流目标,限流时间跨度和限流阈值,内部服务一般认为相对可靠,没必要引入限流。

  • 多线程或分布式环境下,加了重复判断,但是因为数据一致性问题导致判断失效,业务被重复处理。

    通过选择合适的限流目标,使用分布式一致性的限流算法,比如使用Redis,也可以实现提交操作在某个时间范围内只能被执行一次,从而让重复判断的结果有效,避免业务重复处理。

通过对这几种重复提交场景的分析,可以看到:限流并不能完美的避免重复提交,但是它可以提供一种通用的机制很大程度的降低重复提交,而且这种机制的成本可以很低,相比每个方法中硬编码重复数据的判断、查询数据库、使用分布式锁等带来的成本可能都要低不少。当然为了处理的更好,还可能需要前后端的一些其它配合。

其实也可以在业务处理中增加对重复数据的判断,因为前边已经被限流拦截了一道,重复执行的机会可以大为减少,重复数据判断的逻辑带来的影响也将很低。特别是一些关键业务中,重复提交导致的麻烦可能比较大,不过这时候可能要解决数据库的查询性能、分布式的数据一致问题。两害相权取其轻。

实现

分析清楚了限流对于限制重复提交的意义,就可以在合适的场景来应用它。

比如存在一个前后端分离的系统,用户都通过一个前端界面来处理业务,用户同时一般只能操作一个界面,为了尽可能避免重复提交问题,我们在后端API中增加对用户提交行为的限流操作,对于每个独立的用户限制5秒之内只能提交1次。

这里还是使用 FireflySoft.RateLimit 来做限流,后端API基于ASP.NET Core WebAPI实现。

安装 Nuget 包

使用包管理器控制台:

Install-Package FireflySoft.RateLimit.AspNetCore

或者使用 .NET CLI:

dotnet add package FireflySoft.RateLimit.AspNetCore

或者直接添加到项目文件中:




编写限流规则

在Startup.cs中注册限流服务并使用限流中间件。

public void ConfigureServices(IServiceCollection services)
{
    ...

    services.AddRateLimit(new InProcessFixedWindowAlgorithm(
        new[] {
            new FixedWindowRule()
            {
                Id = "1",
                ExtractTarget = context =>
                {
                    // 限流的目标:用户Id,这里假设它是从HTTP Header中传递过来的
                    return (context as HttpContext).Request.GetTypedHeaders().Get("userId");
                },
                CheckRuleMatching = context =>
                {
                  	// 在这里判断当前请求是否 “提交行为”,提交行为才进行限流处理
                    var path = (context as HttpContext).Request.Path.Value;
                    if(path == "/Comapny/Add"
                      ||path == "/Comapny/Update"
                      ||path == "/Goods/Purchase"
                      ||path == "/Goods/ChangePrice"
                      ||path == "/Order/Pay"
                      ||path == "/Order/Cancel"){
                        return true;
                    }
                    return false;
                },
                Name = "用户提交行为限流",
                LimitNumber = 1, // 限流阈值
                StatWindow = TimeSpan.FromSeconds(5), //限流的时间窗口,这里是5秒
                StartTimeType = StartTimeType.FromNaturalPeriodBeign
            }
        })
    );

    ...
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ...

    app.UseRateLimit();

    ...
}

只需要上边这些简单的代码就可以用来限制重复提交了。可以跑起来试试。

不过如果你要在分布式环境下使用,还需要准备一个Redis,将 InProcessFixedWindowAlgorithm 换成 RedisFixedWindowAlgorithm ,除了多传递一个Redis连接对象,其它的代码都是一样的。

FireflySoft.RateLimit 是一个开源的.NET Standard限流类库,其使用灵活轻巧,可以在 GitHub 或者 Gitte 上访问到最新的代码。


好了,这就是这篇文章的主要内容了。对于用限流解决重复提交的问题,你有什么想说的呢?