开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

要探索JDK的核心底层源码,那必须掌握native用法 场景

发表于 2021-10-26

小知识,大挑战!本文正在参与“ 程序员必备小知识 ”创作活动

本文同时参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金

❤️作者简介:Java领域优质创作者🏆,CSDN博客专家认证🏆,华为云享专家认证🏆
❤️技术活,该赏
❤️点赞 👍 收藏 ⭐再看,养成习惯

场景

有探索欲的同学,应该会跟我一样,在看JDK源码时,跟到最后,会出现native方法,类似下面这个方法

1
2
3
4
java复制代码 /**
* Gets the platform defined TimeZone ID.
**/
private static native String getSystemTimeZoneID(String javaHome);

看到这个native ,说明已经挖到核心了,到了这一步,还是不清楚是怎么获取系统的默认时区的,那怎么办,JDK代码只能跟到这里。

转战OpenJDK,源码下载方式:gitee.com/mirrors/ope…

什么是native

native是一个计算机函数,一个Native Method就是一个Java调用非Java代码的接口。方法的实现由非Java语言实现,比如C或C++。

native的源码怎么看呢

以**private static native String getSystemTimeZoneID(String javaHome)**为例

1
go复制代码getSystemTimeZoneID方法所在的package java.util.TimeZone;

如图所示,找到TimeZone.c下的getSystemTimeZoneID方法

image-20210706233905997

image-20210706234052425

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
c复制代码/*
* Gets the platform defined TimeZone ID
*/
JNIEXPORT jstring JNICALL
Java_java_util_TimeZone_getSystemTimeZoneID(JNIEnv *env, jclass ign,
jstring java_home, jstring country)
{
const char *cname;
const char *java_home_dir;
char *javaTZ;

if (java_home == NULL)
return NULL;

java_home_dir = JNU_GetStringPlatformChars(env, java_home, 0);
if (java_home_dir == NULL)
return NULL;

if (country != NULL) {
cname = JNU_GetStringPlatformChars(env, country, 0);
/* ignore error cases for cname */
} else {
cname = NULL;
}

/*
* Invoke platform dependent mapping function
*/
javaTZ = findJavaTZ_md(java_home_dir, cname);

free((void *)java_home_dir);
if (cname != NULL) {
free((void *)cname);
}

if (javaTZ != NULL) {
jstring jstrJavaTZ = JNU_NewStringPlatform(env, javaTZ);
free((void *)javaTZ);
return jstrJavaTZ;
}
return NULL;
}

重点:调用不同平台相关的映射函数

1
2
3
4
scss复制代码  /*
* Invoke platform dependent mapping function
*/
javaTZ = findJavaTZ_md(java_home_dir, cname);

去查找findJavaTZ_md方法时,发现存在分别在solaris和windows两个目录下。

image-20210706234905448

查了下这两个目录的差别:

1
2
3
4
5
6
bash复制代码因为OpenJDK里,Java标准库和部分工具的源码repo(jdk目录)里,BSD和Linux的平台相关源码都是在solaris目录里的。
原本Sun JDK的源码里平台相关的目录就是从solaris和windows这两个目录开始的,后来Unix系的平台相关代码全都放在solaris目录下了,共用大部分代码。

作者:RednaxelaFX
链接:https://www.zhihu.com/question/58982441/answer/170264788
来源:知乎

简单的理解就是:

window系统下,使用windows目录下编译的JDK代码

unix系的平台下,使用solaris目录下编译的JDK代码

了解不同系统下findJavaTZ_md方法执行

windows系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
c复制代码/*
* Detects the platform time zone which maps to a Java time zone ID.
*/
char *findJavaTZ_md(const char *java_home_dir, const char *country)
{
char winZoneName[MAX_ZONE_CHAR];
char winMapID[MAX_MAPID_LENGTH];
char *std_timezone = NULL;
int result;

winMapID[0] = 0;
result = getWinTimeZone(winZoneName, winMapID);

if (result != VALUE_UNKNOWN) {
if (result == VALUE_GMTOFFSET) {
std_timezone = _strdup(winZoneName);
} else {
std_timezone = matchJavaTZ(java_home_dir, result,
winZoneName, winMapID, country);
}
}

return std_timezone;
}

注释写得很清楚,获取“Time Zones”注册表中的当前时区

1
2
3
4
5
6
7
c复制代码/*
* Gets the current time zone entry in the "Time Zones" registry.
*/
static int getWinTimeZone(char *winZoneName, char *winMapID)
{
...
}

时区的设置方式:

image-202107086550950

那时区上的选择值是从哪取到的,上面有说了,是在注册表中取值

打开注册表 :Regedit–>

1
css复制代码计算机\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones\

unix系的平台

findJavaTz_md()方法的注释上写得很清楚了:将平台时区ID映射为Java时区ID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
c复制代码/*
* findJavaTZ_md() maps platform time zone ID to Java time zone ID
* using <java_home>/lib/tzmappings. If the TZ value is not found, it
* trys some libc implementation dependent mappings. If it still
* can't map to a Java time zone ID, it falls back to the GMT+/-hh:mm
* form. `country', which can be null, is not used for UNIX platforms.
*/
/*ARGSUSED1*/
char *
findJavaTZ_md(const char *java_home_dir, const char *country)
{
char *tz;
char *javatz = NULL;
char *freetz = NULL;

tz = getenv("TZ");

#ifdef __linux__
if (tz == NULL) {
#else
#ifdef __solaris__
if (tz == NULL || *tz == '\0') {
#endif
#endif
tz = getPlatformTimeZoneID();
freetz = tz;
}

/*
* Remove any preceding ':'
*/
if (tz != NULL && *tz == ':') {
tz++;
}

#ifdef __solaris__
if (strcmp(tz, "localtime") == 0) {
tz = getSolarisDefaultZoneID();
freetz = tz;
}
#endif

if (tz != NULL) {
#ifdef __linux__
/*
* Ignore "posix/" prefix.
*/
if (strncmp(tz, "posix/", 6) == 0) {
tz += 6;
}
#endif
javatz = strdup(tz);
if (freetz != NULL) {
free((void *) freetz);
}
}
return javatz;
}

步骤:

1、使用< Java home>/lib/tzmappings,。如果没有找到”TZ”变量,就进行第2步

2、 tz = getPlatformTimeZoneID(); 执行Linux特定的映射,如果找到,返回一个时区ID,否则返回null

【Linux】Centos7修改系统时区timezone方式:

1
复制代码timedatectl

image-202107086455780

修改时区

1
arduino复制代码timedatectl  set-timezone Asia/Shanghai

image-2021070864438866

3、对比/etc/localtime与”/usr/share/zoneinfo目录下的文件,如果一致,就返回时区ID,没有则到第4步

4、返回到GMT

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

海康威视一面:Iterator与Iterable有什么区别?

发表于 2021-10-26

那天,小二去海康威视面试,面试官老王一上来就甩给了他一道面试题:请问 Iterator与Iterable有什么区别?小二差点笑出声,因为一年前,也就是 2021 年,他在《Java 程序员进阶之路》专栏上的第 62 篇看到过这题😆。

PS:星标这种事,只能求,不求没效果,come on。《Java 程序员进阶之路》在 GitHub 上已经收获了 460 枚星标,小伙伴们赶紧去点点了,冲 500!

github.com/itwanger/to…


在 Java 中,我们对 List 进行遍历的时候,主要有这么三种方式。

第一种:for 循环。

1
2
3
java复制代码for (int i = 0; i < list.size(); i++) {
System.out.print(list.get(i) + ",");
}

第二种:迭代器。

1
2
3
4
java复制代码Iterator it = list.iterator();
while (it.hasNext()) {
System.out.print(it.next() + ",");
}

第三种:for-each。

1
2
3
java复制代码for (String str : list) {
System.out.print(str + ",");
}

第一种我们略过,第二种用的是 Iterator,第三种看起来是 for-each,其实背后也是 Iterator,看一下反编译后的代码就明白了。

1
2
3
4
5
6
java复制代码Iterator var3 = list.iterator();

while(var3.hasNext()) {
String str = (String)var3.next();
System.out.print(str + ",");
}

for-each 只不过是个语法糖,让我们在遍历 List 的时候代码更简洁明了。

Iterator 是个接口,JDK 1.2 的时候就有了,用来改进 Enumeration:

  • 允许删除元素(增加了 remove 方法)
  • 优化了方法名(Enumeration 中是 hasMoreElements 和 nextElement,不简洁)

来看一下 Iterator 的源码:

1
2
3
4
5
6
7
8
9
10
java复制代码public interface Iterator<E> {
// 判断集合中是否存在下一个对象
boolean hasNext();
// 返回集合中的下一个对象,并将访问指针移动一位
E next();
// 删除集合中调用next()方法返回的对象
default void remove() {
throw new UnsupportedOperationException("remove");
}
}

JDK 1.8 时,Iterable 接口中新增了 forEach 方法:

1
2
3
4
5
6
java复制代码default void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}

它对 Iterable 的每个元素执行给定操作,具体指定的操作需要自己写Consumer接口通过accept方法回调出来。

1
2
java复制代码List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));
list.forEach(integer -> System.out.println(integer));

写得更浅显易懂点,就是:

1
2
3
4
5
6
7
java复制代码List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));
list.forEach(new Consumer<Integer>() {
@Override
public void accept(Integer integer) {
System.out.println(integer);
}
});

如果我们仔细观察ArrayList 或者 LinkedList 的“户口本”就会发现,并没有直接找到 Iterator 的影子。

反而找到了 Iterable!

1
2
3
java复制代码public interface Iterable<T> {
Iterator<T> iterator();
}

也就是说,List 的关系图谱中并没有直接使用 Iterator,而是使用 Iterable 做了过渡。

回头再来看一下第二种遍历 List 的方式。

1
2
3
java复制代码Iterator it = list.iterator();
while (it.hasNext()) {
}

发现刚好呼应上了。拿 ArrayList 来说吧,它重写了 Iterable 接口的 iterator 方法:

1
2
3
java复制代码public Iterator<E> iterator() {
return new Itr();
}

返回的对象 Itr 是个内部类,实现了 Iterator 接口,并且按照自己的方式重写了 hasNext、next、remove 等方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码private class Itr implements Iterator<E> {

public boolean hasNext() {
return cursor != size;
}

@SuppressWarnings("unchecked")
public E next() {
Object[] elementData = ArrayList.this.elementData;
cursor = i + 1;
return (E) elementData[lastRet = i];
}

public void remove() {
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}

}

那可能有些小伙伴会问:为什么不直接将 Iterator 中的核心方法 hasNext、next 放到 Iterable 接口中呢?直接像下面这样使用不是更方便?

1
2
3
java复制代码Iterable it = list.iterator();
while (it.hasNext()) {
}

从英文单词的后缀语法上来看,(Iterable)able 表示这个 List 是支持迭代的,而 (Iterator)tor 表示这个 List 是如何迭代的。

支持迭代与具体怎么迭代显然不能混在一起,否则就乱的一笔。还是各司其职的好。

想一下,如果把 Iterator 和 Iterable 合并,for-each 这种遍历 List 的方式是不是就不好办了?

原则上,只要一个 List 实现了 Iterable 接口,那么它就可以使用 for-each 这种方式来遍历,那具体该怎么遍历,还是要看它自己是怎么实现 Iterator 接口的。

Map 就没办法直接使用 for-each,因为 Map 没有实现 Iterable 接口,只有通过 map.entrySet()、map.keySet()、map.values() 这种返回一个 Collection 的方式才能 使用 for-each。

如果我们仔细研究 LinkedList 的源码就会发现,LinkedList 并没有直接重写 Iterable 接口的 iterator 方法,而是由它的父类 AbstractSequentialList 来完成。

1
2
3
java复制代码public Iterator<E> iterator() {
return listIterator();
}

LinkedList 重写了 listIterator 方法:

1
2
3
4
java复制代码public ListIterator<E> listIterator(int index) {
checkPositionIndex(index);
return new ListItr(index);
}

这里我们发现了一个新的迭代器 ListIterator,它继承了 Iterator 接口,在遍历List 时可以从任意下标开始遍历,而且支持双向遍历。

1
2
3
4
5
6
java复制代码public interface ListIterator<E> extends Iterator<E> {
boolean hasNext();
E next();
boolean hasPrevious();
E previous();
}

我们知道,集合(Collection)不仅有 List,还有 Map 和 Set,那 Iterator 不仅支持 List,还支持 Set,但 ListIterator 就只支持 List。

那可能有些小伙伴会问:为什么不直接让 List 实现 Iterator 接口,而是要用内部类来实现呢?

这是因为有些 List 可能会有多种遍历方式,比如说 LinkedList,除了支持正序的遍历方式,还支持逆序的遍历方式——DescendingIterator:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码private class DescendingIterator implements Iterator<E> {
private final ListItr itr = new ListItr(size());
public boolean hasNext() {
return itr.hasPrevious();
}
public E next() {
return itr.previous();
}
public void remove() {
itr.remove();
}
}

可以看得到,DescendingIterator 刚好利用了 ListIterator 向前遍历的方式。可以通过以下的方式来使用:

1
2
3
java复制代码Iterator it = list.descendingIterator();
while (it.hasNext()) {
}

好了,关于Iterator与Iterable我们就先聊这么多,总结两点:

  • 学会深入思考,一点点抽丝剥茧,多想想为什么这样实现,很多问题没有自己想象中的那么复杂。
  • 遇到疑惑不放弃,这是提升自己最好的机会,遇到某个疑难的点,解决的过程中会挖掘出很多相关的东西。

这是《Java 程序员进阶之路》专栏的第 62 篇。Java 程序员进阶之路,风趣幽默、通俗易懂,对 Java 初学者极度友好和舒适😘,内容包括但不限于 Java 语法、Java 集合框架、Java IO、Java 并发编程、Java 虚拟机等核心知识点。

github.com/itwanger/to…

哇塞!离线版的 PDF 也非常的漂亮~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JDK 与 Cglib 的使用和对比

发表于 2021-10-26

Spring AOP 依靠 JDK 和 CGLib 进行动态代理实现。在此对两种实现方式的一些知识进行整理。

JDK

使用示例

1
2
3
4
5
6
7
Java复制代码/**
* 需要被代理的接口
*/
interface Iinterface {

String proxyMethod(String gift);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Java复制代码/**
* 实现 InvocationHandler 接口,对 invoke 方法进行重写
*/
class MyHandler implements InvocationHandler {

/**
* @param proxy 生成的代理实例
* @param method 被代理类的方法
* @param args 传入的参数列表
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

// 对应被代理的方法
if (method.getName().equals("proxyMethod")) {
System.out.println("接受到参数:" + args[0]);
return "返回值";
}
return null;
}
}
1
2
3
4
5
6
7
8
9
10
11
Java复制代码public class Main {
public static void main(String[] args) {
Iinterface proxy = (Iinterface) Proxy.newProxyInstance(
Thread.currentThread().getContextClassLoader(),
new Class[]{Iinterface.class},
new MyHandler()
);
String res = proxy.proxyMethod("传入的参数");
System.out.println(res);
}
}

可见代理成功。

概括一下,动态代理的方式一般为:

  • 继承 InvocationHandler ,重写方法 invoke
  • 执行 Proxy.newProxyInstance 生成动态代理类

invoke 方法

由上可以看出,proxy 成功对 Iinterface 接口进行代理,但是在使用时,我们并未见到 InvocationHandler 中 invoke 方法的调用,动态代理是如何执行 invoke 的呢?

采用其他资料生成的案例,以下是代理类的反编译代码。

参考链接:链接1 链接2

首先来看看代理类的继承关系:

可以看到代理类继承了 Proxy ,再来看看代理类中的方法调用,其中 teach() 是被代理接口的方法声明,内部只是简单地调用了父类即 Proxy 的 h 属性的 invoke 方法

再回看 Proxy 类的属性

可以猜测,对被代理方法进行调用时,会转而由被代理类中继承自 Proxy 的 InvocationHandler 执行,从而 invoke方法被调用。再来看看调用的 newProxyInstance 的逻辑,代码进行了省略,添加了简单注释

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h){
// 创建代理类$Proxy0,实现了传入的 intfs 接口,并继承了 Proxy
Class<?> cl = getProxyClass0(loader, intfs);
//...
// 获取构造方法
final Constructor<?> cons = cl.getConstructor(constructorParams);
//...
//调用具有 InvocationHandler 的构造方法,创建代理类并返回
return cons.newInstance(new Object[]{h});
// ...
}

CGLib

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码class Hello {

public String hello(String arg) {
System.out.println("获取到参数: " + arg);
return "返回参数";
}

/**
* 通过 final 修饰,该方法不能被子类覆盖,因此 CGLib 无法代理
*/
final public String helloFinal() {
System.out.println("helloFinal");
return null;
}
}
1
2
3
4
5
6
7
8
9
10
java复制代码class MyInterceptor implements MethodInterceptor {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
System.out.println("代理执行前");
String res = (String) methodProxy.invokeSuper(o, objects);
System.out.println(res);
System.out.println("代理执行后");
return null;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码public class Main {
public static void main(String[] args) {
// 通过CGLIB动态代理获取代理对象的过程
Enhancer enhancer = new Enhancer();
// 设置enhancer对象的父类
enhancer.setSuperclass(Hello.class);
// 设置enhancer的回调对象
enhancer.setCallback(new MyInterceptor());
// 创建代理对象
Hello proxy= (Hello)enhancer.create();
// 通过代理对象调用目标方法
proxy.hello("参数");
}
}

概括一下,CGLib 动态代理的方式一般为:

  • 构造 Enhancer
  • 设置代理对象(作为父类)
  • 设置代理策略
  • 创建代理对象

Callback

参考链接:blog.csdn.net/zhang662205…

Callback 可以理解成生成的代理类的方法被调用时会执行的逻辑,具有以下六种方式:

  • NoOp:不做任何操作
  • FixedValue:要求实现接口的 loadObjecd 方法,重写了被代理类的响应方法,同时,要求返回值和方法返回值相同,否则会抛出类型转换异常。此方式看不到人喝原方法的信息,也无法调用原方法。
  • MethodInterceptor:类似 AOP 的环绕增强,代理类的方法调用都会转入执行该接口的 intercept 方法。需要执行原方法可以使用参数 method 进行反射调用,或者使用参数 proxy(proxy会快一些)
  • InvocationHandler:类似 MethodInterceptor,若自定义该接口的 invoke 方法,需要注意参数 method 的 invoke 方法,会无限循环调用
  • LazyLoader:调用时,返回一个代理对象并存储负责所有的该代理类调用,类似 Spring 的 singleton
  • Dispatcher:每次调用都会返回一个新的代理类,类似 Spring 的 prototye

JDK 与 Cglib 的对比

JDK Cglib
代理目标 接口 类(被代理类会作为父类,无法处理 final)
实现 反射机制 运行过程中修改被代理类的 class 文件字节码
Spring采用策略 目标对象实现接口时采用 目标对象未实现接口时采用
  • 默认为 JDK ,使用时可以进行指定

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

推荐一个SpringBoot + Vue + MyBatis

发表于 2021-10-26

项目说明

本音乐网站的客户端和管理端使用 VUE 框架来实现,服务端使用 Spring Boot + MyBatis 来实现,数据库使用了 MySQL。

项目功能

  • 音乐播放
  • 用户登录注册
  • 用户信息编辑、头像修改
  • 歌曲、歌单搜索
  • 歌单打分
  • 歌单、歌曲评论
  • 歌单列表、歌手列表分页显示
  • 歌词同步显示
  • 音乐收藏、下载、拖动控制、音量控制
  • 后台对用户、歌曲、歌手、歌单信息的管理

技术栈

后端

SpringBoot + MyBatis

前端

Vue + Vue-Router + Vuex + Axios + ElementUI

开发环境

  • JDK:jdk-8u141
  • mysql:mysql-5.7.21-1-macos10.13-x86_64
  • node:v12.4.0
  • IDE:IntelliJ IDEA 2018、VSCode

项目预览





后台管理



推荐:手把手带你写一个博客系统(附视频教程)

项目源码

github.com/Yin-Hongwei/music-website

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java设计模式-策略模式(优化过多if/switch语句)

发表于 2021-10-26

简介

策略模式是指有一定行动内容的相对稳定的策略名称,策略模式作为一种软件设计模式,指对象有某个行为,但是在不同的场景中,该行为有不同的实现算法

策略模式:

  • 定义了一组算法(业务规则)
  • 封装了每个算法
  • 这族的算法可互换代替

组成

  • 抽象策略角色: 策略类,通常由一个接口或者抽象类实现
  • 具体策略角色:包装了相关的算法和行为
  • 环境角色:持有一个策略类的引用,最终给客户端调用

应用场景

  1. 多个类只区别在表现行为不同,可以使用Strategy模式,在运行时动态选择具体要执行的行为
  2. 需要在不同情况下使用不同的策略(算法),或者策略还可能在未来用其它方式来实现
  3. 对客户隐藏具体策略(算法)的实现细节,彼此完全独立
  4. 一个类定义了多种行为,并且这些行为在类的操作中以多个条件语句的形式出现

优点:

  • 策略模式符合开闭原则
  • 避免使用多重条件转移语句,如if…else…语句、switch 语句
  • 使用策略模式可以提高算法的保密性和安全性

缺点:

  • 客户端必须知道所有的策略,并且自行决定使用哪一个策略类
  • 代码中会产生非常多策略类,增加维护难度

实际应用

抽象策略类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/11
*/
public interface ChargeStrategy {

/**
* 计算收费
*
* @param cost
* @return
*/
double charge(long cost);
}

具体策略类-内部收费类

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/11
*/
@TaxTypeAnnotation(taxType = ChargeType.INTERNAL)
class InternalStrategy implements ChargeStrategy {
@Override
public double charge(long cost) {
final double taxRate = 0.2;
return cost * taxRate;
}
}

具体策略类-外部收费类

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/11
*/
@TaxTypeAnnotation(taxType = ChargeType.EXTERNAL)
class ExternalTaxStrategy implements ChargeStrategy {
@Override
public double charge(long cost) {

return cost;
}
}

收费类型定义

1
2
3
arduino复制代码public enum ChargeType {
INTERNAL, EXTERNAL
}

策略工厂

通过if-else获取策略类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/11
*/
@Component
public class ChargeStrategyFactory {
public static ChargeStrategy getChargeStrategy(ChargeType taxType) throws Exception {

if (taxType == ChargeType.INTERNAL) {
return new InternalStrategy();
} else if (taxType == ChargeType.EXTERNAL) {
return new ExternalTaxStrategy();
} else {
throw new Exception("未配置相应策略");
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ini复制代码@Test
public void test1(){
try {
ChargeStrategy chargeStrategy = ChargeStrategyFactory.getChargeStrategy(ChargeType.INTERNAL);
double charge = chargeStrategy.charge(100);
System.out.println("内部价格:"+charge);

chargeStrategy = ChargeStrategyFactory.getChargeStrategy(ChargeType.EXTERNAL);
charge = chargeStrategy.charge(100);
System.out.println("外部价格:"+charge);

} catch (Exception e) {
e.printStackTrace();
}
}

image.png

  • 如图,通过传入不同的收费类型,由策略工厂进行判断,赋予相应的处理策略
  • 可以看到,如果通过if语句来获取不同的税策略,当增加新的税策略时就不得不修改已有代码,当方法很多时,就不那么好看,同时也增加了复杂度

通过Map获取策略类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
php复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/11
*/
public class MapChargeStrategyFactory {

/**
*存储策略
*/
static Map<ChargeType, ChargeStrategy> chargeStrategyMap = new HashMap<>();

// 注册默认策略
static {
registerChargeStrategy(ChargeType.INTERNAL, new InternalStrategy());
registerChargeStrategy(ChargeType.EXTERNAL, new ExternalTaxStrategy());
}

/**
* 提供注册策略接口,外部只需要调用此接口接口新增策略
*/
public static void registerChargeStrategy(ChargeType taxType, ChargeStrategy taxStrategy) {
chargeStrategyMap.put(taxType, taxStrategy);
}

/**
* 通过map获取策略,当增加新的策略时无需修改代码
*/

public static ChargeStrategy getChargeStrategy(ChargeType taxType) throws Exception {
// 当增加新的税类型时,需要修改代码,同时增加圈复杂度
if (chargeStrategyMap.containsKey(taxType)) {
return chargeStrategyMap.get(taxType);
} else {
throw new Exception("未配置相应策略");
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ini复制代码@Test
public void test2(){
try {
ChargeStrategy chargeStrategy = MapChargeStrategyFactory.getChargeStrategy(ChargeType.INTERNAL);
double charge = chargeStrategy.charge(100);
System.out.println("内部价格:"+charge);

chargeStrategy = MapChargeStrategyFactory.getChargeStrategy(ChargeType.EXTERNAL);
charge = chargeStrategy.charge(100);
System.out.println("外部价格:"+charge);

} catch (Exception e) {
e.printStackTrace();
}
}

image.png

  • 可以看到,通过map进行获取策略结果相同,进化后if语句没有了,减少了复杂度,增加新的策略后只需调用策略注册接口就好,不需要修改获取策略的代码

通过策略自动注册获取策略类

策略类添加自动注册策略的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/11
*/
public interface ChargeStrategy {

/**
* 计算收费
*
* @param cost
* @return
*/
double charge(long cost);

/**
* 自动注册策略方法
*/
void register();
}

具体策略实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/11
*/
@TaxTypeAnnotation(taxType = ChargeType.EXTERNAL)
class ExternalTaxStrategy implements ChargeStrategy {
@Override
public double charge(long cost) {

return cost;
}

@Override
public void register() {
AutoRegisterChargeStrategyFactory.registerChargeStrategy(ChargeType.EXTERNAL, this);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/11
*/
@TaxTypeAnnotation(taxType = ChargeType.INTERNAL)
class InternalStrategy implements ChargeStrategy {
@Override
public double charge(long cost) {
final double taxRate = 0.2;
return cost * taxRate;
}

@Override
public void register() {
AutoRegisterChargeStrategyFactory.registerChargeStrategy(ChargeType.INTERNAL, this);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
php复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/11
*/
public class AutoRegisterChargeStrategyFactory {

/**
* 存储策略
*/
static Map<ChargeType, ChargeStrategy> chargeStrategyMap = new HashMap<>();

static {
autoRegisterTaxStrategy();
}

/**
* 通过map获取策略,当增加新的策略时无需修改代码
*/
public static ChargeStrategy getChargeStrategy(ChargeType taxType) throws Exception {
// 当增加新的税类型时,需要修改代码,同时增加圈复杂度
if (chargeStrategyMap.containsKey(taxType)) {
return chargeStrategyMap.get(taxType);
} else {
throw new Exception("未配置相应策略");
}
}

/**
* 提供税注册策略接口
*/
public static void registerChargeStrategy(ChargeType chargeType, ChargeStrategy chargeStrategy) {
chargeStrategyMap.put(chargeType, chargeStrategy);
}

/**
* 自动注册策略
*/

private static void autoRegisterTaxStrategy() {
try {
// 通过反射找到所有的策略子类进行注册
Reflections reflections = new Reflections(new ConfigurationBuilder()
.setUrls(ClasspathHelper.forPackage(ChargeStrategy.class.getPackage().getName()))
.setScanners(new SubTypesScanner()));
Set<Class<? extends ChargeStrategy>> taxStrategyClassSet = reflections.getSubTypesOf(ChargeStrategy.class);

if (taxStrategyClassSet != null) {
for (Class<?> clazz : taxStrategyClassSet) {
ChargeStrategy chargeStrategy = (ChargeStrategy) clazz.newInstance();
// 调用策略的自注册方法
chargeStrategy.register();
}
}
} catch (InstantiationException | IllegalAccessException e) {
// 自行定义异常处理
e.printStackTrace();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ini复制代码@Test
public void test3(){
try {
ChargeStrategy chargeStrategy = AutoRegisterChargeStrategyFactory.getChargeStrategy(ChargeType.INTERNAL);
double charge = chargeStrategy.charge(100);
System.out.println("内部价格:"+charge);

chargeStrategy = AutoRegisterChargeStrategyFactory.getChargeStrategy(ChargeType.EXTERNAL);
charge = chargeStrategy.charge(100);
System.out.println("外部价格:"+charge);

} catch (Exception e) {
e.printStackTrace();
}
}

image.png

  • 可以看到,通过策略自动注册进行获取策略结果相同,当添加新的税策略时,就完全不需要修改已有的策略工厂代码,基本完美做到开闭原则,唯一需要修改的是类型定义

通过注解减少耦合

新增加自定义注解类

1
2
3
4
5
6
7
8
9
10
11
less复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/26
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface ChargeTypeAnnotation {
ChargeType taxType();
}

策略去掉了注册方法,添加ChargeTypeAnnotation注解来识别是哪种税类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/11
*/
public interface ChargeStrategy {

/**
* 计算收费
*
* @param cost
* @return
*/
double charge(long cost);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/11
*/
@ChargeTypeAnnotation(taxType = ChargeType.INTERNAL)
class InternalStrategy implements ChargeStrategy {
@Override
public double charge(long cost) {
final double taxRate = 0.2;
return cost * taxRate;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/11
*/
@ChargeTypeAnnotation(taxType = ChargeType.EXTERNAL)
class ExternalTaxStrategy implements ChargeStrategy {
@Override
public double charge(long cost) {

return cost;
}
}

注解类策略工厂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
php复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/26
*/
public class AnnotationChargeStrategyFactory {

/**
* 存储策略
*/
static Map<ChargeType, ChargeStrategy> chargeStrategyMap = new HashMap<>();

static {
registerChargeStrategy();
}

/**
* 通过map获取策略,当增加新的策略时无需修改代码
*/
public static ChargeStrategy getChargeStrategy(ChargeType chargeType) throws Exception {
// 当增加新的类型时,需要修改代码
if (chargeStrategyMap.containsKey(chargeType)) {
return chargeStrategyMap.get(chargeType);
} else {
throw new Exception("未配置相应策略");
}
}

/**
* 自动注册策略
*/

private static void registerChargeStrategy() {
// 通过反射找到所有的策略子类进行注册
Reflections reflections = new Reflections(new ConfigurationBuilder()
.setUrls(ClasspathHelper.forPackage(ChargeStrategy.class.getPackage().getName()))
.setScanners(new SubTypesScanner()));
Set<Class<? extends ChargeStrategy>> taxStrategyClassSet = reflections.getSubTypesOf(ChargeStrategy.class);

if (taxStrategyClassSet != null) {
for (Class<?> clazz : taxStrategyClassSet) {
// 找到类型注解,自动完成策略注册
if (clazz.isAnnotationPresent(ChargeTypeAnnotation.class)) {
ChargeTypeAnnotation taxTypeAnnotation = clazz.getAnnotation(ChargeTypeAnnotation.class);
ChargeType chargeType = taxTypeAnnotation.taxType();
try {
chargeStrategyMap.put(chargeType, (ChargeStrategy) clazz.newInstance());
} catch (InstantiationException | IllegalAccessException e) {
e.getStackTrace();
}
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ini复制代码@Test
public void test4(){
try {
ChargeStrategy chargeStrategy = AnnotationChargeStrategyFactory.getChargeStrategy(ChargeType.INTERNAL);
double charge = chargeStrategy.charge(100);
System.out.println("内部价格:"+charge);

chargeStrategy = AnnotationChargeStrategyFactory.getChargeStrategy(ChargeType.EXTERNAL);
charge = chargeStrategy.charge(100);
System.out.println("外部价格:"+charge);

} catch (Exception e) {
e.printStackTrace();
}
}

image.png

  • 可以看到,通过注解方式确定策略类的方式获取策略结果相同,是代码更加简洁。

注意:
测试阶段这么写不会出问题,但是小编在通过优化代码时,调用接口,发现这样会导致注入的对象为null值,从而导致程序报空指针异常,起初没发现,不知道为什么按照正常程序开发,会导致通过autowired方式注入为null,经过查阅资料和断点代码,发现了问题所在

image.png

  • 图中框住的部分,通过反射代码获取类的时候,我们获得类的方式是 class.newInstance, 这种写法没有与Spring容器关联起来获取bean,虽然也能拿到但是如果类里面有@Autowired这种方式注入的对象就会空了
解决方式

通过spring上下文获得类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
typescript复制代码@Service
public class ApplicationContextHelper implements ApplicationContextAware {

private static ApplicationContext applicationContext;

@Override
public void setApplicationContext(ApplicationContext context) throws BeansException {

applicationContext = context;
}

/**
* 获取bean
* @param clazz
* @param <T>
* @return
*/
public static <T> T popBean(Class<T> clazz) {
//先判断是否为空
if (applicationContext == null) {
return null;
}
return applicationContext.getBean(clazz);
}


public static <T> T popBean(String name, Class<T> clazz) {
if (applicationContext == null) {
return null;
}

return applicationContext.getBean(name, clazz);

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
php复制代码/**
* @author feiniu
* @description 描述
* @date 2021/10/26
*/
public class AnnotationChargeStrategyFactory {

/**
* 存储策略
*/
static Map<ChargeType, ChargeStrategy> chargeStrategyMap = new HashMap<>();

static {
registerChargeStrategy();
}

/**
* 通过map获取策略,当增加新的策略时无需修改代码
*/
public static ChargeStrategy getChargeStrategy(ChargeType chargeType) throws Exception {
// 当增加新的类型时,需要修改代码
if (chargeStrategyMap.containsKey(chargeType)) {
return chargeStrategyMap.get(chargeType);
} else {
throw new Exception("未配置相应策略");
}
}

/**
* 自动注册策略
*/

private static void registerChargeStrategy() {
// 通过反射找到所有的策略子类进行注册
Reflections reflections = new Reflections(new ConfigurationBuilder()
.setUrls(ClasspathHelper.forPackage(ChargeStrategy.class.getPackage().getName()))
.setScanners(new SubTypesScanner()));
Set<Class<? extends ChargeStrategy>> taxStrategyClassSet = reflections.getSubTypesOf(ChargeStrategy.class);

if (taxStrategyClassSet != null) {
for (Class<?> clazz : taxStrategyClassSet) {
// 找到类型注解,自动完成策略注册
if (clazz.isAnnotationPresent(ChargeTypeAnnotation.class)) {
ChargeTypeAnnotation taxTypeAnnotation = clazz.getAnnotation(ChargeTypeAnnotation.class);
ChargeType chargeType = taxTypeAnnotation.taxType();
chargeStrategyMap.put(chargeType, (ChargeStrategy) ApplicationContextHelper.popBean(clazz));
}
}
  • 通过这种方式就可以解决上述问题,完成自动注册策略的优化
  • 小编这篇文章是通过日常开发当中遇到的问题,然后通过查前人写过的代码和资料,完成优化,多看一些好的代码能解决许多问题,大家一定要多多学习,多多进步
今天的分享就到这里啦,希望能给您带来帮助!!!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Mybatis传参类型如何确定?

发表于 2021-10-26

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

最近有小伙伴在讨论#{}与${}的区别时,有提到#{}是用字符串进行替换,就我个人的理解,它的主要作用是占位,最终替换的结果并不一定是字符串方式,比如我们传参类型是整形时,最终拼接的sql,传参讲道理也应该是整形,而不是字符串的方式

接下来我们来看一下,mapper接口中不同的参数类型,最终拼接sql中是如何进行替换的

I. 环境配置

我们使用SpringBoot + Mybatis + MySql来搭建实例demo

  • springboot: 2.2.0.RELEASE
  • mysql: 5.7.22

1. 项目配置

1
2
3
4
5
6
7
8
9
10
11
xml复制代码<dependencies>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
</dependencies>

核心的依赖mybatis-spring-boot-starter,至于版本选择,到mvn仓库中,找最新的

另外一个不可获取的就是db配置信息,appliaction.yml

1
2
3
4
5
yaml复制代码spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/story?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password:

2. 数据库表

用于测试的数据库

1
2
3
4
5
6
7
8
9
10
sql复制代码CREATE TABLE `money` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(20) NOT NULL DEFAULT '' COMMENT '用户名',
`money` int(26) NOT NULL DEFAULT '0' COMMENT '钱',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0',
`create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=551 DEFAULT CHARSET=utf8mb4;

测试数据,主要是name字段,值为一个数字的字符串

1
2
3
sql复制代码INSERT INTO `money` (`id`, `name`, `money`, `is_deleted`, `create_at`, `update_at`)
VALUES
(120, '120', 200, 0, '2021-05-24 20:04:39', '2021-09-27 19:21:40');

II. 传参类型确定

本文忽略掉mybatis中的po、mapper接口、xml文件的详情,有兴趣的小伙伴可以直接查看最下面的源码(或者查看之前的博文也可以)

1. 参数类型为整形

针对上面的case,定义一个根据name查询数据的接口,但是这个name参数类型为整数

mapper接口:

1
2
3
4
5
6
java复制代码/**
* int类型,最终的sql中参数替换的也是int
* @param name
* @return
*/
List<MoneyPo> queryByName(@Param("name") Integer name);

对应的xml文件如下

1
2
3
xml复制代码<select id="queryByName" resultMap="BaseResultMap">
select * from money where `name` = #{name}
</select>

上面这个写法非常常见了,我们现在的问题就是,传参为整数,那么最终的sql是 name = 120 还是 name = '120'呢?

那么怎么确定最终生成的sql是啥样的呢?这里介绍一个直接输出mysql执行sql日志的方式

在mysql服务器上执行下面两个命令,开启sql执行日志

1
2
bash复制代码set global general_log = "ON";
show variables like 'general_log%';

当我们访问上面的接口之后,会发现最终发送给mysql的sql语句中,参数替换之后依然是整数

1
sql复制代码select * from money where `name` = 120

2. 指定jdbcType

在使用#{}, ${}时,有时也会看到除了参数之外,还会指定jdbcType,那么我们在xml中指定这个对最终的sql生成会有影响么?

1
2
3
xml复制代码<select id="queryByNameV2" resultMap="BaseResultMap">
select * from money where `name` = #{name, jdbcType=VARCHAR} and 0=0
</select>

生成的sql如下

1
sql复制代码select * from money where `name` = 120 and 0=0

从实际的sql来看,这个jdbcType并没有影响最终的sql参数拼接,那它主要是干嘛用呢?(它主要适用于传入null时,类型转换可能出现的异常)

3. 传参类型为String

当我们传参类型为string时,最终的sql讲道理应该会带上引号

1
2
3
4
5
6
java复制代码/**
* 如果传入的参数类型为string,会自动带上''
* @param name
* @return
*/
List<MoneyPo> queryByNameV3(@Param("name") String name);

对应的xml

1
2
3
xml复制代码<select id="queryByNameV3" resultMap="BaseResultMap">
select * from money where `name` = #{name, jdbcType=VARCHAR} and 1=1
</select>

上面这个最终生成的sql如下

1
sql复制代码select * from money where `name` = '120' and 1=1

4. TypeHandler实现参数替换强制添加引号

看完上面几节,基本上可以有一个得出一个简单的推论(当然对不对则需要从源码上分析了)

  • sql参数替换,最终并不是简单使用字符串来替换,实际上是由参数java的参数类型决定,若java参数类型为字符串,拼接的sql为字符串格式;传参为整型,拼接的sql也是整数

那么问题来了,为什么要了解这个?

  • 关键点在于索引失效的问题

比如本文实例中的name上添加了索引,当我们的sql是 select * from money where name = 120 会走不了索引,如果想走索引,要求传入的参数必须是字符串,不能出现隐式的类型转换

基于此,我们就有一个应用场景了,为了避免由于传参类型问题,导致走不了索引,我们希望name的传参,不管实际传入参数类型是什么,最终拼接的sql,都是字符串的格式;

我们借助自定义的TypeHandler来实现这个场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
java复制代码@MappedTypes(value = {Long.class, Integer.class})
@MappedJdbcTypes(value = {JdbcType.CHAR, JdbcType.VARCHAR, JdbcType.LONGVARCHAR})
public class StrTypeHandler extends BaseTypeHandler<Object> {

/**
* java 类型转 jdbc类型
*
* @param ps
* @param i
* @param parameter
* @param jdbcType
* @throws SQLException
*/
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, String.valueOf(parameter));
}

/**
* jdbc类型转java类型
*
* @param rs
* @param columnName
* @return
* @throws SQLException
*/
@Override
public Object getNullableResult(ResultSet rs, String columnName) throws SQLException {
return rs.getString(columnName);
}

@Override
public Object getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return rs.getString(columnIndex);
}

@Override
public Object getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return cs.getString(columnIndex);
}
}

然后在xml中,指定TypeHandler

1
2
3
4
5
6
java复制代码/**
* 通过自定义的 TypeHandler 来实现 java <-> jdbc 类型的互转,从而实现即时传入的是int/long,也会转成String
* @param name
* @return
*/
List<MoneyPo> queryByNameV4(@Param("name") Integer name);
1
2
3
xml复制代码<select id="queryByNameV4" resultMap="BaseResultMap">
select * from money where `name` = #{name, jdbcType=VARCHAR, typeHandler=com.git.hui.boot.mybatis.handler.StrTypeHandler} and 2=2
</select>

上面这种写法输出的sql就会携带上单引号,这样就可以从源头上解决传参类型不对,导致最终走不了索引的问题

1
sql复制代码select * from money where `name` = '120' and 2=2

5. 小结

本文通过一个简单的实例,来测试Mapper接口中,不同的参数类型,对最终的sql生成的影响

  • 参数类型为整数时,最终的sql的参数替换也是整数(#{}并不是简单的字符串替换哦)
  • 参数类型为字符串时,最终的sql参数替换,会自动携带'' (${}注意它不会自动带上单引号,需要自己手动添加)

当我们希望不管传参什么类型,最终生成的sql,都是字符串替换时,可以借助自定义的TypeHandler来实现,这样可以从源头上避免因为隐式类型转换导致走不了索引问题

最后疑问来了,上面的结论靠谱么?mybatis中最终的sql是在什么地方拼接的?这个sql拼接的流程是怎样的呢?

关于sql的拼接全流程,后续博文即将上线,我是一灰灰,走过路过的各位大佬帮忙点个赞、价格收藏、给个评价呗

III. 不能错过的源码和相关知识点

0. 项目

  • 工程:github.com/liuyueyi/sp…
  • 源码:github.com/liuyueyi/sp…

系列博文:

  • 【DB系列】Mybatis系列教程之CURD基本使用姿势
  • 【DB系列】Mybatis系列教程之CURD基本使用姿势-注解篇
  • 【DB系列】Mybatis之参数传递的几种姿势
  • 【DB系列】Mybatis之转义符的使用姿势

1. 微信公众号: 一灰灰Blog

尽信书则不如无书,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

  • 一灰灰Blog个人博客 blog.hhui.top
  • 一灰灰Blog-Spring专题博客 spring.hhui.top

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【六大设计原则】迪米特法则

发表于 2021-10-26

六大设计原则

单一职责原则

里氏替换原则

依赖倒置原则

接口隔离原则

迪米特法则

开放封闭原则

定义

一个对象应当对其他对象尽可能少的了解。

示例

Tom 和 David 是朋友,David 和 Eva 是朋友, Tom想和 Eva 做朋友的话必须通过 David 认识。

示例一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
typescript复制代码public class Tom {

public void play(David david){
david.play();
}

public void play(Eva eva) {
eva.play();
}
}

public class David {

public void play(){
System.out.println("和 David 成为朋友");
}
}

public class Eva {

public void play(){
System.out.println("和 Eva 成为朋友");
}
}

public class Test {

public static void main(String[] args) {
Tom tom = new Tom();
David david = new David();
Eva eva = new Eva();

tom.play(david);
tom.play(eva);
}
}

image.png

此示例实现了和Eva成为了朋友,但是Tom直接关联了Eva。

示例二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
csharp复制代码public class Tom {

public void play(David david){
david.play();
Eva eva = david.leadEva();
eva.play();
}
}

public class David {

public void play(){
System.out.println("和 David 成为朋友");
}

public Eva leadEva() {
System.out.println("带领 Eva 过来");
Eva eva = new Eva();
return eva;
}
}

public class Eva {

public void play(){
System.out.println("和 Eva 成为朋友");
}
}

public class Test {

public static void main(String[] args) {
Tom tom = new Tom();
David david = new David();
tom.play(david);
}
}

image.png

此方案实现了通过 David 和 Eva 成为了朋友,但是 Tom 中包含了对 Eva 的引用,不符合迪米特法则。

示例三

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
typescript复制代码public class Tom {

private David david;

public David getFriend() {
return david;
}

public void setFriend(David david) {
this.david = david;
}

public void play(David david){
david.play();
}
}

public class David {

public void play(){
System.out.println("和 David 成为朋友");
playWithStranger();
}

public void playWithStranger() {
Eva eva = new Eva();
eva.play();
}
}

public class Eva {

public void play(){
System.out.println("和 Eva 成为朋友");
}
}

public class Test {

public static void main(String[] args) {
Tom tom = new Tom();
David david = new David();
tom.setFriend(david);
tom.play(david);
}
}

image.png

此方案实现了与 Eva 毫无联系,并且成为了朋友。

示例四

结合依赖倒转原则,为 Eva 定义一个抽象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
csharp复制代码public class Tom {

private David david;

private Eva eva;

public Tom(David david, Eva eva) {
this.david = david;
this.eva = eva;
}

public David getDavid() {
return david;
}

public void setDavid(David david) {
this.david = david;
}

public Eva getEva() {
return eva;
}

public void setEva(Eva eva) {
this.eva = eva;
}

public void play(){
david.play();
eva.play();
}
}

public class David {

public void play(){
System.out.println("和 David 成为朋友");
}
}

public abstract class AbstractEva {

public abstract void play();
}

public class Eva extends AbstractEva {

public void play(){
System.out.println("和 Eva 成为朋友");
}
}

public class Test {

public static void main(String[] args) {
Tom tom = new Tom(new David(), new Eva());
tom.play();
}
}

image.png

和 Tom 直接通信的是 Eva 的抽象父类,和 Eva 具体实现没有直接关系,所以符合迪米特法则。

总结

迪米特法则的核心观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才可以提升上去。但是,这也会造成系统的不同模块之间的通信效率降低,也会使系统的不同模块之间不容易协调。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

CentOS系统限制普通用户切换到root管理员账号

发表于 2021-10-26

概述

默认的情况下,普通用户通过su命令且输入了正确的root密码,就可以登录到root用户,对系统进行管理和配置。为了加强系统的完全性,可以使用Linux特殊的wheel用户组来实现限制普通用户切换到root管理员用户,只有加入到wheel组,才可以使用su切换到root管理员用户。

详细信息

  1. 依次执行如下命令,添加两个用户,分别用于加入wheel和不加入wheel。

1
2
复制代码useradd abc1
useradd abc2
  1. 参考如下命令,修改abc1和abc2的密码。

1
2
复制代码passwd abc1
passwd abc2
  1. 执行如下命令,将abc1用户加入wheel组。

1
复制代码usermod -g wheel abc1
  1. 编辑/etc/pam.d/su文件,找到如下配置所在行,去掉前面的“#”。

1
arduino复制代码#auth required pam_wheel.so use_uid

系统显示类似如下。


5. 使用su命令登录root用户,确认abc1成功登录,abc2登录没成功。

参考网址:CentOS系统限制普通用户切换到root管理员账号 (aliyun.com)

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

MYSQL进阶之体系结构和InnoDB存储引擎 MYSQL

发表于 2021-10-26

MYSQL 基础架构

MySQL基本架构图

image.png

大体来说,MySQL 可以分为 Server 层和存储引擎层两部分。

  • Server 层包括连接器、查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。
+ 连接器


连接器就是你连接到数据库时使用的,负责跟客户端建立连接、获取权限、维持和管理连接。  

命令: `mysql -h$ip -P$port -u$user -p`,回车后输密码,也可以在 -p 后面输入密码,但是有密码泄露的风险。


`show processlist`,可以查看连接的情况,Command 列中有一个 Sleep 表示连接空闲。  

![image.png](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/26da2db09ffbf40cf2925559d9d856677836e4273e30e0a8c6833973c1ac7e54)
空闲连接默认8小时会被断开,可以由wait\_timeout参数配置。


在数据库中,长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个连接。短连接则是指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个。


由于建立连接比较耗资源,所以建议尽量使用长连接,但是使用长连接后,MySQL 占用内存涨得特别快,这是因为 MySQL 在执行过程中临时使用的内存是管理在连接对象里面的。这些资源会在连接断开的时候才释放。所以如果长连接累积下来,可能导致内存占用太大,被系统强行杀掉(OOM),从现象看就是 MySQL 异常重启了。


解决方案:


    1. 定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
    2. 如果你用的是 MySQL 5.7 或更新版本,可以在每次执行一个比较大的操作后,通过执行 `mysql_reset_connection` 来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。
+ 查询缓存  

查询缓存是将之前执行过的语句及其结果以 `key-value` 对的形式缓存在内存中。key 是查询的语句,value 是查询的结果。如果你的查询能够直接在这个缓存中找到 key,那么这个 value 就会被直接返回给客户端。
查询缓存在MYSQL8时被移除了,由于查询缓存失效频繁,命中率低。
+ 分析器  

分析器先会做“词法分析”,识别出里面的字符串分别是什么,代表什么。然后需要做“语法分析”,判断你输入的这个 SQL 语句是否满足 MySQL 语法。
+ 优化器
+ 执行器
  • 存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。

一条 Select 语句执行流程

image.png
上图以 InnoDB 存储引擎为例,处理过程如下:

  • 用户发送请求到 tomcat ,通过 tomcat 链接池和 mysql 连接池建立连接,然后通过连接发送 SQL 语句到 MySQL;
  • MySQL 有一个单独的监听线程,读取到请求数据,得到连接中请求的SQL语句;
  • 将获取到的SQL数据发送给SQL接口去执行;
  • SQL接口将SQL发送给SQL解析器进行解析;
  • 将解析好的SQL发送给查询优化器,找到最优的查询路劲,然后发给执行器;
  • 执行器根据优化后的执行方案调用存储引擎的接口按照一定的顺序和步骤进行执行。

举个例子,比如执行器可能会先调用存储引擎的一个接口,去获取“users”表中的第一行数据,然后判断一下这个数据的 “id”字段的值是否等于我们期望的一个值,如果不是的话,那就继续调用存储引擎的接口,去获取“users”表的下一行数据。 就是基于上述的思路,执行器就会去根据我们的优化器生成的一套执行计划,然后不停的调用存储引擎的各种接口去完成SQL 语句的执行计划,大致就是不停的更新或者提取一些数据出来。

在这里涉及到几个问题:

  • MySQL驱动到底是什么东西?
    以java为例,我们们如果要在Java系统中去访问一个MySQL数据库,必须得在系统的依赖中加入一个MySQL驱动,比如在Maven里面要加上
1
2
3
4
5
xml复制代码<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.46</version>
</dependency>

那么这个MySQL驱动到底是个什么东西?其实L驱动就会在底层跟数据库建立网络连接,有网络连接,接着才能去发送请求给数据库服务器!让语言编写的系统通过MySQL驱动去访问数据库,如下图
image.png

  • 数据库连接池到底是用来干什么的?

假设用java开发一个web服务部署在tomcat上,tomcat可以多线程并发处理请求,所以首先一点就是不可能只会创建一个数据库连接(多个请求去抢一个连接,效率得多低下)。

其次,如果每个请求都去创建一个数据库连接呢? 这也是非常不好的,因为每次建立一个数据库连接都很耗时,好不容易建立好了连接,执行完了SQL语句,还把数据库连接给销毁,频繁创建和销毁带来性能问题。

所以一般使用数据库连接池,也就是在一个池子里维持多个数据库连接,让多个线程使用里面的不同的数据库连接去执行SQL语句,然后执行完SQL语句之后,不要销毁这个数据库连接,而是把连接放回池子里,后续还可以继续使用。基于这样的一个数据库连接池的机制,就可以解决多个线程并发的使用多个数据库连接去执行SQL语句的问题,而且还避免了数据库连接使用完之后就销毁的问题了。

image.png

  • MySQL数据库的连接池是用来干什么的?

MySQL数据库的连接池的作用和java应用端连接池作用一样,都是起到了复用连接的作用。
image.png

InnoDB 存储引擎

InnoDB 架构简析

image.png
从图中可见,InnoDB 存储引擎由内存池,后台线程和磁盘文件三大部分组成

再来一张突出重点的图:
image.png

但是上面这些图都太难记住了,来一张简略图:
image.png

InnoDB 存储引擎第一部分:内存结构

Buffer Pool缓冲池

InnoDB 存储引擎基于磁盘存储的,并将其中的记录按照页的方式进行管理,但是由于CPU速度和磁盘速度之间的鸿沟,基于磁盘的数据库系统通常使用缓冲池记录来提高数据库的整体性能。

在数据库进行读取操作,将从磁盘中读到的页放在缓冲池中,下次再读取相同的页中时,首先判断该页是否在缓冲池中。若在缓冲池中,称该页在缓冲池中被命中,直接读取该页,否则读取磁盘上的页。

对于数据库中页的修改操作,首先修改在缓冲池中的页,然后再以一定的频率刷新到磁盘上,页从缓冲池刷新回磁盘的操作并不是在每次页发生更新时触发,而是通过一种称为 CheckPoint 的机制刷新回磁盘。所以,缓冲池的大小直接影响着数据库的整体性能,可以通过配置参数 innodb_buffer_pool_size 来设置,缓冲池默认是128MB,还是有点小的,如果你的数据库是16核32G的机器,那么你就可以给Buffer Pool分配个2GB的内存。

由于缓冲池不是无限大的,随着不停的把磁盘上的数据页加载到缓冲池中,缓冲池总要被用完,这个时候只能淘汰掉一些缓存页,淘汰方式就使用最近最少被使用算法(LRU),具体来说就是引入一个新的LRU链表,通过这个LRU链表,就可以知道哪些缓存页是最近最少被使用的,那么当你缓存页需要腾出来一个刷入磁盘的时候,可以选择那个LRU链表中最近最少被使用的缓存页淘汰。

缓冲池中缓存的数据页类型有:索引页、数据页、undo页、插入缓冲、自适应哈希索引、InnoDB存储的锁信息和数据字典信息。

数据页和索引页

页(Page)是 Innodb 存储的最基本结构,也是 Innodb 磁盘管理的最小单位,与数据库相关的所有内容都存储在 Page 结构里。Page 分为几种类型,数据页和索引页就是其中最为重要的两种类型。

插入缓冲(Insert Buffer)

在 InnoDB 引擎上进行插入操作时,一般需要按照主键顺序进行插入,这样才能获取较高的插入性能。当一张表中存在非聚簇的不唯一的索引时,在插入时,数据页的存放还是按照主键进行顺序存放,但是对于非聚簇索引叶子节点的插入不再是顺序的了,这时就需要离散的访问非聚簇索引页,由于随机读取的存在导致插入操作性能下降。

所以 InnoDB 存储引擎开创性地设计了 Insert Buffer ,对于非聚集索引的插入或更新操作,不是每一次直接插入到索引页中,而是先判断插入的非聚集索引页是否在缓冲池中,若在,则直接插入;若不在,则先放入到一个 Insert Buffer 对象中,好似欺骗。数据库这个非聚集的索引已经插到叶子节点,而实际并没有,只是存放在另一个位置。然后再以一定的频率和情况进行 Insert Buffer 和辅助索引页子节点的 merge(合并)操作,这时通常能将多个插入合并到一个操作中(因为在一个索引页中),这就大大提高了对于非聚集索引插入的性能。

然而 Insert Buffer 的使用需要同时满足以下两个条件:

  • 索引是辅助索引( secondary index ) ;
  • 索引不是唯一( unique )的。

当满足以上两个条件时, InnoDB 存储引擎会使用 Insert Buffer ,这样就能提高插入操作的性能了。不过考虑这样一种情况:应用程序进行大量的插入操作,这些都涉及了不唯一的非聚集索引,也就是使用了 Insert Buffer。若此时 MySQL数据库发生了宕机这时势必有大量的 Insert Buffer 并没有合并到实际的非聚集索引中去。

因此这时恢复可能需要很长的时间,在极端情况下甚至需要几个小时。辅助索引不能是唯一的,因为在插入缓冲时,数据库并不去查找索引页来判断插入的记录的唯一性。如果去查找肯定又会有离散读取的情况发生,从而导致 Insert Buffer 失去了意义。

可以通过命令 SHOW ENGINE INNODB STATUS 来查看插入缓冲的信息

image.png
seg size显示了当前 Insert Buffer的大小为11336×16KB,大约为177MB; free list len代表了空闲列表的长度;size代表了已经合并记录页的数量。而黑体部分的第2行可能是用户真正关心的,因为它显示了插入性能的提高。 Inserts代表了插入的记录数;merged recs代表了合并的插入记录数量; merges代表合并的次数,也就是实际读取页的次数。 merges: merged recs大约为1:3,代表了插入缓冲将对于非聚集索引页的离散IO逻辑请求大约降低了2/3。

正如前面所说的,目前 Insert Buffer存在一个问题是:在写密集的情况下,插入缓冲会占用过多的缓冲池内存( innodb buffer pool),默认最大可以占用到1/2的缓冲池内存。以下是 InnoDB存储引擎源代码中对于 insert buffer的初始化操作:
image.png

Change Buffer

InnoDB 从1.0.x版本开始引入了 Change Buffer,可将其视为 Insert Buffer的升级版本, InnodB 存储引擎可以对DML操作— INSERT、 DELETE、 UPDATE 都进行缓冲,他们分别是: Insert Buffer、 Delete Buffer、 Purge buffer当然和之前 Insert Buffer一样, Change Buffer适用的对象依然是非唯一的辅助索引。
对一条记录进行 UPDATE 操作可能分为两个过程:

  • 将记录标记为已删除;
  • 真正将记录删除

因此 Delete Buffer对应 UPDATE操作的第一个过程,即将记录标记为删除。 PurgeBuffer对应 UPDATE 操作的第二个过程,即将记录真正的删除。同时, InnoDB 存储引擎提供了参数 innodb_change_buffering,用来开启各种Buffer的选项。该参数可选的值为: Inserts、 deletes、 purges、 changes、all、none。 Inserts、 deletes、 purges 就是前面讨论过的三种情况。 changes 表示启用 Inserts 和 deletes,all表示启用所有,none表示都不启用。该参数默认值为all。
从 InnoDB1.2.x版本开始,可以通过参数 innodb_change_buffer_max_size 来控制Change Buffer最大使用内存的数量:

1
2
3
4
5
6
7
sql复制代码mysql> show variables like 'innodb_change_buffer_max_size';
+-------------------------------+-------+
| Variable_name | Value |
+-------------------------------+-------+
| innodb_change_buffer_max_size | 25 |
+-------------------------------+-------+
1 row in set (0.05 sec)

innodb_change_buffer_max_size 值默认为25,表示最多使用1/4的缓冲池内存空间。
而需要注意的是,该参数的最大有效值为50在 MySQL5.5版本中通过命令 SHOW ENGINE INNODB STATUS,可以观察到类似如下的内容:

可以看到这里显示了 merged operations和 discarded operation,并且下面具体显示 Change Buffer中每个操作的次数。 Insert 表示 Insert Buffer; delete mark表示 Delete Buffer; delete表示 Purge Buffer; discarded operations表示当 Change Buffer发生 merge时,表已经被删除,此时就无需再将记录合并(merge)到辅助索引中了。

自适应哈希索引

InnoDB 会根据访问的频率和模式,为热点页建立哈希索引,来提高查询效率。InnoDB 存储引擎会监控对表上各个索引页的查询,如果观察到建立哈希索引可以带来速度上的提升,则建立哈希索引,所以叫做自适应哈希索引。

自适应哈希索引通过缓冲池的B+树页构建而来,因此建立速度很快,而且不需要对整张数据表建立哈希索引。其有一个要求,即对这个页的连续访问模式必须一样的,也就是说其查询的条件必须完全一样,而且必须是连续的。

锁信息(lock info)

我们都知道,InnoDB 存储引擎会在行级别上对表数据进行上锁,不过 InnoDB 打开一张表,就增加一个对应的对象到数据字典。

数据字典

对数据库中的数据、库对象、表对象等的元信息的集合。在 MySQL 中,数据字典信息内容就包括表结构、数据库名或表名、字段的数据类型、视图、索引、表字段信息、存储过程、触发器等内容,MySQL INFORMATION_SCHEMA 库提供了对数据局元数据、统计信息、以及有关MySQL Server的访问信息(例如:数据库名或表名,字段的数据类型和访问权限等)。该库中保存的信息也可以称为MySQL的数据字典。

预读机制

MySQL的预读机制,就是当你从磁盘上加载一个数据页的时候,他可能会连带着把这个数据页相邻的其他数据页,也加载到缓存里去!

举个例子,假设现在有两个空闲缓存页,然后在加载一个数据页的时候,连带着把他的一个相邻的数据页也加载到缓存里去了,正好每个数据页放入一个空闲缓存页!

哪些情况下会触发MySQL的预读机制?

  1. 有一个参数是innodb_read_ahead_threshold,他的默认值是56,意思就是如果顺序的访问了一个区里的多个数据页,访问的数据页的数量超过了这个阈值,此时就会触发预读机制,把下一个相邻区中的所有数据页都加载到缓存里去。
  2. 如果Buffer Pool里缓存了一个区里的13个连续的数据页,而且这些数据页都是比较频繁会被访问的,此时就会直接触发预读机制,把这个区里的其他的数据页都加载到缓存里去这个机制是通过参数innodb_random_read_ahead来控制的,他默认是OFF,也就是这个规则是关闭的。

所以默认情况下,主要是第一个规则可能会触发预读机制,一下子把很多相邻区里的数据页加载到缓存里去。

预读机制的好处为了提升性能。假设你读取了数据页01到缓存页里去,那么接下来有可能会接着顺序读取数据页01相邻的数据页02到缓存页里去,这个时候,是不是可能在读取数据页02的时候要再次发起一次磁盘IO?

所以为了优化性能,MySQL才设计了预读机制,也就是说如果在一个区内,你顺序读取了好多数据页了,比如数据页01到数据页56都被你依次顺序读取了,MySQL会判断,你可能接着会继续顺序读取后面的数据页。那么此时就提前把后续的一大堆数据页(比如数据页57到数据页72)都读取到Buffer Pool里去。

缓冲池内存管理

这里需要了解三个链表(Free List、Flush List、LRU List),

  • Free List

磁盘上的数据页和缓存页是一 一对应起来的,都是16KB,一个数据页对应一个缓存页。数据库会为Buffer Pool设计一个free链表,他是一个双向链表数据结构,这个free链表里,每个节点就是一个空闲的缓存页的描述数据块的地址,也就是说,只要你一个缓存页是空闲的,那么他的描述数据块就会被放入这个free链表中。刚开始数据库启动的时候,可能所有的缓存页都是空闲的,因为此时可能是一个空的数据库,一条数据都没有,所以此时所有缓存页的描述数据块,都会被放入这个free链表中,除此之外,这个free链表有一个基础节点,他会引用链表的头节点和尾节点,里面还存储了链表中有多少个描述数据块的节点,也就是有多少个空闲的缓存页。

  • Flush List
    和 Free List 链表类似,flush链表本质也是通过缓存页的描述数据块中的两个指针,让被修改过的缓存页的描述数据块,组成一个双向链表。凡是被修改过的缓存页,都会把他的描述数据块加入到flush链表中去,flush的意思就是这些都是脏页,后续都是要flush刷新到磁盘上去。
  • LRU List
    由于缓冲池大小是一定的,换句话说 free 链表中的空闲缓存页数据是一定的,当你不停的把磁盘上的数据页加载到空闲缓存页里去,free 链表中不停的移除空闲缓存页,迟早有那么一瞬间,free 链表中已经没有空闲缓存页,这时候就需要淘汰掉一些缓存页,那淘汰谁呢?这就需要利用缓存命中率了,缓存命中多的就是常用的,那不常用的就可以淘汰了。所以引入 LRU 链表来判断哪些缓存页是不常用的。

那LRU链表的淘汰策略是什么样的呢?

假设我们从磁盘加载一个数据页到缓存页的时候,就把这个缓存页的描述数据块放到 LRU 链表头部去,那么只要有数据的缓存页,他都会在 LRU 里了,而且最近被加载数据的缓存页,都会放到LRU链表的头部去,然后加入某个缓存页在尾部,只要发生查询,就把它移到头部,那么最后尾部就是需要淘汰了。
image.png

但是这样真的就可以吗?

+ 第一种情况预读机制破坏  

由于预读机制会把相邻的没有被访问到的数据页加载到缓存里,实际上只有一个缓存页是被访问了,另外一个通过预读机制加载的缓存页,其实并没有人访问,此时这两个缓存页可都在LRU链表的前面,如下图
![image.png](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/f7631281604299708681fc2fa401d5b5f43fdf1769de2784cd29e18b9d3dce22)
这个时候,假如没有空闲缓存页了,那么此时要加载新的数据页了,是不是就要从LRU链表的尾部把所谓的“最近最少使用的一个缓存页”给拿出来,刷入磁盘,然后腾出来一个空闲缓存页了。这样显然是很不合理的。
+ 第二种情况可能导致频繁被访问的缓存页被淘汰的场景  

全表扫描导致他直接一下子把这个表里所有的数据页,都从磁盘加载到Buffer Pool里去。这个时候可能会一下子就把这个表的所有数据页都一一装入各个缓存页里去!此时可能LRU链表中排在前面的一大串缓存页,都是全表扫描加载进来的缓存页!那么如果这次全表扫描过后,后续几乎没用到这个表里的数据呢?此时LRU链表的尾部,可能全部都是之前一直被频繁访问的那些缓存页!然后当你要淘汰掉一些缓存页腾出空间的时候,就会把LRU链表尾部一直被频繁访问的缓存页给淘汰掉了,而留下了之前全表扫描加载进来的大量的不经常访问的缓存页!**优化LRU算法**:基于冷热数据分离的思想设计LRU链表  

MySQL在设计LRU链表的时候,采取的实际上是冷热数据分离的思想。LRU链表,会被拆分为两个部分,一部分是热数据,一部分是冷数据,这个冷热数据的比例是由 innodb_old_blocks_pct 参数控制的,他默认是37,也就是说冷数据占比37%。数据页第一次被加载到缓存的时候,实际上缓存页会被放在冷数据区域的链表头部。
image.png

然后MySQL设定了一个规则,他设计了一个 innodb_old_blocks_time 参数,默认值1000,也就是1000毫秒也就是说,必须是一个数据页被加载到缓存页之后,在1s之后,你访问这个缓存页,它会被挪动到热数据区域的链表头部去。因为假设你加载了一个数据页到缓存去,然后过了1s之后你还访问了这个缓存页,说明你后续很可能会经常要访问它,这个时间限制就是1s,因此只有1s后你访问了这个缓存页,他才会给你把缓存页放到热数据区域的链表头部去。
image.png

这样的话预读和全表扫描的数据都只会在冷数据头部,不会一开始就进去热数据区。

LRU算法极致优化

LRU链表的热数据区域的访问规则优化一下,即只有在热数据区域的后3/4部分的缓存页被访问了,才会给你移动到链表头部去。如果你是热数据区域的前面1/4的缓存页被访问,他是不会移动到链表头部去的。

举个例子,假设热数据区域的链表里有100个缓存页,那么排在前面的25个缓存页,他即使被访问了,也不会移动到链表头部去的。但是对于排在后面的75个缓存页,他只要被访问,就会移动到链表头部去。这样的话,他就可以尽可能的减少链表中的节点移动了。

LRU链表淘汰缓存页时机

MySQL在执行CRUD的时候,首先就是大量的操作缓存页以及对应的几个链表。然后在缓存页都满的时候,必然要想办法把一些缓存页给刷入磁盘,然后清空这几个缓存页,接着把需要的数据页加载到缓存页里去!

我们已经知道,他是根据LRU链表去淘汰缓存页的,那么他到底是什么时候把LRU链表的冷数据区域中的缓存页刷入磁盘的呢?实际上他有以下三个时机:

+ 定时把LRU尾部的部分缓存页刷入磁盘  

后台线程,运行一个定时任务,这个定时任务每隔一段时间就会把LRU链表的冷数据区域的尾部的一些缓存页,刷入磁盘里去,清空这几个缓存页,把他们加入回`free`链表去。  

![image.png](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/c009173a1d867625751e650c3c4c02459547519dac87a9e3af263094c58bf3a0)
+ 把`flush`链表中的一些缓存页定时刷入磁盘  

如果只是把 LRU 链表的冷数据区域的缓存页刷入磁盘是不够,因为链表的热数据区域里的很多缓存页可能也会被频繁的修改,难道他们永远都不刷入磁盘中了吗?  

所以这个后台线程同时也会在`MySQL`不怎么繁忙的时候,把`flush`链表中的缓存页都刷入磁盘中,这样被你修改过的数据,迟早都会刷入磁盘的!  

只要`flush`链表中的一波缓存页被刷入了磁盘,那么这些缓存页也会从`flush`链表和`lru`链表中移除,然后加入到`free`链表中去!


所以整体效果就是不停的加载数据到缓存页里去,不停的查询和修改缓存数据,然后`free`链表中的缓存页不停的在减少,`flush`链表中的缓存页不停的在增加,`lru`链表中的缓存页不停的在增加和移动。  

另外一边,你的后台线程不停的在把`lru`链表的冷数据区域的缓存页以及`flush`链表的缓存页,刷入磁盘中来清空缓存页,然后`flush`链表和`lru`链表中的缓存页在减少,`free`链表中的缓存页在增加。
+ `free`链表没有空闲缓存页  

如果所有的`free`链表都被使用了,这个时候如果要从磁盘加载数据页到一个空闲缓存页中,此时就会从LRU链表的冷数据区域的尾部找到一个缓存页,他一定是最不经常使用的缓存页!然后把他刷入磁盘和清空,然后把数据页加载到这个腾出来的空闲缓存页里去!

总结一下,三个链表的使用情况,Buffer Pool被使用的时候,实际上会频繁的从磁盘上加载数据页到他的缓存页里去,然后free链表、flush链表、lru链表都会同时被使用,比如数据加载到一个缓存页,free链表里会移除这个缓存页,然后lru链表的冷数据区域的头部会放入这个缓存页。

然后如果你要是修改了一个缓存页,那么flush链表中会记录这个脏页,lru链表中还可能会把你从冷数据区域移动到热数据区域的头部去。

如果你是查询了一个缓存页,那么此时就会把这个缓存页在lru链表中移动到热数据区域去,或者在热数据区域中也有可能会移动到头部去。

Redo log Buffer 重做日志缓冲

InnoDB 有 buffer pool(简称bp)。bp 是数据库页面的缓存,对 InnoDB 的任何修改操作都会首先在bp的page上进行,然后这样的页面将被标记为 dirty(脏页) 并被放到专门的 flush list 上,后续将由 master thread 或专门的刷脏线程阶段性的将这些页面写入磁盘(disk or ssd)。

这样的好处是避免每次写操作都操作磁盘导致大量的随机IO,阶段性的刷脏可以将多次对页面的修改 merge 成一次IO操作,同时异步写入也降低了访问的时延。然而,如果在 dirty page 还未刷入磁盘时,server非正常关闭,这些修改操作将会丢失,如果写入操作正在进行,甚至会由于损坏数据文件导致数据库不可用。

为了避免上述问题的发生,Innodb将所有对页面的修改操作写入一个专门的文件,并在数据库启动时从此文件进行恢复操作,这个文件就是redo log file。这样的技术推迟了bp页面的刷新,从而提升了数据库的吞吐,有效的降低了访问时延。

带来的问题是额外的写redo log操作的开销(顺序IO,当然很快),以及数据库启动时恢复操作所需的时间。

redo日志由两部分构成:redo log buffer、redo log file(在磁盘文件那部分介绍)。innodb 是支持事务的存储引擎,在事务提交时,必须先将该事务的所有日志写入到 redo 日志文件中,待事务的 commit 操作完成才算整个事务操作完成。在每次将redo log buffer写入redo log file后,都需要调用一次fsync操作,因为重做日志缓冲只是把内容先写入操作系统的缓冲系统中,并没有确保直接写入到磁盘上,所以必须进行一次fsync操作。因此,磁盘的性能在一定程度上也决定了事务提交的性能(具体后面 redo log 落盘机制介绍)。
image.png
InnoDB 存储引擎会首先将重做日志信息先放入重做日志缓冲中,然后在按照一定频率将其刷新到重做日志文件,重做日志缓冲一般不需要设置的很大,因为一般情况每一秒钟都会将重做日志缓冲刷新到日志文件中,可通过配置参数 Innodb_log_buffer_size 控制,默认为8MB。

Double Write 双写

如果说 Insert Buffer 给 InnoDB 存储引擎带来了性能上的提升,那么 Double wtite 带给 InnoDB 存储引擎的是数据页的可靠性。

InnoDB 的 Page Size 一般是16KB,其数据校验也是针对这16KB来计算的,将数据写入到磁盘是以 Page 为单位进行操作的。我们知道,由于文件系统对一次大数据页(例如InnoDB的16KB)大多数情况下不是原子操作,这意味着如果服务器宕机了,可能只做了部分写入。16K的数据,写入4K时,发生了系统断电 os crash ,只有一部分写是成功的,这种情况下就是 partial page write 问题。

有经验的DBA可能会想到,如果发生写失效,MySQL可以根据redo log进行恢复。这是一个办法,但是必须清楚地认识到,redo log中记录的是对页的物理修改,如偏移量800,写’aaaa’记录。如果这个页本身已经发生了损坏,再对其进行重做是没有意义的。MySQL在恢复的过程中检查page的checksum,checksum就是检查page的最后事务号,发生partial page write问题时,page已经损坏,找不到该page中的事务号。在InnoDB看来,这样的数据页是无法通过 checksum 验证的,就无法恢复。即时我们强制让其通过验证,也无法从崩溃中恢复,因为当前InnoDB存在的一些日志类型,有些是逻辑操作,并不能做到幂等。

为了解决这个问题,InnoDB实现了double write buffer,简单来说,就是在写数据页之前,先把这个数据页写到一块独立的物理文件位置(ibdata),然后再写到数据页。这样在宕机重启时,如果出现数据页损坏,那么在应用redo log之前,需要通过该页的副本来还原该页,然后再进行redo log重做,这就是double write。double write技术带给innodb存储引擎的是数据页的可靠性,下面对doublewrite技术进行解析

image.png

如上图所示,Double Write 由两部分组成,一部分是内存中的 double write buffer,大小为2MB,另一部分是物理磁盘上共享表空间连续的128个页,大小也为2MB。在对缓冲池的脏页进行刷新时,并不直接写磁盘,而是通过 memcpy 函数将脏页先复制到内存中的该区域,之后通过 double write buffer 再分两次,每次1MB顺序地写入共享表空间的物理磁盘上,然后马上调用 fsync 函数,同步磁盘,避免操作系统缓冲写带来的问题。在完成double write 页的写入后,再将 double wirite buffer 中的页写入各个表空间文件中。

在这个过程中,doublewrite 是顺序写,开销并不大,在完成 doublewrite 写入后,在将 double write buffer写入各表空间文件,这时是离散写入。

如果操作系统在将页写入磁盘的过程中发生了崩溃,在恢复过程中,InnoDB 存储引擎可以从共享表空间中的double write 中找到该页的一个副本,将其复制到表空间文件中,再应用重做日志。

InnoDB 存储引擎第二部分:后台线程

IO 线程

在 InnoDB 中使用了大量的 AIO(Async IO) 来做读写处理,这样可以极大提高数据库的性能。在 InnoDB 1.0 版本之前共有4个 IO Thread,分别是 write,read,insert buffer和log thread,后来版本将 read thread和 write thread 分别增大到了4个,一共有10个了。

  • read thread : 负责读取操作,将数据从磁盘加载到缓存page页。4个
  • write thread:负责写操作,将缓存脏页刷新到磁盘。4个
  • log thread:负责将日志缓冲区内容刷新到磁盘。1个
  • insert buffer thread :负责将写缓冲内容刷新到磁盘。1个

Purge 线程

事务提交之后,其使用的 undo 日志将不再需要,因此需要 Purge Thread 回收已经分配的 undo 页。show variables like '%innodb*purge*threads%';

Page Cleaner 线程

作用是将脏数据刷新到磁盘,脏数据刷盘后相应的 redo log 也就可以覆盖,即可以同步数据,又能达到 redo log 循环使用的目的。会调用write thread线程处理。show variables like '%innodb*page*cleaners%';

InnoDB 存储引擎第三部分:磁盘文件

InnoDB 的主要的磁盘文件主要分为三大块:一是系统表空间,二是用户表空间,三是 redo 日志文件和归档文件。

二进制文件(binlong)等文件是 MySQL Server 层维护的文件,所以未列入 InnoDB 的磁盘文件中。

系统表空间和用户表空间

系统表空间包含 InnoDB 数据字典(元数据以及相关对象)并且 double write buffer , change buffer , undo logs 的存储区域。

系统表空间也默认包含任何用户在系统表空间创建的表数据和索引数据。

系统表空间是一个共享的表空间,因为它是被多个表共享的。

系统表空间是由一个或者多个数据文件组成。默认情况下,1个初始大小为10MB,名为 ibdata1 的系统数据文件在MySQL的data目录下被创建。用户可以使用 innodb_data_file_path 对数据文件的大小和数量进行配置。

innodb_data_file_path 的格式如下:

innodb_data_file_path=datafile1[,datafile2]...

用户可以通过多个文件组成一个表空间,同时制定文件的属性:

innodb_data_file_path = /db/ibdata1:1000M;/dr2/db/ibdata2:1000M:autoextend

这里将 /db/ibdata1 和 /dr2/db/ibdata2 两个文件组成系统表空间。如果这两个文件位于不同的磁盘上,磁盘的负载可能被平均,因此可以提高数据库的整体性能。两个文件的文件名之后都跟了属性,表示文件 ibdata1 的大小为1000MB,文件 ibdata2 的大小为1000MB,而且用完空间之后可以自动增长。

设置 innodb_data_file_path 参数之后,所有基于 InnoDB 存储引擎的表的数据都会记录到该系统表空间中,如果设置了参数 innodb_file_per_table ,则用户可以将每个基于 InnoDB 存储引擎的表产生一个独立的用户空间。

用户表空间的命名规则为:表名.ibd。通过这种方式,用户不用将所有数据都存放于默认的系统表空间中,但是用户表空间只存储该表的数据、索引和插入缓冲BITMAP等信息,其余信息还是存放在默认的系统表空间中。

下图显示 InnoDB 存储引擎对于文件的存储方式,其中frm文件是表结构定义文件,记录每个表的表结构定义。

image.png

重做日志文件(redo log file)和归档文件

默认情况下,在 InnoDB 存储引擎的数据目录下会有两个名为 ib_logfile0 和 ib_logfile1 的文件,这就是 InnoDB 的重做文件(redo log file),它记录了对于 InnoDB 存储引擎的事务日志。

当 InnoDB 的数据存储文件发生错误时,重做日志文件就能派上用场。InnoDB 存储引擎可以使用重做日志文件将数据恢复为正确状态,以此来保证数据的正确性和完整性。

每个 InnoDB 存储引擎至少有1个重做日志文件,每个文件组下至少有2个重做日志文件,加默认的 ib_logfile0 和 ib_logfile1。

为了得到更高的可靠性,用户可以设置多个镜像日志组,将不同的文件组放在不同的磁盘上,以此来提高重做日志的高可用性。

在日志组中每个重做日志文件的大小一致,并以【循环写入】的方式运行。InnoDB 存储引擎先写入重做日志文件1,当文件被写满时,会切换到重做日志文件2,再当重做日志文件2也被写满时,再切换到重做日志1。

用户可以使用 Innodb_log_file_size 来设置重做日志文件的大小 ,这对 InnoDB 存储引擎的性能有着非常大的影响。

如果重做日志文件设置的太大,数据丢失时,恢复时可能需要很长的时间;另一个方面,如果设置的太小,重做日志文件太小会导致依据 checkpoint 的检查需要频繁刷新脏页到磁盘中,导致性能的抖动。

重做日志的落盘机制

InnoDB 对于数据文件和日志文件的刷盘遵守WAL(write ahead redo log)和 Force-log-at-commit 两种规则,二者保证了事务的持久性。WAL 要求数据的变更写入到磁盘前,首先必须将内存中的日志写入到磁盘;Force-log-at-commit 要求当一个事务提交时,所有产生的日志都必须刷新到磁盘上,如果日志刷新成功后,缓冲池中的数据刷新到磁盘前数据库发生了宕机,那么重启时,数据库可以从日志中恢复数据。

image.png

如上图所示,InnoDB 在缓冲池中变更数据时,会首先将相关变更写入重做日志缓冲中,然后再按时(比如每秒刷新机制)或者当事务提交时写入磁盘,这符合 Force-log-at-commit 原则;当重做日志写入磁盘后,缓冲池中的变更数据才会依据 checkpoint 机制写入到磁盘中,这符合 WAL 原则。

在 checkpoint 择时机制中,就有重做日志文件写满的判断,所以,如前文所述,如果重做日志文件太小,经常被写满,就会频繁导致 checkpoint 将更改的数据写入磁盘,导致性能抖动。

操作系统的文件系统是带有缓存的,当 InnoDB 向磁盘写入数据时,有可能只是写入到了文件系统的缓存中,没有真正的“落袋为安”。

InnoDB 的 innodb_flush_log_at_trx_commit 属性可以控制每次事务提交时 InnoDB 的行为。当属性值为0时,事务提交时,不会对重做日志进行写入操作,而是等待主线程按时写入;当属性值为1时,事务提交时,会将重做日志写入文件系统缓存,并且调用文件系统的 fsync ,将文件系统缓冲中的数据真正写入磁盘存储,确保不会出现数据丢失;当属性值为2时,事务提交时,也会将日志文件写入文件系统缓存,但是不会调用fsync,而是让文件系统自己去判断何时将缓存写入磁盘。

日志的刷盘机制如下图所示:

image.png

Innodb_flush_log_at_commit 是 InnoDB 性能调优的一个基础参数,涉及 InnoDB 的写入效率和数据安全。当参数数值为0时,写入效率最高,但是数据安全最低;参数值为1时,写入效率最低,但是数据安全最高;参数值为2时,二者都是中等水平,一般建议将属性值设置为1,以获得较高的安全性,而且也只有设置为1,才能保证事务的持久性。

用一条 UPDATE 语句再来了解 InnoDB 存储引擎

有了上面 InnoDB 存储引擎的架构基础介绍,我们再来分析一下一次 UPDATE 数据更新具体流程。

image.png

我们把这张图分为上下两部分来看,上面那部分是 MySQL Server 层处理流程,下面那部分是 MySQL InnoDB存储引擎处理流程。

MySQL Server 层处理流程

image.png
这部分处理流程无关于哪个存储引擎,它是 Server 层处理的,具体步骤如下:

  1. 用户各种操作触发后台sql执行,通过web项目中自带的数据库连接池:如 dbcp、c3p0、druid 等,与数据库服务器的数据库连接池建立网络连接;
  2. 数据库连接池中的线程监听到请求后,将接收到的sql语句通过SQL接口响应给查询解析器,查询解析器将sql按照sql的语法解析出查询哪个表的哪些字段,查询条件是啥;
  3. 再通过查询优化器处理,选择该sq最优的一套执行计划;
  4. 然后执行器负责调用存储引擎的一系列接口,执行该计划而完成整个sql语句的执行

这部分流程和上面分析的 一次 Select 请求处理流程分析的基本一致。

InnoDB 存储引擎处理流程

image.png
具体执⾏语句得要存储引擎来完成,如上图所示:

  1. 更新users表中id=10的这条数据,如果缓冲池中没有该条数据的,得要先从磁盘中将被更新数据的原始数据加载到缓冲池中。
  2. 同时为了保证并发更新数据安全问题,会对这条数据先加锁,防⽌其他事务进⾏更新。
  3. 接着将更新前的值先备份写⼊到undo log中(便于事务回滚时取旧数据),⽐如 update 语句即存储被更新字段之前的值。
  4. 更新 buffer pool 中的缓存数据为最新的数据,那么此时内存中的数据为脏数据(内存中数据和磁盘中数据不一致)
    image.png
    ⾄此就完成了在缓冲池中的执⾏流程(如上图)。
  5. 缓冲池中更新完数据后,需要将本次的更新信息顺序写到 Redo Log ⽇志,因为现在已经把内存里的数据进行了修改,但是磁盘上的数据还没修改,此时万一 MySQL所在的机器宕机了,必然会导致内存里修改过的数据丢失,redo 日志就是记录下来你对数据做了什么修改,比如对“id=10这行记录修改了name字段的值为xxx”,这就是一个日志,用来在MySQL突然宕机的时候,用来恢复你更新过的数据的。不过注意的是此时 Redo Log 还没有落盘到日志文件。

这个时候思考一个问题:如果还没提交事务,MySQL宕机了怎么办?

上面我们知道到目前我们修改了内存数据,然后记录了 Redo Log Buffer 日志缓冲,如果这个时候 MySQL 奔溃,内存数据和 Redo Log Buffer 数据都会丢失,但是此时数据丢失并不要紧,因为一条更新语句,没提交事务,就代表他没执行成功,此时MySQL宕机虽然导致内存里的数据都丢失了,但是你会发现,磁盘上的数据依然还停留在原样子。

接下来要提交事物了,此时就会根据一定的策略把redo日志从redo log buffer里刷入到磁盘文件里去,此时这个策略是通过 innodb_flush_log_at_trx_commit 来配置的。

* `innodb_flush_log_at_trx_commit=0`,表示提交事物不会把`redo log buffer`里的数据刷入磁盘文件的,此时可能你都提交事务了,结果mysql宕机了,然后此时内存里的数据全部丢失,所以这种方式不可取。
* `innodb_flush_log_at_trx_commit=1`,`redo log`从内存刷入到磁盘文件里去,只要事务提交成功,那么`redo log`就必然在磁盘里了,所以如果这个时候MySQL奔溃,可以根据`Redo Log`日志恢复数据。
* `innodb_flush_log_at_trx_commit=2`,提交事务的时候,把redo日志写入磁盘文件对应的`os cache`缓存里去,而不是直接进入磁盘文件,可能1秒后才会把`os cache`里的数据写入到磁盘文件里去。
  1. 提交事务的时候,同时会写入binlog,binlog也有不同的刷盘策略,有一个sync_binlog参数可以控制binlog的刷盘策略,他的默认值是0,此时你把binlog写入磁盘的时候,其实不是直接进入磁盘文件,而是进入os cache内存缓存。⼀般我们为了保证数据不丢失会配置双1策略,Redo Log 和 Binlog落盘策略都选择1。
  2. Binlog 落盘后,再将Binlog的⽂件名、⽂件所在路径信息以及commit标记给同步顺序写到Redo log中,这一步的意义是用来保持 redo log 日志与 binlog 日志一致的。commit标记是判定事务是否成功提交的⼀个⽐较重要的标准,举个例子,如果如果第5步或者第6步执行成功后MySQL就奔溃了,这个时候因为没有最终的事务commit标记在redo日志里,所以此次事务可以判定为不成功。不会说redo日志文件里有这次更新的日志,但是binlog日志文件里没有这次更新的日志,不会出现数据不一致的问题。
  3. 做完前面后,内存数据已经修改,事物已经提交,日志已经落盘,但是磁盘数据还没有同步修改。InnoDB存储引擎后台有⼀个IO线程,会在数据库压⼒的低峰期间,将缓冲池中被事务更新、但还没来得及写到磁盘中的数据(脏数据,因为磁盘数据和内存数据已经不⼀致了)给刷到磁盘中,完成事务的持久化。

所以 InnoDB 处理写入过程可以用下面这幅图表示
image.png

相关面试题

问题:Buffer Pool在访问的时候需要加锁吗?

问题: 数据库启动的时候,是如何初始化 Buffer Pool 的?

答: 首先得知道 Buffer Pool 是个啥,长啥样,上面已经介绍了缓冲池了,缓冲池里面就是会包含很多个缓存页,同时每个缓存页还有一个描述数据(这个描述信息大体可以认为是用来描述这个缓存页的比如包含如下的一些东西:这个数据页所属的表空间、数据页的编号、这个缓存页在Buffer Pool中的地址以及别的一些杂七杂八的东西。),如下图:
image.png

其实这个也很简单,数据库只要一启动,就会按照你设置的Buffer Pool大小,稍微再加大一点,去找操作系统申请一块内存区域,作为Buffer Pool的内存区域。然后当内存区域申请完毕之后,数据库就会按照默认的缓存页的16KB的大小以及对应的800个字节左右的描述数据的大小,在Buffer Pool中划分出来一个一个的缓存页和一个一个的他们对应的描述数据。

问题: 数据库怎么知道哪些数据页是空闲的呢?

答:磁盘上的数据页和缓存页是一 一对应起来的,都是16KB,一个数据页对应一个缓存页。数据库会为Buffer Pool设计一个free链表,他是一个双向链表数据结构,这个free链表里,每个节点就是一个空闲的缓存页的描述数据块的地址,也就是说,只要你一个缓存页是空闲的,那么他的描述数据块就会被放入这个free链表中。刚开始数据库启动的时候,可能所有的缓存页都是空闲的,因为此时可能是一个空的数据库,一条数据都没有,所以此时所有缓存页的描述数据块,都会被放入这个free链表中,除此之外,这个free链表有一个基础节点,他会引用链表的头节点和尾节点,里面还存储了链表中有多少个描述数据块的节点,也就是有多少个空闲的缓存页。
image.png

问题: 怎么知道数据页有没有被缓存?

答:在执行增删改查的时候,先看看这个数据页有没有被缓存,如果没被缓存就从free链表中找到一个空闲的缓存页,从磁盘上读取数据页写入缓存页,写入描述数据,从free链表中移除这个描述数据块。但是如果数据页已经被缓存了,那么就会直接使用了。

所以数据库还会有一个哈希表数据结构,他会用表空间号+数据页号,作为一个key,然后缓存页的地址作为value。当你要使用一个数据页的时候,通过“表空间号+数据页号”作为key去这个哈希表里查一下,如果没有就读取数据页并写入缓存页,而且在哈希表中写入一个key-value对,key就是表空间号+数据页号,value就是缓存页的地址,如果
已经有了,就说明数据页已经被缓存了。
image.png

问题:在LRU链表的冷数据区域中的都是什么样的数据呢?

答:大部分应该都是预读加载进来的缓存页,加载进来1s之后都没人访问的,然后包括全表扫描或者一些大的查询语句,加载一堆数据到缓存页,结果都是1s之内访问了一下,后续就不再访问这些表的数据了。类似这些数据,统统都会放在冷数据区域里。

问题: Buffer Pool中会不会有内存碎片?

答:当然会有的,因为Buffer Pool大小是自己定的,很可能Buffer Pool划分完全部的缓存页和描述数据块之后,还剩一点点的内存,这一点点的内存放不下任何一个缓存页了,所以这点内存就只能放着不能用,这就是内存碎片。

那怎么减少内存碎片呢?

其实也很简单,数据库在Buffer Pool中划分缓存页的时候,会让所有的缓存页和描述数据块都紧密的挨在一起,这样尽可能减少内存浪费,就可以尽可能的减少内存碎片的产生了。

问题: 脏页是怎么刷新到磁盘呢?

答:脏页是由于修改了缓冲池中的数据,导致内存数据和磁盘数据不一致而形成的,刷新脏页就是把修改过数据的页同步到磁盘上,不可能把所有缓冲池中的页都刷新,那怎么找到这些脏也就很重要了,MySQL 处理方式和空闲页是一样的。

数据库在这里引入了另外一个跟free链表类似的flush链表,这个flush链表本质也是通过缓存页的描述数据块中的两个指针,让被修改过的缓存页的描述数据块,组成一个双向链表。凡是被修改过的缓存页,都会把他的描述数据块加入到flush链表中去,flush的意思就是这些都是脏页,后续都是要flush刷新到磁盘上去。

image.png

问题:undo log和redo log了解过吗?它们的作⽤分别是什么?

答:undo log 和 redo log 是 mysql 中 InnoDB 存储引擎的基本组成:

  • undo log 保存了事务执⾏前数据的值,以便于事务回滚时能回到事务执⾏前的数据版本,多次更新会有undo log的版本链;
  • redo log在物理层⾯上记录了事务操作的⼀系列信息,保证就算遇到mysql宕机等因素还没来得及将数据刷到磁盘⾥,通过redo log也能恢复事务提交的数据。

问题:redo log 怎样保证事务不丢失的?

答:当⼀个事务提交成功后,虽然缓冲池中的数据不⼀定来得及⻢上落地到磁盘中,但是 redo log 记录的事务信息持久化到磁盘中了、且含有 commit 标记,此时如果 mysql 宕机导致缓冲池中的、已经被事务更新过的内存数据丢失了,此时在 mysql 重启时,将磁盘中的 redo log 中将事务变更信息给加载到缓冲池中,保证事务信息不会丢失。或者 redo log 刷盘了,binlog 写成功了,在重启时会⾃动给上 commit 标记,再重放数据。

问题:事务是先提交还是先刷盘?

答:事务先提交后刷盘;

  1. Redo log 刷盘成功
  2. Binlog 刷盘
  3. BinLog 名称和⽂件路径信息、commit 标志写到 Redo log 中,事务两阶段提交的⽅式来保证。

问题:更新操作为什么不直接更新磁盘反⽽设计这样⼀个复杂的 InnoDB 存储引擎来完成?

答:直接更新磁盘是随机IO写,存在磁盘地址寻址操作,性能⾮常低,承载不了⾼并发场景;⽽转换为 InnoDB 中,内存⾼速读写、redo log和undo log顺序写磁盘性能相对于随机IO写性能会⾼的多,⽽这种性能上的提⾼⾜以抵消这种架构上带来的复杂,可在⼀定QPS内承载⾼并发场景。

资料参考:

《从零开始带你成为MySQL实战优化高手》

《MySQL技术内幕-InnoBD存储引擎》

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

微服务框架之争--Spring Boot和Quarkus

发表于 2021-10-26

概述

SpringBoot框架不用多介绍,Java程序员想必都知道。相对来说熟悉Quarkus的人可能会少一些。Quarkus首页放出的标语:超音速亚原子的Java(Supersonic Subatomic Java)。
它是为 OpenJDK HotSpot 和 GraalVM 量身定制的 Kubernetes Native Java 框架,基于同类最佳的 Java 库和标准制作而成。Quarkus 的到来为开发 Linux 容器和 kubernetes 原生 Java 微服务带来了一个创新平台。

在本文中,我们将对这两个 Java 框架 Spring Boot 和 Quarkus 进行简单的比较。我们可以更好地了解它们之间的异同,以及一些特殊性。我们还会执行一些测试来衡量它们的性能。最后,我们会介绍一个开发人员如何从Spring转换到Quarkus。

SpringBoot

Spring Boot 是一个基于 Java 的框架,专注于企业应用。它可以简单使用所有 Spring 项目,并集成了许多开箱即用的功能,来帮助开发人员提高生产力。

Spring Boot减少了配置和样板代码的数量。此外,由于其约定优于配置方法,它根据依赖项自动注册默认配置,大大缩短了 Java 应用程序的开发周期。

Quarkus

Quarkus 是另一个采用与上述 Spring Boot 类似方法的框架,但还有一个额外的优点,即以更快的启动时间、更好的资源利用率和效率交付更小的工件(Supersonic、Subatomic)。

它针对云、无服务器和容器化环境进行了优化。尽管侧重点略有不同, Quarkus 也能与最流行的 Java 框架很好地集成。

比较

如上所述,这两个框架都与其他项目和框架有很好的集成。但是,它们的内部实现和架构是不同的。例如,Spring Boot 提供两种类型的 Web 功能:阻塞(Servlets)和非阻塞(WebFlux)。

另一方面,Quarkus 也提供这两种方法,但与 Spring Boot 不同的是,它允许我们同时使用阻塞和非阻塞方法。此外,Quarkus 在其架构中嵌入了反应式编程方法。

为了在我们的比较中获得更准确的数据,我们将使用两个完全响应式的应用程序,这些应用程序使用 Spring WebFlux 和 Quarkus 响应式功能实现。

此外,Quarkus 项目中最重要的功能之一是能够创建原生镜像(Native Images,基于特定平台的可执行二进制文件)。因此,我们还将在比较中包含两个原生映像,但 Spring 的原生镜像支持仍处于试验阶段。另外我们需要用到 GraalVM。

测试应用

我们的应用程序将实现三个 API:一个允许用户创建邮政编码,另一个用于查找特定邮政编码的信息,最后按城市查询邮政编码。这些 API 是使用了前面提到的 Spring Boot 和 Quarkus 的反应式方法实现的,数据库使用的是PostgreSQL。

我们的目标是创建一个比 HelloWorld 程序稍微复杂一些的样例程序。当然,数据库驱动和序列化框架等内容的实现会影响我们的比较结果。但是,大多数应用程序可能都会需要处理这些事情。

因此,比较的目的并不是为了证明哪个框架更好或更高效,而是分析研究这些特定实现的一个案例。

测试计划

为了测试这两种实现,我们将使用 JMeter 执行测试,并分析其测试报告。此外,我们将使用 VisualVM 在执行测试期间监控应用程序的资源利用率。

测试将运行 5 分钟,会调用所有 API,从预热期开始,然后增加并发用户数,直到达到 1,500。我们将在前几秒钟开始填充数据库,然后开始查询,如下所示:

image.png

image.png

所有测试均在以下规格的机器上进行:

image.png

由于缺乏与其他后台进程的隔离,最终结果可能不太理想,但正如前面提到的,我们无意对这两个框架的性能进行广泛而详细的分析。

调查结果

对开发人员来说,这两个项目的体验都很棒,但值得一提的是 Spring Boot 有更好的文档,在网上也可以找到更多资料。 Quarkus 在这方面正在改进,但仍然有点落后。

在指标方面,我们有如下结果:

image.png

通过这个实验,我们可以观察到 Quarkus 在 JVM 和原生版本的启动时间方面几乎比 Spring Boot 快一倍。构建时间也快得多。在原生镜像的情况下,构建耗时: 9 分钟(Quarkus)对 13 分钟(Spring Boot),JVM 构建耗时: 20 秒(Quarkus)对 39 秒(Spring Boot)。

Artifact(工件)的大小出现了同样的情况,Quarkus 生成了更小的工件而再次领先。原生映像:75MB (Quarkus) 对 109MB (Spring Boot),以及JVM 版本: 4KB (Quarkus) 对 26MB (Spring Boot)。

关于其他指标,结论并不是那么显而易见。因此,我们需要更深入地了解一下。

CPU

我们看到 JVM 版本在预热阶段开始时消耗更多的 CPU。之后CPU使用率趋于稳定,所有版本的消耗相对均等。

以下是 JVM 和 Native 版本中 Quarkus 的 CPU 消耗:

image.png
JVM 版的 Quarkus ↑↑↑

image.png
Native 版的 Quarkus ↑↑↑

内存

内存就更复杂了。首先,很明显,两个框架的 JVM 版本都为Heap(堆)预留了更多内存。尽管如此,Quarkus 从一开始就预留了较少的内存,启动期间的内存利用率也是如此。

然后,查看测试期间的利用率,我们可以观察到Native版本似乎不像 JVM 版本那样有效或频繁地回收内存。可以通过调整一些参数来改善这一点,在这个比较中,我们使用了默认参数,并没有对 GC、JVM 选项或任何其他参数进行更改。

让我们看一下内存使用图:

image.png
Spring Boot JVM ↑↑↑

image.png
Quarkus JVM ↑↑↑

image.png
Spring Boot 原生 ↑↑↑

image.png
Quarkus 原生 ↑↑↑

在测试期间尽管Quarkus出现了更高的峰值,但确实消耗的内存资源更少。

响应时间

最后,关于响应时间和峰值使用的线程数,Spring Boot 似乎略微具有优势。它能够使用更少的线程处理相同的负载,同时还具有更好的响应时间。

Spring Boot Native 版本在这种情况下表现出更好的性能。但是让我们看看每个版本的响应时间分布:

image.png
Spring Boot JVM ↑↑↑

尽管有更多异常值,但 Spring Boot JVM 版本随着时间的推移取得了最好的进展,这很可能是由于 JIT 编译器优化。

image.png
Quarkus JVM ↑↑↑

image.png
Spring Boot 原生 ↑↑↑

image.png
Quarkus 原生 ↑↑↑

Quarkus 在低资源利用率方面表现出强大的实力。然而,至少在这个实验中,Spring Boot 在吞吐量和响应能力方面与Quarkus旗鼓相当。

这两个框架都能够处理所有请求而没有任何错误。不仅如此,他们的表现也十分相似,并没有太大的差距。

总而言之

考虑到所有因素,在实现 Java 应用程序时,这两个框架都是很好的选择。

Native程序速度快且资源消耗低,是无服务器、短期(short-living)应用和资源消耗敏感环境的绝佳选择。

另一方面,JVM 应用程序似乎有更多的开销,但随着时间的推移具有出色的稳定性和高吞吐量,非常适合健壮、长寿命的应用程序。

测试程序的代码和用于测试它们的脚本可以在 GitHub 上找到。

从 Spring 转换到 Quarkus

随着K8s的兴起,对原生应用支持良好的Quarkus框架也越来越受到关注很多开发人员在考虑从 Spring 转换到 Quarkus。然而,开发人员在开始评估新的框架时通常必须搁置他们现有的知识。 幸运的是, Quarkus 不一样,因为它是由一群在 Java 技术方面具有深厚专业知识的工程师创建的。这包括 Spring API 兼容性,创建Quarkus的工程师同时也是在 Red Hat Runtime 上为 Spring Boot 提供支持的工程师。

我是 Spring 开发者,为什么要选Quarkus?

越来越明显的是,容器化,尤其是 Kubernetes,正在迫使人们重新评估 Java ,用于开发云原生应用程序。 Kubernetes 是一种高度动态的共享基础设施。由于集群中托管的应用程序数量的增长以及对应用程序生命周期变化的响应能力的提高(例如重新部署和向上/向下扩展),基础设施的投入变得更加划算。传统的 Java 云原生运行时在现有的栈上增加了新的分层,而没有真正重新考虑底层。这导致更大的内存消耗和更慢的启动时间,以至于现在很多公司为了从 Kubernetes 集群的大量投资中获得更多价值,愿意放弃他们深厚的 Java 专业知识,为 Go 和 Node.js 重新培养人才和开发工具。

image.png

传统云原生 Java 栈 ↑↑↑

这正是 Quarkus 解决的问题。 Quarkus 针对内存使用率和快速启动时间进行了优化。与其他云原生 Java 栈相比,在 JVM 上运行的 Quarkus 应用可以在相同数量的RAM中提供近两倍的应用程序实例,并且当打包为原生二进制文件时,实例数量增加了 7 倍。这不仅仅是使用 SubstrateVM(GraalVM 的一个特性)简单地编译为原生二进制文件。 Quarkus 专为 Kubernetes 的基础设施优化了传统的 “高度动态”框架,从而降低了内存利用率并加快了初始启动速度,结果是运行时效率的显着提高。这些经过优化且文档齐全的框架称为“扩展”,由同类最佳的标准 API 组成。

image.png
运行时效率 ↑↑↑

image.png
Quarkus 栈 ↑↑↑

我司为什么要从 Spring Boot 迁移到 Quarkus?

以我们公司为例,我司的旧系统基于 Spring 和 Tomcat。当我们维护和部署时,这个传统的框架给我们带来了一些困扰,基于以下原因我们决定迁移到Quarkus:

  • 内存和 CPU 消耗:对于正在执行的操作,Spring 和 Tomcat 框架在应用的主要目的之外使用了过多的资源。
  • 预热时间:Spring 应用程序可能需要 10-20 秒的时间才能启动,之后应用程序才可以开始预热。
  • 无用的代码:作为开发人员,我们都讨厌样板代码(boilerplate code)。
  • 测试:Quarkus 让编写单元测试和集成测试变得非常容易。只需在那里打一个@QuarkusTest 注释,它实际上会启动整个应用程序以运行您的测试。
  • 横向扩展(Scale-out) vs. 纵向扩展(Scale-up):每个应用程序越小(资源方面),我们可以添加的越多。在这里横向可扩展性胜出。
  • 学习曲线:Quarkus 的在线文档非常简单易懂。

Spring 开发者可以活用哪些现有知识?

Quarkus 的 Spring API 兼容性包括 Spring DI、Spring Web 和 Spring Data JPA。同时也在计划其他 Spring API,如 Spring Security 和 Spring Config。在 JVM 上运行时,Quarkus 应用程序几乎可以利用任何 Java 库。只要不使用 Java 反射,这些Java库就可以编译为原生。例如,受 Spring 开发人员欢迎的 Lombok 库就可以原生编译。需要明确的是,Quarkus 中的 Spring API 兼容性并非为了作为一个完整的 Spring 平台来重新托管现有的 Spring 应用程序。目的是为了让基于 Quarkus 开发新应用程序成为一种自然的入门体验。结合预先优化的扩展,Quarkus 为微服务开发提供了大量的功能。很多开发人员已成功将 Spring 应用程序迁移到 Quarkus。

Spring 框架本质上是高度动态的。为了解决这个问题,Quarkus的Spring 兼容性扩展将 Spring API 映射到现有扩展中的 API,这些扩展已经针对快速启动、降低内存利用率和原生编译进行了优化,例如 RestEasy 和 CDI。此外,Quarkus的Spring 兼容性扩展不使用 Spring 应用程序上下文。由于这些原因,尝试使用额外的 Spring 库可能不会奏效。

Quarkus Spring Web Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码import java.util.List;
import java.util.Optional;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/person")
public class PersonController {
@GetMapping(path = "/greet/{id}", produces = "text/plain")
public String greetPerson(@PathVariable(name = "id") long id) {
String name="";
// ...
return name;
}

@GetMapping(produces = "application/json")
public Iterable<Person> findAll() {
return personRepository.findAll();
}

Quarkus Spring Repository Example

1
2
3
4
5
6
7
8
java复制代码package org.acme.springmp;

import java.util.List;
import org.springframework.data.repository.CrudRepository;

public interface PersonRepository extends CrudRepository<Person, Long> {
List<Person> findByAge(int age);
}

Quarkus Spring Service + MicroProfile Fault Tolerance Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码import org.eclipse.microprofile.faulttolerance.Fallback;
import org.eclipse.microprofile.faulttolerance.Timeout;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service // Spring
public class PersonService {

@Autowired // Spring
@RestClient // MicroProfile
SalutationMicroProfileRestClient salutationRestClient;

@Value("${fallbackSalutation}") // Spring
String fallbackSalutation;

@CircuitBreaker(delay=5000, failureRatio=.5) // MicroProfile
@Fallback(fallbackMethod = "salutationFallback")// MicroProfile
public String getSalutation() {
return salutationRestClient.getSalutation();
}

对Spring开发者有额外的好处吗?

除了提高内存利用率和启动时间外,Quarkus 还为 Spring 开发人员提供了以下好处:

  • 功能即服务 (FaaS)。当编译为原生二进制文件时,Quarkus 应用程序可以在 0.0015 秒内启动,从而可以将现有的 Spring 和 Java API 知识与 FaaS 功能结合使用。 (Azure、AWS Lambda)
  • 实时编码。从“Hello World”示例应用程序开始,然后将其转换为复杂的微服务,而无需重新启动应用程序。只需保存并重新加载浏览器即可查看沿途的变化。 Quarkus 实时编码“开箱即用”,与 IDE 无关。
  • 支持反应式和命令式模型。 Quarkus 有一个反应式核心,支持传统的命令式模型、反应式模型,或在同一应用程序中同时支持两者。
  • 早期检测依赖注入错误。 Quarkus 在编译期间而不是在运行时捕获依赖项注入错误。
  • 最佳框架和标准的结合。 Quarkus 在同一应用程序中支持 Spring API 兼容性、Eclipse Vert.x、MicroProfile(JAX-RS、CDI 等)、反应式流和消息传递等。参考《@Autowire MicroProfile into Spring Boot》,可以在一个项目中同时使用 Spring 和 MicroProfile API。

Spring开发者如何开始学习Quarkus?

推荐的步骤包括:

  • 参看入门指南作为 Quarkus 的一般介绍。
  • 参看 Spring DI、Spring Web 和 Spring Data JPA 的指南。
  • 使用 code.quarkus.io 创建一个新应用。
  • 【可选】在 Quarkus Devoxx 演示中观看 Kubernetes Native Spring Apps

参考链接

www.baeldung.com/spring-boot…

quarkus.io/blog/quarku…

www.logicmonitor.com/blog/quarku…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…465466467…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%