单机秒杀系统的架构设计与实现
一,秒杀系统
1,秒杀场景
电商抢购限量商品
抢购演唱会的门票
火车票抢座12306
…
2.为什么要做个系统
如果项目流量非常小,完全不用担心并发请求的购买,那么做这样一个系统的意义并不大。但是如果你的系统要是像12306一样,接受高并发访问和下单的考验,那么你就需要一套完整的流程保护措施,来保证你系统在用户流量高峰期不会被搞挂了。
严格防止超卖:库存一百件,卖出去120件。
防止黑产:一个人全买了,其他人啥也没有。
保证用户体验:高并发下,网页打不开,支付不成功,购物车进不去,地址改不了,这个问题非常之大,涉及到各种技术。
3.保护措施有哪些
乐观锁防止超卖
令牌桶限流
redis缓存
消息队列异步处理订单
…
二,无锁状态下的秒杀系统
1.业务流程分析
1.前端接受一个秒杀请求传递到后端控制器
2.控制器接受请求参数,调用业务创建订单
3.业务层需要检验库存,扣除库存,(判断用户是否重复购买),创建订单
2.搭建项目
sql脚本
CREATE TABLE `ms_order` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '订单表',
`product_id` int(11) DEFAULT NULL COMMENT '商品id',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
CREATE TABLE `ms_stock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '库存表',
`product_id` int(11) DEFAULT NULL COMMENT '商品id',
`product_name` varchar(255) DEFAULT NULL COMMENT '商品名称',
`sum` int(11) DEFAULT NULL COMMENT '商品数量',
`sale` int(11) DEFAULT NULL COMMENT '售出数量',
`version` int(11) DEFAULT '0' COMMENT '版本号',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
pom依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.6.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yhd</groupId>
<artifactId>ms</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ms</name>
<description>ms project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.48</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.2</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
<version>1.18.8</version>
</dependency>
</dependencies>
实体类
@NoArgsConstructor
@AllArgsConstructor
@ToString
@TableName("ms_order")
//开启链式调用
@Accessors(fluent = true)
@Data
public class Order implements Serializable {
@TableId(type = IdType.AUTO)
private Integer id;
private Integer productId;
private Date createTime;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@TableName("ms_stock")
//开启链式调用
@Accessors(fluent = true)
public class Stock implements Serializable {
private Integer id;
private Integer productId;
private String productName;
private Integer sum;
private Integer sale;
private Integer version;
}
mapper
public interface OrderMapper extends BaseMapper<Order> {
}
public interface StockMapper extends BaseMapper<Stock> {
}
service
@Service
public class OrderService {
@Resource
private OrderMapper orderMapper;
@Resource
private StockMapper stockMapper;
/**
* 1.验证库存
* 2.修改库存
* 3.创建订单
* @param productId
* @return
*/
@Transactional
public Order Qg(Integer productId) {
Stock product = stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_id", productId));
if (product.sum().equals(product.sale())){
throw new RuntimeException("抢购失败,商品已经卖光!");
}else{
stockMapper.updateById(product.sale(product.sale()+1));
Order order = new Order();
orderMapper.insert(order.createTime(new Date()).productId(productId));
return order;
}
}
}
controller
@RestController
@RequestMapping("order")
public class OrderController {
@Resource
private OrderService orderService;
/**
* 用户点击抢购,开始下单
*/
@GetMapping("qg/{productId}")
public Order Qg(@PathVariable("productId") Integer productId){
return orderService.Qg(productId);
}
}
配置文件
server.port=8888
server.servlet.context-path=/ms
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc:mysql:///ms?characterEncoding=utf-8&useSSL=false&serverTimezone=GMT%2B8
mybatis-plus.type-aliases-package=com.yhd.ms.domain
mybatis-plus.mapper-locations=classpath:mapper/*.xml
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
3.jmeter压测工具
没有并发的情况下能够正常生成订单,但是当产生并发请求的时候,就会发生超卖问题。
三,单机下使用悲观锁解决超卖问题
首先因为synchronized
是本地锁,如果是集群模式下,这样加锁是无法解决超卖的。
1.synchronized和事务的小问题
@Transactional
public synchronized Order Qg(Integer productId) {
Stock product = stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_id", productId));
if (product.sum().equals(product.sale())){
throw new RuntimeException("抢购失败,商品已经卖光!");
}else{
stockMapper.updateById(product.sale(product.sale()+1));
Order order = new Order();
orderMapper.insert(order.createTime(new Date()).productId(productId));
return order;
}
}
单机模式下,我们在业务层代码加上synchronized
关键字,加上以后发现并没有解决超卖问题,原因是synchronized这把锁是在事务里面的一部分,释放锁以后,实际上事务并未执行完,当事务提交,还是会修改数据库,相当于锁白加了。
2.解决方案
第一种方法就是吧事务去掉,但是业务层代码不加事务的问题就不用多描述了。所以采用第二种
第二种:
/**
* 用户点击抢购,开始下单
*/
@GetMapping("qg/{productId}")
public Order Qg(@PathVariable("productId") Integer productId){
synchronized (this) {
return orderService.Qg(productId);
}
}
/**
* 1.验证库存
* 2.修改库存
* 3.创建订单
*
* @param productId
* @return
*/
@Transactional
public Order Qg(Integer productId) {
Stock stock = checkStock(productId);
updateStock(stock);
Order order = createOrder(stock);
return order;
}
/**
* 验证库存
*
* @param productId
* @return
*/
private Stock checkStock(Integer productId) {
Stock product = stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_id", productId));
if (product.sum().equals(product.sale())) {
throw new RuntimeException("抢购失败,商品已经卖光!");
}
return product;
}
/**
* 更新库存
*
* @param stock
* @return
*/
private Integer updateStock(Stock stock) {
return stockMapper.updateById(stock.sale(stock.sale() + 1));
}
/**
* 创建订单
*
* @param stock
* @return
*/
private Order createOrder(Stock stock) {
Order order = new Order();
orderMapper.insert(order.createTime(new Date()).productId(stock.productId()));
return order;
}
这次成功的解决了超卖问题,但是同时悲观锁也带来了效率低下的问题。
四,单机下使用乐观锁解决超卖问题
使用乐观搜解决商品超卖问题,实际上是把主要防止超卖问题交给数据库解决,利用数据库中定义的version
字段以及数据库中的事务实现在并发情况下商品超卖问题。
select * from ms_stock where id =1 and version =0;
update ms_stock set sale=sale+1,version=version+1 where id=#{id} and version =#{version}
经过压力测试,发现不但解决了超卖问题,效率上也得到了很大的提高,但是当请求数量在一秒钟上升到20000个的时候,可以看到,系统崩溃了。
五,令牌桶接口限流防止系统崩溃
1.接口限流
限流:对某一时间窗口内的请求进行限制,保持系统的可用性和稳定性,防止因为流量暴增而导致的系统运行缓慢或者宕机。
在面临高并发的抢购请求时,我们如果不对接口进行限流,可能会对后台系统造成极大压力。大量的请求抢购成功时需要调用下单接口,过多的请求打到数据库会对系统的稳定性造成影响。
2.如何解决接口限流
常用的限流算法有令牌桶算法和漏桶算法,而谷歌的开源项目Guava
中的RateLimiter
使用的就是令牌桶控制算法。在开发高并发系统时有三把利器保护系统:缓存,降级和限流。
缓存:缓存的目的是提升系统访问速度和增大系统的处理容量。
降级:降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行。
限流:通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务,排队或者等待,降级等处理。
3.漏桶算法和令牌桶算法
漏桶算法:请求先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。
令牌桶算法:在网络传输数据时,为了防止网络阻塞,需要限制流出网络的流量,使流量以比较均匀的速度向外发送。令牌桶算法就实现了这个功能,可控制发送到网络上数据的数目,并允许突发数据的发送。大小固定的令牌桶可自行以恒定的速率源源不断的产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断的增多,直到把通填满。后面在产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。这意味着,面对瞬时大流量,该算法可以在短时间内请求拿到大量令牌,而且那令牌的过程并不是消耗很大的事情。
4.令牌桶使用案例
/**
* 创建令牌桶
*/
private RateLimiter rateLimiter=RateLimiter.create(10);
/**
* 测试令牌桶算法
*
* @return
*/
@GetMapping("test")
public String testLpt(){
if (rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){
log.info("争抢令牌消耗的时间为:" +rateLimiter.acquire());
//模拟处理业务逻辑耗时
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "成功抢到令牌,消耗时间为:+" +rateLimiter.acquire();
}
log.info("未抢到令牌,无法执行业务逻辑!");
return "未抢到令牌,无法执行业务逻辑!";
}
5.使用令牌桶优化秒杀系统
private RateLimiter rateLimiter=RateLimiter.create(10);
/**
* 用户点击抢购,开始下单
*/
@GetMapping("qg/{productId}")
public String Qg(@PathVariable("productId") Integer productId) {
if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){
return "抢购失败,请重试!";
}
orderService.Qg(productId);
return "抢购成功,耗时为:"+rateLimiter.acquire();
}
六,隐藏秒杀接口
解决了超卖和限流问题,还要关注一些细节,此时秒杀系统还存在一些问题:
1.我们应该在一定的时间内执行秒杀处理,不能在任意时间都接受秒杀请求。如何加入时间验证?
2.对于稍微懂电脑的人,又会通过抓包的方式获取我们的接口地址,我们通过脚本进行抢购怎们么办?
3.秒杀开始之后如何限制单个用户的请求频率,即单位时间内限制访问次数?
1.使用redis实现限时抢购
@Resource
private StringRedisTemplate redisTemplate;
@Transactional
public Order Qg(Integer productId) {
checkTime(productId);
Stock stock = checkStock(productId);
updateStock(stock);
Order order = createOrder(stock);
return order;
}
/**
* 使用redis实现限时抢购
*/
public void checkTime(Integer productId){
Boolean flag = redisTemplate.hasKey("SECOND_KILL" + productId);
if (!flag){
throw new RuntimeException("秒杀活动已经结束,欢迎下次再来!");
}
}
2.秒杀接口的隐藏处理
我们需要将秒杀接口进行隐藏的具体方法:
每次点击秒杀按钮,实际上是两次请求,第一次先从服务器获取一个秒杀验证值(接口内判断是否到秒杀时间)
redis以缓存用户ID和商品ID为key,秒杀地址为value缓存验证值
用户请求秒杀商品的时候,要带上秒杀验证值进行校验
加入用户表
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@TableName("ms_user")
//开启链式调用
@Accessors(fluent = true)
public class User implements Serializable {
private Integer id;
private String name;
private String pwd;
}
生成MD5接口
/**
* 生成MD5接口
*/
@GetMapping("md5/{pid}/{uid}")
public String createMD5(@PathVariable("pid")Integer pid,@PathVariable("uid")Integer uid){
return orderService.createMD5(pid,uid);
}
/**
* 根据用户id和商品id生成随机盐
* 1.检验用户合法性
* 2.校验库存
* 3.生成hashkey
* 4.生成MD5
*
* @param pid
* @param uid
* @return
*/
@Transactional
public String createMD5(Integer pid, Integer uid) {
checkUser(uid);
checkStock(pid);
return createKey(pid, uid);
}
/**
* 校验用户合法性
*/
public void checkUser(Integer id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new RuntimeException("用户不存在!");
}
}
/**
* 生成key,并存入redis
*
* @param pid
* @param uid
* @return
*/
public String createKey(Integer pid, Integer uid) {
String key = "SECOND_KILL" + pid + uid;
String value = MD5Encoder.encode(key.getBytes());
redisTemplate.opsForValue().set(key, value, 60, TimeUnit.SECONDS);
return value;
}
修改下单接口
/**
* 0.检验MD5
* 1.验证库存
* 2.修改库存
* 3.创建订单
*
* @param productId
* @return
*/
@Transactional
public Order Qg(Integer productId, Integer uid, String md5) {
checkMD5(productId, uid, md5);
checkTime(productId);
Stock stock = checkStock(productId);
updateStock(stock);
Order order = createOrder(stock);
return order;
}
/**
* 生成订单前校验MD5
*
* @param uid
* @param md5
*/
private void checkMD5(Integer pid, Integer uid, String md5) {
if (!md5.equals(createMD5(pid, uid))) {
throw new RuntimeException("参数非法!");
}
}
3.单用户接口调用频率限制
为了防止出现用户撸羊毛,限制用户的购买数量。
用redis给每个用户做访问统计,甚至带上商品id,对单个商品进行访问统计。
/**
* 用户点击抢购,开始下单
*/
@GetMapping("qg/{productId}/{uid}/{md5}")
public String Qg(@PathVariable("productId") Integer productId,@PathVariable("uid")Integer uid,@PathVariable("md5")String md5) {
if (!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){
return "抢购失败,请重试!";
}
//查询用户是否抢购过该商品 ,并在用户下单成功后将该商品加入redis
if (!userService.checkIsBuy(uid,productId)){
return "已经购买过该商品,请勿重复下单!";
}
orderService.Qg(productId,uid,md5);
return "抢购成功,耗时为:"+rateLimiter.acquire();
}
/**
* 校验用户是否购买过该商品
* @param uid
* @param productId
* @return
*/
public boolean checkIsBuy(Integer uid, Integer productId) {
return redisTemplate.opsForHash().hasKey("SECOND_KILL_BUYED"+productId,uid);
}
/**
* 0.检验MD5
* 1.验证库存
* 2.修改库存
* 3.创建订单
* 4.用户下单成功后将该商品加入redis
*
* @param productId
* @return
*/
@Transactional
public Order Qg(Integer productId, Integer uid, String md5) {
checkMD5(productId, uid, md5);
checkTime(productId);
Stock stock = checkStock(productId);
updateStock(stock);
Order order = createOrder(stock);
updateRedis(uid, productId);
return order;
}
/**
* 用户下单成功后将该商品加入redis
*/
private void updateRedis(Integer uid, Integer productId) {
redisTemplate.opsForHash().increment("SECOND_KILL_BUYED"+productId,uid, 1);
}
至此,单机的小体量秒杀系统基本结束,为什么说小体量?因为现在我们的合法购买请求完全打入到了数据库,对数据库压力过大,我们可以考虑操作redis缓存,使用消息队列实现异步下单支付。同时,如果体量再次升级,我们可以考虑使用集群,分布式,随之而来就产生了新的问题,分布式锁的解决。
本文地址:https://blog.csdn.net/weixin_45596022/article/details/111027429
下一篇: “公司决定允许员工自愿降薪”引热议