这两年因为疫情,节假日都不怎么外出了,以前每逢节假日都要提前一个月或者半个月抢火车票,人太多的时候会把12306整崩溃。当时很多技术人员指点江山,激扬想法,粪土当年铁科院。
前几年小米手机还很火爆,特别抢手,每每新机发布,人山人海、万人空巷,却往往都在米兔排队的身影后收获失望的表情。
俱往矣!数风流套路,还看今朝!
几句笑谈,引出抢购这回事儿,下边开始正文。
怎么理解抢购?
抢购,大部分人应该都经历过,没抢过火车票,还没抢过口罩吗?没抢过食盐,还没抢过米面粮油吗?没抢过618,难道没抢过双11?总有一款抢购适合你。
“购”大家都能够理解,就是花钱买东西。“抢”就很有意思了,首先是因为商品数量有限(这里不讲套路了),然后需要的人特别多,所以怎么分配就成了一个问题,也就是人们常说的僧多粥少。
不知道大家是否见过这样的场景,一群人围在商店门口,大门还没完全打开,就咆哮着挤了进去,抢着拿货架上的商品,谁跑得快挤得凶,谁就能拿到,谁拿到谁就可以去付款带走,这就是抢的最真实写照了。如果你不曾体验过,那么挤公交也可以代替,要么挤上去,刷卡走人,要么被挤下来,等待下一辆车。虽然体验不好,但也确实解决了分配问题。
这里的所谓“抢”就是看谁先获得了有利位置,谁先锁定了商品。那么锁定了商品就一定会购买吗?也不见得,说不定你突然发现囊中羞涩,大家应该听过很多弃购的新闻。所以对于抢购来说,抢的是“购”的机会,谁先到了谁就有 “购” 的机会。至于会不会真的买回家,还真不好下定论。
抢购程序的问题
理解了抢购,再来看看程序怎么实现它?
根据前面的理解,我们可以将抢购分为两个阶段,“抢”映射为下单,“购”映射为支付订单。下单的操作就是锁定商品,又可以分为锁定库存、创建订单两个阶段,锁定库存就相当于你在商店中把商品拿到了手里,创建订单就相当于你和商家关于商品的价格达成协议。
操作库存有一个很有名的“超售”问题,说的是扣减库存时出错了,库存数量扣成了负数,那么这个问题是如何发生的呢?
后端服务处理请求时一般都是多线程的,为了高可用,还可能是分布式的,这个问题就是多线程和分布式环境带来的并行处理造成的。扣减库存时,一般有两个步骤,先检查库存是否足够,然后再从库存中减去相应的数量。这两个步骤如果不是原子的,那么在多线程或分布式环境下,就会存在多个线程同时查询库存,单从每个请求处理的上下文看库存是足够,但是库存总量不能满足所有这些扣减加在一起,结果就扣成了负数。
那怎么解决呢?方法很多,比如把查询和更新放到同一个数据库事务中就可以了,或者使用一个锁(分布式部署时为分布式锁)来锁定对某件商品库存的查询+扣减同时只能有一个在处理。
这样是不是就没有问题了呢?还是有的。需要注意这里说的是抢购,意味着会有很多的人来尝试,在现实生活中,如果人特别多,商店中承载不了这么多人,很可能会出现安全事故。在计算机程序中也可能会出现耗尽资源导致崩溃的问题,特别是数据库,IO请求突然爆发,很可能就会被压垮,上文提到的数据库事务无疑会加重这一负担。而锁也会导致吞吐量的下降,请求堆积无法得到及时处理,一样会加重服务器负担,出现频繁超时甚至无法服务的问题。
还有什么办法呢?以前写程序的时候有一个观点,大概是不用太关注代码运行效率,增加硬件就好了,比如增加内存、升级CPU、换固态硬盘等等。不过在抢购这里不太可行,还是因为并发量可能太大了,不得不考虑计算资源的成本,而动态资源调配的速度也是个问题。
现实生活中我们还有一个文明的处理方式:排队,先到先得,售完为止。程序先把请求接下来,保存到队列中,然后再按照先后顺序一个个处理,处理完一个回复一个,这就是队列的处理方式。这样做的优点是不用再去协调那些跨线程跨进程的资源访问冲突,计算资源需求也会大幅降低;缺点是用户要多等一会才能看到结果,体验略差,不过本来就是很紧俏的东西。
限流之于抢购
上边已经分析了抢购可能会遇到的问题,那么限流能干什么呢?
在软件系统中库存就是一个数字,限流呢也有一个数字,我们可以把限流的阈值作为库存的数量,请求过来的时候,先用限流处理,能够通过的就进入下一步,如果被限流了,则说明库存已经耗尽了,返回错误就行了。示意图如下:
这样做的好处是什么呢?减轻后续其它处理的压力。如果库存已经耗尽,也再无必要去查询数据库,白白浪费宝贵的数据库资源,甚至分布式锁、显式的数据库事务都不需要了,因为能够通过限流检查的就是可以扣减库存的,直接使用Update就可以了;不过此时扣减库存和下订单时的压力仍旧存在,比如3W个请求同时进来,限流可以拦截其中的27000,剩下的3000会进入下一环节,影响还是需要仔细考虑的。再看使用队列的方式,加了限流,队列中也只接收能够下单的请求,队列压力小,后续处理队列时的数据库操作同样也减少很多。
这里可能还会出现一些问题,比如限流检查通过了,但是后续的处理因为某种原因失败了,库存不能回收,实际上就浪费了一次机会,不过这不是抢购的主要矛盾,一般会忽略这个问题。
当然对于限购来说还有很多其它问题,限流就很难发挥作用了,比如前端高并发查询库存数,这里就不多讲了。
技术实现
这里以ASP.NET Core Web API为例,限流组件选择 FireflySoft.RateLimit ,算法选择简单的固定窗口也就是计数器方式,进程内计数。这里没有选择Redis,是因为进程内计数比较简单,不需要外部依赖,演示方便;即使在分布式环境下,一般也可以应对,比如总的限流阈值是 1000/小时,部署了5份服务实例,只需要在程序中使用 200/小时 的阈值就可以了;但是如果负载均衡不均匀,某些情况下可能不太合适,此时可以选择Redis全局一致限流。
安装 Nuget 包
使用包管理器控制台:
Install-Package FireflySoft.RateLimit.AspNetCore
或者使用 .NET CLI:
dotnet add package FireflySoft.RateLimit.AspNetCore
或者直接添加到项目文件中:
<ItemGroup> <PackageReference Include="FireflySoft.RateLimit.AspNetCore" Version="2.*" /> </ItemGroup>
编写限流规则
在Startup.cs中注册限流服务并使用限流中间件。里边添加了一些注释,你可以仔细看看。
public void ConfigureServices(IServiceCollection services) { ... services.AddRateLimit(new InProcessFixedWindowAlgorithm( new[] { new FixedWindowRule() { Id = "1", ExtractTarget = context => { // 限流的目标:商品Id,这里假设它是从Url参数中传递过来的 // 实际使用可能还需要很多的安全检查,这里为了演示简单处理 return (context as HttpContext).Request.Query["GoodsId"].ToString();; }, CheckRuleMatching = context => { // 在这里判断当前请求是否 “抢购下单”,抢购下单才进行限流处理 // 实际使用可能还需要很多的检查,这里为了演示简单处理 var path = (context as HttpContext).Request.Path.Value; if(path == "/Order/PanicBuying"){ return true; } return false; }, Name = "缓解抢购压力限流", LimitNumber = 1000, // 限流阈值,等于库存数量 StatWindow = TimeSpan.FromSeconds(3600), //限流的时间窗口,这里是3600秒 StartTimeType = StartTimeType.FromNaturalPeriodBeign } }), new HttpErrorResponse() { BuildHttpContent = (context, ruleCheckResult) => { return "同学!你来晚了,已经售罄!"; } }, ); ... } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { ... app.UseRateLimit(); ... }
只需要上边这些简单的代码就可以跑起来了,你可以用Postman测试一下。
如果你不想在Web程序中使用,或者需要更多自定义的设置,也可以直接安装 FireflySoft.RateLimit.Core 这个包,它可以用于各种.NET程序。点击查看示例: bosima/FireflySoft.RateLimit(github.com)
如果你想要使用Redis,只需将 InProcessFixedWindowAlgorithm 换成 RedisFixedWindowAlgorithm ,除了多传递一个Redis连接对象,其它的代码都是一样的。
好了,这就是本文的主要内容了。对于使用限流缓解抢购压力,你有什么想说的呢?
FireflySoft.RateLimit 是一个开源的.NET Standard限流类库,其使用灵活轻巧,可以在 GitHub 上访问到最新的代码。其主要特点包括:
- 多种限流算法:内置固定窗口、滑动窗口、漏桶、令牌桶四种算法,还可自定义扩展。
- 多种计数存储:目前支持内存、Redis两种存储方式。
- 分布式友好:通过Redis存储支持分布式程序统一计数。
- 限流目标灵活:可以从请求中提取各种数据用于设置限流目标。
- 支持限流惩罚:可以在客户端触发限流后锁定一段时间不允许其访问。
- 动态更改规则:支持程序运行时动态更改限流规则。
- 自定义错误:可以自定义触发限流后的错误码和错误消息。
- 普适性:原则上可以满足任何需要限流的场景。