Java面经

面经累积

1.0 JAVA性质

1.1 JAVA11相比JAVA8 有什么改进(1)

JAVA各个版本的新特性

1.1.1 JAVA9
  1. 模块化系统

就是把想要引用的类统一写在一个module里,再到时候统一引入进来
module modulea { exports com.lz.java9.bean2; exports com.lz.java9.bean; } module moduleb { requires modulea; }
使得组织上更安全,可以指定哪些部分可以暴露,哪些部分隐藏
  1. 多版本兼容jar包

让你创建仅在特定版本的 Java 环境中运行库程序时选择使用的 class 版本。通过 --release 参数指定编译版本。具体的变化就是 META-INF 目录下 MANIFEST.MF 文件新增了一个属性:
Multi-Release: true
  1. 异常处理try-with-resources改进,不需要声明资源就可以使用

java9之前
import java.io.BufferedReader; import java.io.IOException; import java.io.Reader; import java.io.StringReader; public class Tester { public static void main(String[] args) throws IOException { System.out.println(readData("test")); } static String readData(String message) throws IOException { Reader inputString = new StringReader(message); BufferedReader br = new BufferedReader(inputString); // 需要声明资源对象 try (BufferedReader br1 = br) { return br1.readLine(); } } }
java9之后
import java.io.BufferedReader; import java.io.IOException; import java.io.Reader; import java.io.StringReader; public class Tester { public static void main(String[] args) throws IOException { System.out.println(readData("test")); } static String readData(String message) throws IOException { Reader inputString = new StringReader(message); BufferedReader br = new BufferedReader(inputString); try (br) { return br.readLine(); } } }
  1. String 底层存储结构发生变化使用字节数组(byte数组),之前是利用的char数组

因为开发人员发现人们使用的字符串值是拉丁字符居多而之前使用的char数组每一个char占用两个字节而拉丁字符只需要一个字节就可以存储,剩下的一个字节就浪费了,造成内存的浪费,gc的更加频繁
  1. 增强Stream API(有更多的流操作方法了)

takeWhile 从Stream中依次获取满足条件的元素,直匹配到一个不满足条件为止结束获取,不同与filter
List<Integer> list = List.of(11,33,44,102,232,454,67,556,46,78); list.stream().takeWhile(x -> x < 100).forEach(System.out::println); // 输出结果如下 11 33 44
dropWhile 从Stream中依次删除满足条件的元素,直到匹配到一个不满足条件为止结束删除
List<Integer> list = List.of(11,33,44,102,232,454,67,556,46,78); list.stream().dropWhile(x -> x < 100).forEach(System.out::println); // 输出结果如下 102 232 454 67 556 46 78
1.1.2 JAVA10
  1. 局部变量的类型推断 var关键字

当我们使用的变量过多,代码的可读性会受到一定影响。而且,有时候开发人员会尽力避免声明中间变量,因为太多的类型声明只会分散注意力。所以我们在 JDK 10 中引入了 var(var 不是关键字,只是一个类型名。var 除了不能作为类名,其它都可以)
var消除了在代码中编写显式类型的需要。 这不仅减少了重复,而且还使您的代码更易于维护,因为例如,如果您决定将来更改存储在地图中的对象的类型,则只需要更改一行代码即可。
Map<Department, List<Employee>> map = new HashMap<>(); // ... for (Entry<Department, List<Employee>> dept : map.entrySet()) { List<Employee> employees = dept.getValue(); // ... }
使用var后
var map = new HashMap<Department, Employee>(); // ... for (var dept : map.entrySet()) { var employees = dept.getValue(); // ... }
  1. GC改进和内存管理 并行全垃圾回收器 G1

1.1.3 JAVA11
  1. 字符串新增方法(isBlank,strip等方法)

String str = " qq eeee "; System.out.println(str.isBlank());// 是否为空 System.out.println(str.strip());// 去除开头结尾空格 System.out.println(str.stripLeading());// 去除头部空格 System.out.println(str.stripTrailing());// 去除尾部空格 System.out.println(str.repeat(3));// 复制三次 System.out.println(str.lines().count());// 行数操作统计
  1. java命令直接运行源文件,不需要事先javac编译

我们都知道java是静态语言,也就是说,如果你想执行java程序,就必须先编译,再执行,这个是OpenJDK11里新加的一个feature,目的是使单个文件的java源码可以无需编译,直接执行。
$ cat Test #!/usr/bin/java --source 12 public class Test { public static void main(String[] args) { System.out.println("hello"); } } $ chmod +x Test $ ./Test hello
我们用java写的代码居然可以像shell脚本一样直接执行了
原理:
当我们要执行的java程序是java源文件时,该方法中的mode就会被设置为LM_SOURCE。
pwhat指针指向的是我们最终要执行的带main方法的java类,由上我们可以看到,在mode为LM_SOURCE时,最终执行的java类并不是我们提供的java源文件对应的java类,而是SOURCE_LAUNCHER_MAIN_ENTRY宏定义的java类。
当我们以源文件形式执行java命令时,最终调用的main方法是jdk.compiler/com.sun.tools.javac.launcher.Main里的main方法,其参数为我们要执行的java源文件
可以直接使用java解释器执行Java源代码文件。 源代码在内存中编译,然后由解释器执行,而不会在磁盘上生成.class文件
1.1.4 JAVA12
  1. 更简洁的 switch 语法

typeOfDay = switch (dayOfWeek) {
    case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "Working Day";
    case SATURDAY, SUNDAY -> "Day Off";
};

//特定场景的计算逻辑
int day = 1;
int result = switch (day) {
    case 1, 2, 3, 4, 5 -> 1;
    case 6, 7 -> 2;
    default -> 0;
};
System.out.println(result);
  1. 核心库java.lang中支持Unicode11

1、684个新角色 1.1、66个表情符号字符 1.2、Copyleft符号 1.3、评级系统的半星 1.4、额外的占星符号 1.5、象棋中国象棋符号 2、11个新区块 2.1、格鲁吉亚扩展 2.2、玛雅数字 2.3、印度Siyaq数字 2.4、国际象棋符号 3、7个新脚本 3.1、Hanifi Rohingya 3.2、Old Sogdian 3.3、Sogdian 3.4、Dogra 3.5、Gunjala Gondi 3.6、Makasar 3.7、Medefaidrin
  1. G1 及时返回未使用的已分配内存

增强 G1 GC,以便在空闲时自动将 Java 堆内存返回给操作系统。
1.1.5 JAVA13
  1. switch 语法再增强

JAVA 12 中虽然增强了 swtich 语法,但并不能在 -> 之后写复杂的逻辑,JAVA 12 带来了 swtich更完美的体验,就像 lambda一样,可以写逻辑,然后再返回
typeOfDay = switch (dayOfWeek) { case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> { // do sth... yield "Working Day"; } case SATURDAY, SUNDAY -> "Day Off"; };
  1. API增加新方法

FileSystems.newFileSystem新方法
核心库/ java.nio中添加了FileSystems.newFileSystem(Path,Map <String,?>)方法,添加了三种新方法java.nio.file.FileSystems,以便更轻松地使用将文件内容视为文件系统的文件系统提供程序
nio新方法
核心库/ java.nio中新的java.nio.ByteBuffer批量获取/放置方法转移字节而不考虑缓冲区位置。
  1. 增强ZGC

增强ZGC以将未使用的堆内存返回给操作系统
ZGC(The Z Garbage Collector)是标记-整理算法的并发垃圾回收器,官方解释ZGC只是个名字,没有意义

1.2 HashMap的实现原理(1)

HashMap图示

  1. Java中HashMap的实现的基础数据结构是数组,每一对key->value的键值对组成Entity类以双向链表的形式存放到这个数组中

  1. 元素在数组中的位置由key.hashCode()的值决定,如果两个key的哈希值相等,即发生了哈希碰撞,则这两个key对应的Entity将以链表的形式存放在数组中

  1. 调用HashMap.get()的时候会首先计算key的值,继而在数组中找到key对应的位置,然后遍历该位置上的链表找相应的值。

当然这张图中没有体现出来的有两点:

  1. 为了提升整个HashMap的读取效率,当HashMap中存储的元素大小等于桶数组大小乘以负载因子的时候整个HashMap就要扩容,以减小哈希碰撞,具体细节我们在后文中讲代码会说到

  1. 在Java 8中如果桶数组的同一个位置上的链表数量超过一个定值,则整个链表有一定概率会转为一棵红黑树。

HashMap什么时候开辟bucket数组占用内存?

在HashMap第一次put的时候,无论Java 8还是Java 7都是这样实现的

hash

在HashMap这个特殊的数据结构中,hash函数承担着寻址定址(确定key在木桶中的位置)的作用,其性能对整个HashMap的性能影响巨大,那什么才是一个好的hash函数呢?

  • 计算出来的哈希值足够散列,能够有效减少哈希碰撞

  • 本身能够快速计算得出,因为HashMap每次调用get和put的时候都会调用hash方法

java8的实现

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

我们知道hash函数的作用是用来确定key在桶数组中的位置的,在JDK中为了更好的性能,通常会这样写:

index =(table.length - 1) & key.hash();

回忆前文中的内容,table.length是一个2的正整数次幂,类似于000100000,这样的值减一就成了000011111,通过位运算可以高效寻址,这也回答了前文中提到的一个问题,

既然计算出来的哈希值都要与table.length - 1做与运算,那就意味着计算出来的hash值只有低位有效,这样会加大碰撞几率,因此让高16位与低16位做异或,让低位保留部分高位信息,减少哈希碰撞

HashMap内部的bucket数组长度为什么一直都是2的整数次幂?

好处之一就是可以通过构造位运算快速寻址定址。第二,在HashMap扩容的时候可以保证同一个桶中的元素均匀的散列到新的桶中,具体一点就是同一个桶中的元素在扩容后一半留在原先的桶中,一半放到了新的桶中。

HashMap默认的bucket`数组是多大?

默认是16,即使指定的大小不是2的整数次幂,HashMap也会找到一个最近的2的整数次幂来初始化桶数组太大浪费内存

太小频繁扩容,16是一个在性能和资源之间相对折中的选择

HashMap什么时候开辟bucket数组占用内存?

在第一次put的时候调用resize方法

HashMap何时扩容?

当HashMap中的元素熟练超过阈值时,阈值计算方式是capacity * loadFactor,在HashMap中loadFactor是0.75

桶中的元素链表何时转换为红黑树,什么时候转回链表,为什么要这么设计?

当同一个桶中的元素数量大于等于8的时候元素中的链表转换为红黑树,反之,当桶中的元素数量小于等于6的时候又会转为链表,这样做的原因是避免红黑树和链表之间频繁转换,引起性能损耗

Java 8中为什么要引进红黑树,是为了解决什么场景的问题?

  1. 引入红黑树是为了避免hash性能急剧下降,引起HashMap的读写性能急剧下降的场景,正常情况下,一般是不会用到红黑树的,在一些极端场景下,假如客户端实现了一个性能拙劣的hashCode方法,可以保证HashMap的读写复杂度不会低于O(lgN)

  1. 当链表变长时,查找和添加(需要确定 key 是否已经存在)都需要遍历这个链表,速度会变慢。所以利用到了红黑树

HashMap如何处理key为null的键值对?

放置在桶数组中下标为0的桶中

1.3 重载和重写方法的区别

所谓方法重载是指在一个类中,多个方法的方法名相同,但是参数列表不同。参数列表不同指的是参数个数、参数类型或者参数的顺序不同。

重写体现了Java优越性,重写是建立在继承关系上,它使语言结构更加丰富。在Java中的继承中,子类既可以隐藏和访问父类的方法,也可以覆盖继承父类的方法。

在Java中覆盖继承父类的方法就是通过方法的重写来实现的。所谓方法的重写是指子类中的方法与父类中继承的方法有完全相同的返回值类型、方法名、参数个数以及参数类型。

这样,就可以实现对父类方法的覆盖。

1.4 为什么选择java?

优秀的内存管理机制(垃圾回收机制)

C、C++等语言中,内存的分配和释放由程序代码来完成,容易出现由于程序员漏写内存释放代码引起的内存泄露,最终导致系统内存耗尽。

Java代码运行在JVM中,由JVM来管理 堆Heap 内存的分配和回收(Garbage Collection),把程序员从繁琐的内存管理工作中释放出来,更专注于业务开发.。

1.5 抽象类和接口

1.5.1 抽象类

抽象类是特殊的类,只是不能被实例化;除此以外,具有类的其他特性;重要的是抽象类可以包括抽象方法,这是普通类所不能的。抽象方法只能声明于抽象类中,且不包含任何实现,派生类必须覆盖它们。另外,抽象类可以派生自一个抽象类,可以覆盖基类的抽象方法也可以不覆盖,如果不覆盖,则其派生类必须覆盖它们。

抽象类中可以没有抽象方法,也可以包含非抽象方法,但有抽象方法的类一定是抽象类。
1.5.2 接口

接口是引用类型的,类似于类,和抽象类的相似之处有三点:

1、不能实例化;

2、包含未实现的方法声明;

3、派生类必须实现未实现的方法,抽象类是抽象方法,接口则是所有成员(不仅是方法包括其他成员);

一个类可以直接继承多个接口,但只能直接继承一个类

1.5.3 区别
  1. 接口只有定义,不能有方法的实现,java 1.8中可以定义default方法体,而抽象类可以有定义与实现,方法可在抽象类中实现

  1. 实现接口的关键字为implements,继承抽象类的关键字为extends。一个类可以实现多个接口,但一个类只能继承一个抽象类。所以,使用接口可以间接地实现多重继承。

  1. 接口强调特定功能的实现,而抽象类强调所属关系

1.5.4 相同点
  1. 都不能被实例

  1. 接口的实现类或抽象类的子类都只有实现了接口或抽象类中的方法后才能实例化

2.0 锁

2.1 除了redis还有什么方式实现分布式锁(1)

2.1.1 为什么分布式系统中不能用普通锁呢?普通锁和分布式锁有什么区别吗?
  • 普通锁

  1. 单一系统找那个, 同一个应用程序是有同一个进程, 然后多个线程并发会造成数据安全问题, 他们是共享同一块内存的, 所以在内存某个地方做标记即可满足需求.

  1. 例如synchronized和volatile+cas一样对具体的代码做标记, 对应的就是在同一个内存区域作了同步的标记

  • 分布式锁

  1. 分布式系统中, 最大的区别就是不同系统中的应用程序都在各自机器上不同的进程中处理的, 这里的线程不安全可以理解为多进程造成的数据安全问题, 他们不会共享同一台机器的同一块内存区域, 因此需要将标记存储在所有进程都能看到的地方

  1. 例如zookeeper作分布式锁,就是将锁标记存储在多个进程共同看到的地方,redis作分布式锁,是将其标记公共内存,而不是某个进程分配的区域.

2.1.2 分布式锁的三种实现方式

应运而生了三种实现分布式锁的方式:

2.1.2.1 数据库的分布式锁

在数据库中创建一个表, 表中包含方法名等字段, 并在方法名字段上创建唯一索引.

想要执行某个方法, 就使用这个方法名向表中插入数据, 成功插入则获取锁, 执行完成后删除对应的行数据释放锁.

因为我们对method_name做了 唯一性约束, 这里如果有多个请求同事提交到数据库的话, 数据库会保证只有一个操作可以成功, 那么我们就可以认为操作成功的那个现场恒获得了该方法的锁, 可以执行方法体内容.
2.1.2.2 Redis的分布式锁

加锁实际上就是在Redis中, 给Key键设置一个值, 为避免死锁, 并给定一个过期时间.

解锁的过程就是将Key键删除, 但也不能乱删, 不能说客户端1的请求将客户端2的锁给删掉

通过random_value的唯一标识来判别哪个客户端. 删除的时候先输入要删除的random_value, 然后判断当前random_value与先输入的是否相等, 是的话就删除Key, 解锁成功.

问题:释放了别人的锁怎么办?

可以通过给key设置唯一的uuid,然后释放的时候通过uuid来判断这把锁是否是自己的,防止释放了别人的锁。

问题:加锁时间到了业务操作没执行完锁释放了怎么办?

分布式锁可能存在锁过期释放,业务没执行完的问题。有些小伙伴认为,稍微把锁过期时间设置长一些就可以啦。其实我们设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。

当前开源框架Redisson就解决了这个分布式锁问题。

只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了锁过期释放,业务没执行完问题。

2.1.2.3 Zookeeper分布式锁

如何使用Zookeeper实现分布式锁呢?

1) 排它锁

排他锁,又称写锁或独占锁。如果事务T1对数据对象O1加上了排他锁,那么在整个加锁期间,只允许事务T1对O1进行读取或更新操作,其他任务事务都不能对这个数据对象进行任何操作,直到T1释放了排他锁

排他锁核心是保证当前有且仅有一个事务获得锁,并且锁释放之后,所有正在等待获取锁的事务都能够被通知到。

Zookeeper 的强一致性特性,能够很好地保证在分布式高并发情况下节点的创建一定能够保证全局唯一性,即Zookeeper将会保证客户端无法重复创建一个已经存在的数据节点

  • 定义锁:通过Zookeeper上的数据节点来表示一个锁

  • 获取锁:客户端通过调用 create 方法创建表示锁的临时节点,可以认为创建成功的客户端获得了锁,同时可以让没有获得锁的节点在该节点上注册Watcher监听,以便实时监听到lock节点的变更情况

  • 释放锁:以下两种情况都可以让锁释放

当前获得锁的客户端发生宕机或异常,那么Zookeeper上这个临时节点就会被删除.

正常执行完业务逻辑,客户端主动删除自己创建的临时节点.

2) 共享锁

共享锁,又称读锁。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁,直到该数据对象上的所有共享锁都被释放。

共享锁与排他锁的区别在于,加了排他锁之后,数据对象只对当前事务可见,而加了共享锁之后,数据对象对所有事务都可见。

3.0 数据库

3.1 MongoDB和MySQL底层索引实现原理分析及比较(1)

3.1.1 MongoDB

MongoDB索引采用B-树(B树)

MongoDB默认用的是WiredTiger存储引擎。

B树的特点:

(1) 多路 非二叉树

(2) 每个节点 既保存数据 又保存索引

(3) 搜索时 相当于二分查找

3.1.2 MySQL

MySQL索引采用B+树:

MySQL默认用的是InnoDB存储引擎。

B+树的特点:

(1) 多路非二叉

(2) 只有叶子节点保存数据

(3) 搜索时 也相当于二分查找

(4) 增加了相邻节点指针

从上面我们可以看出最核心的区别主要有俩,一个是数据的保存位置,一个是相邻节点的指向。
3.1.3 区别与联系
  1. B+树相邻接点的指针可以大大增加区间访问性,可使用在范围查询等,而B-树每个节点 key 和 data 在一起,适合随机读写 ,而区间查找效率很差。

  1. B+树更适合外部存储,也就是磁盘存储,使用B-结构的话,每次磁盘预读中的很多数据是用不上的数据。因此,它没能利用好磁盘预读的提供的数据。由于节点内无 data 域,每个节点能索引的范围更大更精确

  1. B-树每个节点即保存数据又保存索引树的深度小,所以磁盘IO的次数很少,B+树只有叶子节点保存,较B树而言深度大磁盘IO多,但是区间访问比较好

从B树和B+树的结构来看,对于单值查找,B树查询不稳定但是最好的情况下是O(1),这样的话一次IO就很快;B+树查询稳定(数据都在叶子节点),但是一次IO无法满足。对于多值范围查找,B树就显得很慢了,一条数据一次IO;B+树因为叶子结点形成链条,一次IO可以取出多条数据(Page页大小决定)

3.1.4 适用场景

MySQL:

对于数据一致性和事务要求较高的场景,对于结构化的数据肯定选择MySQL。

比如:WEB通用场景下的用户数据,订单系统下的订单数据等。

MongoDB:

对于事务没有要求,但对增删改等请求有实时性的要求,常作为基础服务中的缓存层。

比如:游戏场景下的用户装备信息,直播场景下的刷礼物等。

3.2 redis东西存在内存里,那关机后东西为什么不会没掉?

在 Redis 中提供了两种持久化机制:RDB 持久化和 AOF 持久化

3.2.1 RDB持久化机制

RDB 全称为:Redis DataBase,是 Redis 当中默认的持久化方案。当触发持久化条件时,Redis 默认会生成一个 dump.rdb 文件,Redis 在重启的时候就会通过解析 dump.rdb 文件进行数据恢复。

3.2.1.1 RDB 机制触发条件

RDB 持久化机制有两种触发方式:自动触发和手动触发

自动触发

自动触发方式也可以分为三种:

  • 执行 flushall 命令(flushdb 命令不会触发)时,不过此时生成的 dump 文件内的数据是空的(dump 文件还会存储一些头信息,所以文件本身是有内容的,只是没有数据),没有什么太大的实际意义。

  • 执行 shutdown 命令时会触发生成 dump 文件。

  • 通过配置文件自动生成,Redis 中配置文件默认配置如下,只要达到这三个条件中的任意一个,就会触发 Redis 的RDB 持久化机制。

手动触发

除了自动触发,Redis 中还提供了 2 个手动触发 RDB 机制的命令(这两个命令不能同时被执行,一旦一个命令正在执行中,另一个命令会被拒绝执行):

  • save:这个命令会阻塞 Redis 服务器进程,直到成功创建 RDB 文件,也就是说在生成 RDB 文件之前,服务器不能处理客户端发送的任何命令。

  • bgsave:父进程会执行 fork 操作来创建一个子进程。RDB 文件由子进程来负责生成,父进程可以正常处理客户端发送的命令(这里也是 Redis 不仅仅只是单线程的一个体现)。

3.2.1.2 优点
  • RDB 是一个非常紧凑的压缩文件,保存了不同时间点上的文件,非常适合用来灾备和数据恢复。

  • RDB 最大限度地提高了 Redis 的性能,因为 Redis 父进程需要做的唯一的工作就是派生一个子进程来完成剩下的工作,父进程永远不会执行磁盘 I/O 或类似的耗时操作。

  • 与后面介绍的 AOF 持久化机制比较,RDB 方式恢复数据的速度更快。

3.2.1.3 缺点
  • RDB 无法做到实时备份,所以如果 Redis 因异常停止工作而没有正确的关机,那么从上一次备份的到异常宕机的这一段时间的数据将会丢失。

  • 2、RDB 通常需要父进程来执行 fork 操作创建子线程,所以如果频繁执行 fork 操作而 CPU 性能又不是很高的话可能会造成短时间内父进程不可用。

3.2.2 AOF 持久化机制

AOF 采用日志的形式将每个写操作追加到文件中。开启 AOF 机制后,只要执行更改 Redis 数据的命令时,命令就会被写入到 AOF 文件中。在 Redis 重启的时候会根据日志内容依次执行 AOF 文件中的命令来恢复数据。

AOF 和 RDB 最大的不同是:AOF 记录的是执行命令(类似于 MySQL 中 binlog 的 statement 格式),而RDB 记录的是数据(类似于 MySQL 中 binlog 的 row 格式)。

需要注意的是:假如同时开启了 RDB 和 AOF 两种机制,那么 Redis 会优先选择 AOF 持久化文件来进行数据恢复。

3.2.2.1 AOF 文件重写

AOF 机制主要是通过记录执行命令的方式来实现的,那么随着时间的增加,AOF 文件不可避免的会越来越大,而且可能会出现很多冗余命令。比如同一个 key 值执行了 10000 次 set 操作,实际上前面 9999 次对恢复数据来说都是没用的,只需要执行最后一次命令就可以把数据恢复,正是为了避免这种问题,AOF 机制就提供了文件重写功能。

AOF 重写时 Redis 并不会去分析原有的文件,因为如果原有文件过大,分析也会很耗时,所以 Redis 选择的做法就是重新去 Redis 中读取现有的键值对,然后用一条命令记录键值对的值

3.2.2.2 AOF 重写缓冲区

AOF 重写的时候一般都会有大量的写操作,所以为了不阻塞客户端的命令请求,Redis 会把重写操作放入到子进程中执行,但是放入子进程中执行也会带来一个问题,那就是重写期间如果同时又执行了客户端发过来的命令,又该如何保证数据的一致性?

为了解决数据不一致问题,Redis 中引入了一个 AOF 重写缓冲区。当开始执行 AOF 文件重写之后又接收到客户端的请求命令,不但要将命令写入原本的 AOF 缓冲区(根据上面提到的参数刷盘),还要同时写 入 AOF 重写缓冲区

3.2.2.3 优点

  • 使用 AOF 机制,可以***选择不同 fsync (刷盘)策略,而且在默认策略下最多也仅仅是损失 1s 的数据。

  • AOF 日志是一个仅追加的日志,因此如果出现断电,也不存在查找或损坏问题。即使由于某些原因(磁盘已满或其他原因),日志已经写了一半的命令结束,redis-check-aof工具也能够轻松地修复它。

  • 当 AOF 文件变得太大时,Redis 能够在后台自动重写。

  • 不同于 RDB 的文件格式,AOF 是一种易于理解和解析的格式,依次包含所有操作的日志。

3.2.2.4 缺点

  • 对于相同的数据集,AOF 文件通常比等效的 RDB 文件大。

  • 根据 fsync 的具体策略,AOF 机制可能比 RDB 机制慢。但是一般情况下,fsync 设置为每秒的性能仍然很高,禁用 fsync 后,即使在高负载下,它的速度也能和 RDB 一样快。

  • 因为 AOF 文件是追加形式,可能会遇到 BRPOP、LPUSH 等阻塞命令的错误,从而导致生成的 AOF 在重新加载时不能复制完全相同的数据集,而 RDB 文件每次都是重新从头创建快照,这在一定程度上来说 RDB 文件更加健壮。

3.3 数据库的一二三范式

3.3.1 第一范式(1NF)

如果一个关系模式R的所有属性都是不可分的基本数据项,则R∈1NF**。**

在表1中,我们可以发现薪水被分为基本工资和提成,所以这里的数据是不符合第一范式的要求。

3.3.2 第二范式(2NF)

若关系模式R∈1NF,并且每一个非主属性完全函数依赖于R的,则R∈2NF

在学生关系中,码为(学号、课程号),学号和课程号是主属性,系部、学生住处、成绩就是非主属性;

对于(学号,课程号)→成绩,非主属性 成绩 完全依赖于码(学号,课程号),因为一个课程有多名学生选,每名学生又可以选多门课程,某学生的相应课程成绩就必须由学号和课程号一起决定,缺一不可;

对于(学号,课程号)→系部,非主属性 系部 部分依赖于码(学号,课程号),已知学生学号也是可以知道它所在的系部的;

对于(学号,课程号)→学生住处,非主属性 学生住处 部分依赖于码(学号,课程号),已知学生学号也是可以知道它所在的学生住处;

所以,关系模式学生(学号、系部、学生住处、课程号、成绩)存在非主属性对于码的部分函数依赖,最高只符合1NF的要求,它不符合2NF的要求。

3.3.3 第三范式(3NF)

若关系模式R∈2NF,并且R中所有非主属性对任意候选码都不存在传递函数依赖

例如,前面提到的关系模式:基本信息(学号、系部、学生住处)有下列函数依赖:

学号->系部

系部->学生住处

学号->学生住处

我们可以看到基本信息表中存在非主属性对码的传递函数依赖。所以,该基本信息表最高只符合2NF的要求,它不符合3NF的要求。

3.4 mysql的sql语句语法

3.4.1 select语句

MySQL中使用select来获取需要查询的数据结果,同时select也可以做输出,与print类似。

select * from <表名>;

3.4.2 where语句

where语句是用来作为条件判断的语句,多个条件之间用and或者or连接,返回结果是where语句中为True的结果。

select * from <表名> where <条件>;

3.4.3 having语句

having语句与where类似,都是用来作条件判断,区别在于,having语句查询的字段必须是在查询的结果中,having后面可以使用聚集函数,同时where所需要查询的字段必须在表中存在。

只能使用 where 不能使用 having 的情况。

select name, birthday from student where id > 2;

-- 报错,having的条件查询,只能包含在前面的搜索结果里 
select `name`, `birthday` from `student` having id > 2;
只能使用 having 不能使用 where 的情况。
select name as n,birthday as b,id as i from student having i > 2; 
-- 报错,where只识别存在的字段 
select name as n,birthday as b,id as i from student where i > 2; 
-- 取出每个城市中满足最小出生年份大于1995的 
select city, group_concat(birthday) from student group by city having min(birthday) >'1995-1-1';
3.4.4 group by 分组查询

group by是按照某一字段对数据进行分组,如性别分为男女,可以查询出所有男生和女生的信息,如果有where字段要放在where字段之后。

select <字段> from <表名> where 条件 group by <分组字段>;

3.4.5 order by字段

order by是用来对查询的数据进行排序的语句,可以按照一定的规则进行排序,默认为升序(asc),降序需要在order by后面加上desc。

select * from <表名> order by <字段> desc;

3.4.6 limit语句

limit语句是用来对查询的结果做一些限制,限制只查询从某行到某行的结果。

select <字段> from <表名> limit m,n;

--其中m为从m行开始,n为向下查询n行

3.4.7 distinct语句

distinct语句是用来去重的语句,将查询的结果只显示一条。

select distinct <字段> from <表名>;

3.4.8 union:联合查询

将两个或多个表联合查询,但需要保证查询两个表的结果字段数一致,查询的结果现实的字段是第一张表的字段。

select * from <表名>

union

select * from <表名>;

3.4.9 inner join:内连接

inner join是将两张表按照某一字段进行匹配连接,显示在一起。

select <字段> from <表1> inner join <表2> on 表1.字段=表2.字段;

3.4.10 left join:左连接

left join是将左边表的查询字段全部显示,右边表没有匹配的行就显示为NULL。

select <字段> from <表1> left join <表2> on <表1.字段>=<表2.字段>;

3.4.11 right join:右连接

right join是将右边表的查询字段全部显示,左边表没有匹配的行就显示为NULL。

select <字段> from <表1> right join <表2> on <表1.字段>=<表2.字段>;

3.5 Redis的List项目中是怎么用的

list 类型的 lrange 命令可以分页查看队列中的数据。可将每隔一段时间计算一次的排行榜存储在 list 类型中,如京东每日的手机销量排行、学校每次月考学生的成绩排名、斗鱼年终盛典主播排名等。

但是,并不是所有的排行榜都能用 list 类型实现,只有定时计算的排行榜才适合使用 list 类型存储,与定时计算的排行榜相对应的是实时计算的排行榜,list 类型不能支持实时计算的排行榜

3.6 Redis有哪些数据类型

Redis支持的数据类型主要有五种:string(字符串),hash(哈希),list(列表),set(集合)及zset(sorted set:有序集合)。

3.6.1 String(字符串)

string是redis最基本的类型,一个key对应一个value。

  • string类型是二进制安全的。意思是redis的string可以包含任何数据。比如jpg图片或者序列化的对象 。

  • string类型是Redis最基本的数据类型,一个键最大能存储512MB。

3.6.2 Hash(哈希)

hash 是一个键值对集合

3.6.3 List(列表)

列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的左边或者右边。

列表可以从两端压入或者弹出元素

特点:

  • 有序

  • 可以重复

  • 可以在左右两边插入弹出

3.6.4 Set(集合)

Redis 的 Set是 string 类型的无序集合。

集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)

特点;

  • 无序

  • 无重复

  • 可以集合间操作

3.6.5 zset(有序集合)

Redis zset 和 set 一样也是 string 类型元素的集合,且不允许重复的成员。

  • 不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。

  • zset 的成员是唯一的,但分数(score)却可以重复

特点:

  • 无重复元素

  • 有序

3.7 mysql怎么查看是否使用了索引

解释Explain得到的结果

3.7.1 type 反应查询语句的性能

我们主需要注意一个最重要的的 type 的信息很明显地体现出是否用到了索引:

type 结果值从好到坏依次是:

system > const> eq_ref> ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL

一般来说,得保证查询至少达到 range 级别,最好能达到 ref 级别,否则就可能出现性能问题

3.7.2 possible_keys: SQL查询时用到的索引。

可以看到,没加索引时,possible_keys 的值为 NULL,加了索引后的值为 address,即用到了索引address(索引默认为(column_list)中的第一个列的名字).

3.7.3 key 显示SQL实际决定查询结果使用的键(索引)。如果没有使用索引,值为NULL

4.0 项目

4.1 共享平台

4.1.1 多个G的文件如何保存在平台上(1)

大文件必然分片上传。 前端给后台传递时,就把文件流切分成几段,分段传给后台,后台并发接收

4.2 powerBi怎么用的

Power BI 是软件服务、应用和连接器的集合,它们协同工作以将相关数据来源转换为连贯的视觉逼真的交互式见解。 数据可以是 Excel 电子表格,也可以是基于云和本地混合数据仓库的集合。 使用 Power BI,可以轻松连接到数据源,可视化并发现重要内容,并根据需要与任何人共享。

就是将各种来源的标准格式数据,如mysql表,excel表里面的数据导入到powerBI里面,通过这个软件,将复杂的数据转化成以各种图表的方式展现出来。

4.3 项目中用到的设计模式

4.3.1 工厂模式

网约车的车类型

4.3.2 单例模式

4.4

4.4 超卖问题解决方案

4.4.1 悲观锁

redis分布式锁也属于悲观锁的范畴

当查询某条记录时,即让数据库为该记录加锁,锁住记录后别人无法操作

beginTranse(开启事务) try{     query('select amount from s_store where goodID = 12345');     if(库存 > 0){         //quantity为请求减掉的库存数量         query('update s_store set amount = amount - quantity where goodID = 12345');     } }catch( Exception e ){     rollBack(回滚) } commit(提交事务)

上面的语句,可能出现死锁的简单的原因,在事务的隔离级别为Serializable时,假设事务t1通过 select拿到了共享锁,而其他事务如果拿到了 排他锁,此时t1 去拿排他锁的时候, 就有可能会出现死锁,注意,这里是可能,并不是一定。实际的原因,与事务的隔离级别和语句的复杂度,都有关系。

总之,避免死锁的方式之一(稍后介绍):为了在单个 InnoDB 表上执行多个并发写入操作时避免死锁,可以在事务开始时通过为预期要修改的每个元祖(行)使用 SELECT … FOR UPDATE 语句来获取必要的锁,即使这些行的更改语句是在之后才执行的。

4.4.1.1 淘宝是如何使用悲观锁的

后端的数据库在高并发和超卖下主要会遇到以下几个问题

  1. 首先MySQL自身对于高并发的处理性能就会出现问题,一般来说,MySQL的处理性能会随着并发thread上升而上升,但是到了一定的并发度之后会出现明显的拐点,之后一路下降,最终甚至会比单thread的性能还要差

  1. 其次,超卖的根结在于减库存操作是一个事务操作,需要先select,然后insert,最后update -1。最后这个-1操作是不能出现负数的,但是当多用户在有库存的情况下并发操作,出现负数这是无法避免的

  1. 最后,当减库存和高并发碰到一起的时候,由于操作的库存数目在同一行,就会出现争抢InnoDB行锁的问题,导致出现互相等待甚至死锁,从而大大降低MySQL的处理性能,最终导致前端页面出现超时异常

4.4.4.2 解决方案
  1. 关闭死锁检测,提高并发处理性能

在一个高并发的MySQL服务器上,事务会递归检测死锁,当超过一定的深度时,性能的下降会变的不可接受。禁止死锁检测后,即使死锁发生,也不会回滚事务,而是全部等待到超时
  1. 请求排队,修改源代码,将排队提到进入引擎层前,降低引擎层面的并发度

请求排队:如果请求一股脑的涌入数据库,势必会由于争抢资源造成性能下降,通过排队,让请求从混沌到有序,从而避免数据库在协调大量请求时过载
  1. 请求合并(组提交)

请求合并(组提交),降低server和引擎的交互次数,降低IO消耗。
4.4.2 乐观锁

乐观锁并不是真实存在的锁,而是在更新的时候判断此时的库存是否是之前查询出的库存,如果相同,表示没人修改,可以更新库存,否则表示别人抢过资源,不再执行库存更新。

使用乐观锁的时候,如果一个事务修改了库存并提交了事务,那其他的事务应该可以读取到修改后的数据值,所以不能使用可重复读的隔离级别,应该修改为读取已提交(Read committed)。

常用的两种方式

  1. 版本号机制

使用乐观锁,查询数据库时,会同时把商品信息和该条记录的版本号这个字段version都查询出来,这个version会在有更新时进行+1操作,然后当要进行扣库存操作时加上一个条件update table set stock=stock-1 where version=#{version} 如果这个version相等说明没人修改过这个值,那么就更新完成,否则就失败,那么就继续秒杀,重试
关键就是修改的时候只能有一个人进行修改,所以在修改前,把其中一个属性值记录下来,每次修改属性值会改变,就可以保证每次只能修改一次
  1. CAS算法

读写的内存值 V
进行比较的值 E
拟写入的新值 N
缺点:
循环时间太长。 自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销
只能保证一个共享变量原子操作
会出现ABA问题

乐观锁在高并发场景下的问题

乐观锁在高并发场景下的问题,是严重的空自旋

4.4.3 分阶段排队下单方案(利用Redis和消息队列)

将提交操作变成两段式

  • 第一阶段申请,申请预减减库,申请成功之后,进入消息队列;

  • 第二阶段确认,从消息队列消费申请令牌,然后完成下单操作。 查库存 -> 创建订单 -> 扣减库存。通过分布式锁保障解决多个provider实例并发下单产生的超卖问题。

申请阶段:

将存库从MySQL前移到Redis中,所有的预减库存的操作放到内存中,由于Redis中不存在锁故不会出现互相等待,并且由于Redis的写性能和读性能都远高于MySQL,这就解决了高并发下的性能问题。

确认阶段:

然后通过队列等异步手段,将变化的数据异步写入到DB中。

引入队列,然后数据通过队列排序,按照次序更新到DB中,完全串行处理。当达到库存阀值的时候就不在消费队列,并关闭购买功能。这就解决了超卖问题。

5.0 并发编程

5.1 线程创建的方式(差了两个)

1)继承Thread类创建线程

2)实现Runnable接口创建线程

3)使用Callable和Future创建线程(可以获得返回值)

Callable接口提供了一个call()方法作为线程执行体,call()方法比run()方法功能要强大。
call()方法可以有返回值
call()方法可以声明抛出异常
Java5提供了Future接口来代表Callable接口里call()方法的返回值,并且为Future接口提供了一个实现类FutureTask,这个实现类既实现了Future接口,还实现了Runnable接口,因此可以作为Thread类的target。在Future接口里定义了几个公共方法来控制它关联的Callable任务。

4)使用线程池例如用Executor框架

ExecutorService newFixedThreadPool() : 创建固定大小的线程池
ExecutorService newCachedThreadPool() : 缓存线程池,线程池的数量不固定,可以根据需求自动的更改数量。
ExecutorService newSingleThreadExecutor() : 创建单个线程池。线程池中只有一个线程
ScheduledExecutorService newScheduledThreadPool() : 创建固定大小的线程,可以延迟或定时的执行任务。

5.2 线程池的参数(3,7漏了)

  1. corePoolSize:核心线程数。

线程池中长期存活的线程数。这些人一般比较稳定,无论这一年的活多活少,这些人都不会被辞退,都是长期生活在大户人家的。
  1. maximumPoolSize:最大线程数。

线程池允许创建的最大线程数量,当线程池的任务队列满了之后,可以创建的最大线程数
  1. keepAliveTime:空闲线程存活时间。

空闲线程存活时间。,当线程池中没有任务时,会销毁一些线程,销毁的线程数=maximumPoolSize(最大线程数)-corePoolSize(核心线程数)。
  1. TimeUnit:时间单位。

  1. BlockingQueue:线程池任务队列。

线程池存放任务的队列,用来存储线程池的所有待执行任务
它可以设置以下几个值:
ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。
SynchronousQueue:一个不存储元素的阻塞队列,即直接提交给线程不保持它们。
PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
DelayQueue:一个使用优先级队列实现的无界阻塞队列,只有在延迟期满时才能从中提取元素。
LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。与SynchronousQueue类似,还含有非阻塞方法。
LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
比较常用的是 LinkedBlockingQueue,线程池的排队策略和 BlockingQueue 息息相关。
  1. ThreadFactory:创建线程的工厂。

通过此方法可以设置线程的优先级、线程命名规则以及线程类型(用户线程还是守护线程)
// 创建线程工厂 ThreadFactory threadFactory = new ThreadFactory() { @Override public Thread newThread(Runnable r) { // 创建线程池中的线程 Thread thread = new Thread(r); // 设置线程名称 thread.setName("Thread-" + r.hashCode()); // 设置线程优先级(最大值:10) thread.setPriority(Thread.MAX_PRIORITY); //...... return thread; } }; // 创建线程池 ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10, 10, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), threadFactory); // 使用自定义的线程工厂 threadPoolExecutor.submit(new Runnable() { @Override public void run() { Thread thread = Thread.currentThread(); System.out.println(String.format("线程:%s,线程优先级:%d", thread.getName(), thread.getPriority())); } });
  1. RejectedExecutionHandler:拒绝策略。

拒绝策略:当线程池的任务超出线程池队列可以存储的最大值之后,执行的策略
AbortPolicy:拒绝并抛出异常。
CallerRunsPolicy:使用当前调用的线程来执行此任务。
DiscardOldestPolicy:抛弃队列头部(最旧)的一个任务,并执行当前任务。
DiscardPolicy:忽略并抛弃当前任务。

5.3 线程的各个状态

线程从创建、运行到结束总是处于下面五个状态之一:新建状态、就绪状态、运行状态、阻塞状态及死亡状态

5.3.1 新建状态(New)

当用new操作符创建一个线程时, 例如new Thread(r),线程还没有开始运行,此时线程处在新建状态。 当一个线程处于新生状态时,程序还没有开始运行线程中的代码

5.3.2 就绪状态(Runnable)

一个新创建的线程并不自动开始运行,要执行线程,必须调用线程的start()方法。当线程对象调用start()方法即启动了线程,start()方法创建线程运行的系统资源,并调度线程运行run()方法。当start()方法返回后,线程就处于就绪状态。

处于就绪状态的线程并不一定立即运行run()方法,线程还必须同其他线程竞争CPU时间,只有获得CPU时间才可以运行线程。因为在单CPU的计算机系统中,不可能同时运行多个线程,一个时刻仅有一个线程处于运行状态。因此此时可能有多个线程处于就绪状态。对多个处于就绪状态的线程是由Java运行时系统的线程调度程序(thread scheduler)来调度的

5.3.3 运行状态(Running)

当线程获得CPU时间后,它才进入运行状态,真正开始执行run()方法

5.3.4 阻塞状态(Blocked)

线程运行过程中,可能由于各种原因进入阻塞状态:

  1. 线程通过调用sleep方法进入睡眠状态;

  1. 线程调用一个在I/O上被阻塞的操作,即该操作在输入输出操作完成之前不会返回到它的调用者;

  1. 线程试图得到一个锁,而该锁正被其他线程持有;

  1. 线程在等待某个触发条件;

所谓阻塞状态是正在运行的线程没有运行结束,暂时让出CPU,这时其他处于就绪状态的线程就可以获得CPU时间,进入运行状态

5.3.5 死亡状态(Dead)

有两个原因会导致线程死亡:

  1. run方法正常退出而自然死亡,

  1. 一个未捕获的异常终止了run方法而使线程猝死

5.4 方法sleep和wait的区别

1、sleep是线程中的方法,但是wait是Object中的方法。

2、sleep方法不会释放资源锁(即使别人进来也用不了),但是wait会释放资源锁,而且会加入到等待队列中。

3、sleep方法不依赖于同步器synchronized,但是wait需要依赖synchronized关键字。

同步方法,其实就是在方法上加上了**同步关键字(synchronized)**进行修饰。多个同步方法只能在某个时刻运行一个。

4、sleep不需要被唤醒(休眠之后退出阻塞),但是wait需要(不指定时间需要被别人中断)。

5.5 怎么知道线程池中哪些线程抛出了异常

自己实现线程池,重写afterExecute方法,在有异常抛出时,记录日志就好

5.6 java中哪几个类会引起线程不安全

  • StringBuilder:线程不安全,效率高;

  • StringBuffer:线程安全,使用synchronized做了处理,效率较builder低;

只有将StringBuilder定义成静态成员变量使用的时候,会造成线程安全问题。(多个线程使用一个变量)

  • SimpleDateFormate:线程不安全,

  • JodaTime:线程安全(日期转换 推荐使用)

5.7 防止死锁的方法

要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可,而在操作系统中,互斥条件和不可剥夺条件是系统规定的,这也没办法人为更改,而且这两个条件很明显是一个标准的程序应该所具备的特性。所以目前只有请求并持有和环路等待条件是可以被破坏的。

  1. 保持加锁顺序:当多个线程都需要加相同的几个锁的时候(例如上述情况一的死锁),按照不同的顺序枷锁那么就可能导致死锁产生,所以我们如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。

例如,线程2和线程3只有在获取了锁A之后才能尝试获取锁C(译者注:获取锁A是获取锁C的必要条件)。因为线程1已经拥有了锁A,所以线程2和3需要一直等到锁A被释放。然后在它们尝试对B或C加锁之前,必须成功地对A加了锁。
  1. 获取锁添加时限:上述死锁代码情况二就是因为出现了获取锁失败无限等待的情况,如果我们在获取锁的时候进行限时等待,例如wait(1000)或者使用ReentrantLock的tryLock(1,TimeUntil.SECONDS)这样在指定时间内获取锁失败就不等待

  1. 进行死锁检测:我们可以通过一些手段检查代码并预防其出现死锁

每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中

5.8 程池的执行流程

基于addWorker添加工作线程的流程切入到整体处理任务的位置

5.9 线程池的拒绝策略

拒绝策略:当线程池的任务超出线程池队列可以存储的最大值之后,执行的策略

  • AbortPolicy:拒绝并抛出异常。

  • CallerRunsPolicy:使用当前调用的线程来执行此任务。

  • DiscardOldestPolicy:抛弃队列头部(最旧)的一个任务,并执行当前任务。

  • DiscardPolicy:忽略并抛弃当前任务。

6.0 分布式编程

6.1 分布式插件

6.1.1 如何进行消息通知

消息队列之 RabbitMQ

6.1.1.1 什么叫消息队列

消息(Message)是指在应用间传送的数据。消息可以非常简单,比如只包含文本字符串,也可以更复杂,可能包含嵌入对象。

消息队列(Message Queue)是一种应用间的通信方式,消息发送后可以立即返回,由消息系统来确保消息的可靠传递。消息发布者只管把消息发布到 MQ 中而不用管谁来取,消息使用者只管从 MQ 中取消息而不管是谁发布的。这样发布者和使用者都不用知道对方的存在。

6.1.1.2 为何用消息队列

以常见的订单系统为例,用户点击【下单】按钮之后的业务逻辑可能包括:扣减库存、生成相应单据、发红包、发短信通知。在业务发展初期这些逻辑可能放在一起同步执行,随着业务的发展订单量增长,需要提升系统服务的性能,这时可以将一些不需要立即生效的操作拆分出来异步执行,比如发放红包、发短信通知等。这种场景下就可以用 MQ ,在下单的主流程(比如扣减库存、生成相应单据)完成之后发送一条消息到 MQ 让主流程快速完结,而由另外的单独线程拉取MQ的消息(或者由 MQ 推送消息),当发现 MQ 中有发红包或发短信之类的消息时,执行相应的业务逻辑。

6.1.1.3 架构简介

这张图中涉及到如下一些概念:

  • 生产者(Publisher):发布消息到 RabbitMQ 中的交换机(Exchange)上。

  • 交换机(Exchange):和生产者建立连接并接收生产者的消息。

  • 消费者(Consumer):监听 RabbitMQ 中的 Queue 中的消息。

  • 队列(Queue):Exchange 将消息分发到指定的 Queue,Queue 和消费者进行交互。

  • 路由(Routes):交换机转发消息到队列的规则。

配置交换机、队列以及绑定

@Bean
public DirectExchange myExchange() {
    DirectExchange directExchange = new DirectExchange("myExchange");
    return directExchange;
}
 
@Bean
public Queue myQueue() {
    Queue queue = new Queue("myQueue");
    return queue;
}
 
@Bean
public Binding binding() {
    return BindingBuilder.bind(myQueue()).to(myExchange()).with("myRoutingKey");
}

生产发送消息

@Autowired
private RabbitTemplate rabbitTemplate;
 
@GetMapping("/send")
public String send(String message) {
    rabbitTemplate.convertAndSend("myExchange","myRoutingKey",message);
    System.out.println("【发送消息】" + message)
    return "【send message】" + message;
}

消费者接收消息

    @RabbitListener(queuesToDeclare = @Queue("myQueue"))
    public void process(String msg, Channel channel, Message message) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        Date date = new Date();
        String time = sdf.format(date);
        System.out.println("【接收信息】" + msg + " 当前时间" + time);
6.1.1.4 消息丢失分析

一条消息的从生产到消费,消息丢失可能发生在以下几个阶段:

  • 生产端丢失: 生产者无法传输到 RabbitMQ

  • 存储端丢失: RabbitMQ 存储自身挂了

  • 消费端丢失:存储由于网络问题,无法发送到消费端,或者消费挂了,无法发送正常消费

RabbitMQ 从生产端、储存端、消费端都对可靠性传输做很好的支持。

6.1.1.5 生产阶段

生产阶段通过请求确认机制,来确保消息的可靠传输。当发送消息到 RabbitMQ 服务器 之后,RabbitMQ 收到消息之后,给发送返回一个请求确认,表示RabbitMQ 服务器已成功的接收到了消息。

消息从 生产者 到 交换机, 有confirmCallback 确认模式。发送消息成功后消息会调用方法confirm(CorrelationData correlationData, boolean ack, String cause),根据 ack 判断消息是否发送成功。

消息从 交换机 到 队列,有returnCallback 退回模式。

生产端模拟消息丢失

这里有两个方案:

  1. 发送消息后立马关闭 broke,后者把网络关闭,但是broker关闭之后控制台一直就会报错,发送消息也报500错误。

  1. 发送不存在的交换机:

RabbitMQ

开启队列持久化,创建的队列和交换机默认配置是持久化的。首先把队列和交换机设置正确,修改消费监听的队列,使得消息存放在队列里

修改队列的持久化,修改成非持久化:

    @Bean
    public Queue myQueue() {
        Queue queue = new Queue("myQueue",false);
        return queue;
    }

发送消息之后,消息存放在队列中,然后重启 RabbitMQ,消息不存在了。 设置队列持久化:

    @Bean
    public Queue myQueue() {
        Queue queue = new Queue("myQueue",true);
        return queue;
    }

重启之后,队列的消息还存在。

6.1.1.6 消费端

消费端默认开始 ack 自动确认模式,当队列消息被消费者接收,不管有没有被消费端消息,都自动删除队列中的消息。所以为了确保消费端能成功消费消息,将自动模式改成手动确认模式:

修改 application.yml 文件

spring:
  rabbitmq:
    # 手动消息确认
    listener:
      simple:
        acknowledge-mode: manual

6.2 实现分布式ID的多个方法

描述

优点

缺点

UUID

UUID是通用唯一标识码的缩写,其目的是上分布式系统中的所有元素都有唯一的辨识信息,而不需要通过中央控制器来指定唯一标识。

1. 降低全局节点的压力,使得主键生成速度更快;2. 生成的主键全局唯一;3. 跨服务器合并数据方便

1. UUID占用16个字符,空间占用较多;2. 不是递增有序的数字,数据写入IO随机性很大,且索引效率下降

数据库主键自增

MySQL数据库设置主键且主键自动增长

1. INT和BIGINT类型占用空间较小;2. 主键自动增长,IO写入连续性好;3. 数字类型查询速度优于字符串

1. 并发性能不高,受限于数据库性能;2. 分库分表,需要改造,复杂;3. 自增:数据量泄露

Redis自增

Redis计数器,原子性自增

使用内存,并发性能好

1. 数据丢失;2. 自增:数据量泄露

号段模式

依赖于数据库,但是区别于数据库主键自增的模式

较自增id性能有显著的提升

受限于数据库性能

雪花算法

大名鼎鼎的雪花算法,分布式ID的经典解决方案

1. 不依赖外部组件;2. 性能好

时钟回拨

上面提到了五种解决方案,目前流行的分布式ID解决方案有两种:「号段模式」和「[雪花算法]

6.2.1 uuid

这种方式很简单,在每次需要新增数据的时候,先生成一个uuid

String id=UUID.randomUUID().toString();
6.2.2 数据库主键自增

我们需要专门创建一个表来存放id,

表可以设计成以下样子:

CREATE TABLE SEQID.SEQUENCE_ID ( 
    id bigint(20) unsigned NOT NULL auto_increment, 
    stub char(10) NOT NULL default '', 
    PRIMARY KEY (id), 
    UNIQUE KEY stub (stub) 
);

在每次新增的时候,先向该表新增一条数据,然后获取返回新增的主键作为要插入的主键Id,我们可以使用下面的语句生成并获取到一个自增ID

begin; 
replace into SEQUENCE_ID (stub) VALUES ('anyword'); 
select last_insert_id(); 
commit;
6.2.3 Redis自增

因为Redis是单线程的,所以可以用来生成全部唯一ID,通过incr、incrby实现。

生产环境可能是Redis集群,假如有5个Redis实例,每个Redis的初始值是1,2,3,4,5,然后增长都是5

各个Redis生成的ID为:

A:1,6,11,16,21

B:2,7,12,17,22

C:3,8,13,18,23

D:4,9,14,19,24

E:5,10,15,20,25

这样的话,无论请求打到那个Redis上面,都可以获得不同的ID

下面我们实操一下,更直观的感受一下

使用redis的效率是非常高的,但是要考虑持久化的问题。Redis支持RDBAOF两种持久化的方式。

6.2.4 号段模式

我们可以使用号段的方式来获取自增ID,号段可以理解成批量获取,比如DistributIdService从数据库获取ID时,如果能批量获取多个ID并缓存在本地的话,那样将大大提供业务应用获取ID的效率。

比如DistributIdService每次从数据库获取ID时,就获取一个号段,比如(1,1000],这个范围表示了1000个ID,业务应用在请求DistributIdService提供ID时,DistributIdService只需要在本地从1开始自增并返回即可,而不需要每次都请求数据库,一直到本地自增到1000时,也就是当前号段已经被用完时,才去数据库重新获取下一号段。

这种方案不再强依赖数据库,就算数据库不可用,那么DistributIdService也能继续支撑一段时间。但是如果DistributIdService重启,会丢失一段ID,导致ID空洞。

为了提供数据库层的高可用,需要对数据库使用多主模式进行部署,对于每个数据库来说要保证生成的号段不重复,这就需要利用最开始的思路,再在刚刚的数据库表中增加起始值和步长,比如如果现在是两台Mysql,那么 mysql1将生成号段(1,1001],自增的时候序列为1,3,5,7… mysql2将生成号段(2,1002],自增的时候序列为2,4,6,8,10

6.2.5 雪花算法(snowflake)

上面的三种方法总的来说是基于自增思想的,而接下来就介绍比较著名的雪花算法-snowflake

我们可以换个角度来对分布式ID进行思考,只要能让负责生成分布式ID的每台机器在每毫秒内生成不一样的ID就行了。

snowflake是twitter开源的分布式ID生成算法,是一种算法,所以它和上面的三种生成分布式ID机制不太一样,它不依赖数据库。

核心思想是:分布式ID固定是一个long型的数字,一个long型占8个字节,也就是64个bit,原始snowflake算法中对于bit的分配如下图:

根据这个算法的逻辑,只需要将这个算法用Java语言实现出来,封装为一个工具方法,那么各个业务应用可以直接使用该工具方法来获取分布式ID,只需保证每个业务应用有自己的工作机器id即可,而不需要单独去搭建一个获取分布式ID的应用。

6.3 RocketMQ为什么不用Redis代替

6.3.1MQ的好处
6.3.1.1 解耦

引入前

引入后

6.3.1.2 异步

引入前

引入后

6.3.1.3 削峰

引入前

引入后

6.3.2 带来的问题
  1. 系统的可用性降低

一旦MQ挂了,那么整个系统就崩了,所以在引入MQ的同时需要考虑到怎么样让MQ实现高可用(集群)

  1. 如何保证数据一致性

如果A系统需要调用B、C、D三个系统来完成用户的一次操作,过程中B、C成功返回所需数据,但D却调用失败了,如果D恰好是写操作,此时就会产生B、C、D数据不一致的情况,此时需要考虑到更多的设计去保证数据的一致性(事务、最终一致)

  1. 如何解决消息堆积、重复消息、消息丢失、顺序消费等问题

  • 在削峰或消息消费能力低于生产能力的场景下,MQ中会堆积大量的在消息从而影响业务功能和降低用户体验等问题

  • 当消费者确认超时或失败,以及Kafka和RocketMQ回调的时候都会造成重复消息问题

  • 发送期间,可能因为网络、持久化时磁盘异常、Kafka和RocketMQ回调、服务器重启等等各式各样的问题而导致消息丢失

  • 在一些场景中需要保证消息的顺序消费,但如果出现路由不一致、多线程消费、消息异常等情况都会造成消费顺序紊乱

  1. 系统复杂度提升

因为引入了MQ,那么关注点就在原来的基础上增加,MQ与下游之间、MQ与上游之间的一系列问题,从而导致系统复杂度上升

6.3.2 MQ选型

公司为啥用RocketMQ?为什么不用其他的消息队列中间件?

性能对比图

6.3.4 为什么不用Redis问题解答

相同点:

解耦

服务与之间耦合度,比如订单服务与用户积分服务(需求:下单成功,增加积分)

如果不用消息队列,订单服务和积分服务就要通信,下单后调用积分服务的接口通知积分服务进行处理(或者定时扫描之类的),那么调用接口失败,或者延时等等...一系列的问题要考虑处理,非常繁琐

用户了消息队列,用户A下单成功后下单服务通过redis发布(mq的生产者)一消息,就不用管了.用户积分服务redis订阅了(mq的消费者),就会受到这用户A下单的消息,进行处理.这就降低了多个服务之间的耦合,即使积分服务发生异常,也不会影响用户正常下单.处理起来就非常的丝滑,各干各的互不影响.

区别:

可靠性和机制不一样

redis客户端在订阅消息时,要求订阅在发布之前,否则无法订阅到客户端订阅前,已经发布的消息。(,需要先订阅,然后发布的信息才会被订阅者收到)

Redis的消息发布与订阅,无法实现高并发和大数据量。前者受限于redis本身的并发量限制和内存大小;后者是因为redis发布消息时,会先将数据推送到每个客户端的连接缓冲区,如果单个消息过大会撑爆缓冲区,导致redis错误,就算redis没有撑爆缓冲区,如果消费者(订阅方)没有及时取走消息,也会因为数据积累而撑爆内存。

mq的生产者和消费者则不存在先后关系,生产者只管往队列里面加信息,消费者只管从队列里面取信息

6.4 RocketMQ的部署类型

6.4.1 单机式(不推荐,测试使用)

缺点:不可靠,如果宕机,会导致服务不可用

6.4.2 多Master

组成一个集群,集群每个节点都是Master节点,配置简单,性能也是最高(无同步双写)

缺点:如果某个节点宕机了, 会导致该节点存在未被消费的消息在节点恢复之前不能被消费。

6.4.3 多Master多Slave模式,异步复制

每个Master配置一个Slave, 多对Master-Slave, Master与Slave消息采用异步复制方式, 主从消息一致只会有毫秒级的延迟。

优点:弥补了多Master模式(无slave)下节点宕机后在恢复前不可订阅的问题。在Master宕机后, 消费者还可以从Slave节点进行消费。采用异步模式复制,提升了一定的吞吐量。总结一句就是,采用多Master多Slave模式,异步复制模式进行部署,系统将会有较低的延迟和较高的吞吐量。

缺点:如果Master宕机, 磁盘损坏的情况下, 如果没有及时将消息复制到Slave, 会导致有少量消息丢失。

6.4.4 多Master多Slave模式,同步双写

与多Master多Slave模式,异步复制方式基本一致,唯一不同的是消息复制采用同步方式,只有master和slave都写成功以后,才会向客户端返回成功。

优点:数据与服务都无单点,Master宕机情况下,消息无延迟,服务可用性与数据可用性都非常高.

缺点:会降低消息写入的效率,并影响系统的吞吐量

6.5 你如何看待微服务

6.5.1 什么是微服务

在介绍微服务时,首先得先理解什么是微服务,顾名思义,微服务得从两个方面去理解,什么是"微"、什么是"服务", 微 狭义来讲就是体积小、著名的"2 pizza 团队"很好的诠释了这一解释(2 pizza 团队最早是亚马逊 CEO Bezos提出来的,意思是说单个服务的设计,所有参与人从设计、开发、测试、运维所有人加起来 只需要2个披萨就够了 )。 而所谓服务,一定要区别于系统,服务一个或者一组相对较小且独立的功能单元,是用户可以感知最小功能集。

微服务是一种软件架构,是聚焦在单一的职责和业务功能,具有独立的进程,能够单独运行的服务,并且与外部服务是通过HTTP进行交互通信的服务。

6.5.2 微服务由来

微服务架构风格是一种使用一套小服务来开发单个应用的方式途径,每个服务运行在自己的进程中,并使用轻量级机制通信,通常是HTTP API,这些服务基于业务能力构建,并能够通过自动化部署机制来独立部署,这些服务使用不同的编程语言实现,以及不同数据存储技术,并保持最低限度的集中式管理。

6.5.3 为什么需要微服务

在传统的IT行业软件大多都是各种独立系统的堆砌,这些系统的问题总结来说就是扩展性差,可靠性不高,维护成本高。到后面引入了SOA服务化,但是,由于 SOA 早期均使用了总线模式,这种总线模式是与某种技术栈强绑定的,比如:J2EE。这导致很多企业的遗留系统很难对接,切换时间太长,成本太高,新系统稳定性的收敛也需要一些时间。最终 SOA 看起来很美,但却成为了企业级奢侈品,中小公司都望而生畏。

6.5.4 微服务本质
  1. 微服务,关键其实不仅仅是微服务本身,而是系统要提供一套基础的架构,这种架构使得微服务可以独立的部署、运行、升级,不仅如此,这个系统架构还让微服务与微服务之间在结构上“松耦合”,而在功能上则表现为一个统一的整体。这种所谓的“统一的整体”表现出来的是统一风格的界面,统一的权限管理,统一的安全策略,统一的上线过程,统一的日志和审计方法,统一的调度方式,统一的访问入口等等。

  1. 微服务的目的是有效的拆分应用,实现敏捷开发和部署

  1. 微服务提倡的理念团队间应该是 inter-operate, not integrate 。inter-operate是定义好系统的边界和接口,在一个团队内全栈,让团队自治,原因就是因为如果团队按照这样的方式组建,将沟通的成本维持在系统内部,每个子系统就会更加内聚,彼此的依赖耦合能变弱,跨系统的沟通成本也就能降低。

6.6 微服务设计模式

6.6.1 聚合设计模式

聚合设计模式常用于报表服务,在微服务系统中报表服务是肯定存在的

6.6.2 代理设计模式

在微服务架构中 代理服务 是必然存在的,常用的代理服务是 网关服务。

微服务的各个服务是没有状态的,需要通过统一的入口(代理服务)经过权限的校验、请求的过滤(非法请求、SQL注入等),然后请求具体的服务。

6.6.3 分支设计模式

这种模式是聚合器模式的扩展,允许同时调用两个微服务链

6.6.4 异步消息传递设计模式

虽然REST设计模式非常流行,但它是同步的,会造成阻塞。因此部分基于微服务的架构可能会选择使用消息队列代替REST请求/响应,如下图所示

6.6.5 链式设计模式

在这种情况下,服务A接收到请求后会与服务B进行通信,类似地,服务B会同服务C进行通信。所有服务都使用同步消息传递。在整个链式调用完成之前,客户端会一直阻塞。因此,服务调用链不宜过长,以免客户端长时间等待。

6.6.6 数据共享设计模式

自治是微服务的设计原则之一,就是说微服务是全栈式服务。但在重构现有的“单体应用(monolithic application)”时,SQL数据库反规范化可能会导致数据重复和不一致。因此,在单体应用到微服务架构的过渡阶段,可以使用这种设计模式

在这种情况下,部分微服务可能会共享缓存和数据库存储。不过,这只有在两个服务之间存在强耦合关系时才可以。对于基于微服务的新建应用程序而言,这是一种反模式。

7.0 框架

7.1 Spirng中Bean的生命周期

7.1.1 实例化

对于BeanFactory容器,当客户向容器请求一个尚未初始化的bean时,或初始化bean的时候需要注入另一个尚未初始化的依赖时,容器就会调用createBean进行实例化。对于ApplicationContext容器,当容器启动结束后,便实例化所有的bean。 容器通过获取BeanDefinition对象中的信息进行实例化。并且这一步仅仅是简单的实例化,并未进行依赖注入。 实例化对象被包装在BeanWrapper对象中,BeanWrapper提供了设置对象属性的接口,从而避免了使用反射机制设置属性。

7.1.2 属性赋值

实例化后的对象被封装在BeanWrapper对象中,并且此时对象仍然是一个原生的状态,并没有进行依赖注入。 紧接着,Spring根据BeanDefinition中的信息进行依赖注入。 并且通过BeanWrapper提供的设置属性的接口完成依赖注入。

当经过上述几个步骤后,bean对象已经被正确构造,但如果你想要对象被使用前再进行一些自定义的处理,就可以通过BeanPostProcessor接口实现
7.1.3 初始化
7.1.4 销毁

7.2 Spring的IOC机制

7.2.1 IOC是什么

**Ioc—Inversion of Control,即“控制反转”,不是什么技术,而是一种设计思想。**在Java开发中,**Ioc意味着将你设计好的对象交给容器控制,而不是传统的在你的对象内部直接控制。**如何理解好Ioc呢?理解好Ioc的关键是要明确“谁控制谁,控制什么,为何是反转(有反转就应该有正转了),哪些方面反转了”,那我们来深入分析一下:

  • 谁控制谁,控制什么:传统Java SE程序设计,我们直接在对象内部通过new进行创建对象,是程序主动去创建依赖对象;而IoC是有专门一个容器来创建这些对象,即由Ioc容器来控制对象的创建;谁控制谁?当然是IoC容器控制了对象;控制什么?那就是主要控制了外部资源获取(不只是对象包括比如文件等)

  • 为何是反转,哪些方面反转了:有反转就有正转,传统应用程序是由我们自己在对象中主动控制去直接获取依赖对象,也就是正转;而反转则是由容器来帮忙创建及注入依赖对象;为何是反转?因为由容器帮我们查找及注入依赖对象,对象只是被动的接受依赖对象,所以是反转;哪些方面反转了?依赖对象的获取被反转了。

传统程序

IOC容器

7.2.2 IOC能做什么

有了IoC容器后,把创建和查找依赖对象的控制权交给了容器,由容器进行注入组合对象,所以对象与对象之间是松散耦合,这样也方便测试,利于功能复用,更重要的是使得程序的整个体系结构变得非常灵活。

其实IoC对编程带来的最大改变不是从代码上,而是从思想上,发生了“主从换位”的变化。应用程序原本是老大,要获取什么资源都是主动出击,但是在IoC/DI思想中,应用程序就变成被动的了,被动的等待IoC容器来创建并注入它所需要的资源了。

7.2.3 IOC和DI

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

7.2.4 IOC和DI的联系

其实它们是同一个概念的不同角度描述,由于控制反转概念比较含糊(可能只是理解为容器控制对象这一个层面,很难让人想到谁来维护对象关系),所以2004年大师级人物Martin Fowler又给出了一个新的名字:“依赖注入”,相对IoC 而言,“依赖注入”明确描述了“被注入对象依赖IoC容器配置依赖对象”。

7.3 Mybatis的$和#

  1. 传入的参数在SQL中显示不同

#传入的参数在SQL中显示为字符串(当成一个字符串),会对自动传入的数据加一个双引号

例:使用以下SQL

select id,name,age from student where id =#{id}

当我们传递的参数id为 "1" 时,上述 sql 的解析为:

select id,name,age from student where id ="1"

$传入的参数在SqL中直接显示为传入的值

例:使用以下SQL

select id,name,age from student where id =${id}

当我们传递的参数id为 "1" 时,上述 sql 的解析为:

select id,name,age from student where id =1

  1. #可以防止SQL注入的风险(语句的拼接);但$无法防止Sql注入

  1. $方式一般用于传入数据库对象,例如传入表名

  1. 大多数情况下还是经常使用#,一般能用#的就别用$;但有些情况下必须使用$,例:MyBatis排序时使用order by 动态参数时需要注意,用$而不是#

7.4 在Spring中,Bean属性中的autowire包括哪些

当我们要往一个bean的某个属性里注入另外一个bean,我们会使用 + 标签的形式。但是对于大型项目,假设有一个bean A被多个bean引用注入,如果A的id因为某种原因修改了,那么所有引用了A的bean的 标签内容都得修改,这时候如果使用autowire="byType",那么引用了A的bean就完全不用修改了

7.4.1 byName

通过属性的名称自动装配(注入)。Spring会在容器中查找名称与bean属性名称一致的bean,并自动注入到bean属性中。

当然bean的属性需要有setter方法。例如:bean A有个属性master,master的setter方法就是setMaster,A设置了autowire="byName",那么Spring就会在容器中查找名为master的bean通过setMaster方法注入到A中

7.4.2 byType

通过类型自动装配(注入)。Spring会在容器中查找类(Class)与bean属性类一致的bean,并自动注入到bean属性中,如果容器中包含多个这个类型的bean,Spring将抛出异常。如果没有找到这个类型的bean,那么注入动作将不会执行

7.4.3 constructor

类似于byType,但是是通过构造函数的参数类型来匹配。假设bean A有构造函数A(B b, C c),那么Spring会在容器中查找类型为B和C的bean通过构造函数A(B b, C c)注入到A中。与byType一样,如果存在多个bean类型为B或者C,则会抛出异常。但时与byType不同的是,如果在容器中找不到匹配的类的bean,将抛出异常,因为Spring无法调用构造函数实例化这个bean。

7.4.4 default

采用父级标签(即beans的default-autowire属性)的配置。

其中byType和constructor模式也支持数组和强类型集合(即指定了集合元素类型)。如bean A有个属性定义是List 类型,Spring会在容器中查找所有类型为Foo的bean,注入到该属性。记住是Foo,不是List。

7.4.5 autowire-candidate

前面我们说到配置有autowire属性的bean,Spring在实例化这个bean的时候会在容器中查找匹配的bean对autowire bean进行属性注入,这些被查找的bean我们称为候选bean。所以候选bean给自己增加了autowire-candidate="false"属性(默认是true),那么容器就不会把这个bean当做候选bean了,即这个bean不会被当做自动装配对象

8.0 算法题

8.1 001用哈希怎么做

哈希表的containsKey时间复杂度是O(1),可以代替一遍的遍历。

8.2 查看链表是否有环

利用快慢指针

Mysql的事务和Redis的事务的区别

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值