面试八股文

目录

Java

ArrayList 和 LinkedList 的区别

String为啥是不可变的

HashMap

hashmap 1.7 && hashmap 1.8 

能否使用任何类作为HashMap的key

HashMap 的长度为什么是2的幂次方

ConcurrentHashMap

1.7&1.8区别

为什么使用CAS+Synchronized替代分段锁

JVM

JVM 的主要组成部分及其作用

JVM 运行时数据区

Java创建对象的过程

对象的内存布局

怎么判断对象是否可以被回收

GC Roots

JVM 垃圾回收算法

CMS 垃圾回收器

G1垃圾回收器

JVM内存分配策略

JVM调优

类加载机制

类加载的时机

类的加载过程

双亲委派

构造方法、成员变量初始化以及静态成员变量三者的初始化顺序?

Java 代码块执行顺序

Java内存模型

volatile 关键字

synchronized 关键字

synchronized关键字最主要的三种使用方式

synchronized 关键字的底层原理

JDK1.6 之后的synchronized 关键字底层做了哪些优化

1. 锁升级机制

2. 自旋锁(Spin Locks)

3. 锁消除(Lock Elimination)

4. 锁粗化(Lock Coarsening)

5. 优化锁的存储结构

volatile 和synchronized 区别

ReenTrantLock

synchronized和ReenTrantLock 的区别

双重校验锁实现对象单例

动态代理

静态代理和动态代理

JDK动态代理

CGLib动态代理

JDK和CGLib动态代理区别

1. 实现机制的不同

2. 应用场景的差异

3. 性能和效率

4. 对代理对象方法的限制

5. 使用的库和依赖

6. 其他特点

spring怎么判定使用这两种方式

为什么jdk1.7比较慢,1.8后快了,优化了什么?jdk反射调用的实现方式是原代码,还是新生成的代码?

并发

进程和线程的区别

Java线程的状态

线程通信的方式

并发原子类AtomicInteger

CountDownLatch

ThreadLocal

AQS

线程池

线程池参数

线程池的执行流程

Redis

Redis 是单线程还是多线程

Redis 单线程模型

为啥 Redis单线程效率高

Redis 是如何实现数据不丢失

RDB(快照持久化)

触发条件

RDB优缺点

AOF(附加文件持久化)

AOF优缺点

Redis 如何实现高可用

主从复制

主从复制的工作流程

哨兵模式

哨兵如何判断主库已经下线

哨兵选举

Redis集群

集群中多 Master 个节点,Redis在存储的时候如何确定选择哪个节点?

Redis 数据结构

sorted set(zset)

Rehash & 渐进式hash

Rehash

渐进式hash

Redis过期策略

Redis内存淘汰机制

Redis实现分布式锁

Redis如何实现延迟队列

Redis 大key如何处理

缓存穿透、击穿、 雪崩

缓存穿透

布隆过滤器

缓存穿透

缓存雪崩

数据库和缓存的一致性

Kafka

kafka架构

kafka为什么要分区

怎么防止kafka不丢数据

kafka的rebalance机制

kafka的 ISR, AR

kafka 为什么快

Kafka 的 ACK 机制

Kafka如何保证顺序消费

MySQL

一条 MySQL 语句执行的步骤

最左前缀原则

explain重要字段详解

MySQL 使用索引的原因

b树和b+数据的区别和优缺点

B树的特点:

B+树的特点:

B树的优点:

B树的缺点:

B+树的优点:

B+树的缺点:

B+树的内部节点不存储实际的数据,而只存储键(keys)和指向子节点的指针。这种设计的优势在于:

什么是覆盖索引和索引下推?

覆盖索引 (Covering Index)

索引下推 (Index Condition Pushdown, ICP)

哪些操作会导致索引失效

事务的四大特性

MySQL怎么保证一致性的

MySQL怎么保证原子性的

MySQL怎么保证持久性的

MySQL怎么保证隔离性的

事务的隔离级别

脏读、不可重复读、幻读

binlog、redo log、undo log 区别与作用

binlog

redo log

undo log

redo log 写入方式

binlog 日志的三种格式

redo log日志格式

MVCC

当数据库 crash 后,如何恢复未刷盘的数据到内存中

行锁和表锁

什么场景需要建索引

什么场景不需建索引

死锁

分库分表怎么做

垂直拆分

水平拆分

mybatis为什么可以把sql拿来就用,底层是怎么实现的?是否有使用到aop原理?怎么用的?

Spring

Spring IOC

IOC和DI的关系

Ioc 配置的三种方式

Spring AOP

Spring 中的 bean 的作用域

Spring的单例 bean的线程安全

解决线程安全问题的策略

Spring bean的生命周期

Spring 用到了哪些设计模式

Spring 事务的隔离级别

Spring事务失效的场景和原因

海量数据

海量的URL中找出相同的URL

海量数据中找出高频词

海量数据中判断一个数是否存在

ES

1.ES 查询性能优化手段

1.1 索引优化

1.2 查询优化

1.3 预热索引

1.4 监控和分析

2.ES 集群故障如何处理

3.ES 选主算法

4.ES 写入流程

5.ES 查询的流程

6. bm25打分算法原理

分布式

1.系统拆分原因,怎么拆分

2.Thrift 协议

3.dubbo 负载均衡

4.服务幂等处理

5.如何设计一个类似dubbo的rpc框架

6.一致性hash

7.布隆过滤器

8.限流算法

令牌桶

9.HTTP和RPC的区别

设计题

1.设计一个IM系统,包括群聊单聊

2. 扫码登录是怎么实现的

3. 微博点赞评论设计

4. 如何设计一个秒杀系统

5. 如何设计一个延迟mq

6. 浏览器输入地址回车,中间发生的过程


Java

ArrayList 和 LinkedList 的区别

ArrayList 是基于动态数组的,而 LinkedList 是基于双向链表的。

  • 在遍历速度上: 数组是一块连续内存空间,基于局部性原理能够更好地命中 CPU 缓存行,而链表是离散的内存空间对缓存行不友好;
  • 在访问速度上: 数组是一块连续内存空间,支持 O(1) 时间复杂度随机访问,而链表需要 O(n) 时间复杂度查找元素;
  • 在添加和删除操作上: 如果是在数组的末尾操作只需要 O(1) 时间复杂度,但在数组中间操作需要搬运元素,所以需要 O(n)时间复杂度,而链表的删除操作本身只是修改引用指向,只需要 O(1) 时间复杂度(如果考虑查询被删除节点的时间,复杂度分析上依然是 O(n),在工程分析上还是比数组快);
  • 额外内存消耗上: ArrayList 在数组的尾部增加了闲置位置,而 LinkedList 在节点上增加了前驱和后继指针。

String为啥是不可变的

  • 不可变对象天生就是线程安全的:因为不可变对象不能被改变,所以他们可以自由地在多个线程之间共享。不需要任何同步处理。
  • 便于实现字符串池:在Java中,由于会大量的使用String常量,如果每一次声明一个String都创建一个String对象,那将会造成极大的空间资源的浪费。Java提出了String pool的概念,在堆中开辟一块存储空间String pool,当初始化一个String变量时,如果该字符串已经存在了,就不会去创建一个新的字符串变量,而是会返回已经存在了的字符串的引用。
  • 避免安全问题:在网络连接和数据库连接中字符串常常作为参数,例如,网络连接地址URL,文件路径path,反射机制所需要的String参数。其不可变性可以保证连接的安全性。如果字符串是可变的,黑客就有可能改变字符串指向对象的值,那么会引起很严重的安全问题。
  • 加快字符串处理速度由于String是不可变的,保证了hashcode的唯一性,于是在创建对象时其hashcode就可以放心的缓存了,不需要重新计算。这也就是Map喜欢将String作为Key的原因,处理速度要快过其它的键对象。所以HashMap中的键往往都使用String。

HashMap

hashmap 1.7 && hashmap 1.8 

区别:

能否使用任何类作为HashMap的key

HashMap给定key进行查询的时候,分为2步:

  • 调用key对象的hashCode()方法,获取hashCode值,然后换算为对应数组的下标,找到对应下标位置;

  • 根据hashCode找到的数组下标可能会同时对应多个key(所谓的hash碰撞,不同元素产生了相同的hashCode值),这个时候使用key对象提供的equals()方法,进行逐个元素比对,直到找到相同的元素,返回其所对应的值

可以使用任何类作为HashMap的key,但需要满足两个条件:

  • 该类必须实现了hashCode()和equals()方法。hashCode()方法用于计算该对象在HashMap中的存储位置,equals()方法用于判断两个对象是否相等。如果两个对象的hashCode()相等,但equals()不相等,则它们会被存储在同一个桶中,但在查找时会被认为是不同的对象。

  • 该类应该是不可变的。如果一个类是可变的,那么它的hashCode()和equals()方法的返回值可能会发生变化,导致HashMap中的数据无法正确地查找和存储。

HashMap 的长度为什么是2的幂次方

  • 哈希函数的问题 将元素充分散列,避免不必要的哈希冲突。通过除留余数法方式获取桶号,因为Hash表的大小始终为2的n次幂,因此可以将取模转为位运算操作,提高效率,容量n为2的幂次方,n-1的二进制会全为1,位运算时可以充分散列,避免不必要的哈希冲突。

  • 扩容时桶内元素是否向高位移动的问题。扩容为2倍,参与计算的hashcode高位为1则移动,为0则不移动,也就是说一个桶内元素只有一半的概率需要移动,从而优化了性能。

ConcurrentHashMap

1.7&1.8区别

  • 数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
  • 保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。
  • 锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。
  • 定位结点的hash算法简化,会带来弊端:Hash冲突加剧。因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。
  • 查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。
  • 插入节点:JDK1.7是链表头,JDK1.8是链表尾。

为什么使用CAS+Synchronized替代分段锁

    分段锁需要维护多个锁,这会增加锁的开销和内存占用。其次,分段锁的并发度受限于锁的数量,如果锁的数量过少,会导致并发度不高;如果锁的数量过多,会导致内存占用过大。因此,分段锁的性能和扩展性都存在一定的局限性。

相比于分段锁,CAS+Synchronized 的锁机制具有以下优点:

  • 减少锁的数量:CAS+Synchronized 只需要维护一个全局锁,而不是像分段锁那样需要维护多个锁。
  • 提高并发度:CAS+Synchronized 的锁机制可以提高并发度,因为多个线程可以同时访问不同的桶,而不需要等待其他线程释放锁。
  • 减少锁的竞争:CAS+Synchronized 的锁机制可以减少锁的竞争,因为多个线程可以同时访问不同的桶。
  • 减少内存占用:CAS+Synchronized 的锁机制可以减少内存占用,因为只需要维护一个全局锁,而不是像分段锁那样需要维护多个锁。

因此,CAS+Synchronized 是一种更加高效和可扩展的锁机制,可以替代分段锁来实现并发哈希表。

JVM

JVM 的主要组成部分及其作用

JVM包含两个子系统和两个组件,两个子系统为Class loader(类装载)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。

  • Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area。
  • Execution engine(执行引擎):执行classes中的指令。
  • Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。
  • Runtime data area(运行时数据区域):这就是我们常说的JVM的内存

JVM 运行时数据区

方法区和堆线程共享,其他非共享

  • 程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器,字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成;
  • Java 虚拟机栈(Java Virtual Machine Stacks):用于存储局部变量表、操作数栈、动态链接、方法出口等信息;
  • 本地方法栈(Native Method Stack):与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的;
  • Java 堆(Java Heap):Java 虚拟机中内存最大的一块,是被所有线程共享的,几乎所有的对象实例都在这里分配内存;堆存放所有类实例和数组对象的地方。在虚拟机启动就根据相关堆参数,创建堆,他也是垃圾收集器工作的主要区域。堆内存里的对象不会被显式的回收,而是由垃圾回收器回收
  • 方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据

Java创建对象的过程

  • 检查该指令的参数能否在常量池中定位到一个类的符号引用,并检查引用代表的类是否已被加载、解析和初始化,如果没有就先执行类加载。
  • 通过检查通过后虚拟机将为新生对象分配内存。
  • 完成内存分配后虚拟机将成员变量设为零值
  • 设置对象头,包括哈希码、GC 信息、锁信息、对象所属类的类元信息等。
  • 执行 init 方法,初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

对象的内存布局

对象在堆内存的存储布局可分为对象头、实例数据和对齐填充。

  • 对象头主要包含两部分数据: MarkWord、类型指针。
    • MarkWord 用于存储哈希码(HashCode)、GC分代年龄、锁状态标志位、线程持有的锁、偏向线程ID等信息。
    • 类型指针即对象指向他的类元数据指针,如果对象是一个 Java 数组,会有一块用于记录数组长度的数据。
  • 实例数据存储代码中所定义的各种类型的字段信息。
  • 对齐填充起占位作用。HotSpot 虚拟机要求对象的起始地址必须是8的整数倍,因此需要对齐填充。

怎么判断对象是否可以被回收

垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是「存活」的,是不可以被回收的;哪些对象已经「死掉」了,需要被回收。一般有两种方法来判断:

  • 引用计数器法:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题;
  • 可达性分析算法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。

GC Roots

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。例如各个线程被调用的方法中使用到的参数、局部变量等
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。字符串常量池(string Table) 里的引用
  • 所有被同步锁synchronized持有的对象

JVM 垃圾回收算法

  • 标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高,无法清除垃圾碎片。

    • 将垃圾收集分为两个阶段:

      • 标记阶段:标记出可以回收的对象。

      • 清除阶段:回收被标记的对象所占用的空间。

    • 优点:实现简单,不需要对象进行移动。

    • 缺点:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。

  • 复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。

    • 优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。

    • 缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制

  • 标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低

    • 优点:解决了标记-清理算法存在的内存碎片问题。

    • 缺点:仍需要进行局部对象移动,一定程度上降低了效率。

  • 分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。

CMS 垃圾回收器

CMS垃圾收集器注重最短时间停顿。CMS垃圾收集器为最早提出的并发收集器,垃圾收集线程与用户线程同时工作。采用标记清除算法。该收集器分为初始标记、并发标记、并发预清理、并发清除、并发重置这么几个步骤。

  • 初始标记:暂停其他线程(stop the world),标记与GC roots直接关联的对象。
  • 并发标记:可达性分析过程(程序不会停顿)。
  • 并发预清理:查找执行并发标记阶段从年轻代晋升到老年代的对象,重新标记,暂停虚拟机(stop the world)扫描CMS堆中剩余对象。
  • 并发清除:清理垃圾对象,(程序不会停顿)。
  • 并发重置,重置CMS收集器的数据结构。

G1垃圾回收器

JVM内存分配策略

  • 对象优先在Eden分配
    • 将新生代分为Eden区,From区,To区是基于其所用的垃圾回收决定的(标记复制算法)这3个区的内存分配过程如下
      • 对象优先在Eden区分配,当进行YGC时,会将存活的对象放到From区,To区空着不用哈。
      • 当第2次进行YGC时,会将From区和Eden区存活的对象复制到To区,此时Eden区和From区就为空哈。
      • 当第3次进行YGC时,会将To区和Eden区存活的对象复制到From区,此时Eden区和To就为空哈。
      • 按照此规律,循环往复下去
  • 大对象直接进入老年代
    • JVM中有这样一个参数 -XX: PretenureSizeThreshold ,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区以及2个Survivor区之间来回复制,产生大量的内存复制操作
  • 长期存活的对象将进入老年代
    • 对象通常在Eden区诞生,如果经过第一次YGC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor中,并且将其对象设为1岁,对象在Survivor区中每熬过一次YGC,年龄就增加一岁,当它的年龄增加到一定程度(默认15),就会被晋升到老年代中,对象晋升老年代的年龄阈值, 可以通过参数-XX:MaxTenuringThreshold设置
  • 动态对象年龄判定
    • HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代

JVM调优

常用命令

  • 查看当前 Java 进程的线程堆栈信息 : jstack pid
  • 输出 Java 进程当前的 gc 情况 :jstat -gc pid
  • 输出 Java 堆详细信息 : jmap -heap pid /
  • 显示堆中对象的统计信息: jmap -histo:live pid
  • 生成 Java 堆存储快照dump文件: jmap -F -dump:format=b,file=dumpFile.phrof pid

查看监控指标、查看gc日志、分析gc日志

类加载机制

类加载机制指的是将这些.class文件中的二进制数据读入到内存中,并对数据进行校验,解析和初始化。最终,每一个类都会在方法区保存一份它的元数据,在堆中创建一个与之对应的Class对象

类加载的时机

Java虚拟机规范有明确规定,当符合以下条件时(包括但不限于),虚拟机内存中没有找到对应类型信息,则必须对类进行“初始化”操作:

  • 创建类的实例:使用new关键字创建类的一个实例时,会触发类的加载。
  • 访问类的静态成员:访问类的静态变量(不包括被final修饰的常量)或调用类的静态方法时。
  • 反射调用:使用反射方法如Class.forName()触发类的加载。
  • 子类初始化:初始化一个类的子类(假设子类还未被加载),会首先触发父类的初始化。
  • Java虚拟机启动时指定的主类:启动Java程序时,定义main方法的那个类会首先被加载。
  • MethodHandle实例解析:当创建并解析MethodHandle实例时,需要加载该MethodHandle指向的方法所在的类。

类的加载过程

加载、验证、准备、解析、初始化的执行过程,就是类的加载过程。这5个阶段,并不是严格意义上的按顺序完成,在类加载的过程中,这些阶段会互相混合,交叉运行,最终完成类的加载和初始化。例如在加载阶段,需要使用验证的能力去校验字节码正确性。在解析阶段,也要使用验证的能力去校验符号引用的正确性。或者加载阶段生成Class对象的时候,需要解析阶段符号引用转直接引用的能力等等

  • 加载。加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情
  • 验证。Class文件中的内容是字节码,这些内容可以由任何途径产出,验证阶段的目的是保证文件内容里的字节流符合Java虚拟机规范,且这些内容信息运行后不会危害虚拟机自身的安全。验证阶段会完成以下校验:
  • 准备。这个阶段,类的静态字段信息(即使用 static 修饰过的变量)会得到内存分配,并且设置为初始值。对于该阶段有以下几个知识点需要注意:
  • 解析。这个阶段,虚拟机会把这个Class文件中,常量池内的符号引用转换为直接引用。主要解析的是 类或接口、字段、类方法、接口方法、方法类型、方法句柄等符号引用。我们可以把解析阶段中,符号引用转换为直接引用的过程,理解为当前加载的这个类,和它所引用的类,正式进行“连接“的过程。
  • 初始化。这是类加载的最后一个步骤啦,初始化的过程,就是执行类构造器 <clinit>()方法的过程。当初始化完成之后,类中static修饰的变量会赋予程序员实际定义的“值”,同时类中如果存在static代码块,也会执行这个静态代码块里面的代码。<clinit>() 方法的作用,就是给这些变量赋予程序员实际定义的“值”。同时类中如果存在static代码块,也会执行这个静态代码块里面的代码。

双亲委派

双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

  • 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
  • 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
  • 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
  • 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。

委托机制的意义:防止内存中出现多份同样的字节码 。比如两个类A和类B都要加载System类:

  • 如果不用委托而是自己加载自己的,那么类A就会加载一份System字节码,然后类B又会加载一份System字节码,这样内存中就出现了两份System字节码。
  • 如果使用委托机制,会递归的向父类查找,也就是首选用Bootstrap尝试加载,如果找不到再向下。这里的System就能在Bootstrap中找到然后加载,如果此时类B也要加载System,也从Bootstrap开始,此时Bootstrap发现已经加载过了System那么直接返回内存中的System即可而不需要重新加载,这样内存中就只有一份System的字节码了。

构造方法、成员变量初始化以及静态成员变量三者的初始化顺序?

先后顺序:静态成员变量、成员变量、构造方法。

详细的先后顺序:父类静态变量、父类静态代码块、子类静态变量、子类静态代码块、父类非静态变量、父类非静态代码块、父类构造函数、子类非静态变量、子类非静态代码块、子类构造函数

Java 代码块执行顺序

  • 父类静态代码块(只执行一次)
  • 子类静态代码块(只执行一次)
  • 父类构造代码块
  • 父类构造函数
  • 子类构造代码块
  • 子类构造函数
  • 普通代码块

Java内存模型

JMM(Java Memory Model)是Java内存模型的缩写,是一种抽象的概念,定义了Java虚拟机如何在计算机内存中存储和访问Java对象的方法。JMM规范主要用于解决多线程访问共享内存时的可见性、有序性和原子性问题。下面是JMM的抽象示意图:

  • 所有的共享变量都存在主内存中;
  • 每个线程都保存了一份该线程使用到的共享变量的副本
  • 如果线程A与线程B之间要通信的话,必须经历下面2个步骤:
    • 线程A将本地内存A中更新过的共享变量刷新到主内存中去。
    • 线程B到主内存中去读取线程A之前已经更新过的共享变量。

因此,线程A无法直接访问线程B的工作内存,那是因为工作内存是线程独有的,线程间通信必须借助主内存,这也是JMM中的规定。当主内存中的共享变量被某个线程更新时,JMM会通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证。因此通过JMM规范,有效的解决了以下问题:

  • 可见性问题:JMM保证对于一个线程对变量的修改,其他线程能够立即看到这个修改,从而避免了线程之间读取数据不一致的问题;
  • 有序性问题:JMM保证程序的执行顺序是有序的,即按照代码的编写顺序执行,从而避免了出现代码执行顺序混乱的问题;
  • 原子性问题:JMM保证对单个变量的读取和写入操作是原子性的,即不会出现数据竞争问题。

volatile 关键字

volatile作用:

  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
    • MESI协议保证了每个缓存中使用的共享变量的副本是一致的。当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
  • 禁止进行指令重排序
    • 由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排,也就是说通过插入内存屏障,就能禁止在内存屏障前后的指令执行重排优化。内存屏障另外一个作用就是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
    • 在每个volatile写操作的前面插入一个StoreStore屏障。在每个volatile写操作的后面插入一个StoreLoad屏障。在每个volatile读操作的后面插入一个LoadLoad屏障。在每个volatile读操作的后面插入一个LoadStore屏障

synchronized 关键字

synchronized关键字最主要的三种使用方式
  • 修饰实例方法,作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁 。也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份,所以对该类的所有对象都加了锁)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。 和 synchronized 方法一样,synchronized(this)代码块也是锁定当前对象的。synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。
synchronized 关键字的底层原理
  • synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权.当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
  • synchronized 修饰方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的是 Access flag后的:ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用
JDK1.6 之后的synchronized 关键字底层做了哪些优化
1. 锁升级机制

在JDK 1.6之后,synchronized关键字底层实现引入了锁升级的概念,使得锁的开销和性能得到了显著改进。锁升级主要包括以下几个阶段:

  • 偏向锁(Biased Locking):当锁刚开始进入时,默认为偏向锁状态,它会偏向于第一个获得它的线程。当这个线程再次请求锁时,无需再次进行同步。
  • 轻量级锁(Lightweight Locking):当有其他线程尝试获取已经被偏向的锁时,偏向模式结束,锁会膨胀为轻量级锁。轻量级锁通过在对象头上的Mark Word中存储锁记录(Lock Record)来实现同步。
  • 重量级锁(Heavyweight Locking):当轻量级锁竞争激烈时,即有多个线程同时竞争锁,锁会膨胀为重量级锁。在这种模式下,锁会阻塞竞争线程,直到锁被释放。
2. 自旋锁(Spin Locks)

为了避免线程在竞争锁时进入阻塞状态,JDK 1.6引入了自旋锁。当线程尝试获取锁而失败时,它会做几次循环,即“自旋”,来看看锁是否能够很快被释放。如果在自旋期间锁被释放,线程可以避免被挂起。

  • 自适应自旋:JDK 1.6之后,自旋的次数不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
3. 锁消除(Lock Elimination)

锁消除是JVM在运行时通过即时编译器(JIT)来实现的优化技术。它会分析代码,去除不可能存在共享数据竞争的锁,从而消除不必要的锁操作,减少锁的开销。

4. 锁粗化(Lock Coarsening)

如果JVM检测到一系列的连续锁操作都是对同一个对象的,它会将锁的范围扩大(即“锁粗化”),以此减少锁操作的次数。这通常发生在循环内连续进行锁操作的情况。

5. 优化锁的存储结构

JDK 1.6对对象头的结构进行了优化,使得锁的状态可以在对象头中的Mark Word中进行编码,减少了锁操作的复杂性和开销。

volatile 和synchronized 区别

  • volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  • volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
  • volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
  • volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  • volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

ReenTrantLock

深入理解ReentrantLock的实现原理 - 掘金

可以实现公平和非公平,默认是非公平,想要使用公平锁需要构造函数(true)

api接口:

// 获取锁
void lock()
// 如果当前线程未被中断,则获取锁,可以响应中断
void lockInterruptibly()
// 返回绑定到此 Lock 实例的新 Condition 实例
Condition newCondition()
// 仅在调用时锁为空闲状态才获取该锁,可以响应中断
boolean tryLock()
// 如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁
boolean tryLock(long time, TimeUnit unit)
// 释放锁
void unlock()

synchronized和ReenTrantLock 的区别

  • Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
  • synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
  • Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
  • 通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
  • Lock可以实现公平琐,synchronized是非公平琐
  • Lock可以提高多个线程进行读操作的效率。(可以通过readwritelock实现读写分离)
  •  性能上来说,在资源竞争不激烈的情形下,Lock性能稍微比synchronized差点(编译程序通常会尽可能的进行优化synchronized)。但是当同步非常激烈的时候,synchronized的性能一下子能下降好几十倍。而ReentrantLock确还能维持常态

双重校验锁实现对象单例

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {

    }
    public static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:1. 为 uniqueInstance 分配内存空间2. 初始化 uniqueInstance3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出先问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

动态代理

动态代理在Java中有着广泛的应用,比如Spring AOP、Hibernate数据查询、测试框架的后端mock、RPC远程调用、Java注解对象获取、日志、用户鉴权、全局性异常处理、性能监控,甚至事务处理等。

静态代理和动态代理

  • 静态代理也就是在程序运行前就已经存在代理类的字节码文件,代理类和真实主题角色的关系在运行前就确定了。
  • 动态代理的源码是在程序运行期间由JVM根据反射等机制动态的生成,所以在运行前并不存在代理类的字节码文件

JDK动态代理

特点

  • 代理类在运行时动态生成,无需预先编写。
  • 被代理的类必须实现至少一个接口。
  • 代理对象可以拦截所有接口方法的调用。

使用步骤

  1. 定义一个接口和一个实现了该接口的类(被代理类)。
  2. 创建一个实现了InvocationHandler接口的类(调用处理器),并重写invoke方法。在invoke方法中定义代理的逻辑。
  3. 使用Proxy.newProxyInstance方法动态创建代理对象。这个方法需要三个参数:被代理类的类加载器、被代理类实现的接口数组、以及实现了InvocationHandler接口的调用处理器实例。
  4. 通过代理对象调用接口中定义的方法时,会自动转发到InvocationHandlerinvoke方法中执行。

JDK动态代理是面向接口的代理模式,如果被代理目标没有接口那么Spring也无能为力,Spring通过Java的反射机制生产被代理接口的新的匿名实现类,重写了其中AOP的增强方法。

CGLib动态代理

利用ASM开源包,对代理对象类的class文件加载进来,通过修改其字节码生成子类来处理。

JDK和CGLib动态代理区别

1. 实现机制的不同
  • JDK动态代理:JDK动态代理是通过实现接口的方式进行的,它使用java.lang.reflect.Proxy类和java.lang.reflect.InvocationHandler接口提供的机制创建代理对象。代理对象会实现目标对象的接口,并在调用接口方法时,转发到InvocationHandler实现类。
  • CGLib动态代理:CGLib(Code Generation Library)是一个强大的高性能代码生成库,它可以在运行时扩展Java类和实现接口。CGLib通过继承目标类来创建代理,并在运行时生成被代理对象的子类。代理类会覆盖父类的方法,在方法调用前后加入增强代码。
2. 应用场景的差异
  • JDK动态代理:仅能代理实现了接口的类。如果一个类没有实现接口,则不能使用JDK动态代理。
  • CGLib动态代理:可以代理没有实现接口的类,因此CGLib在某些场合具有更广泛的应用。
3. 性能和效率
  • JDK动态代理:通常认为,对于接口方法的代理,JDK动态代理的性能较高,因为它是基于原生的Java反射机制实现的。
  • CGLib动态代理:对于继承方式的代理,CGLib在生成类的过程中更耗时,但是生成的类之后的方法调用性能较好,因为它是通过直接继承的方式实现的。
4. 对代理对象方法的限制
  • JDK动态代理:不能对目标对象的非接口方法进行代理,因为它要求目标对象必须实现一个接口。
  • CGLib动态代理:因为CGLib是通过继承来实现的,所以不能对finalprivate方法进行代理,因为这些方法不能被子类覆盖。
5. 使用的库和依赖
  • JDK动态代理:是Java核心API的一部分,不需要添加额外的库或依赖。
  • CGLib动态代理:需要添加CGLib库作为项目依赖。
6. 其他特点
  • JDK动态代理:更简单,使用Java核心API即可实现,无需额外学习第三方库的使用。
  • CGLib动态代理:可以在运行时为一个类添加新的方法或属性,是一个功能更为强大的代码生成库

spring怎么判定使用这两种方式

在 Spring 中,决定使用 JDK 动态代理还是 CGLIB 代理,主要取决于被代理的目标对象是否实现了接口。具体来说,如果目标对象实现了接口,则使用 JDK 动态代理;否则,使用 CGLIB 代理。

为什么jdk1.7比较慢,1.8后快了,优化了什么?jdk反射调用的实现方式是原代码,还是新生成的代码?

JDK 1.8 对反射调用的性能进行了优化,主要包括以下几个方面:

  • MethodHandle:JDK 1.8 引入了 MethodHandle,它是一种轻量级的函数式接口,可以替代传统的反射调用方式。相比于传统的反射调用方式,MethodHandle 的性能更高,因为它是直接调用目标方法的,而不需要像反射那样通过一系列的方法调用来实现。
  • Lambda 表达式:JDK 1.8 引入了 Lambda 表达式,它可以帮助开发者编写更加简洁、易读和高效的代码。Lambda 表达式可以替代一些需要使用反射调用的场景,如事件监听器、回调函数等。
  • 缓存反射信息:JDK 1.8 在反射调用中引入了缓存机制,可以缓存反射信息,避免重复解析和查找。在 JDK 1.7 中,每次反射调用都需要重新解析和查找目标方法或字段,这会带来一定的性能开销。而在 JDK 1.8 中,反射调用会先检查缓存中是否已经存在反射信息,如果存在,则直接使用缓存中的信息,避免了重复解析和查找。
  • 字节码生成优化:JDK 1.8 在字节码生成方面进行了优化,使得生成的字节码更加紧凑和高效。在反射调用中,字节码生成是一个重要的环节,优化字节码生成可以提升反射调用的性能。

并发

进程和线程的区别

进程是计算机中已运行程序的实体,进程是操作系统资源分配的最小单位。而线程是在进程中执行的一个任务,是CPU调度和执行的最小单位。他们两个本质的区别是是否单独占有内存地址空间及其它系统资源(比如I/O):

  •  进程单独占有一定的内存地址空间,所以进程间存在内存隔离,数据是分开的,数据共享复杂但是同步简单,各个进程之间互不干扰;而线程共享所属进程占有的内存地址空间和资源,数据共享简单,但是同步复杂。
  • 进程单独占有一定的内存地址空间,一个进程出现问题不会影响其他进程,不影响主程序的稳定性,可靠性高;一个线程崩溃可能影响整个程序的稳定性,可靠性较低。
  • 进程单独占有一定的内存地址空间,进程的创建和销毁不仅需要保存寄存器和栈信息,还需要资源的分配回收以及页调度,开销较大;线程只需要保存寄存器和栈信息,开销较小。

另外一个重要区别是,进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单位,即CPU分配时间的单位 

Java线程的状态

线程状态有 NEW、RUNNABLE、BLOCK、WAITING、TIMED_WAITING、THERMINATED

  • NEW:新建状态,线程被创建且未启动,此时还未调用 start 方法。
  • RUNNABLE:运行状态。表示线程正在JVM中执行,但是这个执行,不一定真的在跑,也可能在排队等CPU。
  • BLOCKED:阻塞状态。线程等待获取锁,锁还没获得。
  • WAITING:等待状态。线程内run方法执行完Object.wait()/Thread.join()进入该状态。
  • TIMED_WAITING:限期等待。在一定时间之后跳出状态。调用Thread.sleep(long) Object.wait(long) Thread.join(long)进入状态。其中这些参数代表等待的时间。
  • TERMINATED:结束状态。线程调用完run方法进入该状态。

线程通信的方式

  • volatile关键字:在Java中,volatile关键字用于修饰变量,以确保该变量对所有线程的可见性。当一个变量被声明为volatile时,编译器和JVM会知道这个变量可能会被多个线程同时访问。因此,对这个变量的读写都会直接操作主内存中的变量,而不是线程本地内存中的副本,从而确保了变量的可见性。但是,volatile并不保证操作的原子性。
  • synchronized关键字:synchronized关键字是Java中的一个内置锁机制,用于控制同一时刻只有一个线程可以执行某个方法或代码块。当一个方法被声明为synchronized时,它的调用就需要获得对象的锁。如果一个代码块被synchronized修饰,那么线程进入这个代码块前必须先获得与synchronized提供的锁对象关联的锁。这样可以防止多个线程同时访问共享资源,从而保证了线程安全。
  • wait/notify方法:这些方法是Java对象的内置方法,用于线程间的通信。wait()方法会让当前线程进入等待状态,并释放它所持有的锁,直到其他线程调用同一个对象上的notify()或notifyAll()方法。notify()会随机唤醒一个等待该对象的线程,而notifyAll()会唤醒所有等待该对象的线程。这些方法必须在同步代码块或方法中调用,因为它们需要锁的上下文。
  • IO通信:IO通信通常指的是通过输入/输出流进行数据交换,虽然它经常用于网络或文件操作,但也可以用于线程间的通信。例如,两个线程可以通过管道(PipedInputStream和PipedOutputStream)进行数据交换。一个线程写入数据到管道,另一个线程从管道读取数据,实现通信。

并发原子类AtomicInteger

jdk1.8的并发包来说,底层基本上就是通过Usafe和CAS机制来实现的。

CAS 即比较并替换,实现并发算法时常用到的一种技术。CAS操作包含三个操作数——内存位置、预期原值及新值。执行CAS操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。

ABA问题

在多线程环境下,可能会出现多个线程同时执行 CAS 操作的情况。如果两个线程同时执行 compare 操作,都发现当前值和期望值相等,然后都执行 swap 操作,就会出现数据不一致的情况。

为了解决这个问题,可以使用版本号机制。具体来说,每个变量都有一个版本号,每次执行 CAS 操作时,需要比较当前版本号和期望版本号是否相等,如果相等,就执行 swap 操作,并将版本号加 1;否则,就需要重新尝试。

CountDownLatch

CountDownLatch的两种使用场景:

  • 场景1:让多个线程等待。CountDownLatch.await(),让多个参与者线程启动后阻塞等待,然后在主线程 调用CountDownLatch.countdown(1) 将计数减为0,让所有线程一起往下执行;以此实现了多个线程在同一时刻并发执行,来模拟并发请求的目的
    • CountDownLatch countDownLatch = new CountDownLatch(1);
      for (int i = 0; i < 5; i++) {
          new Thread(() -> {
              try {
                  //准备完毕……运动员都阻塞在这,等待号令
                  countDownLatch.await();
                  String parter = "【" + Thread.currentThread().getName() + "】";
                  System.out.println(parter + "开始执行……");
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
          }).start();
      }
      
      Thread.sleep(2000);// 裁判准备发令
      countDownLatch.countDown();// 发令枪:执行发令
  • 场景2:和让单个线程等待。很多时候,我们的并发任务,存在前后依赖关系;比如数据详情页需要同时调用多个接口获取数据,并发请求获取到数据后、需要进行结果合并;或者多个数据操作完成后,需要数据check;这其实都是:在多个线程(任务)完成后,进行汇总合并的场景。
    • CountDownLatch countDownLatch = new CountDownLatch(5);
      for (int i = 0; i < 5; i++) {
          final int index = i;
          new Thread(() -> {
              try {
                  Thread.sleep(1000 + ThreadLocalRandom.current().nextInt(1000));
                  System.out.println("finish" + index + Thread.currentThread().getName());
                  countDownLatch.countDown();
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
          }).start();
      }
      
      countDownLatch.await();// 主线程在阻塞,当计数器==0,就唤醒主线程往下执行。
      System.out.println("主线程:在所有任务运行完成后,进行结果汇总");

ThreadLocal

Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离,ThreadLocal为每个线程都提供了变量的副本。

使用场景

  • 线程间数据隔离,各线程的 ThreadLocal 互不影响
  • 方便同一个线程使用某一对象,避免不必要的参数传递
  • 全链路追踪中的 traceId 或者流程引擎中上下文的传递一般采用 ThreadLocal
  • Spring 事务管理器采用了 ThreadLocal
  • Spring MVC 的 RequestContextHolder 的实现使用了 ThreadLocal

原理

ThreadLocal的实现原理是,在每个线程中维护一个Map,键是ThreadLocal类型,值是Object类型。当想获取ThreadLocal的值时,就从当前线程中拿出Map,然后在把ThreadLocal本身作为键从Map中拿出值返回。

内存泄露

由于ThreadLocalMap中的Entry的key持有的是ThreadLocal对象的弱引用,当这个ThreadLocal对象当且仅当被ThreadLocalMap中的Entry引用时发生了GC,会导致当前ThreadLocal对象被回收;那么 ThreadLocalMap 中保存的 key 值就变成了 null,而Entry 又被 ThreadLocalMap 对象引用,ThreadLocalMap 对象又被 Thread 对象所引用,那么当 Thread 一直不销毁的话,value 对象就会一直存在于内存中,也就导致了内存泄漏,直至 Thread 被销毁后,才会被回收。

解决方案:调用get,set或remove方法时,就会尝试删除key为null的entry,可以释放value对象所占用的内存

try{
       threadLocal.set(1);
       System.out.println(threadLocal.get());
}finally {
       threadLocal.remove(); //用完要remove,防止内存泄漏
}

AQS

线程池

线程池参数

  • corePoolSize核心线程数,线程池中始终存活的线程数
  • maximumPoolSize最大线程数,线程池中允许的最大线程数。
  • keepAliveTime存活时间,线程没有任务执行时最多保持多久时间会终止。
  • unit单位,参数keepAliveTime的时间单位,7种可选。
  • workQueue: 一个阻塞队列,用来存储等待执行的任务,均为线程安全,7种可选。
  • threadFactory线程工厂,主要用来创建线程,默及正常优先级、非守护线程。
  • handler拒绝策略,拒绝处理任务时的策略,4种可选,默认为AbortPolicy。
    • CallerRunsPolicy - 当触发拒绝策略,只要线程池没有关闭的话,则使用调用线程直接运行任务。一般并发比较小,性能要求不高,不允许失败。但是,由于调用者自己运行任务,如果任务提交速度过快,可能导致程序阻塞,性能效率上必然的损失较大
    • AbortPolicy - 丢弃任务,并抛出拒绝执行 RejectedExecutionException 异常信息。线程池默认的拒绝策略。必须处理好抛出的异常,否则会打断当前的执行流程,影响后续的任务执行。
    • DiscardPolicy - 直接丢弃,其他啥都没有
    • DiscardOldestPolicy - 当触发拒绝策略,只要线程池没有关闭的话,丢弃阻塞队列 workQueue 中最老的一个任务,并将新任务加入

线程池的执行流程

Redis

Redis 是单线程还是多线程

在 4.0 之前我们说 Redis 是单线程,指的是Redis 对外提供键值存储服务的核心流程:「接收客户端请求->解析请求 ->进行数据读写等操作->发生数据给客户端」这个流程是由一个线程(主线程)来完成的。但是 Redis 的持久化、集群同步还是使用其他线程来完成。

4.0 之后添加了多线程的支持,加入 Lazy Free 机制。

Redis 4.0 之前在处理客户端命令和 IO 操作时都是以单线程形式运行,期间不会响应其他客户端请求,但若客户端向 Redis 发送一条耗时较长的命令,比如删除一个含有上百万对象的 Set 键,或者执行 flushdb,flushall 操作,Redis 服务器需要回收大量的内存空间,这事就会导致 Redis 服务阻塞,对于负载较高的缓存系统来说将会是个灾难。为了解决这个问题,在 Redis 4.0 版本引入了 Lazy Free,目的是将慢操作异步化,这也是在事件处理上向多线程迈进了一步

Redis 单线程模型

Redis 基于 Reactor 模式开发了网络事件处理器,这个处理器被称为文件事件处理器。

文件事件处理器的四个重要组成部分:

  • 多个套接字请求
  • IO 多路复用器
  • 文件事件派发器
  • 事件处理器

Redis 客户端对服务端的每次调用都经历了发送命令,执行命令,返回结果三个过程。其中执行命令阶段,由于 Redis 是单线程来处理命令的,所有每一条到达服务端的命令不会立刻执行,所有的命令都会进入一个队列中,然后逐个被执行。并且多个客户端发送的命令的执行顺序是不确定的。但是可以确定的是不会有两条命令被同时执行,不会产生并发问题,这就是 Redis 的单线程基本模型

为啥 Redis单线程效率高

  • Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU
  • Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
  • Redis 采用了I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。
  • C 语言实现,效率高。C 语言程序运行速度快,因为其相较于其他高级语言更加接近底层机器。由于 C 语言直接操作内存,不会像其他语言那样依赖虚拟机或垃圾回收机制等中间层,从而能够实现更高的执行效率

Redis 是如何实现数据不丢失

Redis是个基于内存的数据库。那服务一旦宕机,内存中的数据将全部丢失。通常的解决方案是从后端数据库恢复这些数据,但后端数据库有性能瓶颈,如果是大数据量的恢复,1、会对数据库带来巨大的压力,2、数据库的性能不如Redis。导致程序响应慢。所以对Redis来说,实现数据的持久化,避免从后端数据库中恢复数据,是至关重要的。

Redis数据持久化的三种方式:RDB、AOF、RDB和AOF混合持久化

RDB(快照持久化)

RDB是把当前进程数据生成快照保存到磁盘上的过程,由于是某一时刻的快照,那么快照中的值要早于或者等于内存中的值。

触发条件
  • 自动触发:
    • redis.conf中配置save m n,即在m秒内有n次修改时,自动触发bgsave生成rdb文件;
    • 主从复制时,从节点要从主节点进行全量复制时也会触发bgsave操作,生成当时的快照发送到从节点;
    • 执行debug reload命令重新加载redis时也会触发bgsave操作;
    • 默认情况下执行shutdown命令时,如果没有开启aof持久化,那么也会触发bgsave操作
  • 手动触发:使用 SAVE 或 BGSAVE 命令。
RDB优缺点

优点

  • RDB文件是某个时间节点的快照,默认使用LZF算法进行压缩,压缩后的文件体积远远小于内存大小,适用于备份、全量复制等场景;
  • Redis加载RDB文件恢复数据要远远快于AOF方式;

缺点

  • RDB方式实时性不够,无法做到秒级的持久化;
  • 每次调用bgsave都需要fork子进程,fork子进程属于重量级操作,频繁执行成本较高;
  • RDB文件是二进制的,没有可读性,AOF文件在了解其结构的情况下可以手动修改或者补全;

AOF(附加文件持久化)

AOF日志采用写后日志,即先写内存,后写日志。Redis先执行命令,把数据写入内存,然后才记录日志。日志里记录的是Redis收到的每一条命令,这些命令是以文本形式保存。

Redis要求高性能,采用后写日志有两方面好处:

  • 避免额外的检查开销:Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。
  • 不会阻塞当前的写操作

AOF日志记录Redis的每个写命令,步骤分为:命令追加(append)、文件写入(write)和文件同步(sync)

  • 命令追加 当AOF持久化功能打开了,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器的 aof_buf 缓冲区。
  • 文件写入和同步:关于何时将 aof_buf 缓冲区的内容写入AOF文件中,Redis提供了三种写回策略

    • Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
    • Everysec,每秒写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
    • No,操作系统控制的写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。
AOF优缺点

优点

  • 数据保证:我们可以设置fsync策略,一般默认是everysec,也可以设置每次写入追加,所以即使服务死掉了,也最多丢失一秒数据
  • 自动缩小:当aof文件大小到达一定程度的时候,后台会自动的去执行aof重写,此过程不会影响主进程,重写完成后,新的写入将会写到新的aof中,旧的就会被删除掉。但是此条如果拿出来对比rdb的话还是没有必要算成优点,只是官网显示成优点而已

缺点

  • 性能相对较差:它的操作模式决定了它会对redis的性能有所损耗。
  • 体积相对更大:尽管是将aof文件重写了,但是毕竟是操作过程和操作结果仍然有很大的差别,体积也毋庸置疑的更大。
  • 恢复速度更慢:Redis加载RDB文件恢复数据要远远快于AOF方式

Redis 如何实现高可用

Redis 实现高可用主要有三种方式:主从复制、哨兵模式,以及 Redis 集群

主从复制

主从复制,是指将一台Redis服务器的数据,复制到其他的Redis服务器。前者称为主节点(master),后者称为从节点(slave);数据的复制是单向的,只能由主节点到从节点。

主从复制的工作流程
  • 初始复制:
    • 从服务器连接主服务器,并请求同步数据。
    • 主服务器执行 BGSAVE 创建快照,并将快照文件发送给从服务器。
    • 从服务器载入快照文件,并开始接收主服务器后续的写命令。
  • 命令传播:主服务器的写命令会被实时发送到所有从服务器,保持数据一致性。
  • 断线后重连:如果连接中断,从服务器会自动尝试重新连接并进行数据同步。

哨兵模式

哨兵(Sentinel)的核心功能是主节点的自动故障转移。

  • 监控(Monitoring):哨兵会不断地检查主节点和从节点是否运作正常。
  • 自动故障转移(Automatic failover):当主节点不能正常工作时,哨兵会开始自动故障转移操作,它会将失效主节点的其中一个从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
  • 配置提供者(Configuration provider):客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址。
  • 通知(Notification):哨兵可以将故障转移的结果发送给客户端。

如下图所示:

哨兵如何判断主库已经下线

主观下线和客观下线

  • 主观下线:任何一个哨兵都是可以监控探测,并作出Redis节点下线的判断;
  • 客观下线:有哨兵集群共同决定Redis节点是否下线;

当某个哨兵判断主库“主观下线”后,就会给其他哨兵发送 is-master-down-by-addr 命令。接着,其他哨兵会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y 相当于赞成票,N 相当于反对票。如果赞成票数是大于等于哨兵配置文件中的 quorum 配置项, 则可以判定主库客观下线

哨兵选举

Raft选举算法: 选举的票数大于等于num(sentinels)/2+1时,将成为领导者,如果没有超过,继续选举。

任何一个想成为 Leader 的哨兵,要满足两个条件: 第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值

Redis集群

Redis Cluster 是一种分布式去中心化的运行模式,是在 Redis 3.0 版本中推出的 Redis 集群方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。

集群中多 Master 个节点,Redis在存储的时候如何确定选择哪个节点?

Redis Cluster 数据分片引入哈希槽(hash slot)来实现的。一个 RedisCluster包含16384(0~16383)个哈希槽,存储在Redis Cluster中的所有键都会被映射到这些slot中,集群中的每个键都属于这16384个哈希槽中的一个。按照槽来进行分片,通过为每个节点指派不同数量的槽,可以控制不同节点负责的数据量和请求数。

集群使用公式slot=CRC16(key)/16384来计算key属于哪个槽,其中CRC16(key)语句用于计算key的CRC16 校验和。

Redis 数据结构

Redis数据类型

sorted set(zset)

压缩列表的底层结构,本质上就是一个数组。其他的比如列表长度、尾部偏移量、列表元素个数等等,只是有利于快速寻找列表的首、尾节点。寻找其他的元素,只能一个一个遍历了。
什么时候采用压缩列表

  • 有序集合保存的元素数量小于128个
  • 有序集合保存的所有元素的长度小于 64字节

跳表在链表的基础上,增加了多级索引,通过多级索引位置的转跳,实现了快速查找元素。这个查找过程就是在多级索引上跳来跳去,最后定位到元素。所以叫做跳表。当数据量很大时,跳表的查找复杂度就是 O(logN)。这个复杂度和二分查找是一样的,这种查找效率的提升是建立在很多级索引之上的,即空间换时间的思想。

Rehash & 渐进式hash

Rehash

Rehash 是 Redis 在使用哈希表存储数据时,为了应对数据增减导致的负载不均,进行的一种动态扩展或收缩哈希表的过程。当哈希表中的元素太多或太少时,为了维持性能,Redis 会根据预设的负载因子触发 Rehash。

rehash 的触发条件和过程

  • 触发条件

    • 负载因子过大:当哈希表的负载因子超过一定阈值(如默认的1)时,会触发扩展操作。
    • 负载因子过小:当哈希表的元素数量在缩容之后过少,负载因子低于某个阈值时,会触发收缩操作。
  • 过程

    • Redis 会创建一个新的哈希表,大小通常是当前使用的哈希表的两倍(扩展)或一半(收缩)。
    • 然后通过 Rehash 过程将所有键值对从旧的哈希表迁移到新的哈希表中。

渐进式hash

渐进式 Hash(Incremental Rehashing)是 Redis 解决 Rehash 时可能出现的长时间阻塞问题的一种策略。在渐进式 Rehash 过程中,Redis 会分多次、逐步地将旧哈希表中的键值对迁移到新哈希表中,而不是一次性地迁移所有键值对。

  • 分步执行:Redis 不会在一个操作中完成所有的键值对迁移,而是将迁移操作分散到对哈希表的每个写操作中去,每次操作迁移一小部分。
  • 维护两个哈希表:在渐进式 Rehash 过程中,Redis 会同时维护旧的哈希表和新的哈希表。查找键时会先在新哈希表中查找,如果没有找到,再在旧的哈希表中查找。
  • 完成迁移:当所有键值对都迁移到新的哈希表后,旧的哈希表会被释放,Rehash 过程结束。

Redis过期策略

Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。

  • 定期删除。所谓定期删除,指的是 Redis 默认是每隔 100ms 就随机抽取一些设置了过期时间的 key,检查其是否过期,如果过期就删除。
  • 惰性删除。在你获取某个 key 的时候,Redis 会检查一下 ,这个 key 如果设置了过期时间那么是否过期了?如果过期了此时就会删除,不会给你返回任何东西。

Redis内存淘汰机制

  • noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错,这个一般没人用
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key,这个一般没人用
  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key(这个一般不太合适)
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除

Redis实现分布式锁

加锁:SET key unique_value NX PX 1000

  • key 就是 key 键;
  • unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作;
  • NX 代表只在 key 不存在时,才对 key 进行设置操作;
  • PX 1000 表示设置 key 的过期时间为 1s,这是为了避免客户端发生异常而无法释放锁。

解锁:解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 key 键删除。解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。

// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。

Redis如何实现延迟队列

ZSet 实现消息队列,zadd 和 zrangebyscore 来实现存入和读取消息
ZSet 有一个分值(score)属性,用它来存储时间戳,用多个线程轮询Zset获取到期的任务进行处理,以此来实现延迟消息队列等,步骤如下:

  • 利用 zadd 向集合中插入元素,以元素的时间戳(超时时间)作为 score
  • 利用 zrangebyscore 以 0 < score <= 当前时间戳 进行获取需要处理的元素
  • 当有满足的条件的元素, 先删除zrem该元素(保证不被其他进程取到),再进行业务逻辑处理;

有可能出现 zrangebyscore 和 zrem非一个客户端,即原子性问题 -> 采用 lua 脚本解决

Redis实现队列的的缺陷

  • 消息没有持久化,如果服务器宕机或重启,消息将会丢失
  • 没有ACK机制,如果消费失败,消息会丢失
  • 没有队列监控、出入对性能差

Redis 大key如何处理

大 key 并不是指 key 的值很大,而是 key 对应的 value 很大。一般而言,下面这两种情况被称为大 key:

  • String 类型的值大于 10 KB;
  • Hash、List、Set、ZSet 类型的元素的个数超过 5000个;

大 key 的影响

  • 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
  • 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
  • 内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。

大key的发现

  • redis-rdb-tools工具。redis实例上执行bgsave,然后对dump出来的rdb文件进行分析,找到其中的大KEY。
  • redis-cli --bigkeys命令。可以找到某个实例5种数据类型(String、hash、list、set、zset)的最大key。
  • 自定义的扫描脚本,以Python脚本居多,方法与redis-cli --bigkeys类似。
  • debug object key命令。可以查看某个key序列化后的长度,每次只能查找单个key的信息。官方不推荐

大key删除

  • 分批次删除使用SCAN扫描key,比如删除Hash,先取100字段删除删除再取
  • 异步删除。在Redis4.0版本开始,可以采用异步删除法用unlink命令代替del删除,这样Redis会将这个key放入到一个异步线程中进行删除,这样不会阻塞主线程

大key解决

  • 对大key进行拆分。将一个Big Key拆分为多个key-value这样的小Key,并确保每个key的成员数量或者大小在合理范围内,然后再进行存储,通过get不同的key或者使用mget批量获取
  • 对大key进行清理。对Redis中的大Key进行清理,从Redis中删除此类数据。Redis自4.0起提供了UNLINK命令,该命令能够以非阻塞的方式缓慢逐步的清理传入的Key,通过UNLINK,你可以安全的删除大Key甚至特大Key
  • 压缩value、使用序列化、压缩算法将key的大小控制在合理范围内,但是需要注意序列化、反序列化都会带来一定的消耗。如果压缩后,value还是很大,那么可以进一步对key进行拆分。

缓存穿透、击穿、 雪崩

缓存穿透

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求。由于缓存是不命中是被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的查询每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用

解决方案

  • 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
  • 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
  • 布隆过滤器。bloomfilter就类似于一个hash set,用于快速判某个元素是否存在于集合中,其典型的应用场景就是快速判断一个key是否存在于某容器,不存在就直接返回。布隆过滤器的关键就在于hash算法和容器大小。
布隆过滤器

缓存穿透

缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。

解决方案

  • 设置热点数据永远不过期。
  • 接口限流与熔断,降级。重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些服务不可用时候,进行熔断,失败快速返回机制。

缓存雪崩

缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库

解决方案

  • 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
  • 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。
  • 设置热点数据永远不过期

数据库和缓存的一致性

Kafka

kafka架构

kafka的三层消息架构: 

  • 第一层是主题层,每个主题可以配置M个分区,而每个分区又可以配置N个副本。
  • 第二层是分区层,每个分区的N个副本中只能有一个充当领导者角色,对外提供服务;其他N-1个副本是追随者副本,只是提供数据冗余之用。
  • 第三层是消息层,分区中包含若干条消息,每条消息的位移从0开始,依次递增。
  • 最后,客户端程序只能与分区的领导者副本进行交互。

kafka为什么要分区

对数据进行分区的主要原因,就是为了实现系统的高伸缩性(Scalability)。不同的分区能够被放置到不同节点的机器上,而数据的读写操作也都是针对分区这个粒度而进行的,这样每个节点的机器都能独立地执行各自分区的读写请求处理。并且,我们还可以通过添加新的节点机器来增加整体系统的吞吐量。
kafka的分区策略有:轮询、随机、按key hash分区。Kafka默认分区策略实际上同时实现了两种策略:如果指定了Key,那么默认实现按消息键保序策略;如果没有指定Key,则使用轮询策略。

怎么防止kafka不丢数据

  • 不要使用producer.send(msg),而要使用producer.send(msg, callback)。记住,一定要使用带有回调通知的send方法。
  • 设置acks = all。acks是Producer的一个参数,代表了你对“已提交”消息的定义。如果设置成all,则表明所有副本Broker都要接收到消息,该消息才算是“已提交”。这是最高等级的“已提交”定义。
  • 设置retries为一个较大的值。这里的retries同样是Producer的参数,对应前面提到的Producer自动重试。当出现网络的瞬时抖动时,消息发送可能会失败,此时配置了retries > 0的Producer能够自动重试消息发送,避免消息丢失。
  • 设置unclean.leader.election.enable false。这是Broker端的参数,它控制的是哪些Broker有资格竞选分区的Leader。如果一个Broker落后原先的Leader太多,那么它一旦成为新的Leader,必然会造成消息的丢失。故一般都要将该参数设置成false,即不允许这种情况的发生。
  • 设置replication.factor >= 3。这也是Broker端的参数。其实这里想表述的是,最好将消息多保存几份,毕竟目前防止消息丢失的主要机制就是冗余
  • 设置min.insync.replicas > 1。这依然是Broker端参数,控制的是消息至少要被写入到多少个副本才算是“已提交”。设置成大于1可以提升消息持久性。在实际环境中千万不要使用默认值1。
  • 确保replication.factor > min.insync.replicas。如果两者相等,那么只要有一个副本挂机,整个分区就无法正常工作了。我们不仅要改善消息的持久性,防止数据丢失,还要在不降低可用性的基础上完成。推荐设置成replication.factor = min.insync.replicas + 1。
  • 确保消息消费完成再提交。Consumer端有个参数enable.auto.commit,最好把它设置成false,并采用手动提交位移的方式。就像前面说的,这对于单Consumer多线程处理的场景而言是至关重要的。

kafka的rebalance机制

触发 Rebalance (再均衡)操作的场景目前分为以下几种:

  • 消费者组内消费者数量发生变化,包括:
    • 有新消费者加入
    • 有消费者宕机下线。包括真正宕机,或者长时间GC、网络延迟导致消费者未在超时时间内向 GroupCoordinator 发送心跳,也会被认为下线
    • 有消费者主动退出消费者组(发送 LeaveGroupRequest 请求) 比如客户端调用了 unsubscrible() 方法取消对某些主题的订阅
  • 消费者组对应的 GroupCoordinator 节点发生了变化
  • 消费者组订阅的主题发生变化(增减)或者主题分区数量发生了变化

新消费者加入消费者组的场景,分析整个 Rebalance 的流程。其主要涉及四个阶段:

  • 寻找管理该消费者组的 GroupCoordinator 所在节点
  • 向消费者组加入新成员
  • 向所有消费者组成员同步消费分区分配方案
  • 消费者向 GroupCoordinator 发送心跳

kafka的 ISR, AR

  • AR(Assigned Repllicas):一个分区里面所有的副本(不区分leader和follower)
  • ISR(In-Sync Replicas):能够和leader保持同步的follower+leader本身组成的集合
  • OSR(Out-Sync Replicas):不能和leader保持同步的follower集合
  • 公式:  AR=ISR+OSR

kafka只会保证ISR集合中的所有副本保持完全同步。kafka一定会保证leader接收到消息然后完全同步给ISR中的所有副本。ISR的机制保证了处于ISR内部的follower都可以和leader保持同步,一旦出现故障或者延迟(在一定时间内没有同步),就会被踢出ISR

kafka 为什么快

  • 顺序读写:
    • Kafka 利用了一种分段式的、只追加 (Append-Only) 的日志,基本上把自身的读写操作限制为顺序 I/O,也就使得它在各种存储介质上能有很快的速度
  • Page Cache、为了优化读写性能,Kafka利用了操作系统本身的Page Cache,就是利用操作系统自身的内存而不是JVM空间内存。这样做的好处有:
    • 避免Object消耗:如果是使用 Java 堆,Java对象的内存消耗比较大,通常是所存储数据的两倍甚至更多。
    • 避免GC问题:随着JVM中数据不断增多,垃圾回收将会变得复杂与缓慢,使用系统缓存就不会存在GC问题
    • 相比于使用JVM或in-memory cache等数据结构,利用操作系统的Page Cache更加简单可靠。首先,操作系统层面的缓存利用率会更高,因为存储的都是紧凑的字节结构而不是独立的对象。其次,操作系统本身也对于Page Cache做了大量优化,提供了 write-behind、read-ahead以及flush等多种机制。再者,即使服务进程重启,系统缓存依然不会消失,避免了in-process cache重建缓存的过程。
    • 通过操作系统的Page Cache,Kafka的读写操作基本上是基于内存的,读写速度得到了极大的提升。
  • 采用了零拷贝技术。
    • Producer 生产的数据持久化到 broker,采用 mmap 文件映射,实现顺序的快速写入Customer 从 broker 读取数据,采用 sendfile,将磁盘文件读到 OS 内核缓冲区后,转到 NIO buffer进行网络发送,减少 CPU 消耗
  • 分区并发。Kafka 的 Topic 可以分成多个 Partition,每个 Paritition 类似于一个队列,保证数据有序。同一个 Group 下的不同 Consumer 并发消费 Paritition,分区实际上是调优 Kafka 并行度的最小单元,因此,可以说,每增加一个 Paritition 就增加了一个消费并发。

Kafka 的 ACK 机制

  • 0: 相当于异步操作,Producer 不需要Leader给予回复,发送完就认为成功,继续发送下一条(批)Message。此机制具有最低延迟,但是持久性可靠性也最差,当服务器发生故障时,很可能发生数据丢失。
  • 1: Kafka 默认的设置。表示 Producer 要 Leader 确认已成功接收数据才发送下一条(批)Message。不过 Leader 宕机,Follower 尚未复制的情况下,数据就会丢失。此机制提供了较好的持久性和较低的延迟性。
  • -1: Leader 接收到消息之后,还必须要求ISR列表里跟Leader保持同步的那些Follower都确认消息已同步,Producer 才发送下一条(批)Message。此机制持久性可靠性最好,但延时性最差。

Kafka如何保证顺序消费

  • 确保单个分区
    • 为了保证Kafka消费者能够顺序消费消息,首先需要保证消息被顺序地写入到Kafka中。Kafka保证在单个分区内的消息是有序的。因此,如果所有的消息都发送到同一个分区,那么它们就会按照发送的顺序被存储,从而也能按照这个顺序被消费。
  • 使用单个消费者线程
    • 如果你的应用程序只使用一个消费者线程来消费一个分区,那么这个消费者线程就会按照顺序处理消息。这是因为Kafka保证了单个消费者实例在读取单个分区时的消息顺序。
  • 分区策略。
    • 将每个线程分配给特定的分区。Kafka只能保证每个分区内部的消息顺序,所以不同线程消费不同分区可以保证每个线程内部消费的顺序性

MySQL

一条 MySQL 语句执行的步骤

  • 客户端请求 -> 连接器(验证用户身份,给予权限)
  • 查询缓存(存在缓存则直接返回,不存在则执行后续操作)
  • 分析器(对 SQL 进行词法分析和语法分析操作)
  • 优化器(主要对执行的 SQL 优化选择最优的执行方案方法)
  • 执行器(执行时会先看用户是否有执行权限,有才去使用这个引擎提供的接口)-> 去引擎层获取数据返回(如果开启查询缓存则会缓存查询结果)

最左前缀原则

当对某张表创建包含多个字段的联合索引后,进行查询时,会按照所定义的索引中的字段顺序从左至右进行匹配;在遇到函数、排序、不等于等运算时会停止匹配。

explain重要字段详解

  • type:衡量SQL执行效率的重要指标,执行效率从好到坏依次是:system->const->eq_ref->ref->range->index->all。其中const和eq_ref的意思差不多,都是指用到了唯一索引或者主键。区别是const指的是单表查询。eq_ref指的是非单表查询。
  • rows    显示MYSQL执行查询的行数,数值越大越不好    
  • Extra:比较重要
    •  using filtersort:不能容忍!使用了文件排序,效率会很差。一般是由于order by的字段没有索引导致的。
    • using index:表示当前的查询是覆盖索引的,直接从索引中读取数据
    • using where:使用 where 进行条件过滤
    • using temporary :不能容忍。建立临时表来保存中间结果,查询完成之后把临时表删除。使用了临时表,效率会很差。一般是由于group by|distinct|union的字段没有索引导致的。
    • using join buffer:使用连接缓存impossible 
    • where:where 语句的结果总是 false
  • key:实际用到的索引列

MySQL 使用索引的原因

  • 提高数据检索的效率。索引可以大幅度提高数据的检索速度,这是因为索引提供了快速查找数据的方法,类似于书籍的目录,使得数据库不必扫描全表就能快速定位到所需的数据。
  • 减少服务器的IO操作。使用索引可以减少数据库的磁盘I/O操作次数。由于索引通常存储在B-Tree结构中,相关数据通常会在索引树的相邻节点上。这样,一次磁盘I/O操作就能加载多个所需的索引键值。
  • 帮助数据库优化器选择最优的查询方案。在存在多个查询方案时,数据库优化器会利用索引信息来评估哪种方案的成本最低。有了索引,优化器可以选择更有效的查询路径,减少查询成本和时间。
  • 保证数据的唯一性。索引不仅可以提高查询速度,还可以通过创建唯一性索引来保证数据的唯一性。这种索引可以防止数据中出现重复的行。
  • 加速表和表之间的连接。在进行JOIN操作时,索引可以加快连接表的过程,因为索引允许数据库快速匹配两个表中的关联字段。
  • 改善排序和分组的速度。对于ORDER BY和GROUP BY子句,索引可以减少排序操作的时间,因为索引已经是预先排序的。这意味着数据库可以直接利用索引来完成排序和分组,而不必再进行额外的排序操作。
  • 支持全文检索。在支持全文检索的数据库引擎中,如MyISAM和InnoDB,全文索引可以极大地提高对文本数据的查询效率。
  •  实现分布式存储。在某些数据库架构中,例如分区表,索引可以帮助实现数据的分布式存储,使得查询可以在少量分区中进行,而不是全表。

b树和b+数据的区别和优缺点

B树的特点:

  1. 所有节点存储键值:B树的每个节点都存储键和数据。这意味着数据可以在树的任意一个节点被找到。
  2. 非叶子节点也存储数据:在B树中,数据不仅仅存储在叶子节点,内部节点也存储数据。
  3. 键值排序:B树中的键值是有序的,便于查找和插入操作。
  4. 所有叶子节点在同一层:B树是平衡的,所有叶子节点都位于同一层。

B+树的特点:

  1. 所有数据引用都在叶子节点:在B+树中,所有的数据引用都位于叶子节点,并且叶子节点包含了全部键值,以及数据记录的指针。
  2. 内部节点只存储键:B+树的内部节点不存储实际的数据,只存储键值,这允许在内部节点中存放更多的键。
  3. 叶子节点形成链表:B+树的叶子节点通过指针相连,形成了一个有序链表,便于范围查询和顺序访问。
  4. 键值可能重复:在B+树中,叶子节点的键值可能在内部节点中重复出现,作为分割点。

B树的优点:

  • 数据可以在内部节点直接被访问到,这对于某些需要频繁访问内部节点的数据库操作有优势。

B树的缺点:

  • 范围查询较慢,因为数据不是连续存储的,需要通过树结构进行多次跳转。
  • 内部节点存储数据,减少了键值的数量,可能会导致树的高度更高,增加磁盘I/O次数。

B+树的优点:

  • 范围查询快速,因为叶子节点形成了有序链表。
  • 更高的磁盘I/O性能,由于内部节点不存储数据,单个节点可以存储更多的键,减少了树的高度。
  • 查询性能稳定,所有数据都在叶子节点查询,每次查询的磁盘读取次数相同。
  • 更适合磁盘存储,叶子节点顺序存储,可以减少磁盘寻道时间。

B+树的缺点:

  • 直接访问数据需要遍历到叶子节点,相比B树可能会稍微慢一些,因为不能在内部节点直接访问到数据。
  • 插入和删除操作可能会更复杂一些,因为需要维护叶子节点之间的链表结构

B+树的内部节点不存储实际的数据,而只存储键(keys)和指向子节点的指针。这种设计的优势在于:

  1. 节点存储更多的键:由于内部节点不需要存储数据本身,相比于B树,它可以包含更多的键。这意味着每个节点可以有更多的分支,从而减少了树的高度。

  2. 降低树的高度:树的高度是指从根节点到叶子节点的最大层数。B+树能够降低树的高度,这是因为每个节点可以指向更多的子节点。较低的树高意味着查找记录时需要经过的节点数量减少了,从而减少了磁盘I/O操作的次数。

  3. 减少磁盘I/O次数:数据库系统查找记录时,每访问一个节点可能都需要一次磁盘I/O操作(尤其是当节点数据不在内存中时)。因此,树的高度越低,查找记录时需要的磁盘I/O操作就越少,从而提高了磁盘I/O性能。

  4. 提高缓存效率:在数据库系统中,经常访问的节点数据可能被缓存到内存中,减少了对磁盘的访问。由于B+树的结构稳定,系统可以更高效地管理缓存,进一步减少不必要的磁盘I/O。

  5. 优化顺序读取:在B+树中,叶子节点通过指针连接成链表,当进行范围查询或顺序读取时,可以通过链表顺序访问,而不需要回到内部节点。这种顺序读取通常比随机读取更快,因为它减少了磁盘寻道时间。

什么是覆盖索引和索引下推?

覆盖索引 (Covering Index)

覆盖索引是指一个索引包含(或“覆盖”)了查询所需要的所有数据。换句话说,查询可以仅通过访问索引来获取所需的所有信息,而无需访问数据表的行。当一个查询被覆盖索引支持时,数据库引擎可以直接从索引中检索数据,而不必进行额外的数据表访问,从而显著提高查询性能。

例如,假设有一个数据表Users,包含idname,和email字段,你经常需要执行以下查询:

SELECT id, name FROM Users WHERE name = 'Alice';

如果你创建了一个包含nameid字段的复合索引,那么这个索引就是一个覆盖索引,因为它包含了查询所需的所有字段。查询时,数据库系统可以直接使用这个索引来找到所有名为'Alice'的用户的idname,而无需查看表中的实际行。

索引下推 (Index Condition Pushdown, ICP)

索引下推是MySQL数据库引擎(尤其是InnoDB存储引擎)中的一个优化技术,它允许数据库在索引层面就过滤掉不符合条件的记录,而不必将这些记录的所有数据加载到内存中。这意味着数据库引擎可以在处理查询时更早地应用WHERE子句中的条件,减少不必要的数据访问。

在没有索引下推的情况下,数据库系统可能需要先从索引中找到所有匹配的行,然后再加载对应的数据行到内存中,最后在内存中应用过滤条件。这个过程可能会导致大量不必要的数据行被加载和处理。

索引下推优化允许数据库在使用索引键值检索数据之前就应用部分WHERE子句条件,这减少了访问数据表的次数和处理不相关行的开销。因此,索引下推可以显著提高查询的效率,特别是对于包含多个条件和大量数据的查询。

哪些操作会导致索引失效

  • 使用函数或表达式:在索引列上使用函数或表达式会使得索引失效。例如,如果对索引列使用了函数转换,如SELECT * FROM table WHERE YEAR(date_column) = 2021,数据库可能无法使用date_column上的索引。
  • 隐式类型转换:当查询条件中的数据类型与索引列的数据类型不匹配时,可能会发生隐式类型转换,这可能导致索引失效。例如,如果索引列是整数类型,而查询条件使用了字符串类型,索引可能就不会被使用。
  • 不是使用索引的最左前缀:对于复合索引(多列索引),如果查询条件不包括索引的最左列,则索引可能不会被完全利用。这被称为最左前缀规则。
  • 使用OR条件连接不同列:当使用OR连接多个条件,而这些条件涉及不同的列时,索引可能不会被有效使用。例如,SELECT * FROM table WHERE indexed_column1 = 'A' OR indexed_column2 = 'B'可能不会利用到两个列上的索引。
  • 使用NOT IN<>!=运算符:这些运算符可能会导致索引不被使用,因为它们通常需要检查除了特定值之外的所有值。
  • 使用LIKE运算符以通配符开头:当使用LIKE运算符进行模式匹配时,如果模式以通配符(如%)开头,如SELECT * FROM table WHERE column LIKE '%value',索引通常不会被使用。

事务的四大特性

  • 原子性: 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
  • 一致性: 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的;
  • 隔离性: 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
  • 持久性: 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响

MySQL怎么保证一致性的

从数据库层面,数据库通过原子性、隔离性、持久性来保证一致性。也就是说ACID四大特性之中,C(一致性)是目的,A(原子性)、I(隔离性)、D(持久性)是手段,是为了保证一致性,数据库提供的手段。数据库必须要实现AID三大特性,才有可能实现一致性。例如,原子性无法保证,显然一致性也无法保证。

MySQL怎么保证原子性的

利用Innodb的undo log。undo log名为回滚日志,是实现原子性的关键,当事务回滚时能够撤销所有已经成功执行的sql语句,他需要记录你要回滚的相应日志信息。例如

1、当你delete一条数据的时候,就需要记录这条数据的信息,回滚的时候,insert这条旧数据

2、当你update一条数据的时候,就需要记录之前的旧值,回滚的时候,根据旧值执行update操作

3、当年insert一条数据的时候,就需要这条记录的主键,回滚的时候,根据主键执行delete操作

undo log记录了这些回滚需要的信息,当事务执行失败或调用了rollback,导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子。

MySQL怎么保证持久性的

利用Innodb的redo log。当做数据修改的时候,不仅在内存中操作,还会在redo log中记录这次操作。当事务提交的时候,会将redo log日志进行刷盘(redo log一部分在内存中,一部分在磁盘上)。当数据库宕机重启的时候,会将redo log中的内容恢复到数据库中,再根据undo log和binlog内容决定回滚数据还是提交数据。

  • 采用redo log的好处? 其实好处就是将redo log进行刷盘比对数据页刷盘效率高,具体表现如下redo log体积小,毕竟只记录了哪一页修改了啥,因此体积小,刷盘快。
  • redo log是一直往末尾进行追加,属于顺序IO。效率显然比随机IO来的快

MySQL怎么保证隔离性的

利用的是锁和MVCC机制。

MVCC,即多版本并发控制(Multi Version Concurrency Control),一个行记录数据有多个版本对快照数据,这些快照数据在undo log中。如果一个事务读取的行正在做DELELE或者UPDATE操作,读取操作不会等行上的锁释放,而是读取该行的快照版本。
但是有一点说明一下,在事务隔离级别为读已提交(Read Commited)时,一个事务能够读到另一个事务已经提交的数据,是不满足隔离性的。但是当事务隔离级别为可重复读(Repeateable Read)中,是满足隔离性的。

事务的隔离级别

SQL 标准定义了四个隔离级别:

  • READ-UNCOMMITTED(读取未提交): 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读
  • READ-COMMITTED(读取已提交): 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生
  • REPEATABLE-READ(可重复读): 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生
  • SERIALIZABLE(可串行化): 最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读

脏读、不可重复读、幻读

  • 脏读:读取未提交数据
  • 不可重复读:前后多次读取,数据内容不一致
  • 幻读:前后多次读取,数据总量不一)。幻读通常发生在REPEATABLE READ(可重复读)隔离级别下,但在MySQL的InnoDB存储引擎中,默认的REPEATABLE READ隔离级别通过多版本并发控制(MVCC)避免了幻读。

binlog、redo log、undo log 区别与作用

binlog

binlog用于记录数据库执行的写入性操作(不包括查询)信息,以二进制的形式保存在磁盘中。binlog是mysql的逻辑日志,并且由Server层进行记录,使用任何存储引擎的mysql数据库都会记录binlog日志。

  • 逻辑日志:可以简单理解为记录的就是sql语句。
  • 物理日志:因为mysql数据最终是保存在数据页中的,物理日志记录的就是数据页变更。

对于InnoDB存储引擎而言,只有在事务提交时才会记录biglog,此时记录还在内存中,那么biglog是什么时候刷到磁盘中的呢?mysql通过sync_binlog参数控制biglog的刷盘时机,取值范围是0-N:

  • 0:不去强制要求,由系统自行判断何时写入磁盘;
  • 1:每次commit的时候都要将binlog写入磁盘;
  • N:每N个事务,才会将binlog写入磁盘。

从上面可以看出,sync_binlog最安全的是设置是1,这也是MySQL 5.7.7之后版本的默认值。但是设置一个大一些的值可以提升数据库性能,因此实际情况下也可以将值适当调大,牺牲一定的一致性来获取更好的性能。

redo log

redo log是InnoDB存储引擎层的日志,又称重做日志文件,用于记录事务操作的变化,记录的是数据修改之后的值,不管事务是否提交都会记录下来。在实例和介质失败(media failure)时,redo log文件就能派上用场,如数据库掉电,InnoDB存储引擎会使用redo log恢复到掉电前的时刻,以此来保证数据的完整性。

redo log包括两部分:一个是内存中的日志缓冲(redo log buffer),另一个是磁盘上的日志文件(redo log file)。mysql每执行一条DML语句,先将记录写入redo log buffer,后续某个时间点再一次性将多个操作记录写到redo log file。这种先写日志,再写磁盘的技术就是MySQL里经常说到的WAL(Write-Ahead Logging) 技术。

undo log

undolog:undo log主要记录了数据的逻辑变化,比如一条INSERT语句,对应一条DELETE的undo log,对于每个UPDATE语句,对应一条相反的UPDATE的undo log,这样在发生错误时,就能回滚到事务之前的数据状态。

redo log 写入方式

redo log包括两部分内容,分别是内存中的日志缓冲(redo log buffer)和磁盘上的日志文件(redo log file)。

MySQL 每执行一条 DML 语句,会先把记录写入 redo log buffer(用户空间) ,再保存到内核空间的缓冲区 OS-buffer 中,后续某个时间点再一次性将多个操作记录写到 redo log file(刷盘) 。这种先写日志,再写磁盘的技术,就是WAL

redo log buffer写入到redo log file,是经过OS buffer中转的。其实可以通过参数innodb_flush_log_at_trx_commit进行配置,参数值含义如下:

  • 0:称为延迟写,事务提交时不会将redo log buffer中日志写入到OS buffer,而是每秒写入OS buffer并调用写入到redo log file中。
  • 1:称为实时写,实时刷”,事务每次提交都会将redo log buffer中的日志写入OS buffer并保存到redo log file中。
  • 2: 称为实时写,延迟刷。每次事务提交写入到OS buffer,然后是每秒将日志写入到redo log file。

binlog 日志的三种格式

  • Statement:基于SQL语句的复制((statement-based replication,SBR))。每一条会修改数据的 SQL 都会记录在 binlog 中
    • 优点:不需要记录每一行的变化,减少了binlog日志量,节约了IO,提高性能。
    • 缺点:由于记录的只是执行语句,为了这些语句能在备库上正确运行,还必须记录每条语句在执行的时候的一些相关信息,以保证所有语句能在备库得到和在主库端执行时候相同的结果。
  • Row:基于行的复制。(row-based replication,RBR)。不记录 SQL 语句上下文相关信息,仅保存哪条记录被修改。
    • 优点:binlog 中可以不记录执行的 SQL 语句的上下文相关的信息,仅需要记录那一条记录被修改成什么了。所以rowlevel的日志内容会非常清楚的记录下每一行数据修改的细节。不会出现某些特定情况下的存储过程、或 function、或trigger的调用和触发无法被正确复制的问题
    • 缺点:可能会产生大量的日志内容。
  • Mixed:混合模式复制。(mixed-based replication,MBR)。实际上就是 Statement 与 Row 的结合。一般的语句修改使用 statment 格式保存 binlog,如一些函数,statement 无法完成主从复制的操作,则采用 row 格式保存 binlog,MySQL 会根据执行的每一条具体的 SQL 语句来区分对待记录的日志形式。

redo log日志格式

redo log buffer (内存中)是由首尾相连的四个文件组成的,它们分别是:ib_logfile_1、ib_logfile_2、ib_logfile_3、ib_logfile_4。

  • write pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。
  • checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。
  • write pos 和 checkpoint 之间的是“粉板”上还空着的部分,可以用来记录新的操作。
  • 如果 write pos 追上 checkpoint,表示“粉板”满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下。
  • 有了 redo log,当数据库发生宕机重启后,可通过 redo log将未落盘的数据(check point之后的数据)恢复,保证已经提交的事务记录不会丢失,这种能力称为crash-safe

MVCC

MySQL实现的MVCC,主要是用于在并发读写的情况下,保证 “读” 数据时无需加锁也可以读取到数据的某一个版本的快照,好处是可以避免加锁,降低开销,解决了读写冲突,增大了数据库的并发性能。

  • 当前读:读取的数据是最新版本,读取数据时还要保证其他并发事务不会修改当前的数据,当前读会对读取的记录加锁。比如:select …… lock in share mode(共享锁)、select …… for update | update | insert | delete(排他锁)
  • 快照读:每一次修改数据,都会在 undo log 中存有快照记录,这里的快照,就是读取undo log中的某一版本的快照。这种方式的优点是可以不用加锁就可以读取到数据,缺点是读取到的数据可能不是最新的版本。一般的查询都是快照读,比如:select * from t_user where id=1; 在MVCC中的查询都是快照度。

原理

MySQL中MVCC主要是通过行记录中的隐藏字段(隐藏主键 row_id、事务ID trx_id、回滚指针 roll_pointer)、undo log(版本链)ReadView(一致性读视图)来实现的。

1、隐藏字段

MySQL中,在每一行记录中除了自定义的字段,还有一些隐藏字段:

  • row_id:当数据库表没定义主键时,InnoDB会以row_id为主键生成一个聚集索引。

  • trx_id:事务ID记录了新增/最近修改这条记录的事务id,事务id是自增的。

  • roll_pointer:回滚指针指向当前记录的上一个版本(在 undo log 中)。

2、版本链

简单提下 redo log 和 undo log。在修改数据的时候,会向 redo log 中记录修改的页内容(为了在数据库宕机重启后恢复对数据库的操作),也会向 undo log 记录数据原来的快照(用于回滚事务)。undo log有两个作用,除了用于回滚事务,还用于实现MVCC。

3、ReadView

多个事务对 id=1 的数据修改后,这行记录除了最新的数据,在 undo log 中还有多个版本的快照。那其他事务查询时能查到最能读到哪个版本的快照就由ReadView来决定了。ReadView 就是MVCC在对数据进行快照读时,会产生的一个”读视图“。

ReadView中有4个比较重要的变量:

m_ids:活跃事务id列表,当前系统中所有活跃的(也就是没提交的)事务的事务id列表。

min_trx_id:m_ids 中最小的事务id。

max_trx_id:生成 ReadView 时,系统应该分配给下一个事务的id(注意不是 m_ids 中最大的事务id),也就是m_ids 中的最大事务id + 1 。

creator_trx_id:生成该 ReadView 的事务的事务id。

某个事务进行快照读时可以读到哪个版本的数据,ReadView 有一套算法:

(1)当【版本链中记录的 trx_id 等于当前事务id(trx_id = creator_trx_id)】时,说明版本链中的这个版本是当前事务修改的,所以该快照记录对当前事务可见。

(2)当【版本链中记录的 trx_id 小于活跃事务的最小id(trx_id < min_trx_id)】时,说明版本链中的这条记录已经提交了,所以该快照记录对当前事务可见。

(3)当【版本链中记录的 trx_id 大于下一个要分配的事务id(trx_id > max_trx_id)】时,该快照记录对当前事务不可见。

(4)当【版本链中记录的 trx_id 大于等于最小活跃事务id】且【版本链中记录的trx_id小于下一个要分配的事务id】(min_trx_id<= trx_id < max_trx_id)时,如果版本链中记录的 trx_id 在活跃事务id列表 m_ids 中,说明生成 ReadView 时,修改记录的事务还没提交,所以该快照记录对当前事务不可见;否则该快照记录对当前事务可见。

MVCC主要是用来解决RU隔离级别下的脏读和RC隔离级别下的不可重复读的问题,所以MVCC只在RC(解决脏读)和RR(解决不可重复读)隔离级别下生效,也就是MySQL只会在RC和RR隔离级别下的快照读时才会生成ReadView。区别就是,在RC隔离级别下,每一次快照读都会生成一个最新的ReadView;在RR隔离级别下,只有事务中第一次快照读会生成ReadView,之后的快照读都使用第一次生成的ReadView。

当数据库 crash 后,如何恢复未刷盘的数据到内存中

根据 redo log 和 binlog 的两阶段提交,未持久化的数据分为几种情况:

  • change buffer 写入,redo log 虽然做了 fsync 但未 commit,binlog 未 fsync 到磁盘,这部分数据丢失。
  • change buffer 写入,redo log fsync 未 commit,binlog 已经 fsync 到磁盘,先从 binlog 恢复 redo log,再从 redo log 恢复 change buffer。
  • change buffer 写入,redo log 和 binlog 都已经 fsync,直接从 redo log 里恢复。

行锁和表锁

  • 行级锁并不是直接锁记录,而是锁索引。索引分为主键索引和非主键索引两种,如果一条sql语句操作了主键索引,MySQL就会锁定这条主键索引;如果一条语句操作了非主键索引,MySQL会先锁定该非主键索引,再锁定相关的主键索引

  • 有命中索引时候才会使用行锁

行级锁的类型主要有三类:

  • Record Lock,记录锁,也就是仅仅把一条记录锁上;
  • Gap Lock,间隙锁,锁定一个范围,但是不包含记录本身;
  • Next-Key Lock:Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。

什么场景需要建索引

  • 高频查询的列。场景描述:如果某个列经常用于查询条件(如WHERE子句),为这个列创建索引可以显著提高查询效率。
  •  作为连接条件的列。场景描述:在进行表的JOIN操作时,连接条件(ON子句)中使用的列应该有索引,这样可以加快连接查询的速度。
  • 包含在ORDER BY、GROUP BY、DISTINCT子句中的列。场景描述:当对数据进行排序、分组或者去重时,对应的列上如果有索引,可以减少排序和分组的时间。
  • 多列查询的组合索引。场景描述:当查询条件中经常同时包含多个列时,可以创建组合索引。组合索引应根据列的选择性和查询中列的顺序来设计。
  •  大数据量表。场景描述:对于包含大量数据的表,全表扫描的成本很高,因此在查询、排序和分组最常用的列上创建索引是非常有效的。
  •  唯一性约束。场景描述:对于需要保证数据唯一性的列(如用户的邮箱地址),创建唯一索引可以在插入数据时自动保证数据的唯一性。

什么场景不需建索引

  • 数据量较小的表。百万级以下的数据不需要创建索引。场景描述:对于数据量非常小的表,数据库引擎可能会直接进行全表扫描,因为全表扫描的成本可能比使用索引更低。
  •  频繁进行大批量更新或插入操作的表。场景描述:如果一个表经常需要进行大量的插入或更新操作,索引的维护可能会导致性能下降,因为每次数据变动都需要更新索引。
  • 数据分布非常均匀的列。场景描述:如果一个列的值几乎都是唯一的,或者值的分布非常均匀(例如性别列只有“男”和“女”两个值),那么索引的效果会很差,不如全表扫描。
  • 数据变动非常频繁的列。场景描述:对于数据变化非常频繁的列,索引可能会经常被重建,这会消耗大量的资源。
  • 冗余或重复索引。场景描述:如果已经有了一个覆盖了多个列的组合索引,那么对这些列的单独索引可能就是冗余的。
  • 查询中很少使用的列。场景描述:如果某个列很少出现在查询条件中,那么为这个列创建索引可能不会带来性能上的好处。

死锁

要发生死锁,一般需要满足以下四个必要条件:

  • 资源互斥:就是资源不能共享,提现在InnoDB中就是一个事务申请了锁,其他事务就不能申请。
  • 不可抢占:就是一个事务没有获得锁,只能等待拥有锁的事务释放锁,而不能去直接抢占锁。
  • 占有且等待:就是没有获得锁的事务只能进行等待,并且也不会释放自己已拥有的锁。
  • 循环等待:就是事务之间拥有的资源必须形成了一个环,例如事务A拥有一些事务B需要的资源,同时事务A也在申请事务B当前拥有的一些资源。

如何避免死锁

预防死锁的思路主要还是从破坏死锁发生需要的必要条件入手,资源互斥这个条件没法去破坏,

  • 破坏不可抢占条件:就是当一个进程发现自己申请不到某个资源时,那么就把当前持有的资源全部释放掉,待以后需要使用的时候再重新申请。这意味着进程已占有的资源会被短暂地释放或者说是被抢占了。该种方法实现起来比较复杂,且代价也比较大。释放已经保持的资源很有可能会导致进程之前的工作白费了,反复的申请和释放资源会导致进程的执行被无限的推迟,这不仅会延长进程的周转周期,还会影响系统的吞吐量。
  • 破坏占有且等待条件:
    • 执行前申请所有资源。所有的进程在开始运行之前,必须一次性地申请其在整个运行过程中所需要的全部资源,申请成功,进程才能启动。
      • 优点:简单易实施且安全。
      • 缺点:因为某项资源不满足,进程无法启动,而其他已经满足了的资源也不会得到利用,严重降低了资源的利用率,可能会造成每次能启动并运行的进程过少,造成资源浪费。
    • 边执行边释放资源。该方法是对第一种方法的改进,允许进程只获得运行初期需要的资源,便开始运行,在运行过程中逐步释放掉分配到的已经使用完毕的资源,然后再去请求新的资源。这样的话,资源的利用率会得到提高,也会减少进程的饥饿问题。
  • 破坏“循环等待”条件。可将每个资源编号,当一个进程占有编号为i的资源时,那么它下一次申请资源只能申请编号>i的资源。这样资源之间就不会形成环。但是也会造成资源利用率的降低,例如有些进程一开始申请了资源2,哪怕不存在进程竞争,它后续如果需要资源1也申请不到。

分库分表怎么做

垂直拆分

  • 垂直分库本质是专库专用,指按照业务将表进行分类,分布到不同的数据库中,每个库可以放在不同的服务器上。
    • 优点
      • 专库专用,业务层面解耦
      • 能够针对不同业务的数据进行分级管理、维护、监控、扩展
      • 在一定程度上提升了IO、数据库连接数、降低单机硬件资源的瓶颈
  • 缺点

    • 事务一致性的问题
    • 多表连接查询困难
  • 垂直分表本质是将一个表按照字段分成多表,每个表存储其中一部分字段。
    • 垂直分表拆分原则
      • 将热点字段和不常用的字段区分,放在不同的表中
      • 将text,blob等大字段拆分出来放在附表中
      • 将组合查询的列放在一张表中
    • 优点
      • 减少锁竞争,查询不同字段数据互不影响
      • 可实现冷热分离的数据表设计
      • 可以使得行数据变小,一个数据页能存放更多的数据,最大限度利用数据页缓存,减少查询的 I/O 次 数
    • 缺点
      • 事务一致性的问题
      • 多表连接查询困难
      • 无法解决单表数据量过大

水平拆分

水平分表的本质是数据分片,将不同的数据按照一定的规则( hash取模/range范围)将数据存储在不同的表中,以此减少单表的数据量,提高查询效率

  • 优点
    • 解决单表数据量大,查询性能下降的问题
    • 可实现多表连接查询
  • 缺点
    • 引发排序、分页、函数计算等问题
    • 数据扩容的难度和维护量极大。

目前市面上有很多比较成熟的分库分表中间件,可以帮助我们解决分库分表后多数据源问题

  • 「shardingsphere(前身 sharding-jdbc)」
  • 「cobar」
  • 「Mycat」
  • 「Atlas」
  • 「TDDL(淘宝)」
  • 「vitess」

mybatis为什么可以把sql拿来就用,底层是怎么实现的?是否有使用到aop原理?怎么用的?

MyBatis 可以把 SQL 拿来就用,底层是通过动态代理和反射机制实现的。

MyBatis 的 Mapper 接口定义了一系列的方法,每个方法对应一个 SQL 语句。当应用程序调用 Mapper 接口的方法时,MyBatis 会根据方法名和参数类型等信息,动态生成一个代理对象,代理对象会调用 SqlSession 中的相应方法,从而执行 SQL 语句。

在执行 SQL 语句时,MyBatis 会使用反射机制获取 Mapper 接口中对应方法的注解信息,从而获取 SQL 语句和参数信息。然后,MyBatis 会根据 SQL 语句和参数信息,生成一个 BoundSql 对象,BoundSql 对象包含了 SQL 语句和参数信息等。最后,MyBatis 会将 BoundSql 对象传递给 SqlSession,SqlSession 会将 BoundSql 对象转换为 JDBC 的 PreparedStatement 对象,并执行 SQL 语句。

在执行 SQL 语句后,MyBatis 会将查询结果映射为 Java 对象。MyBatis 通过反射机制获取 Mapper 接口中对应方法的返回值类型,然后根据返回值类型,使用反射机制创建一个空的对象。接着,MyBatis 会根据查询结果的列名和 Java 对象的属性名等信息,使用反射机制将查询结果映射到 Java 对象中。

需要注意的是,MyBatis 的 SQL 语句是通过注解或 XML 文件定义的,MyBatis 会根据注解或 XML 文件中的信息,动态生成 SQL 语句。在生成 SQL 语句时,MyBatis 会使用占位符来代替参数,从而避免了 SQL 注入等安全问题。同时,MyBatis 还提供了多种类型的参数映射方式,如基本类型、Map、JavaBean 等,可以满足不同的需求。

Spring

Spring IOC

Ioc—Inversion of Control,即“控制反转”,不是什么技术,而是一种设计思想。在Java开发中,Ioc意味着将你设计好的对象交给容器控制,而不是传统的在你的对象内部直接控制。

传统Java SE程序设计,我们直接在对象内部通过new进行创建对象,是程序主动去创建依赖对象;而IoC是有专门一个容器来创建这些对象,即由Ioc容器来控制对象的创建。有了IoC容器后,把创建和查找依赖对象的控制权交给了容器,由容器进行注入组合对象,所以对象与对象之间是 松散耦合,这样也方便测试,利于功能复用,更重要的是使得程序的整个体系结构变得非常灵活。

IOC和DI的关系

控制反转是通过依赖注入实现的,其实它们是同一个概念的不同角度描述。通俗来说就是IoC是设计思想,DI是实现方式。

DI—Dependency Injection,即依赖注入:组件之间依赖关系由容器在运行期决定,形象的说,即由容器动态的将某个依赖关系注入到组件之中。依赖注入的目的并非为软件系统带来更多功能,而是为了提升组件重用的频率,并为系统搭建一个灵活、可扩展的平台。通过依赖注入机制,我们只需要通过简单的配置,而无需任何代码就可指定目标需要的资源,完成自身的业务逻辑,而不需要关心具体的资源来自何处,由谁实现。

Ioc 配置的三种方式

  • xml 配置。将bean的信息配置.xml文件里,通过Spring加载文件为我们创建bean。这种方式出现很多早前的SSM项目中,将第三方类库或者一些配置工具类都以这种方式进行配置
  • Java 配置。将类的创建交给我们配置的JavcConfig类来完成,Spring只负责维护和管理,采用纯Java创建方式。其本质上就是把在XML上的配置声明转移到Java配置类中
  • 注解配置。通过在类上加注解的方式,来声明一个类交给Spring管理,Spring会自动扫描带有@Component,@Controller,@Service,@Repository这四个注解的类,然后帮我们创建并管理,前提是需要先配置Spring的注解扫描器

Spring AOP

Aspect Oriented Programming (AOP) - 面向切面编程。AOP 采取横向抽取机制(动态代理),取代了传统纵向继承机制的重复性代码,其应用主要体现在事务处理、日志管理、权限控制、异常处理等方面。

主要作用是分离功能性需求和非功能性需求,使开发人员可以集中处理某一个关注点或者横切逻辑,减少对业务代码的侵入,增强代码的可读性和可维护性。

使用方法

  • 选择连接点。选择哪一个类的哪一方法用以增强功能
  • 创建切面。我们可以把切面理解为一个拦截器,当程序运行到连接点的时候,被拦截下来,在开头加入了初始化的方法,在结尾也加入了销毁的方法而已,在 Spring 中只要使用 @Aspect 注解一个类,那么 Spring IoC 容器就会认为这是一个切面了
  • 定义切点。在的注解中定义了 execution 的正则表达式,Spring 通过这个正则表达式判断具体要拦截的是哪一个类的哪一个方法
  • 环绕通知。前置通知和后置通知

Spring 中的 bean 的作用域

可以使用@Scope注解来指定bean的作用域

  •  Singleton:默认的作用域。对于在IOC容器中定义的每个bean配置,容器将创建一个单一的实例。无论请求多少次,每次返回的都是相同的对象实例。
  •  Prototype:每次请求(调用)创建一个新实例。
  • Request:每次HTTP请求都会创建一个新的bean,该bean仅在当前HTTP request内有效。
  • Session:“每次HTTP session都会创建一个新的bean,该bean仅在当前HTTP session内有效。
  • Application:在一个ServletContext的生命周期内,只创建一个bean实例。对于同一个应用级别的ServletContext,bean以单例方式存在。
  • Websocket:在一个WebSocket的生命周期内,只创建一个bean实例。
  • Custom:用户可以创建自定义的作用域,但这需要较高的复杂性,通常不推荐除非有特殊需求。

注意

  • singleton和prototype作用域可以用在任何类型的Spring项目中。
  • request、session、application和websocket作用域仅在Spring的web应用中有效,因为它们是基于web的概念。

Spring的单例 bean的线程安全

Spring框架中的单例Bean默认情况下是不保证线程安全的

  • 共享资源: 如果Bean中含有共享资源(如共享变量),在并发访问时可能会导致数据不一致。
  • 类成员变量: Bean中的非静态字段在多个线程间共享,如果这些字段可变,那么并发访问可能会导致问题。
  • 外部依赖: 如果Bean依赖于其他非线程安全的组件或资源,那么这也可能导致线程安全问题。
解决线程安全问题的策略
  • 无状态Bean: 确保Bean不含有可变的状态(即没有字段或者只有不可变字段),这样Bean自然是线程安全的。
  • 局部变量: 使用方法局部变量代替类成员变量,因为局部变量存储在每个线程自己的栈中,不会被多线程共享。
  • 同步: 对于有状态的Bean,可以使用同步机制(如synchronized关键字或Lock接口)来保证线程安全。
  • ThreadLocal: 使用ThreadLocal为每个线程提供独立的实例副本,从而避免共享。

Spring bean的生命周期

  • Spring容器从配置文件、注解或Java配置中读取Bean的定义。
  • 依赖注入: 容器通过反射机制将定义的属性值或者其他Bean注入到Bean中。
  • 如果涉及到一些属性值 利用 set()方法设置一些属性值。
  • 如果 Bean 实现了 BeanNameAware 接口,调用 setBeanName()方法,传入Bean的名字。
  • 如果 Bean 实现了 BeanClassLoaderAware 接口,调用 setBeanClassLoader()方法,传入 ClassLoader对象的实例。
  • 如果Bean实现了 BeanFactoryAware 接口,调用 setBeanClassLoader()方法,传入 ClassLoade r对象的实例。
  • 与上面的类似,如果实现了其他 *.Aware接口,就调用相应的方法。
  • 如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessBeforeInitialization() 方法
  • 如果Bean实现了InitializingBean接口,执行afterPropertiesSet()方法。
  • 如果 Bean 在配置文件中的定义包含 init-method 属性,执行指定的方法。
  • 如果有和加载这个 Bean的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessAfterInitialization() 方法
  • 当要销毁 Bean 的时候,如果 Bean 实现了 DisposableBean 接口,执行 destroy() 方法。
  • 当要销毁 Bean 的时候,如果 Bean 在配置文件中的定义包含 destroy-method 属性,执行指定的方法。

流程如下所示:

Spring 用到了哪些设计模式

  • 工厂设计模式 : Spring使用工厂模式通过 BeanFactoryApplicationContext 创建 bean 对象。
  • 代理设计模式 : Spring AOP 功能的实现。
  • 单例设计模式 : Spring 中的 Bean 默认都是单例的。
  • 模板方法模式 : Spring 中 jdbcTemplatehibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。
  • 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
  • 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。
  • 适配器模式 :Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller

Spring 事务的隔离级别

相比于 MySQL 的事务隔离级别,Spring 中多了一种 DEFAULT 的事务隔离级别

  • TransactionDefinition.ISOLATION_DEFAULT:  使用后端数据库默认的隔离级别,Mysql 默认采用的 REPEATABLE_READ隔离级别 Oracle 默认采用的 READ_COMMITTED隔离级别
  • TransactionDefinition.ISOLATION_READ_UNCOMMITTED: 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读
  • TransactionDefinition.ISOLATION_READ_COMMITTED:   允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生
  • TransactionDefinition.ISOLATION_REPEATABLE_READ:  对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
  • TransactionDefinition.ISOLATION_SERIALIZABLE:   最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。

Spring事务失效的场景和原因

  • 抛出异常
    • 如果@Transactional 没有特别指定,Spring 只会在遇到运行时异常RuntimeException或者error时进行回滚,而IOException等检查异常不会影响回滚。
    • 解决方法也很简单。配置rollbackFor属性,例如@Transactional(rollbackFor = Exception.class)
  • 业务方法本身捕获了异常
    • Spring是否进行回滚是根据你是否抛出异常决定的,所以如果你自己捕获了异常,Spring 也无能为力
  • 同一类中的方法调用
    • 事务失败的原因也很简单,因为Spring的事务管理功能是通过动态代理实现的,而Spring默认使用JDK动态代理,而JDK动态代理采用接口实现的方式,通过反射调用目标类。简单理解,就是saveUser()方法中调用this.doInsert(),这里的this是被真实对象,所以会直接走doInsert的业务逻辑,而不会走切面逻辑,所以事务失败。
    • 方案一:解决方法可以是直接在启动类中添加@Transactional注解saveUser()。方案二:@EnableAspectJAutoProxy(exposeProxy = true)在启动类中添加,会由Cglib代理实现。
  • 方法使用 final 或 static关键字
    • 如果Spring使用了Cglib代理实现(比如你的代理类没有实现接口),而你的业务方法恰好使用了final或者static关键字,那么事务也会失败。更具体地说,它应该抛出异常,因为Cglib使用字节码增强技术生成被代理类的子类并重写被代理类的方法来实现代理。如果被代理的方法的方法使用final或static关键字,则子类不能重写被代理的方法。如果Spring使用JDK动态代理实现,JDK动态代理是基于接口实现的,那么final和static修饰的方法也就无法被代理。总而言之,方法连代理都没有,那么肯定无法实现事务回滚了
  • 方法不是public
    • 如果方法不是public,Spring事务也会失败,因为Spring的事务管理源码AbstractFallbackTransactionAttributeSource中有判断computeTransactionAttribute()。如果目标方法不是公共的,则TransactionAttribute返回null
  • 错误使用传播机制
    • Spring事务的传播机制是指在多个事务方法相互调用时,确定事务应该如何传播的策略。Spring提供了七种事务传播机制:REQUIRED、SUPPORTS、MANDATORY、REQUIRES_NEW、NOT_SUPPORTED、NEVER、NESTED。如果不知道这些传播策略的原理,很可能会导致交易失败。
    • 事务的传播机制说明如下:
      • REQUIRED 如果当前上下文中存在事务,那么加入该事务,如果不存在事务,创建一个事务,这是默认的传播属性值。
      • SUPPORTS 如果当前上下文存在事务,则支持事务加入事务,如果不存在事务,则使用非事务的方式执行。
      • MANDATORY 如果当前上下文中存在事务,否则抛出异常。
      • REQUIRES_NEW 每次都会新建一个事务,并且同时将上下文中的事务挂起,执行当前新建事务完成以后,上下文事务恢复再执行。
      • NOT_SUPPORTED 如果当前上下文中存在事务,则挂起当前事务,然后新的方法在没有事务的环境中执行。
      • NEVER 如果当前上下文中存在事务,则抛出异常,否则在无事务环境上执行代码。
      • NESTED 如果当前上下文中存在事务,则嵌套事务执行,如果不存在事务,则新建事务
    • 将事务传播策略更改为默认值REQUIRED。REQUIRED原理是如果当前有一个事务被添加到一个事务中,如果没有,则创建一个新的事务,父事务和被调用的事务在同一个事务中。即使被调用的异常被捕获,整个事务仍然会被回滚。
  • 没有被Spring管理
    • 如果此时把 @Service 注解注释掉,这个类就不会被加载成一个 Bean,那这个类就不会被 Spring 管理了,事务自然就失效了
  • 多线程
    • 我们说的同一个事务,其实是指同一个数据库连接,只有拥有同一个数据库连接才能同时提交和回滚。如果在不同的线程,拿到的数据库连接肯定是不一样的,所以是不同的事务。

海量数据

海量的URL中找出相同的URL

给定 a、b 两个文件,各存放 50 亿个 URL,每个 URL 各占 64B,内存限制是 4G。请找出 a、b 两个文件共同的 URL

每个 URL 占 64B,那么 50 亿个 URL 占用的空间大小约为 320GB。
由于内存大小只有 4G,因此,我们不可能一次性把所有 URL加载到内存中处理。对于这种类型的题目,一般采用分治策略,即:把一个文件中的 URL 按照某个特征划分为多个小文件,使得每个小文件大小不超过 4G,这样就可以把这个小文件读到内存中进行处理了。

思路如下

首先遍历文件 a,对遍历到的 URL 求 hash(URL) % 1000 ,根据计算结果把遍历到的 URL 存储到 a0, a1, a2, ..., a999,这样每个大小约为 300MB。使用同样的方法遍历文件 b,把文件 b 中的 URL 分别存储到文件 b0, b1, b2, ..., b999 中。这样处理过后,所有可能相同的 URL 都在对应的小文件中,即 a0 对应 b0, ..., a999 对应 b999,不对应的小文件不可能有相同的 URL。那么接下来,我们只需要求出这 1000 对小文件中相同的 URL 就好了。

接着遍历 ai( i∈[0,999] ),把 URL 存储到一个 HashSet 集合中。然后遍历 bi 中每个 URL,看在 HashSet 集合中是否存在,若存在,说明这就是共同的 URL,可以把这个 URL 保存到一个单独的文件中。

海量数据中找出高频词

有一个 1GB 大小的文件,文件里每一行是一个词,每个词的大小不超过 16B,内存大小限制是 1MB,要求返回频数最高的 100 个词(Top 100)。

同样可以采用分治策略,把一个大文件分解成多个小文件,保证每个文件的大小小于 1MB,进而直接将单个小文件读取到内存中进行处理。

思路如下:

  • 分而治之,进行哈希取。首先遍历大文件,对遍历到的每个词 x,执行 hash(x) % 5000 ,将结果为 i 的词存放到文件 ai 中。遍历结束后,我们可以得到 5000 个小文件。每个小文件的大小为 200KB 左右。如果有的小文件大小仍然超过 1MB,则采用同样的方式继续进行分解。
  • 使用 HashMap 统计频数;接着统计每个小文件中出现频数最高的 100 个词。最简单的方式是使用 HashMap 来实现。其中 key 为词,value 为该词出现的频率。具体方法是:对于遍历到的词 x,如果在 map 中不存在,则执行 map.put(x, 1) ;若存在,则执行 map.put(x, map.get(x)+1) ,将该词频数加 1。
  • 求解最大的 TopN 个,用小顶堆;求解最小的 TopN 个,用大顶堆。上面我们统计了每个小文件单词出现的频数。接下来,我们可以通过维护一个小顶堆来找出所有词中出现频数最高的 100 个。具体方法是:依次遍历每个小文件,构建一个小顶堆,堆大小为 100。如果遍历到的词的出现次数大于堆顶词的出现次数,则用新词替换堆顶的词,然后重新调整为小顶堆,遍历结束后,小顶堆上的词就是出现频数最高的 100 个词。

海量数据中判断一个数是否存在

给定 40 亿个不重复的没排过序的 unsigned int 型整数,然后再给定一个数,如何快速判断这个数是否在这 40 亿个整数当中?

位图法

位图法(BitMap)的核心思想就是用一个bit位来记录0和1两种状态,将具体数据映射到比特数组的具体某一位上,这个bit位设置为0表示该数不存在,设置为1表示该数存在。

如果使用int进行映射,假设我们要排序的数有N个,那么需要申请的内存空间大小就是int[(N - 1) / 32 + 1],映射关系如下:

  • a[0]:0 ~ 31 ;
  • a[1]:32 ~ 63;
  • a[2]:64 ~ 95
  • ...

由此可知一个十进制数num在数组a[num / 32]中,下标为num / 32,一个数num可以通过模32求得在对应数组a[i]中的下标 num % 32。

由于 unsigned int 数字的范围是 [0, 1 << 32),我们用 1<<32=4,294,967,296 个 bit 来表示每个数字。初始位均为 0,那么总共需要内存:4,294,967,296b≈512M。我们读取这 40 亿个整数,将对应的 bit 设置为 1。接着读取要查询的数,查看相应位是否为 1,如果为 1 表示存在,如果为 0 表示不存在。

ES

1.ES 查询性能优化手段

1.1 索引优化

<1>使用适当的数据类型:

  • 使用 keyword 类型而不是 text 类型存储不需要全文搜索的字段,如状态码、ID、邮件地址等

  • 对于整数字段,根据数值范围选择 byte、short、integer 或 long 类型,而不是统一使用 long。

  • 对于小数,根据精度需求选择 float 或 double 类型。

  • 对于日期字段,使用 date 类型,并指定合适的格式。

<2>使用多字段 (multi-fields):

对于同一字段需要支持不同类型的搜索,比如全文搜索和精确匹配,可以使用多字段来为同一数据设置不同的索引类型。

例如,一个 name 字段可以有一个作为 text 类型用于全文搜索的主字段和一个作为 keyword 类型用于精确匹配和排序的子字段。

<3>避免使用嵌套对象或嵌套字段:如果不需要复杂数据结构,尽量避免使用 nested 类型,因为它会增加查询复杂性和资源消耗。

<4>使用布尔字段代替字符串:对于只有两种状态的字段,比如 is_active 或 is_deleted,使用 boolean 类型而不是字符串类型如 "true" 或 "false"。

<5>限制字段长度:对于某些文本字段,如果可能,设置一个合理的字符长度限制,以避免存储过长的无效数据。

<6>选择合适的分片数量: 过多或过少的分片都会影响性能,索引不能过大,单个分片不要超过30-50G,单个索引建议不要超过200G。

<7>分片副本不能设置过多,虽然查询遍历分片是并发的,但是如果分片过多,也会影响聚合性能

<8>ES JVM配置机器一半的内存,但是不要超过32G

1.2 查询优化

  • 避免使用高成本查询: 比如 wildcard、regexp 或 fuzzy 查询。

  • 使用 filter 上下文: 对于不需要评分的查询,使用 filter 上下文可以提高缓存效率。

  • 减少返回字段: 使用 _source 过滤返回所需字段。

  • 避免深度分页: 使用 search_after 替代深度分页。

  • 预计算聚合: 如果可能,预先计算并存储聚合结果。

  • 父子关系数据优化: 父子关系查询可能很慢,尽可能规范化数据结构。

1.3 预热索引

为了将索引和数据加载到内存中,这样当真正的查询请求到来时,数据已经在内存中,可以快速响应。索引预热通常在索引创建后、重启集群后或者执行了大规模数据更新后进行。

1.4 监控和分析

使用慢查询日志: 识别和优化慢查询。

理解查询执行: 使用 explain API 来理解查询是如何执行的

控制并发请求: 避免同时发送大量请求,可能导致集群过载

2.ES 集群故障如何处理

  • 确定故障范围:需要确定故障是影响单个节点还是整个集群

  • 查看监控

    • 查看集群的CPU、内存、磁盘和网络使用情况,以确定是否有资源瓶颈或硬件故障;

    • 查看集群的索引和查询qps、请求的RT是否有增加;

    • 例如GET /_cluster/health,了解是哪个或哪些节点出现了问题。可以使用GET /_cat/shards查看分片信息。

  • 查看慢日志:是否有激增的慢查,是否有大查询把集群大挂了

  • 解决方案:

    • 流量过大:进行限流配置,业务确认流量来源,进行集群扩容

    • 大查询打挂集群:节点重启,限制昂贵查询

3.ES 选主算法

Elasticsearch 7.0后的选主流程的具体步骤

  1. 预选举(Pre-Voting):

    • 当节点认为主节点已经失效时,它会试图发起预选举。在这个阶段,节点会询问其他节点是否会支持它成为主节点,但这并不真正启动选举。

  2. 初始化选举(Initiating Election):

    • 如果节点在预选举阶段得到足够多的支持,它会增加自己的term并正式发起一次选举,成为候选节点。

  3. 请求投票(Requesting Votes):

    • 候选节点会向集群中的其他节点发送请求投票的消息。这些消息包含了候选节点的term和关于其日志的信息。

  4. 投票限制(Voting Restrictions):

    • 其他节点在考虑是否投票给候选节点时,会根据几个条件来决定,包括:

      • 候选节点的term是否至少与自己的term一样大。

      • 候选节点的日志是否至少和自己的日志一样新。

  5. 成为主节点(Becoming Master):

    • 如果候选节点获得了集群中大多数节点(超过半数)的支持,它就会成为新的主节点。

  6. 发布新的集群状态(Publishing New Cluster State):

    • 新的主节点将会发布一个新的集群状态,包含了自己作为主节点的信息,并要求其他节点接受这个状态。

  7. 处理不一致(Handling Inconsistencies):

    • 如果在这个过程中发现了任何不一致(例如,有节点的term比当前主节点的还要高),那么整个选举过程可能会被中断,集群可能会重新开始选举。

  8. 集群状态更新(Cluster State Updates):

    • 一旦新的主节点被确认,它就会开始处理集群状态的更新,并确保这些更新被正确地应用到集群中的每个节点。

  9. 日志复制(Log Replication):

    • 主节点负责接收客户端的数据写入请求,并将这些请求作为日志条目复制到集群中的其他节点,以确保数据的一致性和持久性。

4.ES 写入流程

ElasticSearch写入流程详解 - 掘金

5.ES 查询的流程

Elasticsearch - IT巅峰技术的专栏 - 掘金

6. bm25打分算法原理

BM25 实用详解 - 第 2 部分:BM25 算法及其变量 | Elastic Blog

分布式

1.系统拆分原因,怎么拆分

  • 拆分原因

    • 代码量多达几十万行的中大型项目,团队里有几十个人,那么如果不拆分系统,开发效率极其低下,问题很多。但是拆分系统之后,每个人就负责自己的一小部分就好了,可以随便玩儿随便弄。大幅度提升复杂系统大型团队的开发效率。

  • 拆分原则

    • 单一职责、松耦合、DDD、服务粒度适中、考虑团队结构、以业务模型切入、演进式拆分、避免环形依赖和双向依赖

  • 拆分步骤:

    • 分析业务模型、确定服务边界、模块拆分、数据库拆分

    • 功能隔离

    • 上游来源隔离

2.Thrift 协议

Thrift序列化协议浅析 | Andrew's Blog

3.dubbo 负载均衡

Dubbo内置了4种负载均衡策略:

  1. RandomLoadBalance:随机负载均衡。随机的选择一个。是Dubbo的默认负载均衡策略。

  2. RoundRobinLoadBalance:轮询负载均衡。轮询选择一个。

  3. LeastActiveLoadBalance:最少活跃调用数,相同活跃数的随机。活跃数指调用前后计数差。使慢的 Provider 收到更少请求,因为越慢的 Provider 的调用前后计数差会越大。

  4. ConsistentHashLoadBalance:一致性哈希负载均衡。相同参数的请求总是落在同一台机器上

4.服务幂等处理

  • 设置redis key 分布式锁

  • 数据库唯一索引,订单号是唯一的, 订单支付记录只能有一条

  • 通过状态机流转控制幂等 (update goods_order set status=#{status} where id=#{id} and status<#{status})

5.如何设计一个类似dubbo的rpc框架

  • 上来你的服务就得去注册中心注册吧,你是不是得有个注册中心,保留各个服务的信息,可以用 zookeeper 来做,对吧。

  • 然后你的消费者需要去注册中心拿对应的服务信息吧,对吧,而且每个服务可能会存在于多台机器上。

  • 接着你就该发起一次请求了,咋发起?当然是基于动态代理了,你面向接口获取到一个动态代理,这个动态代理就是接口在本地的一个代理,然后这个代理会找到服务对应的机器地址。

  • 然后找哪个机器发送请求?那肯定得有个负载均衡算法了,比如最简单的可以随机轮询是不是。

  • 接着找到一台机器,就可以跟它发送请求了,第一个问题咋发送?你可以说用 netty 了,nio 方式;第二个问题发送啥格式数据?你可以说用 hessian 序列化协议了,或者是别的,对吧。然后请求过去了。

  • 服务器那边一样的,需要针对你自己的服务生成一个动态代理,监听某个网络端口了,然后代理你本地的服务代码。接收到请求的时候,就调用对应的服务代码,对吧。

6.一致性hash

按照常用的hash算法来将对应的key哈希到一个具有2^32次方个节点的空间中,即0 ~ (2^32)-1的数字空间中。现在我们可以将这些数字头尾相连,想象成一个闭合的环形。

将各个服务器使用Hash进行一个哈希,具体可以选择服务器的ip或唯一主机名作为关键字进行哈希,这样每台机器就能确定其在哈希环上的位置。假设我们将四台服务器使用ip地址哈希后在环空间的位置如下:

现在我们将objectA、objectB、objectC、objectD四个对象通过特定的Hash函数计算出对应的key值,然后散列到Hash环上,然后从数据所在位置沿环顺时针“行走”,第一台遇到的服务器就是其应该定位到的服务器。

为了解决这种数据倾斜问题,一致性哈希算法引入了虚拟节点机制,即对每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。具体做法可以先确定每个物理节点关联的虚拟节点数量,然后在ip或者主机名后面增加编号。

7.布隆过滤器

布隆过滤器它实际上是一个很长的二进制向量和k个随机映射函数。

  • 增加元素:通过k个无偏hash函数计算得到k个hash值,依次取模数组长度,得到数组索引,将计算得到的数组索引下标位置数据修改为1

  • 查询元素:布隆过滤器最大的用处就在于判断某样东西一定不存在或者可能存在,而这个就是查询元素的结果。其查询元素的过程如下:

    • 通过k个无偏hash函数计算得到k个hash值

    • 依次取模数组长度,得到数组索引

    • 判断索引处的值是否全部为1,如果全部为1则存在(这种存在可能是误判),如果存在一个0则必定不存在

布隆过滤器的优点:

  • 时间复杂度低,增加和查询元素的时间复杂为O(N),(N为哈希函数的个数,通常情况比较小)

  • 保密性强,布隆过滤器不存储元素本身

  • 存储空间小,如果允许存在一定的误判,布隆过滤器是非常节省空间的(相比其他数据结构如Set集合)

布隆过滤器的缺点:

  • 有点一定的误判率,但是可以通过调整参数来降低

  • 无法获取元素本身

  • 很难删除元素

布隆过滤器常见的应用场景如下:

  • 网页爬虫对 URL 去重,避免爬取相同的 URL 地址;

  • 反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱;

  • Google Chrome 使用布隆过滤器识别恶意 URL;

8.限流算法

令牌桶

假设令牌桶最大容量为n,每秒产生r个令牌

  • 平均速率:则随着时间推延,处理请求的平均速率越来越趋近于每秒处理r个请求,说明令牌桶算法可以控制平均速率

  • 瞬时速率:如果在一瞬间有很多请求进来,此时来不及产生令牌,则在一瞬间最多只有n个请求能获取到令牌执行业务逻辑,所以令牌桶算法也可以控制瞬时速率

单机版实现

capacity为当前令牌桶的中令牌数量,timeStamp为上一次请求获取令牌的时间,我们没必要真的实现计时器每秒产生多少令牌放入容器中,只要记住上一次请求到来的时间,和这次请求的差值就知道在这段时间内产生了多少令牌

下面假设100ms产生1个令牌,且令牌桶最大容量为10

public class LeakyBucketAlgorithm {
    private int capacity = 10;
    private long timeStamp = System.currentTimeMillis();

    public boolean getToken() {
        if (capacity > 0) {
            capacity--;
            return true;
        }
        long current = System.currentTimeMillis();
        if (current - timeStamp >= 100) {
            if ((current - timeStamp) / 100 >= 2) {
            	// 假设100ms产生一个令牌
                capacity += (int)((current - timeStamp) / 100) - 1;
            }
            timeStamp = current;
            if (capacity > 10) capacity = 10;
            return true;
        }
        return false;
    }

    public static void main(String[] args) throws InterruptedException {
        LeakyBucketAlgorithm leakyBucketAlgorithm = new LeakyBucketAlgorithm();
        while (true) {
//            Thread.sleep(10);
            Thread.sleep(100);
            if (leakyBucketAlgorithm.getToken()) {
                System.out.println("获取令牌成功,可以执行业务逻辑了");
            } else {
                System.out.println("获取令牌失败,请稍后重试");
            }
        }
    }
}

9.HTTP和RPC的区别

  • 通信协议不同:HTTP 使用文本协议,RPC 使用二进制协议。

  • 调用方式不同:HTTP 接口通过 URL 进行调用,RPC 接口通过函数调用进行调用。

  • 参数传递方式不同:HTTP 接口使用 URL 参数或者请求体进行参数传递,RPC 接口使用函数参数进行传递。

  • 接口描述方式不同:HTTP 接口使用 RESTful 架构描述接口,RPC 接口使用接口定义语言(IDL)描述接口。

  • 性能表现不同:RPC 接口通常比 HTTP 接口更快,因为它使用二进制协议进行通信,而且使用了一些性能优化技术,例如连接池、批处理等。此外,RPC 接口通常支持异步调用,可以更好地处理高并发场景。RPC,可以基于thrift实现高效的二进制传输 HTTP,大部分是通过json来实现的,字节大小和序列化耗时都比thrift要更消耗性能

  • 负载均衡。RPC,基本都自带了负载均衡策略 HTTP,需要配置Nginx,HAProxy来实现

设计题

1.设计一个IM系统,包括群聊单聊

如何设计一个IM单聊架构 - 掘金

2. 扫码登录是怎么实现的

3. 微博点赞评论设计

Redis数据结构之Zset实现微博排行榜(五) - 掘金

4. 如何设计一个秒杀系统

5. 如何设计一个延迟mq

6. 浏览器输入地址回车,中间发生的过程

  • 20
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值