秒杀活动是绝大部分电商选择的低价促销、推广品牌的方式。不仅可以给平台带来用户量,还可以提高平台知名度。一个好的秒杀系统,可以提高平台系统的稳定性和公平性,获得更好的用户体验,提升平台的口碑,从而提升秒杀活动的最大价值。
秒杀活动的特征
秒杀活动由于价格优惠力度较大或商品的稀缺性,吸引了大量的用户参与活动,但只又少数用户能下单成功。短时间内将产生页面访问流量与下单流量暴增。
秒杀活动的三个阶段:
- 秒杀开始前:页面倒计时,用户频繁刷新页面,访问流量达到瞬时顶峰
- 秒杀开始:用户疯狂点击秒杀按钮,下单流量达到瞬时顶峰
- 秒杀后:下单成功用户不断刷新订单或产生退单操作,大部分用户任在刷新页面等待退单后的机会。
秒杀活动应该考虑的三大问题
1. 高并发问题
由于秒杀活动的优惠力度较大,吸引了大量用户前来抢购,后端需要考虑如何防止高并发带来的缓存穿透和击穿的风险,一旦缓存穿透或击穿到数据库将会给数据库带来巨大的压力,甚至崩溃的风险。
2. 商品超卖问题
由于秒杀活动是商家用来引流吸引用户,活动的优惠力度较大。因此商品的利润较少甚至亏本。假如活动超卖数量较大这将给商家带来巨大的损失,因此超卖问题需要着重考虑的。
3. 接口防刷问题
对一些利润空间较大的秒杀商品,市面上可能会出现一些针对性软件不断调用秒杀下单接口,但对于大多数秒杀活动的商品来讲还是比较少会出现,但也是不得不防666,可以根据实情酌情考虑。
秒杀活动解决方案
1. 分流
分流是秒杀活动解决方案中最简单的一种方式,它是从业务角度出发的一种解决方案。比如有多个秒杀活动时,我们可以将其时间段错开,或者对于某些电商系统有会员等级或标签的也可以以此条件进行分流。可根据具体业务调整。
2. 限流
对于秒杀活动来讲,限流是比不可少的,它是系统最后的一道防线,防止高吞吐的流量压垮系统。我们不能将限流作为主要手段,应该最大程度接收用户发出的请求。限流只是为防止系统奔溃的一种手段
2.1 如何找到限流阀值?
找到限流的阀值需要在预生产环境做压测,通过压测找到我们系统的性能瓶颈。可以使用阿某云或者腾某云的性能压测服务,使用递增模式。按照固定比例量级递增的方式进行压测,并在每个量级维持固定压测时长,观察业务系统运行情况。找到超出你认可的性能标准的阶段作为限流的阀值。比如说错误率超过了多少、响应时间超过了多少、服务器CPU使用率达到了多少等。。。
2.2 常见限流手段
2.2.1. 前端限流
- 方法一:用户点击秒杀发起请时需要用户填写验证码,或者回答一个问题。(不推荐)
- 方法二:用户点击秒杀后5秒内再次点击不发起真实请求。有人说要给按钮变灰,但本人觉得不应该变灰,可以用另一种动态的方式表现,比如像直播的点赞,我相信直播点赞不是每一次点击都调用一次接口,而是分批提交。虽然用户每一次的点击不一定起作用,但能给用户一种每一次都有作用的假象,增加来用户的参与感,体验上也增加了。
2.2.2. NGINX限流
nginx可以通过令牌桶、连接数以及ip进行限流。具体网上有较多教程,自行搜索。
2.2.3. Guava本地限流
Guava提供了RateLimter类供我们使用令牌桶的算法进行限流。但需要注意的是:这是本地限流不是分布式的限流方式。也就是说假如给的令牌数是100,但服务器有50个节点,那么每秒最大可通过的请求数是100*50=5000。
2.2.4. Redis令牌桶限流
分布式限流方式,不会出现上述Guava本地限流的场景
2.2.5. 限流组件Sentinel (推荐)
Sentinel是阿某云开源限流组件,需要部署Sentinel 控制台,前端技术使用的是angular,两年前使用时前端有bug需要自行修改代码,如今不只是否修复。
使用时需要引入maven依赖:
com.alibaba.csp
sentinel-core
1.8.4
可针对Spring gateway网关限流,也可根据微服务接口进行限流。
Sentinel 具有以下特征:
- 丰富的应用场景:Sentinel 承接了阿某某巴近 10 年的双十一大促流量的核心场景,例如秒杀(即突发流量控制在系统容量可以承受的范围)、消息削峰填谷、集群流量控制、实时熔断下游不可用应用等。
- 完备的实时监控:Sentinel 同时提供实时的监控功能。您可以在控制台中看到接入应用的单台机器秒级数据,甚至 500 台以下规模的集群的汇总运行情况。
- 广泛的开源生态:Sentinel 提供开箱即用的与其它开源框架/库的整合模块,例如与 Spring Cloud、Apache Dubbo、gRPC、Quarkus 的整合。您只需要引入相应的依赖并进行简单的配置即可快速地接入 Sentinel。同时 Sentinel 提供 Java/Go/C++ 等多语言的原生实现。
- 完善的 SPI 扩展机制:Sentinel 提供简单易用、完善的 SPI 扩展接口。您可以通过实现扩展接口来快速地定制逻辑。例如定制规则管理、适配动态数据源等。
Sentinel 的主要特性:
Sentinel传送门:https://github.com/alibaba/Sentinel/wiki/%E4%BB%8B%E7%BB%8D
2.2.6. 应用高可用服务 (不差钱的情况下推荐)
应用高可用服务是阿某云限流产品,支持多种对接方式:Agent接入、SDK接入(sentinel)、k8s接入、SAE接入。具备sentinel所有功能及sentinel没有的功能。支持网关防护和应用防护。可时时查看每个微服务当前并发量。
网关防护:集成到Sptring gateway上,通过网关进行限流。
应用防护:针对不同的微服务进行防护,可对不同业务进行限流。
优点:
1. 可监控应用CPU、JVM虚拟机、java GC、各微服务当前并发数及响应时间、错误率
2. 多种限流策略、包含全智能限流和半智能限流(根据服务器CPU使用情况,并发数,响应时间智能限流)
3. 故障演练等。。。
3. 缓存
3.1 页面静态化
将商品详情静态化的数据生成静态页面。比如商品的详细介绍(不包涵商品价格与库存)、规格信息、富文本信息等不经常变化的内容。在十年前我在一家游戏公司,游戏官网都会有大量的攻略,攻略文章的内容通常都比较长,如果每个用户访问攻略页面都从数据库查询一遍无疑会给数据库带来较大的压力。公司采用的解决方案是:在攻略编写或修改后,后台的服务端会发送一个JMS消息,web前端的服务端在收到JMS消息后会通过freemarker模版生成对应的静态页面到指定的目录,然后由Nginx反向代理出去。用户访问静态页面不会直接访问服务端的Nginx,而是访问的CDN,CDN会回源到我们的Nginx反向代理服务器。这样即使再多的用户也不会对数据库造成压力。
但随着技术的进步,这种使用freemarker模版的技术已经淘汰,目前主流使用的是前后端分离技术,比如:vue、react、angular等。因为前后端进行了分离,所以做页面静态化需要做出一些改变。四年前了解了一下前端技术的服务端渲染,一开始觉得很高级,但了解过后发现,其原理和java的jsp和freemarker模板类似,通过在服务端的模板生成好静态页面的字符串,然后返回给浏览器。了解到这我们已经可以通过这个方案来做到页面静态化。具体方案如下:
3.1.1 使用vue nuxt框架实现页面静态化
首先我们需要了解服务端渲染的两个生命周期,一个运行在服务端,一个运行在客户端也就是浏览器。在服务的运行的生命周期中我们需要调用后端的API接口获取到商品详情的数据。在客户端的生命周期中我们调用后端API接口商品价格库存相关的动态数据。到这里我们还是没有做到页面的静态化,但是我们可以通过CDN回源的特性去做。什么叫CDN回源?就是指:当用户访问CDN中的页面时,如果CDN中没有这个页面,那么CDN会调用源站的地址,拿到页面后后续的请求在CDN缓存失效前不会再调用源站获取数据。这样一来用户访问CDN页面拿到的是经过服务端生命周期生成的商品详情静态页面,然后浏览器执行客户端的生命周期脚本,开始调用后端API接口获取动态价格库存相关数据。到此也就变相的实现了页面静态化。具体流程如下:
nuxt 有直接生成静态页面的命令,但由于笔者是后端出生并不是专业前端,所有没有深入研究,不清楚生成页面期间会不会在原本服务端生命周期中调用后端接口,如果会完全可以用这种方案来做到完整的页面静态化,但这可能会有一些复杂,你需要通过在线上环境运行期间通过某种方式去执行nuxt命令来生成静态页面。
3.1.2 通过CDN回源后端接口实现页面静态化
由于时代的发展,小程序成为了热门,很少人会再选择公众号的形式。当项目的入口在小程序端时,我们不能再通过nuxt框架来实现页面静态化,因为我们小程序用不到页面。面对这种情况我们可以去掉页面,让CDN直接回源到我们的接口获取静态数据。具体流程如下:
3.2 本地缓存+Redis分布式缓存
Redis缓存虽然可以给到我们很大的帮助,但在高并发场景下我们可以结合本地缓存进一步提高我们的性能。在了解本地缓存之前我们先了解一下Redis缓存高并发场景下可能会遇到的三个问题:
- 缓存穿透;
高并发情况下单个Key失效瞬间大量请求穿透至数据库。 - 缓存击穿;
访问数据库不存在的Key,导致每次请求穿透至数据库 - 缓存雪崩;
大量的Key同时失效,导致请求穿透至数据库。
在高并发场景下这三个问题一旦出现都将带来比较严重的后果,而我们使用本地缓存也正好能解决这一问题。我们常见使用的本地缓存组件有:Guava、Caffeine。据网上资料现实Caffeine比Guava性能要好很多。如果项目已经用了Guava的我认为可以选Guava的就行,没用用到Guava的推荐使用Caffeine。
Caffeine官方
Caffeine官方中文文档
guava文档
3.2.1 解决缓存穿透问题
假设当前有大量请求达到服务器,我们先查询本地缓存,缓存不存在,添加一个本地锁,手动加载缓存,没获得本地锁的用户直接返回空,根据需要也可以选择阻塞的方式进行等待。具体流程如下:
另外我们也可以使用异步加载缓存的方式,当本地缓存不存在时,异步开启一个新的线程更新缓存,当其他线程到达时,由于已有一个线程更新缓存,所以这里不再开启新的线程,而是直接返回null。流程如下:
通过上面这两种方式可以很好的解决因缓存穿透而导致数据库压力过大的问题。
3.2.2 解决缓存雪崩问题
缓存雪崩是由于大量的缓存失效导致请求穿透至数据库而引起。解决这个问题我们可以通过本地缓存组件提供的refreshAfterWrite函数解决。该函数会在缓存写入一段时间后刷新,需要注意的是:它是在真正查询的时候刷新,从而避免盲目刷新。也就是说刷新时间到了,但没有请求查询时不会盲目刷新。在缓存刷新期间有新的请求到达时会返回旧值(旧值还未淘汰)。通过这个机制我们可以设置缓存时间假设60秒,设置缓存刷新时间为45秒。这样可以让我们的热点key一直存在与内存当中,从而避免了因大量key同时失效造成的缓存雪崩。
3.3 解决库存超卖问题
3.3.1 通过Redis自增/自减函数解决
在面试当中本人通常会询问对方如何解决超卖的问题,很多人会告诉我可以通过分布式锁解决。虽然分布式锁可以解决超卖问题,但无疑这是性能最差的一种方式。比如服务器有100个节点,当你使用分布式锁以后同一时间只能有一个节点进行处理。这样的方式不仅效果差且给到用户的体验感也不好。也就是说虽然我们常使用Redis缓存但还有部分人不知道Redis有自增自减函数。当我们用自减函数时需要注意,库存被减至负数时程序不会报错,我们需要判断返回值,如果是负数则库存已售罄,另外前端需要判断如果库存为负数则显示为0。
但这种方案也会存在问题,比如我们的秒杀商品每次可购买的数量不是1个库存,而是多个库存时。如剩余1个商品库存时,用户采购数量为5,此时返回库存为-4。发现库存不足但又要给已扣除的库存补回去,此时又有其他用户调用了扣库存,那么最后的那几个库存可能长时间没有用户能抢到。
假如我们不加锁先查询库存然后再扣存,这个方案可以减轻以上这种场景,但还是会发生,且需要连接Redis两次,这时我们可以考虑使用Redis lua脚本解决,先扣库存,发现库存不够则回退库存。具体参考:3.3.2
3.3.2 通过Redis + lua脚本解决超卖问题
在一些复杂点的秒杀场景当中,不仅要判断库存,还要判断是否有资格参与活动,比如已经参与过的不能再参与。Redis可以保证lua脚本以原子性的方式执行,所以我们可以将多个步骤放到lua脚本中执行。比如先判断是否有资格,然后在减库存,如果库存为负数再加回去。但假如使用的是集群版Redis时需要注意,lua脚本查询的key是当前节点的,我们需要确保脚本中用到的key都在当前节点。
3.3.2.1 lua扣库存参考
这里没有秒杀资格校验,需要可自行添加。
-- 自减脚本,小于0返回[-1, 当前库存], key不存在会初始化为0.
local k = KEYS[1]
local n = ARGV[1]
local num = redis.call("DECRBY", k, n)
local res = {0, num}
-- 库存不足,手动回滚并标记为库存不足状态
if num < 0 then
num = redis.call("INCRBY", k, n)
res[1] = -1
res[2] = num
end
return res
java 执行Redis lua脚本参考
@Test
public void testLua() {
// 注意 返回String类型需要使用stringRedisTemplate,以及Object values参数需要是String类型
DefaultRedisScript redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/decrbyJson.lua")));
redisScript.setResultType(String.class);
List keys = Arrays.asList("GOODS:A1005");
Object values[] = new Object[] { "2" };
String res = stringRedisTemplate.execute(redisScript, keys, values);
log.info("测试testLua:{}", res);
}
更多内容请参考:https://gitee.com/zhouJ/zjun-demo/blob/master/zjun-demo-lua/src/test/java/com/zjun/test/LuaTest.java
代码仓库地址:https://gitee.com/zhouJ/zjun-demo
Redis如何使用lua脚本:https://www.jianshu.com/p/77e6503b4cc9
参考阿某云Redis产品文档中的:使用Redis搭建电商秒杀系统
4. 异步
因秒杀的下单量过高,数据库无法承受这么大到压力,因此需要使用异步到方式,减轻数据库压力平滑度过。
4.1 异步方式一
更新数据库库存会产生行级锁,高并发情况下数据库无法承受,而往数据库插入新到数据不会产生行级锁,因此在可承受范围内我们将创建订单还是保持为同步方式。方案如下:
4.1 异步方式二
当新增插入订单达到了一定量级数据库超出负载时,异步到方式需要进一步优化。此时我们需要将下单也提取到异步流程中。但此方案有一缺点:用户秒杀后不能及时看到订单,需要想方案提示用户订单正在处理中,否则用户不知道自己是否已经成功下单。方案如下:
4.2 扩展
假如数据库已经使用了分库分表,通过异步应对高并发到问题,而当使用MQ组件达到了性能瓶颈。这样到场景我们可以参考分库分表策略,使用多个MQ实例。应用程序通过订阅多个MQ实例,使用key进行hash路由选择MQ实例发送MQ消息,将压力分摊到多个MQ实例。