限流的非正式用途 - 解决重复提交问题
问题
在业务应用程序开发中,经常遇到用户重复提交的问题。
比如有一个报名的表单,如果用户不小心连续点击了提交按钮多次,数据库中就可能产生多条报名记录;再或者正常提交后,因为网络或者服务端的原因,前端没有及时收到提交结果,则用户可能认为自己没有提交成功,然后再次甚至多次提交,数据库中也可能产生多条此用户的报名记录。
这个例子中的情况还不会对业务造成多大影响,如果是涉及到资源增减的场景,比如账户、库存等的操作,可能就比较麻烦了。
对于解决问题的办法,你可能会说,前端提交后锁住按钮不就可以了嘛!确实能够解决一部分问题。为什么是一部分呢?因为调用者可能绕过前端界面,直接访问后端服务。那你也可能会说,在服务端加上判断不就可以了嘛!
好的,我们先来看一下怎么判断:
在上面报名的例子中,我们假设用户的身份是用手机号来区分的,那么服务端判断是否重复提交的时候,可以用一条SQL查一下:
select count(*) from table where mobile = 'xxx'
如果查询出的数量大于0,我们就认为已经报名过,从而中断程序的执行,返回错误。
我们再来看两个例子:
-
员工提交报销,一不小心提交了两次,这时服务端或许可以通过 “用户Id+提交时间” 的方式来判断。
-
给用户发放积分的处理,第一次请求超时了,又重试了一次,这时服务端或许可以通过 “用户Id+事务Id” 的方式判断是否重复提交。
还有很多的重复提交的场景,我们也都可以通过在服务端增加类似重复判断的方式来解决。但是每次都要编写这些大体类似的业务判断逻辑,软件开发不是经常说: Don't repeat yourself 吗?
再者增加判断也不能完美解决问题,为什么呢?因为后端服务一般是多线程处理的,甚至可能是分布式的,只是写个判断的逻辑还不够,还要处理数据一致的问题,这个就有点技术含量了。
有什么通用的简单办法吗?
方案
问题归纳
这里先来看下服务端可能收到重复请求的场景,我归纳如下:
- 前端把关不严,用户 “提交中” 时没有禁用提交按钮,导致用户多次点击,向服务端发起多次请求。
- 调用者直接访问服务,因程序错误(如死循环)或者攻击行为(如重放攻击),导致对同一业务多次发起服务请求。
- 程序重试,可能会在前端或者后端的代码中使用重试逻辑,发生某些异常或者超时的时候自动重试,导致一次业务多次请求服务。
- 多线程或分布式环境下,加了重复判断,但是因为数据一致性问题导致判断失效,业务被重复处理。
前两种场景比较好理解,这里不做过多说明。重点说明一下后两种是如何发生的。
先来看重试导致的重复提交,客户端第一次请求后没有正常收到返回,判断超时后,再次发起第二次业务请求,此时服务端执行了两次相同的业务处理。
再来看多线程环境下的重复提交,线程1访问数据库查询数据,然后判断没有提交过,在线程1写入数据前,线程2也来访问数据库查询数据,然后判断也没有提交过,于是线程1和线程2都向数据库写入相同的数据。
限流方案
现在到重点了,限流为何能够应用到解决重复提交的问题?
重复提交满足限流的基本要素
关于限流,这里定义如下几个基本要素(个人总结):
-
限流有一个针对的目标,比如限制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 上访问到最新的代码。
好了,这就是这篇文章的主要内容了。对于用限流解决重复提交的问题,你有什么想说的呢?