秒杀系统
- edit by zml
- 乐观锁解决超卖问题 + 令牌桶解决高并发问题 + 限时抢购 + 接口隐藏处理 + 单一用户抢购次数限制
1.秒杀系统
1.1 秒杀场景
- 电商抢购限量商品
- 卖周董演唱会的门票
- 火车票抢座12306
- …….
1.2 为什么要做个系统
如果你的项目流量非常小,完全不用担心有并发的购买请求,那么做这样一个系统意义不大。
但如果你的系统要向12306那样,接受高并发访问和下单的考验,那么你就需要一套完整的流程保护措施
,来保证你系统在用户流量高峰期不会被搞挂了。
- 严格防止超卖 : 库存100件你卖了120件,等着辞职把
- 防止黑产 :防止不怀好意的人群通过各种技术手段把你本该下发给群众的利益全收入了囊中。
- 保证用户体验 :高并发下,网页打不开了/支付不成功了/购物车进不去了/地址改不了了。这个问题非常之大,涉及到各种技术,也不是一下子就能讲完的,甚至根本讲不完。
1.3 保护措施有哪些
乐观锁防止超卖
--> 核心基础令牌桶限流
Redis缓存
消息队列异步处理订单
- ……
2.防止超卖
毕竟,你网页可以卡住最多是大家没参与到活动,上网口吐芬芳,骂你一波。但是你要是卖多了,本该拿到商品的用户可就不乐意了,轻则投诉你,重则找漏洞起诉赔偿,让你吃不了兜着走。
2.1 数据库表
CREATE TABLE `study`.`stock`
(
`id` int(11) NOT NULL,
`name` varchar(45) DEFAULT NULL COMMENT '产品名称',
`sale` int(11) DEFAULT NULL COMMENT '销售量',
`count` int(11) DEFAULT NULL COMMENT '库存',
`create_date` timestamp(6) NULL DEFAULT NULL COMMENT '创建时间',
`version` int(11) DEFAULT NULL COMMENT '版本号',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8 COMMENT ='秒杀库存表'
CREATE TABLE `study`.`order`
(
`id` INT NOT NULL,
`stock_id` INT NULL COMMENT '库存商品id',
`name` VARCHAR(45) NULL COMMENT '库存商品名称',
`create_date` TIMESTAMP(6) NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
)
ENGINE = InnoDB
DEFAULT CHARACTER SET = utf8;
2.2 业务分析
2.3 悲观锁方式开发代码
SecKillController.java
@RestController
@RequestMapping("seckill")
public class SecKillController {
@Autowired
private SecKillService secKillService;
private static Logger LOGGER = LoggerFactory.getLogger(SecKillController.class);
@RequestMapping("syncOrder")
public String syncOrder(Integer id) {
System.out.println("id = " + id);
try {
synchronized (id) {
Integer orderId = secKillService.syncOrder(id);
return "秒杀成功,订单号为:" + orderId;
}
} catch (Exception e) {
e.printStackTrace();
return e.getMessage();
}
}
}
SecKillService.java
@Service
public class SecKillServiceImpl implements SecKillService {
private static Logger logger = LoggerFactory.getLogger(SecKillServiceImpl.class);
@Autowired
private SecKillMapper secKillMapper;
@Transactional
public Integer syncOrder(Integer id) {
// 校验库存
Stock stock = check(id);
// 减库存
stock.setSale(stock.getSale() + 1);
stock.setCount(stock.getCount() - 1);
secKillMapper.reduceCount(stock);
// 下单
Order order = new Order();
order.setName(stock.getName());
order.setStockId(stock.getId());
secKillMapper.order(order);
return order.getId();
}
/**
* 校验库存
*
* @param id
* @return
*/
public Stock check(Integer id) {
Stock stock = secKillMapper.queryStockById(id);
if (stock.getCount() <= 0) {
throw new RuntimeException("库存不足");
}
return stock;
}
}
SecKillMapper.java
@Mapper
public interface SecKillMapper {
//校验商品是否还有库存
Stock queryStockById(Integer id);
void order(Order order);
void reduceCount(Stock stock);
}
SecKillMapper.xml
<mapper namespace="cn.ys.springbootdemo.mapper.seckill.SecKillMapper">
<select id="queryStockById" parameterType="integer"
resultType="cn.ys.springbootdemo.pojo.seckill.Stock">
select id , name , sale ,count, create_date as createDate,version from study.stock where id = #{id}
</select>
<insert id="order" parameterType="cn.ys.springbootdemo.pojo.seckill.Order"
useGeneratedKeys="true"
keyProperty="id">
insert into study.order (id ,stock_id , name , create_date) values (#{id},#
{stockId},#{name},now())
</insert>
<update id="reduceCount" parameterType="cn.ys.springbootdemo.pojo.seckill.Stock">
update study.stock set sale = #{sale} , count = #{count} where id = #{id}
</update>
</mapper>
Stock.java
public class Stock {
private Integer id ;
private String name ;
private Integer sale ;
private Integer count ;
private Date createDate ;
private Integer version ;
get .. set ..
}
Stock.java
public class Order {
private Integer id;
private Integer stockId;
private String name;
private Date createDate;
get... set...
}
2.4 正常测试
在正常测试下发现没有问题
2.5 Jmeter压力测试
官网
: https://jmeter.apache.org/
1、介绍
Apache JMeter
是Apache组织开发的基于Java的压力测试工具。用于对软件做压力测试,它最初被设计用于Web应用测试,但后来扩展到其他测试领域。 它可以用于测试静态和动态资源,例如静态文件、Java 小服务程序、CGI 脚本、Java 对象、数据库、FTP 服务器, 等等。JMeter 可以用于对服务器、网络或对象模拟巨大的负载,来自不同压力类别下测试它们的强度和分析整体性能。另外,JMeter能够对应用程序做功能/回归测试,通过创建带有断言的脚本来验证你的程序返回了你期望的结果。为了最大限度的灵活性,JMeter允许使用正则表达式创建断言。
2、使用
详见我的博文:https://blog.csdn.net/MonkeySun123321/article/details/81903612(Jmeter接口压力测试)
2.6 乐观锁解决超卖
说明:使用乐观锁解决商品的超卖问题交给数据库解决,利用数据库中定义的version字段以及数据库中的事务实现在并发情况下商品的超卖问题。该方式为线程安全的。
SecKillController.java
@RequestMapping("optimisticOrder")
public String optimisticOrder(Integer id) {
System.out.println("id = " + id);
try {
Integer orderId = secKillService.optimisticOrder(id);
return "秒杀成功,订单号为:" + orderId;
} catch (Exception e) {
e.printStackTrace();
return e.getMessage();
}
}
SecKillServiceImpl.java
@Transactional
public Integer optimisticOrder(Integer id) {
// 校验库存
Stock stock = check(id);
// 减库存
int updateCount = secKillMapper.reduceCountWithVersion(stock);
if (updateCount == 0) {
throw new RuntimeException("抢购失败,请重新抢购");
}
// 下单
Order order = new Order();
order.setName(stock.getName());
order.setStockId(stock.getId());
secKillMapper.order(order);
return order.getId();
}
public Stock check(Integer id) {
Stock stock = secKillMapper.queryStockById(id);
if (stock.getCount() <= 0) {
throw new RuntimeException("库存不足");
}
return stock;
}
SecKillMapper.java
@Mapper
public interface SecKillMapper {
//校验商品是否还有库存
Stock queryStockById(Integer id);
void order(Order order);
void reduceCount(Stock stock);
int reduceCountWithVersion(Stock stock);
}
SecKillMapper.xml
<mapper namespace="cn.ys.springbootdemo.mapper.seckill.SecKillMapper">
<select id="queryStockById" parameterType="integer"
resultType="cn.ys.springbootdemo.pojo.seckill.Stock">
select id , name , sale ,count, create_date as createDate,version from study.stock
where id = #{id}
</select>
<insert id="order" parameterType="cn.ys.springbootdemo.pojo.seckill.Order"
useGeneratedKeys="true"
keyProperty="id">
insert into study.order (id ,stock_id , name , create_date) values (#{id},#
{stockId},#{name},now())
</insert>
<update id="reduceCountWithVersion"
parameterType="cn.ys.springbootdemo.pojo.seckill.Stock">
update study.stock set sale = sale+1 , count = count-1 ,version = version+1
where id = #{id} and version = #{version}
</update>
</mapper>
3.令牌桶限流
3.1 令牌桶和漏桶算法特点介绍
流量控制在计算机领域称为过载保护
。何为过载保护?所谓“过载”,即需求超过了负载能力;而“保护”则是指当“过载”发生了,采取必要的措施保护自己不受“伤害”。在计算机领域,尤其是分布式系统领域,“过载保护”是一个重要的概念。一个不具备“过载保护”功能的系统,是非常危险和脆弱的,很可能由于瞬间的压力激增,引起“雪崩效应”,导致系统的各个部分都同时崩溃,停止服务。这就好像在没有保险丝的保护下,电压突然变高,导致所有的电器都会被损坏一样,“过载保护”功能是系统的“保险丝”。
常用的限流算法有两种:漏桶算法和令牌桶算法
。
令牌与漏桶的区别:1、漏桶是出,令牌是进 2、令牌是允许伸缩
3.1.1 漏桶算法
漏桶算法思路很简单,请求先进入到漏桶里,漏桶以固定的速度出水,也就是处理请求,当水加的过快,则会直接溢出,也就是拒绝请求,可以看出漏桶算法能强行限制数据的传输速率。
漏桶算法(Leaky Bucket)是网络世界中流量整形(Traffic Shaping)或速率限制(Rate Limiting)时经常使用的一种算法,它的主要目的是控制数据注入到网络的速率,平滑网络上的突发流量。漏桶算法提供了一种机制,通过它,突发流量可以被整形以便为网络提供一个稳定的流量。
漏桶可以看作是一个带有常量服务时间的单服务器队列,如果漏桶(包缓存)溢出,那么数据包会被丢弃。 在网络中,漏桶算法可以控制端口的流量输出速率,平滑网络上的突发流量,实现流量整形,从而为网络提供一个稳定的流量。
如图所示,把请求比作是水,水来了都先放进桶里,并以限定的速度出水,当水来得过猛而出水不够快时就会导致水直接溢出,即拒绝服务。可以看出,漏桶算法可以很好的控制流量的访问速度,一旦超过该速度就拒绝服务。
3.2.2 令牌桶算法
令牌桶算法是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法。典型情况
下,令牌桶算法用来控制发送到网络上的数据的数目,并允许突发数据的发送。
令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取
一个令牌,当桶里没有令牌可取时,则拒绝服务。从原理上看,令牌桶算法和漏桶算法是相反的,一个“进水”,一
个是“漏水”。
Google的Guava包中的RateLimiter类就是令牌桶算法的解决方案。
3.3.3 漏桶算法和令牌桶算法的选择
漏桶算法与令牌桶算法在表面看起来类似,很容易将两者混淆。但事实上,这两者具有截然不同的特性,且为
不同的目的而使用。
漏桶算法与令牌桶算法的区别在于,漏桶算法能够强行限制数据的传输速率,令牌桶算法能够在限制数据的平
均传输速率的同时还允许某种程度的突发传输。
需要注意的是,在某些情况下,漏桶算法不能够有效地使用网络资源,因为漏桶的漏出速率是固定的,所以即
使网络中没有发生拥塞,漏桶算法也不能使某一个单独的数据流达到端口速率。因此,漏桶算法对于存在突发特性
的流量来说缺乏效率。而令牌桶算法则能够满足这些具有突发特性的流量。通常,漏桶算法与令牌桶算法结合起来
为网络流量提供更高效的控制。
3.2 如何使用令牌桶算法
pom.xml
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>25.1-jre</version>
</dependency>
SecKillController
private static RateLimiter rateLimiter = RateLimiter.create(30);
@RequestMapping("token")
public String token(Integer id) {
// 请求获取不到token,则一直等待,直至获取到token
//LOGGER.info("请求等待时间:" + rateLimiter.acquire());
// 设置超时时间 如果请求在2s内获取不到token,则抛弃该请求
if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)) {
LOGGER.info("当前请求被限流,直接抛弃");
throw new RuntimeException("当前请求被限流,直接抛弃");
} else {
System.out.println("id = " + id);
return "抢购成功";
}
}
并发100个用户访问,观察效果
3.3 秒杀系统通过令牌桶限流
SecKillController
/**
* 乐观锁解决超卖问题 + 令牌桶解决高并发问题
*
* @param id
* @return
*/
@RequestMapping("optimisticOrderWithToken")
public String optimisticOrderWithToken(Integer id) {
try {
if (!rateLimiter.tryAcquire(3, TimeUnit.SECONDS)) {
LOGGER.info("当前请求被限流,直接抛弃");
return "当前请求被限流,直接抛弃";
}
System.out.println("id = " + id);
Integer orderId = secKillService.optimisticOrder(id);
return "秒杀成功,订单号为:" + orderId;
} catch (Exception e) {
e.printStackTrace();
return e.getMessage();
}
}
4.其他细节问题
上边我们已经完成了防止超卖商品和抢购接口的限流,已经能够防止大流量把我们的服务器直接搞炸,现在,我们要关心一些细节的问题:
1、我们应该在一定的时间内执行秒杀处理,不能在任意时间都接受秒杀请求。如何加入时间验证
2、对于稍微懂点电脑的,又会动歪脑筋的人来说开始通过抓包方式获取我们的接口地址,然后通过脚本进行抢购
怎么办
3、秒杀开始之后如何限制单个用户的请求频率,即单位时间内限制访问次数
这个章节主要讲解秒杀系统中,关于抢购(下单)接口相关的单用户防刷措施,主要有三点:
限时抢购
抢购接口隐藏
单用户限制频率(单位时间内限制访问次数)
4.1 限时抢购
使用Redis来记录秒杀商品的时间,对秒杀过期请求进行拒绝处理
1、启动Redis
./redis-server ../redis.config
2、执行如下命令
1、打开redis客户端,执行 ./redis-cli
2、执行 set kill1 1 ex 30
3、ttl kill1
3、代码实现
pom.xml
<!--引入redis -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.1.3.RELEASE</version>
</dependency>
SecKillController
/**
* 乐观锁解决超卖问题 + 令牌桶解决高并发问题 + 限时抢购
*
* @param id
* @return
*/
@RequestMapping("optimisticOrderWithTokenAndTimer")
public String optimisticOrderWithTokenAndTimer(Integer id) {
try {
if (!rateLimiter.tryAcquire(3, TimeUnit.SECONDS)) {
LOGGER.info("当前请求被限流,直接抛弃");
return "当前请求被限流,直接抛弃";
}
System.out.println("id = " + id);
Integer orderId = secKillService.optimisticOrderWithTimer(id);
return "秒杀成功,订单号为:" + orderId;
} catch (Exception e) {
return e.getMessage();
}
}
SecKillServiceImpl.java
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Transactional
public Integer optimisticOrderWithTimer(Integer id) {
// 限时抢购校验
if (!stringRedisTemplate.hasKey("kill" + id)) {
throw new RuntimeException("抢购时间已过,请参与下一轮抢购");
}
// 校验库存
Stock stock = check(id);
// 减库存
int updateCount = secKillMapper.reduceCountWithVersion(stock);
if (updateCount == 0) {
throw new RuntimeException("抢购失败,请重新抢购");
}
// 下单
Order order = new Order();
order.setName(stock.getName());
order.setStockId(stock.getId());
secKillMapper.order(order);
return order.getId();
}
4.2 抢购接口隐藏
对于稍微懂点电脑的,又会动歪脑筋的人来说,点击F12打开浏览器的控制台,就能在点击抢购按钮后,获取我们抢购接口的链接。(手机APP等其他客户端可以通过抓包来拿到)一旦坏蛋拿到我们抢购的链接,只要稍微写点爬虫代码,模拟一个抢购请求,就可以不通过点击下单按钮,直接在代码中请求我们的接口,完成下单。所以就有了成千上万的薅羊毛军团。写一些脚本抢购各种秒杀商品
他们只需要在抢购时刻的000毫秒,开始不间断发送大量请求,觉得比大家在APP上点抢购按钮要快,毕竟人的速度有极限,更别说APP说不定还要经过几层前端验证才能真正发出请求。
所以我们需要将抢购接口隐藏,抢购接口隐藏(接口加盐salt)具体做法如下:
4.2.1 盐获取代码实现
SecKillController
@RequestMapping("getMd5")
public String getMd5(Integer id, Integer goodsId) {
try {
String md5 = secKillService.getMd5(id, goodsId);
return md5;
} catch (Exception e) {
return "加密失败";
}
}
SecKillServiceImpl.java
/**
* 根据用户id + 抢购产品id 进行md5加密
*
* @param id
* @param goodsId
* @return
*/
public String getMd5(Integer id, Integer goodsId) {
// 校验用户合法性 getUserById(Integer id)
// 校验商品合法性 getStockById(Integer id)
// 加密
String secret_key = "KEY_" + id + "_" + goodsId;
String secret_value_md5 = DigestUtils.md5DigestAsHex((secret_key +
"#@&%!").getBytes());
logger.info("key [{}] value [{}]", secret_key, secret_value_md5);
// 添加到redis中
stringRedisTemplate.opsForValue().set(secret_key, secret_value_md5, 300,
TimeUnit.SECONDS);
return secret_value_md5;
}
获取md5的字符串
4.2.2 用户携带盐进行验证抢购
SecKillController
/**
* 乐观锁解决超卖问题 + 令牌桶解决高并发问题 + 限时抢购 + 接口隐藏处理
*
* @param id
* @return
*/
@RequestMapping("optimisticOrderWithTokenAndTimerHide")
public String optimisticOrderWithTokenAndTimerHide(Integer id, Integer goodsId, String
md5) {
try {
if (!rateLimiter.tryAcquire(3, TimeUnit.SECONDS)) {
LOGGER.info("当前请求被限流,直接抛弃");
return "当前请求被限流,直接抛弃";
}
Integer orderId = secKillService.optimisticOrderWithTokenAndTimerHide(id,
goodsId, md5);
return "秒杀成功,订单号为:" + orderId;
} catch (Exception e) {
return e.getMessage();
}
}
SecKillServiceImpl.java
public Integer optimisticOrderWithTokenAndTimerHide(Integer id, Integer goodsId, String md5) {
// 限时抢购校验
if (!stringRedisTemplate.hasKey("kill" + id)) {
throw new RuntimeException("抢购时间已过,请参与下一轮抢购");
}
// 校验用户是否合法
logger.info("id [{}] goodsId [{}] md5 [{}]", id, goodsId, md5);
String key = "KEY_" + id + "_" + goodsId;
if (!stringRedisTemplate.hasKey(key) ||
!stringRedisTemplate.opsForValue().get(key).equals(md5)) {
throw new RuntimeException("该用户不合法");
}
logger.info("limit_key [{}] limit [{}]", limit_key, limit);
if (10 < limit) {
throw new RuntimeException("该用户抢购次数已超限,请停止抢购");
}
// 校验库存
Stock stock = check(id);
// 减库存
int updateCount = secKillMapper.reduceCountWithVersion(stock);
if (updateCount == 0) {
throw new RuntimeException("抢购失败,请重新抢购");
}
// 下单
Order order = new Order();
order.setName(stock.getName());
order.setStockId(stock.getId());
secKillMapper.order(order);
return order.getId();
}
4.3 单用户限制频率
假设我们做好了接口隐藏,但是像我们上边说的,总有无聊的人写一些复杂脚本,先请求Hash值,再立刻请求购买,如果你的app下单按钮做的很差,大家都要开抢后0.5秒才能请求成功,那可能会让脚本仍然能够在大家面前抢购成功。
我们需要做一个额外的措施,来限制单用户的抢购频率。
其实很简单,就是用redis给每个用户做访问统计,甚至可以带上商品id,对单个商品做访问统计,都是可行的
实现思路:在用户申请下单时,检查用户的访问次数,超过访问次数,则不让他继续抢购商品[
]()
SecKillServiceImpl.java
/**
* 乐观锁解决超卖问题 + 令牌桶解决高并发问题 + 限时抢购 + 接口隐藏处理 + 单一用户抢购次数限制
*/
public Integer optimisticOrderWithTokenAndTimerHide(Integer id, Integer goodsId, String md5) {
// 限时抢购校验
if (!stringRedisTemplate.hasKey("kill" + id)) {
throw new RuntimeException("抢购时间已过,请参与下一轮抢购");
}
// 校验用户是否合法
logger.info("id [{}] goodsId [{}] md5 [{}]", id, goodsId, md5);
String key = "KEY_" + id + "_" + goodsId;
if (!stringRedisTemplate.hasKey(key) ||
!stringRedisTemplate.opsForValue().get(key).equals(md5)) {
throw new RuntimeException("该用户不合法");
}
// 单个用户抢购次数限制
String limit_key = "LIMIT" + id + "_" + goodsId;
int limit;
if (stringRedisTemplate.hasKey(limit_key)) {
limit = Integer.parseInt(stringRedisTemplate.opsForValue().get(limit_key)) + 1;
stringRedisTemplate.opsForValue().set(limit_key, String.valueOf(limit), 300,
TimeUnit.SECONDS);
} else {
limit = 1;
stringRedisTemplate.opsForValue().set(limit_key, String.valueOf(limit), 300,
TimeUnit.SECONDS);
}
logger.info("limit_key [{}] limit [{}]", limit_key, limit);
if (10 < limit) {
throw new RuntimeException("该用户抢购次数已超限,请停止抢购");
}
// 校验库存
Stock stock = check(id);
// 减库存
int updateCount = secKillMapper.reduceCountWithVersion(stock);
if (updateCount == 0) {
throw new RuntimeException("抢购失败,请重新抢购");
}
// 下单
Order order = new Order();
order.setName(stock.getName());
order.setStockId(stock.getId());
secKillMapper.order(order);
return order.getId();
}
结束