模拟了一个高并发场景的商城系统,它具备秒杀功能,并在经过几个版本的迭代之后成为支持高并发的高性能系统。为了解决秒杀场景下的高并发问题,引入了redis作为缓存中间件,主要作用是缓存预热、预减库存等等。针对高并发场景进行了页面优化,缓存页面至浏览器,加快用户访问速度。在安全性问题上,我使用双重MD5密码校验,隐藏了秒杀接口地址,设置了接口限流防刷。最后还使用数学公式验证码不仅可以防恶意刷访问,还起到了削峰的作用。通过Jmeter压力测试,系统的QPS从150/s提升到2000/s。
秒杀主要解决两个问题,一个是并发读,一个是并发写。并发读的核心优化理念是尽量减少用户到服务端来“读”数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。
秒杀的整体架构可以概括为“稳、准、快”几个关键字,对应了我们架构上的高可用、一致性和高性能的要求。
用户表(用户ID、密码等)、商品信息表(商品ID和其他商品详情信息)、商品秒杀表(商品ID、秒杀始末时间、秒杀价和库存数量)、秒杀订单表(订单ID、用户ID和商品ID)、订单详情表(订单ID和其他订单详情信息)
MD5(MD5(明文 + salt1) + salt2)
第一次:客户端输入的密码传入后端之前
原因:客户端输入的是明文密码,直接在网络中传输容易被截获,因此要防止密码在网络中明文传输。
第二次:后端接收到第一次加密后的密码之后,传入到数据库之前
原因:万一数据库被盗,盗用者虽然可以获得第二次的密文和盐,但由于MD5“解密”过程很困难并且无法确定加盐的方式,因此基本无法反推出第一次加密后的密文。
黑客在网络中截获数据包后获得了第一次加密后的密文怎么办?
如果使用的是https进行传输,黑客即使截获了数据包也无法获得里面的内容。如果不是https,黑客只能用第一加密的密文伪造数据包向服务端发送请求,而无法在前端用用户的明文密码登录,增加了作案成本。
每个类都写大量的健壮性判断过于麻烦,我们可以使用 validation 简化我们的代码。比如可以自定义一个@IsMobile
注解来判断登录功能中手机号码的合法性
如何将异常展现在前端?使用SpringBoot全局异常处理
系统中异常包括:编译时异常和运行时异常 RuntimeException
,前者通过捕获异常从而获
取异常信息,后者主要通过规范代码开发、测试通过手段减少运行时异常的发生。在开发中,不管是
dao层、service层还是controller层,都有可能抛出异常,在Springmvc中,能将所有类型的异常处理从各处理过程解耦出来,既保证了相关处理过程的功能较单一,也实现了异常信息的统一处理和维护。
SpringBoot全局异常处理方式主要两种:
@ControllerAdvice
和 @ExceptionHandler
注解。ErrorController
类 来实现区别:
@ControllerAdvice
方式只能处理控制器抛出的异常。此时请求已经进入控制器中。ErrorController
类 方式可以处理所有的异常,包括未进入控制器的错误,比如404,401等错误@ControllerAdvice
方式处理控制器抛出的异常,ErrorController
类 方式处理未进入控制器的异常。@ControllerAdvice
方式可以定义多个拦截方法,拦截不同的异常类,并且可以获取抛出的异常因此,我们使用@ControllerAdvice
和 @ExceptionHandler
注解的组合。
使用cookie+session记录用户信息,这样可以保持用户的登录状态。
之前的代码在我们之后一台应用系统,所有操作都在一台Tomcat上,没有什么问题。当我们部署多台系统,配合Nginx的时候会出现用户登录的问题
原因:由于 Nginx 使用默认负载均衡策略(轮询),请求将会按照时间顺序逐一分发到后端应用上。也就是说刚开始我们在 Tomcat1 登录之后,用户信息放在 Tomcat1 的 Session 里。过了一会,请求又被 Nginx 分发到了 Tomcat2 上,这时 Tomcat2 上 Session 里还没有用户信息,于是又要登录。
基本解决方案:
有两个方法:使用SpringSession工具包和登录时直接将用户信息存入Redis。二者的共同原理都是将用户信息存在第三方的一个Redis中
QPS(Queries Per Second):一台服务器每秒能够响应的查询次数
TPS(Transactions Per Second):一台服务器每秒能够处理的事务数量。一个事务是指一个客户机向服务器发送请求然后服务器做出反应的过程。
如果每个功能接口都只有一次查询,那么QPS=TPS。
缓存页面数据,避免每次页面请求都查询数据库,提升系统的QPS。
分布式Session中将User类(用户信息)存入Redis就属于对象缓存。
缓存和数据库数据一致性如何保证?
每次更新数据库的数据时,一定要处理Redis里的相应数据。我们选择直接删除Redis,然后下一次查缓存找不到数据后会先从数据库查找,然后同步到缓存。
使用缓存后QPS提升了多少?
将近一倍
减库存成功了,但是生成订单失败了,该怎么办?
非分布式的系统中使用Spring提供的事务功能即可。
分布式事务的两个协议以及几种解决方案:全局消息、基于可靠消息(MQ)的分布式事务、TCC、
最大努力通知
解决同一用户同时秒杀多件商品的步骤
解决超卖问题(避免零库存时继续减库存)
SQL的update
会自动加行级别排他锁。在减库存时判断商品库存是否为负,为负不再继续,解决超卖。(注意是在update
语句加where stock>0
而不仅仅是查库存的select
语句)
但单纯加这种锁会影响并发量,因此需要进行接口优化。
简介:
RabbitMQ是一个消息代理:它接受并转发消息。你可以把它想象成一个邮局,RabbitMQ和邮局的主要区别在于,它不处理纸张,而是接受、存储和转发二进制数据块消息。
RabbitMQ和一般的消息传递使用了一些术语:
生产者:生产只意味着发送。发送消息的程序就是生产者
队列:尽管消息流经RabbitMQ和您的应用程序,但它们只能存储在队列中。队列只受主机的内存和磁盘限制,它本质上是一个大的消息缓冲区。许多生产者可以向一个队列发送消息,许多消费者可以尝试从一个队列接收数据。
消费者:消费和接受有着相似的含义。消费者是一个主要等待接收消息的程序
SpringBoot集成RabbitMQ的配置之application.yml:
spring:
#RabbitMQ
rabbitmq:
#服务器地址
host: 192.168.10.100
#用户名
username: guest
#密码
password: guest
#虚拟主机
virtual-host: /
#端口
port: 5672
listener:
simple:
#消费者最小数量
concurrency: 10
#消费者最大数量
max-concurrency: 10
#限制消费者每次只处理一条消息,处理完再继续下一条消息
prefetch: 1
#启动时是否默认启动容器,默认true
auto-startup: true
#被拒绝时重新进入队列
default-requeue-rejected: true
template:
retry:
#发布重试,默认false
enabled: true
#重试时间 默认1000ms
initial-interval: 1000
#重试最大次数,默认3次
max-attempts: 3
#重试最大间隔时间,默认10000ms
max-interval: 10000
#重试间隔的乘数。比如配2.0 第一次等10s,第二次等20s,第三次等40s
multiplier: 1.0
交换机(Exchanges):
RabbitMQ中消息传递模型的核心思想是生产者从不直接向队列发送任何消息。实际上,通常情况下,生产者甚至根本不知道消息是否会被传递到任何队列。
相反,生产者只能向交换机发送消息。交换是一件非常简单的事情。它一方面接收来自生产者的消息,另一方面将它们推送到队列中。交换机必须确切地知道如何处理收到的消息。是否应将其附加到特定队列?是否应该将它附加到许多队列中?或者应该丢弃它。其规则由交换类型定义。
这里有几种可用的交换类型:direct、topic、headers和fanout。我们将集中讨论最后一个——fanout(广播)。
Fanout模式:
Direct模式:
Topic模式:
对于routing key匹配模式定义规则举例如下:
构建步骤同Direct Exchange。
核心目的: 减少数据库访问
首先,系统初始化时,需要把商品库存数量从数据库加载到Redis
RabbitMQ的消费者:
如何增减Redis中的库存数?
decrement方法减库存,increment方法回增库存。以上的指令都是单线程原子性的。
为什么要用RabbitMQ优化?
变成了异步操作,请求的返回更快,缓解了数据库的并发压力,起到了流量削峰的效果
使用RabbitMQ后QPS提升了多少?
也是翻了将近一倍
上面代码实际演示会发现Redis的库存有问题,原因在于Redis没有做到多线程的原子性,多机器扣减库存时线程不安全,虽然不会超卖,但Redis的库存数可能变成负。我们采用锁去解决。
分布式锁:
特点:分布式锁对分布式系统多进程访问资源进行控制,用来解决分布式互斥问题。属于进程所,因此无法通过synchronized等线程锁实现进程锁
为了防止业务执行过程中抛异常或者挂机导致delete方法没法调用形成死锁,可以给锁添加超时时间
但是,仅仅加个过期时间会设计到两个问题
举例:第一个线程先获得锁然后执行业务代码,但是业务代码耗时8秒,这样会在第一个线程的任务还未执行成功锁就会被释放,这时第二个线程会获取到锁开始执行,在第二个线程开执行了3秒,第一个线程也执行完了,此时第一个线程会释放锁,但是注意,他释放的第二个线程的锁,释放之后,第三个线程进来。
分析原因:锁的释放不是原子性的
解决方案:
Lua脚本优势:
使用Lua脚本的两种思路:
自动续期:锁过期问题的出现,是我们对持有锁的时间不好进行预估,设置较短的话会有【提前过期】风险,但是过期时间设置过长,可能锁长时间得不到释放。
锁过期问题的出现,是我们对持有锁的时间不好进行预估,设置较短的话会有提前过期的风险,但是过期时间设置过长,可能锁长时间得不到释放。
如果Client1获取到锁后,因为业务问题需要较长的处理时间,超过了锁过期时间,该怎么办?
业务执行时间快要超过了锁过期时间时,给锁续期。
比如开启一个守护进程,定时监测锁的失效时间,在快要过期的时候,对锁进行自动续期,重新设置过期时间。Redisson框架中就实现了WatchDog(看门狗)机制:加锁时没有指定加锁时间时会启用 watchdog 机制,默认加锁 30 秒,每 10 秒钟检查一次,如果存在就重新设置过期时间为 30 秒(即 30 秒之后它就不再续期了)。
如果项目中的Redis挂掉,如何减轻数据库的压力?
用Redis集群模式:主从模式、哨兵模式、集群模式。
秒杀开始时,如果有用户提前知道了、或者能快速获取秒杀接口的URL地址,那么他就可以利用这个地址通过脚本不断地刷新秒杀。
用户仍然能通过脚本快速获取随机的UUID和字符串拼接规则,实现快速不断刷新秒杀。因此每次点击秒杀开始前,先让用户输入验证码。此外,验证码还能起到分散用户的请求的作用,防止大量的用户请求集中在刚开始的几秒中,对服务器造成压力。
目的同样是为了减轻服务器的压力。
常见算法:
一定时间周期内的请求数量有上限。
缺点:周期时间内的用户到达数量是随机的,因此可能会出现周期内实际到达数量少而导致资源的浪费、以及相邻周期的临界时间段内的到达数量过多导致请求数超过服务器处理容量。
将请求放入一个队列,控制请求放行速度
以恒定的速度发放令牌(token)到令牌桶,请求从桶里取token。令牌桶满时直接丢弃token。
优点:减轻突发流量带来的压力
组合后的安全优化方案:
网关过滤:
设置每个服务器发放的总token数量
加了缓存之后的缓存三大问题及解决方法: