目录
主要解决问题
- 高并发
- 线程安全(超卖)
- 事务一致性(分布式事务)
- 防止提前下单
- 倒计时实现
项目架构
数据库设计
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.后端返回秒杀时间
进入主页面后向服务器发起请求,获取当前时间并计算出五个场次的集合
DateUtil.getSecKillTime(i)
,参数为场次- 获取当前时间,时位如果为奇数就-1,分秒位 置0
- 加上 场次*2 为当前时间并返回
- 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.秒杀倒计时
- 客户端进入商品页面,随即从服务器获取时间
- 获取该场次时间,相减获得时间差
- 使用
setTimeout()
让服务器时间每隔一秒加一秒 - 时间差小于等于0,则改变按钮,开始秒杀
有一个专门处理时间的服务器,这个服务器可以被其他服务调用,这样保证了在集群情况下,各个服务器中的时间误差达到微妙级别
5.如何防止提前下单
方案一:
下单前,查询缓存,获得当前商品的秒杀开始时间,看当前时间是否在秒杀时间之内,就可以下单;(redis中key过多,需要判断)
方案二:
通过Redis记录当前秒杀时间段的商品集合,这样Redis中key不会太多
- 管理员添加秒杀商品,存入数据库,再在Rides的该时间段的集合中加入该商品
- Redis开定时任务,时间到了更变当前秒杀商品集合
- 有下单请求,先在Redis的当前时间集合中查看是否有该商品Id,有就说明该商品正在秒杀时间段,完成购买;没有就说明不在,返回错误。
6.如何防止重复提交
方案一:
商品页面生成时,会在页面隐藏一个UUID,提交请求后把这个UUID存入Redis,如果这个UUID已存在,就是重复提交
(只能防止误触碰;如果有人恶意提交不能判断)
方案二:
提交必须输入验证码
- 防止重复提交
- 防止恶意提交(秒杀器、脚本)(普通验证码容易被破解;现在是行为验证码)
- 拉长服务器的请求处理时间
7.网关限流
计数器算法
在redis中计数,每次请求计数器减一,达到0,停止接收请求,设置过期时间,这样可以控制在单位时间内接收的请求数量。
如果单位时间请求分布不均匀,比如集中在第一秒的前半段和第二秒的后半段,会超出阈值
漏桶算法
- 水(请求)从上方倒入水桶,从水桶下方流出(被处理);
- 来不及流出的水存在水桶中(缓冲),以固定速率流出;
- 水桶满后水溢出(丢弃)。
这个算法的核心是:缓存请求、匀速处理、多余的请求丢弃。
漏桶算法对突发流量不做额外处理,无法应对存在突发特性的流量
*令牌桶算法
按照一定的速率往令牌桶中放令牌,请求来了就拿走令牌,发现没有令牌了就是限流了。还可以根据其他因素限流,比如说同一个请求普通用户要3个令牌,vip只要一个令牌,这样vip用户被限流的概率就比普通用户低。
根据URI对访问资源的请求限流,项目对获取验证码请求 和 秒杀下单请求分别限流,一个URI对应一个令牌桶。
桶结构
令牌桶可以看成一个Map集合,key 是 请求的URI,值又是一个Map,也就是桶结构,有四个键值对 对应 桶的四个参数。
- 当前剩余的令牌数
- 最大令牌数
- 每秒产生的令牌数
- 下一次可以产生令牌的时间
令牌桶要放到Redis中,服务集群下每个节点都是使用的这个桶,hash结构,key为需要限流的关键属性(URI),value为桶参数
实现
用户发出请求,路由网关先查询Redis中是否存在令牌桶,没有就创建这个桶,有就判断桶中当前令牌数是否大于这次请求需要的令牌数,如果剩余令牌足够就取领牌(hdecr uri tokens);这是两条命令,会发生安全性问题。Redis执行命令是单线程可以不用加锁,使用lua脚本或Redis事务保证隔离性
设计要点
- 令牌的生成方式:不用线程添加(桶多个线程多增大开销),而是每次请求主动计算要生成多少:每次请求都会写带当前时间,用当前时间减下一次可以生成令牌的时间,用这个时间差*每秒生成令牌桶的数量,再加上现有令牌数,就更新了现有令牌数;(不能大于最大令牌数);同时更新下一次生成令牌的时间
- 令牌的预支设计:为了让重量级的请求有机会执行;预支的是时间,而不是令牌数;比如每秒生成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.请求下单数据一致性
- 使用sync加锁,锁在业务层,但是事务靠aop加强,如果锁释放事务没提交却有线程修改,还是会出现问题,耗时15s(锁加的范围一定要大于事务,否则事务没有提交锁就释放了)
- 使用redis分布式锁,用商品id作标识上锁,在超高并发下,大量的加锁请求超时失败重试,IO消耗性能非常差,107s
- 使用mysql事务+读排他锁:开启数据库事务,虽然事务保证ACID但是事务之间不可间,读不加锁就会同时读,所以使用
select ... for update
给查询加排他锁,然后查出来库存进行判断并扣减库存和生成订单,保证读和修改 的原子性,耗时11s(数据库的压力非常大) - 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里。
- 分布式事务最终一致性保证,可以保证发出的消息一定能收到(发布确认+消息持久化+手动应答)
- 请求削峰,放到mq里慢慢消费
10.sql出错导致feign超时调用
sql错误,导致消息一直不能被消费,消费者就一直消费(.ListenerExecutionFailedException),feign调用超时出错
11.sentinel防服务雪崩
流量控制
限制业务访问的qps,避免服务因流量突增而故障,预防服务挂掉
获取秒杀地址的请求、获取验证码的请求在gateway进行令牌桶限流;
- 用户查询商品和减库存操作都调用商品服务,所以使用关联模式限流,当减库存的请求达到一定数量,对查询限流,返回友好提示。
- 用户查询订单、生成订单 都会查询商品(getGoodsById),两个controller层的方法 链路访问同一资源,在getGoodsById()使用链路模式限流,对用户查询订单这个链路限流。
熔断降级
- 订单服务依赖商品服务,对feign的调用进行慢调用比例统计:超过300ms的认为是慢调用,在5秒内,慢调用的次数大于10或者慢调用比例大于0.5,对该商品服务熔断,熔断时间为5s,这时断路器记性half-open状态,5s后再放一次请求实验一下。调用失败走失败逻辑
- 订单服务还依赖用户服务,查询订单的时候要查用户,同样使用这种方式进行熔断降级
12.管理员修改商品问题
管理员在对商品进行操作时,先判断该商品是否已经在秒杀时间段,如果在则不能操作;
添加商品库存:数据库添加,成功后缓存中添加
修改和删除时都要删除相应的缓存
13.隐藏秒杀接口
- 点击秒杀按钮不直接进行秒杀请求,而是获取秒杀接口地址
- 新秒杀接口地址其实就是在原来基础上拼接一个 md5加密的uuid的唯一加密字符串,然后将这个字符串返回给用户;并存入redis,键由uid+gid组成,并设置过期时长;
- 调用秒杀方法,将返回的字符串拼接成新的请求路径,这样这个路径就是唯一的
- 调用真正的秒杀接口,判断这个路径是否存在,若存在则进行秒杀服务
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删除这条消息,并且各个服务之间不影响,各服务只会消费自己队列中的消息;
持久化
- 交换机持久化:在上游服务配置声明交换机时的参数
- 队列持久化:在下游服务接收消息,在声明队列时
durable
参数设置
- 消息持久化? 这个不用配置;因为底层
MessageProperties
的持久化策略默认是MessageDeliveryMode.PERSISTENT
,初始化时默认消息时持久化的
防止消息被重复消费
生产者端幂等
mq收到了消息,但是因为网络没有给生产者应答,导致生产者重复发送消息
对每条消息,MQ系统内部必须生成一个全局唯一的inner-msg-id
,作为去重和幂等的依据
消费者端幂等
当前消息被处理完成,没有给mq应答,网络原因或宕机了;
消息就会被重新发送,导致消息被重复消费。
发送消息的时候就用uuid生成订单id,以此来做唯一标识,消费消息前先setIfAbsent()
也就是set if not exist
,成功了就消费,失败了证明是重复消息,不消费