面试技术要点

12879 字
64 分钟
面试技术要点

..面试技术要点#

多线程#

线程池#

在使用线程池(ThreadPoolExecutor)的时候,都是通过其execute方法执行异步任务的,当提交异步任务的时候,有可能会让异步任务直接在线程中执行,也有可能将异步任务放入任务队列中排队等待。

public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
/*
* Proceed in 3 steps:
*
* 1. If fewer than corePoolSize threads are running, try to
* start a new thread with the given command as its first
* task. The call to addWorker atomically checks runState and
* workerCount, and so prevents false alarms that would add
* threads when it shouldn't, by returning false.
*
* 2. If a task can be successfully queued, then we still need
* to double-check whether we should have added a thread
* (because existing ones died since last checking) or that
* the pool shut down since entry into this method. So we
* recheck state and if necessary roll back the enqueuing if
* stopped, or start a new thread if there are none.
*
* 3. If we cannot queue task, then we try to add a new
* thread. If it fails, we know we are shut down or saturated
* and so reject the task.
*/
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
// 当前线程数小于核心线程数
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
// 如果当前线程池在正常运行,且添加异步任务到队列成功
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false)) // 否则尝试给线程池添加非核心线程
reject(command);
}
# 把一个任务交给线程池来执行,有以下四种情况:
1.如果当前线程池中的线程数 < 这个线程池设定的核心线程数;那么创建一个新的核心线程来执行这个异步任务
2. 如果当前线程池中的线程数 >= 这个线程池设定的核心线程数;那么就把这个新的任务添加到任务队列中
3. 如果添加到任务队列失败了, 那么对于线程池来说,会创建一个新的非核心线程来执行这个异步任务
4. 如果创建非核心线程也失败了(比如说线程池中的线程数量已经达到最大线程数),此时会调用线程池的拒绝策略拒绝这个异步任务

线程池的中的线程是如何实现复用的呢?

其实很简单,JAVA语言中虽然任何一个线程只能被启动一次,但是当异步任务的线程执行完了异步任务之后,它会去异步任务队列中获取新的异步任务来执行,此时就会有两种情况:

  • 获取到了队列中的异步任务,就继续执行这个异步任务
  • 异步任务队列中,没有异步任务,此时因为使用异步任务队列实际为阻塞队列,因此当队列为空时,获取任务的线程会阻塞等待,直到因为有新的任务加入队列而获取任务成功

ThreadPoolExecutor3 个最重要的参数:

  • corePoolSize :核心线程数,线程数定义了最小可以同时运行的线程数量。
  • maximumPoolSize :线程池中允许存在的工作线程的最大数量
  • workQueue:当新任务来的时候会先判断当前运行的线程数量是否达到核心线 程数,如果达到的话,任务就会被存放在队列中。

ThreadPoolExecutor其他常见参数:

  1. keepAliveTime:线程池中的线程数量大于corePoolSize 的时候,如果这 时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直 到等待的时间超过 keepAliveTime才会被回收销毁;
  2. unit :keepAliveTime 参数的时间单位。
  3. threadFactory:为线程池提供创建新线程的线程工厂
  4. handler :线程池任务队列超过 maxinumPoolSize 之后的拒绝策略

线程池中的线程数量#

对于一个线程池中的线程数,到底设置为多少合适呢?

首先,理论上有一些经验经验公式,设N为核数的话:

  • 如果是CPU密集型应用线程数设置为N+1,如果是IO密集型应用线程数设置为2N+1。
  • 还有如下公式,可以来计算线程池中线程的数量,Ncpu表示核数,Ucpu表示cpu的利用率
线程数=NcpuUcpu(1+等待时间/计算时间)线程数 = Ncpu * Ucpu * (1 + 等待时间 / 计算时间)

但是,公式通常不建议直接套公式,还是需要通过实际测试的结果来确定。我们可以在一开始的时候根据公式计算出一个大致的值,然后再根据实际的业务要求,不断的进行压力测试,不断调整,最终得到一个相对合适的值

异步任务的编排#

有三个线程T1,T2,T3,如何保证顺序执行?

join方法: 等待该线程终止

​ 谁等待? 调用join方法的线程阻塞

​ 等待谁? 线程对象.join(),等待join方法所对应的那个线程执行完毕

在多线程中有多种方法让线程按特定顺序执行,我们可以用线程类的join()方法在一个线程中等待另一个线程,另外一个线程完成,该线程继续执行。实际上先启动三个线程中哪一个都行,因为在每个线程的run方法中用join方法限定了三个线程的执行顺序。

public class JoinTest2 {
// 1.现在有T1、T2、T3三个线程,你怎样保证T2在T1执行完后执行,T3在T2执行完后执行
public static void main(String[] args) {
final Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("t1");
}
});
final Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
//引用t1线程,等待t1线程执行完
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2");
}
});
Thread t3 = new Thread(new Runnable() {
@Override
public void run() {
try {
//引用t2线程,等待t2线程执行完
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t3");
}
});
t3.start();
t2.start();
t1.start();
}
}

通过上述例子可以看到,自己实现还是有点麻烦的,我们自己得根据线程间的依赖关系,给每个Thread对象传递它所依赖的Thread对象,而且还得自己调用join方法。

但是,如果我们使用CompletableFuture那就简单多了,只需要使用其thenRun,或者thenAccept或者thenApply或其对应的xxxAsync方法即可实现异步任务的顺序编排了。

同时,我们还需要知道,对于CompletebleFuture而言,其xxxAsync方法如果没有指定线程池的话,都是在其默认的ForkJoin线程池的线程中执行的。

CAS#

即 compare and swap(比较与交换),是一种有名的无锁算法。基于CAS算法,可以实现无锁编程, 即不使用锁的情况下实现多线程同步的原子操作,所以也叫非阻塞同步(Non-blocking Synchronization)

CAS 算法涉及到三个操作数 :

  1. 需要读写的内存值 V
  2. 进行比较的值 A
  3. 拟写入的新值 B

当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值,否则 不会执行任何操作(其中比较和替换是一个原子操作),所以为了保证使用CAS操作一定更新成功,CAS通常还会和循环(自旋)结合在一起。

java.util.concurrent.atomic 包下的类大多是使用 CAS 操作来实现的,比如AtomicInteger。

悲观锁和乐观锁#

乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生 活中悲观的人总是想着事情往坏的方向发展。这两种锁各有优缺点,不能不以 场景而定说一种人好于另外一种人。

悲观锁:每次去拿数据的时候都认为别人会修改,所以每次在拿 数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。我们之前学习过的比如synchronized,以及Lock锁等都属于悲观锁。

乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和 CAS 算法实现。

两种锁的使用场景:

  • 乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的 时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的 情况,一般会经常产生冲突,这就会导致上层应用会不断的进行循环重试
  • 而在写操作比较多的情况下,就比较适合使用悲观锁了。

乐观锁的缺点:

  • 乐观锁只能保证一个共享变量的原子操作。 如果多一个或几个变量 ,乐观锁将变得力不从心 ,但互斥锁能轻 易解决, 不管对象数量多少及对象颗粒度大小 。
  • 长时间自旋(循环重试)可能导致开销大 。假如CAS长时间不成功而一直自旋 ,会给CPU带来很大的开销 。
  • ABA 问题 。 CAS的核心思想是通过比对内存值与预期值是否一样而判断内存值是否被改过 ,但这个判断逻辑 不严谨 , 假如内存值原来是A,后来被一条线程改为B, 最后又被改成了A, 则CAS认此内存值并没有发 生改变 ,但实际上是有被其他线程改过的 ,这种情况对依赖过程值的情景的运算结果影响很大 。解决的思路 是引入版本号 , 每次变量更新都把版本号加 1。

分布式锁#

在商品详情页的缓存优化中,我们给大家介绍过,常见的分布式锁的实现方式有基于MySQL,Redis以及Zookeeper实现的分布式锁。因为我们在项目中使用的是基于Redisson实现的分布式锁。所以我们重点来看看它的实现原理。

Redisson实现的分布式锁,在Redis中使用hash数据结构表示锁(lockObj表示锁对应的key):

  • 当加锁时,如果lockObj这个key对应hash数据结构中存在一个field-value,且field值表示的不是当前线程则说明,该锁已被其他线程加锁,加锁失败
  • 否则,如果加锁时lockObj这个key对应的hash结构的值不存在,或者field-value存在但是当前加锁线程和field表示的线程相同(同一个加锁线程),则加锁成功
  • 当释放锁时,会将hash数据结构的值删除掉。

同时,我们还要注意,当我们调用lock()无参方法加锁时,如果加锁成功,会给锁设置默认的过期时间30s。那是不是意味着我们通过lock()方法加的锁只有30s的有效时间呢?并不是。

这是因为,在Redisson中每当调用lock()方法加锁,就会同时启动一个定时任务(watch dog),该定时任务每隔10s钟,会重新设置锁的过期时间为30s, 直到锁被释放。

定时任务#

在我们的任何一个JAVA进程中,我们都可以通过使用Spring定时调度的功能从而实现定时任务。但是一旦我们在服务的实现代码中通过Spring定时调度的功能实现定时任务,那就意味着该服务的每个服务实例只要运行起来,也都会执行这个定时任务。

但是有时候完全没有必要,比如说秒杀服务的缓存预热功能,如果有多个秒杀服务实例,我们只需要让其中一个服务实例执行一次缓存预热任务即可,那么如何实现这个功能呢?可以通过使用分布式任务调度的方式来实现,比如我们以xxl-job为例来说明:

对于xxl-job这个分布式调度框架而言有两个核心概念:

  • 调度中心: 调度中心负责任务的注册、调度及管理等功能(独立运行)
  • 执行器: 执行器是任务执行的工作节点。它负责接收调度中心的执行命令,并运行相应的任务逻辑(Java进程)

xxl-job会由调度中心根据定时任务的触发条件,在满足条件时触发(调用)执行器中定义的对应的定时任务,具体过程如下:

  • 在JAVA进程中定义执行器,以及多个任务处理器(JobHandler),每个任务处理器就可以当做是被调度的任务
  • 同时,我们可以在调度中心,定义调度任务,指定其触发条件,指定要调度的执行器以及执行器中要触发执行的任务处理器
  • 在JAVA进程启动的时候,如果定义了执行器,则会向调度中心注册进程所在的主机IP以及监听端口,这样一来调度中心就知道了执行器的地址
  • 紧接着,我们只需要在调度中心启动调度任务,调度任务就会根据触发条件自动调用指定执行器中定义的指定任务处理器,从而完成定时调度
  • 如果多个进程中定义了同一个执行器,即有执行器的集群,则在调度的时候,每一次执行,调度中心都会选择某个执行器实例中的任务处理器执行,即存在负载均衡

集合#

List,Set, Map接口#

Java 容器分为 Collection 和 Map 两大类,Collection集合的子接口有Set、 List、Queue三种子接口。我们比较常用的是Set、List,而Map接口不是 collection的子接口。

Collection集合主要有List和Set两大接口

  • List:一个有序(元素存入集合的顺序和取出的顺序一致)容器,元素可以重复,可以插入多个null元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和 Vector。

  • Set:一个无序(存入和取出顺序有可能不一致)容器,不可以存储重复元素, 只允许存入一个null元素,必须保证元素唯一性。Set 接口常用实现类是 HashSet、 LinkedHashSet 以及 TreeSet。

ArrayList和LinkedList#

相同点:

  • 都是List子实现, 描述的数据结构都是线性表, 都有序, 允许存储重复元素, 允许存储null
  • 都线程不安全的集合类实现

不同点:

  • 但是ArrayList底层是个数组(默认长度10, 扩容机制1.5倍), LinkedList底层是个双向链表
  • 并且LinkedList不仅仅是List的子实现还是Deque接口的子实现, 也就是说LinkedList除了作为线性表以外还可以作为队列/双端队列/栈

HashMap#

JDK1.7 的HashMap的实现主要如下图所示:

![](/assets/firefly-docs/microservice/microservice-29-interview/hashmap jdk1.7.png)

JDK 1.8中的hashMap其实现主要如下:

JDK1.7对于Hash冲突的解决方法主要是通过链地址法,JDK1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)且数组长度>=64时,将链表转化为红黑树,以减少搜索时间。

在HashMap中通过如下方式判断Key是否相同,所以就可以解释我们在使用HashMap的时候,如果key的类型为自定义类型,为什么Key对应的类必须实现hashCode和equals方法了

p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))

并发安全的集合#

常见的并发安全的集合主要分成两类:

  • 诸如Vector,Stack,HashTable, 以及SynchronizedMap, SynchronizedList 这样的集合,我们统称为同步集合,因为它们都是通过阻塞式的方式(要加锁)来保证多线程的数据并发安全
  • 诸如ConcurrentHashMap,ConcurrentLindQueue,CopyOnWriteArrayList等集合类,我们统称为并发集合,因为它们主要是通过非阻塞的方式来保证多线程的数据并发安全(主要采用CAS等方式实现)

并发集合中的CopyOnWriteArrayList采用了Copy-On-Write的思想。通俗的理解就是当我们向一个List中添加或修改元素的时候,不直接向当前集合底层的数组中写入数据,而是先将当前List底层数组中的数据Copy一份得到一个新的数组,然后在新的数组中添加或修改元素,然后在讲当前List底层使用的数组替换为这个新的数组

CopyOnWriteArrayList中的修改数据的方法都是加了锁的,但是读方法却是没有加锁的。因为每次修改都需要复制已有数据,因此CopyOnWriteArrayList适用于读多写少的并发场景

MySQL#

SQL语句执行顺序#

(5) SELECT column_name, … (1) FROM table_name, … (2) [WHERE …] (3) [GROUP BY …] (4) [HAVING …] (6) [ORDER BY …]; (7)[Limit …]

索引相关#

针对InnoDB存储引擎,什么是聚簇索引,什么是非聚簇索引,它们有何区别?

InnoDB中根据主键列创建的索引就是聚簇索引,根据非主键列创建的索引就叫非聚簇索引或者二级索引。它们的区别如下:

  • 聚簇索引,索引即数据,而对于非聚簇索引而言,索引非数据
  • 聚簇索引根据主键值有序,非聚簇索引根据非主键的其他索引列有序
  • 在非聚簇索引上所做的查询,有可能需要回表

这里需要注意的是,并不是,如果一张表没有定义主键,那么MySQL或做如下工作创建聚簇索引:

  • 会为当前表中添加了NOT NULL 和UNIQUE域约束的列创建聚簇索引
  • 如果既没有定义主键,也没有定义了NOT NULL和UNIQUE的列,那么MySQL会自动生成一个名为row_id的隐藏列,MySQL保证该列的值在一张表中不会重复。
  • 然后根据row_id这一列的值创建聚簇索引

什么是回表?为什么要尽量规避回表操作?

回表指的是当我们在二级索引中根据查询条件查询到数据后,从查询结果中取出每条数据对应的主键值,然后再去聚簇索引中根据主键值查询完整行数据的过程,我们称之为回表。

之所以要尽量规避回表操作,首先是因为针对查询结果中的每条记录要在聚簇索引中多做一次查询,其次,也是更重要的是,因为对于非聚簇索引中查询的数据,可能并非根据主键值有序,因此回表可能会包含大量的随机IO可能会涉及频繁的磁头移动,大大降低IO效率

InnoDB存储引擎和MyISAM存储引擎有什么区别?

  • InnoDB存储引擎的聚簇索引索引即数据,MyISAM的索引和数据是分开存放的
  • InnoDB 支持事务处理,而MyISAM不支持事务,因此MyISAM更适合于只读数据或者在数据完整性要求不高的场合使用。
  • InnoDB支持行级锁,但是MyISAM只支持表级锁
  • InnoDB提供了崩溃恢复机制,但是MyISAM缺少崩溃恢复机制

设计索引时的原则有哪些?

  • 只为用于搜索、排序或分组的列创建索引,也就是说只为,出现在 WHERE 子句中的列,连接子句中的连接列 (连接的时候也可以使用到索引),或者出现在 ORDER BY 或 GROUP BY 子句中的 列创建索引。

  • 在上面的基础上,我们还可以根据查询频率来确定,给查询频率高的列创建索引

  • 选择基数大的列作为索引

  • 尽可能使用联合索引,避免回表

  • 避免冗余索引

  • 避免索引列的长度过长(字符串类型)

事务#

什么是事务?事务的ACID特性?事务的隔离级别?事务的传播行为?

数据库事务(transaction)是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。

数据库事务具有ACID特性:

  • 原子性(Atomicity):事务作为一个整体被执行,包含在其中的数据库操作要么全部执行,要么全部不执行
  • 一致性(Consistency):事务应该保证数据从一个一致性状态转化到下一个一致性状态
  • 隔离性(Isolation): 多个事务并发执行时,一个事务不应该影响其他事务的执行。
  • 持久性(Durability):一个事务一旦提交,其对数据库的修改就应该永久保存在数据库中

针对事务的隔离性,还有不同的隔离级别:

  • 读未提交(Read Uncommitted):最低的隔离级别,在这种事务隔离级别下,允许一个事务读取另一个事务尚未提交的数据。这可能导致脏读、不可重复读和幻读的问题。
  • 读已提交(Read Committed):也可以翻译成提交读,在一个事务修改数据过程中,如果事务还没提交,其他事务不能读该数据。这可以避免脏读问题,但仍可能出现不可重复读和幻读的问题。
  • 可重复读(Repeatable Read):在一个事务中,多次读取同一数据时,得到的结果保持一致。即使其他事务对数据进行了修改并提交,当前事务读取的数据也不会发生变化。可重复读可以避免脏读和不可重复读问题,但仍可能出现幻读的问题。
  • 串行化(Serializable):最高的隔离级别,确保事务之间完全隔离,一个事务执行时,其他事务无法对其进行并发操作。串行化可以避免脏读、不可重复读和幻读的问题,但会降低并发性能。

事务的传播行为(与MySQL无关,纯粹是Spring定义的):

  • Propagation_required:如果当前没有事务就创建一个事务,如果存在事务,则加入该事务。
  • Propatation_supports:支持当前事务,如果当前存在事务,则加入该事务,如果不存在该事务,则以非事务执行
  • Propagation_requires_new:无论当前存不存在事务,创建一个新事务
  • Propagation_mandatory:如果当前存在事务,则加入该事务;如果不存在事务,则抛出异常
  • Propagation_not_supported:以非事务方式运行,如果当前存在事务,则把当前事务挂起
  • Propagation_never:以非事务方式运行,如果当前存在事务,则抛出异常
  • Propagation_nested:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则执行与PROPAGATION_REQUIRED类似的操作。

优化#

随着系统越来越复杂,数据库中的表会变的越来越多,表中的数据也会越来越多,此时Mysql数据库可能出现性能瓶颈,具体表现在某些SQL语句的执行时间长,或者等待时间长。因此我们就需要对数据库系统做优化了。

导致MySQL出现性能瓶的原因有很多,比如:

  • 查询语句写的不好,各种连接,各种子查询用不上索引或者没有建立索引
  • 建立的索引失效,建立了索引,在真正执行时,没有用上建立的索引
  • 表设计不合理,导致太多连接查询
  • 表中的数据量太大
  • 服务器调优及配置参数,如果设置的不合理,比如并发连接数过少等

所以,我们的优化手段可以从以下角度入手:

  • 索引优化: 添加适当索引(index)(重点)
  • SQL优化: 写出高质量的sql,避免索引失效 (重点)
  • 设计优化: 表的设计合理化(符合3NF,有时候要进行反三范式操作)
  • 配置优化: 对mysql配置优化 [比如,配置最大并发数my.ini, 调整缓存大小 ]
  • 架构优化:分库分表、读写分离、(分布式数据库)
  • 硬件优化: 服务器的硬件优化

作为我们来说,在面试的时候可以重点多说一些的其实主要是,索引优化SQL优化

关于索引优化,我们说一说设计索引的原则即可,关于SQL优化常见的优化手段如下:

  • 不建议查询时使用select *
  • 针对联合索引,查询条件尽量满足最左前缀匹配原则
  • 查询条件中,建议索引列单独出现(不出现在方法调用,以及运算表达式中)
  • like模糊匹配时,不要写 like ‘%a’
  • 排序中的多列顺序与联合索引列一致(如果有的话)
  • 排序不建议ASC,DESC混用
  • 建议保持主键列递增
  • 在满足业务需要的前提下,尽量创建联合索引

有些时候,即使创建了索引,查询条件中也是用到了索引列(索引列单独出现),因为查询优化器的存在可能有些情况下,查询优化器也不会选择根据索引查询,比如如果在二级索引中查询出许多需要回表的数据,因为会存在大量的随机IO,此时查询优化器宁愿走全表扫描,所以,在查询时到底有没有走索引,我们还可以通过explain来分析查询语句的执行过程!然后具体问题具体分析。

我们如何能够方便的发现一些执行比较慢的SQL语句呢?通过开启MySQL的慢查询日志即可。MySQL的慢查询日志是默认关闭的,但是一旦开启,那么查询时间超过指定阈值(默认10秒)的查询,会出现在慢查询日志中。

11.897911Z
# User@Host: myuser[myuser] @ localhost [127.0.0.1]
# Query_time: 11.293654 Lock_time: 0.000094 Rows_sent: 45 Rows_examined: 17888
# Rows_affected: 0 Bytes_sent: 894
SET timestamp=1683565331;
SELECT * FROM my_table WHERE column_date > '2024-05-01';
  1. 时间戳 (# Time): 记录查询发生的具体时间。
  2. 用户和主机 (# User@Host): 执行查询的用户和来源主机。
  3. 查询时间 (# Query_time): 查询执行所需的总时间(秒)。
  4. 锁定时间 (# Lock_time): 查询在等待锁的时间。
  5. 发送的行数 (# Rows_sent): 查询结果返回的行数。
  6. 检查的行数 (# Rows_examined): 查询在执行过程中检查的行数。
  7. 影响的行数 (# Rows_affected): 查询导致更改的数据行数。
  8. 发送的字节数 (# Bytes_sent): 发送给客户端的字节数。
  9. 时间戳设置 (SET timestamp): 查询执行时的UNIX时间戳。
  10. 查询语句: 实际的SQL查询。

Redis#

单工作线程#

redis 为什么这么快?

  • 完全基于内存,绝大部分请求是纯粹的内存操作,非常快速
  • 使用了一些简单高效的数据结构,如hash表,跳表
  • 高效的IO,使用了异步非阻塞的事件驱动模型
  • 使用单工作线程,避免的不必要的上下文切换,以及加锁释放锁的操作

Rehash(渐进式Hash)#

在Redis中,随着元素数量的增多,可能会导致用来存储键值对的hash表的扩容,这通常会导致hash表(数组)的容量变为之前的2倍。一旦扩容之后,元素在扩容之后的新的hash表中的位置可能也会变化,这个元素位置的变化过程就是通过Rehash实现的。

在Rehash的过程中,因为hash表中的元素可能会比较多,因此想要一次性在扩容之后的hash表中将所有元素放置到正确的位置是一件比较耗时的事情,而且在移动过程中为了保证数据访问的正确性,可能还需要阻止对hash表中元素的访问。所以Redis并未这样做,还是选择了分多次来完成这个元素移动过程。

在Redis中有两张hash表,我们称其为hash表1,hash表2。并且会维护一个rehashIndex(初值为-1),记录当前Rehash到的数组下标位置。一开始,当我们开始插入数据时,只使用哈希表1,而哈希表2没有分配空间

随着插入的数据越来越多,需要对hash表扩容,于是就会为哈希表2分配了hash表1两倍的存储空间,并开始Rehash过程:

在Rehash过程中,不会将hash表1中的数据全部放到hash表2中去,而是在后续有增删改查操作的时候,会从hash表(数组)中的第一个元素开始,每个操作完成对hash数组下标为rehashIndex + 1位置的所有元素的移动,并且移动完成后rehashIndex的值增1。重复这个过程,直到将hash表1中的所有元素移动完。

最后,将hash表2变为新的hash表1,将原来的hash表1变成hash表1并将其值置为null(rehashIndex的值也置为-1),直到下一次rehash发生,重复以上过程。

持久化#

Redis的两种持久化方式:RDB和AOF我们在讲解Redis的时候就已经讲过,这里不在赘述。但是有一个结论,大家一定要有印象,就是当RDB和AOF同时开启的时候,在恢复数据时Redis优先采用AOF日志来恢复数据,因为,AOF更新频率更高,数据更加完整,所以如果AOF和RDB同时存在的时候,Redis会优先使用从AOF文件来还原数据库状态。

其实,从Redis4.0开始实现了一种新的持久化方式即所谓的混合持久化。混合持久化旨在结合 AOF 和 RDB 的优点,以提供更快的数据恢复速度和更高效的数据写入性能。

混合持久化工作原理:

  1. 持久化前的操作:
    • 在没有触发重写操作之前,Redis 将继续按照正常的 AOF 机制记录每个写命令到 AOF 文件中。此时,数据的持久化完全依赖于 AOF 日志。(重写)
  2. 触发 AOF 重写时的操作:
    • 当触发 AOF 文件的重写时(比如达到文件大小的阈值或根据配置的定时任务),Redis 不仅仅重写 AOF 文件,而是首先创建一个 RDB 快照,然后再将这个 RDB 文件包含在新的 AOF 文件中。
    • 在 RDB 快照之后,Redis 会继续记录之后发生的所有写命令。这样,新的 AOF 文件实际上是一个 RDB 文件,后面跟着从 RDB 快照点之后开始的所有写命令。
  3. 重启时的恢复:
    • 当 Redis 重启时,它会使用这个混合的 AOF 文件进行数据恢复。首先加载 RDB 文件中的数据快照,然后应用其后的 AOF 命令,这样可以确保数据的完整性和一致性。

优点:

  • 快速恢复:使用 RDB 快照可以快速加载大量数据,而后续的 AOF 命令则确保了数据的最新状态。
  • 效率:在 AOF 重写过程中生成 RDB 快照,减少了重写过程中的 IO 操作,同时也缩短了重写的时间。

缓存双写一致性#

双写一致性主要指在一个数据同时存在于缓存(如Redis)和持久化存储(如数据库)的情况下,任何一方的数据更新都必须确保另一方数据的同步更新,以保持双方数据的一致状态。

如果以Redis作为缓存使用,读取数据没有什么争议,主要问题时如何在更新数据是,如何保证MySQL和Redis中数据的一致性?其解决方案主要有下面几种。

设置过期时间#

对于实时性要求不高的数据,可以采用设置过期时间的方式,对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存

应用程序直接更新#

如果我们的应用程序使用通过更新MySQL和Redis的方式来使其数据同步,有如下策略:

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

先来看第一种情况,先更新缓存,在更新数据库。这种方式通常是不行的。因为缓存更新成功了,数据库没更新(更新失败),导致缓存存的是最新值,数据库存的是旧值。如果缓存失效了,就会拿到数据库中的旧值。

再来看第二种情况,先更新数据库,在更新缓存。这种方式也是大家普遍反对的。若同时有请求A和请求B进行更新操作,会出现如下情况:

  1. 线程A更新了数据库 更新的旧数据
  2. 线程B更新了数据库 最后更新新数据
  3. 线程B更新了缓存
  4. 线程A更新了缓存

再来看第三种情况, 先删除缓存,在更新数据库. 该方案同样会存在多线程的线程安全问题。若同时有一个请求A进行更新操作,另一个请求B进行查询操作,会出现如下情况:

  1. 请求A进行写操作,删除缓存
  2. 请求B查询发现缓存不存在
  3. 请求B去数据库查询得到旧值
  4. 请求B将旧值写入缓存
  5. 请求A将新值写入数据库

image-20240612010951121
image-20240612010951121

针对第三种情况,还有一种改进策略:延迟双删策略,其伪代码如下:

public void write(String key,Object data){
// 第一次删除缓存
redis.delKey(key);
// 更新数据库
db.updateData(data);
// 休眠一段时间
Thread.sleep(1000);
// 第二次删除缓存
redis.delKey(key);
}

第二次删除的意义在于,防止更新数据库的过程中,有一个读请求读取数据库的旧数据并将其放入到Redis中的情况。那么这里的休眠时间是如何确定的呢?我们通常会在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。大大降低数据不一致的概率。

再来看第四种情况,先更新数据库,再删除缓存。这种情况即所谓的 Cache Aside Pattern,知名社交网站facebook也在论文《Scaling Memcache at Facebook》中提出,他们用的也是先更新数据库,再删缓存的策略。这种情况下,不会出现数据并发安全问题吗?当然不是,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生:

  1. 缓存刚好失效
  2. 请求A查询数据库,得一个旧值
  3. 请求B将新值写入数据库
  4. 请求B删除缓存
  5. 请求A将查到的旧值写入缓存

然而,发生这种情况的概率又有多少呢?

发生上述情况有一个先天性条件,就是步骤3的写数据库操作比步骤2的读数据库操作耗时更短,才更有可能使得步骤4先于步骤5。

可是,大家想想,数据库的读操作的速度远快于写操作的因此步骤3耗时比步骤2更短,这一情形出现的概率比较低。

为什么写操作比读操作要更慢呢?
1. 写操作涉及了数据的移动,而读不会
2. 写操作对比数据库而言,需要加锁,而读不需要
3. 写操作对于MySQL而言需要记录日志,比如binLog日志,而读操作不需要

在删除缓存的时候,我们还可以采用异步更新的方式。

在大厂中这种方式用的是比较多的。

加分布式锁#

还可以通过加分布式读写锁的方式,在一个线程更新数据时,其他线程排队等待,但是因为加读写锁,可以让读-读请求并发执行。

主从架构#

可以通过配置实现,在Redis中为一个Redis-Server(master节点)配置一个或多个slave节点,此时有了主从架构。

  • 写数据的请求,只能由Master节点处理写数据请求
  • Master节点会将写入的数据复制到其对应的一个或多个Slave节点
  • Slave节点可以接收读数据的请求

哨兵模式#

为了让主从架构的Redis服务器具有更高的可用性,因此在Redis中引入了哨兵模式。Redis 哨兵模式(Sentinel)是 Redis 的高可用性解决方案之一。其主要目的是监控 Redis 主从服务器的运行状态,并在主服务器出现故障时自动进行故障转移,选则一个Slave节点作为新的Master节点,从而使得主从架构的Redis-Server继续正常运行。

哨兵模式主要包括以下几个组成部分:

  • 主节点(Master):负责处理所有客户端请求的 Redis 服务器节点。
  • 从节点(Slaves):复制主节点的数据,并在主节点故障时可以提升为新的主节点。
  • 哨兵(Sentinels):一组独立运行的进程,用于监控主节点和从节点的健康状态,并在主节点故障时执行自动故障转移。

哨兵的工作过程可以分为以下几个步骤:

  • 监控:每个哨兵节点定期向所有的主节点和从节点发送心跳包(PING),以检查它们的健康状态。
  • 通知:哨兵在发现节点异常时,会通知系统管理员和其他哨兵节点,报告发现的问题。
  • 自动故障转移:当哨兵通过足够数量的哨兵节点确认主节点无法访问时,它将开始故障转移过程。
  • 配置更新:在故障转移过程中,哨兵会选举出一个从节点来成为新的主节点,并通知其余的从节点更新配置,将新的主节点作为自己的主节点

集群模式#

redis集群是一个由多个主从节点群组成的分布式服务器群,它具有复制、高可用和分片特性。

  • redis集群支持分片,每个Master节点都只存储了一部分Redis中的数据。Redis 集群将所有的数据划分为 16384 个哈希槽(hash slot), 每个键根据其键名被分配到某一个槽中。
  • 当客户端尝试访问一个键时,它会使用相同的哈希函数计算出键应该在哪个槽,并直接连接到负责该槽的节点
  • Redis集群不需要sentinel哨兵∙也能完成节点移除和故障转移的功能,每个节点包括主节点和从节点都会向其他节点发送心跳数据包,用以维护集群中所有节点的状态信息和健康状况。当超过半数的人认为某Master节点失效,那么就会选择该Master节点的一个Slave节点成为新的Master节点

这种集群模式没有中心节点,可水平扩展,据官方文档称可以线性扩展到上万个节点(官方推荐不超过1000个节点)。redis集群的性能和高可用性均优于之前版本的哨兵模式,且集群配置非常简单

# 补充说明: hash槽的分配问题 → 一致性hash的问题

Spring#

IOC和AOP#

概念及其理解,参考课件

AOP的实现#

AOP实现的关键在于 代理模式,AOP代理主要分为静态代理和动态代理。静态代理的代表为AspectJ;动态代理则以Spring AOP为代表。

  • AspectJ是编译时的增强,AOP框架会在编译阶段生成AOP代理类,他会在编译阶段将AspectJ(切面)织入到Java字节码中,运行的时候就是增强之后的AOP对象。

  • Spring AOP使用的动态代理,所谓的动态代理就是说AOP框架不会去修改字节码,而是每次运行时在内存中临时为方法生成一个AOP对象,这个AOP对象包含了目标对象的全部方法,并且在特定的切点做了增强处理,并回调原对象的方法。

Spring事务失效的情况#

几种事务常见的失效场景如下:

  • 数据库不支持事务。比如使用MySQL时存储引擎为MyISAM
  • 事务没有被Spring管理
public class XxxImpl implements XxxService{
@Transactional
public void update(参数){
//update
}
}
XxxService service = new XxxImpl();
service.update();
  • 方法不是 public 的。@Transactional注解只能作用于public 的方法上,否则事多不会生效
  • 自身调用问题。在同一个类中的一个事务方法内部调用另一个事务方法,内部方法的事务设置不会生效。
@Service
public class OrderServiceImpl implements OrderService {
public void update(Order order) {
// 原始对象
this.updateOrder(order);
}
}
@Transactional
public void updateOrder(0rder order) {
// update order
}
}
  • 异常没有被抛出
@Service
public class OrderServiceImpl implements OrderService {
@Transactional
public void update(Order order) {
try{
// update order
}catch{
}
}
}
  • 事务传播行为中设置了不支持事务
@Service
public class OrderServiceImpl implements OrderService {
@Transactional
public void update(Order order) {
updateOrder(order);
}
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void updateOrder(Order order) {
//update order
}
}
  • 异常类型不匹配。因为 Spring 默认回滚的是 RuntimeException 异常,和程序抛出的 Exception 异常不匹配,所以事务也是不生效的。
@Service
public class OrderServiceImpl implements OrderService {
@Transactional(rollbackFor=Exception.class)
public void update(Order order) {
try{
// update order
}catch{
throw new Exception("更新失败");
}
}
}

Mybatis#

获取数据库的自增主键#

  • 使用 useGeneratedKeyskeyProperty。这通常用于数据库支持自动增长的主键字段,如 MySQL、PostgreSQL、SQL Server 等。
<insert id="insertUser" useGeneratedKeys="true" keyProperty="id">
INSERT INTO users (name, email) VALUES (#{name}, #{email})
</insert>
  • 使用 selectKey,如果数据库使用的是如序列等特定的方式生成主键,或者需要在插入前获取主键值,你可以使用 “ 元素来实现。这在 Oracle 等数据库中特别有用,因为它们使用序列生成主键。
<insert id="insertUser">
<selectKey keyProperty="id" resultType="int" order="AFTER">
查询主键值的sql语句
</selectKey>
INSERT INTO users (id, name, email) VALUES (#{id}, #{name}, #{email})
</insert>

#{}和${}的区别#

  • 处理方式不同:#{} 是预处理语句(PreparedStatement)的参数占位符。MyBatis 会使用预处理语句来传递参数,这种方式可以有效防止 SQL 注入攻击。${} 是简单的字符串替换。在 SQL 语句被执行之前,${} 中的内容会被直接替换成变量值。
  • 安全性不同: 使用${}更容易受到SQL注入攻击
  • 类型处理不同:如果是字符串类型的参数,则#{}会自动为替换后的值加”, 而${}则不会
  • 如果需要动态指定排序,分组,表名,以及select部分的列名,那么只能使用${},因为#{}只针对where查询条件的参数进行替换

一对多,一对一映射#

在xml中定义映射关系, 使用association代表映射一个实体类, property映射的属性名,javaType该属性的实体类类型,fetchType是否延迟加载,column用于执行select方法所传递的参数,select需要执行的mapper方法的全限定名称

在xml中定义一对多的映射关系,使用collection标签标识映射一个集合,ofType代表集合中实体类的类型,其他属性和上面对一的情况一致

RocketMQ#

消息类型#

说一下消息队列消息的类型有哪些?

  • 普通消息:普通消息也叫做无序消息,简单来说就是没有顺序的消息。因为不需要保证消息的顺序,所以消息可以大规模的并发的发生和消费,吞吐量很高,适合大部分场景

  • 批量消息:Apache RocketMQ可以将一些消息聚成一批以后进行发送,可以增加吞吐率,并减少API和网络调用次数。

  • 有序消息:有序消息就是按照一定的先后顺序的消息类型。

    有序消息还可以进一步分为:全局有序消息(1个MessageQueue)、局部有序消息(多个MessageQueue)

  • 延时消息:简单来说就是当producer将消息发送到broker之后,会延时一定时间后才投递给consumer进行消费。

  • 事务消息:本地事务和事务消息的投递保持一致性

如何实现顺序消息#

消息有序指的是可以按照消息的发送顺序来消费(FIFO)。RocketMQ可以严格的保证消息有序,可以分为分区有序或者全局有序。

在默认的情况下消息发送会采取Round Robin轮询方式把消息发送到不同的queue(分区队列);而消费消息的时候从多个queue上拉取消息,这种情况发送和消费是不能保证顺序。

所以如果要实现先发送的消息一定先被消费的效果,即顺序消息的效果,必须满足如下条件:

  • 消息被发送时保持顺序(使用一个Producer发送消息,且发送方法不在多线程中调用)
  • 消息被存储时保持和发送的顺序⼀致(顺序消息总是存储在同一个MessgeQueue中)
  • 消息被消费时保持和存储的顺序⼀致(使用顺序消费的消息监听器消费)
/*
* 要保证消息的有序性,即需要把有先后顺序的消息,发送到同一个MessageQueue中去,如何实现呢?
* a. 在发送消息的时候,就需要给send方法,传递一个MessageQueueSelector,选择消息发送的具体是Topic中的那个MessageQueue
* b. MessageQueueSelector中有一个方法select方法,该方法中有一个参数List<MessageQueue> mqs,该参数代表某个Topic对应的所有的MessageQueue
* c. 在select方法中,实现消息的路由,即决定消息发送到哪个MessageQueue中,实例中就是一种方式,利用orderId对MessageQueue数量取余,这样一来
同一个id的消息,就会被发送到同一个MessageQueue中去了
*/
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
Long id = (Long) arg; //根据订单id选择发送queue
long index = id % mqs.size();
return mqs.get((int) index);
}
}, orderList.get(i).getOrderId());//订单id
/*
* 这里我们只需要保证,同一个MessageQueue中的消息,是前一个消息消费完了,才消费后一个消息,此时我们只需要设置MessageListenerOrderly
* 这种类型的监听器,在监听器内实现消费逻辑即可
*/
consumer.registerMessageListener(new MessageListenerOrderly() {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
// 消费逻辑
return ConsumeOrderlyStatus.SUCCESS;
}
});

MQ消费者如果消费不成功#

  • MQ如何消费不成功,RocketMQ会把该消息丢入重试队列当中去
  • MQ重试队列默认有重试机制,会反复去投递直到投递成功或者是在16次(默认的,可更改)之后假如还没有消费失败的话会把该消息投递到MQ死信队列里面
  • 我们可以通过后台管理系统从死信队列里面把没消费成功的消息取出来,然后等问题解决之后,将消息发送给RocketMQ让消费者正常消费

项目#

购物车与数据库#

我们项目中的购物车数据数据是存放在Redis中的,但是实际开发的时候,也有可能还会在数据库中存储用户的购物车数据,因此会存在缓存双写一致性问题。我们利用上面关于缓存双写一致性的分析去回答即可。

Elasticsearch与数据库#

使用ES来搜索的前提是ES中得有数据,ES中的数据肯定是来自于数据库中的,数据的同步方式:

  • 使用普通消息实现个别商品的异步上下架(增量同步)
  • 使用批量消息,在搜索服务中实现批量商品的上下架
  • 使用定时任务,批量同步(全量同步)
  • 或者,使用canal实现数据库和ES数据的同步

秒杀服务缓存预热#

关于秒杀服务的缓存预热,我们使用的是Spring的定时调度功能,但是这里有一个问题就是,如果启动了多个秒杀服务实例,那么在多个秒杀服务实例中,都会运行这个定时任务去完成定时任务的功能,但是我们知道缓存预热的功能只需要执行一次即可,所以我们有两种解决方案:

  1. 还是使用Spring定时调度功能,在Redis中增加一个标志位用来表示缓存预热功能是否已经完成,同时每次执行缓存预热之前使用Double Check方式加分布式锁
  2. 使用分布式定时调度,比如xxl-job

秒杀商品库存同步#

因为存在缓存预热,我们在秒杀活动开始之前就已经将商品信息,库存等存储到Redis中了,所以秒杀活动开始之后我们的库存扣减都是在Redis中完成的,在秒杀活动结束后,在利用Redis中的库存更新数据库中的库存、

这里面试官有可能会问,那如果秒杀过程中Redis宕机了,不是库存数据全都没有了吗?关于这个问题,我们可以基于以下两点来回答:

  • 我们的秒杀下单是基于分布式事务实现的,因此可以认为,秒杀库存扣减成功,那么就会生成对应的订单
  • 所以,Redis挂了也没关系,因为我们可以通过通过订单数据,重新计算出秒杀商品的库存
  • 可能会存在个别极端情况是,库存扣减了,还没返回给秒杀服务扣减结果,结果Redis挂了,此时本地事务的执行会抛出异常,RocketMQ也会认为本地事务执行的结果为UNKONW,最终事务消息不会被订单服务消费,不会生成订单
  • 但是,注意到在用户点击提交订单之后,我们给用户展示的是排队中,并未告之用户秒杀成功的结果这个结果对于用户来说应该也是可以接受的。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

面试技术要点
https://firefly-mu-weld.vercel.app/posts/microservice-29-interview/
作者
Daisy
发布于
2026-06-14
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录