分布式秒杀系统构建

主要解决问题

  1. 高并发
  2. 线程安全(超卖)
  3. 事务一致性(分布式事务)
  4. 防止提前下单
  5. 倒计时实现

项目架构

在这里插入图片描述

数据库设计

CREATE DATABASE shop

-- 商品表
CREATE TABLE goods(
	id INT PRIMARY KEY AUTO_INCREMENT,
	title VARCHAR(100) NOT NULL,
	info TEXT,
	price DECIMAL(10,2),
	save INT NOT NULL,
	begin_time TIMESTAMP NOT NULL,
	end_time TIMESTAMP NOT NULL
);

-- 订单表
CREATE TABLE orders(
	id INT PRIMARY KEY AUTO_INCREMENT,
	oid VARCHAR(30) NOT NULL UNIQUE,
	gid INT NOT NULL,
	uid INT NOT NULL,
	gnumber TINYINT NOT NULL DEFAULT 1,
	all_price DECIMAL(10,2) NOT NULL,
	create_time TIMESTAMP DEFAULT NOW(),
	STATUS TINYINT DEFAULT 0
)

项目启动

windows+VMWare启动

Nacos
cmd D:
cd Program Files\nacos\bin
startup.cmd -m standalone
http://192.168.52.1:8848/nacos/index.html
启动Sentinel
cd Program Files
java -jar sentinel-dashboard-1.8.1.jar
localhost:8080账号密码sentinel
Reids
启动CentOS8
cd usr/local/redis/bin
./redis-server redis.conf
./redis-cli
auth 000
RabbitMQ
启动CentOS7
/usr/local/software/rabbitmq_software/rabbitmq_server-3.7.16/sbin/rabbitmq-server -detached
http://192.168.32.129:15672/
启动前后端各服务

云服务启动

Nacos
cd /usr/local/nacos/bin
startup.cmd -m standalone
http://47.94.56.33:8848/nacos/index.html
启动Sentinel
cd usr/local/sentinel
java -Dserver.port=8849 -Dcsp.sentinel.dashboard.server=localhost:8849 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard-1.8.1.jar
http://47.94.56.33:8849/#/login账号密码sentinel
RabbitMQ
http://47.94.56.33:15672/

1.后端返回秒杀时间

进入主页面后向服务器发起请求,获取当前时间并计算出五个场次的集合

  1. DateUtil.getSecKillTime(i),参数为场次
  2. 获取当前时间,时位如果为奇数就-1,分秒位 置0
  3. 加上 场次*2 为当前时间并返回
  4. i从0到5循环获取五次,放到一个集合中返回

为什么不用js
如果用前端js,时间就参考本机的时间,为了保证所有时间一致,使用服务器返回时间;基本所有时间有关的都由服务器返回;

可以设定一个专门提供时间的服务器,这样各个服务器获取的当前时间都一致

2.解决跨域问题

浏览器和gateway网关之间会产生跨域问题

因为浏览器的请求会先到网关

spring:
  cloud:
    gateway:
      # gateway的全局跨域请求配置
      globalcors:
        cors-configurations:
          '[/**]':
            allow-credentials: true
            allowed-originPatterns: "*"
            allowed-headers: "*"
            allowed-methods:
              - OPTIONS
              - GET
              - POST

3.Redis序列化

序列化问题:增加配置类

@Configuration
public class RedisConfig extends CachingConfigurerSupport {
    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        config=config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.json()));
        return config;
    }
}

4.秒杀倒计时

  1. 客户端进入商品页面,随即从服务器获取时间
  2. 获取该场次时间,相减获得时间差
  3. 使用setTimeout()让服务器时间每隔一秒加一秒
  4. 时间差小于等于0,则改变按钮,开始秒杀

有一个专门处理时间的服务器,这个服务器可以被其他服务调用,这样保证了在集群情况下,各个服务器中的时间误差达到微妙级别

5.如何防止提前下单

方案一:
下单前,查询缓存,获得当前商品的秒杀开始时间,看当前时间是否在秒杀时间之内,就可以下单;(redis中key过多,需要判断)

方案二:
通过Redis记录当前秒杀时间段的商品集合,这样Redis中key不会太多

  1. 管理员添加秒杀商品,存入数据库,再在Rides的该时间段的集合中加入该商品
  2. Redis开定时任务,时间到了更变当前秒杀商品集合
  3. 有下单请求,先在Redis的当前时间集合中查看是否有该商品Id,有就说明该商品正在秒杀时间段,完成购买;没有就说明不在,返回错误。
    在这里插入图片描述

6.如何防止重复提交

方案一:
商品页面生成时,会在页面隐藏一个UUID,提交请求后把这个UUID存入Redis,如果这个UUID已存在,就是重复提交
(只能防止误触碰;如果有人恶意提交不能判断)

方案二:
提交必须输入验证码

  1. 防止重复提交
  2. 防止恶意提交(秒杀器、脚本)(普通验证码容易被破解;现在是行为验证码)
  3. 拉长服务器的请求处理时间

7.网关限流

计数器算法

在redis中计数,每次请求计数器减一,达到0,停止接收请求,设置过期时间,这样可以控制在单位时间内接收的请求数量。

如果单位时间请求分布不均匀,比如集中在第一秒的前半段和第二秒的后半段,会超出阈值

漏桶算法

  1. 水(请求)从上方倒入水桶,从水桶下方流出(被处理);
  2. 来不及流出的水存在水桶中(缓冲),以固定速率流出;
  3. 水桶满后水溢出(丢弃)。

这个算法的核心是:缓存请求、匀速处理、多余的请求丢弃。

漏桶算法对突发流量不做额外处理,无法应对存在突发特性的流量

*令牌桶算法

按照一定的速率往令牌桶中放令牌,请求来了就拿走令牌,发现没有令牌了就是限流了。还可以根据其他因素限流,比如说同一个请求普通用户要3个令牌,vip只要一个令牌,这样vip用户被限流的概率就比普通用户低。

根据URI对访问资源的请求限流,项目对获取验证码请求 和 秒杀下单请求分别限流,一个URI对应一个令牌桶。

桶结构
令牌桶可以看成一个Map集合,key 是 请求的URI,值又是一个Map,也就是桶结构,有四个键值对 对应 桶的四个参数。

  1. 当前剩余的令牌数
  2. 最大令牌数
  3. 每秒产生的令牌数
  4. 下一次可以产生令牌的时间

令牌桶要放到Redis中,服务集群下每个节点都是使用的这个桶,hash结构,key为需要限流的关键属性(URI),value为桶参数

实现
用户发出请求,路由网关先查询Redis中是否存在令牌桶,没有就创建这个桶,有就判断桶中当前令牌数是否大于这次请求需要的令牌数,如果剩余令牌足够就取领牌(hdecr uri tokens);这是两条命令,会发生安全性问题。Redis执行命令是单线程可以不用加锁,使用lua脚本或Redis事务保证隔离性

设计要点

  1. 令牌的生成方式:不用线程添加(桶多个线程多增大开销),而是每次请求主动计算要生成多少:每次请求都会写带当前时间,用当前时间减下一次可以生成令牌的时间,用这个时间差*每秒生成令牌桶的数量,再加上现有令牌数,就更新了现有令牌数;(不能大于最大令牌数);同时更新下一次生成令牌的时间
  2. 令牌的预支设计:为了让重量级的请求有机会执行;预支的是时间,而不是令牌数;比如每秒生成4个令牌,如今我有3个令牌,这个请求要5个令牌,就会让当前令牌置零,预支到0.5秒后的时间,而在这0.5秒之中如果有个请求在0.2需要预支令牌,告知它需要等待0.3秒(或者直接限流);
    在这里插入图片描述

获取令牌流程
比如说我一个令牌桶,当前1个令牌,最大10个令牌,每秒产生5个令牌,当前时间是0:00:00。0:00:01来了个请求,计算出前令牌数就是6(这里要保证不能大于最大令牌数),这个请求要申请3个令牌,够用,直接当前令牌数-3。
如果要8个令牌,不够用,就预支2个令牌,这时候令牌桶数量置为0,将下一次产生令牌的时间置为
0:00:01+2/5=0:00:01.40,也就是在这个时间点再+5个令牌,在到这个时间点前的请求看到的当前令牌数都是0,就没申请到,这个请求就被限流了。(getTokenNow(),没拿到就限流,不等待)

java中怎么做
给网关加上一个过滤器类实现GatewayFilter接口。用ConcurrentHashMap保存桶集合,键是请求的url,值是当前url对应的令牌桶,从map找是否有这个桶,没有就初始化,要用并发安全的map。

lua脚本

redis执行命令单线程特性,保证对redis多个操作的隔离性,实现无锁化线程安全

eval script numkeys key... arg...

eval:固定写法,代表要执行一个lua脚本
script:脚本内容,lua语言代码
numkeys:脚本中key变量的数量
key…:实际的key
arg…:其他参数

script:redis.call('','','')参数就是redis指令用逗号隔开

存定参数
在这里插入图片描述
存不定参数,KEY[1],代表key 的数组,ARGV[1]是值的数组,key变量长度为1就只把后面1个参数放到key数组,其余放到ARGV数组
在这里插入图片描述
返回
在这里插入图片描述
判断
在这里插入图片描述

8.请求下单数据一致性

  1. 使用sync加锁,锁在业务层,但是事务靠aop加强,如果锁释放事务没提交却有线程修改,还是会出现问题,耗时15s(锁加的范围一定要大于事务,否则事务没有提交锁就释放了
  2. 使用redis分布式锁,用商品id作标识上锁,在超高并发下,大量的加锁请求超时失败重试,IO消耗性能非常差,107s
  3. 使用mysql事务+读排他锁:开启数据库事务,虽然事务保证ACID但是事务之间不可间,读不加锁就会同时读,所以使用select ... for update给查询加排他锁,然后查出来库存进行判断并扣减库存和生成订单,保证读和修改 的原子性,耗时11s(数据库的压力非常大
  4. mysql乐观锁:尝试扣减库存,修改时这条数据会自动加锁,返回是否被修改,扣减成功了再生成订单,耗时6s (同样数据库压力过大在这里插入图片描述

Redis单线程+Lua

因为将各个商品的缓存预先存入redis,查询库存和减库存这两个操作都是操作redis,防止超卖就是保证这两个操作的隔离性,不会被打断;

利用redis单线程特性和lua脚本

redis的lua脚本:把库存放到redis,使用lua脚本在reids查询并下单,最后把redis数据同步到数据库,无锁,耗时4s

9.多线程异步处理减库存和生成订单

如果不使用消息队列实现异步。

在redis判断超卖后,将信息先存入redis,等到商品库存扣到0或秒杀结束,调用一个方法实现@Sync注解,然后这个方法中new一个线程去从redis拿然后完成减库存和生成订单,这样就会异步处理这个线程。

减库存就是拿到redis中库存数然后直接存入数据库

生成订单采用批量insert的方式,sql就是用insert into order values <foreach></foreach>的方式插入保存;因为sql语句长度有限,所以500保存一次
在这里插入图片描述
在这里插入图片描述
因为redis是AP架构的,是异步持久化或者主从复制的,会出现信息丢失,会造成不可逆的后果,所以不能放到redis里。

  1. 分布式事务最终一致性保证,可以保证发出的消息一定能收到(发布确认+消息持久化+手动应答)
  2. 请求削峰,放到mq里慢慢消费

10.sql出错导致feign超时调用

sql错误,导致消息一直不能被消费,消费者就一直消费(.ListenerExecutionFailedException),feign调用超时出错

11.sentinel防服务雪崩

流量控制

限制业务访问的qps,避免服务因流量突增而故障,预防服务挂掉

获取秒杀地址的请求、获取验证码的请求在gateway进行令牌桶限流;

  1. 用户查询商品和减库存操作都调用商品服务,所以使用关联模式限流,当减库存的请求达到一定数量,对查询限流,返回友好提示。
  2. 用户查询订单、生成订单 都会查询商品(getGoodsById),两个controller层的方法 链路访问同一资源,在getGoodsById()使用链路模式限流,对用户查询订单这个链路限流。

熔断降级

  1. 订单服务依赖商品服务,对feign的调用进行慢调用比例统计:超过300ms的认为是慢调用,在5秒内,慢调用的次数大于10或者慢调用比例大于0.5,对该商品服务熔断,熔断时间为5s,这时断路器记性half-open状态,5s后再放一次请求实验一下。调用失败走失败逻辑
  2. 订单服务还依赖用户服务,查询订单的时候要查用户,同样使用这种方式进行熔断降级

12.管理员修改商品问题

管理员在对商品进行操作时,先判断该商品是否已经在秒杀时间段,如果在则不能操作;

添加商品库存:数据库添加,成功后缓存中添加

修改和删除时都要删除相应的缓存

13.隐藏秒杀接口

  1. 点击秒杀按钮不直接进行秒杀请求,而是获取秒杀接口地址
    015fe6a3552336bb298ff8af9333523a.png
  2. 新秒杀接口地址其实就是在原来基础上拼接一个 md5加密的uuid的唯一加密字符串,然后将这个字符串返回给用户;并存入redis,键由uid+gid组成,并设置过期时长;
    657ff8bc63d29c64d440cfafc80af50f.png
  3. 调用秒杀方法,将返回的字符串拼接成新的请求路径,这样这个路径就是唯一的
    8f5f2be71648f8173872e604a5cb45bf.png
  4. 调用真正的秒杀接口,判断这个路径是否存在,若存在则进行秒杀服务
    dad2a508deae2c0bfdf1a7a29ace228b.png

14.秒杀验证码

为了防止使用脚本一次提交大量请求,隐藏接口地址不能完全避免;所以让用户在进入商品页面的时候通过 gid 和 uid 得到一个图片验证码,这个验证码是从gitee上下载的验证码组件,它将一个验证码和答案一一对应,我这里将图片返回到用户界面,答案存放在redis中并设置过期时长,用户在获取秒杀请求url时就需要携带验证码答案 通过 gid和uid 在redis中校验验证码是否正确;

<!--验证码-->
<dependency>
    <groupId>com.github.whvcse</groupId>
    <artifactId>easy-captcha</artifactId>
    <version>1.6.2</version>
</dependency>

生成验证码接口

@RequestMapping("/captcha/{gid}/{uid}")
public void verifyCode(@PathVariable("gid") Integer gid, @PathVariable("uid") Integer uid, HttpServletResponse response) {
    //设置请求头为图片类型
    response.setContentType("image/jpg");
    response.setHeader("Pargam", "No-cache");
    response.setHeader("Cache-Control", "no-cache");
    response.setDateHeader("Expires", 0);
    //生成验证码,将验证码放入redis
    ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 32, 3);
    redisTemplate.opsForValue().set("captcha:" + uid + ":" + gid, captcha.text(), 300, TimeUnit.SECONDS);
    try {
        captcha.out(response.getOutputStream());
    } catch (IOException e) {
        log.error("验证码生成失败");
        e.printStackTrace();
    }
}

前端请求

//刷新验证码
refreshCaptcha() {
	var _this = this;
	this.$http({
		method: 'GET',
		url: "/kill/captcha/" + _this.gid + "/" + _this.uid,
		responseType: 'blob',
	}).then(res => {
		const {
			data,
			headers
		} = res
		const blob = new Blob([data], {
			type: headers['content-type']
		})
		_this.imgsrc = window.URL.createObjectURL(blob)
	})
}

校验验证码

public boolean checkCaptcha(Integer uid, Integer gid, String captcha) {
    String redisCaptcha = redisTemplate.opsForValue().get("captcha" + uid + "-" + gid);
    if (redisCaptcha == null) {
        return false;
    } else {
        return redisCaptcha.equals(captcha);
    }
}

15.Redis和MySQL双写一致性

在插入商品和修改商品库存时,需要同时在Redis中写入库存,这时就要保证这两个操作的原子性;但是一个存redis一个存mysql不能通过事务达到一致性,

16.减库存和生成订单最终一致

RabbitMQ 实现分布式事务

消息不丢失

发布确认
//发布确认  confirm-callback  (单个确认)
rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
    @Override
    public void confirm(CorrelationData correlationData, boolean b, String s) {
    //未接收到,重新发送
    if (!b) {
        RepublishMessageRecoverer recoverer = new RepublishMessageRecoverer(rabbitTemplate, "secKill_exchange", "");
    }
  }
});
手动应答

秒杀服务在redis中扣减库存成功后,向MQ发出消息,而消费者也就是商品服务和订单服务使用手动应答的方式,商品服务发sql减库存,订单服务就是生成订单,在自己执行完业务且没有错误后,才会通知MQ删除这条消息,并且各个服务之间不影响,各服务只会消费自己队列中的消息;

持久化
  1. 交换机持久化:在上游服务配置声明交换机时的参数
    在这里插入图片描述
  2. 队列持久化:在下游服务接收消息,在声明队列时durable参数设置
    在这里插入图片描述
  3. 消息持久化? 这个不用配置;因为底层MessageProperties的持久化策略默认是MessageDeliveryMode.PERSISTENT,初始化时默认消息时持久化的
防止消息被重复消费

生产者端幂等

mq收到了消息,但是因为网络没有给生产者应答,导致生产者重复发送消息

对每条消息,MQ系统内部必须生成一个全局唯一的inner-msg-id,作为去重和幂等的依据

消费者端幂等

当前消息被处理完成,没有给mq应答,网络原因或宕机了;
消息就会被重新发送,导致消息被重复消费。

发送消息的时候就用uuid生成订单id,以此来做唯一标识,消费消息前先setIfAbsent()也就是set if not exist,成功了就消费,失败了证明是重复消息,不消费

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

EnndmeRedis

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值