商品秒杀功能的高并发解决方案
一。业务逻辑分析
- 所谓秒杀: 从业务角度看,是短时间内多个用户“争抢”资源,这里的资源在大部分秒杀场景里是商品;将业务抽象,技术角度看,秒杀就是多个线程对资源进行操作,所以实现秒杀,就必须控制线程对资源的争抢,既要保证高效并发,也要保证操作的正确
1.秒杀业务的大概运行流程
- 提交秒杀商品申请(审核通过),录入秒杀商品数据,主要有:商品标题,商品原价,秒杀价格,商品图片,介绍等信息
- 运营商审核秒杀申请
- 秒杀频道首页列出秒杀商品,点击秒杀商品图片可以跳转到秒杀商品详细页面
- 商品详细页面显示秒杀商品信息,点击立即抢购实现秒杀下单,下单时扣减库存,当库存为0或者不存在活动时间范围内时无法秒杀
- 秒杀下单成功,直接跳转到支付页面(扫码),支付成功,跳转到成功页面,填写收货、电话、收件人等信息,完成订单。
- 当用户秒杀下单5分钟内未支付,取消预订单,调用支付的关闭订单接口,恢复库存。
A:查询模块(查询库存)并发查询,库存数存在缓存中,(商品信息和图片信息等)静态化处理(生成静态页)和库存剩余数量缓存化处理。
B: 下订单模块(秒杀关键部分),队列控制异步化处理,首先判断这个队列是否已满, 如果没满就将请求放入队列中排队,队列满以后的所有请求直接返回秒杀失败。
C: 支付模块,异步付款,等待付款成功结果。(付款成功更新库存,也可下单的时候扣库存)。
2.秒杀/抢购技术特点
-
读多写少 (一趟火车其实只有2000张票,200w个人来买,最多2000个人下单成功,其他人都是查询库存)
- 使用(Redis)缓存解决
-
高并发
-
限流 (限制同一时间访问的流量)
-
负载均衡 (单体tomcat并发200完美胜任,突破五,六百就力不从心)
-
缓存 (预先把秒杀商品加载进内存)
-
异步 (将同步的并发请求转换为异步,多线程处理)
-
队列 (使用redis队列,因为pop操作是原子的,即使有很多用户同时到达,也是依次执行)
-
3.秒杀架构思想
(1).设计思路
-
超买超卖问题的解决。
-
**将请求拦截在系统上游,降低下游压力: ** 秒杀系统特点是并发量极大,但实际秒杀成功的请求数量却很少,所以如果不在前端拦截很可能造成数据库读写锁冲突,甚至导致死锁,最终请求超时
-
**充分利用缓存: **利用缓存可极大提高系统读写速度。
-
消息队列: 消息队列可以削峰,将拦截大量并发请求,这也是一个异步处理过程,后台业务根据自己的处理能力,从消息队列中主动的拉取请求消息进行业务处理。
二。秒杀中的问题解决方案
1.秒杀页面(恶意刷新页面)
-
秒杀活动开始前,其实就有很多用户访问该页面了。如果这个页面的一些资源,比如 CSS、JS、图片、商品详情等,都访问后端服务器,甚至 DB 的话,服务肯定会出现不可用的情况。
-
解决方案: 所以要创建静态页面让这个页面整体进行静态化,并将页面静态化之后的页面分发到 CDN(类似于资源服务器) 边缘节点上,起到压力分散的作用
-
生成的静态页面会遇到的问题: 由于我们以后开发的系统肯定不是给自己用的,用户可能处于不同的时区,他们的当前系统时间也是不同的,所以我们写一个通用的时间规范:就是当前服务器的时间;
2.防止提前下单
- 在之前我们做的后端项目中,跳转到某个详情页一般都是:根据ID查询该详情数据,然后将页面跳转到详情页并将数据直接渲染到页面上。但是秒杀系统不同,它也不能就这样简单的定义;
- 解决方案:
- 首先要保证该商品处于秒杀状态。也就是1.秒杀开始时间要<当前时间;2.秒杀截止时间要>当前时间。
- 要保证一个用户只能抢购到一件该商品,应做到商品秒杀接口对应同一用户只能有唯一的一个URL秒杀地址,不同用户间秒杀地址应是不同的,且配合订单表
seckill_order
中联合主键的配置实现。- 可以根据正在进行秒杀的商品ID生成秒杀地址值(md5混合值, 避免用户抓包拿到秒杀地址)
- 通过MD5加密以后,用户在秒杀之前模拟不出真实的地址
3.Nginx优化(可以防止一个ip多个账号抢购)
-
动态资源与静态资源进行分离,获取静态资源时不走Tomcat
-
限制某个IP同一时间段的访问次数,即针对某一个IP,限制单位时间内发起请求数量。
http { limit_req_zone $binary_remote_addr zone=perip:10m rate=1r/s; limit_req_zone $server_name zone=perserver:10m rate=10r/s; ... server { ... limit_req zone=perip burst=5 nodelay; #漏桶数为5个.也就是队列数.nodelay:不启用延迟. limit_req zone=perserver burst=10; #限制nginx的处理速率为每秒10个 } # 静态资源 server { #侦听端口 listen 80; #当前虚拟机所配置的域名信息[所有该域名访问都遵循该配置规则] server_name www.seckill.com; #所有以.jpg,.png。。。都遵循该配置路由规则 location ~ \.(jpg|png|gif|js|css)$ { #都直接到D盘的images目录找文件 root D:/images; } } }
4.库存超卖
原子性(atomicity):一个事务是一个不可分割的最小工作单位,要么都成功要么都失败
Redis所有单个命令的执行都是原子性的
- 秒杀的商品只有10个库存,可能一秒钟有1k个订单;核心思想就是保证库存递减是原子性操作
当减库存和高并发碰到一起的时候,由于操作的库存数目在同一行,就会出现争抢InnoDB行锁的问题,导致出现互相等待甚至死锁,从而大大降低MySQL的处理性能,最终导致前端页面出现超时异常。
-
解决方案:
-
数据库
-
update seckill set num=num-1 where num>0 and goodsId=1
数据库查询、更新的时候有用到行级锁,是可以保证更新操作的原子性的。数据库性能较差,不建议使用
```
-
Redis分布式锁
用redis来做一个分布式锁,reids->setnx(‘lock’, 1) 设置一个锁,程序执行完成再解锁。锁定的过程,不利于并发执行,所有线程都在等待锁解开,不建议使用。
```
-
消息队列
将订单请求全部放入消息队列,然后另外一个后台程序监听消息一个个处理队列中的订单请求。
并发不受影响,但是用户等待的时间较长,进入队列的订单也会很多,体验上不好,不建议使用。
```
-
redis递减
通过 redis->incrby('product', -1) 得到递减之后的库存数。
redis的incr和decr 可以实现原子性的递增递减
```
- Java代码的实现
```java
// 根据抢购的商品id
Seckill seckill = (Seckill) redisTemplate.boundHashOps("seckill").get(goodsId);
if (seckill.getStockCount() < 0) {
// 商品已售完
throw new SeckillException("商品已售完");
}
// 减少抢购商品的库存信息
Long count = redisTemplate.boundHashOps("seckill_count").increment(goodsId,-1);
// 更新库存信息
seckill.setStockCount(count);
if (count <= 0) {
// 库存不足
// 更新数据库库存数据
seckill.setStockCount(count);
seckillMapper.update(seckill);
// 秒杀结束,把订单信息更新到MySQL中
throw new SeckillException("库存不足,谢谢参与!");
} else {
// 更新库存信息
redisTemplate.boundHashOps("seckill").put(goodsId,seckill);
// 存储订单信息,暂时存储到redis中
}
```
5.解决高并发
活动周期短,瞬间流量大(高并发),大量的人短期涌入服务器抢购,但是数量有限,最终只有少数人能成功下单。
-
解决方案:
- 利用Redis建立一条队列,将每个请求加入到队列中,然后异步获取队列数据进行处理,把多线程的事情变成单线程,处理完一个就从队列中删除一个。
- 这样可能会发生redis雪崩现象,请求特别多的时候,一瞬间将redis队列内存撑爆,导致系统异常,又或者队列内存足够大
-
可以使用限流限制过多请求访问系统
- Ratelimiter: Google开源工具包Guava提供了限流工具类RateLimiter,该类基于令牌桶算法(Token Bucket)来完成限流
’栈’:从链表的头部添加元素,先进后出
’队列’:从链表的尾部添加元素,先进先出
// 存入队列, 构建用户抢单信息
SeckillStatus seckillStatus = new SeckillStatus(userPhone, new Date(), 1, seckillId);
// 存储用户排队信息(先进先出)
redisTemplate.boundListOps("seckillOrderStatus").leftPush(seckillStatus);
// 获取队列排队中的用户
SeckillStatus seckillStatus = (SeckillStatus)redisTemplate.boundListOps("seckillOrderStatus").rightPop();
6.多个集群的数据怎么保持一致性
不要做多集群的数据同步,而是用散列,每个集群的数据是独立存在的。
假设,有10个商品,每个商品有1w库存,规划用10个集群,那么每个集群有10个商品,每个商品是1k库存。
每个集群只需要负责把自己的库存卖掉即可,至于说,会不会有用户知道有10个集群,然后每个集群都去抢。
这种情况就不要用程序来处理了,利用运营规则,活动结束后汇总订单的时候再去处理
比如:某个集群用户访问量特别少,那么可以引入一个中控服务,来监控各个集群的库存,然后再做平衡。
三。秒杀模块的数据库设计
1.数据库设计
-
垂直(纵向)切分
-
垂直分库就是根据业务耦合性,将关联度低的不同表存储在不同的数据库。做法与大系统拆分为多个小系统类似,按业务分类进行独立划分。与"微服务"的做法相似,每个微服务使用单独的一个数据库
-
也能避免跨页问题,MySQL底层是通过数据页存储的,一条记录占用空间过大会导致跨页,造成额外的性能开销。另外数据库以行为单位将数据加载到内存中,这样表中字段长度较短且访问频率较高,内存能加载更多的数据,命中率更高,减少了磁盘IO,从而提升了数据库性能。
-
秒杀跟普通商品购买是有区别的,所以数据库表设计也不同 进行业务隔离
-
将这种秒杀数据隔离出来,不要让1%的请求影响到另外的99%,隔离出来后也更方便对这1%的请求做针对性优化
-
进行表与表之间的分离
-
垂直切分的优点:
解决业务系统层面的耦合,业务清晰
与微服务的治理类似,也能对不同业务的数据进行分级管理、维护、监控、扩展等
高并发场景下,垂直切分一定程度的提升IO、数据库连接数、单机硬件资源的瓶颈
1.Ratelimiter的简单使用
常用的两种限流方式
使用漏桶(Leaky Bucket)算法来进行限流
使用令牌桶(Token Bucket)算法来进行限流
漏桶: 类似于队列, 水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率
QPS-->Queries Per Second “每秒查询率” 一台服务器每秒能够相应的查询次数
令牌桶: 系统会按恒定1/QPS时间间隔(如果QPS=100,则间隔是10ms)的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
-
项目中的使用
-
导入pom.xml依赖
<!--基于令牌桶算法实现流量限制-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>27.1-jre</version>
</dependency>
- 代码实现
@Service
public class AccessLimitServiceImpl implements AccessLimitService {
/**
* 每秒钟只发出60个令牌,拿到令牌的请求才可以进入秒杀过程
*/
private RateLimiter seckillRateLimiter = RateLimiter.create(60);
/**
* 尝试从令牌桶中获取令牌
* @return
*/
@Override
public boolean tryAcquireSeckill() {
return seckillRateLimiter.tryAcquire();
}
}
@Autowired
private GuavaRateLimiterService rateLimiterService;
@ResponseBody
@RequestMapping("/ratelimiter")
public Result testRateLimiter(){
if(rateLimiterService.tryAcquire()){
return ResultUtil.success1(1001,"成功获取令牌");
}
return ResultUtil.success1(1002,"未获取到令牌");
}
ava
@Autowired
private GuavaRateLimiterService rateLimiterService;
@ResponseBody
@RequestMapping("/ratelimiter")
public Result testRateLimiter(){
if(rateLimiterService.tryAcquire()){
return ResultUtil.success1(1001,“成功获取令牌”);
}
return ResultUtil.success1(1002,“未获取到令牌”);
}