📕我是廖志伟,一名Java开发工程师、Java领域优质创作者、CSDN博客专家、51CTO专家博主、阿里云专家博主、清华大学出版社签约作者、产品软文创造者、技术文章评审老师、问卷调查设计师、个人社区创始人、开源项目贡献者。
🌎跑过十五公里、徒步爬过衡山、🔥有过三个月减肥20斤的经历、是个喜欢躺平的狠人。

📘拥有多年一线研发和团队管理经验,研究过主流框架的底层源码(Spring、SpringBoot、Spring MVC、SpringCould、Mybatis、Dubbo、Zookeeper),消息中间件底层架构原理(RabbitMQ、RockerMQ、Kafka)、Redis缓存、MySQL关系型数据库、 ElasticSearch全文搜索、MongoDB非关系型数据库、Apache ShardingSphere分库分表读写分离、设计模式、领域驱动DDD、Kubernetes容器编排等。🎥有从0到1的高并发项目经验,利用弹性伸缩、负载均衡、报警任务、自启动脚本,最高压测过200台机器,有着丰富的项目调优经验。

📙经过多年在CSDN创作上千篇文章的经验积累,我已经拥有了不错的写作技巧。同时,我还与清华大学出版社签下了四本书籍的合约,并将陆续在明年出版。这些书籍包括了基础篇、进阶篇、架构篇的📌《Java项目实战—深入理解大型互联网企业通用技术》📌,以及📚《解密程序员的思维密码--沟通、演讲、思考的实践》📚。具体出版计划会根据实际情况进行调整,希望各位读者朋友能够多多支持!


 正文开始~~~

本文内容过长,建议收藏,通过本文的学习,你可以知道:

  • HashMap底层实现原理?HashMap加载因子为什么是0.75?HashMap扩容操作可能会出现的问题?HashMap并发修改异常解决方案?什么是CAS和volatile?什么是快速失败?什么是安全失败?
  • 什么是Spring IOC,Spring AOP?应用场景有哪些?数据库事务隔离级别,数据库的四大属性底层实现原理、Spring如何实现事务、传播行为?
  • CAP,BASE理论,分布式事务的四种解决方案?
  • Spring Bean的作用域和生命周期
  • 设计模式
  • 线程池实现原理,七大核心参数,如何合理的配置核心线程数?拒绝策略?JUC并发包:信号灯,循环栅栏,倒计时器
  • Redis缓存数据类型的应用场景以及底层数据结构,数据同步问题(双删策略),缓存雪崩,缓存穿透,缓存失效,高并发场景下的分布式锁,热点缓存,哨兵机制,持久化(主从)
  • 消息丢失,消息重复消费,消息顺序性,大规模消息积压发生的场景和解决方案,几种消息队列的区别以及选型
  • Dubbo底层运行原理,特性,支持的协议,容错策略,负载均衡策略,Zookeeper底层原理,选举机制,假死以及解决方案,ZooKeeper典型使用场景,Spring MVC工作原理,Mybatis框架优点
  • JVM算法,垃圾收集器,垃圾回收机制,JMM和JVM内存模型,JVM调优,双亲委派机制,堆溢出,栈溢出,方法区溢出,你都有哪些手段用来排查内存溢出?
  • SpringCould组件说七八个
  • MySQL优化,索引限制条件
  • 公平锁,非公平锁,可重入锁,递归锁,自旋锁,读写锁,悲观锁,乐观锁,行锁,排它锁,共享锁,表锁,死锁,分布式锁,AQS,Synchronized
  • 幂等性实现,单点登录,金额篡改问题,秒杀场景设计,库存超卖问题
  • Linux常用命令,生产环境服务器变慢诊断,线上排查,性能评估

特别说明:本文薪资目标为22k,别纠结于薪资能不能到22k,在到达22k之前,这些博文里的专业技能方面都要懂吧,如果连这些基础的东西都不懂,你到了22k,估计也是很水的存在了。除此之外,拿22薪你还需要具备,能独立完成一个复杂模块的需求分析、方案设计和最终落地实现,寻找更优的设计和解决方案,积极优化慢 SQL、慢服务,具备排查问题的能力,遇到线上问题能及时定位和修复上线,例如:数据库死锁、服务器宕机、服务器 Full GC 频繁等。合理分配需求,做好进度把控、风险评估、Code Review。

我这里提供的面试博文是面向大部分未到高级开发的工程师,给予专业技能方面的讲解,薪资水平每个地方每个时间段都不一样,里面的知识点很多人都知道,但面试就是讲不出来,一些简单常见且比较通用,高频的讲出来深度也不够,高级开发的专业技能更多的是个人平时的努力,专研的深度,我不可能一篇博文全部写完,也不现实。

文章还有部分问题没写后期另外出一篇博文,写Netty:NIO 原理、核心组件、I/O 多路复用(epoll)、零拷贝。网络:TCP、HTTP、HTTPS、负载均衡算法。数据库:分库分表、慢 SQL 定位及优化。消息中间件:Kafka、RocketMQ、RabbitMQ、ActiveMQ。搜索引擎:ES。

对于中级的朋友们也可以看我之前写的博文:面试:第十六章:Java中级开发(访问量十万了哟)

对于初级的朋友们也可以看我的专栏:面试第一章到第十二章

这里推荐二篇文章,个人觉得写的还挺不错的,里面大部分的问题在我的博文中也可以找到对应的答案,这里给上地址,点击:4 年 Java 经验,阿里网易拼多多面试总结、心得体会如何准备好一场大厂面试

目录

HashMap底层实现原理?HashMap加载因子为什么是0.75?HashMap扩容操作可能会出现的问题?HashMap并发修改异常解决方案?什么是CAS和volatile?什么是快速失败?什么是安全失败?

HashMap底层实现原理?

HashMap加载因子为什么是0.75?

扩容操作可能会出现的问题?

并发修改异常解决方案?

什么是CAS和volatile?

什么是快速失败?

什么是安全失败?

什么是Spring IOC,Spring AOP?应用场景有哪些?数据库事务隔离级别,数据库的四大属性、Spring如何实现事务、传播行为

AOP:面向切面编程

IOC:依赖注入或者叫做控制反转

数据库事务隔离级别

属性(特性) 底层实现原理

spring事务的传播行为

CAP,BASE理论,分布式事务的四种解决方案

CAP

BASE

两阶段提交(2PC)

3PC

补偿事务(TCC)

本地消息表(异步确保)

MQ 事务消息

最大努力通知

Spring Bean的作用域和生命周期

设计模式

线程池实现原理,七大核心参数,如何合理的配置核心线程数?拒绝策略?JUC并发包:信号灯,循环栅栏,倒计时器

线程池实现原理

七大核心参数

如何合理的配置核心线程数?

拒绝策略

JUC并发包:CountDownLatch倒计时器

JUC并发包:Semaphore信号灯:

JUC并发包:CyclicBarrier循环栅栏:

Redis缓存数据类型的应用场景以及底层数据结构,数据同步问题(双删策略),缓存雪崩,缓存穿透,缓存失效,高并发场景下的分布式锁,热点缓存,哨兵机制,持久化(主从)

大数据类型应用场景

底层数据结构

refcount

数据同步问题(双删策略)三种更新策略

先更新数据库,再更新缓存;

先删除缓存,再更新数据库;

先更新数据库,再删除缓存;

缓存雪崩

缓存穿透

缓存击穿

高并发场景下的分布式锁

热点数据缓存

哨兵机制

持久化

RDB

AOF

redis主从架构持久化

主从架构下的数据部分复制(断点续传)

消息丢失,消息重复消费,消息顺序性,大规模消息积压发生的场景和解决方案,几种消息队列的区别以及选型

消息丢失

生产者弄丢了数据

RabbitMQ 弄丢了数据

消费端弄丢了数据

消息重复消费

保证生产者等幂性

保证消费者等幂性

消息顺序性

消息积压

消息队列的消息过期失效了

消息队列满了

关于mq选用

Dubbo底层运行原理,特性,支持的协议,容错策略,负载均衡策略,Zookeeper底层原理,选举机制,假死以及解决方案,ZooKeeper典型使用场景,Spring MVC工作原理,Mybatis框架优点

工作流程

服务注册与发现

dubbo的特性

协议

Dubbo协议:dubbo 缺省协议 采用单一长连接和NIO异步通讯,适合于小数据量大并发的服务调用,以及服务消费者机器数远大于服务提供者机器数的情况,不适合传送大数据量的服务,比如传文件,传视频等,除非请求量很低。

rmi 协议:RMI协议采用JDK标准的java.rmi.*实现,采用阻塞式短连接和JDK标准序列化方式 。

http 协议:基于http表单的远程调用协议

容错

负载均衡

zookeeper是什么?

zookeeper工作原理

zookeeper集群数量为什么是单数?

领导者选举

假死

ZooKeeper典型使用场景一览

SpringMVC运行原理

Mybaits 的优点

MyBatis 框架的缺点

JVM算法,垃圾收集器,垃圾回收机制,JMM和JVM内存模型,JVM调优,双亲委派机制,堆溢出,栈溢出,方法区溢出,你都有哪些手段用来排查内存溢出?

JVM算法

垃圾回收器

Serial收集器

ParNew收集器

Parallel收集器

Parallel Old 收集器

CMS 收集器

G1 收集器

zgc收集器

java垃圾回收机制

内存区域与回收策略

对象分配过程

JVM内存模型

程序计数器(PC)

java虚拟机栈

本地方法栈

java堆

方法区

JMM

在JVM内部使用的java内存模型(JMM)将线程堆栈和堆之间的内存分开线程堆栈(thread stack):

堆:

JVM调优

双亲委派模型

内存溢出

堆溢出

栈溢出

方法区溢出:(偷懒溢出常量池)

JVM 堆内存溢出后,其他线程是否可继续工作?

什么是溢出和泄漏?

你都有哪些手段用来排查内存溢出?

栈溢出

SpringCould组件说七八个

Eureka

Feign

Ribbon

Hystrix

Zuul网关

config配置中心

Bus数据总线

Sleuth

MySQL优化,索引限制条件

公平锁,非公平锁,可重入锁,递归锁,自旋锁,读写锁,悲观锁,乐观锁,行锁,排它锁,共享锁,表锁,死锁,分布式锁,AQS,Synchronized

幂等性实现,单点登录,金额篡改问题,秒杀场景设计,库存超卖问题

幂等性实现

单点登录

金额篡改问题

秒杀场景设计

库存超卖

悲观锁

分布式锁

乐观锁

Linux常用命令,生产环境服务器变慢诊断,线上排查,性能评估

面试演练

一、开场白

二、Java多线程

三、JVM相关

四、Java扩展篇

五、Spring相关

六、中间件篇

七、数据库篇

八、Redis


HashMap底层实现原理?HashMap加载因子为什么是0.75?HashMap扩容操作可能会出现的问题?HashMap并发修改异常解决方案?什么是CAS和volatile?什么是快速失败?什么是安全失败?

HashMap底层实现原理?

HashMap是Map的一个实现类,它是以键值对存储数据的,Key-Value都是Map.Entry中的属性。当我们向HashMap中存放一个元素(k1,v1),先根据k1的hashCode方法来决定在数组中存放的位置。如果这个位置没有其它元素,将(k1,v1)直接放入一个Node类型的数组中,当元素加到12的时候,底层会进行扩容,扩容为原来的2倍。如果该位置已经有其它元素(k2,v2),那就调用k1的equals方法和k2进行比较二个元素是否相同,如果结果为true,说明二个元素是一样的,用v1替换v2,如果返回值为false,二个元素不一样,就用链表的形式将(k1,v1)存放。不过当链表中的数据较多时,查询的效率会下降,所以在JDK1.8版本后做了一个升级,HashMap存储数据结构链表长度超过8且数组长度大于64时数据结构,会将链表替换成红黑树才会树化时,会将链表替换成红黑树,来提高查找效率。因为对于搜索,插入,删除操作多的情况下,使用红黑树的效率要高一些。因为红黑树是一种特殊的二叉查找树,二叉查找树所有节点的左子树都小于该节点,所有节点的右子树都大于该节点,就可以通过大小比较关系来进行快速的检索。在红黑树上插入或者删除一个节点之后,红黑树就发生了变化,但它不再是一颗红黑树时,可以通过左旋和右旋,保证每次插入最多只需要三次旋转就能达到平衡,因为红黑树强制约束了从根到叶子的最长的路径不多于最短的路径的两倍长,插入、删除和查找某个值的最坏情况时间都要求与树的高度成比例,这个在高度上的理论上限允许红黑树在最坏情况下都是高效的。

HashMap加载因子为什么是0.75?

如果加载因子比较大,扩容发生的频率比较低,浪费的空间比较小,发生hash冲突的几率比较大。比如,加载因子是1的时候,hashmap长度为128,实际存储元素的数量在64至128之间时间段比较多,这个时间段发生hash冲突比较多,造成数组中其中一条链表比较长,会影响性能。

如果加载因子比较小,扩容发生的频率比较高,浪费的空间比较多,发生hash冲突的几率比较小。比如,加载因子是0.5的时候,hashmap长度为128,当数量达到65的时候会触发扩容,扩容后为原理的256,256里面只存储了65个浪费了。

综合了一下,取了一个平均数0.75作为加载因子。当负载因子为0.75,时代入到泊松分布公式,计算出来长度为8时,概率=0.00000006,概率很小了,链表长度为8时转红黑树。

扩容操作可能会出现的问题?

HashMap实际使用过程中会出现一些线程安全问题,在JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况,开多个线程不断进行put操作,rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置(就是因为头插) 所以最后的结果打乱了插入的顺序,就可能发生环形链和数据丢失的问题,引起死循环,导致CPU利用率接近100%。在jdk1.8中对HashMap进行了优化,发生hash碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况,但是在多线程环境下,会发生数据覆盖的情况,如果没有hash碰撞的时候,它会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,线程A会把线程B插入的数据给覆盖,导致数据发生覆盖的情况,发生线程不安全。实际的故障:java.util.ConcurrentModificationException并发修改异常。导致原因:并发争取修改导致,一个线程正在写,一个线程过来争抢,导致线程写的过程被其他线程打断,导致数据不一致。

并发修改异常解决方案?

第一种解决方案:使用HashTable:

HashTable是线程安全的,只不过实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁。多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。

第二种解决方案:使用工具类,线程同步:Map<String,String> hashMap = Collections.synchronizedMap(new HashMap<>());

和Hashtable一样,实现上在操作HashMap时自动添加了synchronized来实现线程同步,都对整个map进行同步,在性能以及安全性方面不如ConcurrentHashMap。

第三种解决方案:使用写时复制:CopyOnWrite:往一个容器里面加元素的时候,不直接往当前容器添加,而是先将当前容器的元素复制出来放到一个新的容器中,然后新的元素添加元素,添加完之后,再将原来容器的引用指向新的容器,这样就可以对它进行并发的读,不需要加锁,因为当前容器不添加任何元素。利用了读写分离的思想,读和写是不同的容器。

会有内存占用问题,在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存。

会有数据一致性问题,CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。

第四种解决方案:使用ConcurrentHashMap:

为了应对hashmap在并发环境下不安全问题可以使用,ConcurrentHashMap大量的利用了volatile,CAS等技术来减少锁竞争对于性能的影响。在JDK1.7版本中ConcurrentHashMap避免了对全局加锁,改成了局部加锁(分段锁),分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。不过这种结构的带来的副作用是Hash的过程要比普通的HashMap要长。

所以在JDK1.8版本中CurrentHashMap内部中的value使用volatile修饰,保证并发的可见性以及禁止指令重排,只不过volatile不保证原子性,使用为了确保原子性,采用CAS(比较交换)这种乐观锁来解决。

什么是CAS和volatile?

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

volatile有三个特性:可见性,不保证原子性,禁止指令重排。

可见性:线程1从主内存中拿数据1到自己的线程工作空间进行操作(假设是加1)这个时候数据1已经改为数据2了,将数据2写回主内存时通知其他线程(线程2,线程3),主内存中的数据1已改为数据2了,让其他线程重新拿新的数据(数据2)。

不保证原子性:线程1从主内存中拿了一个值为1的数据到自己的工作空间里面进行加1的操作,值变为2,写回主内存,然后还没有来得及通知其他线程,线程1就被线程2抢占了,CPU分配,线程1被挂起,线程2还是拿着原来主内存中的数据值为1进行加1,值变成2,写回主内存,将主内存值为2的替换成2,这时线程1的通知到了,线程2重新去主内存拿值为2的数据。

禁止指令重排:首先指令重排是程序执行的时候不总是从上往下执行的,就像高考答题,可以先做容易的题目再做难的,这时做题的顺序就不是从上往下了。禁止指令重排就杜绝了这种情况。

什么是快速失败?

在使用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行修改(增加,删除,修改),则会抛出并发修改异常。迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个modCount变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检查modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。这里异常的抛出条件是检测到modCount!=expectedmodCount这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)

什么是安全失败?

采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,使用不会触发并发修改异常。拷贝内容的优点是避免了并发修改异常,迭代器并不能访问到修改后的内容。(迭代器遍历是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的)concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。

什么是Spring IOC,Spring AOP?应用场景有哪些?数据库事务隔离级别,数据库的四大属性、Spring如何实现事务、传播行为

AOP:面向切面编程

即在一个功能模块中新增其他功能,比方说你要下楼取个快递,你同事对你说帮我也取一下呗,你就顺道取了。在工作中如果系统中有些包和类中没有使用AOP,例如日志,事务和异常处理,那么就必须在每个类和方法中去实现它们。 代码纠缠每个类和方法中都包含日志,事务以及异常处理甚至是业务逻辑。在一个这样的方法中,很难分清代码中实际做的是什么处理。AOP 所做的就是将所有散落各处的事务代码集中到一个事务切面中。

场景

比方说我现在要弄一个日志,记录某些个接口调用的方法时间。使用Aop我可以在这个接口前插入一段代码去记录开始时间,在这个接口后面去插入一段代码记录结束时间。

又或者你去访问数据库,而你不想管事务(太烦),所以,Spring在你访问数据库之前,自动帮你开启事务,当你访问数据库结束之后,自动帮你提交/回滚事务!

异常处理你可以开启环绕通知,一旦运行接口报错,环绕通知捕获异常跳转异常处理页面。

动态代理

Spring AOP使用的动态代理,所谓的动态代理就是说AOP框架不会去修改字节码,而是在内存中临时为方法生成一个AOP对象,这个AOP对象包含了目标对象的全部方法,并且在特定的切点做了增强处理,并回调原对象的方法。它的动态代理主要有两种方式,JDK动态代理和CGLIB动态代理。JDK动态代理通过反射来接收被代理的类,并且要求被代理的类必须实现一个接口。JDK动态代理的核心是InvocationHandler接口和Proxy类。如果目标类没有实现接口,那么Spring AOP会选择使用CGLIB来动态代理目标类。CGLIB是一个代码生成的类库,可以在运行时动态的生成某个类的子类,注意,CGLIB是通过继承的方式做的动态代理,因此如果某个类被标记为final,那么它是无法使用CGLIB做动态代理的。

IOC:依赖注入或者叫做控制反转

正常情况下我们使用一个对象时都是需要new Object()的。而ioc是把需要使用的对象提前创建好,放到spring的容器里面。

所有需要使用的类都会在spring容器中登记,告诉spring你是个什么东西,你需要什么东西,然后spring会在系统运行到适当的时候,把你要的东西主动给你,同时也把你交给其他需要你的东西。所有的类的创建、销毁都由 spring来控制,也就是说控制对象生存周期的不再是引用它的对象,而是spring。DI(依赖注入)其实就是IOC的一种实现方式。

场景:

正常情况下我们使用一个对象时都是需要new Object() 的。而ioc是把需要使用的对象提前创建好,放到spring的容器里面。需要使用的时候直接使用就行,而且可以设置单例或多例,非常灵活。

我们在service层想调用另外一个service的方法,不需要去new了,直接把它交给spring管理,然后用注解的方式引入就能使用。

IOC三种注入方式

(1)XML:Bean实现类来自第三方类库,例如DataSource等。需要命名空间等配置,例如:context,aop,mvc。

(2)注解:在开发的类使用@Controller,@Service等注解

(3)Java配置类:通过代码控制对象创建逻辑的场景。例如:自定义修改依赖类库。

@Resource,@Autowired,@Qualifier区别?

@resource:默认是按照名称来装配注入的,只有在找不到名称匹配bean的时候才会按照类型来注入

@Autowired:默认是按照类型进行装配注入,默认情况下,它要求依赖对象必须存在,如果允许null值,可以设置它required为false

如果我们想要按照名称进行装配的话,可以添加一个@Qualifier注解解决。

@Autowired

@Qualifier(“loginService”)

private LoginService loginService;

@resource注解式J2EE提供的,@Autowired是Spring提供的,如果想要减少对Spring的依赖建议使用@resource注解。

数据库事务隔离级别

Read uncommitted 读未提交
公司发工资了,领导把20000元打到廖志伟的账号上,但是该事务并未提交,而廖志伟正好去查看账户,发现工资已经到账,是20000元整,非常高兴。可是不幸的是,领导发现发给廖志伟的工资金额不对,是16000元,于是迅速修改金额,将事务提交,最后廖志伟实际的工资只有16000元,廖志伟空欢喜一场。

出现上述情况,即我们所说的脏读,两个并发的事务,“事务A:领导给廖志伟发工资”、“事务B:廖志伟查询工资账户”,事务B读取了事务A尚未提交的数据。当隔离级别设置为Read uncommitted时,就可能出现脏读,如何避免脏读,请看下一个隔离级别。

Read committed 读提交
廖志伟拿着工资卡去消费,系统读取到卡里确实有2000元,而此时她的老婆也正好在网上转账,把廖志伟工资卡的2000元转到另一账户,并在廖志伟之前提交了事务,当廖志伟扣款时,系统检查到廖志伟的工资卡已经没有钱,扣款失败,廖志伟十分纳闷,明明卡里有钱,为何…

出现上述情况,即我们所说的不可重复读,两个并发的事务,“事务A:廖志伟消费”、“事务B:廖志伟的老婆网上转账”,事务A事先读取了数据,事务B紧接了更新了数据,并提交了事务,而事务A再次读取该数据时,数据已经发生了改变。当隔离级别设置为Read committed时,避免了脏读,但是可能会造成不可重复读。大多数数据库的默认级别就是Read committed,比如Sql Server , Oracle。如何解决不可重复读这一问题,请看下一个隔离级别。

Repeatable read 重复读
当廖志伟拿着工资卡去消费时,一旦系统开始读取工资卡信息(即事务开始),廖志伟的老婆就不可能对该记录进行修改,也就是廖志伟的老婆不能在此时转账。这就避免了不可重复读。廖志伟的老婆工作在银行部门,她时常通过银行内部系统查看廖志伟的信用卡消费记录。有一天,她正在查询到廖志伟当月信用卡的总消费金额(select sum(amount) from transaction where month = 本月)为80元,而廖志伟此时正好在外面胡吃海喝后在收银台买单,消费1000元,即新增了一条1000元的消费记录(insert transaction … ),并提交了事务,随后廖志伟的老婆将廖志伟当月信用卡消费的明细打印到A4纸上,却发现消费总额为1080元,廖志伟的老婆很诧异,以为出现了幻觉,幻读就这样产生了。当隔离级别设置为Repeatable read时,可以避免不可重复读,但会出现幻读。注:MySQL的默认隔离级别就是Repeatable read。

Serializable 序列化
Serializable是最高的事务隔离级别,同时代价也花费最高,性能很低,一般很少使用,在该级别下,事务顺序执行,不仅可以避免脏读、不可重复读,还避免了幻像读。

属性(特性) 底层实现原理

A(原子性):要么全部完成,要么完全不起作用。底层实现原理undo log(当这个事务对数据库进行修改的时候,innodb生成对应undo log,他会记录这个SQL执行的相关信息,如果SQL执行失败发生这个回滚,innodb根据这个undo log内容去做相反的工作,比如说我执行了一个insert操作,那么回滚的时候,就会执行一个相反的操作,就是delete,对应update,回滚的时候也是执行相反的update)
C(一致性):一旦事务完成(不管成功还是失败),业务处于一致的状态,而不会是部分完成,部分失败。事务执行前后,数据库的完整约束没有遭受破坏,事务执行前后都是合法的一个数据状态。
I(隔离性):多事务会同时处理相同的数据,因此每个事务都应该与其他事务隔离开来,防止数据损坏。底层实现原理:写-写操作:锁(和java里面的锁机制是一样的)。写-读操作:MVCC(多版本并发控制,可以通过乐观锁和悲观锁实现,只在读已提交和可重复读二个隔离级别,事务的排它锁形式修改数据,修改之前把数据放到undolog里面,通过回滚指针关联,成功了什么都不做,失败了,从undolog回滚数据。)
D(持久性):一旦事务完成,无论发生什么系统错误,它的结果都不应该受到影响,事务的结果被写到持久化存储器中。底层实现原理redo log(mysql的数据是存放在这个磁盘上的,但是每次去读数据都需要通过这个磁盘io,效率就很低,使用innodb提供了一个缓存buffer,这个buffer中包含了磁盘部分数据页的一个映射,作为访问数据库的一个缓冲,当从这个数据库读取一个数据,就会先从这个buffer中获取,如果buffer中没有,就从这个磁盘中获取,读取完再放到这个buffer缓冲中,当数据库写入数据的时候,也会首先向这个buffer中写入数据,定期将buffer中的数据刷新到磁盘中,进行持久化的一个操作。如果buffer中的数据还没来得及同步到这个磁盘上,这个时候MySQL宕机了,buffer里面的数据就会丢失,造成数据丢失的情况,持久性就无法保证了。使用redolog解决这个问题,当数据库的数据要进行新增或者是修改的时候,除了修改这个buffer中的数据,还会把这次的操作写入到这个redolog中,如果msyql宕机了,就可以通过redolog去恢复数据,redolog是预写式日志(会先将所有的修改写入到日志里面,然后再更新到buffer里面,保证了这个数据不会丢失,保证了数据的持久性))

spring事务的传播行为

Spring的事务是对数据库的事务的封装,最后本质的实现还是在数据库,假如数据库不支持事务的话,Spring的事务是没有作用的。所以说Spring事务的底层依赖MySQL的事务,Spring是在代码层面利用AOP实现,执行事务的时候使用TransactionInceptor进行拦截,然后处理。本质是对方法前后进行拦截,然后在目标方法开始之前创建或者加入一个事务,执行完目标方法之后根据执行的情况提交或者回滚。

Spring支持7中事务传播行为
一个场景:假设外层方法里面包含二个新增用户和新增角色的方法后面还会抛一个异常。
propagation_required(需要传播):当前没有事务则新建事务,有则加入当前事务。

  • 外围方法未开启事务,插入用户表和用户角色表的方法在自己的事务中独立运行,外围方法异常不影响内部插入,所以两条记录都新增成功。
  • 外围方法开启事务,内部方法加入外围方法事务,外围方法回滚,内部方法也要回滚,所以两个记录都插入失败。

propagation_supports(支持传播):支持当前事务,如果当前没有事务则以非事务方式执行

  • 外围方法未开启事务,插入用户表和用户角色表的方法以非事务的方式独立运行,外围方法异常不影响内部插入,所以两条记录都新增成功。
  • 外围方法开启事务,内部方法加入外围方法事务,外围方法回滚,内部方法也要回滚,所以两个记录都插入失败。

propagation_mandatory(强制传播):使用当前事务,如果没有则抛出异常

  • 外围方法开启事务,内部方法加入外围方法事务,外围方法回滚,内部方法也要回滚,所以两个记录都插入失败。
  • 外围方法没有开启事务,两张表数据都为空,在调用用户新增方法时候已经报错了,所以两个表都没有新增数据。

propagation_nested(嵌套传播):如果当前存在事务,则在嵌套事务内执行,如果当前没有事务,则执行需要传播行为。
propagation_never(绝不传播):以非事务的方式执行,如果当前有事务则抛出异常。

  • 外层方法没有事务会以非事务的方式运行,两个表新增成功;
  • 外围方法有事务则抛出异常,两个表都都没有新增数据

propagation_requires_new(传播需要新的):新建事务,如果当前有事务则把当前事务挂起,创建新的事务。

  • 无论当前存不存在事务,都创建新事务,所以两个数据新增成功。

propagation_not_supported(不支持传播):以非事务的方式执行,如果当前有事务则把当前事务挂起。

  • 外围方法未开启事务,插入用户表和用户角色表的方法在自己的事务中独立运行,外围方法异常不影响内部插入,所以两条记录都新增成功。
  • 外围方法开启事务,两个数据新增成功。

CAP,BASE理论,分布式事务的四种解决方案

CAP

分布式环境下(数据分布)要任何时刻保证数据一致性是不可能的,只能采取妥协的方案来保证数据最终一致性。这个也就是著名的CAP定理。

C一致性:对于指定的客户端来说,读操作保证能够返回最新的写操作结果。

A可用性:非故障的节点在合理的时间内返回合理的响应(不是错误和超时的响应)。

P分区容错性:当出现网络分区后,系统能够继续“履行职责”。

对于一个分布式系统而言,网络失效一定会发生,分区容错性P其实就是每个服务都会有多个节点(一般都是主从),这样就可以保证此服务的一个节点挂了之后,此服务的其他节点依然可以响应,其实这就是分区容错性。
但是一个服务有多个节点之后,一个服务的多个节点之间的数据为了保持一致性就要进行的数据复制,在此过程中就会出现数据一致性C(强一致性)的问题。也就是说,分区耐受性是必须要保证的,那么在可用性和一致性就必须二选一。网络不可用的时候,如果选择了一致性,系统就可能返回一个错误码或者干脆超时,即系统不可用。如果选择了可用性,那么系统总是可以返回一个数据,但是并不能保证这个数据是最新的。

BASE

BASE 理论是对 CAP 理论的延伸,核心思想是即使无法做到强一致性,但应用可以采用适合的方式达到最终一致性。

  •     基本可用: 基本可用是指分布式系统在出现故障的时候,允许损失部分可用性,即保证核心可用。电商大促时,为了应对访问量激增,部分用户可能会被引导到降级页面,服务层也可能只提供降级服务。这就是损失部分可用性的体现。
  •     软状态: 软状态是指允许系统存在中间状态,而该中间状态不会影响系统整体可用性。分布式存储中一般一份数据至少会有三个副本,允许不同节点间副本同步的延时就是软状态的体现。MySQL Replication 的异步复制也是一种体现。
  •     最终一致性: 最终一致性是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。弱一致性和强一致性相反,最终一致性是弱一致性的一种特殊情况。
     

应用场景:

Erueka

erueka是SpringCloud系列用来做服务注册和发现的组件,作为服务发现的一个实现,在设计的时候就更考虑了可用性,保证了AP。

Zookeeper

Zookeeper在实现上牺牲了可用性,保证了一致性(单调一致性)和分区容错性,也即:CP。所以这也是SpringCloud抛弃了zookeeper而选择Erueka的原因。

具体根据各自业务场景所需来制定相应的策略而选择适合的产品服务等。例如:支付订单场景中,由于分布式本身就在数据一致性上面很难保证,从A服务到B服务的订单数据有可能由于服务宕机或其他原因而造成数据不一致性。因此此类场景会酌情考虑:AP,不强制保证数据一致性,但保证数据最终一致性。

分布式事务指事务的操作位于不同的节点上,需要保证事务的 AICD 特性,在下单场景下,库存和订单如果不在同一个节点上,就涉及分布式事务。

两阶段提交(2PC)

第一阶段:协调者询问参与者事务是否执行成功,参与者发回事务执行结果。这一阶段的协调者有超时机制,假设因为网络原因没有收到某参与者的响应或某参与者挂了,那么超时后就会判断事务失败,向所有参与者发送回滚命令。

第二阶段:如果事务在每个参与者上都执行成功,事务协调者才发送通知让参与者提交事务;否则,协调者发送通知让参与者回滚事务。这一阶段的协调者的没法超时,只能不断重试。

协调者是一个单点,存在单点故障问题。
假设协调者在发送准备命令之前挂了,还行等于事务还没开始。
假设协调者在发送准备命令之后挂了,这就不太行了,有些参与者等于都执行了处于事务资源锁定的状态。不仅事务执行不下去,还会因为锁定了一些公共资源而阻塞系统其它操作。
假设协调者在发送回滚事务命令之前挂了,那么事务也是执行不下去,且在第一阶段那些准备成功参与者都阻塞着。
假设协调者在发送回滚事务命令之后挂了,这个还行,至少命令发出去了,很大的概率都会回滚成功,资源都会释放。但是如果出现网络分区问题,某些参与者将因为收不到命令而阻塞着。
假设协调者在发送提交事务命令之前挂了,这个不行,傻了!这下是所有资源都阻塞着。
假设协调者在发送提交事务命令之后挂了,这个还行,也是至少命令发出去了,很大概率都会提交成功,然后释放资源,但是如果出现网络分区问题某些参与者将因为收不到命令而阻塞着。

存在的缺点:

  • 同步阻塞 所有事务参与者在等待其它参与者响应的时候都处于同步阻塞状态,无法进行其它操作。
  • 单点问题 协调者在 2PC 中起到非常大的作用,发生故障将会造成很大影响。特别是在阶段二发生故障,所有参与者会一直等待状态,无法完成其它操作。
  • 数据不一致 在阶段二,如果协调者只发送了部分 Commit 消息,此时网络发生异常,那么只有部分参与者接收到 Commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。
  • 太过保守 任意一个节点失败就会导致整个事务失败,没有完善的容错机制。

3PC

相比于 2PC 它在参与者中也引入了超时机制,并且新增了一个阶段使得参与者可以利用这一个阶段统一各自的状态,3PC 包含了三个阶段,分别是准备阶段、预提交阶段和提交阶段
准备阶段的变更成不会直接执行事务,而是会先去询问此时的参与者是否有条件接这个事务,因此不会一来就干活直接锁资源,使得在某些资源不可用的情况下所有参与者都阻塞着。
而预提交阶段的引入起到了一个统一状态的作用,它像一道栅栏,表明在预提交阶段前所有参与者其实还未都回应,在预处理阶段表明所有参与者都已经回应了。
假如你是一位参与者,你知道自己进入了预提交状态那你就可以推断出来其他参与者也都进入了预提交状态。

缺点:

多引入一个阶段也多一个交互,因此性能会差一些,而且绝大部分的情况下资源应该都是可用的,这样等于每次明知可用执行还得询问一次。

和2PC对比:


2PC 是同步阻塞的,协调者挂在了提交请求还未发出去的时候是最伤的,所有参与者都已经锁定资源并且阻塞等待着,提交阶段 2PC 协调者和某参与者都挂了之后新选举的协调者不知道当前应该提交还是回滚

改进的优势:


新协调者来的时候发现有一个参与者处于预提交或者提交阶段,那么表明已经经过了所有参与者的确认了,所以此时执行的就是提交命令。
所以说 3PC 就是通过引入预提交阶段来使得参与者之间的状态得到统一,也就是留了一个阶段让大家同步一下。
但是这也只能让协调者知道该如果做,但不能保证这样做一定对,这其实和上面 2PC 分析一致,因为挂了的参与者到底有没有执行事务无法断定。
所以说 3PC 通过预提交阶段可以减少故障恢复时候的复杂性,但是不能保证数据一致,除非挂了的那个参与者恢复。


总结一下

3PC 相对于 2PC 做了一定的改进:引入了参与者超时机制,并且增加了预提交阶段使得故障恢复之后协调者的决策复杂度降低,但整体的交互过程更长了,性能有所下降,并且还是会存在数据不一致问题。
2PC 和 3PC 都不能保证数据100%一致,因此一般都需要有定时扫描补偿机制

补偿事务(TCC)

针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:

  • Try 阶段主要是对业务系统做检测及资源预留

  • Confirm 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。

  • Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。

举个例子,假入 Bob 要向 Smith 转账,思路大概是:我们有一个本地方法,里面依次调用

  1. 首先在 Try 阶段,要先调用远程接口把 Smith 和 Bob 的钱给冻结起来。

  2. 在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。

  3. 如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。

优点: 跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些

缺点: 缺点还是比较明显的,在2,3步中都有可能失败。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理。

本地消息表(异步确保)

本地消息表与业务数据表处于同一个数据库中,这样就能利用本地事务来保证在对这两个表的操作满足事务特性,并且使用了消息队列来保证最终一致性。

  1. 在分布式事务操作的一方完成写业务数据的操作之后向本地消息表发送一个消息,本地事务能保证这个消息一定会被写入本地消息表中。

  2. 之后将本地消息表中的消息转发到 Kafka 等消息队列中,如果转发成功则将消息从本地消息表中删除,否则继续重新转发。

  3. 在分布式事务操作的另一方从消息队列中读取一个消息,并执行消息中的操作。

优点: 一种非常经典的实现,避免了分布式事务,实现了最终一致性。

缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。

MQ 事务消息

有一些第三方的MQ是支持事务消息的,比如RocketMQ,他们支持事务消息的方式也是类似于采用的二阶段提交,但是市面上一些主流的MQ都是不支持事务消息的,比如 RabbitMQ 和 Kafka 都不支持。

第一阶段Prepared消息,会拿到消息的地址。第二阶段执行本地事务,第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。也就是说在业务方法内要想消息队列提交两次请求,一次发送消息和一次确认消息。如果确认消息发送失败了RocketMQ会定期扫描消息集群中的事务消息,这时候发现了Prepared消息,它会向消息发送者确认,所以生产方需要实现一个check接口,RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。

优点: 实现了最终一致性,不需要依赖本地数据库事务。

缺点: 实现难度大,主流MQ不支持,RocketMQ事务消息部分代码也未开源。

最大努力通知

其实我觉得本地消息表也可以算最大努力,事务消息也可以算最大努力。

就本地消息表来说会有后台任务定时去查看未完成的消息,然后去调用对应的服务,当一个消息多次调用都失败的时候可以记录下然后引入人工,或者直接舍弃。这其实算是最大努力了。

事务消息也是一样,当半消息被commit了之后确实就是普通消息了,如果订阅者一直不消费或者消费不了则会一直重试,到最后进入死信队列。其实这也算最大努力。

所以最大努力通知其实只是表明了一种柔性事务的思想:我已经尽力我最大的努力想达成事务的最终一致了。适用于对时间不敏感的业务,例如短信通知。

各个场景对比的解决方案:

2PC 和 3PC 是一种强一致性事务,不过还是有数据不一致,阻塞等风险,而且只能用在数据库层面。

而 TCC 是一种补偿性事务思想,适用的范围更广,在业务层面实现,因此对业务的侵入性较大,每一个操作都需要实现对应的三个方法。

本地消息、事务消息和最大努力通知其实都是最终一致性事务,因此适用于一些对时间不敏感的业务。

Spring Bean的作用域和生命周期

Spring对bean进行实例化,默认bean是单例;

Spring对bean进行依赖注入;

如果bean实现了BeanNameAware接口,Spring将bean的名称传给setBeanName()方法;

如果bean实现了BeanFactoryAware接口,Spring将调用setBeanFactory()方法,将BeanFactory实例传进来;

如果bean实现了ApplicationContextAware接口,它的setApplicationContext()方法将被调用,将应用上下文的引用传入到bean中;

如果bean实现了BeanPostProcessor接口,它的postProcessBeforeInitialization()方法将被调用;

如果bean中有方法添加了@PostConstruct注解,那么该方法将被调用;

如果bean实现了InitializingBean接口,spring将调用它的afterPropertiesSet()接口方法,类似的如果bean使用了init-method属性声明了初始化方法,该方法也会被调用;

如果在xml文件中通过<bean>标签的init-method元素指定了初始化方法,那么该方法将被调用;

如果bean实现了BeanPostProcessor接口,它的postProcessAfterInitialization()接口方法将被调用;

此时bean已经准备就绪,可以被应用程序使用了,他们将一直驻留在应用上下文中,直到该应用上下文被销毁;

如果bean中有方法添加了@PreDestroy注解,那么该方法将被调用;

若bean实现了DisposableBean接口,spring将调用它的distroy()接口方法。同样的,如果bean使用了destroy-method属性声明了销毁方法,则该方法被调用

设计模式

1、FACTORY 工厂方法:追MM少不了请吃饭了,麦当劳的鸡翅和肯德基的鸡翅都是MM爱吃的东西,虽然口味有所不同,但不管你带MM去麦当劳或肯德基,只管向服务员说“来四个鸡翅”就行了。麦当劳和肯德基就是生产鸡翅的Factory 工厂模式:客户类和工厂类分开。消费者任何时候需要某种产品,只需向工厂请求即可。消费者无须修改就可以接纳新产品。缺点是当产品修改时,工厂类也要做相应的修改。如:如何创建及如何向客户端提供。

2、BUILDER建造者模式:MM最爱听的就是“我爱你”这句话了,见到不同地方的MM,要能够用她们的方言跟她说这句话哦,我有一个多种语言翻译机,上面每种语言都有一个按键,见到MM我只要按对应的键,它就能够用相应的语言说出“我爱你”这句话了,国外的MM也可以轻松搞掂,这就是我的“我爱你”builder。(这一定比美军在伊拉克用的翻译机好卖) 建造模式:将产品的内部表象和产品的生成过程分割开来,从而使一个建造过程生成具有不同的内部表象的产品对象。建造模式使得产品内部表象可以独立的变化,客户不必知道产品内部组成的细节。建造模式可以强制实行一种分步骤进行的建造过程。

3、FACTORY METHOD抽象工厂:请MM去麦当劳吃汉堡,不同的MM有不同的口味,要每个都记住是一件烦人的事情,我一般采用Factory Method模式,带着MM到服务员那儿,说“要一个汉堡”,具体要什么样的汉堡呢,让MM直接跟服务员说就行了。 工厂方法模式:核心工厂类不再负责所有产品的创建,而是将具体创建的工作交给子类去做,成为一个抽象工厂角色,仅负责给出具体工厂类必须实现的接口,而不接触哪一个产品类应当被实例化这种细节。

4、ADAPTER 适配器模式:在朋友聚会上碰到了一个美女Sarah,从香港来的,可我不会说粤语,她不会说普通话,只好求助于我的朋友kent了,他作为我和Sarah之间的Adapter,让我和Sarah可以相互交谈了(也不知道他会不会耍我) 适配器(变压器)模式:把一个类的接口变换成客户端所期待的另一种接口,从而使原本因接口原因不匹配而无法一起工作的两个类能够一起工作。适配类可以根据参数返还一个合适的实例给客户端。

5、BRIDGE 桥梁模式:早上碰到MM,要说早上好,晚上碰到MM,要说晚上好;碰到MM穿了件新衣服,要说你的衣服好漂亮哦,碰到MM新做的发型,要说你的头发好漂亮哦。不要问我“早上碰到MM新做了个发型怎么说”这种问题,自己用BRIDGE组合一下不就行了 桥梁模式:将抽象化与实现化脱耦,使得二者可以独立的变化,也就是说将他们之间的强关联变成弱关联,也就是指在一个软件系统的抽象化和实现化之间使用组合/聚合关系而不是继承关系,从而使两者可以独立的变化。

6、PROXY代理模式:跟MM在网上聊天,一开头总是“hi,你好”,“你从哪儿来呀?”“你多大了?”“身高多少呀?”这些话,真烦人,写个程序做为我的Proxy吧,凡是接收到这些话都设置好了自己的回答,接收到其他的话时再通知我回答,怎么样,酷吧。 代理模式:代理模式给某一个对象提供一个代理对象,并由代理对象控制对源对象的引用。代理就是一个人或一个机构代表另一个人或者一个机构采取行动。某些情况下,客户不想或者不能够直接引用一个对象,代理对象可以在客户和目标对象直接起到中介的作用。客户端分辨不出代理主题对象与真实主题对象。代理模式可以并不知道真正的被代理对象,而仅仅持有一个被代理对象的接口,这时候代理对象不能够创建被代理对象,被代理对象必须有系统的其他角色代为创建并传入。

7、OBSERVER观察者模式:想知道咱们公司最新MM情报吗?加入公司的MM情报邮件组就行了,tom负责搜集情报,他发现的新情报不用一个一个通知我们,直接发布给邮件组,我们作为订阅者(观察者)就可以及时收到情报啦 观察者模式:观察者模式定义了一种一队多的依赖关系,让多个观察者对象同时监听某一个主题对象。这个主题对象在状态上发生变化时,会通知所有观察者对象,使他们能够自动更新自己。

8、STATE 状态模式:跟MM交往时,一定要注意她的状态哦,在不同的状态时她的行为会有不同,比如你约她今天晚上去看电影,对你没兴趣的MM就会说“有事情啦”,对你不讨厌但还没喜欢上的MM就会说“好啊,不过可以带上我同事么?”,已经喜欢上你的MM就会说“几点钟?看完电影再去泡吧怎么样?”,当然你看电影过程中表现良好的话,也可以把MM的状态从不讨厌不喜欢变成喜欢哦。 状态模式:状态模式允许一个对象在其内部状态改变的时候改变行为。这个对象看上去象是改变了它的类一样。状态模式把所研究的对象的行为包装在不同的状态对象里,每一个状态对象都属于一个抽象状态类的一个子类。状态模式的意图是让一个对象在其内部状态改变的时候,其行为也随之改变。状态模式需要对每一个系统可能取得的状态创立一个状态类的子类。当系统的状态变化时,系统便改变所选的子类。

9、STRATEGY 策略模式:跟不同类型的MM约会,要用不同的策略,有的请电影比较好,有的则去吃小吃效果不错,有的去海边浪漫最合适,单目的都是为了得到MM的芳心,我的追MM锦囊中有好多Strategy哦。 策略模式:策略模式针对一组算法,将每一个算法封装到具有共同接口的独立的类中,从而使得它们可以相互替换。策略模式使得算法可以在不影响到客户端的情况下发生变化。策略模把行为和环境分开。环境类负责维持和查询行为类,各种算法在具体的策略类中提供。由于算法和环境独立开来,算法的增减,修改都不会影响到环境和客户端。

线程池实现原理,七大核心参数,如何合理的配置核心线程数?拒绝策略?JUC并发包:信号灯,循环栅栏,倒计时器

线程池实现原理

线程池就是控制运行的线程数量,处理过程中将任务放到队列,然后在线程创建后启动这些任务,如果线程数量超出了最大数量就排队等候,等其他线程执行完毕再从队列中取出任务执行。

线程池相当于银行网点,常驻核心数相当于今日当值窗口,线程池能够同时执行的最大线程数相当于银行所有的窗口,任务队列相当于银行的候客区,当今日当值窗口满了,多出来的客户去候客区等待,当候客区满了,银行加开窗口,候客区先来的客户去加班窗口,当银行所有的窗口满了,其他客户在候客区等待,同时拒绝其他客户进入银行。当用户少了,加班的窗口等待时间(相当于多余线程存活的时间)(等待时间的单位相当于unit参数)假设超过一个小时还是没有人来,就取消加班的窗口。

七大核心参数

底层在创建线程池的时候有七个参数:核心线程数,同时执行的最大线程数,多余线程存活时间,单位时间秒,任务队列,默认线程工厂,拒绝策略

maximumPoolsize:同时执行的最大线程数
keepAliveTime:多余线程存活时间,当前线程池数量超过核心线程数时,当前空闲时间达到多余线程存活时间的值的时候,多余空闲线程会被销毁到只剩核心线程数为止
unit:多余线程存活时间的单位
workQueue:任务队列,被提交但尚未被执行的任务
threadFactory:生成线程池的线程工厂
handler:拒绝策略,当队列满了并且工作线程数量大于线程池的最大线程数时,提供拒绝策略。

如何合理的配置核心线程数?

  • 对于CPU密集型任务,由于CPU密集型任务的性质,导致CPU的使用率很高,如果线程池中的核心线程数量过多,会增加上下文切换的次数,带来额外的开销。因此,考虑到CPU密集型任务因为某些原因而暂停,这个时候有额外的线程能确保CPU这个时刻不会浪费,还可以增加一个CPU上下文切换。一般情况下:线程池的核心线程数量等于CPU核心数+1。例如需要大量的计算,视频渲染啊,仿真啊之类的。这个时候CPU就卯足了劲在运行,这个时候切换线程,反而浪费了切换的时间,效率不高。打个比方,你的大脑是CPU,你本来就在一本心思地写作业,多线程这时候就是要你写会作业,然后立刻敲一会代码,然后在P个图,然后在看个视频,然后再切换回作业。emmmm,过程中你还需要切换(收起来作业,拿出电脑,打开VS…)那你的作业怕是要写到挂科。这个时候你就该一门心思地写作业。
  • 对于I/O密集型任务,由于I/O密集型任务CPU使用率并不是很高,可以让CPU在等待I/O操作的时去处理别的任务,充分利用CPU。一般情况下:线程的核心线程数等于2*CPU核心数。例如你需要陪小姐姐或者小哥哥聊天,还需要下载一个VS,还需要看博客。打个比方,小姐姐给你发消息了,回一下她,然后呢?她给你回消息肯定需要时间,这个时候你就可以搜索VS的网站,先下安装包,然后一看,哎呦,她还没给你回消息,然后看会自己的博客。小姐姐终于回你了,你回一下她,接着看我的博客,这就是类似于IO密集型。你可以在不同的“不烧脑”的工作之间切换,来达到更高的效率。而不是小姐姐不回我的信息,我就干等,啥都不干,就等,这个效率可想而知,也许,小姐姐根本就不会回复你。
  • 对于混合型任务,由于包含2种类型的任务,故混合型任务的线程数与线程时间有关。在某种特定的情况下还可以将任务分为I/O密集型任务和CPU密集型任务,分别让不同的线程池去处理。一般情况下:线程池的核心线程数=(线程等待时间/线程CPU时间+1)*CPU核心数;

并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,我们的项目使用的时redis作为缓存(这类非关系型数据库还是挺好的)。增加服务器是第二步(一般政府项目的首先,因为不用对项目技术做大改动,求一个稳,但前提是资金充足),至于线程池的设置,设置参考 2 。最后,业务执行时间长的问题,也可能需要分析一下,看看能不能使用中间件(任务时间过长的可以考虑拆分逻辑放入队列等操作)对任务进行拆分和解耦。

拒绝策略

  • 第一种拒绝策略:AbortPolicy:超出最大线程数,直接抛出RejectedExecutionException异常阻止系统正常运行。可以感知到任务被拒绝了,于是你便可以根据业务逻辑选择重试或者放弃提交等策略。
  • 第二种拒绝策略:该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,相当于当线程池无能力处理当前任务时,会将这个任务的执行权交予提交任务的线程来执行,也就是谁提交谁负责,从而降低新任务的流量。(谁调用了你,到达最大线程数时,你回去找调用你的人,然后听从调用你的人安排)(超出的我们能办的给你办,不能办的给你回退 )这样的话提交的任务就不会被丢弃而造成业务损失,如果任务比较耗时,那么这段时间内提交任务的线程也会处于忙碌状态而无法继续提交任务,这样也就减缓了任务的提交速度,这相当于一个负反馈,也有利于线程池中的线程来消化任务。这种策略算是最完善的相对于其他三个。
  • 第三拒绝策略:DiscardOldestPolicy:抛弃队列中等待最久的任务,也就是它丢弃的是队列中的头节点,然后把当前任务加入队列中尝试再次提交当前任务
  • 第四种拒绝策略:DiscardPolicy:直接丢弃任务,不予任何处理也不抛异常,当任务提交时直接将刚提交的任务丢弃,而且不会给与任何提示通知。

实际创建线程池:

在实际使用的时候,选择线程池的时候尽量不用JDK提供的三种常见的创建方式,因为它的底层队列是Linked这个接近于无界,非常大,这样会堆积大量的请求,从而导致OOM,阿里巴巴开发手册推荐我们使用ThreadPoolExecutor去创建线程池。

JUC并发包:CountDownLatch倒计时器

让一些线程阻塞直到另一些线程完成一系统操作后才被唤醒。一个 CountDownLatch 用给定的计数初始化。await() 方法阻塞,直到由于countDown() 方法的调用而导致当前计数达到零,之后所有等待线程被释放,并且任何后续的 await() 调用立即返回。 这是一个一次性的现象 - 计数无法重置。

举个例子:我们的API接口响应时间被要求在200ms以内,但是如果一个接口内部依赖多个三方/外部服务,那串行调用接口的响应时间必然很久,所以可使用内部服务并行调用进行优化。假设接口内部依赖了10个外部服务,创建CountDownLatch实例,计数数量为10,有10个线程来完成任务,等待在CountDownLatch上的线程执行完才能继续执行那个响应时间较快的接口。latch.countDown();方法作用是通知CountDownLatch有一个线程已经准备完毕,倒计数器可以减一了。latch.await()方法要求主线程等待所有10个检查任务全部准备好才一起并行执行。

一种典型的场景就是火箭发射。在火箭发射前,为了保证万无一失,往往还要进行各项设备、仪器的检测。只有等到所有的检查完毕后,引擎才能点火。那么在检测环节当然是多个检测项可以同时进行的

JUC并发包:Semaphore信号灯

多个共享资源互斥使用,控制线程并发数量,多个线程抢多个资源。

1、Semaphore信号量作为一种流控手段,可以对特定资源的允许同时访问的操作数量进行控制,例如池化技术(连接池)中的并发数,有界阻塞容器的容量等。

2、Semaphore中包含初始化时固定个数的许可,在进行操作的时候,需要先acquire获取到许可,才可以继续执行任务,如果获取失败,则进入阻塞;处理完成之后需要release释放许可。

3、acquire与release之间的关系:在实现中不包含真正的许可对象,并且Semaphore也不会将许可与线程关联起来,因此在一个线程中获得的许可可以在另一个线程中释放。可以将acquire操作视为是消费一个许可,而release操作是创建一个许可,Semaphore并不受限于它在创建时的初始许可数量。也就是说acquire与release并没有强制的一对一关系,release一次就相当于新增一个许可,许可的数量可能会由于没有与acquire操作一对一而导致超出初始化时设置的许可个数。 举例,有六台车抢三个停车位。

JUC并发包:CyclicBarrier循环栅栏

当多个线程一起执行任务是,一个线程没有完成任务,其他线程都必须进入等待状态,等待这个线程完成任务后,才能再执行其他任务。强调相互等待,一个线程不完成,其他线程全部等待。

创建CyclicBarrier时,它默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量。调用await方法的线程告诉CyclicBarrier自己已经到达同步点,然后当前线程被阻塞。

例如:一个公司去团建,需要大家全部集合完毕后,才能出发,有一个人员不到,全员都得等待。它的作用就是会让所有线程都等待完成后才会继续下一步行动

Redis缓存数据类型的应用场景以及底层数据结构,数据同步问题(双删策略),缓存雪崩,缓存穿透,缓存失效,高并发场景下的分布式锁,热点缓存,哨兵机制,持久化(主从)

大数据类型应用场景

字符串string

可以通过set key value 实现单值缓存

可以通过setnx product:10001 true 实现分布式锁,返回1表示获取锁成功,返回0表示获取锁失败,这个值已经被设置过

可以通过incr acticle:readcount{文章id} 实现计数器,每执行一次加一

可以通过incrby orderId 1000 实现分布式系统全局序列号,一次性拿1000个序列号,在redis里面加一,批量生成序列号提升性能

因为string 类型是二进制安全的,可以用来存放图片,视频等内容,另外由于Redis的高性能读写功能,而string类型的value也可以是数字,可以用作计数器(INCR,DECR),比如分布式环境中统计系统的在线人数,秒杀等。

哈希hash(比string类型操作消耗内存和cpu更小,更节约空间,集群架构下不适合大规模使用

可以通过hmget user 1:name 1:balance 实现对象缓存:多个字段修改方便一些,大数据量要进行分段存储

电商购物车实现场景可以通过hset 添加商品 hincrby 添加商品数量 hlen获取商品总数 hdel删除商品 hgetall获取购物车所有商品

可以做单点登录存放用户信息

列表list

lpush把值设置到列表的表头(最左边)rpush把值设置到列表的表尾(最右边)lpop从列表头拿掉值(最左边)rpop从列表尾拿掉值(最右边)

Stach(栈):lpush+lpop

Queue(队列):lpush+rpop

Blocking MQ(阻塞队列):lpush + brpop

微博消息和微信公众号文章,我关注了MacTalk和备胎说车,MacTalk先发了一篇文章,文章id为10018,备胎说车后发了一篇文章,文章id为10086

查看最新消息,LRANGE msg:{我的用户id} 0 4//查看自己订阅文章的最新五篇

可以实现简单的消息队列,另外可以利用lrange命令,做基于redis的分页功能

集合set

微信抽奖小程序

sadd key {userId} 点击参与抽奖加入集合

smembers key 查看参与抽奖所有用户

srandmember key [count] /spop key [count] 抽取count名中奖者

微信微博点赞,收藏,标签

sadd key value 点赞

srem key value 取消点赞

sismember key value 检查用户是否点过赞

smembers key 获取点赞的用户列表

scard key 获取点赞用户数

实现微博微信关注模型

SINTER 交集 取共同的元素 可以实现商品筛选面包屑

SUNION 并集 取所有的元素

SDIFF 差集 以第一个集合为基准减去后面所有集合的并集,最后看第一个集合还剩下的元素

共同关注的人:交集

我可能认识的人:进到王五的主页,拿王五的集合和自己的集合对比,取差集

我关注的人也关注他:取出自己关注人的集合的交集

由于底层是字典实现的,查找元素特别快,另外set 数据类型不允许重复,利用这两个特性我们可以进行全局去重,比如在用户注册模块,判断用户名是否注册;另外就是利用交集、并集、差集等操作,可以计算共同喜好,全部的喜好,自己独有的喜好等功能。

zset

实现排行榜

zincrby key count 实现点击新闻

zreverange key 0 9 withscores 实现展示当日排行前十

zunionstore key count 几日搜索榜单

zreveange key 0 9 withscores 展示七日排行前十

有序的集合,可以做范围查找,排行榜应用,取 TOP N 操作等。

底层数据结构

每次在Redis数据库中创建一个键值对时,至少会创建两个对象,一个是键对象,一个是值对象,而Redis中的每个对象都是由 redisObject

typedef struct redisObject{
     //类型
     unsigned type:4;
     //编码
     unsigned encoding:4;
     //指向底层数据结构的指针
     void *ptr;
     //引用计数
     int refcount;
     //记录最后一次被程序访问的时间
     unsigned lru:22;
 
}robj

对象的type属性记录了对象的类型,就是前面讲的五大数据类型,在Redis中,键总是一个字符串对象,而值可以是字符串、列表、集合等对象。

对象的 prt 指针指向对象底层的数据结构,而数据结构由 encoding 属性来决定。

字符串

字符串是Redis最基本的数据类型,不仅所有key都是字符串类型,字符串的长度不能超过512M。

①、编码

  字符串对象的编码可以是int,raw或者embstr。

  • int 编码:保存的是可以用 long 类型表示的整数值。
  • raw 编码:保存长度大于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)。
  • embstr 编码:保存长度小于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)。

int 编码是用来保存整数值,raw编码是用来保存长字符串,而embstr是用来保存短字符串。其实 embstr 编码是专门用来保存短字符串的一种优化编码,embstr与raw都使用redisObject和sds保存数据,区别在于,embstr的使用只分配一次内存空间(因此redisObject和sds是连续的),而raw需要分配两次内存空间(分别为redisObject和sds分配空间)。因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,因此redis中的embstr实现为只读。

  说明:Redis中对于浮点数类型也是作为字符串保存的,在需要的时候再将其转换成浮点数类型。

  ②、编码的转换

  当 int 编码保存的值不再是整数,或大小超过了long的范围时,自动转化为raw。对于 embstr 编码,由于 Redis 没有对其编写任何的修改程序(embstr 是只读的),在对embstr对象进行修改时,都会先转化为raw再进行修改,因此,只要是修改embstr对象,修改后的对象一定是raw的,无论是否达到了44个字节。

list 列表

是简单的字符串列表,按照插入顺序排序,你可以添加一个元素到列表的头部(左边)或者尾部(右边),它的底层实际上是个链表结构。
①、编码:列表对象的编码可以是 ziplist(压缩列表) 和 linkedlist(双端链表)。
②、编码转换:当同时满足下面两个条件时,使用ziplist(压缩列表)编码:
1、列表保存元素个数小于512个
2、每个元素长度小于64字节
不能满足这两个条件的时候使用 linkedlist 编码。
上面两个条件可以在redis.conf 配置文件中的 list-max-ziplist-value选项和 list-max-ziplist-entries 选项进行配置。

哈希对象 

哈希对象的键是一个字符串类型,值是一个键值对集合。
①、编码
哈希对象的编码可以是 ziplist 或者 hashtable。
当使用ziplist,也就是压缩列表作为底层实现时,新增的键值对是保存到压缩列表的表尾。
hashtable 编码的哈希表对象底层使用字典数据结构,哈希对象中的每个键值对都使用一个字典键值对。
在前面介绍压缩列表时,我们介绍过压缩列表是Redis为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,相对于字典数据结构,压缩列表用于元素个数少、元素长度小的场景。其优势在于集中存储,节省空间。
②、编码转换
和上面列表对象使用 ziplist 编码一样,当同时满足下面两个条件时,使用ziplist(压缩列表)编码:
1、列表保存元素个数小于512个
2、每个元素长度小于64字节
不能满足这两个条件的时候使用 hashtable 编码。第一个条件可以通过配置文件中的 set-max-intset-entries 进行修改。

集合对象  

集合对象 set 是 string 类型(整数也会转换成string类型进行存储)的无序集合。注意集合和列表的区别:集合中的元素是无序的,因此不能通过索引来操作元素;集合中的元素不能有重复。
①、编码
集合对象的编码可以是 intset 或者 hashtable。
intset 编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合中。
hashtable 编码的集合对象使用 字典作为底层实现,字典的每个键都是一个字符串对象,这里的每个字符串对象就是一个集合中的元素,而字典的值则全部设置为 null。这里可以类比Java集合中HashSet 集合的实现,HashSet 集合是由 HashMap 来实现的,集合中的元素就是 HashMap 的key,而 HashMap 的值都设为 null。
②、编码转换
当集合同时满足以下两个条件时,使用 intset 编码:
1、集合对象中所有元素都是整数
2、集合对象所有元素数量不超过512
不能满足这两个条件的就使用 hashtable 编码。第二个条件可以通过配置文件的 set-max-intset-entries 进行配置。

有序集合

有序集合为每个元素设置一个分数(score)作为排序依据。
①、编码
有序集合的编码可以是 ziplist 或者 skiplist。
ziplist 编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个节点保存元素的分值。并且压缩列表内的集合元素按分值从小到大的顺序进行排列,小的放置在靠近表头的位置,大的放置在靠近表尾的位置。
skiplist 编码的有序集合对象使用 zet 结构作为底层实现,一个 zset 结构同时包含一个字典和一个跳跃表
字典的键保存元素的值,字典的值则保存元素的分值;跳跃表节点的 object 属性保存元素的成员,跳跃表节点的 score 属性保存元素的分值。
这两种数据结构会通过指针来共享相同元素的成员和分值,所以不会产生重复成员和分值,造成内存的浪费。
说明:其实有序集合单独使用字典或跳跃表其中一种数据结构都可以实现,但是这里使用两种数据结构组合起来,原因是假如我们单独使用 字典,虽然能以 O(1) 的时间复杂度查找成员的分值,但是因为字典是以无序的方式来保存集合元素,所以每次进行范围操作的时候都要进行排序;假如我们单独使用跳跃表来实现,虽然能执行范围操作,但是查找操作有 O(1)的复杂度变为了O(logN)。因此Redis使用了两种数据结构来共同实现有序集合。
②、编码转换
当有序集合对象同时满足以下两个条件时,对象使用 ziplist 编码:
1、保存的元素数量小于128;
2、保存的所有元素长度都小于64字节。
不能满足上面两个条件的使用 skiplist 编码。以上两个条件也可以通过Redis配置文件zset-max-ziplist-entries 选项和 zset-max-ziplist-value 进行修改。

refcount

Redis自己构建了一个内存回收机制,通过在 redisObject 结构中的 refcount 属性实现。这个属性会随着对象的使用状态而不断变化:

  •   1、创建一个新对象,属性 refcount 初始化为1
  •   2、对象被一个新程序使用,属性 refcount 加 1
  •   3、对象不再被一个程序使用,属性 refcount 减 1
  •   4、当对象的引用计数值变为 0 时,对象所占用的内存就会被释放。

数据同步问题(双删策略)三种更新策略

先更新数据库,再更新缓存;

同时有请求A和请求B进行更新操作,那么会出现:

  1. 线程A更新了数据库;

  2. 线程B更新了数据库;

  3. 线程B更新了缓存;

  4. 线程A更新了缓存;

缺点:

  • 这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑!
  • 如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
  • 如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。

先删除缓存,再更新数据库;

同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:

(1)请求A进行写操作,删除缓存;

(2)请求B查询发现缓存不存在;

(3)请求B去数据库查询得到旧值;

(4)请求B将旧值写入缓存;

(5)请求A将新值写入数据库;

导致数据不一致的情形出现,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。

解决方案:延时双删策略

(1)先淘汰缓存;

(2)再写数据库(这两步和原来一样);

(3)休眠1秒,再次淘汰缓存;

这么做,可以将1秒内所造成的缓存脏数据,再次删除!这个一秒如何得出来的呢?评估自己的项目的读数据业务逻辑的耗时,在读数据业务逻辑的耗时基础上,加几百ms即可,确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

MySQL的读写分离架构

一个请求A进行更新操作,另一个请求B进行查询操作。

(1)请求A进行写操作,删除缓存;

(2)请求A将数据写入数据库了;

(3)请求B查询缓存发现,缓存没有值;

(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值;

(5)请求B将旧值写入缓存;

(6)数据库完成主从同步,从库变为新值;

导致数据不一致,解决方案使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms。

采用这种同步淘汰策略,吞吐量降低怎么办?

ok,那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。
 

先更新数据库,再删除缓存;

一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生:

(1)缓存刚好失效;

(2)请求A查询数据库,得一个旧值;

(3)请求B将新值写入数据库;

(4)请求B删除缓存;

(5)请求A将查到的旧值写入缓存;

会发生脏数据,但是几率不大,因为步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。

如何解决脏数据呢?给缓存设有效时间是一种方案。其次,采用策略2(先删除缓存,再更新数据库)里给出的异步延时删除策略,保证读请求完成以后,再进行删除操作。

第二次删除,如果删除失败怎么办?

这是个非常好的问题,因为第二次删除失败,就会出现如下情形。还是有两个请求,一个请求A进行更新操作,另一个请求B进行查询操作,为了方便,假设是单库:

(1)请求A进行写操作,删除缓存;

(2)请求B查询发现缓存不存在;

(3)请求B去数据库查询得到旧值;

(4)请求B将旧值写入缓存;

(5)请求A将新值写入数据库;

(6)请求A试图去删除请求B写入对缓存值,结果失败了;ok,这也就是说。如果第二次删除缓存失败,会再次出现缓存和数据库不一致的问题。

解决方案一:

(1)更新数据库数据;

(2)缓存因为种种问题删除失败;

(3)将需要删除的key发送至消息队列;

(4)自己消费消息,获得需要删除的key;

(5)继续重试删除操作,直到成功;

缺点:对业务线代码造成大量的侵入

解决方案二:

启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

(1)更新数据库数据;

(2)数据库会将操作信息写入binlog日志当中;

(3)订阅程序提取出所需要的数据以及key;

(4)另起一段非业务代码,获得该信息;

(5)尝试删除缓存操作,发现删除失败;

(6)将这些信息发送至消息队列;

(7)重新从消息队列中获得该数据,重试操作;

订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能。重试机制,采用的是消息队列的方式。如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试。

缓存雪崩

一个系统,高峰期请求为5000次/秒,4000次走了缓存,只有1000次落到了数据库上,数据库每秒1000的并发是一个正常的指标,完全可以正常工作,但如果缓存宕机了,或者缓存设置了相同的过期时间,导致缓存在同一时刻同时失效,每秒5000次的请求会全部落到数据库上,数据库立马就死掉了,因为数据库一秒最多抗2000个请求,如果DBA重启数据库,立马又会被新的请求打死了,这就是缓存雪崩。

解决方案:

  1. 事前:redis高可用,主从+哨兵,redis cluster,避免全盘崩溃
  2. 事中:本地ehcache缓存 + hystrix限流&降级,避免MySQL被打死
  3. 事后:redis持久化RDB+AOF,快速恢复缓存数据
  4. 缓存的失效时间设置为随机值,避免同时失效

缓存穿透

客户端每秒发送5000个请求,其中4000个为黑客的恶意攻击,即在数据库中也查不到。举个例子,用户id为正数,黑客构造的用户id为负数,如果黑客每秒一直发送这4000个请求,缓存就不起作用,数据库也很快被打死。

解决方案:

  1. 对请求参数进行校验,不合理直接返回
  2. 查询不到的数据也放到缓存,value为空,如 set -999 ""
  3. 使用布隆过滤器,快速判断key是否在数据库中存在,不存在直接返回

缓存击穿

设置了过期时间的key,承载着高并发,是一种热点数据。从这个key过期到重新从MySQL加载数据放到缓存的一段时间,大量的请求有可能把数据库打死。缓存雪崩是指大量缓存失效,缓存击穿是指热点数据的缓存失效。

解决方法

  1. 设置key永远不过期,或者快过期时,通过另一个异步线程重新设置key
  2. 当从缓存拿到的数据为null,重新从数据库加载数据的过程上分布式锁

高并发场景下的分布式锁

redis使用setnx作为分布式锁,多个线程setnx调用时,有且仅有一个线程会拿到这把锁,所以拿到锁的执行业务代码,最后释放掉锁。加大了调用次数,执行业务代码需要一点时间,这段时间拒绝了很多等待获取锁的请求。假如redis服务挂掉了,抛出异常了,这时锁不会被释放掉,出现死锁问题,可以添加try finally处理,Redis服务挂掉导致死锁的问题解决了,但是,如果服务器果宕机了,又会导致锁不能被释放的现象,所以可以设置超时时间为10s。如果有一个线程执行需要15s,当执行到10s时第二个线程进来拿到这把锁,会出现多个线程拿到同一把锁执行,在第一个线程执行完时会释放掉第二个线程的锁,以此类推…就会导致锁的永久失效。所以,只能自己释放自己的锁,可以给当前线程取一个名字,永久失效的问题解决了,但是,如果第一个线程执行15s,还是会存在多个线程拥有同一把锁的现象。所以,需要续期超时时间,当一个线程执行5s后对超时时间进行续期都10s,就可以解决了,续期设置可以借助redission工具,加锁成功,后台新开一个线程,每隔10秒检查是否还持有锁,如果持有则延长锁的时间,如果加锁失败一直循环(自旋)加锁。

分布式锁保证了数据的准确性,但是减低了并发能力,同一个锁key,同一时间只能有一个客户端拿到锁,其他客户端会陷入无限的等待来尝试获取那个锁,只有获取到锁的客户端才能执行业务逻辑。

缺陷就是同一个商品多用户同时下单的时候,会基于分布式锁串行化处理,导致没法同时处理同一个商品的大量下单的请求。

假设库存超卖就是用分布式锁来解决,机器一秒钟只能处理针对iphone的50个订单,而且一秒对一个iphone下上千订单,怎么优化?

  • 分段加锁。你想,假如你现在iphone有1000个库存,那么你完全可以给拆成20个库存段,要是你愿意,可以在数据库的表里建20个库存字段,比如stock_01,stock_02,类似这样的,也可以在redis之类的地方放20个库存key。
  • 总之,就是把你的1000件库存给他拆开,每个库存段是50件库存,比如stock_01对应50件库存,stock_02对应50件库存。
  • 接着,每秒1000个请求过来了,好!此时其实可以是自己写一个简单的随机算法,每个请求都是随机在20个分段库存里,选择一个进行加锁。
  • bingo!这样就好了,同时可以有最多20个下单请求一起执行,每个下单请求锁了一个库存分段,然后在业务逻辑里面,就对数据库或者是Redis中的那个分段库存进行操作即可,包括查库存 -> 判断库存是否充足 -> 扣减库存。
  • 这相当于什么呢?相当于一个20毫秒,可以并发处理掉20个下单请求,那么1秒,也就可以依次处理掉20 * 50 = 1000个对iphone的下单请求了。
  • 一旦对某个数据做了分段处理之后,有一个坑大家一定要注意:就是如果某个下单请求,咔嚓加锁,然后发现这个分段库存里的库存不足了,此时咋办?
  • 这时你得自动释放锁,然后立马换下一个分段库存,再次尝试加锁后尝试处理。这个过程一定要实现。

方案实现的缺点,很不方便啊!实现太复杂了。

热点数据缓存

当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常 大重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的SQL、多次IO、多个依赖等在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。

解决方案一:互斥锁(mutex)

只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据。

  • 1)从Redis获取数据,如果值不为空,则直接返回值;否则执行下面的2.1)和2.2)步骤
  • 2.1)如果set(nx和ex)结果为true,说明此时没有其他线程重建缓存, 那么当前线程执行缓存构建逻辑
  • 2.2)如果set(nx和ex)结果为false,说明此时已经有其他线程正在执 行构建缓存的工作,那么当前线程将休息指定时间(例如这里是50毫秒,取决于构建缓存的速度)后,重新执行函数,直到获取到数据。

优缺点:如果构建缓存过程出现问题或者时间较长,可能会存在死锁和线程池阻塞的风险,但是这种方法能够较好地降低后端存储负载,并在一致性上做得比较好。

解决方案二:永远不过期

从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期 后产生的问题,也就是“物理”不过期。

从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻 辑过期时间后,会使用单独的线程去构建缓存。

优缺点:由于没有设置真正的过期时间,实际上已经不存在热点key产生的一系列危害,但是会存在数据不一致的情况,同时代码复杂度会增大。

怎么知道哪些数据是热点数据?因为本地缓存资源有限,不可能把所有的商品数据进行缓存,它只会缓存热点的数据。那怎么知道数据是热点数据呢?

利用redis4.x自身特性,LFU机制发现热点数据。实现很简单,只要把redis内存淘汰机制设置为allkeys-lfu或者volatile-lfu方式,再执行

./redis-cli --hotkeys

会返回访问频率高的key,并从高到底的排序,在设置key时,需要把商品id带上,这样就是知道是哪些商品了。

哨兵机制

哨兵是一个分布式系统,你可以在一个架构中运行多个哨兵进程,这些进程使用流言协议来接收关于主节点是否下线的信息,并使用投票协议来决定是否执行自动故障迁移,以及选择哪个备节点作为新的主节点。每个哨兵会向其它哨兵、主节点、备节点定时发送消息,以确认对方是否”活”着,如果发现对方在指定时间(可配置)内未回应,则暂时认为对方已挂.若“哨兵群”中的多数哨兵,都报告某一主节点没响应,系统才认为该主节点"彻底死亡",通过算法,从剩下的备节点中,选一台提升为主节点,然后自动修改相关配置。

可以通过修改sentinel.conf配置文件,配置主节点名称,IP,端口号,选举次数,主服务器的密码,心跳检测毫秒数,做多少个节点等。

持久化

RDB

在默认情况下, Redis 将内存数据库快照保存在名字为 dump.rdb 的二进制文件中。

可以对 Redis 进行设置, 让它在“ N 秒内数据集至少有 M 个改动”这一条件被满足时, 自动保存一次数据集。比如说, 以下设置会让 Redis 在满足“ 60 秒内有至少有 1000 个键被改动”这一条件时, 自动保存一次数据集:# save 60 1000 //关闭RDB只需要将所有的save保存策略注释掉即可。还可以手动执行命令生成RDB快照,进入redis客户端执行命令save或bgsave可以生成dump.rdb文件,每次命令执行都会将所有redis内存快照到一个新的rdb文件里,并覆盖原有rdb快照文件。

bgsave的写时复制(COW)机制

Redis 借助操作系统提供的写时复制技术(Copy-On-Write, COW),在生成快照的同时,依然可以正常处理写命令。简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。此时,如果主线程对这些数据也都是读操作,那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据,那么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。

命令

save

bgsave

IO类型

同步

异步

是否阻塞redis其它命令

否(在生成子进程执行调用fork函数时会有短暂阻塞)

复杂度

O(n)

O(n)

优点

不会消耗额外内存

不阻塞客户端命令

缺点

阻塞客户端命令

需要fork子进程,消耗内存

快照功能并不是非常耐久(durable): 如果 Redis 因为某些原因而造成故障停机, 那么服务器将丢失最近写入、且仍未保存到快照中的那些数据。从 1.1 版本开始, Redis 增加了一种完全耐久的持久化方式: AOF 持久化,将修改的每一条指令记录进文件appendonly.aof中,先写入os cache,每隔一段时间fsync。

AOF

通过修改配置文件​appendonly yes 打开 AOF 功能,这样每当 Redis 执行一个改变数据集的命令时(比如 SET), 这个命令就会被追加到 AOF 文件的末尾。

这样的话, 当 Redis 重新启动时, 程序就可以通过重新执行 AOF 文件中的命令来达到重建数据集的目的。

 可以通过配置多久持久化一次:

appendfsync always:每次有新命令追加到 AOF 文件时就执行一次 fsync ,非常慢,也非常安全
appendfsync everysec:每秒 fsync 一次,足够快,并且在故障时只会丢失 1 秒钟的数据。
appendfsync no:从不 fsync ,将数据交给操作系统来处理。更快,也更不安全的选择。

推荐(并且也是默认)的措施为每秒 fsync 一次, 这种 fsync 策略可以兼顾速度和安全性。

AOF文件里可能有太多没用指令,比如查询之类的并没有对redis值进行修改,所以AOF会定期根据内存的最新数据生成aof文件,通过配置也可以控制AOF自动重写频率

# auto-aof-rewrite-min-size 64mb   //aof文件至少要达到64M才会自动重写,文件太小恢复速度本来就很快,重写的意义不大
# auto-aof-rewrite-percentage 100  //aof文件自上一次重写后文件大小增长了100%则再次触发重写

当然AOF还可以手动重写,进入redis客户端执行命令bgrewriteaof重写AOF。AOF重写redis会fork出一个子进程去做(与bgsave命令类似),不会对redis正常命令处理有太多影响。

重启 Redis 时,我们很少使用 RDB来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 RDB来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。 Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。

通过配置开启:# aof-use-rdb-preamble yes 开启混合持久化

如果开启了混合持久化,AOF在重写时,不再是单纯将内存数据转换为RESP命令写入AOF文件,而是将重写这一刻之前的内存做RDB快照处理,并且将RDB快照内容和增量的AOF修改内存数据的命令存在一起,都写入新的AOF文件,新的文件一开始不叫appendonly.aof,等到重写完新的AOF文件才会进行改名,覆盖原有的AOF文件,完成新旧两个AOF文件的替换。于是在 Redis 重启的时候,可以先加载 RDB 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,因此重启效率大幅得到提升。

redis主从架构持久化

主从架构下的数据部分复制(断点续传)

当master和slave断开重连后,一般都会对整份数据进行复制。但从redis2.8版本开始,redis改用可以支持部分数据复制的命令PSYNC去master同步数据,slave与master能够在网络连接断开重连后只进行部分数据复制(断点续传)。master会在其内存中创建一个复制数据用的缓存队列,缓存最近一段时间的数据,master和它所有的slave都维护了复制的数据下标offset和master的进程id,因此,当网络连接断开后,slave会请求master继续进行未完成的复制,从所记录的数据下标开始。如果master进程id变化了,或者从节点数据下标offset太旧,已经不在master的缓存队列里了,那么将会进行一次全量数据的复制。

消息丢失,消息重复消费,消息顺序性,大规模消息积压发生的场景和解决方案,几种消息队列的区别以及选型

消息丢失

生产者弄丢了数据

消息生产者和消息系统一般都是独立部署在不同的服务器上,两台服务器之间要通信就要通过网络来完成,网络不稳定可能会发生抖动,那么数据就有可能会丢失,网络发生抖动会有以下两种情况:

  • 情形一:消息在传给消息系统的过程中会发生网络抖动,数据直接丢失。

  • 情形二:消息已经达到消息系统,但是消息系统再给生产这服务器返回信息室,网络发生抖动,此时的数据不一定真正的丢失,很可能只是生产者认为数据丢失。

针对消息在消息生产时丢失,可以采用重投机制,当程序检测到网络异常时,小消息再次投到消息系统。但是当重新投递在情形二情况下,可能造成数据重复。

  • 事务功能: 此时可以选择用 RabbitMQ 提供的事务功能,就是生产者发送数据之前开启 RabbitMQ 事务channel.txSelect,然后发送消息,如果消息没有成功被 RabbitMQ 接收到,那么生产者会收到异常报错,此时就可以回滚事务channel.txRollback,然后重试发送消息;如果收到了消息,那么可以提交事务channel.txCommit。 但是问题是,RabbitMQ 事务机制(同步)太耗性能,会降低吞吐量。
  • Confirm 模式: 所以一般来说,如果用户需要确保写 RabbitMQ 的消息别丢,可以开启 confirm 模式,在生产者那里设置开启 confirm 模式之后,用户每次写的消息都会分配一个唯一的 id,然后如果写入了 RabbitMQ 中,RabbitMQ 会给用户回传一个 ack 消息,告诉用户说这个消息 ok 了。如果 RabbitMQ 没能处理这个消息,会回调用户的一个 nack 接口,告诉用户这个消息接收失败,用户可以重试。而且用户可以结合这个机制自己在内存里维护每个消息 id 的状态,如果超过一定时间还没接收到这个消息的回调,那么用户可以重发。
  • 区别: 事务机制和 confirm 机制最大的不同在于,事务机制是同步的,用户提交一个事务之后会阻塞在那儿,但是 confirm 机制是异步的,你发送个消息之后就可以发送下一个消息,然后那个消息 RabbitMQ 接收了之后会异步回调你的一个接口通知你这个消息接收到了。

RabbitMQ 弄丢了数据

RabbitMQ 自己弄丢了数据,此时用户必须开启 RabbitMQ 的持久化,就是消息写入之后会持久化到磁盘,哪怕是 RabbitMQ 自己挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢。除非极其罕见的是,RabbitMQ 还没持久化,自己就挂了,可能导致少量数据丢失,但是这个概率较小。解决方案:持久化可以跟生产者那边的 confirm 机制配合起来,只有消息被持久化到磁盘之后,才会通知生产者 ack 了,所以哪怕是在持久化到磁盘之前,RabbitMQ 挂了,数据丢了,生产者收不到 ack,你也是可以自己重发的。 消息中间件的持久化和redis在持久化数据时并不是每次新增一条就立即存入本地磁盘,而是将数据先写入到操作系统的page cache中,当满足一定条件时,再将page cache中的数据刷入到磁盘。因为这样可以减少对磁盘的随机I/O操作,我们知道随机I/O操作时非常耗时的,这样也提高了系统的性能。在某些极端情况下,可能会造成page cache中的数据丢失,比如突然断电或者机器异常重启操作。要解决pagecache中数据丢失问题,可采用集群部署的方式,来尽量保证数据不丢失。

消费端弄丢了数据

RabbitMQ 如果丢失了数据,主要是因为你消费的时候,刚消费到,还没处理,结果进程挂了,比如重启了,那么就尴尬了,RabbitMQ 认为你都消费了,这数据就丢了。这个时候得用 RabbitMQ 提供的 ack 机制,简单来说,就是用户必须关闭 RabbitMQ 的自动 ack,可以通过一个 api 来调用就行,然后每次用户自己代码里确保处理完的时候,再在程序里 ack 一把。这样的话,如果用户还没处理完,不就没有 ack 了?那 RabbitMQ 就认为你还没处理完,这个时候 RabbitMQ 会把这个消费分配给别的 consumer 去处理,消息是不会丢的。

一条消息消费过程大概分为三步:

  • 消费者拉取消息

  • 消费者处理消息

  • 消费系统更新消费进度

第一步在消息拉取消息时会发生网络抖动异常,第二步在处理消息的时候可能发生一些业务异常,而导致而导致流程并没有走完,如果在第一步第二步发生异常的情况下通知消息系统更新消费进度,那么这条失败的消息就永远不会再处理了,自然就丢失了,其实我们的业务并没有跑完。要避免消息在消费时丢失的情况,可以在消息接收和处理完成之后才更新消费进度,但是在极端情况下会出现消息重复消费的问题,比如某一条消息在处理完成之后消费者宕机了,这时还没有更新消费进度,消费者重启后,这条消息还是会被消费到。

总结:

生产者丢消息:

  • 解决方案一:开启Rabbitmq事务(同步,不推荐)
  • 解决方案二:开启confirm模式(异步,推荐)

MQ丢消息:

  • 解决方案:开启RabbitMq持久化

消费者丢消息:

  • 解决方案:关闭RabbitMQ自动ack

消息重复消费

如何保证消息只被消费一次?消息系统本身不能保证消息仅被消费一次,因为:

  • 消费本身可能重复

  • 下游系统启动拉取重复

  • 失败重试带来的重复

  • 补偿逻辑导致的重复

保证生产者等幂性

保证生产者等幂性,再生产消息的时候,利用雪花算法给消息生成一个全局id,在消息系统中维护消息与id的映射关系,如果在映射表中已经存在相同id,则丢掉这条消息,虽然消息被投递了两次,但实际上就保存了一条,避免了消息重复问题。生产者等幂性跟所选的消息中间件有关系,因为绝大多数情况下消息系统不需要我们自己实现,所以等幂性不太好控制的,消费者等幂性才是我们开发人员控制的重点方向。

保证消费者等幂性

在通用层面,在消费消息时产生全局唯一id,消息被处理成功后,把这个全局id存入数据库中,在处理下一条消息之前,先从数据库中查询这个全局id是否存在,如果存在,则直接放弃该消息。

消息顺序性

一个 queue,多个 consumer。比如,生产者向 RabbitMQ 里发送了三条数据,顺序依次是 data1/data2/data3,压入的是 RabbitMQ 的一个内存队列。有三个消费者分别从 MQ 中消费这三条数据中的一条,结果消费者 2 先执行完操作,把 data2 存入数据库,然后是 data1/data3。这不明显乱了。

解决方案:

拆分多个 queue,每个 queue 一个 consumer,就是多一些 queue 而已,确实是麻烦点;或者就一个 queue 但是对应一个 consumer,然后这个 consumer 内部用内存队列做排队,然后分发给底层不同的 worker 来处理。

消息积压

大量消息在mq里积压了几个小时了还没解决

  • 先修复consumer的问题,确保其恢复消费速度,然后将现有consumer都停掉;
  • 新建一个topic,partition是原来的10倍,临时建立好原先10倍或者20倍的queue数量;
  • 然后写一个临时的分发数据的consumer程序,这个程序部署上去消费积压的数据,消费之后不做耗时的处理,直接均匀轮询写入临时建立好的10倍数量的queue;
  • 接着临时征用10倍的机器来部署consumer,每一批consumer消费一个临时queue的数据;
  • 这种做法相当于是临时将queue资源和consumer资源扩大10倍,以正常的10倍速度来消费数据;
  • 等快速消费完积压数据之后,得恢复原先部署架构,重新用原先的consumer机器来消费消息;

对于 RocketMQ,官方针对消息积压问题,提供了解决方案

  •  提高消费并行度:绝大部分消息消费行为都属于 IO 密集型,即可能是操作数据库,或者调用 RPC,这类消费行为的消费速度在于后端数据库或者外系统的吞吐量,通过增加消费并行度,可以提高总的消费吞吐量,但是并行度增加到一定程度,反而会下降。所以,应用必须要设置合理的并行度。
  • 批量方式消费:某些业务流程如果支持批量方式消费,则可以很大程度上提高消费吞吐量,例如订单扣款类应用,一次处理一个订单耗时 1 s,一次处理 10 个订单可能也只耗时 2 s,这样即可大幅度提高消费的吞吐量。
  • 跳过非重要消息:发生消息堆积时,如果消费速度一直追不上发送速度,如果业务对数据要求不高的话,可以选择丢弃不重要的消息。例如,当某个队列的消息数堆积到 100000 条以上,则尝试丢弃部分或全部消息,这样就可以快速追上发送消息的速度。
  • 优化每条消息消费过程:把 多 次 DB 交互优化,减少和DB交互的次数,那么总耗时就可以减少,总体性能就可以提高。所以应用如果对时延敏感的话,可以把 DB 部署在 SSD 硬盘,相比于 SCSI 磁盘,前者的 RT 会小很多。

消息队列的消息过期失效了

rabbitmq是可以设置过期时间的,就是TTL,如果消息在queue中积压超过一定的时间就会被rabbitmq给清理掉,这个数据就没了。解决办法:批量重导,这个我们之前线上也有类似的场景干过。就是大量积压的时候,我们当时就直接丢弃数据了,然后等过了高峰期以后,比如大家一起喝咖啡熬夜到晚上12点以后,用户都睡觉了。这个时候我们就开始写程序,将丢失的那批数据,写个临时程序,一点一点的查出来,然后重新灌入mq里面去,把白天丢的数据给他补回来。也只能是这样了。假设1万个订单积压在mq里面,没有处理,其中1000个订单都丢了,你只能手动写程序把那1000个订单给查出来,手动发到mq里去再补一次。

消息队列满了

消息积压在mq里,那么如果你很长时间都没处理掉,此时导致mq都快写满了,咋办?这个还有别的办法吗?没有,谁让你第一个方案执行的太慢了,你临时写程序,接入数据来消费,消费一个丢弃一个,都不要了,快速消费掉所有的消息。然后走第二个方案,到了晚上再补数据吧。

关于mq选用

从公司基础建设力量角度

  1. 中小型软件公司,建议选RabbitMQ,一方面,erlang语言天生具备高并发的特性,而且他的管理界面用起来十分方便。他的弊端也在这里,虽然RabbitMQ是开源的,然而国内有几个能定制化开发erlang的程序员呢?所幸,RabbitMQ的社区十分活跃,可以解决开发过程中遇到的bug,这点对于中小型公司来说十分重要。不考虑RocketMQ和kafka的原因是,一方面中小型软件公司不如互联网公司,数据量没那么大,选消息中间件,应首选功能比较完备的,所以kafka排除。不考虑RocketMQ的原因是,RocketMQ是阿里出品,如果阿里放弃维护RocketMQ,中小型公司一般抽不出人来进行RocketMQ的定制化开发,因此不推荐。
  2. 大型软件公司,根据具体使用在RocketMQ和kafka之间二选一。一方面,大型软件公司,具备足够的资金搭建分布式环境,也具备足够大的数据量。针对RocketMQ,大型软件公司也可以抽出人手对RocketMQ进行定制化开发,毕竟国内有能力改JAVA源码的人,还是相当多的。至于kafka,根据业务场景选择,如果有日志采集功能,肯定是首选kafka了。

从业务场景角度出发

  1. RocketMQ定位于非日志的可靠消息传输(日志场景也OK),目前RocketMQ在阿里集团被广泛应用在订单,交易,充值,流计算,消息推送,日志流式处理,binglog分发等场景。
  2. Kafka是LinkedIn开源的分布式发布-订阅消息系统,目前归属于Apache定级项目。Kafka主要特点是基于Pull的模式来处理消息消费,追求高吞吐量,一开始的目的就是用于日志收集和传输。0.8版本开始支持复制,不支持事务,对消息的重复、丢失、错误没有严格要求,适合产生大量数据的互联网服务的数据收集业务。
  3. RabbitMQ是使用Erlang语言开发的开源消息队列系统,基于AMQP协议来实现。AMQP的主要特征是面向消息、队列、路由(包括点对点和发布/订阅)、可靠性、安全。AMQP协议更多用在企业系统内,对数据一致性、稳定性和可靠性要求很高的场景,对性能和吞吐量的要求还在其次。

Dubbo底层运行原理,特性,支持的协议,容错策略,负载均衡策略,Zookeeper底层原理,选举机制,假死以及解决方案,ZooKeeper典型使用场景,Spring MVC工作原理,Mybatis框架优点

工作流程

服务注册与发现

1、Provider(提供者)绑定指定端口并启动服务
2、供者连接注册中心,并发本机 IP、端口、应用信息和提供服务信息发送至注册中心存储
3、Consumer(消费者),连接注册中心 ,并发送应用信息、所求服务信息至注册中心
4、注册中心根据消费者所求服务信息匹配对应的提供者列表发送至Consumer 应用缓存。
5、Consumer 在发起远程调用时基于缓存的消费者列表择其一发起调用。
6、Provider 状态变更会实时通知注册中心、在由注册中心实时推送至Consumer设计的原因:Consumer 与 Provider 解偶,双方都可以横向增减节点数。注册中心对本身可做对等集群,可动态增减节点,并且任意一台宕掉后,将自动切换到另一台
7、去中心化,双方不直接依懒注册中心,即使注册中心全部宕机短时间内也不会影响服务的调用
8、服务提供者无状态,任意一台宕掉后,不影响使用

dubbo的特性

  • 透明远程调用:就像调用本地方法一样调用远程方法;只需简单配置,没有任何 API 侵入
  • 负载均衡机制:Client 端 LB,可在内网替代 F5 等硬件负载均衡器
  • 容错重试机制:服务 Mock 数据,重试次数、超时机制等
  • 自动注册发现:注册中心基于接口名查询服务提 供者的 IP 地址,并且能够平滑添加或删除服务提供者
  • 性能日志监控:Monitor 统计服务的调用次调和调用时间的监控中心
  • 服务治理中心:路由规则,动态配置,服务降级,访问控制,权重调整,负载均衡,等手动配置
  • 自动治理中心:无,比如:熔断限流机制、自动权重调整等(因此可以搭配SpringCloud的熔断机制等进行开发)

协议

Dubbo协议:dubbo 缺省协议 采用单一长连接和NIO异步通讯,适合于小数据量大并发的服务调用,以及服务消费者机器数远大于服务提供者机器数的情况,不适合传送大数据量的服务,比如传文件,传视频等,除非请求量很低。

连接个数:单连接
连接方式:长连接
传输协议:TCP
传输方式:NIO异步传输
序列化:Hessian 二进制序列化
适用范围:传入传出参数数据包较小(建议小于100K),消费者比提供者个数多,单一消费者无法压满提供者,尽量不要用dubbo协议传输大文件或超大字符串。
适用场景:常规远程服务方法调用

rmi 协议:RMI协议采用JDK标准的java.rmi.*实现,采用阻塞式短连接JDK标准序列化方式 。

  • 连接个数:多连接
  • 连接方式:短连接
  • 传输协议:TCP
  • 传输方式:同步传输
  • 序列化:Java标准二进制序列化
  • 适用范围:传入传出参数数据包大小混合,消费者与提供者个数差不多,可传文件。
  • 适用场景:常规远程服务方法调用,与原生RMI服务互操作

http 协议:基于http表单的远程调用协议

  • 连接个数:多连接
  • 连接方式:短连接
  • 传输协议:HTTP
  • 传输方式:同步传输
  • 序列化:表单序列化 ,即 json
  • 适用范围:传入传出参数数据包大小混合,提供者比消费者个数多,可用浏览器查看,可用表单或URL传入参数,暂不支持传文件。
  • 适用场景:需同时给应用程序和浏览器JS使用的服务。

容错

容错机制调用流程

1、Cluster 将 Directory 中的多个 Invoker 伪装成一个Invoker,对上层透明,伪装过程包含了容错逻辑
2、Router 负责从多个 Invoker 中按路由规则选出子集,比如读写分离,应用隔离等
3、LoadBalance 负责从多个 Invoker 中选出具体的一个用于本次调用,选的过程包含了负载均衡算法

Dubbo 官网提出总共有六种容错策略
1、Failover Cluster失败自动切换,当出现失败,重试其它服务器。(默认)
2、Failfast Cluster快速失败,只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。
3、Failsafe Cluster失败安全,出现异常时,直接忽略。通常用于写入审计日志等操作。
4、Failback Cluster失败自动恢复,后台记录失败请求,定时重发。通常用于消息通知操作。
5、Forking Cluster并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。可通过 forks=”2”来设置最大并行数。
6、Broadcast Cluster广播调用所有提供者,逐个调用,任意一台报错则报错。(2.1.0 开始支持) 通常用于通知所有提供者更新缓存或日志等本地资源信息。

负载均衡

  • 1、Random LoadBalance,随机(默认的负载均衡策略)是加权随机算法的具体实现,可以完全随机,也可以按权重设置随机概率。
  • 2、RoundRobin LoadBalance,轮循。可以轮询和加权轮询。存在响应慢的提供者会累积请求的问题,比如:第二台机器很慢,但没挂,当请求调到第二台时就卡在那,久而久之,所有请求都卡在调到第二台上。
  • 3、LeastActive LoadBalance,最少活跃调用数。活跃调用数越小,表明该服务提供者效率越高,单位时间内可处理更多的请求。此时应优先将请求分配给该服务提供者。
  • 4、ConsistentHash LoadBalance,一致性 Hash。一致性 Hash 算法,相同参数的请求一定分发到一个 provider 上去。provider 挂掉的时候,会基于虚拟节点均匀分配剩余的流量,抖动不会太大。

zookeeper是什么?

Zookeeper会维护一个类似于标准的文件系统的具有层次关系的数据结构。这个文件系统中每个子目录项都被称为znode节点,这个znode节点也可以有子节点,每个节点都可以存储数据,客户端也可以对这些node节点进行getChildren,getData,exists方法,同时也可以在znode tree路径上设置watch(类似于监听),当watch路径上发生节点create、delete、update的时候,会通知到client。client可以得到通知后,再获取数据,执行业务逻辑操作。Zookeeper 的作用主要是用来维护和监控存储的node节点上这些数据的状态变化,通过监控这些数据状态的变化,从而达到基于数据的集群管理。

为什么要用zookeeper作为dubbo的注册中心?能选择其他的吗?

Zookeeper的数据模型是由一系列的Znode数据节点组成,和文件系统类似。zookeeper的数据全部存储在内存中,性能高;zookeeper也支持集群,实现了高可用;同时基于zookeeper的特性,也支持事件监听(服务的暴露方发生变化,可以进行推送),所以zookeeper适合作为dubbo的注册中心区使用。redis、Simple也可以作为dubbo的注册中心来使用。

zookeeper工作原理

官方定义:当一个集群的不同部分在同一时间都认为自己是活动的时候,我们就可以将这个现象称为脑裂症状。通俗的说,就是比如当你的 cluster 里面有两个结点,它们都知道在这个 cluster 里需要选举出一个 master。那么当它们两之间的通信完全没有问题的时候,就会达成共识,选出其中一个作为 master。但是如果它们之间的通信出了问题,那么两个结点都会觉得现在没有 master,所以每个都把自己选举成 master。于是 cluster 里面就会有两个 master

ZooKeeper每个节点都尝试注册一个象征master的临时节点,其他没有注册成功的则成为slaver,并且通过watch机制监控着master所创建的临时节点,Zookeeper通过内部心跳机制来确定master的状态,一旦master出现意外Zookeeper能很快获悉并且通知其他的slaver,其他slaver在之后作出相关反应。这样就完成了一个切换。

Zookeeper的核心是原子广播,这个机制保证了各个Server之间的同步。实现这个机制的协议叫做Zab协议。Zab协议有两种模式,它们分别是恢复模式(选主)和广播模式(同步)。当服务启动或者在领导者崩溃后,Zab就进入了恢复模式,当领导者被选举出来,且大多数Server完成了和leader的状态同步以后,恢复模式就结束了。状态同步保证了leader和Server具有相同的系统状态。

为了保证事务的顺序一致性,zookeeper采用了递增的事务id号(zxid)来标识事务。所有的提议(proposal)都在被提出的时候加上了zxid。实现中zxid是一个64位的数字,它高32位是epoch用来标识leader关系是否改变,每次一个leader被选出来,它都会有一个新的epoch,标识当前属于那个leader的统治时期。低32位用于递增计数。

每个Server在工作过程中有三种状态:

  • LOOKING:当前Server不知道leader是谁,正在搜寻
  • LEADING:当前Server即为选举出来的leader
  • FOLLOWING:leader已经选举出来,当前Server与之同步

zookeeper集群数量为什么是单数?

zookeeper有这样一个特性:集群中只要有过半的机器是正常工作的,那么整个集群对外就是可用的。也就是说如果有2个zookeeper,那么只要有1个死了zookeeper就不能用了,因为1没有过半,所以2个zookeeper的死亡容忍度为0;同理,要是有3个zookeeper,一个死了,还剩下2个正常的,过半了,所以3个zookeeper的容忍度为1;同理你多列举几个:2->0;3->1;4->1;5->2;6->2会发现一个规律,2n和2n-1的容忍度是一样的,都是n-1,所以为了更加高效,何必增加那一个不必要的zookeeper呢。

领导者选举

领导者选举的过程实际上就是比较哪台服务器比较强,比较规则是:1. 谁的数据比较新谁当领导(zxid),2.数据都一样则看谁的服务器Id(myid)比较大,谁就是领导;这个过程是通过各个服务器之间相互投票来进行的,每台服务器会接收其他服务器的投票,在投票信息里就会包含上面说的两个信息zxid, myid,然后进行PK,选出谁比较强,而PK中弱的那一方修改自己的投票,改为投刚刚和自己PK赢的一方,所以按照这个规则,每台服务器都会有一个自己认为最强的那个人,而在整个投票的过程中,每台服务器内部都会存在一个投票箱,该投票箱内存放了其他服务器当前投给了谁,所以每台服务器可以根据这个投票箱内的数据来看是否有超过半数的服务器和我当前投的最强者是同一台服务器,如果超过了则认为选出了Leader(自己当前所投的那个最强者即为Leader),如果发现自己就是这个最强者,则进行领导,如果自己不是,则进行跟随(Follower)。

假死

心跳出现超时可能是master挂了,但是也可能是master,zookeeper之间网络出现了问题,也同样可能导致。这种情况就是假死,master并未死掉,但是与ZooKeeper之间的网络出现问题导致Zookeeper认为其挂掉了然后通知其他节点进行切换,这样slaver中就有一个成为了master,但是原本的master并未死掉,这时候client也获得master切换的消息,但是仍然会有一些延时,zookeeper需要通讯需要一个一个通知,这时候整个系统就很混乱可能有一部分client已经通知到了连接到新的master上去了,有的client仍然连接在老的master上如果同时有两个client需要对master的同一个数据更新并且刚好这两个client此刻分别连接在新老的master上,就会出现很严重问题。

解决方案:

1、添加心跳线。

原来两个namenode之间只有一条心跳线路,此时若断开,则接收不到心跳报告,判断对方已经死亡。此时若有2条心跳线路,一条断开,另一条仍然能够接收心跳报告,能保证集群服务正常运行。2条心跳线路同时断开的可能性比1条心跳线路断开的小得多。再有,心跳线路之间也可以HA(高可用),这两条心跳线路之间也可以互相检测,若一条断开,则另一条马上起作用。正常情况下,则不起作用,节约资源。

2、启用磁盘锁。

由于两个active会争抢资源,导致从节点不知道该连接哪一台namenode,可以使用磁盘锁的形式,保证集群中只能有一台namenode获取磁盘锁,对外提供服务,避免数据错乱的情况发生。但是,也会存在一个问题,若该namenode节点宕机,则不能主动释放锁,那么其他的namenode就永远获取不了共享资源。因此,在HA上使用"智能锁"就成为了必要措施。"智能锁"是指active的namenode检测到了心跳线全部断开时才启动磁盘锁,正常情况下不上锁。保证了假死状态下,仍然只有一台namenode的节点提供服务。

3、设置仲裁机制

脑裂导致的后果最主要的原因就是从节点不知道该连接哪一台namenode,此时如果有一方来决定谁留下,谁放弃就最好了。因此出现了仲裁机制,比如提供一个参考的IP地址,当出现脑裂现象时,双方接收不到对方的心跳机制,但是能同时ping参考IP,如果有一方ping不通,那么表示该节点网络已经出现问题,则该节点需要自行退出争抢资源的行列,或者更好的方法是直接强制重启,这样能更好的释放曾经占有的共享资源,将服务的提供功能让给功能更全面的namenode节点。

ZooKeeper典型使用场景一览

场景类别

典型场景描述(ZK特性,使用方法)

应用中的具体使用

数据发布与订阅

发布与订阅即所谓的配置管理,顾名思义就是将数据发布到zk节点上,供订阅者动态获取数据,实现配置信息的集中式管理和动态更新。例如全局的配置信息,地址列表等就非常适合使用。

1. 索引信息和集群中机器节点状态存放在zk的一些指定节点,供各个客户端订阅使用。2. 系统日志(经过处理后的)存储,这些日志通常2-3天后被清除。

3. 应用中用到的一些配置信息集中管理,在应用启动的时候主动来获取一次,并且在节点上注册一个Watcher,以后每次配置有更新,实时通知到应用,获取最新配置信息。

4. 业务逻辑中需要用到的一些全局变量,比如一些消息中间件的消息队列通常有个offset,这个offset存放在zk上,这样集群中每个发送者都能知道当前的发送进度。

5. 系统中有些信息需要动态获取,并且还会存在人工手动去修改这个信息。以前通常是暴露出接口,例如JMX接口,有了zk后,只要将这些信息存放到zk节点上即可。

Name Service

这个主要是作为分布式命名服务,通过调用zk的create node api,能够很容易创建一个全局唯一的path,这个path就可以作为一个名称。

分布通知/协调

ZooKeeper 中特有watcher注册与异步通知机制,能够很好的实现分布式环境下不同系统之间的通知与协调,实现对数据变更的实时处理。使用方法通常是不同系统都对 ZK上同一个znode进行注册,监听znode的变化(包括znode本身内容及子节点的),其中一个系统update了znode,那么另一个系统能 够收到通知,并作出相应处理。

1. 另一种心跳检测机制:检测系统和被检测系统之间并不直接关联起来,而是通过zk上某个节点关联,大大减少系统耦合。2. 另一种系统调度模式:某系统有控制台和推送系统两部分组成,控制台的职责是控制推送系统进行相应的推送工作。管理人员在控制台作的一些操作,实际上是修改 了ZK上某些节点的状态,而zk就把这些变化通知给他们注册Watcher的客户端,即推送系统,于是,作出相应的推送任务。

3. 另一种工作汇报模式:一些类似于任务分发系统,子任务启动后,到zk来注册一个临时节点,并且定时将自己的进度进行汇报(将进度写回这个临时节点),这样任务管理者就能够实时知道任务进度。

总之,使用zookeeper来进行分布式通知和协调能够大大降低系统之间的耦合。

分布式锁

分布式锁,这个主要得益于ZooKeeper为我们保证了数据的强一致性,即用户只要完全相信每时每刻,zk集群中任意节点(一个zk server)上的相同znode的数据是一定是相同的。锁服务可以分为两类,一个是保持独占,另一个是控制时序。

所 谓保持独占,就是所有试图来获取这个锁的客户端,最终只有一个可以成功获得这把锁。通常的做法是把zk上的一个znode看作是一把锁,通过create znode的方式来实现。所有客户端都去创建 /distribute_lock 节点,最终成功创建的那个客户端也即拥有了这把锁。

控 制时序,就是所有视图来获取这个锁的客户端,最终都是会被安排执行,只是有个全局时序了。做法和上面基本类似,只是这里 /distribute_lock 已经预先存在,客户端在它下面创建临时有序节点(这个可以通过节点的属性控制:CreateMode.EPHEMERAL_SEQUENTIAL来指 定)。Zk的父节点(/distribute_lock)维持一份sequence,保证子节点创建的时序性,从而也形成了每个客户端的全局时序。

集群管理

1. 集群机器监 控:这通常用于那种对集群中机器状态,机器在线率有较高要求的场景,能够快速对集群中机器变化作出响应。这样的场景中,往往有一个监控系统,实时检测集群 机器是否存活。过去的做法通常是:监控系统通过某种手段(比如ping)定时检测每个机器,或者每个机器自己定时向监控系统汇报“我还活着”。 这种做法可行,但是存在两个比较明显的问题:1. 集群中机器有变动的时候,牵连修改的东西比较多。2. 有一定的延时。

利 用ZooKeeper有两个特性,就可以实时另一种集群机器存活性监控系统:a. 客户端在节点 x 上注册一个Watcher,那么如果 x 的子节点变化了,会通知该客户端。b. 创建EPHEMERAL类型的节点,一旦客户端和服务器的会话结束或过期,那么该节点就会消失。

例 如,监控系统在 /clusterServers 节点上注册一个Watcher,以后每动态加机器,那么就往 /clusterServers 下创建一个 EPHEMERAL类型的节点:/clusterServers/{hostname}. 这样,监控系统就能够实时知道机器的增减情况,至于后续处理就是监控系统的业务了。
2. Master选举则是zookeeper中最为经典的使用场景了。

在 分布式环境中,相同的业务应用分布在不同的机器上,有些业务逻辑(例如一些耗时的计算网络I/O处理),往往只需要让整个集群中的某一台机器进行执行, 其余机器可以共享这个结果,这样可以大大减少重复劳动,提高性能,于是这个master选举便是这种场景下的碰到的主要问题。

利用ZooKeeper的强一致性,能够保证在分布式高并发情况下节点创建的全局唯一性,即:同时有多个客户端请求创建 /currentMaster 节点,最终一定只有一个客户端请求能够创建成功。

利用这个特性,就能很轻易的在分布式环境中进行集群选取了。

另外,这种场景演化一下,就是动态Master选举。这就要用到 EPHEMERAL_SEQUENTIAL类型节点的特性了。

上 文中提到,所有客户端创建请求,最终只有一个能够创建成功。在这里稍微变化下,就是允许所有请求都能够创建成功,但是得有个创建顺序,于是所有的请求最终 在ZK上创建结果的一种可能情况是这样: /currentMaster/{sessionId}-1 , /currentMaster/{sessionId}-2 , /currentMaster/{sessionId}-3 ….. 每次选取序列号最小的那个机器作为Master,如果这个机器挂了,由于他创建的节点会马上小时,那么之后最小的那个机器就是Master了。

1. 在搜索系统中,如果集群中每个机器都生成一份全量索引,不仅耗时,而且不能保证彼此之间索引数据一致。因此让集群中的Master来进行全量索引的生成, 然后同步到集群中其它机器。2. 另外,Master选举的容灾措施是,可以随时进行手动指定master,就是说应用在zk在无法获取master信息时,可以通过比如http方式,向 一个地方获取master。

分布式队列

队列方面,我目前感觉有两种,一种是常规的先进先出队列,另一种是要等到队列成员聚齐之后的才统一按序执行。对于第二种先进先出队列,和分布式锁服务中的控制时序场景基本原理一致,这里不再赘述。

第 二种队列其实是在FIFO队列的基础上作了一个增强。通常可以在 /queue 这个znode下预先建立一个/queue/num 节点,并且赋值为n(或者直接给/queue赋值n),表示队列大小,之后每次有队列成员加入后,就判断下是否已经到达队列大小,决定是否可以开始执行 了。这种用法的典型场景是,分布式环境中,一个大任务Task A,需要在很多子任务完成(或条件就绪)情况下才能进行。这个时候,凡是其中一个子任务完成(就绪),那么就去 /taskList 下建立自己的临时时序节点(CreateMode.EPHEMERAL_SEQUENTIAL),当 /taskList 发现自己下面的子节点满足指定个数,就可以进行下一步按序进行处理了。

SpringMVC运行原理

流程说明:

(1)客户端(浏览器)发送请求,直接请求到DispatcherServlet。

(2)DispatcherServlet根据请求信息调用HandlerMapping,解析请求对应的Handler。

(3)解析到对应的Handler后,开始由HandlerAdapter适配器处理。

(4)HandlerAdapter会根据Handler来调用真正的处理器开处理请求,并处理相应的业务逻辑。

(5)处理器处理完业务后,会返回一个ModelAndView对象,Model是返回的数据对象,View是个逻辑上的View。

(6)ViewResolver会根据逻辑View查找实际的View。

(7)DispaterServlet把返回的Model传给View。

(8)通过View返回给请求者(浏览器)
 

Mybaits 的优点

  • 基于 SQL 语句编程,相当灵活,不会对应用程序或者数据库的现有设计造成任何影响,SQL 写在 XML 里,解除 sql 与程序代码的耦合,便于统一管理;提供 XML标签,支持编写动态 SQL 语句,并可重用。
  • 与 JDBC 相比,减少了 50%以上的代码量,消除了 JDBC 大量冗余的代码,不需要手动开关连接;
  • 很好的与各种数据库兼容(因为 MyBatis 使用 JDBC 来连接数据库,所以只要JDBC 支持的数据库 MyBatis 都支持)。
  • 能够与 Spring 很好的集成;
  • 提供映射标签,支持对象与数据库的 ORM 字段关系映射;提供对象关系映射标签,支持对象关系组件维护。

MyBatis 框架的缺点

  1. SQL 语句的编写工作量较大,尤其当字段多、关联表多时,对开发人员编写SQL 语句的功底有一定要求。
  2. SQL 语句依赖于数据库,导致数据库移植性差,不能随意更换数据库。

JVM算法,垃圾收集器,垃圾回收机制,JMM和JVM内存模型,JVM调优,双亲委派机制,堆溢出,栈溢出,方法区溢出,你都有哪些手段用来排查内存溢出?

JVM算法

垃圾判断算法:

  • 引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。对象循环引用问题,即对象A引用对象B的,而在对象B中又引用了对象A,那么对于对象A和对象B来说,其引用计数器都为1,难以判断其是否存活。
  • 可达性分析:从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,即为不可达对象。

垃圾回收算法:

  • “标记-清除”算法:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
  • “复制”算法:它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,降低了内存的利用率,持续复制长生存期的对象则导致效率降低,还有在分配对象较大时,该种算法也存在效率低下的问题。
  • “标记-整理”算法:标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,这种算法克服了复制算法的低效问题,同时克服了标记清除算法的内存碎片化的问题。
  • 分代收集算法:是一种划分的策略,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
     

垃圾回收器

Serial收集器

串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。新生代、老年代使用串行回收;新生代复制算法老年代标记-压缩;垃圾收集的过程中会服务暂停。参数控制:-XX:+UseSerialGC 串行收集器

ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本。新生代并行,老年代串行;新生代复制算法、老年代标记-压缩

参数控制:-XX:+UseParNewGC ParNew收集器 -XX:ParallelGCThreads 限制线程数量参数

Parallel收集器

Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例;新生代复制算法、老年代标记-压缩

参数控制:-XX:+UseParallelGC 使用Parallel收集器+ 老年代串行

Parallel Old 收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供

参数控制:-XX:+UseParallelOldGC使用Parallel收集器+ 老年代并行

CMS 收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器是基于“标记-清除”算法实现的,整个过程分为4个步骤,包括:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要服务暂停。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)

  • 优点:并发收集、低停顿
  • 缺点:产生大量空间碎片、并发阶段会降低吞吐量

参数控制:

  • -XX:+UseConcMarkSweepGC 使用CMS收集器
  • -XX:+ UseCMSCompactAtFullCollection Full GC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长
  • -XX:+CMSFullGCsBeforeCompaction 设置进行几次Full GC后,进行一次碎片整理
  • -XX:ParallelCMSThreads 设定CMS的线程数量(一般情况约等于可用CPU数量)
     

G1 收集器

  • 空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。
  • 可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
     

其他垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分可以不连续Region的集合。

收集步骤:

  1. 标记阶段,首先初始标记(Initial-Mark),这个阶段是停顿的(Stop the World Event),并且会触发一次普通Mintor GC。对应GC log:GC pause (young) (inital-mark)
  2. Root Region Scanning,程序运行过程中会回收survivor区(存活到老年代),这一过程必须在young GC之前完成。
  3. Concurrent Marking,在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那个这个区域会被立即回收(图中打X)。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  4. Remark, 再标记,会有短暂停顿(STW)。再标记阶段是用来收集 并发标记阶段 产生新的垃圾(并发阶段和应用程序一同运行);G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。
  5. Copy/Clean up,多线程清除失活对象,会有STW。G1将回收区域的存活对象拷贝到新区域,清除Remember Sets,并发清空回收区域并把它返回到空闲区域链表中。
  6. 复制/清除过程后。回收区域的活性对象已经被集中回收到深蓝色和深绿色区域。

zgc收集器

最近一个新的GC收集器概念比较火,JDK团队在JDK 11中即将迎来ZGC,这是一个处于实验阶段的,可扩展的低延迟垃圾回收器。它能够处理从几百M到几T的JAVA堆,与G1相比,吞吐量下降不超过15%。

java垃圾回收机制

内存区域与回收策略

对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在线程本地分配缓冲区上分配。少数情况下也可能会直接分配在老年代中(大对象直接分到老年代),分配的规则并不是百分百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

对象分配过程

多数情况下,小对象会在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机会发起一次 Minor GC。通过Minor GC之后,Eden区中绝大部分对象会被回收,而那些存活对象,将会送到Survivor的From区。Survivor又分为2个区,一个是From区,一个是To区。Minor GC后,from里面存活下来的对象,将它挪到to区,对象在From变To , To变From二个存活区切换15次,也就是说只有经历16次Minor GC还能在新生代中存活的对象(长期存活的对象),才会被送到老年代。

大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来 “安置” 它们。虚拟机提供了一个XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。

JVM内存模型

程序计数器(PC)

程序计数器是一块很小的内存空间,用于记录下一条要运行的指令。每个线程都需要一个程序计数器,各个线程之中的计数器相互独立,是线程中私有的内存空间

java虚拟机栈

java虚拟机栈也是线程私有的内存空间,它和java线程同一时间创建,保存了局部变量、部分结果,并参与方法的调用和返回

本地方法栈

本地方法栈和java虚拟机栈的功能相似,java虚拟机栈用于管理Java函数的调用,而本地方法栈用于管理本地方法的调用,但不是由Java实现的,而是由C实现的

java堆

为所有创建的对象和数组分配内存空间,被JVM中所有的线程共享

方法区

也被称为永久区,与堆空间相似,被JVM中所有的线程共享。方法区主要保存的信息是类的元数据,方法区中最为重要的是类的类型信息、常量池、域信息、方法信息,其中运行时常量池就在方法区,对永久区的GC回收,一是GC对永久区常量池的回收;二是永久区对元数据的回收

JMM

在JVM内部使用的java内存模型(JMM)将线程堆栈和堆之间的内存分开
线程堆栈(thread stack):

1.运行在java虚拟机上的每个线程都有自己的线程堆栈(thread stack)

2.线程堆栈还包含正在执行的每个方法的所有局部变量,一个线程只能访问它自己的线程堆栈。由线程创建的局部变量对于除创建它的线程之外的所有其他线程都是不可见的。

3.即使两个线程正在执行完全相同的代码,两个线程仍然会在每个线程堆栈中创建该代码的局部变量,一个线程可能会将一个有限变量的副本传递给另一个线程,但它不能共享原始局部变量本身

堆:

1.堆包含在Java应用程序中创建的所有对象,而不管是不是由线程创建的该对象。

2.堆中的对象可以被具有对象引用的所有线程访问。当一个线程访问一个对象时,它也可以访问该对象的成员变量。

3.如果两个线程同时调用同一个对象上的一个方法,它们都可以访问该对象的成员变量,但每个线程都有自己的局部变量副本

4.堆中的数据是共享的,线程不安全

JVM调优

1.针对JVM堆的设置,一般可以通过-Xms -Xmx限定其最小、最大值,为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,通常把最大、最小设置为相同的值;

2.年轻代和年老代将根据默认的比例(1:2)分配堆内存

.年轻代和年老代设置多大才算合理

1)更大的年轻代必然导致更小的年老代,大的年轻代会延长普通GC的周期,但会增加每次GC的时间;小的年老代会导致更频繁的Full GC

2)更小的年轻代必然导致更大年老代,小的年轻代会导致普通GC很频繁,但每次的GC时间会更短;大的年老代会减少Full GC的频率

如何选择应该依赖应用程序对象生命周期的分布情况: 如果应用存在大量的临时对象,应该选择更大的年轻代;如果存在相对较多的持久对象,年老代应该适当增大。

在抉择时应该根 据以下两点:

(1)本着Full GC尽量少的原则,让年老代尽量缓存常用对象,JVM的默认比例1:2也是这个道理 。

(2)通过观察应用一段时间,看其他在峰值时年老代会占多少内存,在不影响Full GC的前提下,根据实际情况加大年轻代,比如可以把比例控制在1:1。但应该给年老代至少预留1/3的增长空间。

4.在配置较好的机器上(比如多核、大内存),可以为年老代选择并行收集算法-XX:+UseParallelOldGC

5.线程堆栈的设置:每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太了,一般256K就足用。理论上,在内存不变的情况下,减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统

双亲委派模型

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。

类随着它的类加载器一起具备了一种带有优先级的层次关系。

例如类java.lang.Object,它由启动类加载器加载。双亲委派模型保证任何类加载器收到的对java.lang.Object的加载请求,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并用自定义的类加载器加载,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。
 

内存溢出

堆溢出

package kan;
/**
 * VM Args : -Xms1m -Xmx1m
 */
 
import java.util.ArrayList;
import java.util.List;
 
public class Overheap {
 
	static class OOheap {
 
	}
 
	public static void main(String[] args) {
 
		List<OOheap> l = new ArrayList<OOheap>();
		while (true) {
			l.add(new OOheap());
		}
	}
}

栈溢出

/**
 * VM Args : -Xss100k
 * 
 * 
 */
public class OverStack {
 
	public void leakStack() {
		leakStack();
	}
 
	public static void main(String[] args) {
		OverStack o = new OverStack();
		try {
			o.leakStack();
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
}

方法区溢出:(偷懒溢出常量池)

/**
 * VM Args: -XX:PremSize=100k -XX:MaxPremSize=100k
 */
import java.util.ArrayList;
import java.util.List;
 
public class OverPremGen {
 
	public static void main(String[] args) {
 
		List l = new ArrayList();
		int i = 0;
		while(true){
			
			l.add(String.valueOf(i++).intern());
		}
	}
}

JVM 堆内存溢出后,其他线程是否可继续工作?

当一个线程抛出OOM异常后,它所占据的内存资源会全部被释放掉,使用堆的数量,突然间急剧下滑,从而不会影响其他线程的运行!其实发生OOM的线程一般情况下会死亡,也就是会被终结掉,该线程持有的对象占用的heap都会被gc了,释放内存。因为发生OOM之前要进行gc,就算其他线程能够正常工作,也会因为频繁gc产生较大的影响。

什么是溢出和泄漏?

  • 内存溢出:程序在申请内存的时候,没有足够的内存可以分配,导致内存溢出。俗称,内存不够了。
  • 内存泄漏:内存在生命周期完成后,如果得不到及时的释放,就会一直占用内存,造成内存泄漏。随着内存泄漏的堆积,可用的内存空间越来越少,最后会导致内存溢出。

你都有哪些手段用来排查内存溢出?

内存溢出包含很多种情况,我在平常工作中遇到最多的就是堆溢出。有一次线上遇到故障,重新启动后,使用jstat命令,发现Old区在一直增长。我使用jmap命令,导出了一份线上堆栈,然后使用MAT进行分析。通过对GC Roots的分析,我发现了一个非常大的HashMap对象,这个原本是有位同学做缓存用的,但是一个***缓存,造成了堆内存占用一直上升。后来,将这个缓存改成 guava的Cache,并设置了弱引用,故障就消失了。

有什么堆外内存的排查思路?
进程占用的内存,可以使用top命令,看RES段占用的值。如果这个值大大超出我们设定的最大堆内存,则证明堆外内存占用了很大的区域。

使用gdb可以将物理内存dump下来,通常能看到里面的内容。更加复杂的分析可以使用perf工具,或者谷歌开源的gperftools。那些申请内存最多的native函数,很容易就可以找到。

栈溢出

一个栈是大小是固定了的,当你调用的方法太多,线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常,方法递归调用产生这种结果。

还有就是栈的大小可以动态改变,用-Xss改变栈的大小,当尝试扩展的时候无法申请到足够的内存去完成拓展,或者在建立新线程的时候没有足够的内存去创建对应的虚拟机栈,那java虚拟机将会抛出一个OutOfMemoryError异常。(线程启动过多)

栈是线程私有的,他的生命周期与线程相同,每个方法在执行的时候都会创建一个栈帧,用来存储局部变量表,操作数栈,动态链接,方法出口灯信息。

栈溢出的原因:

1. 局部数组过大。当函数内部的数组过大时,有可能导致堆栈溢出。局部变量是存储在栈中的,因此这个很好理解。解决这类问题的办法有两个,一是增大栈空间,二是改用动态分配,使用堆(heap)而不是栈(stack)。

2. 递归调用层次太多。递归函数在运行时会执行压栈操作,当压栈次数太多时,也会导致堆栈溢出。

3. 指针或数组越界。这种情况最常见,例如进行字符串拷贝,或处理用户输入等等。

SpringCould组件说七八个

假设咱们现在开发一个电商网站,要实现支付订单的功能,我们需要有订单服务、库存服务、仓储服务、积分服务,流程为:

订单服务压根儿就不知道人家库存服务在哪台机器上啊!他就算想要发起一个请求,都不知道发送给谁,有心无力!

Eureka

Eureka是微服务架构中的注册中心,专门负责服务的注册与发现。

库存服务、仓储服务、积分服务中都有一个Eureka Client组件,这个组件专门负责将这个服务的信息注册到Eureka Server中。说白了,就是告诉Eureka Server,自己在哪台机器上,监听着哪个端口。而Eureka Server是一个注册中心,里面有一个注册表,保存了各服务所在的机器和端口号。

订单服务里也有一个Eureka Client组件,这个Eureka Client组件会找Eureka Server问一下:库存服务在哪台机器啊?监听着哪个端口啊?仓储服务呢?积分服务呢?然后就可以把这些相关信息从Eureka Server的注册表中拉取到自己本地缓存起来。这时如果订单服务想要调用库存服务,不就可以找自己本地的Eureka Client问一下库存服务在哪台机器?监听哪个端口吗?收到响应后,紧接着就可以发送一个请求过去,调用库存服务扣减库存的那个接口!同理,如果订单服务要调用仓储服务、积分服务,也是如法炮制。

总结一下:

  • Eureka Client:负责将这个服务的信息注册到Eureka Server中
  • Eureka Server:注册中心,里面有一个注册表,保存了各个服务所在的机器和端口号

订单服务确实知道库存服务、积分服务、仓库服务在哪里了,同时也监听着哪些端口号了。但是新问题又来了:难道订单服务要自己写一大堆代码,跟其他服务建立连接、构造请求、解析响应的代码。

Feign

直接就是用注解定义一个 FeignClient接口,然后调用那个接口就可以了,Feign Client会在底层根据你的注解,跟你指定的服务建立连接、构造请求、发起靕求、获取响应、解析响应。

  • 首先,如果你对某个接口定义了@FeignClient注解,Feign就会针对这个接口创建一个动态代理
  • 接着你要是调用那个接口,本质就是会调用 Feign创建的动态代理,这是核心中的核心
  • Feign的动态代理会根据你在接口上的@RequestMapping等注解,来动态构造出你要请求的服务的地址
  • 最后针对这个地址,发起请求、解析响应

Ribbon

库存服务部署在了5台机器上,Feign怎么知道该请求哪台机器呢?

每次请求时Ribbon默认使用轮询算法进行负载均衡选择一台机器,均匀的把请求分发到各个机器上

  • 首先Ribbon会从 Eureka Client里获取到对应的服务注册表,也就知道了所有的服务都部署在了哪些机器上,在监听哪些端口号。
  • 然后Ribbon就可以使用默认的Round Robin算法,从中选择一台机器
  • Feign就会针对这台机器,构造并发起请求。

Hystrix

订单服务在一个业务流程里需要调用三个服务。现在假设订单服务自己最多只有100个线程可以处理请求,然后呢,积分服务不幸的挂了,每次订单服务调用积分服务的时候,都会卡住几秒钟,然后抛出—个超时异常,订单服务的100个线程都会卡在请求积分服务这块,请求订单服务的时候,发现订单服务也挂了,不响应任何请求了。

积分服务挂了,订单服务也可以不用挂,支付订单的时候,只要把库存扣减了,然后通知仓库发货就OK了,积分服务挂了,大不了等他恢复之后,慢慢人肉手工恢复数据!为啥一定要因为一个积分服务挂了,就直接导致订单服务也挂了呢?不可以接受!

Hystrix断路器

Hystrix是隔离、熔断以及降级的一个框架。Hystrix会搞很多个小小的线程池,比如订单服务请求库存服务是一个线程池,请求仓储服务是一个线程池,请求积分服务是一个线程池。每个线程池里的线程就仅仅用于请求那个服务。

调用积分服务的线程都卡死不能工作了啊!但由于订单服务调用库存服务、仓储服务的这两个线程池都是正常工作的,所以这两个服务不会受到任何影响。这个时候如果别人请求订单服务,订单服务还是可以正常调用库存服务扣减库存,调用仓储服务通知发货。积分服务都挂了,每次调用都要去卡住几秒钟干啥呢?有意义吗?当然没有!所以我们直接对积分服务熔断不就得了,比如在5分钟内请求积分服务直接就返回了,不要去走网络请求卡住几秒钟,这个过程,就是所谓的熔断!每次调用积分服务,你就在数据库里记录一条消息,说给某某用户增加了多少积分,因为积分服务挂了,导致没增加成功!这样等积分服务恢复了,你可以根据这些记录手工加一下积分。这个过程,就是所谓的降级。

在ribbon使用断路器:pom文件之后引入依赖,在程序的启动类ServiceRibbonApplication 加@EnableHystrix注解开启Hystrix,业务方法上加上@HystrixCommand注解,注解对该方法创建了熔断器的功能,并指定熔断方法。

Feign是自带断路器的,在D版本的Spring Cloud中,它没有默认打开。需要在配置文件中配置打开它,在配置文件加以下代码:feign.hystrix.enabled=true

在FeignClient的业务接口的注解中加上fallback的指定类,该类需要实现业务接口,并注入到Ioc容器中

Zuul网关

zuul负责网络路由,可以实现统一的降级、限流、认证授权、安全

假设你后台部署了几百个服务,对应有有几百个服务的名称和地址,像android、ios、pc前端、微信小程序、H5等不同请求,需要走不同后端请求,但是每个请求不可能记住服务名称和服务地址吧,这里就可以使用zuul网关。不用去关心后端有几百个服务,就知道有一个网关,所有请求都往网关走,网关会根据请求中的一些特征,将请求转发给后端的各个服务。

config配置中心

在分布式系统中,由于服务数量巨多,为了方便服务配置文件统一管理,实时更新,需要分布式配置中心组件。

支持配置服务放在配置服务的内存中(即本地),也支持放在远程Git仓库中。config 组件中,分两个角色,一是config server,二是config client。config-client可以从config-server获取配置属性。

Bus数据总线

将分布式的节点用轻量的消息代理连接起来。它可以用于广播配置文件的更改或者服务之间的通讯,也可以用于监控。

应用场景:实现通知微服务架构的配置文件的更改。去代码仓库将foo的值改为“foo version 4”,即改变配置文件foo的值。如果是传统的做法,需要重启服务,才能达到配置文件的更新。我们只需要发送post请求:http://localhost:8881/bus/refresh,会发现config-client会重现肚脐配置文件,重新读取配置文件。

案例:当git文件更改的时候,通过pc端用post 向端口为8882的config-client发送请求/bus/refresh/;此时8882端口会发送一个消息,由消息总线向其他服务传递,从而使整个微服务集群都达到更新配置文件。

Sleuth

微服务架构上通过业务来划分服务的,通过REST调用,对外暴露的一个接口,可能需要很多个服务协同才能完成这个接口功能,如果链路上任何一个服务出现问题或者网络超时,都会形成导致接口调用失败。随着业务的不断扩张,服务之间互相调用会越来越复杂。一个 HTTP 请求会调用多个不同的微服务来处理返回最后的结果,在这个调用过程中,可能会因为某个服务出现网络延迟过高或发送错误导致请求失败,所以需要对服务追踪分析,提供一个可视化页面便于排查问题所在。

Sleuth 整合 Zipkin,可以使用它来收集各个服务器上请求链路的跟踪数据,并通过它提供的 REST API 接口来辅助查询跟踪数据以实现对分布式系统的监控程序,从而及时发现系统中出现的延迟过高问题。除了面向开发的 API 接口之外,它还提供了方便的 UI 组件来帮助我们直观地搜索跟踪信息和分析请求链路明细,比如可以查询某段时间内各用户请求的处理时间等。

MySQL优化,索引限制条件

表中字段的宽度设得尽可能小。
尽量把字段设置为NOTNULL,
使用连接(JOIN)来代替子查询(Sub-Queries)
使用联合(UNION)来代替手动创建的临时表
减少表关联,加入冗余字段
使用外键:锁定表的方法可以维护数据的完整性,但是它却不能保证数据的关联性。这个时候我们就可以使用外键。
使用索引
优化的查询语句
集群
读写分离
主从复制
分表分库
适当的时候可以使用存储过程

最左前缀:查询从索引的最左前列开始并且不跳过索引中的列
索引列上不操作,范围之后全失效
不等空值还有OR
like以通配符%开头索引失效会变成全表扫描的操作
字符串不加单引号索引失效

公平锁,非公平锁,可重入锁,递归锁,自旋锁,读写锁,悲观锁,乐观锁,行锁,排它锁,共享锁,表锁,死锁,分布式锁,AQS,Synchronized

  • 公平锁:多个线程按照申请锁的顺序来获取锁的,类似排队打饭,先来后到。每个线程在获取锁的时候会先查看此锁维护的等待队列,如果为空或者当前线程是等待队列的第一个,就占有锁,否则加入到等待队列之后,后面按照规则从队列中获取。
  • 非公平锁:多个线程获取锁的顺序并不是按照申请锁的顺序来到,高并发情况下,后申请的线程可能比先申请的线程优先获取锁。非公平锁上来就直接尝试占有锁,如果尝试失败,在使用公平锁的方式。它的吞吐量比公平锁大。ReentrantLock默认是非公平锁,Synchronized就是非公平锁。
  • 可重入锁(递归锁):同一线程外层方法获取锁之后,内层方法可以自动获取该锁,可以防止死锁,因为是同一把锁。家里的大门有一把锁,厕所没有上锁。我进了大门了,就不用在厕所上锁了。ReentrantLock就是把可重入锁。
  • 自旋锁:获取锁的线程不会立即堵塞,使用循环的方式去尝试获取锁,减少线程上下文切换的消耗,会消耗CPU资源。
  • 独占锁(写锁):锁一次只能被一个线程所持有,ReentrantLock和Synchronized都是独占锁。
  • 共享锁(读锁):锁可以被多个线程持有,可以保证并发的读。ReentrantReadWriteLock的读锁是共享锁,写锁是独占锁。
  • 读写锁:使用ReentrantReadWriteLock解决原子性和独占性,可以很好的解决并发性和数据的一致性。起因:一个线程去写共享资源,其他线程就不能对它进行读写。写的操作原子性和独占性没有得到保证,一个线程正在写入共享资源的时候,其他线程有写入和读取的共享资源操作,导致数据不一致。writeLock和readLock方法都是通过调用Sync方法实现的。AQS的状态state(int类型)是32的,掰成二瓣,读锁使用高16位表示读锁的线程数,写锁使用低16位表示写锁的重入次数。状态值位0表示锁空闲,读状态为2,写状态为3,sharedCount不为0表示分配了读锁,exclusiveCount不为0表示分配了写锁。
private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

写锁:

lock.writeLock().lock();//加上写锁		
try {
	//执行写操作
} catch (InterruptedException e) {
	e.printStackTrace();
}finally {
	lock.writeLock().unlock();//释放锁
}

读锁:

lock.readLock().lock();//加上读锁
try {
	//执行读操作
} catch (InterruptedException e) {
	e.printStackTrace();
}finally {
	lock.readLock().unlock();//释放读锁
}

悲观锁:

每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。适用于写为居多的场景下。比如行锁,表锁等,读锁,写锁,syncronized实现的锁等。sql中实现悲观锁,使用for update对数据加锁,例如:select num from goods where id = 1 for update;

乐观锁:

每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,在表中增加一个版本(version)或时间戳(timestamp)来实现。适用于读为居多的场景下。乐观锁适用于多读的应用类型,这样可以提高吞吐量。

工作流程:

  • 获取当前数据版本
  • 更新操作版本号+1
  • 提交更新时,获取版本号
  • 比较提交时的版本号与第一次获取的版本号,如果一致,那么认为资源是最新的,可以更新
  • 否则回滚或者抛出异常

案例:

  • 事务一开启,男柜员先执行读操作,取出金额和版本号,执行写操作,此时金额改为 120,版本号为1,事务还没有提交
  • 事务二开启,女柜员先执行读操作,取出金额和版本号,执行写操作,此时金额改为 50,版本号变为 1,事务未提交

现在提交事务一,金额改为 120,版本变为1,提交事务。理想情况下应该变为 金额 = 50,版本号 = 2,但是实际上事务二 的更新是建立在金额为 100 和 版本号为 0 的基础上的,所以事务二不会提交成功,应该重新读取金额和版本号,再次进行写操作。这样,就避免了女柜员 用基于 version=0 的旧数据修改的结果覆盖男操作员操作结果的可能。

乐观锁和悲观锁的使用:

(高性能、高可用、高并发)来说,悲观锁的实现用的越来越少了,但是一般多读的情况下还是需要使用悲观锁的,因为虽然加锁的性能比较低,但是也阻止了像乐观锁一样,遇到写不一致的情况下一直重试的时间。使用悲观锁锁住某个数据后,再遇到其他需要修改数据的操作,那么此操作就无法完成金额的修改,对产品来说是灾难性的一刻,使用乐观锁的版本号机制能够解决这个问题。

行锁和表锁:

MySQL常用引擎有MyISAM和InnoDB,而InnoDB是mysql默认的引擎。MyISAM不支持行锁,而InnoDB支持行锁和表锁。

如何加锁?

MyISAM在执行查询语句(SELECT)前,会自动给涉及的所有表加读锁,在执行更新操作(UPDATE、DELETE、INSERT等)前,会自动给涉及的表加写锁,这个过程并不需要用户干预,因此用户一般不需要直接用LOCK TABLE命令给MyISAM表显式加锁。

显式加锁:

  • 上共享锁(读锁)的写法:lock in share mode,例如:select  math from zje where math>60 lock in share mode;
  • 上排它锁(写锁)的写法:for update,例如:select math from zje where math >60 for update;

表锁:不会出现死锁,发生锁冲突几率高,并发低。
MySQL的表级锁有两种模式:

  •     表共享读锁
  •     表独占写锁

读锁会阻塞写,写锁会阻塞读和写

  •     对MyISAM表的读操作,不会阻塞其它进程对同一表的读请求,但会阻塞对同一表的写请求。只有当读锁释放后,才会执行其它进程的写操作。
  •     对MyISAM表的写操作,会阻塞其它进程对同一表的读和写操作,只有当写锁释放后,才会执行其它进程的读写操作。

MyISAM不适合做写为主表的引擎,因为写锁后,其它线程不能做任何操作,大量的更新会使查询很难得到锁,从而造成永远阻塞
行锁:会出现死锁,发生锁冲突几率低,并发高。

在MySQL的InnoDB引擎支持行锁,与Oracle不同,MySQL的行锁是通过索引加载的,也就是说,行锁是加在索引响应的行上的,要是对应的SQL语句没有走索引,则会全表扫描,行锁则无法实现,取而代之的是表锁,此时其它事务无法对当前表进行更新或插入操作。

for update:

如果在一条select语句后加上for update,则查询到的数据会被加上一条排它锁,其它事务可以读取,但不能进行更新和插入操作。

行锁的实现需要注意:

  •     行锁必须有索引才能实现,否则会自动锁全表,那么就不是行锁了。
  •     两个事务不能锁同一个索引。
  •     insert,delete,update在事务中都会自动默认加上排它锁。

行锁场景:
A用户消费,service层先查询该用户的账户余额,若余额足够,则进行后续的扣款操作;这种情况查询的时候应该对该记录进行加锁。否则,B用户在A用户查询后消费前先一步将A用户账号上的钱转走,而此时A用户已经进行了用户余额是否足够的判断,则可能会出现余额已经不足但却扣款成功的情况。为了避免此情况,需要在A用户操作该记录的时候进行for update加锁。

产生死锁的四个必要条件

  • 互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
  • 不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放)。
  • 请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
  • 循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。即存在一个处于等待状态的进程集合{Pl, P2, …, pn},其中Pi等 待的资源被P(i+1)占有(i=0, 1, …, n-1),Pn等待的资源被P0占有

处理死锁的方法

  •     预防死锁:通过设置某些限制条件,去破坏产生死锁的四个必要条件中的一个或几个条件,来防止死锁的发生。
  •     避免死锁:在资源的动态分配过程中,用某种方法去防止系统进入不安全状态,从而避免死锁的发生。
  •     检测死锁:允许系统在运行过程中发生死锁,但可设置检测机构及时检测死锁的发生,并采取适当措施加以清除。
  •     解除死锁:当检测出死锁后,便采取适当措施将进程从死锁状态中解脱出来。

避免死锁的技术

  •     加锁顺序(线程按照一定的顺序加锁)
  •     加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)
  •     死锁检测 (首先为每一个进程和每一个资源指定一个唯一的号码;而后创建资源分配表和进程等待表)

死锁检测工具:

  • Jstack命令:用于打印出给定的java进程ID或core file或远程调试服务的Java堆栈信息,生成java虚拟机当前时刻的线程快照。生成线程快照的主要目的是定位线程出现长时间停顿的缘由,如线程间死锁、死循环、请求外部资源致使的长时间等待等。 线程出现停顿的时候经过jstack来查看各个线程的调用堆栈,就能够知道没有响应的线程到底在后台作什么事情,或者等待什么资源。
  • JConsole工具:用于链接正在运行的本地或者远程的JVM,对运行在Java应用程序的资源消耗和性能进行监控,并画出大量的图表,提供强大的可视化界面。

什么是死锁?锁等待?如何优化这类问题?通过数据库哪些表可以监控?

死锁是指两个或多个事务在同一资源上互相占用,并请求加锁时,而导致的恶性循环现象。当多个事务以不同顺序试图加锁同一资源时,就会产生死锁。

锁等待:mysql数据库中,不同session在更新同行数据中,会出现锁等待

重要的三张锁的监控表innodb_trx,innodb_locks,innodb_lock_waits

 如何优化锁:

1、尽可能让所有的数据检索都通过索引来完成,从而避免Innodb因为无法通过索引键加锁而升级为表级锁定

2、合理设计索引。不经常使用的列最好不加锁

3、尽可能减少基于范围的数据检索过滤条件

分布式锁:

基于数据库实现分布式锁;

对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功。

获取锁:INSERT INTO method_lock (method_name, desc) VALUES ('methodName', 'methodName');

先获取锁的信息: select id, method_name, state,version from method_lock where state=1 and method_name='methodName';

占有锁:update t_resoure set state=2, version=2, update_time=now() where method_name='methodName' and state=1 and version=2;

如果没有更新影响到一行数据,则说明这个资源已经被别人占位了。

缺点:

1、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
3、这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
4、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

解决方案:

1、数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。

2、没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。

3、非阻塞的?搞一个while循环,直到insert成功再返回成功。

4、非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

基于缓存(Redis等)实现分布式锁;

redis使用setnx作为分布式锁,多个线程setnx调用时,有且仅有一个线程会拿到这把锁,所以拿到锁的执行业务代码,最后释放掉锁。加大了调用次数,执行业务代码需要一点时间,这段时间拒绝了很多等待获取锁的请求。假如redis服务挂掉了,抛出异常了,这时锁不会被释放掉,出现死锁问题,可以添加try finally处理,Redis服务挂掉导致死锁的问题解决了,但是,如果服务器果宕机了,又会导致锁不能被释放的现象,所以可以设置超时时间为10s。如果有一个线程执行需要15s,当执行到10s时第二个线程进来拿到这把锁,会出现多个线程拿到同一把锁执行,在第一个线程执行完时会释放掉第二个线程的锁,以此类推…就会导致锁的永久失效。所以,只能自己释放自己的锁,可以给当前线程取一个名字,永久失效的问题解决了,但是,如果第一个线程执行15s,还是会存在多个线程拥有同一把锁的现象。所以,需要续期超时时间,当一个线程执行5s后对超时时间进行续期都10s,就可以解决了,续期设置可以借助redission工具,加锁成功,后台新开一个线程,每隔10秒检查是否还持有锁,如果持有则延长锁的时间,如果加锁失败一直循环(自旋)加锁。

基于Zookeeper实现分布式锁;

客户端A要获取分布式锁的时候首先到locker下创建一个临时顺序节点(node_n),然后立即获取locker下的所有(一级)子节点。此时因为会有多个客户端同一时间争取锁,因此locker下的子节点数量就会大于1。对于顺序节点,特点是节点名称后面自动有一个数字编号,先创建的节点数字编号小于后创建的,因此可以将子节点按照节点名称后缀的数字顺序从小到大排序,这样排在第一位的就是最先创建的顺序节点,此时它就代表了最先争取到锁的客户端!此时判断最小的这个节点是否为客户端A之前创建出来的node_n,如果是则表示客户端A获取到了锁,如果不是则表示锁已经被其它客户端获取,因此客户端A要等待它释放锁,也就是等待获取到锁的那个客户端B把自己创建的那个节点删除。此时就通过监听比node_n次小的那个顺序节点的删除事件来知道客户端B是否已经释放了锁,如果是,此时客户端A再次获取locker下的所有子节点,再次与自己创建的node_n节点对比,直到自己创建的node_n是locker的所有子节点中顺序号最小的,此时表示客户端A获取到了锁!

AQS

AQS即 队列同步器,是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。AQS是将每一条请求共享资源的线程封装成一个FIFO锁队列的一个结点(Node),来实现锁的分配。它是基于FIFO队列,使用模板方法模式,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物。

ReentrantLock为例,(可重入独占式锁):state初始化为0,表示未锁定状态,A线程lock()时,会调用tryAcquire()独占锁并将state+1.之后其他线程再想tryAcquire的时候就会失败,直到A线程unlock()到state=0为止,其他线程才有机会获取该锁。A释放锁之前,自己也是可以重复获取此锁(state累加),这就是可重入的概念。
注意:获取多少次锁就要释放多少次锁,保证state是能回到零态的。

以CountDownLatch为例,任务分N个子线程去执行,state就初始化 为N,N个线程并行执行,每个线程执行完之后countDown()一次,state就会CAS减一。当N子线程全部执行完毕,state=0,会unpark()主调用线程,主调用线程就会从await()函数返回,继续之后的动作。

class Mutex implements Lock, java.io.Serializable {
    // 自定义同步器
    private static class Sync extends AbstractQueuedSynchronizer {
        // 判断是否锁定状态
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        // 尝试获取资源,立即返回。成功则返回true,否则false。
        public boolean tryAcquire(int acquires) {
            assert acquires == 1; // 这里限定只能为1个量
            if (compareAndSetState(0, 1)) {//state为0才设置为1,不可重入!
                setExclusiveOwnerThread(Thread.currentThread());//设置为当前线程独占资源
                return true;
            }
            return false;
        }

        // 尝试释放资源,立即返回。成功则为true,否则false。
        protected boolean tryRelease(int releases) {
            assert releases == 1; // 限定为1个量
            if (getState() == 0)//既然来释放,那肯定就是已占有状态了。只是为了保险,多层判断!
                throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);//释放资源,放弃占有状态
            return true;
        }
    }

    // 真正同步类的实现都依赖继承于AQS的自定义同步器!
    private final Sync sync = new Sync();

    //lock<-->acquire。两者语义一样:获取资源,即便等待,直到成功才返回。
    public void lock() {
        sync.acquire(1);
    }

    //tryLock<-->tryAcquire。两者语义一样:尝试获取资源,要求立即返回。成功则为true,失败则为false。
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    //unlock<-->release。两者语文一样:释放资源。
    public void unlock() {
        sync.release(1);
    }

    //锁是否占有状态
    public boolean isLocked() {
        return sync.isHeldExclusively();
    }
}

Synchronized

Synchronized如何从轻量级到重量级?Synchronized实现原理?

一个对象在内存中分为对象头(MarkWord(hashCode,GC分代年龄,锁信息),指向类的指针,数组长度(只有数组对象才有)),实例变量(存本对象的属性信息),和对其填充(使该对象保持占用8字节的整数倍)。

锁是升级的过程是怎样的?MarkWord是怎么变化的?

1.无锁状态:首先,当对象没有被当初一个锁时,此时MarkWord记录着对象的hashcode,锁标志为为01,是否偏向为0。

2.偏向锁状态:当线程A使用该对象加锁时,MarkWord中将记录该线程的ID,并将是否偏向改为1。即此时使用偏向锁。当某线程再次获取该锁时,会比较线程ID和MarkWord中的线程ID是否一致,如一致则再次获取锁,若不一致说明有竞争,不过由于偏向锁并不会主动释放锁,所以会使用CAS获取锁,获取成功则该线程获取到锁,将MarkWord中的线程ID改为自己ID。锁CAS获取锁失败,进入步骤3.

3.轻量级锁状态:进入此步骤说明,有多个线程竞争同一个锁,那么将在锁升级为轻量级锁。升级过程是,在线程中开辟锁记录空间(Lock Record),用户储存MarkWord的拷贝,并将MarkWord和Lock Record相互指向,此操作为CAS操作。将MarkWord锁标志置为00。如果CAS成功,说明此线程获取到轻量级锁。若失败,进入步骤4.

4.自旋锁:自旋锁不是一种锁状态,而是一种策略。由于此时需要阻塞没有拿到锁的线程。不过阻塞线程需要CPU从用户态转为内核态,开销较大。如果下一刻就可以拿到锁了,一没拿到锁就进入内核态很显然是不合适的。所以,此时使用自旋来减少开销。自旋一段时间成功获得锁,则仍在轻量级锁状态。否则轻量级锁膨胀为重量级锁。即步骤5.

5.重量级锁状态:将锁标志为置为10,将MarkWord中指针指向重量级所monitor。并阻塞所有没有获取到锁的线程。

幂等性实现,单点登录,金额篡改问题,秒杀场景设计,库存超卖问题

幂等性实现

乐观锁:数据库:通过version或者时间戳防止其他操作并发更新,更新失败要有一定的重试机制。CAS比较与交换也是乐观锁。
去重表:在插入数据的时候,插入去重表,利用数据库的唯一索引特性,保证唯一的逻辑。这种方法适用于在业务中有唯一标的插入场景中,比如在以上的支付场景中,如果一个订单只会支付一次,所以订单ID可以作为唯一标识。这时,我们就可以建一张去重表,并且把唯一标识作为唯一索引,在我们实现时,把创建支付单据和写入去去重表,放在一个事务中,如果重复创建,数据库会抛出唯一约束异常,操作就会回滚,不做任何操作,实现了幂等。
悲观锁:select for update,当线程A执行for update,数据会对当前记录加锁,其他线程执行到此行代码的时候,会等待线程A释放锁之后,才可以获取锁,继续后续操作。事物提交时,for update获取的锁会自动释放。如果业务处理比较耗时,并发情况下,后面线程会长期处于等待状态,占用了很多线程,让这些线程处于无效等待状态,我们的web服务中的线程数量一般都是有限的,如果大量线程由于获取for update锁处于等待状态,不利于系统并发操作。
状态机:在消费者业务表中存在状态字段,并在消费消息后是变更状态,状态流转是单向不可逆的。例如:status :[1  → 2  →  3  →  4...] 基于数据库乐观锁CAS方式。例如: `update table set status = 2...  where status = 1`
数据库唯一约束:消息消费者在业务表中需要存储上游业务唯一id,在消息这业务表中加入上游业务唯一id并设置为唯一约束。

单点登录

  1. 用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
  2. sso认证中心发现用户未登录,将用户引导至登录页面
  3. 用户输入用户名密码提交登录申请
  4. sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌
  5. sso认证中心带着令牌跳转会最初的请求地址(系统1)
  6. 系统1拿到令牌,去sso认证中心校验令牌是否有效
  7. sso认证中心校验令牌,返回有效,注册系统1
  8. 系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源
  9. 用户访问系统2的受保护资源
  10. 系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
  11. sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌
  12. 系统2拿到令牌,去sso认证中心校验令牌是否有效
  13. sso认证中心校验令牌,返回有效,注册系统2
  14. 系统2使用该令牌创建与用户的局部会话,返回受保护资源

单点登录过程实质是sso客户端与服务端通信的过程

sso-client

  1. 拦截子系统未登录用户请求,跳转至sso认证中心
  2. 接收并存储sso认证中心发送的令牌
  3. 与sso-server通信,校验令牌的有效性
  4. 建立局部会话
  5. 拦截用户注销请求,向sso认证中心发送注销请求
  6. 接收sso认证中心发出的注销请求,销毁局部会话

sso-server

  1. 验证用户的登录信息
  2. 创建全局会话
  3. 创建授权令牌
  4. 与sso-client通信发送令牌
  5. 校验sso-client令牌有效性
  6. 系统注册
  7. 接收sso-client注销请求,注销所有会话

金额篡改问题

案例:拿到别人的URL,篡改数据(金额)发送给系统

  • 方法一:对插入的操作进行校验:一个请求的URL传入进来,根据参数找到对应的用户关联表,查询到用户的userid和用户登录后保存到redis中的userid进行对比。例如:传入参数为(订单id)和(优惠券id),拿(订单id)查询该订单的用户id,拿来和登录的用户id进行对比,判断是否为本人操作。拿(优惠券id)查询用户表是否领取了该优惠券,该优惠券是否可用。
  • 方法二:前端传入一个加密的信息数据,后端给这个给这个数据解密,判断是否为同一用户。例如:将用户id+项目id+密钥生成一个token,传入后端解密,拿到用户id,项目id,密钥对比是否一致
  • 方法三:权限框架:可以指定某些角色,用户的登录名称密码正确才可以访问,修改。例如:1.Spring Security  2.apache shiro
     

秒杀场景设计

流量过滤

本质上,参与秒杀的用户很多,但是商品的数量是有限的,真正能抢到的用户并不多,那么第一步就是要过滤掉大部分无效的流量。

  • 活动开始前前端页面的Button置灰,防止活动未开始无效的点击产生流量。
  • 前端添加验证码或者答题,防止瞬间产生超高的流量,可以很好的起到错峰的效果,现在的验证码花样繁多,题库有的还要做个小学题,而且题库更新频繁,想暴力破解怕是很难。当然我知道的还有一种人工打码的方式,不过这个也是需要时间的,不像机器无限刷你的接口。
  • 活动校验,既然是活动,那么活动的参与用户,参加条件,用户白名单之类的要首先做一层校验拦截,还有其他的比如用户终端、IP地址、参与活动次数、黑名单用户的校验。比如活动主要针对APP端的用户校验,那么根据参数其他端的用户将被拦截,针对IP、mac地址、设备ID和用户ID可以对用户参与活动的次数做校验,黑名单根据平时的活动经验拦截掉一部分羊毛党等异常用户。
  • 非法请求拦截,做了以上拦截如果还有用户能绕过限制,那不得不说太牛X了。比如双11零点开始还做了答题限制,那么正常人怎么也需要1秒的时间来答题吧,就算单身30年手速我想也不能超过0.5秒了,那么针对刚好0点或者在0.5秒以内的请求就可以完全拦截掉。
  • 限流,使用不同类型的限流器,
  1. 请求限流器:该限流器限制每个用户每秒可发送 N 个请求。
  2. 并发请求限流器:限制每秒最高请求数,这种限流器则是限制最高并发请求数。
  3. 基于使用量的负载降级
  4. 基于 Worker 利用率的负载降级:worker 太忙,无法处理分配给它的请求,它会缓慢降级非关键请求

有什么限流算法?以及如何实现?

令牌桶算法 来进行流量限制。该算法有一个集中的桶,为每一个请求分配一个令牌,并不断地缓慢地在桶中放入令牌。 如果桶为空,则拒绝该请求。在我们的例子中,每个用户都被分配一个桶,每当他们产生一个请求时,我们从这个桶中移除一个令牌。

性能优化:

  • 页面静态化,参与秒杀活动的商品一般都是已知的,可以针对活动页面做静态化处理,缓存到CDN。假设我们一个页面300K大小,1千万用户的流量是多少?这些请求要请求后端服务器、数据库,压力可想而知,缓存到CDN用户请求不经过服务器,大大减小了服务器的压力。
  • 活动预热,针对活动的活动库存可以独立出来,不和普通的商品库存共享服务,活动库存活动开始前提前加载到redis,查询全部走缓存,最后扣减库存再视情况而定。
  • 独立部署,资源充足的情况下可以考虑针对秒杀活动单独部署一套环境,这套环境中可以剥离一些可能无用的逻辑,比如不用考虑使用优惠券、红包、下单后赠送积分的一些场景,或者这些场景可以活动结束后异步的统一发放。这只是一个举例,实际上单独针对秒杀活动的话你肯定有很多无用的业务代码是可以剥离的,这样可以提高不少性能。

超卖:

针对秒杀建议选择下单扣库存的方式

首先查询redis缓存库存是否充足先扣库存再落订单数据,可以防止订单生成了没有库存的超卖问题扣库存的时候先扣数据库库存,再扣减redis库存,保证在同一个事务里,无论两者哪一个发生了异常都会回滚。有一个问题是可能redis扣成功了由于网络问题返回失败,事务回滚,导致数据库和缓存不一致,这样实际少卖了,可以放到下轮秒杀去。

质量保障

为了保证系统的稳定性,防止你的系统被秒杀,一些质量监控就不得不做。

熔断限流降级,老生常谈,根据压测情况进行限流,可以使用sentinel或者hystrix。另外前端后端都该有降级开关。监控,该上的都上,QPS监控、容器监控、CPU、缓存、IO监控等等。演练,大型秒杀事前演练少不了,不能冒冒失失的就上了吧。核对、预案,事后库存订单 金额、数量核对,是否发生超卖了?金额是否正常?都是必须的。预案可以在紧急情况下进行降级。

数据统计

  • 前端埋点
  • 数据大盘,通过后台服务的打点配合监控系统可以通过大盘直观的看到一些活动的监控和数据
  • 离线数据分析,事后活动的数据可以同步到离线数仓做进一步的分析统计

库存超卖

库存超卖问题是有很多种技术解决方案的,比如悲观锁,分布式锁,乐观锁

悲观锁

  • 采用排他锁(悲观锁)
  • 当用户同时到达更新操作,同时到达的用户一个个执行
  • 在当前这个update语句commit之前,其他用户等待执行

分布式锁

  • 采用Redis的队列实现,用于抢购
  • 先从MySQL读取库存数,放到Redis的队列中
  • 用户直接操作队列,当队列为空时提醒售空
  • 当抢购结束后可执行更新库存表操作

redis分布式锁还是zookeeper分布式锁?

redis分布式锁类似于自旋锁的方式需要自己不断尝试去获取锁,这个是比较耗性能的。zk获取不到锁的话则可以注册监听器,不需要不断尝试,这样的活性能开销较小;其次,redis锁有一个问题就是,如果获取到锁的客户端崩溃了或者没有正常释放锁则会导致只能等到过期时间完了才能获取到锁,而zk建立的由于是临时节点,客户端崩溃了或者挂了,临时节点会自动删除,此时会自动释放锁;最后,这个redis的实现方式如果采用RedLock算法的话较为复杂并且还存在争议,普通的算法存在单点故障和主从同步的问题,所以一般来说,个人认为zk分布式锁要比redis分布式锁更可靠并且易用。

乐观锁

采用乐观锁原理
在数据表中加入版本号字段
当读取数据时,将version字段的值一同读出,数据每更新一次,对当前version值加一,当并发数据进行出库操作时新version版本号不同而停止
这样虽然不能避免脏读,但是能避免脏读后对数据产生的影响,对比悲观锁需要一直锁数据来说性能提升很大。

Linux常用命令,生产环境服务器变慢诊断,线上排查,性能评估

  • 查看整机:top
  • 查看CPU:vmstat
  • 查看内存:free
  • 查看硬盘:df
  • 查看磁盘IO:lostat -xdk 间隔秒数 次数
  • 查看网络IO:ifstat

CPU占用过高定位分析思路

使用top命令找出cpu占比最高的

使用ps -ef或者jps进一步定位,得知是怎样的一个后台程序出问题了

使用ps -mp pid(线程id) -o THREAD,tid,time 定位到具体的线程或者代码

使用printf “%x\n” tid 把线程ID转换为16进制格式

使用jstack pid |grep tid -A 30打印线程的堆栈信息,定位具体的行数

看了这么多,在给你面试一下,看看你学的了多少

面试演练

一、开场白

  1. 简单的介绍一下自己的工作经历与职责,在校或者工作中主要的工作内容,主要负责的内容;(你的信息一清二白的写在简历上,这个主要为了缓解面试者的压力)
  2. 介绍下自己最满意的,有技术亮点的项目或平台,重点介绍下自己负责那部分的技术细节;(主要考察应聘者对自己做过的事情是否有清晰的描述,判断做的事情的复杂度)


二、Java多线程

  1. 线程池的原理,为什么要创建线程池?创建线程池的方式;
  2. 线程的生命周期,什么时候会出现僵死进程;
  3. 说说线程安全问题,什么实现线程安全,如何实现线程安全
  4. 创建线程池有哪几个核心参数? 如何合理配置线程池的大小?
  5. volatile、ThreadLocal的使用场景和原理;
  6. ThreadLocal什么时候会出现OOM的情况?为什么?
  7. synchronized、volatile区别、synchronized锁粒度、模拟死锁场景、原子性与可见性;


三、JVM相关

  1. JVM内存模型,GC机制和原理;
  2. GC分哪两种,Minor GC 和Full GC有什么区别?什么时候会触发Full GC?分别采用什么算法?
  3. JVM里的有几种classloader,为什么会有多种?
  4. 什么是双亲委派机制?介绍一些运作过程,双亲委派模型的好处;
  5. 什么情况下我们需要破坏双亲委派模型;
  6. 常见的JVM调优方法有哪些?可以具体到调整哪个参数,调成什么值?
  7. JVM虚拟机内存划分、类加载器、垃圾收集算法、垃圾收集器、class文件结构是如何解析的;


四、Java扩展篇

  1. 红黑树的实现原理和应用场景;
  2. NIO是什么?适用于何种场景?
  3. Java9比Java8改进了什么;
  4. HashMap内部的数据结构是什么?底层是怎么实现的?(还可能会延伸考察
  5. ConcurrentHashMap与HashMap、HashTable等,考察对技术细节的深入了解程度);
  6. 说说反射的用途及实现,反射是不是很慢,我们在项目中是否要避免使用反射;
  7. 说说自定义注解的场景及实现;
  8. List 和 Map 区别,Arraylist 与 LinkedList 区别,ArrayList 与 Vector 区别;


五、Spring相关

  1. Spring AOP的实现原理和场景?
  2. Spring bean的作用域和生命周期;
  3. Spring Boot比Spring做了哪些改进? Spring 5比Spring4做了哪些改进;
  4. 如何自定义一个Spring Boot Starter?
  5. Spring IOC是什么?优点是什么?
  6. SpringMVC、动态代理、反射、AOP原理、事务隔离级别;


六、中间件篇

  1. Dubbo完整的一次调用链路介绍;
  2. Dubbo支持几种负载均衡策略?
  3. Dubbo Provider服务提供者要控制执行并发请求上限,具体怎么做?
  4. Dubbo启动的时候支持几种配置方式?
  5. 了解几种消息中间件产品?各产品的优缺点介绍;
  6. 消息中间件如何保证消息的一致性和如何进行消息的重试机制?
  7. Spring Cloud熔断机制介绍;Spring Cloud对比下Dubbo,什么场景下该使用Spring Cloud?


七、数据库篇

  1. 锁机制介绍:行锁、表锁、排他锁、共享锁;
  2. 乐观锁的业务场景及实现方式;
  3. 事务介绍,分布式事物的理解,常见的解决方案有哪些,什么事两阶段提交、三阶段提交;
  4. MySQL记录binlog的方式主要包括三种模式?每种模式的优缺点是什么?
  5. MySQL锁,悲观锁、乐观锁、排它锁、共享锁、表级锁、行级锁;
  6. 分布式事务的原理2阶段提交,同步异步阻塞非阻塞;
  7. 数据库事务隔离级别,MySQL默认的隔离级别、Spring如何实现事务、JDBC如何实现事务、嵌
  8. 套事务实现、分布式事务实现;
  9. SQL的整个解析、执行过程原理、SQL行转列;


八、Redis

  1. Redis为什么这么快?redis采用多线程会有哪些问题?
  2. Redis支持哪几种数据结构;
  3. Redis跳跃表的问题;
  4. Redis单进程单线程的Redis如何能够高并发?
  5. Redis如何使用Redis实现分布式锁?
  6. Redis分布式锁操作的原子性,Redis内部是如何实现的?

正文结束~~~


📂下面是博主认为自己上千篇文章中质量最高的几篇,分享给大家,文章内容太长了,所以只能进行拆分处理,读者大大们可以通过点击超链接直接跳转访问文章,根据你自己的需要选择对应的面试文章或者理论文章,这里我可以各位读者大大做出保证,文章内容和质量一定是最好的。

🍑面试直达:

🌅理论知识直达(每篇文章图文并茂且字数过万):


 🔔如果您需要转载或者搬运这篇文章的话,非常欢迎您私信我哦~ 

🎥 希望各位读者大大多多支持用心写文章的博主,现在时代变了,信息爆炸,酒香也怕巷子深,博主真的需要大家的帮助才能在这片海洋中继续发光发热,所以,赶紧动动你的小手,点波关注❤️,点波赞👍,点波收藏⭐,甚至点波评论✍️,都是对博主最好的支持和鼓励!

📥博主的人生感悟和目标:

🍋程序开发这条路不能停,停下来容易被淘汰掉,吃不了自律的苦,就要受平庸的罪,持续的能力才能带来持续的自信。我本是是一个很普通程序员,放在人堆里,除了与生俱来的盛世美颜,就剩180的大高个了,就是我这样的一个人,默默写博文也有好多年了。

📺有句老话说的好,牛逼之前都是傻逼式的坚持,希望自己可以通过大量的作品、时间的积累、个人魅力、运气、时机,可以打造属于自己的技术影响力。

💥内心起伏不定,我时而激动,时而沉思。我希望自己能成为一个综合性人才,具备技术、业务和管理方面的精湛技能。我想成为产品架构路线的总设计师,团队的指挥者,技术团队的中流砥柱,企业战略和资本规划的实战专家。

🎉这个目标的实现需要不懈的努力和持续的成长,但我必须努力追求。因为我知道,只有成为这样的人才,我才能在职业生涯中不断前进并为企业的发展带来真正的价值。在这个不断变化的时代,我必须随时准备好迎接挑战,不断学习和探索新的领域,才能不断地向前推进。我坚信,只要我不断努力,我一定会达到自己的目标。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐