秒杀系统介绍
秒杀无论是双十一购物还是 12306 抢票,秒杀场景已随处可见。简单来说,秒杀就是在同一时刻大量请求争抢购买同一商品并完成交易的过程。从架构视角来看,秒杀系统本质是一个高性能、高一致、高可用的三高系统。
通俗一点讲就是网络商家为促销等目的组织的网上限时抢购活动
秒杀系统特点
- 高并发:秒杀的特点就是这样
时间极短
、
瞬间用户量大
。- 库存量少:一般秒杀活动商品量很少,这就导致了只有极少量用户能成功购买到。
- 业务简单:流程比较简单,一般都是下订单、扣库存、支付订单
- 恶意请求,数据库压力大
- 秒杀的商品不需要添加到购物车
- 秒杀系统独立部署
秒杀活动对稀缺或者特价的商品进行定时定量售卖,吸引成大量的消费者进行抢购,但又只有少部分消费者可以下单成功。因此,秒杀活动将在较短时间内产生比平时大数十倍,上百倍的页面访问流量和下单请求流量。
秒杀活动可以分为3个阶段:
- 秒杀前:用户不断刷新商品详情页,页面请求达到瞬时峰值。
- 秒杀开始:用户点击秒杀按钮,下单请求达到瞬时峰值。
- 秒杀后:一部分成功下单的用户不断刷新订单或者产生退单操作,大部分用户继续刷新商品详情页等待退单机会。
消费者提交订单,一般做法是利用数据库的行级锁,只有抢到锁的请求可以进行库存查询和下单操作。但是在高并发的情况下,数据库无法承担如此大的请求,往往会使整个服务 blocked,在消费者看来就是服务器宕机。
秒杀需要解决的问题
- 商品超卖问题
- 高平发的处理
- 库存和订单一致性的问题
秒杀系统设计理念
- 限流:鉴于只有少部分用户能够秒杀成功,所以要限制大部分流量,只允许少部分流量进入服务后端(暂未处理)。
- 削峰:对于秒杀系统瞬时的大量用户涌入,所以在抢购开始会有很高的瞬时峰值。实现削峰的常用方法有利用缓存或消息中间件等技术。
- 异步处理:对于高并发系统,采用异步处理模式可以极大地提高系统并发量,异步处理就是削峰的一种实现方式。
- 内存缓存:秒杀系统最大的瓶颈最终都可能会是数据库的读写,主要体现在的磁盘的 I/O,性能会很低,如果能把大部分的业务逻辑都搬到缓存来处理,效率会有极大的提升。
- 可拓展:如果需要支持更多的用户或更大的并发,将系统设计为弹性可拓展的,如果流量来了,拓展机器就好。
秒杀场景
- 电商抢购限量商品
- 火车票抢座 12306
- …
数据库建表
CREATE TABLE `t_goods` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`gname` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`gdesc` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`gprice` decimal(10,0) DEFAULT NULL,
`gstock` int(10) DEFAULT NULL,
`gpic` text COLLATE utf8_bin,
`gtype` int(11) DEFAULT NULL,
`version` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
CREATE TABLE `t_order` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`gid` int(10) DEFAULT NULL,
`gname` varchar(30) COLLATE utf8_bin DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5335 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
业务流程
使用乐观锁解决超卖问题
public interface IGoodsMpper{
@Select("select * from t_goods where id = #{gid}")
public Goods getGoodsById(Integer id);
@Update("update t_goods set gstock = gstock - 1,version=version+1 where id = #{id} and version = #{version}")
public int updateGstock(@Param("id") Integer id, @Param("version") Integer version);
}
接口限流
什么是接口限流?
某一时间窗口内的请求数进行限制,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢或宕机`
保护高并发系统的三把利器
- 缓存:缓存的目的是提升系统访问速度和增大系统处理容量
- 降级:降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行
- 限流:限流的目的是通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。
如何解决接口限流
常用的限流算法有
令牌桶
和和
漏桶(漏斗算法)
,而Google开源项目Guava中的RateLimiter使用的就是令牌桶控制算法
漏斗算法
把请求比作是水,水来了都先放进桶里,并以限定的速度出水,当水来得过猛而出水不够快时就会导致水直接溢出,即拒绝服务。
漏斗有一个进水口 和 一个出水口,出水口以一定速率出水,并且有一个最大出水速率。在漏斗中没有水的时候:
如果进水速率小于等于最大出水速率,那么,出水速率等于进水速率,此时,不会积水
如果进水速率大于最大出水速率,那么,漏斗以最大速率出水,此时,多余的水会积在漏斗中在漏斗中有水的时候:
如果漏斗未满,且有进水的话,那么这些水会积在漏斗中
如果漏斗已满,且有进水的话,那么这些水会溢出到漏斗之外
令牌桶
最初来源于计算机网络。在网络传输数据时,为了防止网络拥塞,需限制流出网络的流量,使流量以比较均匀的速度向外发送。令牌桶算法就实现了这个功能,可控制发送到网络上数据的数目,并允许突发数据的发送。大小固定的令牌桶可自行以恒定的速率源源不断地产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。这意味,面对瞬时大流量,该算法可以在短时间内请求拿到大量令牌,而且拿令牌的过程并不是消耗很大的事情。
令牌桶的应用
添加依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.2-jre</version>
</dependency>
令牌桶算法应用
// 创建令牌桶实例, 每秒给桶中放10个令牌
private RateLimiter rateLimiter = RateLimiter.create(10);
@GetMapping("/testToken")
@ResponseBody
public String testToken() {
// 获取令牌,该方法会被阻塞直到获取到请求,返回0表示获取令牌成功
double acquire = rateLimiter.acquire();
// 尝试获取令牌,设置等待时间为1s
if (rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) {
System.out.println("抢购成功");
return "抢购成功。。。";
} else {
System.out.println("抢购失败");
return "抢购失败。。。";
}
}
RateLimiter主要接口
/**
* 创建一个稳定输出令牌的RateLimiter,保证了平均每秒不超过permitsPerSecond个请求
* 当请求到来的速度超过了permitsPerSecond,保证每秒只处理permitsPerSecond个请求
* 当这个RateLimiter使用不足(即请求到来速度小于permitsPerSecond),会囤积最多permitsPerSecond个请求
*/
public static RateLimiter create(double permitsPerSecond);
/**
* 创建一个稳定输出令牌的RateLimiter,保证了平均每秒不超过permitsPerSecond个请求
* 还包含一个热身期(warmup period),热身期内,RateLimiter会平滑的将其释放令牌的速率加大,直到起达到最大速率
* 同样,如果RateLimiter在热身期没有足够的请求(unused),则起速率会逐渐降低到冷却状态
*
* 设计这个的意图是为了满足那种资源提供方需要热身时间,而不是每次访问都能提供稳定速率的服务的情况(比如带缓存服务,需要定期刷新缓存的)
* 参数warmupPeriod和unit决定了其从冷却状态到达最大速率的时间
*/
public static RateLimiter create(double permitsPerSecond, long warmupPeriod, TimeUnit unit);
// 表示获取一个或多个令牌.如果没有令牌则一直等待,返回等待的时间(单位为秒),没有被限流则直接返回0.0:
public double acquire();
public double acquire(int permits);
// 尝试获取令牌,分为待超时时间和不带超时时间两种:
//尝试获取一个令牌,立即返回
public boolean tryAcquire();
public boolean tryAcquire(int permits);
public boolean tryAcquire(long timeout, TimeUnit unit);
//尝试获取permits个令牌,带超时时间
public boolean tryAcquire(int permits, long timeout, TimeUnit unit);
使用令牌桶实现接口限流
添加拦截器
@Component
public class LimitInterceptor implements HandlerInterceptor {
private RateLimiter rateLimiter = RateLimiter.create(60);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取到令牌往下执行
if (rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) {
return true;
} else {
return false;
}
}
}
配置拦截器
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private LimitInterceptor limitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(limitInterceptor).addPathPatterns("/**");
}
}
限时抢购
目前为止我们完成了防止超卖商品和抢购接口的限流,已经能够防止大流量把我们的服务器直接搞炸,但是现在设计的系统还存在一些问题。
- 在任何时间都可以进行秒杀,一般是在一定时间内可以进行秒杀
- 秒杀开始之后单个用户请求的频率
- 黑客通过脚本进行抢购
利用Redis超时机制实现秒杀,秒杀开始后把seckilil_gid作为key,gid做为value,并设置超时时间,秒杀请求过来后先判断该key是否已进过期,如果已经过期说明秒杀已经结束。
秒杀开始设置超时时间
抢购是判断是否已经结束
@Autowired
private StringRedisTemplate stringRedisTemplate;
public Goods checkGoodsStock(Integer id) {
Goods goods = killMapper.getGoodsById(id);
if (goods.getGstock() <= 0) {
throw new RuntimeException("商品已经被抢购为空");
}
return goods;
}
public int updateStock(Goods goods) {
int row = killMapper.updateGstock(goods.getId(), goods.getVersion());
if (row <= 0) {
throw new RuntimeException("修改库存失败。。");
}
return row;
}
public void createOrder(Goods goods) {
Order order = new Order();
order.setGid(goods.getId());
order.setGname(goods.getGname());
order.setCreateTime(new Date());
orderMapper.insert(order);
}
@Override
public boolean killGoods(Integer id) {
// 检查秒杀是否开始
checkSecillIsStart(id);
// 1.检查库存
Goods goods = checkGoodsStock(id);
// 2.修改库存
updateStock(goods);
// 3.创建订单
createOrder(goods);
return true;
}
private void checkSecillIsStart(Integer id) {
if(!stringRedisTemplate.hasKey("seckill_"+id)){
throw new RuntimeException("秒杀已经结束");
}
}
限制单用户抢购频率
private boolean chekUserAccessLimit(Integer gid, Integer uid) {
// 设置redis中的key
String key = "seckill_" + uid + "_" + gid;
if (!stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 60, TimeUnit.SECONDS)) {
// 获取用户访问商品的次数
synchronized (this){
String num = stringRedisTemplate.opsForValue().get(key);
// System.out.println("用户访问次数:" + num);
if (Integer.parseInt(num) >= 10) {
return false;
}
// 访问次数自增
stringRedisTemplate.opsForValue().increment(key,1);
}
}
return true;
}
削锋填谷
某应用的处理能力是每秒 10 个请求。在某一秒,突然到来了 30 个请求,而接下来两秒,都没有请求到达。在这种情况下,如果直接拒绝 20 个请求,应用在接下来的两秒就会空闲。所以,需要把请求突刺均摊到一段时间内,让系统负载保持在请求处理水位之内,同时尽可能地处理更多请求,从而起到“削峰填谷”的效果。
spring:
rabbitmq:
host: 192.168.193.88
port: 5672
username: guest
password: guest
virtual-host: /
listener:
type: simple
simple:
prefetch: 10 # 消费者每次从队列获取的消息数量
concurrency: 1 # 消费者数量
max-concurrency: 1 # 启动消费者最大数量
秒杀系统其它环节解决方案
前端:页面资源静态化,按钮控制,使用答题校验码可以防止秒杀器的干扰,让更多用户有机会抢到
nginx:校验恶意请求,转发请求,负载均衡;动静分离,不走tomcat获取静态资源;gzip压缩,减少静态文件传输的体积,节省带宽,提高渲染速度
业务层:集群,多台机器处理,提高并发能力
redis:集群保证高可用,持久化数据;分布式锁(悲观锁);缓存热点数据(库存)
mq:削峰限流,MQ堆积订单,保护订单处理层的负载,Consumer根据自己的消费能力来取Task,实际上下游的压力就可控了。重点做好路由层和MQ的安全
数据库:读写分离,拆分事务提高并发度
秒杀系统设计小结
- 秒杀系统就是一个“三高”系统,即
高并发、高性能
和
高可用
的分布式系统- 秒杀设计原则:
前台请求尽量少,后台数据尽量少,调用链路尽量短,尽量不要有单点
- 秒杀高并发方法:
访问拦截、分流、动静分离
- 秒杀数据方法:
减库存策略、热点、异步、限流降级
- 访问拦截主要思路:通过CDN和缓存技术,尽量把访问拦截在离用户更近的层,尽可能地过滤掉无效请求。
- 分流主要思路:通过分布式集群技术,多台机器处理,提高并发能力
什么是CDN?
CDN——Content Delivery Network,内容分发网络, 就是采用更多的缓存服务器(CDN边缘节点),布放在用户访问相对集中的地区或网络中。当用户访问网站时,利用全局负载技术,将用户的访问指向距离最近的缓存服务器上,由缓存服务器响应用户请求。(有点像电商的本地仓吧?)
利用浏览器缓存和 CDN 抗压静态页面流量
秒杀前,用户不断刷新商品详情页,造成大量的页面请求。所以,我们需要把秒杀商品详情页与普通的商品详情页分开。对于秒杀商品详情页尽量将能静态化的元素静态化处理,除了秒杀按钮需要服务端进行动态判断,其他的静态数据可以缓存在浏览器和 CDN 上。这样,秒杀前刷新页面导致的流量进入服务端的流量只有很小的一部分。
利用读写分离 Redis 缓存拦截流量
CDN 是第一级流量拦截,第二级流量拦截我们使用支持读写分离的 Redis。在这一阶段我们主要读取数据,读写分离 Redis 能支持高达60万以上 qps,完全可以支持需求。
首先通过数据控制模块,提前将秒杀商品缓存到读写分离 Redis,并设置秒杀开始标记如下:
"goodsId_count": 100 //总数
"goodsId_start": 0 //开始标记
"goodsId_access": 0 //接受下单数
- 秒杀开始前,服务集群读取 goodsId_Start 为 0,直接返回未开始。
- 数据控制模块将 goodsId_start 改为1,标志秒杀开始。
- 服务集群缓存开始标记位并开始接受请求,并记录到 redis 中 goodsId_access,商品剩余数量为(goodsId_count – goodsId_access)。
- 当接受下单数达到 goodsId_count 后,继续拦截所有请求,商品剩余数量为 0。
可以看出,最后成功参与下单的请求只有少部分可以被接受。在高并发的情况下,允许稍微多的流量进入。因此可以控制接受下单数的比例。
利用主从版 Redis 缓存加速库存扣量
成功参与下单后,进入下层服务,开始进行订单信息校验,库存扣量。为了避免直接访问数据库,我们使用主从版 Redis 来进行库存扣量,主从版 Redis 提供10万级别的 QPS。使用 Redis 来优化库存查询,提前拦截秒杀失败的请求,将大大提高系统的整体吞吐量。
通过数据控制模块提前将库存存入 Redis,将每个秒杀商品在 Redis 中用一个 hash 结构表示。
"goodsId" : {
"Total": 100
"Booked": 100
}
扣量时,服务器通过请求 Redis 获取下单资格,通过以下 lua 脚本实现,由于 Redis 是单线程模型,lua 可以保证多个命令的原子性。
local n = tonumber(ARGV[1])
if not n or n == 0 then
return 0
end
local vals = redis.call("HMGET", KEYS[1], "Total", "Booked");
local total = tonumber(vals[1])
local blocked = tonumber(vals[2])
if not total or not blocked then
return 0
end
if blocked + n <= total then
redis.call("HINCRBY", KEYS[1], "Booked", n)
return n;
end
return 0
先使用
SCRIPT LOAD
将 lua 脚本提前缓存在 Redis,然后调用
EVALSHA
调用脚本,比直接调用
EVAL
节省网络带宽:
redis 127.0.0.1:6379>SCRIPT LOAD "lua code"
"438dd755f3fe0d32771753eb57f075b18fed7716"
redis 127.0.0.1:6379>EVAL 438dd755f3fe0d32771753eb57f075b18fed7716 1 goodsId 1
秒杀服务通过判断 Redis 是否返回抢购个数 n,即可知道此次请求是否扣量成功。
使用主从版 Redis 实现简单的消息队列异步下单入库
扣量完成后,需要进行订单入库。如果商品数量较少的时候,直接操作数据库即可。如果秒杀的商品是1万,甚至10万级别,那数据库锁冲突将带来很大的性能瓶颈。因此,利用消息队列组件,当秒杀服务将订单信息写入消息队列后,即可认为下单完成,避免直接操作数据库。
- 消息队列组件依然可以使用 Redis 实现,在 R2 中用 list 数据结构表示。
orderList {
[0] = {订单内容}
[1] = {订单内容}
[2] = {订单内容}
...
}
将订单内容写入 Redis:
LPUSH orderList {订单内容}
异步下单模块从 Redis 中顺序获取订单信息,并将订单写入数据库。
BRPOP orderList 0
通过使用 Redis 作为消息队列,异步处理订单入库,有效的提高了用户的下单完成速度。
数据控制模块管理秒杀数据同步
扣量完成后,需要进行订单入库。如果商品数量较少的时候,直接操作数据库即可。如果秒杀的商品是1万,甚至10万级别,那数据库锁冲突将带来很大的性能瓶颈。因此,利用消息队列组件,当秒杀服务将订单信息写入消息队列后,即可认为下单完成,避免直接操作数据库。
- 消息队列组件依然可以使用 Redis 实现,在 R2 中用 list 数据结构表示。
orderList {
[0] = {订单内容}
[1] = {订单内容}
[2] = {订单内容}
...
}
将订单内容写入 Redis:
LPUSH orderList {订单内容}
异步下单模块从 Redis 中顺序获取订单信息,并将订单写入数据库。
BRPOP orderList 0
通过使用 Redis 作为消息队列,异步处理订单入库,有效的提高了用户的下单完成速度。
数据控制模块管理秒杀数据同步
最开始,利用读写分离 Redis 进行流量限制,只让部分流量进入下单。对于下单检验失败和退单等情况,需要让更多的流量进来。因此,数据控制模块需要定时将数据库中的数据进行一定的计算,同步到主从版 Redis,同时再同步到读写分离的 Redis,让更多的流量进来