Java高并发秒杀系统

概述&如何设计一个秒杀系统

模拟了一个高并发场景的商城系统,它具备秒杀功能,并在经过几个版本的迭代之后成为支持高并发的高性能系统。为了解决秒杀场景下的高并发问题,引入了redis作为缓存中间件,主要作用是缓存预热、预减库存等等。针对高并发场景进行了页面优化,缓存页面至浏览器,加快用户访问速度。在安全性问题上,我使用双重MD5密码校验,隐藏了秒杀接口地址,设置了接口限流防刷。最后还使用数学公式验证码不仅可以防恶意刷访问,还起到了削峰的作用。通过Jmeter压力测试,系统的QPS从150/s提升到2000/s。

秒杀主要解决两个问题,一个是并发读,一个是并发写。并发读的核心优化理念是尽量减少用户到服务端来“读”数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。
秒杀的整体架构可以概括为“稳、准、快”几个关键字,对应了我们架构上的高可用、一致性和高性能的要求。

  • 高性能。 秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键。
  • 一致性。 在大并发更新的过程中要保证数据的准确性
  • 高可用。 现实中总难免出现一些我们考虑不到的情况,所以要保证系统的高可用和正确性,我们还要设计一个 PlanB 来兜底,以便在最坏情况发生时仍然能够从容应对。

设计MySQL表

用户表(用户ID、密码等)、商品信息表(商品ID和其他商品详情信息)、商品秒杀表(商品ID、秒杀始末时间、秒杀价和库存数量)、秒杀订单表(订单ID、用户ID和商品ID)、订单详情表(订单ID和其他订单详情信息)

两次MD5加密的时机及原因

MD5(MD5(明文 + salt1) + salt2)
第一次:客户端输入的密码传入后端之前
原因:客户端输入的是明文密码,直接在网络中传输容易被截获,因此要防止密码在网络中明文传输。
第二次:后端接收到第一次加密后的密码之后,传入到数据库之前
原因:万一数据库被盗,盗用者虽然可以获得第二次的密文和盐,但由于MD5“解密”过程很困难并且无法确定加盐的方式,因此基本无法反推出第一次加密后的密文。

黑客在网络中截获数据包后获得了第一次加密后的密文怎么办?
如果使用的是https进行传输,黑客即使截获了数据包也无法获得里面的内容。如果不是https,黑客只能用第一加密的密文伪造数据包向服务端发送请求,而无法在前端用用户的明文密码登录,增加了作案成本。

登录功能

Java高并发秒杀系统_第1张图片

用自定义注解进行参数校验

每个类都写大量的健壮性判断过于麻烦,我们可以使用 validation 简化我们的代码。比如可以自定义一个@IsMobile注解来判断登录功能中手机号码的合法性

异常处理

如何将异常展现在前端?使用SpringBoot全局异常处理

系统中异常包括:编译时异常和运行时异常 RuntimeException ,前者通过捕获异常从而获
取异常信息,后者主要通过规范代码开发、测试通过手段减少运行时异常的发生。在开发中,不管是
dao层、service层还是controller层,都有可能抛出异常,在Springmvc中,能将所有类型的异常处理从各处理过程解耦出来,既保证了相关处理过程的功能较单一,也实现了异常信息的统一处理和维护。
SpringBoot全局异常处理方式主要两种:

  1. 使用 @ControllerAdvice@ExceptionHandler 注解。
  2. 使用 ErrorController类 来实现

区别:

  1. @ControllerAdvice 方式只能处理控制器抛出的异常。此时请求已经进入控制器中。
  2. ErrorController类 方式可以处理所有的异常,包括未进入控制器的错误,比如404,401等错误
  3. 如果应用中两者共同存在,则 @ControllerAdvice 方式处理控制器抛出的异常,
    ErrorController类 方式处理未进入控制器的异常。
  4. @ControllerAdvice 方式可以定义多个拦截方法,拦截不同的异常类,并且可以获取抛出的异常
    信息,自由度更大

因此,我们使用@ControllerAdvice@ExceptionHandler 注解的组合。

用分布式Session完善登录功能

使用cookie+session记录用户信息,这样可以保持用户的登录状态。

为什么要用分布式Session?

之前的代码在我们之后一台应用系统,所有操作都在一台Tomcat上,没有什么问题。当我们部署多台系统,配合Nginx的时候会出现用户登录的问题
原因:由于 Nginx 使用默认负载均衡策略(轮询),请求将会按照时间顺序逐一分发到后端应用上。也就是说刚开始我们在 Tomcat1 登录之后,用户信息放在 Tomcat1 的 Session 里。过了一会,请求又被 Nginx 分发到了 Tomcat2 上,这时 Tomcat2 上 Session 里还没有用户信息,于是又要登录。

Java高并发秒杀系统_第2张图片

基本解决方案:

  1. Session复制
  • 优点
    无需修改代码,只需要修改Tomcat配置
  • 缺点
    Session同步传输占用内网带宽;多台Tomcat同步性能指数级下降;Session占用内存,无法有效水平扩展
  1. 前端存储(Cookie)
  • 优点
    不占用服务端内存
  • 缺点
    存在安全风险;数据大小受cookie限制;占用外网带宽
  1. Session粘滞
  • 优点
    无需修改代码;服务端可以水平扩展
  • 缺点
    增加新机器,会重新Hash,导致重新登录;应用重启,需要重新登录
  1. 后端集中存储
  • 优点
    安全;容易水平扩展
  • 缺点
    增加复杂度;需要修改代码

用Redis实现分布式Session

有两个方法:使用SpringSession工具包和登录时直接将用户信息存入Redis。二者的共同原理都是将用户信息存在第三方的一个Redis中

系统压测

QPS(Queries Per Second):一台服务器每秒能够响应的查询次数
TPS(Transactions Per Second):一台服务器每秒能够处理的事务数量。一个事务是指一个客户机向服务器发送请求然后服务器做出反应的过程。
如果每个功能接口都只有一次查询,那么QPS=TPS。

缓存

页面缓存

缓存页面数据,避免每次页面请求都查询数据库,提升系统的QPS。

Java高并发秒杀系统_第3张图片

对象缓存

分布式Session中将User类(用户信息)存入Redis就属于对象缓存。

缓存和数据库数据一致性如何保证?
每次更新数据库的数据时,一定要处理Redis里的相应数据。我们选择直接删除Redis,然后下一次查缓存找不到数据后会先从数据库查找,然后同步到缓存。

使用缓存后QPS提升了多少?
将近一倍

秒杀功能

Java高并发秒杀系统_第4张图片

减库存成功了,但是生成订单失败了,该怎么办?
非分布式的系统中使用Spring提供的事务功能即可。
分布式事务的两个协议以及几种解决方案:全局消息、基于可靠消息(MQ)的分布式事务、TCC、
最大努力通知

解决库存超卖

解决同一用户同时秒杀多件商品的步骤

  1. 对数据库建立唯一索引

在这里插入图片描述

  1. 将秒杀订单信息存入Redis,方便判断是否重复抢购时进行查询。如果Redis中已经存在相同的订单信息,就报重复下单的错。

解决超卖问题(避免零库存时继续减库存)

SQL的update会自动加行级别排他锁。在减库存时判断商品库存是否为负,为负不再继续,解决超卖。(注意是在update语句加where stock>0而不仅仅是查库存的select语句)
但单纯加这种锁会影响并发量,因此需要进行接口优化。

RabbitMQ

简介:
RabbitMQ是一个消息代理:它接受并转发消息。你可以把它想象成一个邮局,RabbitMQ和邮局的主要区别在于,它不处理纸张,而是接受、存储和转发二进制数据块消息。
RabbitMQ和一般的消息传递使用了一些术语:

  • 生产者:生产只意味着发送。发送消息的程序就是生产者

  • 队列:尽管消息流经RabbitMQ和您的应用程序,但它们只能存储在队列中。队列只受主机的内存和磁盘限制,它本质上是一个大的消息缓冲区。许多生产者可以向一个队列发送消息,许多消费者可以尝试从一个队列接收数据。

  • 消费者:消费和接受有着相似的含义。消费者是一个主要等待接收消息的程序

Java高并发秒杀系统_第5张图片

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模式:

  • 不处理路由键,只需要简单的将队列绑定到交换机上
  • 发送到交换机的消息都会被转发到与该交换机绑定的所有队列上
  • Fanout交换机转发消息是最快的
  1. 创建多个队列Queue和一个交换机FanoutExchange
  2. 把这些队列绑定(Binding)到交换机上
  3. 生产者发送消息到交换机
  4. 每个消费者从一个队列接收消息

Direct模式:

  • 所有发送到Direct Exchange的消息被转发到RouteKey中指定的Queue
  • 注意:Direct模式可以使用RabbitMQ自带的Exchange:default Exchange,所以不需要将Exchange进行任何绑定(Binding)操作,消息传递时,RouteKey必须完全匹配才会被队列接收,否则该消息会被抛弃。
  • 重点:routing key与队列queues 的key保持一致,即可以路由到对应的queue中。
    Java高并发秒杀系统_第6张图片
  1. 创建多个队列Queue和一个交换机DirectExchange
  2. 把这些队列绑定(Binding)到交换机上,并为每个队列设置路由键
  3. 生产者发送带有一个路由键的消息到交换机,用于和队列的路由键匹配
  4. 每个消费者从一个队列接收消息(消费者不知道路由键)

Topic模式:

  • 所有发送到Topic Exchange的消息被转发到所有管线RouteKey中指定Topic的Queue上
  • Exchange将RouteKey和某Topic进行模糊匹配,此时队列需要绑定一个Topic

对于routing key匹配模式定义规则举例如下:

  • routing key为一个句点号 . 分隔的字符串(我们将被句点号 . 分隔开的每一段独立的字符串称为一
    个单词),如“stock.usd.nyse”、“nyse.vmw”、“quick.orange.rabbit”
  • routing key中可以存在两种特殊字符 * 与 # ,用于做模糊匹配,其中 * 用于匹配一个单词, # 用于匹配多个单词(可以是零个)
  • 发送到主题交换机的消息不能有任意的routing_key,它必须是用句点分隔的单词列表。

Java高并发秒杀系统_第7张图片

构建步骤同Direct Exchange。

接口优化

核心目的: 减少数据库访问

优化后的秒杀功能

首先,系统初始化时,需要把商品库存数量从数据库加载到Redis

Java高并发秒杀系统_第8张图片

RabbitMQ的消费者:

Java高并发秒杀系统_第9张图片

如何增减Redis中的库存数?
decrement方法减库存,increment方法回增库存。以上的指令都是单线程原子性的。

为什么要用RabbitMQ优化?
变成了异步操作,请求的返回更快,缓解了数据库的并发压力,起到了流量削峰的效果

使用RabbitMQ后QPS提升了多少?
也是翻了将近一倍

客户端轮询秒杀结果

Java高并发秒杀系统_第10张图片

通过在Redis中实现分布式锁,优化库存预减操作

上面代码实际演示会发现Redis的库存有问题,原因在于Redis没有做到多线程的原子性,多机器扣减库存时线程不安全,虽然不会超卖,但Redis的库存数可能变成负。我们采用锁去解决。

分布式锁:

  • 进来一个线程先占位,当别的线程进来操作时,发现已经有人占位了,就会放弃或者稍后再试(互斥)
  • 线程操作执行完成后,需要调用delete方法释放位子

特点:分布式锁对分布式系统多进程访问资源进行控制,用来解决分布式互斥问题。属于进程所,因此无法通过synchronized等线程锁实现进程锁

为了防止业务执行过程中抛异常或者挂机导致delete方法没法调用形成死锁,可以给锁添加超时时间

但是,仅仅加个过期时间会设计到两个问题

  1. 释放别人的锁

举例:第一个线程先获得锁然后执行业务代码,但是业务代码耗时8秒,这样会在第一个线程的任务还未执行成功锁就会被释放,这时第二个线程会获取到锁开始执行,在第二个线程开执行了3秒,第一个线程也执行完了,此时第一个线程会释放锁,但是注意,他释放的第二个线程的锁,释放之后,第三个线程进来。

分析原因:锁的释放不是原子性的

解决方案:

  • 尽量避免在获取锁之后,执行耗时操作将锁的value设置为一个随机字符串,每次释放锁的时候,都去比较随机字符串是否一致,如果一致,再去释放,否则不释放。
  • 释放锁时要去查看所得value、比较value是否正确、释放锁,总共三个步骤,但是这三个步骤不具备原子性。 因此我们用Lua脚本去解决这个问题

Lua脚本优势:

  • 使用方便,Redis内置了对Lua脚本的支持
  • Lua脚本可以在Rdis服务端原子地执行多个Redis命令
  • 由于网络在很大程度上会影响到Redis性能,使用Lua脚本可以让多个命令一次执行,可以有效解决网络给Redis带来的性能问题

使用Lua脚本的两种思路:

  • 提前在Redis服务端写好Lua脚本,然后在java客户端去调用脚本。
  • 可以在java客户端写Lua脚本,写好之后,去执行。需要执行时,每次将脚本发送到Redis服务端上去执行。我们选择这一个,将Lua脚本放在resources目录下。

自动续期:锁过期问题的出现,是我们对持有锁的时间不好进行预估,设置较短的话会有【提前过期】风险,但是过期时间设置过长,可能锁长时间得不到释放。

  1. 锁过期

锁过期问题的出现,是我们对持有锁的时间不好进行预估,设置较短的话会有提前过期的风险,但是过期时间设置过长,可能锁长时间得不到释放。

如果Client1获取到锁后,因为业务问题需要较长的处理时间,超过了锁过期时间,该怎么办?

业务执行时间快要超过了锁过期时间时,给锁续期。
比如开启一个守护进程,定时监测锁的失效时间,在快要过期的时候,对锁进行自动续期,重新设置过期时间。Redisson框架中就实现了WatchDog(看门狗)机制:加锁时没有指定加锁时间时会启用 watchdog 机制,默认加锁 30 秒,每 10 秒钟检查一次,如果存在就重新设置过期时间为 30 秒(即 30 秒之后它就不再续期了)。

如果项目中的Redis挂掉,如何减轻数据库的压力?
用Redis集群模式:主从模式、哨兵模式、集群模式。

安全优化

秒杀接口地址隐藏

秒杀开始时,如果有用户提前知道了、或者能快速获取秒杀接口的URL地址,那么他就可以利用这个地址通过脚本不断地刷新秒杀。

图形验证码

用户仍然能通过脚本快速获取随机的UUID和字符串拼接规则,实现快速不断刷新秒杀。因此每次点击秒杀开始前,先让用户输入验证码。此外,验证码还能起到分散用户的请求的作用,防止大量的用户请求集中在刚开始的几秒中,对服务器造成压力。

接口限流

目的同样是为了减轻服务器的压力。

常见算法:

  1. 计数器算法(采用这个算法)

一定时间周期内的请求数量有上限。
缺点:周期时间内的用户到达数量是随机的,因此可能会出现周期内实际到达数量少而导致资源的浪费、以及相邻周期的临界时间段内的到达数量过多导致请求数超过服务器处理容量。

  1. 漏斗算法

将请求放入一个队列,控制请求放行速度

  1. 令牌桶算法

以恒定的速度发放令牌(token)到令牌桶,请求从桶里取token。令牌桶满时直接丢弃token。
优点:减轻突发流量带来的压力

组合后的安全优化方案:

Java高并发秒杀系统_第11张图片

其他优化方案

网关过滤:

  • 设置预约按钮,提前预约的用户提前获得token,秒杀开始时网关直接过滤掉没有token的。
  • 黑名单中的IP地址(单个IP访问频率和次数多了之后就进行拉黑,解决客户的恶意下单问题
  • 同一个IP地址发起的重复请求

设置每个服务器发放的总token数量

加了缓存之后的缓存三大问题及解决方法:

  • 穿透:查询一个数据库一定不存在的数据。解决方法:布隆过滤器
  • 击穿:缓存中没有但数据库中有的数据,并且某一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间(一般是缓存时间到期),持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。解决方法:设置热点数据永远不过期。
  • 雪崩:缓存中大批量数据到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。解决方法:(1)随机设置缓存有效期,比如,热门类目的商品缓存时间长一些,冷门类目的商品缓存时间短一些,也能节省缓存服务的资源;(2)如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中;(3)设置热点数据永远不过期。

你可能感兴趣的:(java,后端,spring,boot,redis,rabbitmq,mysql)