项目总结

1

项目背景

在开学时学生会进行选课操作,对于部分热点课程来说容量是有限制的,所
以对这些课程可以设置一个开始时间和结束时间,在这个时间内才可以抢课
,当到达开始时间的时候,会有大量请求涌入,这个系统的核心就是着重处
理大量请求,尽可能多的承载容量

RESTful API

API翻译过来是应用程序编程接口的意思。我们在进行后端开发的时候,主要的
工作就是为前端或者其他后端服务提供API比如查询用户数据的API。前端调用
API 向后台发起HTTP 请求,后台响应请求将处理结果反馈给前端。也就是说
Restful 是典型的基于HTTP的协议

REST

REST翻译过来就是“表现层状态转化”。也就是“资源”在网络传输中以某种“表现
形式”进行“状态转移”。

  1. 资源:我们可以把真实的对象数据称为资源。一个资源既可以是一个集合,
    也可以是单个个体。比如我们的班级classes 是代表一个集合形式的资源,而
    特定的class 代表单个个体资源。每一种资源都有特定的URI(统一资源标识
    符)与之对应,如果我们需要获取这个资源,访问这个URI 就可以了
  2. 表现形式:”资源”是一种信息实体,它可以有多种外在表现形式。我们把”资
    源”具体呈现出来的形式比如json xml image txt 等等叫做它的”表现层/表现
    形式”
  3. 状态转移:REST 中的状态转移更多地描述的服务器端资源的状态,比如你
    通过增删改查(通过HTTP 动词实现)引起资源状态的改变

总结为三点

  1. 每一个 URI 代表一种资源
  2. 客户端和服务器之间,传递这种资源的某种表现形式比如json xml image
    txt 等等
  3. 客户端通过特定的HTTP 动词,对服务器端资源进行操作,实现”表现层状
    态转化”

优化方式

我采用了多种优化方式提升性能,并且使用Jemeter进行压测发现性能问题
并进行优化

  1. 分布式扩展 包括nginx反向代理到多台应用服务器和使用分布式会话管理
  2. 查询优化采用多级缓存技术 包括redis缓存、本地缓存、热点nginx lua
    缓存解决缓存前置的性能容量瓶颈、动态请求缓存和静态请求cdn解决页面静
    态化,使用PhantomJS无头浏览器做全页面静态化
  3. 交易泄压 包括缓存库存模型解决交易缓存的验证技术、交易异步化模型和
    使用异步化的事务来保证应用的最终一致性问题,使用库存售罄防击穿的优化
    解决后置流量问题
  4. 流量错峰 包括秒杀令牌、秒杀大闸和队列泄洪技术来解决流量涌入问题防
    止系统被击穿
  5. 防刷限流 包括验证码、限流器和防黄牛技术解决风控问题

使用到的技术

Vue+SpringBoot+MyBatis-generator+MySQL+Redis+Rocketmq
项目部署在阿里云ECS服务器,采用MVC+领域模型的设计模式

数据模型

  1. 接入层模型 View Object 可以返回给前端的数据对象
  2. 业务层模型 Domain Model 多个数据对象的融合,不提供服务功能
  3. 数据层模型 Data Object 数据模型,与数据库映射

表的设计

学生表和密码表分开存储

  1. 用户密码可以存储在加密机、加密数据库或其他系统中
  2. 除了登录和修改密码操作之外其余学生相关信息的操作不需要密码,比如
    展示学生信息,这里只用到学生数据表即可,节省存储空间

课程表和容量表分开存储

  1. 单独做课程容量表进行优化
  2. 对课程容量的操作非常耗时耗性能,每次减容量都会对该记录进行行锁操作
    ,容量表也可以进行分库分表,根据不同的课程id进行拆分来减少数据库性能
    的消耗

交易表的主键

这个主键是由交易号组成,一共16位。前8位是选课时间,中间6位是自增序列
通过保存当前值和每次递增的值组成,最后两位是分库分表位。这是一种雪花
模型

功能概述

通用返回对象

定义通用的返回对象返回正确信息。归化为一个统一的status+data的格式。
status就是返回的处理成功或失败结果。data是要返回给前端的数据。
使用枚举类定义各种不同的错误信息,比如参数不合法、学号不存在、学生未
登录等精确信息,如果程序出现错误就会返回错误信息,有两个属性,一个
是错误码一个是错误信息

异常拦截器处理自定义异常

抛出自定义的异常使用拦截器拦截,使用@ControllerAdvice注解添加在异
常处理类上,使用@ExceptionHandler添加在处理异常的方法上,抛出的异
常,就会先进入这个方法进行处理,封装错误码和错误信息返回给前端

解决跨域

  1. 前端ajax解决跨域
    由于浏览器的安全机制,JS只能访问与所在页面同一个域的内容,但是我们这
    里需要通过Ajax请求去请求后端接口并返回数据,这时候就会受到浏览器的安
    全限制,产生跨域问题(如果只是通过Ajax向后端服务器发送请求而不要求返
    回数据,是不受跨域限制的)。
    前端的HTML页面,在Ajax请求体里面,需要设置
    1
    2
    contentType:"application/x-www-form-urlencoded",
    并添加一个额外的字段xhrFields:{withCredentials: true}
  2. Vue解决跨域
    Vue 框架中有一个Vue.config.js 配置文件,包含生成环境和开发环境以及一
    些打包的配置,其中有一个devServer 的部分用来配置跨域请求,配置主机端
    口和目标代理服务器的地址,允许这个地址跨域
  3. 后端解决跨域
  • @CrossOrigin 在controller 层的类或者方法上添加这个注解,指定属性
    allowedCredentials 为true,表示允许跨域请求上传cookie或用户凭证等
    信息,指定属性allowedHeaders 为true,允许跨域请求传入的header字段
  • 全局配置 创建一个配置类CrosConfig 实现WebMvcConfigurer,使用注
    解@Configuration 表示这是一个配置类,实现addCorsMappings方法,
    允许所有类型的请求方法和头字段跨域

跨域产生的条件

  1. 使用ajax请求
  2. 访问的域名不同

解决跨域

  1. JSONP
  2. CORS
  3. 代理法 打破不同源的限制,只要让它同源即可。将对应的服务部署在不同
    的机器上,使用一个公共的域名作为nginx反向代理的入口域名,将静态服务
    和动态服务分别挂在后面的被代理局域网服务器内

优化校验

前端的数据传给后端后直接使用validator来进行校验,直接可以对字段添加
注解实现校验规则

mysql插入优化

  1. 使用批量插入的sql语句,而不要用for循环逐个插入
  2. 使用事务包括所有的插入语句,而不是每一个插入都开启一个事务
  3. 插入的时候尽量保证插入的条目顺序是按照索引顺序递增插入

课程信息展示

展示课程的图片、学分和选课人数。这里根据选课人数倒排排序展示所有课程
信息,不过课程还需要进行封装,通过stream().map 将课程信息表和课程
容量表组合在一个模型中返回给前端,前端点击相应课程就会进行该课程的
详细页面

密码传输安全

唯一的解决方法就是数据加密,有三种加密方式

  1. 哈希散列
  2. 对称加密 由于客户端的秘钥容易泄露,只适用于服务端与服务端的交互
  3. 非对称加密

最好的选择是使用https协议

核心流程

注册流程

  1. 首先在前端展示获取验证码页面,这里输入学号获取验证码
  2. 后端接收到学号后通过random.nextInt(90000)+10000生成5位验证码,
    并发送给前端展示,将学号和验证码以键值对的形式保存在redis中,并设置
    过期时间为1分钟
  3. 学生收到验证码后进入注册页面,输入学生的基本信息、密码和验证码进
    行注册操作
  4. 后端收到学生信息和验证码后从缓存中取出相应学生的验证码对比是否一
    致,如果一致说明学号验证无误,调用注册接口,通过RandomStringUtils
    类为学号生成16位的盐,使用DigestUtils.md5Hex加密算法将密码和盐的
    组合进行加密并保存在学生密码表,密码是长度为32的字符串,学生的盐信
    息保存在单独的学生密码盐表。这步操作就是加盐加密操作
  5. 注册成功后前端通过window.location.href跳转到登录页

登录流程

  1. 在登录页面输入学号和密码选择登录选项
  2. 后端根据学号查询密码,学号添加了唯一索引加快查询,如果密码存在那
    么根据学生id取出相应的盐拼接一起并进行加密操作,如果与密码表中的密
    码一致那么学生信息无误
  3. 使用UUID生成唯一登录凭证token,然后将生成的token作为KEY,学生信
    息作为VALUE存入到Redis服务器,并将token返回给前端
  4. 前端在登录成功之后,将token存放到localStorage里面,跳转到课程信
    息页

获取课程信息流程

  1. 课程信息页展示所有课程信息,包括课程的图片、课程名、学分和已选课人
    数,点击课程触发window.location.href进入该课程的详细信息页
  2. 前端向后端发送HTTP请求传入课程id,后端首先查询本地缓存中是否有课
    程信息,这里使用Guava Cache,本质是利用本地JVM的内存,这里没有使用
    ConcurrentHashMap 是因为无法高效处理过期时限、没有淘汰机制。Guava
    Cache 除了线程安全外,还可以控制超时时间,提供淘汰机制。这里使用设
    置本地缓存初始容量为10,最大容量为100,超过后会按照LRU策略移除,
    设置写缓存60s之后过期
  3. 如果本地缓存没有相应课程数据那么从redis数据库中获取缓存数据,并
    设置过期时间为10分钟,如果redis中也没有缓存那么就调用相应接口从数
    据库中取数据,取出之后在redis中设置
  4. 通过数据库获取课程信息时也会查询课程的活动信息,如果活动信息表中
    该课程信息不为空,那么就会根据当前时间判断课程的活动状态,这里有三
    种状态:第一种就是活动还未开始不能选择该课程,第二种状态是当前时间
    处于活动时间可以选课,第三种状态是活动时间已经结束不能选择该课程。
  5. 前端接收到后端传来的课程信息后根据课程的活动状态进行操作,如果
    活动还未开始显示倒计时时间,选课键不能点击,当差值为0的时候变为可
    选状态,当活动结束后选课键不能点击

抢课流程

  1. 当课程处于活动时间时可以进行选课操作,点击选课时请求会带上之前登
    录成功时保存的token,后端根据token从redis缓存中取出学生信息,如果
    不为空说明验证成功,后端生成一个验证码和验证码图像,验证码由4 位英
    文字母和数字组成并放入redis缓存中,通过ImageIO.write向前端返回一
    个验证码图片,用户根据图片识别验证码输入之后点击验证,会生成 HTTP
    请求调用获取选课令牌的接口

  2. 请求选课令牌接口的时候带上课程id、活动id和验证码和token,从缓存
    中根据学生id获取验证码进行验证,这里验证码的过期时间设置为10分钟,
    如果验证成功就生成选课令牌,使用选课令牌的目的是使校验逻辑和选课逻
    辑分离,将活动、课程、学生信息校验逻辑封装在请求令牌的接口方法里通过
    UUID生成令牌,以活动id、学生id和课程id为KEY令牌为token保存在redis
    缓存中,设置超时时间是5s。

  3. 完成以上两步操作后会生成HTTP请求带上选课令牌请求下单接口,

  4. 前端进行选课操作时首先查询课程信息,然后查询课程活动信息,最后查
    询学生信息。查询学生信息,是为了用户风控策略,判断学生信息是否存在是
    最基本的策略。查询课程信息、活动信息,是为了活动校验策略。课程和学生
    信息先从redis缓存中获取,如果没有再从数据库查。过期时间设置为10分

  5. 加入课程容量流水的初始状态再调用事务型消息去选课,课程容量流水
    的状态有三种状态,第一种就是刚刚初始化,第二种表示选课扣减课程容
    量成功,第三种是回滚。当选课操作完成也就是执行Redis扣减库存、订单
    入库、选课人数增加的操作之后,就说明选课完成了,等着异步更新数据
    库了。那么需要修改流水的状态。使用课程容量流水的作用就是说如果在
    执行选课操作的时候,突然宕机了,此时事务型消息的状态是UNKNOWN
    ,需要在回调方法中根据课程容量流水的状态进行处理。在初始化课程
    流水之前先在缓存中判断该课程是否人数已满,如果人数已满那么就不
    需要初始化课程容量流水,容量不足选课失败

  6. 在redis中完成扣减课程容量的操作之后,那么就发送消息到消息队列,
    准备异步扣减,通过new Message生成消息,指定topic和标签,通过send
    将消息发送出去,如果消息发送失败,需要回滚Redis。但是数据库扣减失败
    redis不能回滚,所以这一步只负责扣减Redis库存,不发送异步消息。在事
    务提交成功后,执行发送消息操作。所以最终的优化结果是在事务型消息中
    去执行选课操作,选课失败,则消息回滚,不会去数据库扣减课程容量。选
    课成功,则消息被消费,扣减数据库课程容量。选课操作的具体内容就是创
    建订单和redis扣减课程容量的操作

项目用到的命令

  1. java -Xms400m -Xmx400m -XX:NewSize=200m -XX:MaxNewSize=200m

-jar miaosha.jar –spring.config.additional-location=/usr
/projects/application.properties 启动Java的命令并指定Java虚拟机参
数和配置文件。JVM参数需要根据不断的压测以及线上环境进行设置,最大堆栈
和最小堆栈设置一样保证不会频繁从操作系统分配内存
2. ./deploy.sh & 启动Java项目
3. tail -f nohup.out 查看项目启动信息
4. ps -ef | grep java 检查java进程是否存在,|管道命令符的作用能用
一句话来概括:把前一个命令原本要输出到屏幕的数据当作是后一个命令的
标准输入。ps显示进程信息的,-e显示所有进程,-f全格式。grep命令是查
找,使用正则表达式搜索文本,然后把匹配的行显示出来
5. netstat -anp | grep port 查看端口占用情况。netstat命令用于显
示网络状态,-a显示所有,-n不用别名显示只用数字显示,-p显示进程号和
进程名
6. pstree -p pid | wc -l 查看Java进程一共维护了多少个线程
7. top -H 查看CPU的使用情况。us表示用户进程占用的CPU,sy表示内核进
程占用的CPU,load average反映了CPU的负载强度,表示一些很耗时的操作
8. systemctl start mariadb.service 启动MySQL需要的服务器
9. mysql -uroot -pXX 连接数据库
10. sbin/nginx -c conf/nginx.conf 启动nginx
11. sbin/nginx -s reload 修改配置后直接无缝重启
12. src/redis-server ./redis.conf & 启动redis
13. nohup sh bin/mqnamesrv & 启动nameserver,默认在9876端口
14. tail -f ~/logs/rocketmqlogs/namesrv.log 查看启动情况
15. nohup sh ./mqbroker -n IP:9876 & 启动broker
16. export NAMESRV_ADDR=IP:PORT
17. sh bin/tools.sh org.apache.rocketmq.example.quickstart.
Producer 投放消息
18. sh bin/tools.sh org.apache.rocketmq.example.quickstart.
Consumer 消费消息
19. ./mqadmin updateTopic -n IP:PORT -t stock -c DefaultCluster
创建自定义的topic

分布式扩展优化

Spring Boot项目部署

将jar包部署在两台阿里云服务器,有的时候线上环境需要更改一些配置,比如
在9090端口部署等等。
Spring Boot 支持在线上环境中使用spring.config.additional-location
指定线上环境的配置文件,而不是打到jar包里的配置文件。
创建一个自定义的外挂配置文件,SpringBoot会优先使用这个文件里面的配置
信息。然后编写一个启动脚本deploy.sh 用于启动应用。使用nohup启动应用程
序并且退出终端也不影响程序运行,可以在nohup.out文件查看项目运行信息。
如果要做到自动化部署可以通过批处理脚本和集成Jenkins

Jmeter

使用jmeter来进行并发压测,新建一个线程组,添加需要压测的接口地址,查看
结果树和聚合报告,接口化压测应用还包括FTP LDAP TCP SMTP

  1. 线程组 启动多个并发线程,并发送一些接口的请求。设置线程数、Ramp-Up时
    间(在该时间内要启动的线程数)、循环次数(每个线程发送多少个请求)

  2. Http请求 发送http请求,需要设置协议、方法和路径

  3. 查看结果树 http请求响应的结果

  4. 聚合报告 性能压测报告,包括响应时间、TPS和QPS的数据

  5. TPS
    即服务器每秒处理的事务数。TPS包括一条消息入和一条消息出,加上一次用
    户数据库访问。TPS是软件测试结果的测量单位。一个事务是指一个客户机向
    服务器发送请求然后服务器做出反应的过程。客户机在发送请求时开始计时
    ,收到服务器响应后结束计时,以此来计算使用的时间和完成的事务个数

  6. QPS
    每秒查询率,是一台服务器每秒能够响应的查询次数(数据库中的每秒执行
    查询sql的次数),这个不够全面,不能描述增删改

pv可以用来作为性能测试的指标么

即 page view,页面浏览量。用户每一次对网站中的每个页面访问均被记录1
次。用户对同一页面的多次刷新,访问量累计

TPS和QPS的区别

如果是对一个查询接口(单场景)压测,且这个接口内部不会再去请求其它接
口,那么tps=qps,否则tps≠qps。jmeter聚合报告中,Throughput 是用来
衡量请求的吞吐量,也就是tps,tps =样本数/运行时间;我们定义的是tps
,不是qps。如果没有定义事务,会把每个请求作为一个事务

查询接口压测结果

  1. 单机压测 200*50个请求,200TPS
  2. 分布式压测 发送1000*30个请求,900TPS
  3. 分布式缓存压测 发送1000*20个请求,1300TPS

Tomcat线程优化

Spring Boot内嵌Tomcat默认的线程设置,默认最大等待队列为100,默认最
大可连接数为10000,默认最大工作线程数为200,默认最小工作线程数为10。
当请求超过200+100后,会拒绝处理;当连接超过10000后,会拒绝连接。对
于最大连接数,一般默认的10000就行了。在配置文件中进行修改tomcat 配
置,最大线程数400(操作系统切换线程也有开销),最小线程数50(解决
突发容量问题有充足时间反应),最大等待队列设置为500(受到内存的限
制,二是大量的出队入队操作耗费CPU性能)

Tomcat网络连接优化

Spring Boot并没有把内嵌Tomcat的所有配置都导出。一些配置需要通过接口
来实现自定义,自定义KeepAlive长连接的配置,减少客户端和服务器的连接
请求次数,避免重复建立连接,提高性能,设置Timeout为30秒,设置10000
个请求则自动断开,实现WebServerFactoryCustomizer接口做定制化配置
HTTP 1.1的长连接

使用OpenResty

OpenResty 是一个基于Nginx 与Lua 的高性能Web 平台,其内部集成了大
量精良的 Lua 库、第三方模块以及大多数的依赖项。用于方便地搭建能够处
理超高并发、扩展性极高的动态Web 应用、Web 服务和动态网关。
在nginx.conf中进行配置

  1. location节点path指定resources:静态资源路径
  2. location节点其他路径:动态资源用
  3. server节点可以配置多个,做域名解析

Nginx反向代理负载均衡

单机有容量问题(CPU使用率高,内存占用高,网络带宽高),进行水平扩展
。使用nginx反向代理,采用负载均衡轮询策略,Ajax请求则会通过Nginx反
向代理到两台不同的应用服务器,MySQL 部署在单独的应用服务器,需要在
Java应用的配置文件中添加数据库连接池

  1. web服务器 动态web和静态web服务器,动静分离,将动态请求反向代理
    到后端服务器。通过/resources/访问的是静态资源,否则就是动态请求

反向代理具体配置操作

  1. 配置upstream server用来做对应的反向代理服务器节点,指定后端服务
    器的局域网IP地址和端口,使用weight表示权重,两个weight都为1
  2. 设置动态请求location为proxy pass路径,反向代理到两台应用服务器
  3. 开启tomcat access log验证 开启这个功能可以查看是哪个IP发过来的
    请求,在application.properties里面添加server.tomcat.accesslog.
    enabled=true

Nginx反向代理长连接优化

Nginx服务器与前端的连接是长连接,但是与后端的代理服务器,默认是短连接
,设置Nginx服务器和应用服务器之间的长连接。在nginx.conf中配置HTTP1.1
和keepalive 30。通过nestat -an | grep IP | grep ESTABILSHED可以判
断是否是长连接,如果是长连接Nginx连接端口不会改变。nginx 高性能的原因
总结如下

  1. 首先依靠epoll模型的多路复用机制,解决的IO阻塞的一个回调通知的问题
  2. 第二,依靠master worker进程模型可以完成平滑的过度,平滑的重启,并
    且基于worker单线程的模型,结合epoll多路复用的机制完成高效的操作
  3. 第三基于协程的机制,将每个用户的请求对应到线程中的某个协程中,然后
    在协程中使用epoll 多路复用的机制,来完成对应一个同步调用的开发,完成
    高性能的操作

分布式会话管理

将token存到Redis服务器上,从而实现分布式会话。Java对象会被存到Redis里
面,需要被序列化,序列化的目的是为了对象可以跨平台存储和进行网络传输。
一般常用UUID生成类似SessionId的唯一登录凭证token,然后将生成的token
作为KEY,UserModel作为VALUE存入到Redis服务器。设置超时时间是一个小时
使用redisTemplate可以操作SpringBoot中内嵌的redis的bean。
redisTemplate.opsForValue().set(uuidToken,userModel);
redisTemplate.expire(uuidToken,1,TimeUnit.HOURS);
前端收到后端传来的uuidToken,使用window.localStorage存储token。
localStorage用于长久保存整个网站的数据,保存的数据没有过期时间,直到
手动去删除

查询优化之多级缓存

所谓缓存,就是将磁盘中的热点数据,暂时存到内存里面,以后查询直接从
内存中读取,减少磁盘I/O,提高速度。所谓多级,就是在多个层次设置缓
存,一个层次没有就去另一个层次查询。
缓存的局限性是缓存具有丢失性的特点,数据必须保存在磁盘上。缓存数据也
必须同步更新

redis缓存第一级

redis集中式管理缓存,所有的应用服务器都在这台redis服务器中操作。
但是单机版的redis也有缺点:若单机redis宕机那么整个系统都会受影响,
单机redis还有容量上限问题。
两种方式解决:sentinal哨兵模式和集群cluster模式。
每次查询课程详情的时候都需要查询课程的基本信息、容量信息和活动信息,
这里需要查询三个表太耗时。设置model到redis中,根据课程id到redis中
获取,设置10分钟失效时间

序列化格式

Java对象存到Redis里面的VALUE是类似/x05/x32的二进制格式,我们需要自
定义RedisTemplate的序列化格式,以JSON的格式存储和显示

本地缓存第二级

Redis缓存虽好,但是有网络I/O,没有本地缓存快,缓存热点数据,这里使用
的JVM内存,由于JVM内存有限,仅存放多次查询的数据,脏读不敏感。
本地缓存,说白了就是一个HashMap,但是HashMap不支持并发读写,肯定是不
行的。j.u.c包里面的ConcurrentHashMap虽然也能用,但是无法高效处理过期
时限、没有淘汰机制等问题,所以这里使用了Google的Guava Cache方案。
Guava Cache除了线程安全外,还可以控制超时时间,提供淘汰机制。
最大100个KEY,超过后会按照LRU策略移除,设置60s的过期时间。本地缓存是不
能保证一致性的,分布式环境下需要广播消息给每台机器去清除对应的缓存,可
以用rocketmq的广播消息

OpenResty Shared Dict+Nginx+Lua+OpenResty redis缓存第三级

通过Redis缓存,避免了MySQL大量的重复查询,提高了部分效率;通过本地缓存
,减少了与Redis服务器的网络I/O,提高了大量效率。但实际上,前端(客户端
)请求Nginx服务器,Nginx 有分发过程,需要去请求后面的两台应用服务器有
一定网络I/O,可以直接把热点数据存放到Nginx服务器上。

  1. Nginx Proxy Cache原理是基于文件系统的,它把后端返回的响应内容作为
    文件存放在Nginx指定目录下,依靠内存缓存文件地址。有磁盘I/O,虽然减少了
    一定的网络I/O,但是磁盘I/O 并没有内存快,得不偿失,所以不建议使用。在
    nginx.conf配置文件中声明一个cache缓存节点,levels=1:2 表示以二级目
    录存放,文件内容被分散到多个目录减少寻址操作
  2. Nginx lua脚本基于“内存”的缓存策略,lua也是基于协程机制的。lua脚本
    可以挂载在Nginx 处理请求的起始、worker进程启动、内容输出等阶段。协程是
    依附于线程的内存模型,切换开销小。遇到阻塞则释放执行权,代码同步。无需
    加锁。
    创建一个lua脚本文件,在nginx.conf里面添加一个init_by_lua_file的字段
    ,指定上述lua脚本的位置,这样当Nginx 启动的时候就会执行这个lua脚本,
    新建一个helloworld.lua,使用ngx.exec(“/item/get?id=1”)访问某个URL。
    同样在nginx.conf添加一个helloworld location。这样当访问/helloworld
    的时候就会跳转到item/get?id=1这个URL上。使用lua脚本的方式在nginx上
    完成对应的业务处理,避免访问后端服务器
  3. OpenResty—Shared dict Shared dict是一种类似于HashMap的Key-Value
    内存结构,对所有worker进程可见,并且可以指定LRU淘汰规则。
    在nginx.conf指定一个名为my_cache,大小为128m的lua_shared_dict。
    在lua文件夹下,新建一个itemshareddict.lua脚本,编写两个函数,一个是获
    取缓存对象,一个是设置缓存对象。在main函数中根据课程id从缓存中取出课程
    信息,如果取不到就去请求后端的接口,并把后端返回的数据存入缓存中,新建
    一个luaitem/get的location
  • ngx.shared.my_cache 缓存对象
  • ngx.req.get_uri_args() 得到请求的参数
  • ngx.location.capture(“/item/get?id=”..id) 请求后端接口
  • ngx.say() 输出一段字符串
    使用Ngxin的Shared dict,把压力转移到了Nginx服务器,后面两个Tomcat服务
    器压力减小。同时减少了与后面两个Tomcat服务器、Redis服务器和数据库服务器
    的网络I/O,当网络I/O成为瓶颈时,Shared dict不失为一种好方法,依然受制
    于缓存容量和缓存更新问题。
  1. OpenResty redis支持 Nginx可以连在redis服务器上只读不写,若redis没
    有对应的数据那么请求应用服务器。应用服务器中当数据发生改变的时候也可以更
    改redis中的数据,那么nginx就可以实时获取redis的数据,可以使用redis 集
    群分散压力

交易优化

课程容量扣减行锁优化

之前扣减课程容量的操作,会执行以下这条SQL语句,给itemId加上唯一索引,
当修改某个课程的剩余容量时只会锁住该行数据,串行化减课程容量依然会有
阻塞

1
2
update stock set stock = stock -#{amount} where item_id = 
#{itemId} and stock >= #{amount}

课程容量扣减缓存化

之前选课,是直接操作数据库,一旦活动开始,大量的流量涌入扣减库存接口
,数据库压力很大。那么先在缓存中进行选课,如果要在缓存中扣减课程容量
,需要解决两个问题,第一个是活动开始前,将数据库的课程容量信息,同
步到缓存中。第二个是选课之后,要将缓存中的容量信息同步到数据库中

  1. 活动发布同步课程容量信息进缓存,把数据库的缓存存到Redis里面去,
    当我们把库存存到Redis的时候商品可能被下单,这样数据库的库存和Redis
    的库存就不一致了。解决方法就是活动未开始的时候商品是下架状态,不能
    被下单
  2. 异步扣减课程容量在缓存中减,这里的问题就是数据库记录不一致,在
    Redis里面扣减容量,使用redisTemplate.opsForValue().increment
    修改容量数据,返回值是完成减操作后的剩余结果,如果大于等于0那么
    更新成功
  3. 异步消息扣减数据库内课程容量数据 使用rocketmq修改mqnamesrv.xml
    保证内存足够使用。在配置文件中指定rocketmq的地址和topic

异步扣减课程容量

  1. 创建生产者和消费者类并初始化,初始化方法添加@PostConstruct注解
    ,在生产者和消费者中指定group,对生产者没有意义。通过start方法连接
  2. 消费者的初始化方法中通过subscribe(topicName,”*“);监听topic
    话题下的所有消息
  3. 创建匿名类会监听消息队列中的消息,从消息中获取要扣减容量的课程信
    息,扣减成功后返回消费成功
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    consumer.registerMessageListener(new MessageListenerConcurrently() {
    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt>
    list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
    //实现缓存数据真正到数据库扣减的逻辑
    //从消息队列中获取消息
    Message message=list.get(0);
    //反序列化消息
    String jsonString=new String(message.getBody());
    Map<String,Object> map=JSON.parseObject(jsonString, Map.class);
    Integer itemId= (Integer) map.get("itemId");
    Integer amount= (Integer) map.get("amount");
    //去数据库扣减库存
    itemStockDOMapper.decreaseStock(itemId,amount);
    //返回消息消费成功
    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }
    });

有两个问题需要注意

  1. 发送异步消息在最后一步执行,因为订单入库、增加选课人数的操作可能失
    败,如果失败那么redis和已经发送的异步消息无法回滚,会导致数据库的不
    一致
  2. Spring的@Transactional标签,会在事务方法返回后才提交,如果提
    交的过程中,发生了异常,可能是网络或者磁盘容量问题,则数据库回滚
    ,但是Redis库存已扣,还是无法保证一致性。我们需要在事务提交成功
    后,再发送异步消息

事务型消息

消费端从数据库扣减操作执行失败,那么创建选课订单事务会回滚,扣减课
程容量事务也回滚,但是Redis的扣减操作却不能回滚,会导致数据不一致

  1. 在订单入库、增加该课程选课人数成功之后,再发送异步消息
  2. Spring的@Transactional标签,会在事务方法返回后才提交,如果提交
    的过程中,发生了异常,则数据库回滚,但是Redis库存已扣,还是无法保证
    一致性。我们需要在事务提交成功后,再发送异步消息。
  3. TransactionSynchronizationAdapter的匿名类,通过afterCommit方
    法,在事务提交成功后,执行发送消息操作

transactionMQProducer

  1. 上面的做法,依然不能保证万无一失。假设现在事务提交成功了,等着执行
    afterCommit方法,这个时候突然宕机了,那么订单已然入库,销量已然增加
    ,但是去数据库扣减库存的这条消息却“丢失”了。这里就需要引入RocketMQ的
    事务型消息transactionMQProducer
  2. 所谓事务型消息,也会被发送到消息队列里面,这条消息处于prepared状
    态,broker会接受到这条消息,但是不会把这条消息给消费者消费,在该状态
    下会去执行对应的executeLocalTransaction 方法,这个方法里就是创建订
    单的操作,返回的结果有三种类型,如果创建订单的操作失败那么回滚,如果
    成功完成就返回COMMITED
  • 第一种就是将prepared消息转化为COMMITED消息给消费者消费
  • 第二种就是将prepared消息撤回
  • 第三种就是未知状态

只要数据库事务提交对应消息必定发送成功,数据库事务回滚消息必定不发送
,数据库状态未知消息必须是处理中的状态。事务型消息有一个二阶段提交的
概念,消息发出后并不是可被消费状态。

库存流水

当执行选课操作后,突然又宕机了,根本没有返回,这个时候事务型消息就会
进入UNKNOWN状态,我们需要处理这个状态。在匿名类TransactionListener
里面,还需要覆写checkLocalTransaction方法,这个方法就是用来处理
UNKNOWN状态的。消息中间件会定期回调这个方法,根据redis中是否扣减
课程容量成功来判断是返回COMMITED还是ROLLBACK还是继续UNKNOWN

  1. 这就需要引入库存操作流水来记录容量的状态,以便在事务型消息处于不
    同状态时进行处理,可以追踪对应的异步扣减消息。数据可以分为主业务数据
    和操作型数据,操作型数据是说库存扣减发生了这样的操作就记录下来,便
    于追踪库存操作流水的状态,消息中间件就可以根据这个状态得到系统当前
    状态
  2. 执行Redis扣减库存、订单入库、选课人数增加的操作,当这些操作都完
    成后,就说明选课完成了,等着异步更新数据库了。那么需要修改订单流水
    的状态,回调函数就可以处理。有一个问题就是如果选课操作失败但是缓存
    已经扣减数据库没有更新那么就会出现数据不一致问题,一种解决方法是
    超时释放,如果长时间没有返回结果那么就会回补缓存

选课人数已满处理

如果选课人数已满但是还是会初始化一次流水,大量的学生进行请求抢夺少
数的课程,但是每个学生都会初始化一次课程流水,设置一个选课人数已满
的标识,初始化流水之前,先判断一下是否选课人数已满,人数已满了就直
接抛出异常选课失败,在redis中保存该课程选课已满的状态,如果已经没
有剩余容量那么就在redis中设置该课程选课已满的标志

小结

通过引入课程流水,来记录课程容量的状态,以便在事务型消息处于不同状
态时进行处理。事务型消息提交后,会在broker里面处于prepare状态,也
即是UNKNOWN状态,等待被消费端消费,或者是回滚。prepare状态下,会
执行创建选课单方法。此时有两种情况:

  1. 创建课程交易单执行完没有宕机,要么执行成功,要么抛出异常。执行成
    功,那么就说明下单成功了,订单入库了,Redis里的库存扣了,选课人数
    增加了,等待着异步扣减课程容量,所以将事务型消息的状态,从UNKNOWN
    变为COMMIT,这样消费端就会消费这条消息,异步扣减课程容量。抛出异常
    ,那么订单入库、Redis库存、选课人数增加,就会被数据库回滚,此时去异
    步扣减的消息,就应该“丢弃”,所以发回ROLLBACK,进行回滚
  2. 创建课程交易单执行完宕机了,那么这条消息会是UNKNOWN状态,这个时
    候就需要在checkLocalTransaction进行处理。如果创建订单执行完毕,此
    时stockLog.status==2,就说明下单成功,需要去异步扣减库存,所以返
    回COMMIT。如果status==1,说明下单还未完成,还需要继续执行下单操作
    ,所以返回UNKNOWN。如果status==3,说明下单失败,需要回滚,不需要
    异步扣减库存,所以返回ROLLBACK
  3. 目前只是扣减库存异步化,实际上选课人数增加逻辑和选课交易逻辑都可
    以异步化

流量削峰

在活动开始的一瞬间,有大量流量涌入,优化不当会导致服务器停滞,甚至
宕机。所以引入流量削峰技术十分有必要。将最开始的流量使用平滑的方式
过渡掉

选课令牌

  1. 利用选课令牌,使校验逻辑和选课逻辑分离。将活动、课程、学生信息校验
    逻辑抽离出选课逻辑,选课接口需要依靠选课令牌才能进入,选课令牌由活
    动模块进行生成,在选课接口中只需要验证令牌的有效性即可,只有活动正
    在进行时这个令牌才会生成,在redis中保存这个token,设置5分钟的时限
    ,这个token的key由活动id和学生id和课程id为标志生成

令牌大闸

有大量的学生发送抢课请求,但是每个学生都生成抢课令牌太消耗性能,令
牌的数量是有限的,当令牌用完时,就不再发放令牌了,那么下单将无法进

  1. 将令牌总量也发布到Redis上,这里我们设定令牌总量是课程容量的2倍
    ,在生成令牌之前,首先将Redis里的令牌总量减1,然后再判断是否剩余

队列泄洪

队列泄洪,就是让多余的请求排队等待。排队有时候比多线程并发效率更高,
多线程毕竟有锁的竞争、上下文的切换,很消耗性能。而排队是无锁的,单线
程的,某些情况下效率更高。
比如Redis就是单线程模型,多个用户同时执行set操作,只能一一等待。
MySQL的insert和update语句,会维护一个行锁。阿里SQL就不会,而是让
多个SQL语句排队,然后依次执行。

  1. 依靠排队去限制并发流量
  2. 使用@PostConstruce注解创建一个init方法初始化一个大小为20的线程
    池用来队列化泄洪,也就是同一时刻只有20个线程可以被执行,在拿到秒杀
    令牌后,使用线程池来处理下单请求

防刷限流

  1. 之前的流程是,学生点击选课后,会直接拿到令牌然后执行选课流程。现在
    学生点击下单后,前端会弹出一个“验证码”,学生输入之后才能请求选课接口。
    之前获取秒杀令牌的generateToken接口,需要添加验证码校验逻辑。这样就
    实现了在下单之前,添加一个验证码,限制部分流量的功能。

  2. 限并发 维护一个全局计数器,当请求进入接口时,计数器-1,并且判断
    计数器是否>0,大于0则处理请求,小于0则拒绝等待

  3. 令牌桶 客户端请求接口,必须先从令牌桶中获取令牌,令牌是由一个“定
    时器”定期填充的。在一个时间内,令牌的数量是有限的。令牌桶的大小为100
    ,那么TPS就为100,使用RateLimiter实现限流

分布式会话存储策略

  1. 分布式会话持久性管理
  • 安全性管理 用安全传输的https
  • 自定义协议
  1. 强登录态和与弱登录态
  • 强登录态
  • 无登录态
  • 弱登录态
  1. SSO单点登录
  • 同域名
  • 根域名相同子域名不同
  • 域名都不相同

mysql性能提升

  1. mysql应用性能优化扩展
  2. mysql单机配置性能优化扩展
  3. mysql分布式配置性能优化扩展

Nginx

Nginx是一个web服务器、反向代理服务器和动静分离服务器,用于HTTP、HTTPS
、SMTP、POP3和IMAP协议。Web服务器一般指网站服务器,是指驻留于因特网上
某种类型计算机的程序,可以处理浏览器等Web客户端的请求并返回相应响应。
反向代理服务器位于用户与目标服务器之间,但是对于用户而言,反向代理服
务器就相当于目标服务器,即用户直接访问反向代理服务器就可以获得目标服
务器的资源

  1. web服务器
  2. 反向代理服务器
  3. 动静分离服务器

Nginx 常用配置

1
2
3
4
5
6
worker_processes  8; # 工作进程个数
worker_connections 65535;
listen 80; # 监听端口
server_name rrc.test.jiedaibao.com; # 允许域名
root /data/release/rrc/web; # 项目根目录
index index.php index.html index.htm; # 访问根文件

正向代理和反向代理

在开发的时候,前端用前端的服务器(Nginx),后端用后端的服务器(Tomcat)
,当我开发前端内容的时候,可以把前端的请求通过前端服务器转发给后端(称
为反向代理)

  1. 正向代理 正向代理是一个位于客户端和原始服务器之间的服务器,为了从原
    始服务器获取数据,客户端向代理发送请求并指定目标(原始服务器),然后代
    理向原始服务器转交请求并将获得的内容返回给客户端。正向代理的过程隐藏了
    真实的请求客户端,服务端不知道真实的客户端是是谁
  2. 反向代理 隐藏了真实的服务端,对于客户端而言它就像是原始服务器,并且
    客户端不需要进行任何特别的设置,对于客户端而言它就像是原始服务器
  3. 正向代理代理的对象是客户端,反向代理代理的对象是服务端

Nginx 和Apache的区别

  1. 轻量级,同样是web 服务,Nginx 比 Apache 占用更少的内存及资源
  2. 抗并发,Nginx 处理请求是异步非阻塞的,而Apache 则是阻塞型的,在高并
    发下 Nginx 能保持低资源低消耗高性能
  3. 最核心的区别在于Apache 是同步多进程模型,一个连接对应一个进程;Nginx
    是异步的,多个连接(万级别)可以对应一个进程
  4. Nginx 高度模块化的设计,编写模块相对简单

Nginx 如何处理HTTP 请求

  1. Nginx 在启动时,会解析配置文件,得到需要监听的端口与IP 地址,然后在
    Nginx 的Master 进程里面先初始化好这个监控的Socket(创建Socket设置addr
    、reuse 等选项,绑定到指定的ip 地址端口,再listen 监听)
  2. 再fork(一个现有进程可以调用fork 函数创建一个新进程。由fork 创建的新
    进程被称为子进程)出多个子进程出来
  3. 子进程会竞争accept 新的连接。此时客户端就可以向nginx 发起连接了。当
    客户端与nginx进行三次握手,与nginx 建立好一个连接后。此时某一个子进程
    会accept 成功,得到这个建立好的连接的Socket ,然后创建nginx 对连接
    的封装,即ngx_connection_t 结构体
  4. 设置读写事件处理函数,并添加读写事件来与客户端进行数据的交换

Nginx 是如何实现高并发

如果一个server 采用一个进程(或者线程)负责一个request的方式,那么进程数
就是并发数。那么显而易见的,就是会有很多进程在等待中,也就是等待网络传输
。Nginx 的异步非阻塞工作方式正是利用了这点等待的时间。在需要等待的时候,
这些进程就空闲出来待命了。
Nginx 每进来一个request ,会有一个worker 进程去处理。但不是全程的处理,
处理到什么程度呢?处理到可能发生阻塞的地方,比如向上游(后端)服务器转发
request,并等待请求返回。那么这个处理的worker 不会这么傻等着,他会在发
送完请求后,注册一个事件:“如果upstream 返回了,告诉我一声,我再接着干”
。于是他就
休息去了。此时,如果再有 request 进来,他就可以很快再按这种
方式处理。而一旦上游服务器返回
了,就会触发这个事件,worker 才会来接手,这个 request 才会接着往下走。

epoll多路复用

在了解epoll多路复用之前,先看看Java BIO模型,也就是Blocking IO阻塞
模型。当客户端与服务器建立连接之后,通过Socket.write()向服务器发送数
据,只有当数据写完到缓冲区之后才会发送。如果当Socket缓冲区满了,那就
不得不阻塞等待。
Linux Select模型。该模式下会监听一定数量的客户端连接,一旦发现有变动
,就会唤醒自己,然后遍历这些连接,看哪些连接发生了变化,执行IO操作。
相比阻塞式的BIO,效率更高,但是也有个问题,如果10000个连接变动了1个
,那么效率将会十分低下。此外Java NIO就借鉴了Linux Select模型。理论
上最多连接数1024
而epoll模型,在Linux Select模型之上新增了回调函数,一旦某个连接发生
变化,直接执行回调函数不用遍历,效率更高

master worker进程模型

通过ps -ef|grep nginx命令可以看到有两个Nginx进程,一个标注为master
,一个标注为worker,而且worker进程是master进程的子进程。这种父子关系
的好处就是,master进程可以管理worker进程。
客户端的请求,并不会被master进程处理,而是交给下面的worker进程来处理
,多个worker 进程通过“抢占”的方式,取得处理权。如果某个worker 挂了,
master会立刻感知到,用一个新的worker代替。这就是Nginx高效率的原因之
一,也是可以平滑重启的原理。
此外worker进程是单线程的,没有阻塞的情况下,效率很高。而epoll模型避免
了阻塞。综上epoll机制+master-worker机制使得worker进程可以高效率地执
行单线程I/O操作

协程机制

Nginx引入了一种比线程更小的概念,那就是“协程”。协程依附于内存模型,切
换开销更小;遇到阻塞Nginx 会立刻剥夺执行权;由于在同一个线程内,也不
需要加锁。
nginx的每个Worker进程都是在epoll或kqueue这种事件模型之上封装成协程。
每一个请求都有一个协程进行处理,即使ngx_lua需要运行lua,相对C有一定
开销,但依旧能保证高并发能力。
lua代码调用io等异步接口时,协程被挂起,切换上下文数据。io异步操作完成
后还原协程上下文,代码继续执行。
nginx lua的常用插载点

  1. init_by_lua 系统启动时调用
  2. init_worker_by_lua worker进程启动时调用
  3. set_by_lua nginx变量用复杂lua return
  4. rewrite_by_lua 重写url规则
  5. access_by_lua 权限验证阶段
  6. content_by_lua 内存输出节点

动静分离

动态资源、静态资源分离,是让动态网站里的动态网页根据一定规则把不变的资
源和经常变的资源区分开来,动静资源做好了拆分以后我们就可以根据静态资
源的特点将其做缓存操作,这就是网站静态化处理的核心思路。
有些请求是需要后台处理的(如:.jsp,.do 等等),有些请求是不需要经过后
台处理的(如: css、html、jpg、js 等等文件),这些不需要经过后台处理的
文件称为静态文件,否则动态文件。使用这种动静分离的策略去解决动、静分离
将网站静态资源(HTML,JavaScript,CSS,img等文件)与后台应用分开部
署,提高用户访问静态代码的速度,降低对后台应用访问。

负载均衡策略

负载均衡,即是代理服务器将接收的请求均衡的分发到各服务器中

  1. 轮询
    每个请求按时间顺序逐一分配到不同的后端服务器,如果后端服务器down 掉,
    能自动剔除
  2. IP哈希
    每个请求按访问ip 的hash 结果分配,这样每个访客固定访问一个后端服务器
  3. 最少连接
    下一个请求将被分派到活动连接数量最少的服务器

页面静态化

CDN是内容分发网络,一般用来存储(缓存)项目的静态资源。访问静态资源,会
从离用户最近的CDN服务器上返回静态资源。如果该CDN服务器上没有静态资源,
则会执行回源操作,从Nginx服务器上获取静态资源

CDN的使用

  1. 购买一个CDN服务器,选择要加速的域名(比如miaoshaserver.jiasu.com)
    ,同时要填写源站IP,也就是Nginx服务器,便于回源操作
  2. 接下来要配置miaoshaserver.jiasu.com的DNS解析规则。一般的解析规则是
    A记录类型,也就是把一个域名直接解析成IP地址。这里使用CNAME进行解析,将
    一个域名解析到另外一个域名。而这个”另一个域名“是云服务器厂商提供的,它
    会把请求解析到相应的CDN服务器上
  3. 访问miaoshaserver.jiasu.com/resources/getitem.html?id=1即可以
    CDN的方式访问静态资源

cache controll响应头

在响应里面有一个cache controll响应头,这个响应头表示客户端是否可以缓存
响应

  1. private 客户端可以缓存
  2. public 客户端和代理服务器(中间层的节点)都可以缓存
  3. max-age=xxx 缓存的内容将在xxx秒后失效
  4. no-cache 也会缓存,但是使用缓存之前会询问服务器,该缓存是否可用
  5. no-store 不缓存任何响应内容

有效性验证

ETag:第一次请求资源的时候,服务器会根据资源内容生成一个唯一标示ETag,
并返回给浏览器。浏览器下一次请求,会把ETag(If-None-Match)发送给服务
器,与服务器的ETag进行对比。如果一致,就返回一个304响应,即Not Modify
,表示浏览器缓存的资源文件依然是可用的,直接使用就行了,不用重新请求

浏览器三种刷新方式

  1. a标签/回车刷新
    查看max-age是否有效,有效直接从缓存中获取,无效进入缓存协商逻辑
  2. F5刷新
    取消max-age或者将其设置为0,直接进入缓存协商逻辑
  3. CTRL+F5强制刷新
    直接去掉cache-control和协商头,重新请求资源

自定义缓存策略

CDN服务器,既充当了浏览器的服务端,又充当了Nginx的客户端。所以它的缓存策
略尤其重要。除了按照服务器的max-age,CDN服务器还可以自己设置过期时间。
总的规则就是:源站没有配置,遵从CDN控制台的配置;CDN控制台没有配置,遵从
服务器提供商的默认配置。源站有配置,CDN控制台有配置,遵从CDN控制台的配置
;CDN控制台没有配置,遵从源站配置

静态资源部署策略

假如服务器端的静态资源更新了,但是由于客户端的max-age还未失效,用的
还是老的资源,文件名又一样,用户不得不使用CTRL+F5强制刷新,才能请求
更新的静态资源。解决方法

  1. 版本号:在静态资源文件后面追加一个版本号,比如a.js?v=1.0。这种方法
    维护起来十分麻烦,比如只有一个js文件做了修改,那其它html、css文件要不
    要追加版本号呢?
  2. 摘要:对静态资源的内容进行哈希操作,得到一个摘要,比如a.js?v=45edw
    ,维护起来更加方便。但是会导致是先部署js 还是先部署html的问题。比如先部
    署js,那么html 页面引用的还是老的js,js直接失效;如果先部署html,那么
    引用的js又是老版本的js
  3. 摘要作为文件名:比如45edw.js,会同时存在新老两个版本,方便回滚

全页面静态化

现在的架构是,用户通过CDN请求到了静态资源,然后静态页面会在加载的时候,
发送一个Ajax请求到后端,接收到后端的响应后,再用DOM渲染。也就是每一个用
户请求,都有一个请求后端接口并渲染的过程。那能不能取消这个过程,直接在服
务器端把页面渲染好,返回一个纯html文件给客户端呢?

  1. phantomJS实现全页面静态化
    phantomJS就像一个爬虫,会把页面中的JS执行完毕后,返回一个渲染完成的html
    文件

RocketMQ

RocketMQ是阿里巴巴在RabbitMQ基础上改进的一个高性能、高并发、分布式消息
中间件。典型应用场景:分布式事务,异步解耦

核心概念

  1. 生产者Producer
    负责生产消息,一般由业务系统负责生产消息。一个消息生产者会把业务应用系统里
    产生的消息发送到broker服务器。RocketMQ提供多种发送方式,同步发送、异步发
    送、顺序发送、单向发送。同步和异步方式均需要Broker返回确认信息,单向发送
    不需要
  2. 消费者Consumer
    负责消费消息,一般是后台系统负责异步消费。一个消息消费者会从Broker服务器拉
    取消息、并将其提供给应用程序。从用户应用的角度而言提供了两种消费形式:拉取
    式消费、推动式消费(主动,被动)
  3. 代理服务器Broker Server
    消息中转角色,负责存储消息、转发消息。代理服务器在RocketMQ系统中负责接收
    从生产者发送来的消息并存储、同时为消费者的拉取请求作准备。代理服务器也存
    储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等。(主要
    操作消息组件)
  4. 消息主题Topic
    表示一类消息的集合,每个主题包含若干条消息,每条消息只能属于一个主题,是
    RocketMQ进行消息订阅的基本单位
  5. 消息队列MessageQueue
    对于每个Topic都可以设置一定数量的消息队列用来进行数据的读取
  6. 名字服务Name Server
    名称服务充当路由消息的提供者。生产者或消费者能够通过名字服务查找各主题
    相应的Broker IP列表。多个Namesrv实例组成集群,但相互独立,没有信息交
    换(绑定IP,匹配IP)
  7. 标签Tag
    为消息设置的标志,用于同一主题下区分不同类型的消息。来自同一业务单元的
    消息,可以根据不同业务目的在同一主题下设置不同标签。标签能够有效地保持
    代码的清晰度和连贯性,并优化RocketMQ提供的查询系统。消费者可以根据Tag
    实现对不同子主题的不同消费逻辑,实现更好的扩展性

消息队列的优势

  1. 削峰填谷(主要解决瞬时写压力大于应用服务能力导致消息丢失、系统奔溃
    等问题)
  2. 系统解耦(解决不同重要程度、不同能力级别系统之间依赖导致一死全死)
  3. 提升性能(当存在一对多调用时,可以发一条消息给消息系统,让消息系统
    通知相关系统)
  4. 蓄流压测(线上有些链路不好压测,可以通过堆积一定量消息再放开来压测)

RocketMQ的优势

  1. 支持事务型消息(消息发送和DB操作保持两方的最终一致性,rabbitmq和
    kafka不支持)
  2. 支持结合rocketmq的多个系统之间数据最终一致性(多方事务,二方事务是
    前提)
  3. 支持18个级别的延迟消息(rabbitmq和kafka不支持)
  4. 支持指定次数和时间间隔的失败消息重发(kafka不支持,rabbitmq需要手
    动确认)
  5. 支持consumer端tag过滤,减少不必要的网络传输(rabbitmq和kafka不支持)
  6. 支持重复消费(rabbitmq不支持,kafka支持)

Redis与MySQL双写一致性

一致性

  1. 强一致性:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出
    来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
  2. 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写
    入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级
    别(比如秒级别)后,数据能够达到一致状态
  3. 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,
    能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是
    弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致
    性上比较推崇的模型

集中式redis缓存的三个经典的缓存模式

  1. 旁路缓存模式
    它的提出是为了尽可能地解决缓存与数据库的数据不一致问题,读的时候,先读缓
    存,缓存命中的话,直接返回数据;缓存没有命中的话,就去读数据库,从数据库
    取出数据,放入缓存后,同时返回响应。更新的时候,先更新数据库,然后再删除
    缓存。
  2. 读写穿透
    服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过抽象缓存
    层完成的。从缓存读取数据,读到直接返回如果读取不到的话,从数据库加载,写
    入缓存后,再返回响应。Read-Through实际只是在Cache-Aside之上进行了一层
    封装,它会让程序代码变得更简洁,同时也减少数据源上的负载。当发生写请求时
    ,也是由缓存抽象层完成数据源和缓存数据的更新
  3. 异步缓存写入
    只更新缓存,不直接更新数据库,通过批量异步的方式来更新数据库。这种方式下
    ,缓存和数据库的一致性不强,对一致性要求高的系统要谨慎使用。但是它适合频
    繁写的场景,MySQL的InnoDB Buffer Pool机制就使用到这种模式。

旁路缓存模式的问题

更新数据的时候,Cache-Aside是删除缓存呢,还是应该更新缓存?
比如有两个线程A和B,线程A先发起一个写操作,第一步先更新数据库,线程B再发
起一个写操作,第二步更新了数据库。现在由于网络等原因,线程B先更新了缓存,
线程A更新缓存。
这时候,缓存保存的是A的数据(老数据),数据库保存的是B的数据(新数据),
数据不一致了,脏数据出现啦。如果是删除缓存取代更新缓存则不会出现这个脏数
据问题。更新缓存相对于删除缓存,还有两点劣势:

  1. 如果你写入的缓存值,是经过复杂计算才得到的话。 更新缓存频率高的话,就
    浪费性能啦。
  2. 在写多读少的情况下,数据很多时候还没被读取到,又被更新了,这也浪费了
    性能呢(实际上,写多的场景,用缓存也不是很划算了)

双写的情况下,先操作数据库还是先操作缓存

Cache-Aside缓存模式中,在写入请求的时候,为什么是先操作数据库呢?为什
么不先操作缓存呢?假设有A、B两个请求,请求A做更新操作,请求B做查询读取
操作。A、B两个请求的操作流程如下:

  1. 线程A发起一个写操作,第一步del cache
  2. 此时线程B发起一个读操作,cache miss
  3. 线程B继续读DB,读出来一个老数据
  4. 然后线程B把老数据设置入cache
  5. 线程A写入DB最新的数据

缓存和数据库的数据不一致了,缓存保存的是老数据,数据库保存的是新数据。
因此Cache-Aside缓存模式,选择了先操作数据库而不是先操作缓存。

redis分布式缓存与数据库的数据一致性

缓存是通过牺牲强一致性来提高性能的。如果需要数据库和缓存数据保持强一致
,就不适合使用缓存。所以使用缓存提升性能,就是会有数据更新的延迟。这需
要我们在设计时结合业务仔细思考是否适合用缓存。然后缓存一定要设置过期
时间,这个时间太短、或者太长都不好:

  1. 太短的话请求可能会比较多的落到数据库上,这也意味着失去了缓存的优势
  2. 太长的话缓存中的脏数据会使系统长时间处于一个延迟的状态,而且系统中
    长时间没有人访问的数据一直存在内存中不过期,浪费内存。

3种方案保证数据库与缓存的一致性

  1. 延时双删策略
  2. 删除缓存重试机制
  3. 读取biglog异步删除缓存

缓存延时双删

  1. 先删除缓存
  2. 再更新数据库
  3. 休眠一会(比如1秒),再次删除缓存。 这个休眠时间 = 读业务逻辑数据的耗
    时 + 几百毫秒。

为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。

删除缓存重试机制

不管是延时双删还是Cache-Aside的先操作数据库再删除缓存,如果第二步的删除
缓存失败呢?删除失败会导致脏数据,删除失败就多删除几次,保证删除缓存成功
,所以可以引入删除缓存重试机制

  1. 写请求更新数据库
  2. 缓存因为某些原因,删除失败
  3. 把删除失败的key放到消息队列
  4. 消费消息队列的消息,获取要删除的key
  5. 重试删除缓存操作

同步biglog异步删除缓存

重试删除缓存机制还可以,就是会造成好多业务代码入侵。其实还可以通过数据库
的binlog来异步淘汰key。
以mysql为例 可以使用阿里的canal将binlog日志采集发送到MQ队列里面,然后
编写一个简单的缓存删除消息者订阅binlog日志,根据更新log删除缓存,并且
通过ACK机制确认处理这条更新log,保证数据缓存一致性。

L2级缓存与数据库的数据一致性

三级缓存与数据一致性

秒杀超卖解决

Author: 高明
Link: https://skysea-gaoming.github.io/2021/05/21/%E9%A1%B9%E7%9B%AE%E6%80%BB%E7%BB%93/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.