04 group by 中使用 where & having写到这里,有小伙伴就说了。狗哥你这里描述的只是 group by 的单独执行过程,很简单呀。我也会,如果加上 where 或者 having 或者两者都加上的时候的执行过程是怎样的呢?4.1 group by + where现在产品又改需求统计每个城市下的下单人数,且下的订单量要大于 2。OS:mmp,又改按照惯例,看到 where 我们一般想到怎么优化?没错,加索引嘛。加索引:alter table sale_order add index idx_order_num (order_num);最终语句:select city, count(*) as num from sale_order where order_num > 2 group by city;结果:explain 分析:从上图得知,加上索引之后。这条语句命中了索引 idx_order_number,并且此时的 Extra 多了 Using index Condition 的执行计划。type 变成了 range 说明不用全表扫描。解释下 Using index Condition:会先条件过滤索引,过滤完索引后找到所有符合索引条件的数据行,常见于 where 中有 between > < 等条件的 sql 语句。它的出现说明这个语句先走索引过滤掉不符合 where 条件的数据,再去统计,然后排序,最后返回客户端。流程如下:创建内存临时表,表里面有两个字段:city 和 num;根据索引 idx_order_num 找到大于 2 的数据的主键 ID;通过主键 ID 取出 city = 某城市(比如广州、深圳、上海,囊括你表里涉及到的城市)的记录;临时表没有 city = 某城市的记录,直接插入,并记为 (某城市,1);临时表里有 city = 某城市的记录,直接更新,把 num 值 +1。重复 2、3 步骤,直至找到所有吗,满足 order_num > 2 的记录。根据 city 字段做排序,然后把结果集返回客户端。PS:回表的概念我就不说了哈,有兴趣的可以看我之前的《MySQL 索引详解》文章,强烈建议你去看,非常重要的是概念。4.2 group by + having现在产品又改需求统计每个城市的下单的人数,且总的下单人数需要在 100 以上。OS:mmp,又改根据需求很快写出 sql 语句:select city, count(*) as num from sale_order group by city having num > 100;再用 explain 分析一下,得出如下结果:哇草,咋回事?跟没加 having 的执行流程一样的?你没看错,其实 having 不直接参与到执行计划中去,它是对结果集操作的,所以这里的加的 having 跟没加是一样的执行计划。画个图,大概就是这样的:4.3 group by + where + having现在产品又改需求统计每个城市的下单超过两单的人数,且总的人数需要在 100 以上。OS:mmmp,又改按照惯例,我们给 where 条件加上索引:alter table sale_order add index idx_order_num (order_num);根据需求很快写出 sql 语句:select city, count(*) as num from sale_order where order_nunm > 2 group by city having num > 100;explain 结果:执行流程:创建内存临时表,表里面有两个字段:city 和 num;根据索引 idx_order_num 找到大于 2 的数据的主键 ID;通过主键 ID 取出 city = 某城市(比如广州、深圳、上海,囊括你表里涉及到的城市)的记录;临时表没有 city = 某城市的记录,直接插入,并记为 (某城市,1);临时表里有 city = 某城市的记录,直接更新,把 num 值 +1。重复 2、3 步骤,直至找到所有吗,满足 order_num > 2 的记录。根据 city 字段做排序。having 对结果集进行过滤,并返回客户端不难看出这里的执行流程跟 4.1 一样就多了个 having 过滤05 group by 优化根据上面的分析,我们知道 group by 是需要创建临时表并且排序的。耗时也应该在这两个步骤,那我们应该从这两个步骤入手优化。如果分组字段本身就是有序的,我们是不是就不用排序了?或者我们的需求并没有要求排序是不是就可以优化了?如果必须使用临时表,我们是不是可以只用内存临时表呢?如果数据量实在是太大,是不是可以直接用磁盘临时表,而不是发现内存临时表不够大才用它呢?以上可以总结出四个优化方案:分组字段加索引order by null 不排序尽量使用内存临时表SQL_BIG_RESULT5.1 分组字段加索引select city, count(*) as num from sale_order group by city;上面的 sql 中,city 没加索引,所以这时的 group by 还是要使用临时表的。那我们可不可以个组合索引 idx_city,结果如下所示:加索引:alter table sale_order add index idx_city (city);结果:Extra 是不是 Using temporary 和 Using filesort 都没了?所以不用排序也不用临时表啦。那有小伙伴又问了,那我有 where 条件怎么办?那就加组合索引呗:alter table sale_order add index idx_order_num_city(order_num,city);但是这种情况只适用于 where 条件是等值的,如果有大于、小于的情况还是避免不了排序和使用临时表。适用情况:select city, count(*) as num from sale_order where order_num = 2 group by city;不适用情况:select city, count(*) as num from sale_order where order_num > 2 group by city;5.2 order by null 避免排序如果需求是不用排序,我们就可以这样做。在 sql 末尾加上 order by nullselect city, count(*) as num from sale_order where order_num > 2 group by city order by null;从分析结果看,还是需要使用临时表的。5.3 尽量使用内存临时表有些小伙伴可能很懵哈,内存临时表是啥?其实 mysql 临时表分内存临时表和磁盘临时表。但是这里就不展开了,有时间专门写一篇文章介绍。group by 在执行过程中使用内存临时表还是不够用,那就会使用磁盘临时表。内存临时表的大小是有限制的,mysql 中 tmp_table_size 代表的就是内存临时表的大小,默认是 16M。当然你可以自定义社会中适当大一点,这就要根据实际情况来定了。比如:可以设置成 32M,也就是 33554432 字节。set tmp_table_size=33554432;5.4 SQL_BIG_RESULT如果数据量实在过大,大到内存临时表都不够用了,这时就转向使用磁盘临时表。而发现不够用再转向这个过程也是很耗时的,那我们有没有一种方法,可以告诉 mysql 从一开始就使用 磁盘临时表呢?有的,在 group by 语句中加入 SQL_BIG_RESULT 提示 MySQL 优化器直接用磁盘临时表。优化器分析,磁盘临时表是 B+ 树存储,存储效率不如数组来得高。所以直接用数组存储。用法如下:select SQL_BIG_RESULT city, count(*) as num from sale_order where group by city;此时的执行过程就不需要创建临时表啦:初始化 sort_buffer(排序缓冲区),放入 city 字段;扫描 sale_order 表,取出 city 的值存入 sort_buffer 中;扫描完成后,对 sort_buffer 的字段 city 做排序(如果 sort_buffer 内存不够用,就会利用磁盘临时文件辅助排序);排序完成后,就得到了一个有序数组。根据有序数组,得到数组里面的不同值,以及每个值的出现次数06 group by 面试题6.1 group by 一定要配合聚合函数使用吗?不一定,以下 sql 语句,我用的 MySQL 5.7.13 运行是报错的;但是我司的 MySQL 8.0 版本是没有问题的。select goods_name, city from sale_order group by city;出现这个错误的原因是 mysql 的 sql_mode 开启了 ONLY_FULL_GROUP_BY 模式。查看 sql_mode:select @@GLOBAL.sql_mode;如果想要不做限制的话,直接重新设置 sql_mode 的值,把 ONLY_FULL_GROUP_BY 去掉即可。当然,开启这个要慎重,有可能会造成一些意想不到的错误,一般情况下还是加上这个设置比较稳妥。6.2 group by 后面的一定要出现在 select 中吗?不一定,我的就没报错。当然,这个还跟版本有关系。大家可以回去自己实践下。select max(order_num) from sale_order group by city;6.1 where & having 的区别?where 用于条件筛选,having 用于分组后筛选where 条件后面不能跟聚合函数,having 一般配合 group by 或者聚合函数(min、max、avg、count、sum)使用where 用在 group by 之前,having 用在 group by 之后
02 Java 注解的分类上面介绍注解的语法和使用,我们遇到了 @Target、@Retention 等没见过的注解,你可能有点懵。但没关系,听我说道说道。Java 中有 @Override、@Deprecated 和 @SuppressWarnings 等内置注解;也有 @Target、@Retention、@Documented、@Inherited 等修饰注解的注解,称之为元注解。2.1 内置注解Java 定义了一套自己的注解,其中作用在代码上的是:@Override - 检查该方法是否是重写方法。如果发现其父类,或者是引用的接口中并没有该方法时,会报编译错误。@Target(ElementType.METHOD) @Retention(RetentionPolicy.SOURCE) public @interface Override { }@Deprecated - 标记过时方法。如果使用该方法,会报编译警告。@Documented @Retention(RetentionPolicy.RUNTIME) @Target(value={CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, PARAMETER, TYPE}) public @interface Deprecated { }@SuppressWarnings - 用于有选择的关闭编译器对类、方法、成员变量、变量初始化的警告。@Target({TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE}) @Retention(RetentionPolicy.SOURCE) public @interface SuppressWarnings { String[] value(); }JDK7 之后又加了 3 个,这几个的用法,我也用得很少。就不过多介绍了,感兴趣的小伙伴自行百度分别是:@SafeVarargs - Java 7 开始支持,忽略任何使用参数为泛型变量的方法或构造函数调用产生的警告。@Documented @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.CONSTRUCTOR, ElementType.METHOD}) public @interface SafeVarargs {}@FunctionalInterface - Java 8 开始支持,标识一个匿名函数或函数式接口。@Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface FunctionalInterface {}@Repeatable - Java 8 开始支持,标识某注解可以在同一个声明上使用多次。@Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.ANNOTATION_TYPE) public @interface Repeatable { Class<? extends Annotation> value(); }2.2 元注解元注解就是修饰注解的注解,分别有:2.2.1 @Target用来指定注解的作用域(如方法、类或字段),其中 ElementType 是枚举类型,其定义如下,也代表可能的取值范围public enum ElementType { /**标明该注解可以作用于类、接口(包括注解类型)或enum声明*/ TYPE, /** 标明该注解可以作用于字段(域)声明,包括enum实例 */ FIELD, /** 标明该注解可以作用于方法声明 */ METHOD, /** 标明该注解可以作用于参数声明 */ PARAMETER, /** 标明注解可以作用于构造函数声明 */ CONSTRUCTOR, /** 标明注解可以作用于局部变量声明 */ LOCAL_VARIABLE, /** 标明注解可以作用于注解声明(应用于另一个注解上)*/ ANNOTATION_TYPE, /** 标明注解可以作用于包声明 */ PACKAGE, /** * 标明注解可以作用于类型参数声明(1.8新加入) * @since 1.8 */ TYPE_PARAMETER, /** * 类型使用声明(1.8新加入) * @since 1.8 */ TYPE_USE }PS:如果 @Target 无指定作用域,则默认可以作用于任何元素上。等同于:@Target(value={CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, PARAMETER, TYPE})2.2.2 @Retention用来指定注解的生命周期,它有三个值,对应 RetentionPolicy 中的三个枚举值,分别是:源码级别(source),类文件级别(class)或者运行时级别(runtime)SOURCE:只在源码中可用CLASS:注解在 class 文件中可用,但会被 VM 丢弃(该类型的注解信息会保留在源码里和 class 文件里,在执行的时候,不会加载到虚拟机中),PS:当注解未定义 Retention 值时,默认值是 CLASS,如 Java 内置注解,@Override、@Deprecated、@SuppressWarnning 等RUNTIME:在源码,class,运行时均可用,因此可以通过反射机制读取注解的信息(源码、class 文件和执行的时候都有注解的信息),如 SpringMvc 中的 @Controller、@Autowired、@RequestMapping 等。此外,我们自定义的注解也大多在这个级别。2.2.2.1 理解 @Retention这里引申一下话题,要想理解 @Retention 就要理解下从 java 文件到 class 文件再到 class 被 jvm 加载的过程了。下图描述了从 .java 文件到编译为 class 文件的过程:其中有一个注解抽象语法树的环节,这个环节其实就是去解析注解然后做相应的处理。所以重点来了,如果你要在编译期根据注解做一些处理,你就需要继承 Java 的抽象注解处理器 AbstractProcessor,并重写其中的 process () 方法。一般来说只要是注解的 @Target 范围是 SOURCE 或 CLASS,我们就要继承它;因为这两个生命周期级别的注解等加载到 JVM 后,就会被抹除了。比如,lombok 就用 AnnotationProcessor 继承了 AbstractProcessor,以实现编译期的处理。这也是为什么我们使用 @Data 就能实现 get、set 方法的原因。2.2.3 @Documented执行 javadoc 的时候,标记这些注解是否包含在生成的用户文档中。2.2.4 @Inherited标记这个注解具有继承性,比如 A 类被注解 @Table 标记,而 @Table 注解被 @Inherited 声明(具备继承性);继承于 A 的子类,也继承 @Table 注解。//声明 Table 注解,有继承性 @Inherited @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface Table { }03 自定义注解好啦,说了这么多理论。大家也听累了,我也聊累了。那怎么自定义一个注解并让它起作用呢?下面我将带着你们看看我司的防止重复提交的注解是怎么实现的?当然,由于设计内部的东西,我只会写写伪代码。思路在前面介绍过了,为方便阅读我拿下来,大家理解就行。需求是:同一用户,三秒内重复提交一样的参数,就会报异常阻止重复提交,否则正常提交处理写请求。3.1 定义注解首先,定义注解必须是 @interface 修饰;其次,有四个考虑的点:注解的生命周期 @Retention,一般都是 RUNTIME 运行时。注解的作用域 @Target,作用于写请求,也就是 controller 方法上。是否需要元素,用分布式锁实现,必须要有锁的过期时间。给定默认值,也支持自定义。是否生成 javadoc @Documented,这个注解无脑加就对了。基于此,我司的防止重复提交的自定义注解就出来了:@Documented @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface BanReSubmitLock { /** * 锁定时间,默认单位(秒)默认时间(3秒) */ long lockTime() default 3L; }3.2 AOP 切面处理@Aspect @Component public class BanRepeatSubmitAop { @Autowired private final RedisUtils redisUtils; @Pointcut("@annotation(com.nasus.framework.web.annotation.BanReSubmitLock)") private void banReSubmitLockAop() { } @Around("banReSubmitLockAop()") public Object aroundApi(ProceedingJoinPoint point) throws Throwable { // 获取 AOP 切面方法签名 MethodSignature signature = (MethodSignature) point.getSignature(); // 方法 Method method = signature.getMethod(); // 获取目标方法上的 BanRepeatSubmitLock 注解 BanReSubmitLock banReSubmitLock = method.getAnnotation(BanReSubmitLock.class); // 根据用户信息以及提交参数,创建 Redis 分布式锁的 key String lockKey = createReSumbitLockKey(point, method); // 根据 key 获取分布式锁对象 Lock lock = redisUtils.getReSumbitLock(lockKey); // 上锁 boolean result = lock.tryLock(); // 上锁失败,抛异常 if (!result) { throw new Exception("请不要重复请求"); } // 其他处理 ... } /** * 生成 key */ private String createReSumbitLockKey(ProceedingJoinPoint point, Method method) { // 拼接用户信息 & 请求参数 ... // MD5 处理 ... // 返回 } }可以看到这里利用了 AOP 切面的方式获取被 @NoReSubmitLock 修饰的方法,并借此拿到切点(被注解修饰方法)的参数、用户信息等等,通过 MD5 处理,最终尝试上锁。3.3 使用public class TestController { // NoReSubmitLock 注解修饰 save 方法,防止重复提交 @NoReSubmitLock public boolean save(Object o){ // 保存逻辑 } }使用也非常简单,只需要一个注解就可以完成大部分的逻辑;如果不用注解,每个写接口的方法都要写一遍防止重复提交的逻辑的话,代码非常繁琐,难以维护。通过这个例子相信你也看到了,注解的作用。04 总结本文介绍了注解的作用主要是标记、检查以及解耦;介绍了注解的语法;介绍了注解的元素以及传值方式;介绍了 Java 的内置注解和元注解,最后通过我司的一个实际例子,介绍了注解是如何起作用的?注解是代码的特殊标记,可以在程序编译、类加载、运行时被读取并做相关处理。其对应 RetentionPolicy 中的三个枚举,其中 SOURCE、CLASS 需要继承 AbstractProcessor (注解抽象处理器),并实现 process () 方法来处理我们自定义的注解。而 RUNTIME 级别是我们常用的级别,结合 Java 的反射机制,可以在很多场景优化代码。
01 什么是注解?Java 注解(Annotation),相信大家没用过也见过。个人理解,注解就是代码中的特殊标记,这些标记可以在编译、类加载、运行时被读取,从而做相对应的处理。注解跟注释很像,区别是注释是给人看的(想想自己遇到那些半句注释没有的业务代码,还是不是很难受?);而注解是给程序看的,它可以被编译器读取。1.1 注解的作用注解大多时候与反射或者 AOP 切面结合使用,它的作用有很多,比如标记和检查,最重要的一点就是简化代码,降低耦合性,提高执行效率。比如我司就是通过自定义注解 + AOP 切面结合,解决了写接口重复提交的问题。简单描述下我司防止重复提交注解的逻辑:请求写接口提交参数 —— 参数拼接字符串生成 MD5 编码 —— 以 MD5 编码加用户信息拼接成 key,set Redis 分布式锁,能获取到就顺利提交(分布式锁默认 3 秒过期),不能获取就是重复提交了,报错。如果每加一个写接口,就要写一次以上逻辑的话,那程序员会疯的。所以,有大佬就使用注解 + AOP 切面的方式解决了这个问题。只要在写接口 Controller 方法上加这个注解即可解决,也方便维护。1.2 注解的语法以我司防止重复提交的自定义注解,介绍下注解的语法。它的定义如下:// 声明 NoRepeatSubmit 注解 @Target(ElementType.METHOD) // 元注解 @Retention(RetentionPolicy.RUNTIME) // 元注解 public @interface NoRepeatSubmit { /** * 锁定时间,默认单位(秒) */ long lockTime() default 3L; }Java 注解使用 @interface 修饰,我司的 NoRepeatSubmit 注解也不例外。此外,还使用两个元注解。其中 @Target 注解传入 ElementType.METHOD 参数来标明 @NoRepeatSubmit 只能用于方法上,@Retention(RetentionPolicy.RUNTIME) 则用来表示该注解生存期是运行时,从代码上看注解的定义很像接口的定义,在编译后也会生成 NoRepeatSubmit.class 文件。1.3 注解的元素定义在注解内部的变量,称之为元素。注解可以有元素,也可以没有元素。像 @Override 就是无元素的注解,@SuppressWarnings 就属于有元素的注解。@Target(ElementType.METHOD) @Retention(RetentionPolicy.SOURCE) public @interface Override { }@Target({TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE, MODULE}) @Retention(RetentionPolicy.SOURCE) public @interface SuppressWarnings { String[] value(); }带元素的自定义注解:@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface NoRepeatSubmit { /** * 锁定时间,默认单位(秒) */ long lockTime() default 2L; }1.3.1 注解元素的格式注解的元素格式如下:// 基本格式 数据类型 元素名称(); // 带默认值 数据类型 元素名称() default 默认值;1.3.2 注解元素的数据类型注解元素支持如下数据类型:所有基本类型(int,float,boolean,byte,double,char,long,short) String Class enum Annotation 上述类型的数组声明注解元素时可以使用基本类型但不允许使用任何包装类型,同时注解也可以作为元素的类型,也就是嵌套注解。1.3.3 编译器对元素默认值的限制遵循规则:元素要么具有默认值,要么在使用注解时提供元素的值。对于非基本类型的元素,无论是在源代码中声明,还是在注解接口中定义默认值,都不能以 null 作为值。1.4 注解的使用注解是以 @注释名 的格式在代码中使用,比如:以下常见的用法。public class TestController { // NoRepeatSubmit 注解修饰 save 方法,防止重复提交 @NoRepeatSubmit public static void save(Object o){ // 保存逻辑 } // 一个方法上可以有多个不同的注解 @Deprecated @SuppressWarnings("uncheck") public static void getDate(){ } }在 save 方法上使用 @NoRepeatSubmit (我司自定义注解),加上之后,编译期会自动识别该注解并执行注解处理器的方法,防止重复提交;而对于 @Deprecated 和 @SuppressWarnings (“uncheck”),则是 Java 的内置注解,前者意味着该方法是过时的,后者则是忽略指定的异常检查。
01 做个实验首先整一张表结构:订单表 order,主键是 id,另外还有一个索引 index_city 用 city 字段建索引。CREATE TABLE `order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `user_code` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户编号', `goods_name` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '商品名称', `order_date` timestamp(0) NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', `city` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '下单城市', `order_num` int(10) NOT NULL COMMENT '订单号数量', PRIMARY KEY (`id`) USING BTREE, INDEX `city_index`(`city`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 2000002 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '商品订单表' ROW_FORMAT = Compact;1.1 插入数据造点数据,为了效果。我直接造 200W 条数据,然后直接 delete 删掉一半。// 第一步:创建函数 delimiter // DROP PROCEDURE IF EXISTS proc_buildata; CREATE PROCEDURE proc_buildata ( IN loop_times INT ) BEGIN DECLARE var INT DEFAULT 0; WHILE var < loop_times DO SET var = var + 1; INSERT INTO `order` ( `id`, `user_code`, `goods_name`, `order_date`, `city` , `order_num`) VALUES ( var, var + 1, '有线耳机', '2021-06-20 16:46:00', '杭州', 1 ); END WHILE; END // delimiter; // 第二步:调用上面生成的函数,即可插入数据 CALL proc_buildata(2000000);插入完成,耗时贼久。建议批量插入:插入完成,到 MySQL 查看文件大小对应文件大小(下图中的 .idb 文件)200W 数据大概是 184M 左右的大小:1.1.1 一些小知识1、一个 InnoDB 表包含表结构定义和数据两部分,在 MySQL 8.0 版本以前,表结构是存在以 .frm 为后缀的文件里。而 MySQL 8.0 版本,则已经允许把表结构定义放在系统数据表中了2、表数据既可以存在共享表空间里,也可以是单独的文件。由参数 innodb_file_per_table 控制。MySQL 5.6.6 版本之后,默认是 ON,也即每个 InnoDB 表数据以及索引存储在一个以 .ibd 为后缀的文件中。3、为方便管理建议你设置为 ON,因为当你不需要这个表时,通过 drop table 命令,系统直接删除这个文件。而如果放在共享表空间中,即使表删掉了,空间也是不会回收的。4、由于表结构文件一半很小,本文讨论的表空间是指表数据文件 .ibd 的变化。1.2 删除数据批量删除其中的 100W 的数据,此时的总数据量:再次查看 order.ibd 文件的大小,还是 184M。也就是说 MySQL 表删除一半数据之后,表空间并没有随之减小,好特么奇怪呀。这是为啥呢?这就得说说 MySQL 删除数据的流程了02 删除数据流程还记得我之前讲的索引原理么?不清楚的朋友们,请看以下这篇文章,看看 InnDB 索引是怎么组织数据的。不然你是看不懂下面的过程的。MySQL 索引原理InnoDB 里的数据都是用 B+ 树的结构组织的,假设现在我们表里的数据长这样:我删除 id = 10 的这行数据,MySQL 实际上只是把这行数据标记为已删除,并不会回收表空间,而是给后来的数据复用。那怎么复用呢?总得有规则吧?如果这时客户端申请插入的是 id 在 (8,18) 范围内的数据,此时 id = 10 的位置就会被复用。比如我插入 id=11 的记录就会复用 id=10 的空间。但如果插入的是 id = 20 的数据就没法复用这个空间了。2.1 整页删除InnoDB 的数据是按页存储的,如果删掉了一个数据页上的所有记录,会怎么样?那就是这个页的所有数据都能被复用。但是数据页的复用跟记录的复用是不同,记录的复用有限定范围,而数据页的复用并没有限制。举例:如果我现在把 P2 整页数据删除,那么限制我要插入 id = 50 的数据也是可以被复用,当然这时候 P2 页的范围就不再是 id (8,19) 了。2.2 什么是数据 "空洞"?如果相邻的两个数据页利用率都很小,MySQL 会把这两个页的数据合到其中一个页,另外一个被标记为可复用。当然,如果用 delete 删除整个表数据的结果就是:所有的数据页都会被标记为可复用。但是磁盘上,文件不会变小。所以,delete 命令其实只是把记录的位置,或者数据页标记为了可复用,但磁盘文件的大小是不会变的。也就是说,通过 delete 命令是不能回收表空间的。这些可以复用,而没有被使用的空间,被称为空洞。03 新增数据不止是删除数据会造成空洞,插入数据也会如果数据是随机插入,非主键自增的,就可能造成索引的数据页分裂。下图中,假设数据页 P2 已满,这时再插入 id=16 的记录,就需要申请一个新的 P3 页来存储数据。等到页分裂完成后,P2 的末尾就留下了空洞(PS:实际上,可能不止 1 个记录的位置是空洞)。但是如果数据是按照索引递增顺序插入的,索引就是紧凑的,就不会有页分裂这回事。这也是为什么数据库要设置自增 ID 的主要原因04 修改数据不仅是插入数据,更新数据也会造成空洞。很多人可能不理解这个过程,更新数据主键都没变怎么会造成数据空洞呢?实际上更新索引上的值,可以理解为删除一个旧的值,再插入一个新值。比如,我把 id = 10 的城市从北京改成东京,就会造成空洞。你可能会说不对啊,上图中 id 都没变怎么会数据空洞呢?实际上文章开头就说了,city 这个字段是二级索引,索引 index_city 的值从北京变成南京,北京的索引数据会标记为删除,然后重新建立南京的索引数据,一删一增的过程就产生了空洞。总结一句:更新过程中如果有索引更新了,就会造成数据空洞。也就是二级索引树更新造成的数据空洞05 重建表,回收空间从上面的结论你也知道了,大量的增删改确实会造成空洞的。如果能够把这些空洞去掉,就能达到收缩表空间的目的。而重建表就能做到。具体怎么做呢?拿 order 表举例,可以新建一个临时表 order_tmp,它的表数据结构与 order 完全相同。然后按 id 从小到大的顺序把数据从 order 表读出来插入到 order_tmp 表。此时,由于 order_tmp 并没有数据空洞,所以它的主键索引更紧凑,数据页利用率更高。等到迁移完成,可以用 order_tmp 表替代 order 表,从而收缩 order 表的空间。以上描述的一系列操作,是不是觉得超级麻烦?贴心的 MySQL 在 5.5 版本之前,提供了以下命令来重建表,回收空间。alter table order engine=InnoDB执行它,临时表 order_tmp 不需要你自己创建,MySQL 会自动完成转存数据、交换表名、删除旧表的操作。我画个流程图,帮助大家理解下:看到这里你可能觉得完美解决了空洞问题,其实不然,这个方案最大的缺点就是:表重构过程中,往临时表插入数据是很耗时的;如果有新的数据写入 order 时,不会被迁移,会造成数据丢失。5.2 Online DDL那咋办呢?MySQL 5.6 版本开始引入的 Online DDL,解决了这个问题。引入了 Online DDL 之后,重建表的流程只这样的:建立一个临时文件,扫描表 order 主键的所有数据页;用数据页中表 order 的记录生成 B+ 树,存储到临时文件中;生成临时文件的过程中,将所有对 order 的操作记录在一个日志文件(row log)中,对应的是图中 state2 的状态;临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表 order 相同的数据文件,对应的就是图中 state3 的状态;用临时文件替换表 order 的数据文件。上图,方便你们理解:由于日志文件记录和重放操作这个功能的存在,这个方案在重建表的过程中,允许对表 A 做增删改操作。06 总结这篇文章我们聊了 MySQL 中大量的增删改都有可能造成数据空洞、数据库中收缩表空间的方法。其中 delete 命令是不会回收表空间的,还要通过 alter table 命令重建表,才能达到表文件变小的目的。这个命令在 5.6 版本以及之后可以考虑在业务低峰期使用的,但在 5.5 及之前的版本,这个命令是会阻塞 DML 的,建议你慎重。另外,重建表都会扫描原表数据和构建临时文件。对于大表来说,这个操作是很消耗 IO 和 CPU 的。因此,如果是线上服务你要很小心地控制操作时间。如果想要比较安全的操作的话,推荐使用 GitHub 开源的 gh-ost 来做。
01 Material Theme UI推荐指数 :⭐⭐⭐⭐下载地址:https://plugins.jetbrains.com/plugin/8006-material-theme-ui其实这是 IDEA 中的一个插件,很火,一共有一千多万的下载量。相信很多小伙伴都在使用,我就不贴下载地址了。直接在 IDEA 设置里面的插件就可以搜索安装,非常方便。这个插件里面包含好多款主题,每一款都非常精致。狗哥随意选了一个,它的效果图如下,看还是蛮好看的(够骚气)。但是它默认的字体是真的很小,有喜欢的小伙伴们,就需要你们自行调整下啦。02 One Dark theme推荐指数 :⭐⭐⭐⭐⭐下载地址:https://plugins.jetbrains.com/plugin/11938-one-dark-theme强烈推荐,这个主题是我用的最长时间的一个主题,很多人也喜欢。我看了下一共有 1982409 的下载量,真的很棒。简洁大气,看着很舒服。我个人非常喜欢(可能是因为黄色比较多。。。)。正如它的名字:一个黑暗的主题,方便加班吗,难受。03 Gradianto推荐指数 :⭐⭐⭐⭐⭐下载链接:https://plugins.jetbrains.com/plugin/12334-gradiantoGradianto 我叫它健康主题。它本身就是一种非常自然的色调,非常利用眼睛保护。我感觉挺舒服的,代码颜色的过渡也很自然。另外,这个主题提供了 4 中自然色彩主题选择,其中最健康的 Gradianto Nature Green 的效果图如下,真的很健康(就是绿了点~)04 Dark Purple Theme推荐指数 :⭐⭐⭐⭐⭐下载地址:https://plugins.jetbrains.com/plugin/12100-dark-purple-theme从名字就知道这是一款非常骚气的紫色色调的深色主题,坐我隔壁的小胖非常喜欢,喜欢骚紫的小伙伴也不要错过。这个主题的效果图如下。个人觉得整体颜色搭配的是比较不错的,就是比较骚,适合编码!05 Hiberbee Theme推荐指数 :⭐⭐⭐⭐⭐下载链接:https://plugins.jetbrains.com/plugin/12118-hiberbee-theme一款受到了 Monokai Pro 和 MacOS Mojave 启发的主题,是一款色彩层次分明的浅色主题。这个主题的效果图如下。看着也是非常赞!适合编码!上面推荐的都是偏暗色系的主题,这里我再推荐两款浅色系的主题。06 Gray Theme推荐指数 :⭐⭐⭐下载链接:https://plugins.jetbrains.com/plugin/12103-gray-theme这是一款对比度比较低的一款浅色主题,这个主题,我是没想到的,居然有 10 万人下载。它其实不太适合编码,猜测是经常要浏览 MD 文件的小伙伴使用,毕竟这款主题是专门为在 IDEA 中使用 Markdown 而设计的。这个主题的效果图如下。07 Roboticket Light Theme推荐指数 :⭐⭐⭐下载链接:https://plugins.jetbrains.com/plugin/12191-roboticket-light-theme这是一款对比度比较低的浅色主题,使用的人相对较少,我看只有 17828 人下载使用,不太适合代码阅读。这个主题的效果图如下。
01 前言哈喽,我是狗哥。小伙伴都知道我最近换工作了,薪资、工作内容什么的都是我比较满意的。五月底也面试了有 6、7 家公司,应该拿了有 5 个 offer。这段时间也被问了很多面试题,我打算写一个专题分享出来,希望对你们有所帮助~我的号还没留言,对文章内容或者我个人有什么建议的。希望你们能加我微信聊聊,我很开心能跟大家交流。TIP:文末福利,记得领取~这期面试官提的问题是:count (1) 和 count (*) 有啥区别?你更推荐用哪个?数据量很大的情况下怎么优化?国际惯例先上思维导图:02 四种 count 的区别count 是一个聚合函数,对于返回的结果集,一行行地判断,如果 count 函数的参数不是 NULL,累计值就加 1,否则不加。最后返回累计值。既然都说到这里了,干脆就把 4 种 count 的区别都对比下:count (字段):遍历整张表,需要取值,判断 字段!= null,按行累加;count (主键) :遍历整张表,需要取 ID,判断 id !=null,按行累加;count (1) :遍历整张表,不取值,返回的每一行放一个数字 1,按行累加;count (*):不会把全部字段取出,专门做了优化,不取值。count ( * ) 肯定不是 null,按行累加。count (主键) 可能会选择最小的索引来遍历,而 count (字段) 的话,如果字段上没有索引,就只能选主键索引,所以性能上 count (字段) < count (主键)因为 count (*) 和 count (1) 不取字段值,减少往 server 层的数据返回,所以比其他 count (字段) 要返回值的性能较好;所以结论是:** 按照效率排序的话,count (字段)<count (主键 id)<count (1)≈count (),建议尽量使用 count ()。2.1 MySQL 对 count (*) 做的优化InnoDB 是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。因此,普通索引树比主键索引树小很多。对于 count (*) 来说,遍历哪个索引树得到的结果逻辑上都是一样的。MySQL 优化器会找到最小的那棵树来遍历。在保证逻辑正确的前提下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一。03 count (*) 的实现方式count (*) 在不同引擎中的实现方式是不一样的:MyISAM:不支持事务,把一个表的总行数存在了磁盘上,因此执行 count (*) 的时候会直接返回这个数,效率很高;InnoDB:支持事务,它执行 count (*) 的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。当然这里讨论的是没有 where 条件下的 count,如果有 where 条件,那么即使是 MyISAM 也必须累积计数的。至于有 where 条件怎么执行,建议看看海神的这篇文章:SELECT COUNT (*) 会造成全表扫描吗?当你的记录数越来越多的时候,计算一个表的总行数会越来越慢。你可能会问:为什么 InnoDB 不跟 MyISAM 一样,也把数字存起来呢?其实是因为 InnDB 支持事务的 MVCC 的原因,当前时刻的 SQL 应该返回的记录数是多少,它也需要扫描才知道。不知道 MVCC 的,可以看看之前的旧文:MySQL 事务与 MVCC看完还不懂?举个例子:假设表 t 中现在有 10000 条记录,有三个用户并行的会话。会话 A 先启动事务并查询一次表的总行数;会话 B 启动事务,插入一行后记录后,查询表的总行数;会话 C 先启动一个单独的语句,插入一行记录后,查询表的总行数。它的执行流程以及结果是这样的:你也发现了,因为 MVCC 机制,事务之间是存在可见性的。所以,并发环境下每个会话得到的数据是不一样的。分析:会话 A 在 C 之前启动,C 可见 A 且会话 C 自己插入一行,再 count (*),对它自己来说肯定是可见的、所以结果 +1。会话 A、C 在 B 之前启动,B 可以看见 A、C,自己插入一条数据 +1、C 插入一条数据 +1、所以 B 结果 + 204 TABLE_ROWS 能代替 count (*) 吗?如果你看过官方文档的话,你会知道 show table status 命令,它的结果有个 ROWS 字段就是估算该表的数据量,如下所示:真实数据:图一是估算数据、图二是真实数据。实际上你会发现两种数据不一致,因为 show table status 命令对数量的统计是估算的,并不准确。到这里我们小结一下:MyISAM 表虽然 count (*) 很快,但是不支持事务;show table status 命令虽然返回很快,但是不准确;InnoDB 表直接 count (*) 会遍历全表,虽然结果准确,但会导致性能问题。那么问题来了:假设我现在有个订单页面,更新很频繁,并且需求是要显示实时的操作记录总数、并且展现最新的 100 条记录信息。应该用那种方式呀?很明显只能自己计数呀,那么如何设计呢?05 基于 count (*) 的计数方案基本思路就是:你需要自己找一个地方,把操作记录表的行数存起来。5.1 结果放在 Redis更新频繁,我第一时间肯定是想到 Redis 这神器呀。表插入一行 Redis 计数加一,删除一行计数减一。Redis 性能贼好,听起来这方案似乎完美。仔细一想,还是有 ** 丢失更新的问题:MySQL 插入一行,Redis 宕机咋办?** 你可能会说,恢复之后再执行一次 count (*),再次缓存不就得了?好,丢失更新的问题确实解决了,但是 MySQL 和 Redis 的数据怎么保证一致性呢?假设我现在要取最新的 100 条数据,并在前端展现。时序图如下:很明显,会话 A 插入数据,但是还没来得及更新 Redis;会话 B 查询 Redis 计数,并向 MySQL 查询最新的 100 条记录。此时数据就不精确:查到的 100 行结果里面有最新插入记录,而 Redis 的计数里还没加 1,总数不精确。有人可能说,你 SessionA 换个顺序不就好了。先更新 Redis 计数、再插入 MySQL 表记录。像下面这样其实在 T3 时刻还是会出现不一致的情况:查到的 100 行结果里面没有最新插入记录,而 Redis 的计数里加了 1,最新记录不精确所以说,用 Redis 保存计数有丢失数据和计数不精确的问题。5.2 结果放在 MySQL上面出现数据丢失或计算不精确的原因在于:MySQL 和 Redis 的事务不是同一体系的,我们并不能保证两者事务的原子性,而把 Redis 也换成 MySQL 这就迎刃而解了。那我们换个思路,不能新建一张 MySQL 表 C 专门用来存放订单表的总数吗?看到这里,你可能会说这不跟开头冲突了么?由于 InnoDB 要支持事务,从而导致 InnoDB 表不能把 count (*) 直接存起来,然后查询的时候直接返回计算好的。你现在说又能存,这不扯了么?其实我们可以利用事务原子性和隔离特性解决这一问题:表 C 计数器的修改和订单数据的写表在一个事务中。读取计数器和查询最近订单数据也在一个事务中。看到这里,有没有清晰一点?我来画个时序图:会话 A 进行写操作,T3 时刻,A 的更新事务还没有提交;所以计数值加 1 这个操作对会话 B 还不可见。也就是说会话 B 看到的结果在逻辑上就是一致的。看到这里是不是有点,成也事务败也事务的感觉?06 总结首先,在 4 中 count 的对比中,我们应该选 count (*),因为 MySQL 对它作做了优化;第二,count (*) 在两种搜索引擎中的实现是不一样的,MyIsam 直接把总数存在硬盘、而 InnDB 则是老实计数;第三,分析了 Redis 存储计数会出现的问题,把计数值也放在 MySQL 中,利用事务的原子性和隔离性,就可以解决一致性的问题。最后,数据量不大,我们尽量用 count (*) 实现计数;数据量很大的情况考虑新建 MySQL 表存储计数,用事务的原子性和隔离性解决。
03 rowid 排序上面的全字段排序其实会有很大的问题,你可能发现了。我们需要查询的字段都要放到 sort_buffer 中,如果查询的字段多了起来,内存占用升高,就会很容易打满 sort_buffer 。这时,就要用很多的临时文件辅助排序,导致性能降低。那问题来了:我们思考的方向应该是降低排序的单行长度,哪有没有方法能做到呢?肯定是有的,MySQL 之所以走全字段排序是由 max_length_for_sort_data 控制的,它的 默认值是 1024。show variables like 'max_length_for_sort_data';因为本文示例中 city,order_num,user_code 长度 = 16+4+16 =36 < 1024, 所以走的是全字段排序。我们来改下这个参数,改小一点,SET max_length_for_sort_data = 16;当单行的长度超过这个值,MySQL 就认为单行太大,要换一个算法。原来 city、user_code、order_num 占用的长度是 36,显然放不下全部查询字段了。这时就要换算法:sort_buffer 只存 order_num 和 id 字段。这时的流程应该是这样的:1、初始化 sort_buffer,确定放入两个字段,即 order_num 和 id;2、从索引 city 找到第一个满足 city=' 广州’条件的主键 id,也就是图中的 ID_3;3、回表,取 order_num、id 这两个字段,存入 sort_buffer 中;4、从索引 city 取下一个记录的主键 id;5、重复步骤 3、4 直到不满足 city=' 广州’条件为止,也就是图中的 ID_X;6、对 sort_buffer 中的数据按照字段 order_num 进行排序;7、遍历排序结果,取前 1000 行,再次回表取出 city、order_num 和 user_code 三个字段返回给客户端。图示:由图可见,这种方式其实多了一次回表操作、但 sort_buffer_size 占用却变小了。此时,执行上面的检测方法,可以发现 OPTIMIZER_TRACE 表中的信息变了。sort_mode 变成了 <sort_key, rowid>,表示参与排序的只有 order_num 和 id 这两个字段。number_of_tmp_files 变成 0 了,是因为这时参与排序的行数虽然仍然是 6883 行,但是每一行都变小了,因此需要排序的总数据量就变小了,sort_buffer_size 能满足排序用的内存,所以临时文件就不需要了。examined_rows 的值还是 6883,表示用于排序的数据是 6883 行。但是 select @b-@a 这个语句的值变成 7884 了。因为这时候除了排序过程外,在排序完成后,还要回表一次。由于语句是 limit 1000,所以会多读 1000 行。3.1 做个小结rowid 排序中,排序过程一次可以排序更多行,但是需要回表取数据。如果内存足够大,MySQL 会优先选择全字段排序,把需要的字段都放到 sort_buffer 中,这样排序后就会直接从内存返回查询结果了,不用回表。这也就体现了 MySQL 的一个设计思想:如果内存够,就要多利用内存,尽量减少磁盘访问。对于 InnoDB 表来说,rowid 排序会要求回表多造成磁盘读,因此不会被优先选择。这两种都是因为数据本身是无序的,才要放到 sort_buffer 并生成临时文件才能做排序。哪有没有办法,让数据本身就有序呢?回想下,我们学过的索引就是有序的。04 索引优化这时,要是我把 city、order_num 建一个组合索引,得出的数据是不是就是天然有序的了?比如:alter table `order` add index city_order_num_index(city, order_num);此时,order 表的索引长这样:文章开头的 sql 执行语句。执行流程长这样:1、从索引 (city,order_num) 找到第一个满足 city=' 广州’条件的主键 id;2、回表,取 city、order_num、user_code 三个字段的值,作为结果集的一部分直接返回;3、从索引 (city,order_num) 取下一个记录主键 id;4、重复步骤 2、3,直到查到第 1000 条记录,或者是不满足 city=' 广州’条件时循环结束。用 explain 看下,这个过程不需要排序,更不需要临时表。只需要一次回表:从图中可以看到,Extra 字段中没有 Using filesort 了,也就是不需要排序了。而且由于 (city,order_num) 这个联合索引本身有序,只要找到满足条件的前 1000 条记录就可以退出了,再回表一次。也就是说,只需要扫描 2000 次。问题来了,还有没有更优解呢?05 终极优化上面的方法,还是有一次回表,主要是因为索引中不包括 user_code。回顾下我们之前学过的 sql 优化,是怎么避免回表的?查询字段,加到组合索引中呀,对应到这张表,就是把 user_code 也加到组合索引中:alter table `order` add index city_order_num_user_code_index(city, order_num, user_code);此时的流程长这样,直接取数据就完事了:explain 看下执行情况:从图中可知,Extra 字段中多了 Using index 了,也就是使用了索引覆盖。连回表都不需要了,只需扫描 1000 次。完美~5.1 参数调优除此以外,还可以通过调整参数优化 order by 的执行。比如调整 sort_buffer_size 尽量大点,因为 sort_buffer 太小,排序数据量大的话,会借助磁盘临时文件排序。如果 MySQL 服务器配置高的话,可以稍微调大点。再比如把 max_length_for_sort_data 的值调大点。如果该值过小,则会增加回表次数、降低查询性能。06 order by 常见面试题1、查询语句有 in 多个属性时,SQL 执行是否有排序过程?假设现在有联合索引 (city,order_num,user_code),执行以下 SQL 语句:select city, order_num, user_code from `order` where city in ('广州') order by order_num limit 1000in 单个条件,毫无疑问是不需要排序的。explain 一下:但是,in 多个条件时;就会有排序过程,比如执行以下语句select city, order_num, user_code from `order` where city in ('广州','深圳') order by order_num limit 1000explain 以下,看到最后有 Using filesort 就说明有排序过程。这是为啥呢?因为 order_num 本来就是组合索引,满足 "city = 广州" 只有一个条件时,它是有序的。满足 "city = 深圳" 时,它也是有序的。但是两者加到一起就不能保证 order_num 还是有序的了。2、分页 limit 过大,导致大量排序。咋办?select * from `user` order by age limit 100000,10可以记录上一页最后的 id,下一页查询时,查询条件带上 id,如:where id > 上一页最后 id limit 10。也可以在业务允许的情况下,限制页数。3、索引存储顺序与 order by 不一致,如何优化?假设有联合索引 (age,name), 我们需求修改为这样:查询前 10 个学生的姓名、年龄,并且按照年龄小到大排序,如果年龄相同,则按姓名降序排。对应的 SQL 语句应该是:select name, age from student order by age, name desc limit 10;explain 一下,extra 的值是 Using filesort,走了排序过程:这是因为,(age,name) 索引树中,age 从小到大排序,如果 age 相同,再按 name 从小到大排序。而 order by 中,是按 age 从小到大排序,如果 age 相同,再按 name 从大到小排序。也就是说,索引存储顺序与 order by 不一致。我们怎么优化呢?如果 MySQL 是 8.0 版本,支持 Descending Indexes,可以这样修改索引:CREATE TABLE `student` ( `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '主键id', `student_id` varchar(20) NOT NULL COMMENT '学号', `name` varchar(64) NOT NULL COMMENT '姓名', `age` int(4) NOT NULL COMMENT '年龄', `city` varchar(64) NOT NULL COMMENT '城市', PRIMARY KEY (`id`), KEY `idx_age_name` (`age`,`name` desc) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8 COMMENT='学生表';4、没有 where 条件,order by 字段需要加索引吗日常开发中,可能会遇到没有 where 条件的 order by,这时候 order by 后面的字段是否需要加索引呢。如有这么一个 SQL,create_time 是否需要加索引:select * from student order by create_time;无条件查询的话,即使 create_time 上有索引,也不会使用到。因为 MySQL 优化器认为走普通二级索引,再去回表成本比全表扫描排序更高。所以选择走全表扫描,然后根据全字段排序或者 rowid 排序来进行。如果查询 SQL 修改一下:select * from student order by create_time limit m;无条件查询,如果 m 值较小,是可以走索引的。因为 MySQL 优化器认为,根据索引有序性去回表查数据,然后得到 m 条数据,就可以终止循环,那么成本比全表扫描小,则选择走二级索引。07 总结这篇文章跟你聊了聊 order by 的执行流程,以及全字段排序和 rowid 排序的区别,从而得知,MySQL 更愿意用内存去换取性能上的提升。与此同时,通过组合索引的索引覆盖小技巧,我们还可以减少回表的次数。以后设计索引的时候如果业务有涉及排序的字段,尽量加到索引中,并且把业务中其余的查询字段(比如文中的 city、user_code)加到组合索引中,更好地实现索引覆盖。当然,索引也有缺点。它占空间,有维护的代价。所以大家设计的时候还是需要根据自己的实际业务去考虑。最后,我还跟你探讨了关于 order by 的四个经典面试题,希望对你有帮助。
01 前言刚换了新工作,用了两周时间准备,在 3 天之内拿了 5 个 offer,最后选择了广州某互联网行业独角兽 offer,昨天刚入职。这几天刚好整理下在面试中被问到有意思的问题,也借此机会跟大家分享下。这家企业的面试官有点意思,一面是个同龄小哥,一起聊了两个小时(聊到我嘴都干了)。二面是个从阿里出来的架构师,视频面试,我做完自我介绍之后,他一开场就问我:对 MySQL 熟悉吗?我一愣,随之意识到这是个坑。他肯定想问我某方面的原理了,恰好我研究过索引。就回答:对索引比较熟悉。他:order by 是怎么实现排序的?还好我又复习,基本上排序缓冲区、怎么优化之类的都答到点子上。今天也跟大家盘一盘 order by,我将从原理讲到最终优化,给大家聊聊 order by,希望对你有所帮助。国际惯例,先上思维导图。PS:文末有福利1.2 先举个栗子现在有一张订单表,结构是这样的:CREATE TABLE `order` ( id INT ( 11 ) NOT NULL AUTO_INCREMENT COMMENT '主键', user_code VARCHAR ( 16 ) NOT NULL COMMENT '用户编号', goods_name VARCHAR ( 64 ) NOT NULL COMMENT '商品名称', order_date TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', city VARCHAR ( 16 ) DEFAULT NULL COMMENT '下单城市', order_num INT ( 10 ) NOT NULL COMMENT '订单号数量', PRIMARY KEY ( `id` ) ) ENGINE = INNODB AUTO_INCREMENT = 100 DEFAULT CHARSET = utf8 COMMENT = '商品订单表';造点数据:// 第一步:创建函数 delimiter // DROP PROCEDURE IF EXISTS proc_buildata; CREATE PROCEDURE proc_buildata ( IN loop_times INT ) BEGIN DECLARE var INT DEFAULT 0; WHILE var < loop_times DO SET var = var + 1; INSERT INTO `order` ( `id`, `user_code`, `goods_name`, `order_date`, `city` , `order_num`) VALUES ( var, var + 1, '有线耳机', '2021-06-20 16:46:00', '杭州', 1 ); END WHILE; END // delimiter; // 第二步:调用上面生成的函数,即可插入数据,建议大家造点随机的数据。比如改改城市和订单数量 CALL proc_buildata(4000);我生成的数据是这样的:现有需求:查出 618 期间,广州的小伙伴的订单数量和用户编号,并按照订单数量升序,只要 1000 条。根据需求可以得出以下 SQL,相信小伙伴都很熟悉了。select city, order_num, user_code from `order` where city='广州' order by order_num limit 1000;那这个语句是怎么执行的呢?有什么参数可以影响它的行为吗?02 全字段排序得到这个需求,我第一反应是先给 city 字段加上索引,避免全表扫描:ALTER TABLE `order` ADD INDEX city_index ( `city` );用 explain 看看执行情况注意到最后一个 extra 字段的结果是:Using filesort,表示需要排序。 其实 MySQL 会给每个线程分配一块内存用于排序,称为 sort_buffer。为了更直观了解排序的执行流程,我粗略画了个 city 索引的图示:可见,现在满足 sql 条件的就是 ID-3 到 ID-X 这一段数据。sql 的整个流程是这样的:1、初始化 sort_buffer,放入 city、order_num、user_code 这三个字段;2、从索引 city 找到第一个满足 city=' 广州’条件的主键 id,也就是图中的 ID_3;3、到主键 id 索引取出整行,取 city、order_num、user_code 三个字段的值,存入 sort_buffer 中;4、从索引 city 取下一个记录的主键 id;5、重复步骤 3、4 直到 city 的值不满足查询条件为止,对应的主键 id 也就是图中的 ID_X;6、对 sort_buffer 中的数据按照字段 order_num 做快速排序;7、按照排序结果取前 1000 行返回给客户端。这个过程称之为全字段排序,画个图,长这样:其中,按 order_num 排序这个步骤,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数 sort_buffer_size。也就是 MySQL 为排序开辟的内存(sort_buffer)的大小。如果要排序的数据量小于 sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存顶不住,就得磁盘临时文件辅助排序。当然,在 MySQL5.7 以上版本可以用下面介绍的检测方法(后面都有用到),来查看一个排序语句是否使用了临时文件。PS:这里的语句直接复制到 navicat 执行即可,要一起执行(都复制进去,点下执行)/* 打开optimizer_trace,只对本线程有效 */ SET optimizer_trace='enabled=on'; /* @a保存Innodb_rows_read的初始值 */ select VARIABLE_VALUE into @a from performance_schema.session_status where variable_name = 'Innodb_rows_read'; /* 执行语句 */ select city, order_num, user_code from `order` where city='广州' order by order_num limit 1000; /* 查看 OPTIMIZER_TRACE 输出 */ SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`; /* @b保存Innodb_rows_read的当前值 */ select VARIABLE_VALUE into @b from performance_schema.session_status where variable_name = 'Innodb_rows_read'; /* 计算Innodb_rows_read差值 */ select @b-@a;执行完之后,可从 OPTIMIZER_TRACE 表的 TRACE 字段得到以下结果:其中 examined_rows 表示需要排序的行数 6883;sort_buffer_size 就是排序缓冲区的大小;sort_buffer_size 就是我 MySQL 的排序缓冲区大小 256 KB。另外,sort_mode 的值是 packed_additional_fields,它表示排序过程对数据做了优化,也就是数据占用多少就算多少内存。举个栗子:不存在数据定义长度 16,就按这个长度算,如果数据只占 2,只会按长度 2 分配内存。number_of_tmp_files 代表的是用了几个外部文件来辅助排序。我这里是用了两个,内存放不下时,就使用外部排序,外部排序一般使用归并排序算法。可以这么简单理解, MySQL 将需要排序的数据分成 2 份,每一份单独排序后存在这些临时文件中。然后把这 2 个有序文件再合并成一个有序的大文件。最后一个查询语句,select @b-@a 的值是 6884,表示整个过程只扫描了 6883 行,为啥显示 6884?因为查询 OPTIMIZER_TRACE 表时,需用到临时表;而 InnDB 引擎把数据从临时表取出时,Inndb_rows_read 值会加 1。所以,把 internal_tmp_disk_storage_engine 设置为 MyISAM 可解决此问题。
01 前言哈喽,好久没更新啦。因为最近在面试。用了两周时间准备,在 3 天之内拿了 5 个 offer,最后选择了广州某互联网行业独角兽 offer,昨天刚入职。这几天刚好整理下在面试中被问到有意思的问题,也借此机会跟大家分享下。这家企业的面试官有点意思,一面是个同龄小哥,一起聊了两个小时(聊到我嘴都干了)。他问了我一个有意(keng)思(b)问题:数据库中的自增 ID 用完了该怎么办?这个问题其实可以分为有主键 & 无主键两种情况回答。国际惯例,先上张脑图:02 有主键如果你的表有主键,并且把主键设置为自增。在 MySQL 中,一般会把主键设置成 int 型。而 MySQL 中 int 型占用 4 个字节,作为有符号位的话范围就是 [-2^31,2^31-1],也就是 [-2147483648,2147483647];无符号位的话最大值就是 2^32-1,也就是 4294967295。下面以有符号位创建一张表:CREATE TABLE IF NOT EXISTS `t`( `id` INT(11) NOT NULL AUTO_INCREMENT, `url` VARCHAR(64) NOT NULL, PRIMARY KEY ( `id` ) )ENGINE=InnoDB DEFAULT CHARSET=utf8;插入一个 id 为最大值 2147483647 的值,如下图所示:如果此时继续下面的插入语句:INSERT INTO t (url) VALUES ('wwww.javafish.top/article/erwt/spring')结果就会造成主键冲突:2.1 解决方案虽说 int 4 个字节,最大数据量能存储 21 亿。你可能会觉得这么大的容量,应该不至于用完。但是互联网时代,每天都产生大量的数据,这是很有可能达到的。所以,我们的解决方案是:把主键类型改为 bigint,也就是 8 个字节。这样能存储的最大数据量就是 2^64-1,我也数不清有多少了。反正在你有生之年应该是够用的。PS:单表 21 亿的数据量显然不现实,一般来说数据量达到 500 万就该分表了。03 没主键另一种情况就是建表时没设置主键。这种情况,InnoDB 会自动帮你创建一个不可见的、长度为 6 字节的 row_id,默认是无符号的,所以最大长度是 2^48-1。实际上 InnoDB 维护了一个全局的 dictsys.row_id,所以未定义主键的表都共享该 row_id,并不是单表独享。每次插入一条数据,都把全局 row_id 当成主键 id,然后全局 row_id 加 1。这种情况的数据库自增 ID 用完会发生什么呢?1、创建一张无显示设置主键的表 t:CREATE TABLE IF NOT EXISTS `t`( `age` int(4) NOT NULL )ENGINE=InnoDB DEFAULT CHARSET=utf8;2、通过 ps -ef|grep mysql 命令获取 mysql 的进程 ID,然后执行命令,通过 gdb 先把 row_id 修改为 1。PS:没有 gdb 的,百度安装下sudo gdb -p 16111 -ex 'p dict_sys->row_id=1' -batch出现下图就是没错的:3、插入三条数据:insert into t(age) values(1); insert into t(age) values(2); insert into t(age) values(3);此时的数据库数据:4、gdb 把 row_id 修改为最大值:281474976710656sudo gdb -p 16111 -ex 'p dict_sys->row_id=281474976710656' -batch5、再插入三条数据:insert into t(age) values(4); insert into t(age) values(5); insert into t(age) values(6);此事的数据库数据:分析:刚开始设置 row_id 为 1,插入三条数据 1、2、3 的 row_id 也理应是 1、2、3;这是没问题的。接着设置 row_id 为最大值,紧跟着插入三条数据。这时的数据库结果是:4、5、6、3;你会发现 1、2 被覆盖了。row_id 达到后最大值后插入的值 4、5、6 的 row_id 分别是 0、1、2;由于 row_id 为 1、2 的值已存在,所以后者的值 5、6 会覆盖掉 row_id 为 1、2 的值。结论:row_id 达到最大值后会从 0 重新开始算;前面插入的数据就会被后插入的数据覆盖,且不会报错。04 总结数据库自增主键用完后分两种情况:有主键,报主键冲突无主键,InnDB 会自动生成一个全局的 row_id。它到达最大值后会从 0 开始算,遇到 row_id 一样时,新数据覆盖旧数据。所以,我们还是尽量给表设置主键。为什么我说这是个有意(keng)思(b)问题?我的回答除了以上解决方法外,还提到在业务开发中,我们不会等到主键用完那天就已经分库分表了,基本不会遇到这种情况。这时,面试官可能会问你分库分表咋处理,如果你不会就不要主动提了,点到即止。
01 前言哈喽,好久没更新啦。因为最近在面试。用了两周时间准备,在 3 天之内拿了 5 个 offer,最后选择了广州某互联网行业独角兽 offer,昨天刚入职。这几天刚好整理下在面试中被问到有意思的问题,也借此机会跟大家分享下。这家企业的面试官有点意思,一面是个同龄小哥,一起聊了两个小时(聊到我嘴都干了)。二面是个从阿里出来的架构师,他问了个场景题:数据库有个字符串类型的字段,存的是 URL 怎么设计索引?当时我给出拆分字段:url 的前半部分肯定区分度低,到了后半部分才高;我把区分度高和低的分别拆分为两个字段存储,并在区分度高的字段建立索引的具体答案,并提出了尽量提高区分度的思路。面试官也认可了我的方向,但是问我还有没其他方案。当时没答出来,回去之后我自己查了下资料,这里也给大家分享下具体的设计方案。国际惯例,先上思维导图:02 整个字段加索引先亮出表设计:CREATE TABLE IF NOT EXISTS `t`( `id` INT(11) NOT NULL AUTO_INCREMENT, `url` VARCHAR(100) NOT NULL, PRIMARY KEY ( `id` ) )ENGINE=InnoDB DEFAULT CHARSET=utf8;表数据:其实这个问题 = 字符串怎么设计索引?,你可能会说直接执行下面的语句不就得了?alter table t add index index_url(url);我随意画了张图,在 MySQL index_url 的结构是这样的:确实,这样是可以的。执行下面的查询语句只需要一次扫描操作即可。select id,url from t where url='javafish/nhjj/mybatis';但它还有个问题就是浪费存储空间,这种情况 ** 只适合存储数据较短且区分度足够高(这点是必须的,要不然我们也不会在区分度很低的字段建索引)** 的情况。你想想整个字段这么长,肯定贼费空间了。那有没有不那么费空间的方法呢?我们自然就想到了 MySQL 的前缀索引。03 前缀索引针对上面的表数据,加下前缀索引,没必要整个字段加索引,因此可以这样建索引:alter table t add index index_url(url(8));此时,index_url 的结构是这样的:select id,url from t where url='javafish/nhjj/mybatis';执行同样的 sql 查询,它的流程是这样的:从 index_url 索引树找到满足索引值是 javafish 的记录,找到的第一个是 ID1;到主键上查到主键值是 ID1 的行,判断出 url 的值不是 javafish/nhjj/mybatis,这行记录丢弃;取刚刚查到的位置 ID1 的下一条记录,发现仍然是 javafish,取出 ID2,再到 ID 索引上取整行然后判断,还是不对;重复上一步,直到在 index_url 上取到的值不是 javafish 时,循环结束。在这个过程中,要回主键索引取 6 次数据,也就是扫描了 6 行。通过这个对比,你很容易就可以发现,使用前缀索引后,可能会导致查询语句读数据的次数变多。当我们把 url 前缀索引的长度增加到 10 的时候。你会发现执行一样的查询语句,只需要扫描 1 行就可以获得目标数据。3.1 前缀的长度选择看到这里,你可能也发现了。使用前缀索引,定义好长度,可以做到既节省空间,又不用额外增加太多的查询成本。它的选择尤为关键,数据少的时候我们可以肉眼就能判断前缀长度的选择,都是数据量很大我们应该怎么判断呢?此时脑瓜子不断想,我们可以想到 MySQL 有 count distinct 去重计数这个操作,于是可以执行以下 sql 看选择多少前缀长度合适。select count(distinct url) as L from t;可以这样批量操作:SELECT count( DISTINCT LEFT ( url, 8 ) ) AS L8, count( DISTINCT LEFT ( url, 9 ) ) AS L9, count( DISTINCT LEFT ( url, 10 ) ) AS L10, count( DISTINCT LEFT ( url, 11 ) ) AS L11 FROM t;结果是这样的:我们选择前缀长度的原则是:区分度高 + 占用空间少;考虑这二者的因素,我会选择 10 作为前缀索引的长度。3.2 前缀索引的不足前缀索引虽好,但也有不足。比如我们上面说的长度选择不好就会导致扫描行数增多。还有一点就是使用了前缀索引,当你优化 sql 时,就不能使用索引覆盖这个优化点了。不清楚索引覆盖的小伙伴建议看看这篇文章《MySQL 索引原理》举个栗子:即使你将 index_url 的定义修改为 url (100) 的前缀索引,这时候虽然 index_url 已经包含了所有的信息,但 InnoDB 还是要回到 id 索引再查一下,因为系统并不确定前缀索引的定义是否截断了完整信息。这也是你是否选择前缀索引的一个考虑点。04 其他方式上面的 url 都比较短,还可以用前缀索引。假设 url 突然变长(别问为啥,就是能变长变粗),长成这个样子:由于前缀区分度实在不高,最起码长度 > 20 时,区分度才比较理想。索引选取的越长,占用的磁盘空间就越大,相同的数据页能放下的索引值就越少,搜索的效率也就会越低。那还有别的方法既能保证区分度又能不占用那么多空间吗?有的,比如:倒序存储以及加哈希字段4.1 倒序存储先说第一种,在存储 url 时,倒序存。这时候前缀的区分度就很高啦,利用倒序建立前缀索引。查询的时候可以利用 reverse 函数查:select url from t where url = reverse('输入的 url 字符串');4.2 哈希字段在数据表里面加一个整形字段,用作 url 的校验码,同时在这上面建立索引。alter table t add url_crc int unsigned, add index(url_crc);插入的时候可以这样做:调用 MySQL 的 crc32 函数计算出一个校验码,并保存入库。INSERT INTO t VALUE( 00000000007, 'wwww.javafish.top/article/erwt/spring', CRC32('wwww.javafish.top/article/erwt/spring'))然后执行完之后就插入这么个结果啦。不过有一点要注意,每次插入新记录时,都同时用 crc32 () 函数得到校验码填到这个新字段,可能存在冲突。也就是说两个不同的 url 通过 crc32 () 函数得到的结果可能是相同的,所以查询语句 where 部分还要判断 url 的值是否相同:select url from t where url_crc = crc32('输入的 url 字符串') and url = '输入的 url 字符串'如此一来,就相当于把 url 的索引长度降低到 4 个字节,缩短存储空间的同时提高了查询效率。4.3 二者对比相同点:都不支持范围查询。倒序存储的字段上创建的索引是按照倒序字符串的方式排序的,没有办法利用索引方式进行范围查询了。同样地,hash 字段的方式也只能支持等值查询。它们的区别,主要体现在以下三个方面:从占用的额外空间来看,倒序存储方式在主键索引上,不会消耗额外的存储空间,而 hash 字段方法需要增加一个字段。当然,倒序存储方式使用 4 个字节的前缀长度应该是不够的,如果再长一点,这个消耗跟额外这个 hash 字段也差不多抵消了。在 CPU 消耗方面,倒序方式每次写和读的时候,都需要额外调用一次 reverse 函数,而 hash 字段的方式需要额外调用一次 crc32 () 函数。如果只从这两个函数的计算复杂度来看的话,reverse 函数额外消耗的 CPU 资源会更小些。从查询效率上看,使用 hash 字段方式的查询性能相对更稳定一些。因为 crc32 算出来的值虽然有冲突的概率,但是概率非常小,可以认为每次查询的平均扫描行数接近 1。而倒序存储方式毕竟还是用的前缀索引的方式,也就是说还是会增加扫描行数。05 总结这篇文章聊了四种解决方法,每一种都有优缺点。没有办法判断哪一种最好,只有最合适的。在开发中,你也需要根据业务来选择,总的方向就是:提高区分度 & 尽量 减少占用空间。直接创建完整索引,这样可能比较占用空间;创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引;倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题;创建 hash 字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描。
00 前言很多小伙伴都用 Redis 做缓存,那如果 Redis 服务器宕机,内存中数据全部丢失,应该如何做数据恢复呢?有人说很简单呀,直接从 MySQL 数据库再读回来就得了。这种方式存在两个问题:一是频繁访问 MySQL 数据库,有一定的风险;二是慢,从界面上来看,从 MySQL 读就不如从 Redis 快。远哥远哥,那咋办呀?教教我吧。我用中指抵着小胖的下吧,说到:傻瓜,我们可以做持久化呀。Redis 的持久化分两种,一种是 AOF,另一种是 RDB。来,坐哥哥腿上,我给你好好说道说道。老规矩,先上张脑图:0.1 什么是持久化?持久化(Persistence),即把数据(如内存中的对象)保存到可永久保存的存储设备中(如磁盘)。持久化的主要应用是将内存中的对象存储在数据库中,或者存储在磁盘文件中、XML 数据文件中等等。持久化是将程序数据在持久状态和瞬时状态间转换的机制。01 怎么理解 Redis 的单线程?必须声明一点:Redis 的单线程,是指 Redis 的网络 IO 和键值对读写是由一个线程(主线程)完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。1.0 Redis 快的原因?基于内存数据都存储在内存里,减少了一些不必要的 I/O 操作,操作速率很快。高效的数据结构底层多种数据结构支持不同的数据类型,支持 Redis 存储不同的数据;不同数据结构的设计,使得数据存储时间复杂度降到最低。合理的线程模型I/O 多路复用模型同时监听多个客户端连接;单线程在执行过程中不需要进行上下文切换,减少了耗时。02 AOF 持久化AOF(Append Only File) 持久化是通过保存 Redis 服务器所执行的写命令来记录数据库状态,也就是每当 Redis 执行一个改变数据集的命令时(比如 SET), 这个命令就会被追加到 AOF 文件的末尾。修改 redis.conf 配置文件,默认是 appendonly no(关闭状态),将 no 改为 yes 即可开启 AOF 持久化:appendonly yes在客户端输入如下命令也可,但是 Redis 服务器重启后会失效。192.168.17.101:6379> config set appendonly yes OKAOF 持久化功能的实现可以分为命令追加(append)、文件写回磁盘两个步骤。2.0 命令追加AOF 持久化功能开启时,Redis 在执行完一个写命令之后,会将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾,此时缓冲区的记录还没有写入到 appendonly.aof 文件中。2.0.1 AOF 的格式AOF 保存的是 Redis 的写命令,比如:执行命令 set testkey testvalue,它存储的内容如下图所示:其中,“*3” 表示当前命令有三个部分,每部分都是由 $+ 数字开头,后面紧跟着具体的命令、键或值。这里,数字表示这部分中的命令、键或值一共有多少字节。例如, $3 set 表示这部分有 3 个字节,也就是 set 命令。2.0.2 写后日志有啥优缺点?AOF 记录日志的方式被称为写后日志,也就是先执行命令再记录,而 MySQL 中的 redo log、binlog 等都是写前日志。它的写入流程是下图这样的:写后有什么优点?记录 AOF 时不会对命令进行语法检查 ,写后就只记录了执行成功的命令。(避免保存的错误的命令,恢复的时候就完犊子了)执行完之后再记录,不会阻塞当前的写操作写后有什么缺陷?如果执行完一个命令还没来得及写日志就宕机了会造成响应数据丢失。AOF 的写入由主线程处理,如果写入时出现较长耗时,那就会影响主线程处理后续的请求。你发现没有?写后的两个缺陷都是 AOF 的写入磁盘时相发生的,我们来看看它是怎么写入的呢?2.1 AOF 写入磁盘AOF 提供了三个选择,也就是 AOF 配置项 appendfsync 的三个可选值。Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;Everysec(默认),每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。2.1.0 三种策略的优缺点针对避免主线程阻塞和减少数据丢失问题,这三种写回策略都无法做到两全其美。主要原因是:Always(同步写回) 基本不丢数据,但是它在每一个写命令后都有一个慢速的落盘操作,影响主线程性能;No(操作系统控制的写回)在写完缓冲区后,继续执行后续的命令,但是落盘的时机已经不在 Redis 手中了,只要 AOF 记录没有写回磁盘,一旦宕机对应的数据就丢失了;Everysec(每秒写回)采用一秒写回一次的频率,避免了 Always 的性能开销,虽然减少了对系统性能的影响,但是如果发生宕机,上一秒内未落盘的命令操作仍然会丢失。总结一下就是:想高性能,选择 No 策略;想高可靠性,选择 Always 策略;允许数据有一点丢失,又希望性能别受太大影响,选择 Everysec 策略。2.2 AOF 恢复数据不说了,看图:2.3 AOF 重写我不知道你发现没有?AOF 文件是不断地将写命令追加到文件的末尾来记录数据库状态的。写命令不断增加,AOF 体积也越来越大。有些命令是执行多次更新同一条数据,但其实它是可以合并成同一条命令的。比如:LPUSH 对列表数据做了 6 次更改,但 AOF 只需要记录最后一次更改。因为日志恢复时,只需要执行最后一次更改的命令即可。为了处理这种情况,Redis 提供了 AOF 的重写机制。它的多变一功能,把 6 条写命令合并成一条。如下所示:如果你的某些键有成百上千次的修改,重写机制节约的空间就很可观了。2.3.1 触发重写有两种触发的方法,一个是调用命令 BGREWRITEAOF;一个是修改配置文件参数。# 方式一 192.168.17.101:6379> BGREWRITEAOF Background append only file rewriting started # 方式二 auto-aof-rewrite-percentage 100 #当前AOF文件大小和上一次重写时AOF文件大小的比值 auto-aof-rewrite-min-size 64mb #文件的最小体积2.3.2 重写步骤创建子进程进行 AOF 重写将客户端的写命令追加到 AOF 重写缓冲区子进程完成 AOF 重写工作后,会向父进程发送一个信号父进程接收到信号后,将 AOF 重写缓冲区的所有内容写入到新 AOF 文件中对新的 AOF 文件进行改名,覆盖现有的 AOF 文件2.4 相关配置# 是否开启AOF功能 appendonly no # AOF文件件名称 appendfilename "appendonly.aof" # 写入AOF文件的三种方式 appendfsync always appendfsync everysec appendfsync no # 重写AOF时,是否继续写AOF文件 no-appendfsync-on-rewrite no # 自动重写AOF文件的条件 auto-aof-rewrite-percentage 100 #百分比 auto-aof-rewrite-min-size 64mb #大小 # 是否忽略最后一条可能存在问题的指令 aof-load-truncated yes2.5 优缺点优点AOF 文件可读性高,分析容易AOF 文件过大时,自动进行重写追加形式,写入时不需要再次读取文件,直接加到末尾缺点相同数据量下,AOF 一般比 RDB 大AOF 恢复时需要重放命令,恢复速度慢根据 fsync 策略,AOF 的速度可能慢于 RDB
02 Redis 的数据结构2.6 压缩列表压缩列表是 list 和 hash 的底层实现之一,当 list 只包含少量元素,并且每个元素都是小整数值,或者是比较短的字符串,压缩列表会作为 list 的底层实现。压缩列表(ziplist)是 Redis 为节约内存而开发,它的理念是多大元素用多大内存。如下图,根据每个节点的实际存储的内容决定内存的大小,第一个节点占用 5 字节,第二个节点占用 5 字节,第三个节点占用 1 字节,第四个节点占用 4 字节,第五个节点占用 3 字节。图示为 ziplist 的结构:它类似于一个数组,不同的是它在表头有三个字段 zlbytes、zltail 和 zllen;分别表示列表长度、列表尾的偏移量和元素的个数;表尾有 zlend,列表结束的标识。2.6.0 节点构成图示一个压缩列表中一个节点的构成:previous_entry_length:记录前一个节点的长度encoding:编码,控制 content 的类型和长度;分为字节数组编码和整数编码content:保存节点值,可以是一个字节数组或整数2.6.1 压缩列表的查找如果查找的是第一个元素或最后一个元素,可通过表头三个字段的长度直接定位,复杂度是 O (1)。而查找其他元素时,只能逐个查找,复杂度是 O (N) 。倒序遍历:首先指针通过 zltail 偏移量指向表尾节点,然后通过指向节点记录的前一个节点的长度依次向前遍历访问整个压缩列表。03 数据类型与数据结构还记得文章开头那张数据类型与底层数据结构的对应关系图吗?长这样:Redis 这种对应关系实际上是由 redisObject 的 type(类型)和 encoding (编码)共同决定的,详细对应关系如下:下面来具体介绍下,什么条件下使用那种类型实现对应的对象。比如:String 什么情况下用 int 编码实现?什么情况下用 embstr 编码实现?什么情况下用 raw 编码实现呢?3.0 字符串(String)对象从上图得知,String 有 int、raw、embst 三种编码格式:int:整数值,可以用 long 类型表示,使用整数值保存对象raw:字符串值且长度 > 32 字节,使用 SDS 保存对象embstr:字符串值且长度 < 32 字节,使用 embstr 编码的 SDS 保存对象PS:对于浮点数(long double 类型表示的),Redis 会将浮点数转换成字符串值;最终视长度决定用那种编码(embstr 或 raw)保存。取出时,再将其转成浮点值。3.0.0 embstr 和 raw 有啥区别?raw 分配内存和释放内存的次数是两次,embstr 是一次embstr 编码的数据保存在一块连续的内存里面3.0.1 编码的转换int 类型的字符串,当保存的不再是整数值,将转换成 raw 类型embstr 类型的字符串是只读的,修改时会转换成 raw 类型。原因:Redis 没有为 embstr 提供修改程序,所以它是只读的;要修改只能先转成 raw。3.1 列表(list)对象还是从上图得知,列表的编码可以是 ziplist 或 linkedlist:ziplist:所有元素长度都小于 64 字节且元素数量少于 512 个以上两个条件的上限值可以通过配置文件的 list-max-ziplist-value 和 list-max-ziplist-entries 修改linkedlist:不满足上述条件时,将从 ziplist 转换成 linkedlist3.1.0 区别执行 RPUSH 命令将创建一个列表对象,比如:redis> RPUSH numbers 1 "three" 5 (integer) 3如果 numbers 使用 ziplist 编码,对象结构如下:否则使用 linkedlist,就是双端链表作为底层实现。结构如下:3.2 哈希(hash)对象又是从上图得知,哈希的编码可以是 ziplist 或 hashtable:ziplist:哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节且键值对数量小于 512以上两个条件的上限值可以通过配置文件的 hash-max-ziplist-value 和 hash-max-ziplist-entries 修改hashtable:不能满足上述条件,将从 ziplist 转成 hashtable3.2.0 区别执行 HSET 命令,可以创建一个 hash 对象并保存数据:redis> HSET profile name "Tom" (integer) 1 redis> HSET profile age 25 (integer) 1 redis> HSET profile career "Programmer" (integer) 1ziplist 保存的 hash 对象:hashtable 保存的 hash 对象:字典中每个键都是一个字符串对像,对象中保存键值对的键字典中每个值都是一个字符串对像,对象中保存键值对的值架构如下:3.3 集合(set)对象又又是从上图得知,哈希的编码可以是 intset 或 hashtable:intset:集合对象保存的所有元素都是整数值且元素数量小于 512 个以上两个条件的上限值可以通过配置文件的 set-max-intset-entries 修改hashtable:不能满足上述条件,将从 intset 转成 hashtable3.3.0 区别使用 SADD 命令可构建一个 intset 编码的 set 对象并保存数据:redis> SADD numbers 1 3 5 (integer) 3intset 编码的集合对象结构如下:使用 SADD 命令可构建一个 hashtable 编码的 set 对象并保存数据:redis> SADD fruits "apple" "banana" "cherry" (integer) 3hashtable 编码的 set 使用字典作为底层实现,每个键都是字符串对象,每个对象包含一个集合元素,字典值全部置为 null 。hashtable 编码的集合对象结构如下:3.4 有序集合(Sorted Set)对象又又又是从上图得知,有序集合的编码可以是 ziplist 或 skiplist:ziplist:保存的元素数量小于 128 个且所有元素长度都小于 64 字节以上两个条件的上限值可以通过配置文件的 zset-max-ziplist-entries 和 zset-max-ziplist-value 修改skiplist:不能同时满足上述条件,将从 ziplist 转成 skiplist3.4.0 区别使用 ZADD 命令可以构建一个 Sorted Set 对象并保存数据:redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry (integer) 3ziplist 编码实现的 Sorted Set 对象,每个集合元素使用两个相邻的节点保存,第一个节点是元素成员,第二个节点是元素分值。按分值从小到大进行排序,结构如下:skiplist 编码实现的 Sorted Set 使用 zset 作为底层实现,它包含 跳跃表和字典,源码如下:typedef struct zset { zskpilist *zsl; dict *dict; }zset;大体结构如下:跳跃表 zsl 按分值从小到大保存所有集合元素;每个节点保存一个集合元素;object 属性保存元素成员、score 属性保存元素分值。目的:实现快速的范围查询操作。字典 dict 创建一个从成员到分值的 key-value;字典中每个键值对都保存一个集合元素;键保存元素成员、值保存元素分值。目的:用 O (1) 复杂度 get 元素分值。最后,详细的结构如下所示:听到这里有人可能有疑问:zset 结构同时使用跳跃表和字典来保存有序集合元素,不会重复吗?不会,因为二者会通过指针来共享同一个元素,并不会产生重复。为什么 skiplist 编码实现的有序集合要同时用跳跃表和字典实现?随便用一个行吗?答案是:不好。我们来看看两种情况:只用 dict ,可以保留以 O (1) 复杂度 get 成员分值;但字典是无序的,所以每次进行范围操作都要对所有元素排序;显然这是性能更低的。只用跳跃表,快速范围操作得以保留;但是没了字典,get 成员分值的复杂度将提高至 O (logN),这也影响性能。所以,Redis 为了把两者有点结合起来,采用了通过指针共享的方式,使用两种数据结构实现。04 一些注意的点4.0 Redis 如何执行命令Redis 执行命令前,会先检查值对象类型,判断键是否能执行该命令;再检查值对象的编码方式选择合适的命令执行。举个例子:列表对象有 ziplist 和 linkedlist 两种编码格式可用;前者通过 ziplist 的 API 执行命令、后者通过 linkedlist 的 API 执行命令。如果我们执行 LLEN 命令,Redis 第一步判断执行的命令是不是针对列表的?是的话,第二步判断值的编码格式,如果是 ziplist,使用 ziplistLen 函数操作;如果是 linkedlist 则使用 listLength 函数操作。4.1 Redis 内存回收机制与共享对象Redis 为每个对象构建一个引用计数属性,通过它可实现内存回收机制(当一个对象的引用计数为 0 时,将会释放所占用内存)。Redis 会共享值为 0 到 9999 的字符串对象(这个值可能通过修改 redis.h 文件的 REDIS_SHARDED_INTEGER 常量修改)Redis 只共享字符串对象本身,为什么不共享包含字符串的对象?能共享的前提是目标对象和共享对象完全相同。要共享就需要验证两者是否相同?因为包含字符串的对象复杂度更高,验证消耗的 CPU 时间也更多,而性能将会下降。4.2 lru 属性的作用redisObject 的 lru 属性记录对象最后一次被访问的时间,这个时间可以用于计算对象的空转时间(公式:当前时间 - lru 时间)。05 巨人的肩膀《Redis 设计与实现》redis 源码:github.com/antirez/redisredis 源码中文注释版:github.com/huangz1990/redis-3.0-annotatedcnblogs.com/Java3y/p/9870829.htmltime.geekbang.org/column/article/268253http://www.fidding.me/article/108segmentfault.com/a/1190000019980165cnblogs.com/chenchen0618/p/13260202.html06 总结本文从常用的缓存技术讲起,深入 Redis 的数据类型与底层数据结构。第一小节从 Redis 和缓存聊起;第二节站在源码角度跟你分析 Redis 的 6 种数据结构:SDS、链表、哈希表、跳跃表、整数集合以及压缩列表的特性;第三节着重和你分享 5 种数据类型和 6 中底层结构的对应关系;第四节则是画龙点睛地和你分享了 Redis 是怎么执行命令的?怎么释放内存等问题。全文将近,张图,希望能帮到你。好啦,以上就是狗哥关于 MySQL 锁的总结。感谢各技术社区大佬们的付出,尤其是极客时间,真的牛逼。如果说我看得更远,那是因为我站在你们的肩膀上。希望这篇文章对你有帮助,我们下篇文章见~
01 什么是 Redis?官方是这么描述的:Redis (用 C 语言实现的)是一个开源的,基于内存的数据结构存储,可用作于数据库、缓存、消息中间件。信息简洁明了,一下就知道了三个点:基于内存、用作缓存、多种数据结构。的了,那就从这三个方面开始研究呗。1.0 为什么要用 Redis 做缓存?上面说了,用作缓存。有些小伙伴可能会问:有 MySQL 数据库就得了呗?干嘛还要缓存?而且为啥要用 Redis 做?Map 不行嘛?第一、二个问题,都知道 MySQL 数据是存在磁盘的,而 CPU 访问磁盘是非常慢的。如果遇到并发高的时候,所有线程每次都要访问磁盘,估计得挂。到底有多慢?请看链接:zhuanlan.zhihu.com/p/24726196Redis 和 Map 做下对比,就知道为啥不合适了。Map 是本地缓存,如果在多台机器部署,必须每个机器都要复制一份,否则造成缓存不一致;Redis 是分布式缓存,部署在多台机器,也是用的同一份缓存,保持了一致性,问题不大。Map 做缓存,数据量大的话会导致 JVM 内存飙升,进而拖垮程序,并且 JVM 挂了,还会导致数据丢失;Redis 可以用更大容量的内存(看你的配置,即几十 G 都没问题)做缓存,并且还可以持久化到磁盘。02 Redis 的数据结构你可能第一反应不就 "String(字符串)、List(列表)、Hash(哈希)、Set(集合)和 Sorted Set(有序集合)么?",太简单了,我都会。老铁你错了,你说的是 Redis 的数据类型只有 5 种,也就是他的表现形式。而我说的数据结构是底层的,有 6 种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组,它们的对应关系如下:由上图可知 String 类型的底层实现只有一种数据结构,而 List、Hash、Set 和 Sorted Set 这四种数据类型,都有两种底层实现结构都是集合。看到这里,你可能又有疑问了。这些数据结构都是值的底层实现,键和值本身之间用什么结构组织?2.0 键和值用什么结构组织?实际上,Redis 使用了一个哈希表来保存所有键值对。它的存储是以 key-value 的形式的。key 一定是字符串,value 可以是 string、list、hash、set、sortset 中的随便一种。一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。每个哈希桶中保存了键值对数据,哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。这点从下图可以看出:** 哈希桶中的 entry 元素中保存了 *key 和 value 指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过 value 指针被查找到。redis 的键值都是 redisObject 对象,即在创建时会生成一个用于键名的 redisObject 对象和一个用于键值的 redisObject 对象。这点从源码也可以看出来:typedef struct redisObject { // 类型 unsigned type:4; // 编码 unsigned encoding:4; // 指向数据的指针 void *ptr; // 记录对象最后一次被程序访问时间,用于计算空转时长(当前时间-lru) unsigned lru:22; /* lru time (relative to server.lruclock) */ // 引用计数,用于内存回收 int refcount; } robj;也就是说上图 entry 中的健值指针就分别指向这样一个 redisObject。其中 type、 encoding 和 ptr 是最重要的三个属性。type 记录了对象所保存的值的类型,它的值可能是以下常量的其中一个。/* * 对象类型 */ #define REDIS_STRING 0 // 字符串 #define REDIS_LIST 1 // 列表 #define REDIS_SET 2 // 集合 #define REDIS_ZSET 3 // 有序集 #define REDIS_HASH 4 // 哈希表encoding 记录了 对象所保存的值的编码,它的值可能是以下常量的其中一个./* * 对象编码 */ #define REDIS_ENCODING_RAW 0 // 编码为字符串 #define REDIS_ENCODING_INT 1 // 编码为整数 #define REDIS_ENCODING_HT 2 // 编码为哈希表 #define REDIS_ENCODING_ZIPMAP 3 // 编码为 zipmap #define REDIS_ENCODING_LINKEDLIST 4 // 编码为双端链表 #define REDIS_ENCODING_ZIPLIST 5 // 编码为压缩列表 #define REDIS_ENCODING_INTSET 6 // 编码为整数集合 #define REDIS_ENCODING_SKIPLIST 7 // 编码为跳跃表比如,我们在 redis 里面 put ("狗哥",666),在 redisObject 实际上是这样存放的:2.1 SDS 简单动态字符串简单动态字符串 (Simple dynamic string,SDS)跟传统的 C 语言字符串不一样,Redis 使用了 SDS 来构建自己的字符串对象,源码如下:struct sdshdr{ // 字节数组,用于保存字符串 char buf[]; // 记录buf数组中已使用的字节数量,也是字符串的长度 int len; // 记录buf数组未使用的字节数量 int free; }图示:buf 属性是一个 char 类型的数组,最后一个字节保存了空字符 '\0',不算入 len 长度。2.1.0 为什么使用 SDS?SDS 比 C 字符串好在哪?常数复杂度获取字符串长度:C 字符串不记录长度,统计长度只能逐个遍历字符,复杂度是 O (N);而 SDS 在 len 属性中记录了自身长度,复杂度仅为 O (1)。不会发生缓冲区溢出:SDS 不会发生溢出的问题,如果修改 SDS 时,空间不足。先会扩展空间,再修改!(内部实现了动态扩展机制)。SDS 可以减少内存分配的次数 (空间预分配 & 惰性空间释放)。在扩展空间时,除了分配修改时所必要的空间,还会分配额外的空闲空间 (free 属性)。SDS 是二进制安全的,所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据。2.2 链表链表,大家都很熟悉了吧?在 Java 中 LinkedList 的底层数据结构就是链表 + 数组实现的。那 Redis 中的链表是怎样的呢?按照惯例,上源码。它使用 listNode 结构(源码位于 adlist.h)表示链表的每个节点:typedef strcut listNode{ //前置节点 strcut listNode *pre; //后置节点 strcut listNode *pre; //节点的值 void *value; }listNode多个 listNode 可以通过 prev 和 next 指针组成一个双向链表,像这样:节点表示出来了,整个链表又该怎么表示呢?Redis 使用 list 结构(源码位于 adlist.h)来构建链表,上源码:typedef struct list{ //表头结点 listNode *head; //表尾节点 listNode *tail; //链表长度 unsigned long len; //节点值复制函数 void *(*dup) (viod *ptr); //节点值释放函数 void (*free) (viod *ptr); //节点值对比函数 int (*match) (void *ptr,void *key); }list2.2.0 Redis 链表的特性双端:有 prev 和 next 两个指针;可以前后移动。无环:链表不闭环,prev 和 next 都指向 null,链表访问以 null 为终点。获取带表头指针、表尾指针、节点数量的时间复杂度均为 O (1)。链表使用 void * 指针来保存节点值,可以保存各种不同类型的值。2.3 哈希表哈希表,大家也都不陌生吧?在 Java 中哈希表的底层数据结构就是数组 + 链表实现的。那 Redis 中的哈希表是怎样实现的呢?按照惯例,上源码。哈希表使用 dictht 结构(源码位于 dict.h)表示哈希表,源码如下:typedef struct dictht{ // 哈希表数组 dictEntry **table; // 哈希表大小,也即 table 大小 unsigned long size; // 哈希表大小掩码,用于计算索引值 // 总是等于size-1 unsigned long sizemark; // 哈希表已有节点数量 unsigned long used; }dicthtsizemark 和哈希值决定一个键应该被放到 table 数组的那个索引上。PS:就是 Java 中计算哈希值决定位置的方法。图示一个大小为 4 的空哈希表(不包含任何键值)哈希表节点使用 dictEntry 结构表示,每个 dictEntry 都保存着一个键值对。源码如下:typedef struct dictEntry { // 键 void *key; // 值 union { void *val; uint64_tu64; int64_ts64; }v; // 指向下个哈希节点,组成链表 struct dictEntry *next; }dictEntry;key 解释得很清楚了;说说 v 属性,它 保存着键值对中的值,可以是一个指针,或者是一个 uint64_t 整数,又或者是一个 int64_t 整数。**next 则是执行下一个哈希表节点的指针,可以将多个哈希值相同的键值对连接在一起作为一个链表,以此来解决键冲突(collision)的问题。**PS:参考 Java 中 HashMap 是怎么解决冲突的。旧文:《HashMap 源码解读》有提过。图示通过 next 指针把相同索引值的键 k1 和 k0 连接在一起。为了更好实现 rehash(扩容);Redis 又在哈希表之上封装了一层,称之为字典。由 dict 结构表示,源码如下:typedef struct dict { // 类型特定函数 dictType *type; // 私有数据 void * privdata; // 哈希表,代表两个哈希表 dictht ht[2]; // rehash索引 // 当rehash不在进行时, 值为 - 1 in trehashidx; /*rehashing not in pro gress if rehashidx==-1*/ }dict; ------------------------------------------------------- typedef struct dictType{ //计算哈希值的函数 unsigned int (*hashFunction)(const void * key); // 复制键的函数 void *(*keyDup)(void *private, const void *key); // 复制值的函数 void *(*valDup)(void *private, const void *obj); // 对比键的函数 int (*keyCompare)(void *privdata , const void *key1, const void *key2) // 销毁键的函数 void (*keyDestructor)(void *private, void *key); // 销毁值的函数 void (*valDestructor)(void *private, void *obj); }dictTypetype 属性和 privdata 属性是针对不同类型的键值对,为创建多态字典而设置的。type 是一个指向 dictType 的指针,每个 dictType 保存了一簇用于操作特定类型键值对的函数,Redis 为用途不同的字典设置不同的类型特定函数而 privdata 则保存了传给类型特定函数的可选参数ht 是包含了两个哈希表的数组;ht [0] 存放真实数据,ht [1] 在对 ht [0] 进行 rehash(扩容)时使用。最终,你会发现其实所谓的字典就是两个哈希表组成的。图式结构如下:
03 表级锁MySQL 有两种表级锁:表锁以及元数据锁(meta data lock,MDL)3.1 表锁表锁的语法是这样的:lock tables ... read/write,它是显式使用的,同样也是通过 unlock tables 主动释放锁;当然,客户算断开或者异常时也会释放。mysql> lock tables student read,course read; mysql> SELECT count(1) FROM student; mysql> SELECT count(1) FROM course; mysql> unlock tables;需要注意一点:lock tables 除了会限制别的线程读写以外,也限定了本线程接下来操作的对象。举个栗子:线程 A 执行 lock tables student read,course write; 语句,其他线程读 student、读写 course 都会被阻塞。同时,线程 A 在执行 unlock tables 之后,也只能读 student、读写 course;不能访问其他表。整个表格更直观:student 表course 表其他表线程 A读读写不允许其他线程阻塞阻塞随便PS:在没有更细粒度的年代,表锁是最常用与处理并发的方式。但是对于 InnDB 来说,一般不使用 lock tables 控制并发,因为粒度太大了。3.2 MDL 元数据锁MDL 不需要我们记命令,它是隐式使用的,访问表会自动加上。它的主要作用是防止 DDL(改表结构) 和 DML(CRUD 表数据) 并发的冲突。举个栗子,线程 A 遍历查询表数据,这期间线程 B 删了表的某一列,这时 A 拿到的数据就跟表结构对不上,MySQL 不允许这种事发生,所以在 5.5 版本引入了 MDL。它的逻辑很简单,对表进行 CRUD 操作,加 MDL 读锁;对表结构下手时,加 MDL 写锁。因此:读读不互斥,可以多线程对一张表增删改查。读写互斥、写写互斥,保证对表结构下手时只能有一个线程操作,另一个进入阻塞。3.2.1 加个字段就搞挂数据库?我们知道 MDL 默认是系统加的,对表结构下手时(加字段、该字段、加索引等等),需要全表扫描。对大表操作时,你肯定会选月黑凤高,系统使用人数最少时进行,以免遭投诉。但不只是大表,有时候对小表进行操作时,也会有这样的问题。比如下面的例子:4 个 session 对表进行操作。PS:版本是 MySQL 5.7前提:注意,我这里的事务是手动开启和提交的。而 MDL 锁是语句开始时申请,事务提交才释放。所以,如果是自动提交就不会出现下面的问题。T1、T2 时刻 session A 事务启动,加个 MDL 读锁,然后执行 select 语句。注意:这时事务并没有提交;T3 时刻 session B 也是读操作,可以共享 MDL 读锁,顺利执行;T4 时刻 session C 不讲武德,对表执行 DDL (改表结构)操作,需要的是 MDL 写锁,所以被阻塞;T5 时刻 session D 也是读操作,按道理说 session C 阻塞应该没影响。但是 MySQL 有一个队列会根据时间先后决定哪个 Session 先执行。所以,不管是 D 还是之后的 session 都会被 C 阻塞。而恰巧 student 又是访问频率很高的表,如此这个库的线程数很快就打满了。此时,数据库完全不能读写,甚至导致宕机,在用户界面看来就是没响应了。3.2.2 安全地更改表相信你都看出来了,出现上面问题是因为使用了长事务(一个事务包括 session A、B、C、D 的操作)。事务一直不提交,MDL 锁就会一直被占用。所以,遇到这种情况就要在 MySQL 的 information_schema 表中先找出长事务对应的线程,把它 kill 掉。// MySQL 长事务请看这篇:cnblogs.com/mysqljs/p/11552646.html // 查询事务 select * from information_schema.INNODB_TRX;那你可能又问了。我的表就是热点表访问很高频,但我又不得不加个字段。那应该咋办呢?回想下多线程业务操作时,线程一直拿不到锁,我们是怎么处理的?没错,就是加超时时间。比如在 alter 语句里面加个等待时间,超过了这时间还拿不到锁。也不要阻塞后面的业务查询语句,先放弃更改。之后再交由你司 DBA 重复这个过程,直到更改成功。加等待时间语句,像下面这样的:// N 以秒为单位 ALTER TABLE tbl_name WAIT N add column ...04 行锁mysql 的行索是在引擎实现的,但并不是所有引擎都支持行锁,不支持行锁的引擎只能使用表锁。行锁比较容易理解:行锁就是针对数据表中行记录的锁。比如:事务 A 先更新一行,同时事务 B 也要更新同一行,则必须等事务 A 的操作完成后才能进行更新。4.1 两阶段提交先举个栗子:事务 A 和 B 对 student 中的记录进行操作。其中事务 A 先启动,在这个事务中更新两条数据;事务 B 后启动,更新 id = 1 的数据。由于 A 更新的也是 id = 1 的数据,所以事务 B 的 update 语句从事务 A 开始就会被阻塞,直到事务 A 执行 commit 之后,事务 B 才能继续执行。在事务期间,事务 A 实际上持有 id = 1 和 id = 2 这两行的行锁。如果事务 B 更新的是 id = 2 的数据,那么它阻塞的时间就是从 A 更新 id = 2 这行开始(事务 A 更新 id = 1 时,它并没有阻塞),到事务 A 提交结束,比更新 id = 1 数据阻塞的时间要短。PS:理解这句话很重要。在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。锁的添加与释放分到两个阶段进行,之间不允许交叉加锁和释放锁。根据这个特性,对于高并发的行记录的操作语句就可以尽可能的安排到最后面,以减少锁等待的时间,提高并发性能。举个栗子:广州长隆乐园卖票系统。卖出一张票的逻辑应该分三步:1、扣除用户账户余额2、增加长隆账户收入3、插入一条交易记录三个操作必须是要放在同一个事务当中,那应该怎么安排它们的执行顺序呢?做个分析:用户余额表是个人的,并发度很低;长隆账户表每个用户买票都要访问,并发度最高;交易记录表是插入操作问题不大;这时将事务步骤安排成 3、1、2 这样的顺序是最佳的。因为此时如果有别的用户买票,它的事务在顺序 1、2 并不会阻塞,而是到了顺序 3 更新长隆账户表才会引起阻塞。但它的阻塞时间是最短的。4.2 死锁不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。举个行锁死锁的例子:两个事物相互等待对方持有的锁。操作开始,事务 A 持有 id = 1 的行锁,事务 B 持有 id = 2 的行锁;事务 A 想更新 id = 2 行数据,不料事务 B 已持有,事务 A 只能等待事务 B 释放 id = 2 的行锁;同理,事务 B 想更新 id = 1 行数据,不料事务 A 已持有,事务 B 只能等事务 A 释放 id = 1 的行锁。两者互相等待,一直到完犊子。这就是死锁,懂了么?4.3 如何解决死锁?那出现了死锁怎么办?有两个解决策略:进入等待,直到超时进行死锁检测,主动回滚某个事务4.2.2 加入等待时间首先是第一种:直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 设置。这个参数,默认设置的锁等待时间是 50s在 MySQL 中,像下面这样执行即可:// 设置等待时间 mysql> set global innodb_lock_wait_timeout = 500;上面这个语句表示:当出现死锁以后,第一个被锁住的线程要过 500s 才会超时退出,然后其他线程才有可能继续执行。你可能说这不解决啦?真简单。别得意,这里还有个坑。到底设置多长的过期时间合适呢?我设置 1s 吧,有些线程可能并没有发生死锁,只是正常的等待锁。这就会造成本来正常的线程让我给干掉了。4.2.3 死锁检测再看第二种:死锁检测,主动回滚某个事务。MySQL 通过设置 innodb_deadlock_detect 的值决定是否开启检测,默认值是 on(开启)。主动死锁检测在发生死锁的时候,可以快速发现并进行处理的,但是它也有额外负担。什么负担呢?循环依赖检测,过程如下图:新来的线程 F,被锁了后就要检查锁住 F 的线程(假设为 D)是否被锁,如果没有被锁,则没有死锁,如果被锁了,还要查看锁住线程 D 的是谁,如果是 F,那么肯定死锁了,如果不是 F(假设为 B),那么就要继续判断锁住线程 B 的是谁,一直走知道发现线程没有被锁(无死锁)或者被 F 锁住(死锁)才会终止如果大量并发修改同一行数据,死锁检测又会怎样呢?假设有 1000 个并发线程同时更新同一行,那么死锁检测操作就是 1000 x 1000 达到 100 万量级的。即便最终检测结果没有死锁,但这期间要消耗大量 CPU 资源。所以,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务的情况。4.2.4 解决热点行更新问题那前面两种方案都有弊端,死锁的问题应该怎么解决呢?一种比较依赖运气的方法就是:如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。但是这可能会影响到业务:开启死锁检测,出现死锁就回滚重试,不会影响到业务。如果关闭,可能就会大量超时,严重就会拖垮数据库。另一种就是在服务端(消息队列或者数据库服务端)控制并发度:之所以担心死锁检测会造成额外的负担,是因为并发线程很多的时候,假设我们能在服务端做下限流,比如同一样最多只能允许 10 个线程同时修改。一个思想:减少死锁的主要方向,就是控制访问相同资源的并发事务量。05 巨人的肩膀《高性能 MySQL》time.geekbang.org/column/article/69862cnblogs.com/dyh004/p/11264569.htmlcnblogs.com/mysqljs/p/11552646.htmlblog.csdn.net/Annie_ya/article/details/104938829blog.csdn.net/u012483153/article/details/10730871506 总结本文详细介绍了 MySQL 的全局锁、表级锁、元数据锁以及行锁和死锁。其中全局锁撩到了应用场景、为什么备份要加全局锁?如何利用一致性视图备份以及为啥 readonly = 1 不适合用来做备份?表级锁聊了表锁、MDL 元数据锁以及怎么利用 MDL 锁安全快速更改表结构;行锁聊了两阶段提交、死锁的定义、死锁的检测以及给怎么解决死锁,提供了两种思路。好啦,以上就是狗哥关于 MySQL 锁的总结。感谢各技术社区大佬们的付出,尤其是极客时间,真的牛逼。如果说我看得更远,那是因为我站在你们的肩膀上。希望这篇文章对你有帮助,我们下篇文章见~
01 什么是事务?数据库事务指的是一组数据操作,事务内的操作要么就是全部成功,要么就是全部失败,什么都不做,其实不是没做,是可能做了一部分但是只要有一步失败,就要回滚所有操作,有点一不做二不休的意思。在 MySQL 中,事务支持是在引擎层实现的。MySQL 是一个支持多引擎的系统,但并不是所有的引擎都支持事务。比如 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。1.1 四大特性原子性(Atomicity):事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出错,会回滚到事务开始前的状态,所有的操作就像没有发生一样。也就是说事务是一个不可分割的整体,就像化学中学过的原子,是物质构成的基本单位。一致性(Consistency):事务开始前和结束后,数据库的完整性约束没有被破坏 。比如 A 向 B 转账,不可能 A 扣了钱,B 却没收到。隔离性(Isolation):同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如 A 正在从一张银行卡中取钱,在 A 取钱的过程结束前,B 不能向这张卡转账。持久性(Durability):事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。1.2 隔离级别SQL 事务的四大特性中原子性、一致性、持久性都比较好理解。但事务的隔离级别确实比较难的,今天主要聊聊 MySQL 事务的隔离性。SQL 标准的事务隔离从低到高级别依次是:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。级别越高,效率越低。读未提交:一个事务还没提交时,它做的变更就能被别的事务看到。读提交:一个事务提交之后,它做的变更才会被其他事务看到。可重复读:一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。串行化:顾名思义是对于同一行记录,“写” 会加 “写锁”,“读” 会加 “读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。所以种隔离级别下所有的数据是最稳定的,但是性能也是最差的。1.3 解决的并发问题SQL 事务隔离级别的设计就是为了能最大限度的解决并发问题:脏读:事务 A 读取了事务 B 更新的数据,然后 B 回滚操作,那么 A 读取到的数据是脏数据不可重复读:事务 A 多次读取同一数据,事务 B 在事务 A 多次读取的过程中,对数据作了更新并提交,导致事务 A 多次读取同一数据时,结果不一致。幻读:系统管理员 A 将数据库中所有学生的成绩从具体分数改为 ABCDE 等级,但是系统管理员 B 就在这个时候插入了一条具体分数的记录,当系统管理员 A 改结束后发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读。SQL 不同的事务隔离级别能解决的并发问题也不一样,如下表所示:只有串行化的隔离级别解决了全部这 3 个问题,其他的 3 个隔离级别都有缺陷。事务隔离级别脏读不可重复读幻读读未提交可能可能可能读已提交不可能可能可能可重复读不可能不可能可能串行化不可能不可能不可能PS:不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表1.4 举个栗子这么说可能有点难以理解,举个栗子。还是之前的表结构以及表数据CREATE TABLE `student` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, `age` int(11) NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 66 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;假设现在,我要同时启动两个食物,一个事务 A 查询 id = 2 的学生的 age,一个事务 B 更新 id = 2 的学生的 age。流程如下,在四种隔离级别下的 X1、X2、X3 的值分别是怎样的呢?读未提交:X1 的值是 23,因为事务 B 虽然没提交但它的更改已被 A 看到。(如果 B 后面又回滚了 X1 的值就是脏的)。X2、X3 的值也是 23,这无可厚非。读已提交:X1 的值是 22,因为 B 虽然改了,但 A 看不到。(如果 B 后面回滚了,X1 的值不变,解决了脏读),X2、X3 的值是 23,没毛病,B 提交了,A 才能看到。可重复读:X1、X2 都是 22,A 开启的时刻值是 22,那么在 A 的整个过程中,它的值都是 22。(不管 B 在这期间怎么修改,只要 A 还没提交,都是看不见的,解决了不可重复读),而 X3 的值是 23,因为 A 提交了,能看到 B 修改的值了。串行化:B 在执行更改期间会被锁住,直至 A 提交。B 才能继续执行。(A 在读期间,B 不能写。得保证此时数据是最新的。解决了幻读)所以 X1、X2 都是 22,而最后的 X3 在 B 提交之后执行,它的值就是 23。那为什么会出现这样的结果呢?事务隔离级别到底是怎么实现的呢?事务隔离级别是怎么是实现的呢?我在极客时间丁奇老师的课上找到了答案:实际上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在 “可重复读” 隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在 “读提交” 隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。这里需要注意的是,“读未提交” 隔离级别下直接返回记录上的最新值,没有视图概念;而 “串行化” 隔离级别下直接用加锁的方式来避免并行访问。1.5 设置事务隔离级别不同的数据库默认设置的事务隔离级别也大不一样,Oracle 数据库的默认隔离级别是读提交,而 MySQL 是可重复读。所以,当你的系统需要把数据库从 Oracle 迁移到 MySQL 时,请把级别设置成与搬迁之前的(读提交)一致,避免出现不可预测的问题。1.5.1 查看事务隔离级别# 查看事务隔离级别 5.7.20 之前 SELECT @@transaction_isolation show variables like 'transaction_isolation'; # 5.7.20 以及之后 SELECT @@tx_isolation show variables like 'tx_isolation' +---------------+-----------------+ | Variable_name | Value | +---------------+-----------------+ | tx_isolation | REPEATABLE-READ | +---------------+-----------------+1.5.2 设置隔离级别修改隔离级别语句格式是:set [作用域] transaction isolation level [事务隔离级别]其中作用域可选:SESSION(会话)、GLOBAL(全局);隔离级别就是上面提到的 4 种,不区分大小写。例如:设置全局隔离级别为读提交set global transaction isolation level read committed;1.6 事务的启动MySQL 的事务启动有以下几种方式:显式启动事务语句, begin 或 start transaction。配套的提交语句是 commit,或者回滚语句是 rollback。# 更新学生名字 START TRANSACTION; update student set name = '张三' where id = 2; commit;set autocommit = 0,这个命令会将线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。set autocommit = 1,表示 MySQL 自动开启和提交事务。比如执行一个 update 语句,语句只完成后就自动提交了。不需要显示的使用 begin、commit 来开启和提交事务。所以当我们执行多个语句的时候,就需要手动的用 begin、commit 来开启和提交事务。start transaction with consistent snapshot;上面提到的 begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 命令。第一种启动方式,一致性视图是在执行第一个快照读语句时创建的;第二种启动方式,一致性视图是在执行 start transaction with consistent snapshot 时创建的。02 事务隔离的实现理解了隔离级别,那事务的隔离是怎么实现的呢?要想理解事务隔离,先得了解 MVCC 多版本的并发控制这个概念。而 MVCC 又依赖于 undo log 和 read view 实现。2.1 什么是 MVCC?百度上的解释是这样的:MVCC,全称 Multi-Version Concurrency Control,即多版本并发控制。MVCC 是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。MVCC 使得数据库读不会对数据加锁,普通的 SELECT 请求不会加锁,提高了数据库的并发处理能力;数据库写才会加锁。借助 MVCC,数据库可以实现 READ COMMITTED,REPEATABLE READ 等隔离级别,用户可以查看当前数据的前一个或者前几个历史版本,保证了 ACID 中的 I 特性(隔离性)。MVCC 只在 REPEATABLE READ 和 READ COMMITIED 两个隔离级别下工作。其他两个隔离级别都和 MVCC 不兼容 ,因为 READ UNCOMMITIED 总是读取最新的数据行,而不是符合当前事务版本的数据行。而 SERIALIZABLE 则会对所有读取的行都加锁。2.1.1 InnDB 中的 MVCCInnDB 中每个事务都有一个唯一的事务 ID,记为 transaction_id。它在事务开始时向 InnDB 申请,按照时间先后严格递增。而每行数据其实都有多个版本,这就依赖 undo log 来实现了。每次事务更新数据就会生成一个新的数据版本,并把 transaction_id 记为 row trx_id。同时旧的数据版本会保留在 undo log 中,而且新的版本会记录旧版本的回滚指针,通过它直接拿到上一个版本。所以,InnDB 中的 MVCC 其实是通过在每行记录后面保存两个隐藏的列来实现的。一列是事务 ID:trx_id;另一列是回滚指针:roll_pt。2.2 undo log回滚日志保存了事务发生之前的数据的一个版本,可以用于回滚,同时可以提供多版本并发控制下的读(MVCC),也即非锁定读。根据操作的不同,undo log 分为两种:insert undo log 和 update undo log。2.2.1 insert undo loginsert 操作产生的 undo log,因为 insert 操作记录没有历史版本只对当前事务本身可见,对于其他事务此记录不可见,所以 insert undo log 可以在事务提交后直接删除而不需要进行 purge 操作。purge 的主要任务是将数据库中已经 mark del 的数据删除,另外也会批量回收 undo pages所以,插入数据时。它的初始状态是这样的:2.2.2 update undo logUPDATE 和 DELETE 操作产生的 Undo log 都属于同一类型:update_undo。(update 可以视为 insert 新数据到原位置,delete 旧数据,undo log 暂时保留旧数据)。事务提交时放到 history list 上,没有事务要用到这些回滚日志,即系统中没有比这个回滚日志更早的版本时,purge 线程将进行最后的删除操作。一个事务修改当前数据:另一个事务修改数据:这样的同一条记录在数据库中存在多个版本,就是上面提到的多版本并发控制 MVCC。另外,借助 undo log 通过回滚可以回到上一个版本状态。比如要回到 V1 只需要顺序执行两次回滚即可。2.3 read-viewread view 是 InnDB 在实现 MVCC 时用到的一致性读视图,用于支持 RC(读提交)以及 RR(可重复读)隔离级别的实现。read view 不是真实存在的,只是一个概念,undo log 才是它的体现。它主要是通过版本和 undolog 计算出来的。作用是决定事务能看到哪些数据。每个事务或者语句有自己的一致性视图。普通查询语句是一致性读,一致性读会根据 row trx_id 和一致性视图确定数据版本的可见性。2.3.1 数据版本的可见性规则read view 中主要包含当前系统中还有哪些活跃的读写事务,在实现上 InnDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正活跃(还未提交)的事务。前面说了事务 ID 随时间严格递增的,把系统中已提交的事务 ID 的最大值记为数组的低水位,已创建过的事务 ID + 1 记为高水位。这个视图数组和高水位就组成了当前事务的一致性视图(read view)这个数组画个图,长这样:规则如下:1 如果 trx_id 在灰色区域,表明被访问版本的 trx_id 小于数组中低水位的 id 值,也即生成该版本的事务在生成 read view 前已经提交,所以该版本可见,可以被当前事务访问。2 如果 trx_id 在橙色区域,表明被访问版本的 trx_id 大于数组中高水位的 id 值,也即生成该版本的事务在生成 read view 后才生成,所以该版本不可见,不能被当前事务访问。3 如果在绿色区域,就会有两种情况:a) trx_id 在数组中,证明这个版本是由还未提交的事务生成的,不可见b) trx_id 不在数组中,证明这个版本是由已提交的事务生成的,可见第三点我在看教程的时候也有点疑惑,好在有热心网友解答:落在绿色区域意味着是事务 ID 在低水位和高水位这个范围里面,而真正是否可见,看绿色区域是否有这个值。如果绿色区域没有这个事务 ID,则可见,如果有,则不可见。在这个范围里面并不意味着这个范围就有这个值,比如 [1,2,3,5],4 在这个数组 1-5 的范围里,却没在这个数组里面。这样说可能有点难以理解,我假设一个场景:三个事务对同一条数据进行查询更新等操作,为此画了张图以方便理解:原始数据还是下图这样的,对 id = 2 的张三进行信息的更新:针对上图,我想提个问题。** 分别在 RC(读提交)以及 RR(可重复读)隔离级别下,T4 和 T5 时间点的查询 age 值分别是多少呢?T4 更新的值又是多少呢?** 思考片刻,相信大家都有自己的答案。答案在文末,希望大家能带着自己的疑问继续读下去。2.3.2 RR(可重复读)下的结果RR 级别下,查询只承认在事务启动前就已经提交完成的数据,一旦启动事务就会建视图。所以使用 start transaction with consistent snapshot 命令,马上就会建视图。现在假设:事务 A 开始前,只有一个活跃的事务,ID = 2,已提交的事务也就是插入数据的事务 ID = 1事务 A、B、C 的事务 ID 分别是 3、4、5在这种隔离级别下,他们创建视图的时刻如下:根据上图得,事务 A 的视图数组是 [2,3];事务 B 的视图数组是 [2,3,4];事务 C 的视图数组是 [2,3,4,5]。分析一波:T4 时刻,B 读数据都是从当前版本读起,过程是这样的:读到当前版本的 trx_id = 4,刚好是自己,可见所以 age = 24T5 时刻,A 读数据都是从当前版本读起,过程是这样的:读到当前版本的 trx_id = 4,比自己视图数组的高水位大,不可见再往上读到 trx_id = 5,比自己视图数组高水位大,不可见再往上读到 trx_id = 1,比自己视图数组低水位小,可见所以 age = 22这样执行下来,虽然期间这一行数据被修改过,但是事务 A 不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读。其实视图是否可见主要看创建视图和提交的时机,总结下规律:版本未提交,不可见版本已提交,但在视图创建后提交,不可见版本已提交,但在视图创建前提交,可见2.3.2.1 快照读和当前读事务 B 的 update 语句,如果按照上图的一致性读,好像结果不大对?如下图周明,B 的视图数组是先生成的,之后事务 C 才提交。那就应该看不见 C 修改的 age = 23 呀?最后 B 怎么得出 24 了?没错,如果 B 在更新之前执行查询语句,那返回的结果肯定是 age = 22。问题是更新就不能在历史版本更新了呀,否则 C 的更新不就丢失了?所以,更新有个规则:更新数据都是先读后写(读是更新语句执行,不是我们手动执行),读的就是当前版本的值,叫当前读;而我们普通的查询语句就叫快照读。因此,在更新时,当前读读到的是 age = 23,更新之后就成 24 啦。2.3.2.2 select 当前读除了更新语句,查询语句如果加锁也是当前读。如果把事务 A 的查询语句 select age from t where id = 2 改一下,加上锁(lock in mode 或者 for update),也都可以得到当前版本 4 返回的 age = 24下面就是加了锁的 select 语句:select age from t where id = 2 lock in mode; select age from t where id = 2 for update;2.3.2.3 事务 C 不马上提交假设事务 C 不马上提交,但是 age = 23 版本已生成。事务 B 的更新将会怎么走呢?事务 C 还没提交,写锁还没释放,但是事务 B 的更新必须要当前读且必须加锁。所以事务 B 就阻塞了,必须等到事务 C 提交,释放锁才能继续当前的读。2.3.3 RC(读提交)下的结果在读提交隔离级别下,查询只承认在语句启动前就已经提交完成的数据;每一个语句执行之前都会重新算出一个新的视图。注意:在上图的表格中用于启动事务的是 start transaction with consistent snapshot 命令,它会创建一个持续整个事务的视图。所以,在 RC 级别下,这命令其实不起作用。等效于普通的 start transaction(在执行 sql 语句之前才算是启动了事务)。所以,事务 B 的更新其实是在事务 C 之后的,它还没真正启动事务,而 C 已提交。现在假设:事务 A 开始前,只有一个活跃的事务,ID = 2,已提交的事务也就是插入数据的事务 ID = 1事务 A、B、C 的事务 ID 分别是 3、4、5在这种隔离级别下,他们创建视图的时刻如下:根据上图得,事务 A 的视图数组是 [2,3,4],但它的高水位是 6 或者更大(已创建事务 ID + 1);事务 B 的视图数组是 [2,4];事务 C 的视图数组是 [2,5]。分析一波:T4 时刻,B 读数据都是从当前版本读起,过程是这样的:读到当前版本的 trx_id = 4,刚好是自己,可见所以 age = 24T5 时刻,A 读数据都是从当前版本读起,过程是这样的:读到当前版本的 trx_id = 4,在自己一致性视图范围内但包含 4,不可见再往上读到 trx_id = 5,在自己一致性视图范围内但不包含 5,可见所以 age = 2303 巨人的肩膀cnblogs.com/wyaokai/p/10921323.htmltime.geekbang.org/column/article/70562zhuanlan.zhihu.com/p/117476959cnblogs.com/xd502djj/p/6668632.htmlblog.csdn.net/article/details/109044141blog.csdn.net/u014078930/article/details/9965927204 总结本文详细聊了事务的方方面面,比如:四大特性、隔离级别、解决的并发问题、如何设置、查看隔离级别、如何启动事务等。除此以外,还深入了解了 RR 和 RC 两个级别的隔离是怎么实现的?包括详解 MVCC、undo log 和 read view 是怎么配合实现 MVCC 的。最后还聊了快照读、当前读等等。可以说,事务相关的知识点都在这了。看完这一篇还不懂的话,你来捶我呀!好啦,以上就是狗哥关于数据库事务的总结。感谢各技术社区大佬们的付出,尤其是极客时间,真的牛逼。如果说我看得更远,那是因为我站在你们的肩膀上。希望这篇文章对你有帮助,我们下篇文章见~
01 前言事情是这样的,我负责我司的报表系统,小胖是我小弟。某天他手贱误删了一条生产的数据。被用户在群里疯狂投诉质问,火急火燎的跑来问我怎么办。我特么冷汗都出来了,训斥了他一顿:蠢,蠢得都可以进博物馆了,生产的数据能随便动?小胖看我平常笑嘻嘻的,没想到发这么大的火。心一急,居然给我跪下了:远哥,我上有老,下有小,中有女朋友,不要开除我呀。我一听火更大了:合着就你有女朋友???这个时候我们 DBA 老林来打圆场:别慌,年轻人管不住下本身,难免做错事。我可以把数据恢复到一个月内任意时刻的状态。听到这,小胖忙抱着老林大腿哭爹喊娘地感谢。听到这你是不是很奇怪?能恢复到半个月前的数据?DBA 老林到底是如何做到的?我跟他细聊了一番。老林点燃了手中 82 年的华子,深深吸了一口说到:事情还得从 update 语句是如何执行的说起。1.1 从更新语句说起假设我现在有建表语句,如下:CREATE TABLE `student` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, `age` int(11) NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 66 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;表数据如下:今天恰好张三生日,我要把它的 age 加一岁。于是执行以下的 sql 语句:update student set age = age + 1 where id = 2;前面聊过查询语句是如何执行的?错过的同学看这篇《工作三年:小胖连 select 语句是如何执行的都不知道,真的菜!》,里面的查询语句流程,更新语句也会走一遍,如下流程图:update 语句发起:首先连接器会连接数据库。接着分析器通过词法、语法分析知道这是更新语句。所以查询缓存失效。之前的文章提到:如果表有更新。那么它的查询缓存会失败。这也是为啥,我不建议你使用查询缓存的原因。优化器则决定使用 ID 索引去更新,最后执行器负责找到这行数据,执行更新。重点来了:与查询流程不一样,更新还涉及两个重要的日志模块。一是重做日志:redo log,二是归档日志:binlog。要解答文章开头的问题,必须要明白这两日志的原理才能整明白 DBA 是怎么做到的。02 事务日志:redo log什么是 redo log?为了方便理解,先举个来自极客时间的例子:还记得《孔乙己》这篇文章,饭店掌柜有一个粉板,专门用来记录客人的赊账记录。如果赊账的人不多,那么他可以把顾客名和账目写在板上。但如果赊账的人多了,粉板总会有记不下的时候,这个时候掌柜一定还有一个专门记录赊账的账本。如果有人要赊账或者还账的话,掌柜一般有两种做法:一种做法是直接把账本翻出来,把这次赊的账加上去或者扣除掉;另一种做法是先在粉板上记下这次的账,等打烊以后再把账本翻出来核算。在生意红火柜台很忙时,掌柜一定会选择后者,因为前者操作实在是太麻烦了。首先,你得找到这个人的赊账总额那条记录。你想想,密密麻麻几十页,掌柜要找到那个名字,可能还得带上老花镜慢慢找,找到之后再拿出算盘计算,最后再将结果写回到账本上。这整个过程想想都麻烦。相比之下,还是先在粉板上记一下方便。你想想,如果掌柜没有粉板的帮助,每次记账都得翻账本,效率是不是低得让人难以忍受?2.1 为什么需要 redo log?同样,在 MySQL 中,如果每一次的更新要写进磁盘,这么做会带来严重的性能问题:因为 Innodb 是以页为单位进行磁盘交互的,而一个事务很可能只修改一个数据页里面的几个字节,这时将完整的数据页刷到磁盘的话,太浪费资源了!一个事务可能涉及修改多个数据页,并且这些数据页在物理上并不连续,使用随机 IO 写入性能太差!为了解决这个问题,MySQL 的设计者就用了类似掌柜粉板的思路来提升更新效率。这种思路在 MySQL 中叫 WAL(Write-Ahead Logging),意思就是:先写 redo log 日志,后写磁盘。日志和磁盘就对应上面的粉板和账本。具体到 MySQL 是这样的:有记录需要更新,InnDB 把记录写到 redo log 中,并更新内存中的数据页,此时更新就算完成。同时,后台线程会把操作记录更新异步到磁盘中的数据页。PS:当需要更新的数据页在内存中时,就会直接更新内存中的数据页;不在内存中时,在可以使用 change buffer(篇幅有限,这个后面写文章再聊) 的情况下,就会将更新操作记录到 change buffer 中,并将这些操作记录到 redo log 中;如果此时有查询操作,则触发 merge 操作,返回更改后的记录值。有些人说 InnoDB 引擎把日志记录写到 redo log 中,redo log 在哪,不也是在磁盘上么?对,这也是一个写磁盘的过程,但是与更新过程不一样的是,更新过程是在磁盘上随机 IO,费时。而写 redo log 是在磁盘上顺序 IO,效率要高。PPS:redo log 的存在就是把全局的随机写,变换为局部的顺序写,从而提高效率。2.2 redo log 的写入过程redo log 记录了事务对数据页做了哪些修改。它包括两部分:分别是内存中的日志缓冲(redo log buffer)和磁盘上的日志文件(redo logfile)。mysql 每执行一条 DML 语句,先将记录写入 redo log buffer,后续某个时间点再一次性将多个操作记录写到 redo log file。也就是我们上面提到的 WAL 技术。计算机操作系统告诉我们:用户空间下的缓冲区数据是无法直接写入磁盘的。因为中间必须经过操作系统的内核空间缓冲区(OS Buffer)。所以,redo log buffer 写入 redo logfile 实际上是先写入 OS Buffer,然后操作系统调用 fsync () 函数将日志刷到磁盘。过程如下:mysql 支持三种将 redo log buffer 写入 redo log file 的时机,可以通过 innodb_flush_log_at_trx_commit 参数配置,各参数值含义如下:建议设置成 1,这样可以保证 MySQL 异常重启之后数据不丢失。参数值含义0(延迟写)事务提交时不会将 redo log buffer 中日志写到 os buffer,而是每秒写入 os buffer 并调用 fsync () 写入到 redo logfile 中。也就是说设置为 0 时是(大约)每秒刷新写入到磁盘中的,当系统崩溃,会丢失 1 秒钟的数据。1(实时写、实时刷新)事务每次提交都会将 redo log buffer 中的日志写入 os buffer 并调用 fsync () 刷到 redo logfile 中。这种方式即使系统崩溃也不会丢失任何数据,但是因为每次提交都写入磁盘,IO 的性能差。2(实时写、延迟刷新刷新)每次提交都仅写入到 os buffer,然后是每秒调用 fsync () 将 os buffer 中的日志写入到 redo log file。写的过程如下:2.3 redo log file 的结构InnoDB 的 redo log 是固定大小的。比如可以配置为一组 4 个文件,每个文件的大小是 1GB,那么 redo log file 可以记录 4GB 的操作。从头开始写。写到末尾又回到开头循环写。如下图:上图中,write pos 表示 redo log 当前记录的 LSN (逻辑序列号) 位置,一边写一遍后移,写到第 3 号文件末尾后就回到 0 号文件开头;check point 表示数据页更改记录刷盘后对应 redo log 所处的 LSN (逻辑序列号) 位置,也是往后推移并且循环的。PS:check point 是当前要擦除的位置,它与数据页中的 LSN 应当是一致的。write pos 到 check point 之间的部分是 redo log 的未写区域,可用于记录新的记录;check point 到 write pos 之间是 redo log 已写区域,是待刷盘的数据页更改记录。当 write pos 追上 check point 时,表示 redo log file 写满了,这时候有就不能执行新的更新。得停下来先擦除一些记录(擦除前要先把记录刷盘),再推动 check point 向前移动,腾出位置再记录新的日志。2.4 什么是 crash-save ?有了 redo log ,即在 InnoDB 存储引擎中,事务提交过程中任何阶段,MySQL 突然奔溃,重启后都能保证事务的完整性,已提交的数据不会丢失,未提交完整的数据会自动进行回滚。这个能力称为 crash-safe,依赖的就是 redo log 和 undo log 两个日志。比如:重启 innodb 时,首先会检查磁盘中数据页的 LSN ,如果数据页的 LSN 小于日志中 check point 的 LSN ,则会从 checkpoint 开始恢复。2.5 回滚日志 undo log**undo log,主要提供回滚的作用,但还有另一个作用,就是多个行版本控制 (MVCC),保证事务的原子性。** 在数据修改的流程中,会记录一条与当前操作相反的逻辑日志到 undo log 中(可以认为当 delete 一条记录时,undo log 中会记录一条对应的 insert 记录,反之亦然,当 update 一条记录时,它记录一条对应相反的 update 记录),如果因为某些原因导致事务异常失败了,可以借助该 undo log 进行回滚,保证事务的完整性,所以 undo log 也必不可少。03 归档日志:binlog上一篇聊查询语句的执行过程时,聊到 MySQL 的架构包含 server 层和引擎层。而 redo log 是 InnoDB 引擎特有的日志,而 server 层也有自己的日志,那就是 binlog。最开始 MySQL 里并没有 InnoDB 引擎。MySQ L 自带的引擎是 MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog 日志只能用于归档。而 InnoDB 是另一个公司以插件形式引入 MySQL 的,只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 使用另外一套日志系统 —— 也就是 redo log 来实现 crash-safe 能力。3.1 binlog 日志格式?binlog 有三种格式,分别为 STATMENT 、 ROW 和 MIXED。在 MySQL 5.7.7 之前,默认的格式是 STATEMENT , MySQL 5.7.7 之后,默认值是 ROW。日志格式通过 binlog-format 指定。STATMENT:每一条会修改数据的 sql 语句会记录到 binlog 中 。ROW:不记录 sql 的上下文信息,仅需记录哪条数据被修改。记两条,更新前和更新后都有。MIXED:前两种模式的混合,一般的复制使用 STATEMENT 模式保存 binlog ,对于 STATEMENT 模式无法复制的操作使用 ROW 模式保存 binlog3.2 binlog 可以做 crash-save 吗?只用一个 binlog 是否可以实现 cash_safe 能力呢?答案是可以的,只不过 binlog 中也要加入 checkpoint,数据库故障重启后,binlog checkpoint 之后的 sql 都重放一遍。但是这样做让 binlog 耦合的功能太多。有人说,也可以直接直接对比匹配全量 binlog 和磁盘数据库文件,但这样做的话,效率低不说。因为 binlog 是 server 层的记录并不是引擎层的,有可能导致数据不一致的情况:假如 binlog 记录了 3 条数据,正常情况引擎层也写了 3 条数据,但是此时节点宕机重启,binlog 发现有 3 条记录需要回放,所以回放 3 条记录,但是引擎层可能已经写入了 2 条数据到磁盘,只需要回放一条 1 数据。那 binlog 回放的前两条数据会不会重复呢,比如会报错 duplicate key。另外,binlog 是追加写,crash 时不能判定 binlog 中哪些内容是已经写入到磁盘,哪些还没被写入。而 redolog 是循环写,从 check point 到 write pos 间的内容都是未写入到磁盘的。所以,binlog 并不适合做 crash-save。3.3 两种日志的区别redo log 和 binlog 主要有三种不同:redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。redo log 是物理日志,记录的是在某个数据页上做了什么修改;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如 **"给 ID=2 这一行的 age 字段加 1"**。redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。追加写是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。3.4 update 语句的执行流程了解了两种日志的概念,再来看看执行器和 InnoDB 引擎在执行 update 语句时的流程:执行器取 id = 2 的行数据。ID 是主键,引擎用树搜索找到这一行。如果这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,再返回。执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。执行器生成这个操作的 binlog,并把 binlog 写入磁盘。执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,redo log 会写入 binlog 的文件名和位置信息来保证 binlog 和 redo log 的一致性,更新完成。整个过程如下图所示,其中橙色框表示是在 InnoDB 内部执行的,绿色框表示是在执行器中执行的:3.5 两阶段提交由于 redo log 和 binlog 是两个独立的逻辑,如果不用两阶段提交,要么就是先写完 redo log 再写 binlog,或者采用反过来的顺序。我们看看这两种方式会有什么问题。仍然用前面的 update 语句来做例子。假设当前 id=2 的行,字段 age 的值是 22,再假设执行 update 语句过程中在写完第一个日志后,第二个日志还没有写完期间发生了 crash,会出现什么情况呢?先写 redo log 后写 binlog。假设在 redo log 写完,binlog 还没有写完的时候,MySQL 进程异常重启。由于我们前面说过的,redo log 写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行 age 的值是 22。但是 binlog 没写完就 crash 了,这时 binlog 里面并没有记录这个语句。因此,之后备份日志的时候,存起来的 binlog 里面就没有这条语句。等到需要用这个 binlog 来恢复临时库的话,由于这个语句的 binlog 丢失,这个临时库就会少了这一次更新,恢复出来的这一行 age 值就是 22,与原库的值不同。先写 binlog 后写 redo log。如果在 binlog 写完之后 crash,由于 redo log 还没写,崩溃恢复以后这个事务无效,所以 age 的值是 22。但是 binlog 里面已经记录了 "把从 22 改成 23" 这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 age 的值就是 23,与原库的值不同。所以,如果不使用 "两阶段提交",数据库的状态就有可能和用 binlog 恢复出来的不一致。另外:sync_binlog 这个参数建议设置成 1,表示每次事务的 binlog 都持久化到磁盘,这样可以保证 MySQL 异常重启之后 binlog 不丢失。3.6 binlog 的应用场景主从复制 :在 Master 端开启 binlog ,然后将 binlog 发送到各个 Slave 端, Slave 端重放 binlog 从而达到主从数据一致。数据恢复 :通过使用 mysqlbinlog 工具来恢复数据。04 数据恢复的过程前面说过,binlog 会记录所有的逻辑操作,并且是采用 "追加写" 的形式。如果你的 DBA 承诺说一个月内可以恢复,那么备份系统中一定会保存最近一个月的所有 binlog,同时系统会定期做整库备份。这里的 "定期" 取决于系统的重要性,可以是一天一备,也可以是一周一备。当需要恢复到指定的某一秒时,比如某天下午两点发现中午十二点有一次误删表,需要找回数据,那你可以这么做:首先,找到最近的一次全量备份,如果你运气好,可能就是昨天晚上的一个备份,从这个备份恢复到临时库;然后,从备份的时间点开始,将备份的 binlog 依次取出来,重放到中午误删表之前的那个时刻。这样你的临时库就跟误删之前的线上库一样了,然后你可以把表数据从临时库取出来,按需要恢复到线上库。看到这里,小胖露出了目视父亲的笑容。巨人的肩膀《高性能 MySQL》zhihu.com/question/411272546/answer/1375199755zhihu.com/question/425750274/answer/1525436152time.geekbang.org/column/article/68633my.oschina.net/vivotech/blog/4289724hiddenpps.blog.csdn.net/article/details/10850537105 总结本文讲解了事务日志(redo og)的几个方面:为什么需要 redo log?它的写入过程、结构、存的啥以及什么是 crash-save 等等;此外还聊了 binlog 的定义、日志格式、与 redo log 的区别、update 语句的执行流程、两阶段提交、以及 binlog 的应用场景。好啦,以上就是狗哥关于 MySQL 日志的总结。感谢各技术社区大佬们的付出,如果说我看得更远,那是因为我站在你们的肩膀上。希望这篇文章对你有帮助,我们下篇文章见~
mysql 作为一个关系型数据库,在国内使用应该是最广泛的。也许你司使用 Oracle、Pg 等等,但是大多数互联网公司,比如我司使用得最多的还是 Mysql,重要性不言而喻。事情是这样的,某天我司小胖问我执行 select * from table,数据库底层到底发生了啥?从而我们得到数据呢?以下把我给问住了,为此我查阅了大量的书籍、博客。于是就有了这篇文章。假设现在我有张 user 表,只有两列,一列 id 自增的,一列 name 是 varchar 类型。建表语句是这样的:CREATE TABLE IF NOT EXISTS `user`( `id` INT UNSIGNED AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL, PRIMARY KEY ( `id` ) )ENGINE=InnoDB DEFAULT CHARSET=utf8;小胖的问题就是下面这个语句的执行过程。select * from user where id = 1;01 mysql 架构概览要想理解这个问题就必须要知道 mysql 的内部架构。为此,我画了张 mysql 的架构图(你也可以理解为 sql 查询语句的执行过程),如下所示:首先 msql 分为 server 层和存储引擎层两个部分。server 层包括四个功能模块,分别是:连接器、查询缓存、优化器、执行器。这一层负责了 mysql 的所有核心工作,比如:内置函数、存储过程、触发器以及视图等。而存储引擎层则是负责数据的存取。注意,存储引擎在 mysql 是可选的,常见的还有: InnoDB、MyISAM 以及 Memory 等,最常用的就是 InnoDB。现在默认的存储引擎也是它(从 mysql 5.5.5 版本开始),大家可以看到我上面的建表语句就是指定了 InnoDB 引擎。当然,你不指定的话默认也是它。由于存储引擎是可选的,所以 mysql 中,所有的存储引擎其实是共用一个 server 层的。回到正题,我们就以这张图的流程来解决一下小胖的问题。1.1 连接器首先,数据库要执行 sql,肯定要先连接数据库吧。这部分工作就是由连接器完成。它负责校验账户密码、获取权限、管理连接数,最终与客户端建立连接等工作。mysql 链接数据库是这样写的:mysql -h 127.0.0.1 -P 3306 -u root -p # 127.0.0.1 : ip 3306 : 端口 root : 用户名运行命令之后需要输入密码,当然也可以跟在 -p 后面。不过不建议这么做,会有密码泄露的风险。输入命令后,连接器根据你的账户名密码验证身份。这是会出现两种情况:账号或密码不对,服务端会返回一个 "ERROR 1045 (28000): Access denied for user 'root'@'127.0.0.1' (using password: YES)" 的错误,退出连接。验证通过,连接器就会到权限表查出你的权限。之后你有啥权限都要通过这时读到的权限进行判断。注意,我说的是此时查到的权限。就算你用管理员账号修改了当前用户的权限,此时已连接上的当前用户不受影响,必须要重启 mysql 新的权限才会生效。1.1.1 查看连接状态连接完成,如果后续没有做任何事情,这个连接就处于空闲状态。你可以用 show processlist; 命令查看 mysql 的连接信息,如下图,我的数据库连接都是 Sleep 状态的,除了执行 show processlist 操作的连接。1.1.2 控制连接如果客户端太长时间没有操作,此连接将会自动断开。这个时间默认是 8 小时,由参数 wait_timeout 控制。如果断开以后继续操作就会收到 "Lost connection to MySQL server during query" 的错误。这时就必须重连才能执行请求。数据库里面有长短连接之分,长连接:连接成功后不断有请求,就会一直使用同一连接。短连接:每次执行完几次请求就断开连接,下次需要再建立。由于建立连接是比较耗时的操作,所以建议使用长连接。但这会有个问题长连接一直连着就会导致内存占用过大,被系统强行沙雕。从而导致 MySQL 异常重启。如何解决呢?两个方法:定期断开长连接。使用特定时间,或者程序判断执行一个占用内存大的操作后,断开连接。之后需要操作就重连。mySQL 5.7 或以上版本,可以在每次执行一个占用内存大的操作后,执行 mysql_reset_connection 来重新连接资源,此时不需重连或重新做权限认证,但会把连接状态恢复到刚创建完时。1.2 查询缓存连接建立以后可以执行 select 语句了。这就会来到第二步:查询缓存。查询缓存中存储的数据是 key-value 的形式,key 是查询语句,value 是查询的结果。逻辑是这样的:先看看查询缓存有没该语句对应的 value?有则直接取出返回客户端,无则继续到数据库执行语句。查出结果后会放一份到缓存中,再返回客户端。你可能发现缓存真的香,但是并不建议使用查询缓存,因为有弊端。查询缓存的失效非常频繁,只有某个表有更新。它马上失效了,对于经常更新的表来说,命中缓存的概率极低。它仅仅适用于那些不经常更新的表。而 MySQL 似乎也考虑到这点了。提供了 query_cache_type 参数,把它设置为 DEMAND 就不再使用缓存。而对于要使用缓存的语句则可用 SQL_CACHE 显示指定,像这样:select SQL_CACHE * from user where id = 1;PS:MySQL 8.0 及以上版本把查询缓存删掉了,之后再也没有这块功能了。1.3 分析器如果没有命中缓存就进入分析器,这里就是对 sql 进行分析。分析器会做词法分析。你输入的 sql 是啥,由啥组成,MySQL 都需要知道它们代表什么。首先根据 "select" 识别出这是查询语句。字符串 "user" 识别成 "表名 user"、字符串 "id" 识别成 "列名 id"。之后进行语法分析,它会根据输入的语句分析是不是符合 MySQL 的语法。具体表现就是 select、where、from 等关键字少了个字母,明显不符合 MySQL 语法,这次就会报个语法错误的异常:它一般会提示错误行数,关注 "use near" 后面即可。1.4 优化器过了分析器,就来到了优化器。MySQL 是个聪明的仔,再执行之前会自己优化下客户端传过来的语句,看看那种执行起来不那么占内存、快一点。比如下面的 sql 语句:select * from user u inner join role r on u.id = r.user_id where u.name = "狗哥" and r.id = 666它可以先从 user 表拿出 name = "狗哥" 记录的 ID 值再跟 role 表内连接查询,再判断 role 表里面 id 的值是否 = 666也可以反过来:先从 role 表拿出 id = 666 记录的 ID 值再跟 user 表内连接查询,在判断 user 表里面的 name 值是否 = "狗哥"。两种方案的执行结果是一样的,但是效率不一样、占用的资源也就不一样。优化器就是在选择执行的方案。它优化的是索引应该用哪个?多表联查应该先查哪个表?怎么连接等等。1.5 执行器分析器知道了做啥、优化器知道了应该怎么做。接下来就交给执行器去执行了。开始执行,判断是否有相应的权限。比如该账户对 user 表没权限就返回无权限的错误,如下所示:select * from user where id = 1; ERROR 1142 (42000): SELECT command denied to user 'nasus'@'localhost' for table 'user'PS:如果命中缓存没走到执行器这里,那么在返回查询结果时做权限验证。回到正题,如果有权限,继续打开表执行。执行器会根据表定义的引擎去使用对应接口。比如我们上面的 sql 语句执行流程是这样的:走 id 索引、调用 InnoDB 引擎取 "满足条件的第一行" 接口,再循环调用 "满足条件的下一行" 接口(这些接口都是存储引擎定义好的),直到表中不再有满足条件的行。执行器就将上述遍历得到的行组成结果集返回给客户端。对于 id 不是索引的表,执行器只能调用 "取表记录的第一行" 接口,再判断 id 是否 = 1。如果不是则跳过,是则存在结果集中;再调存储引擎接口取 "下一行",重复判断逻辑,直到表的最后一行。至此,整个 SQL 的执行流程完毕,小胖懂了吗?巨人的肩膀https://time.geekbang.org/column/article/68319总结本文通过一条简单的 SQL 查询语句,引出 MySQL 的结构以及这条 sql 查询语句的执行流程。相信你看完会对 SQL 有更深的理解。
03 MySQL 的索引是如何执行的?好了,可以作为所索引内存模型的数据结构都分析了一遍。最终 MySQL 还是选择了 B+ 树作为索引内存模型。那 B+ 树在具体的引擎中是怎么发挥作用的呢?一起来看看3.1 InnDB 索引首先是 InnDB 索引,篇幅原因,我就聊聊主键索引和普通索引。3.1.1 主键索引主键索引又叫聚簇索引,它使用 B+ 树构建,叶子节点存储的是数据表的某一行数据。当表没有创建主键索引是,InnDB 会自动创建一个 ROWID 字段用于构建聚簇索引。规则如下:在表上定义主键 PRIMARY KEY,InnoDB 将主键索引用作聚簇索引。如果表没有定义主键,InnoDB 会选择第一个不为 NULL 的唯一索引列用作聚簇索引。如果以上两个都没有,InnoDB 会使用一个 6 字节长整型的隐式字段 ROWID 字段构建聚簇索引。该 ROWID 字段会在插入新行时自动递增。多说无益,以下面的 Student 表为例,它的 id 是主键,age 列为普通索引。CREATE TABLE `student` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, `age` int(11) NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE, INDEX `index_age`(`age`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 66 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;表数据如下:主键索引等值查询 sql:select * from student where id = 38;过程如下:第一次磁盘 IO:从根节点检索,将数据块 1 加载到内存,比较 38 < 44,走左边。第二次磁盘 IO:将左边数据块 2 加载到内存,比较 8<37<38,走右边。第三次磁盘 IO:将右边数据块 6 加载到内存,比较 37<38,38=38。查询完毕,将数据返回客户端。流程图:3 次磁盘 IO主键索引范围查询 sqlselect * from student where id between 38 and 44;前面也介绍说了,B+ 树因为叶子节点有双向指针,范围查询可以直接利用双向有序链表。过程如下:第一次磁盘 IO:从根节点检索,将数据块 1 加载到内存,比较 38 < 44,走左边。第二次磁盘 IO:将左边数据块 2 加载到内存,比较 8<37<38,走右边。第三次磁盘 IO:将右边数据块 6 加载到内存,比较 37<38,38=38。走右边。第四次磁盘 IO:将右边数据块 7 加载到内存,比较 38<44=44。查询完毕,将数据返回客户端。流程图:一共四次磁盘 IO3.1.2 普通索引普通索引等值查询 sql在 InnDB 中,B+ 树普通索引不存储数据,只存储数据的主键值。比如本表中的 age,它的索引结构就是这样的:执行以下查询语句,它的流程又是怎样的呢?select * from student where age = 48;使用普通索引需要检索两次索引。第一次检索普通索引找出 age = 48 得到主键值,再使用主键到主键索引中检索获得数据。这个过程称为回表。也就是说,基于非主键索引的查询需要多扫描一遍索引树。因此,我们应该尽量使用主键查询。过程如下:第一次磁盘 IO:从根节点检索,将数据块 1 加载到内存,比较 48 < 54,走左边。第二次磁盘 IO:将左边数据块 2 加载到内存,比较 28<47<48,走右边。第三次磁盘 IO:将右边数据块 6 加载到内存,比较 47<48,48=48。得到主键 38。第四次磁盘 IO:从根节点检索,将根节点加载到内存,比较 38 < 44,走左边。第五次磁盘 IO:将左边数据块 2 加载到内存,比较 8<37<38,走右边。第六次磁盘 IO:将右边数据块 6 加载到内存,比较 37<38,38=38。查询完毕,将数据返回客户端。流程图:一共 6 次磁盘 IO。3.1.3 组合索引如果为每一种查询都设计一个索引,索引是不是太多了?如果我现在要根据学生的姓名去查它的年龄。假设这个需求出现的概览很低,但我们也不能让它走全表扫描吧?但是为一个不频繁的需求创建一个(姓名)索引是不是有点浪费了?那该咋做呢?我们可以建个(name,age)的联合索引来解决呀。组合索引的结构如下图所示:执行以下查询语句,它的流程又是怎样的呢?select * from student where name = '二狗5' and age = 48;过程如下:第一次磁盘 IO:从根节点检索,将数据块 1 加载到内存,比较 二狗 5 < 二狗 6,走左边。第二次磁盘 IO:将左边数据块 2 加载到内存,比较 二狗 2 < 二狗 4 < 二狗 5,走右边。第三次磁盘 IO:将右边数据块 6 加载到内存,比较 二狗 4 < 二狗 5,二狗 5 = 二狗 5。得到主键 38。第四次磁盘 IO:从根节点检索,将根节点加载到内存,比较 38 < 44,走左边。第五次磁盘 IO:将左边数据块 2 加载到内存,比较 8<37<38,走右边。第六次磁盘 IO:将右边数据块 6 加载到内存,比较 37<38,38=38。查询完毕,将数据返回客户端。流程图:一共六次磁盘 IO3.1.4 最左匹配原则最左前缀匹配原则和联合索引的索引存储结构和检索方式是有关系的。在组合索引树中,最底层的叶子节点按照第一列 name 列从左到右递增排列,但是 age 列是无序的,age 列只有在 name 列值相等的情况下小范围内递增有序。就像上面的查询,B+ 树会先比较 name 列来确定下一步应该搜索的方向,往左还是往右。如果 name 列相同再比较 age 列。但是如果查询条件没有 name 列,B + 树就不知道第一步应该从哪个节点查起,这就是所谓的最左匹配原则。可以说创建的 idx_name_age (name,age) 索引,相当于创建了 (name)、(name,age)两个索引。组合索引的最左前缀匹配原则:使用组合索引查询时,mysql 会一直向右匹配直至遇到范围查询 (>、<、between、like) 就停止匹配。3.1.5 覆盖索引覆盖索引是一种很常用的优化手段。因为在上面普通索引的例子中,由于查询结果所需要的数据只在主键索引上有,所以不得不回表。那么有没有可能经过索引优化,避免回表呢?比如改成这样子:select age from student where age = 48;在上面普通索引例子中,如果我只需要 age 字段,那是不是意味着我们查询到普通索引的叶子节点就可以直接返回了,而不需要回表。这种情况就是覆盖索引。看下执行计划:覆盖索引的情况:未覆盖索引的情况:3.2 myisam 索引还是上面那张 student 表,建表语句:CREATE TABLE `student` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, `age` int(11) NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE, INDEX `index_age`(`age`) USING BTREE ) ENGINE = MyISAM AUTO_INCREMENT = 66 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;3.2.1 主键索引与 InnDB 不同的是 myisam 的数据文件和索引文件是分开存储的。它的叶子节点存的是健值,数据是索引所在行的磁盘地址。它的结构如下:表 student 的索引文件存放在 student.MYI 中,数据文件存储在 student.MYD 中。主键索引等值查询select * from student where id = 38;它的具体执行流程如下:第一次磁盘 IO:从根节点检索,将数据块 1 加载到内存,比较 38 < 44,走左边。第二次磁盘 IO:将左边数据块 2 加载到内存,比较 8<37<38,走右边。第三次磁盘 IO:将右边数据块 6 加载到内存,比较 37<38,38=38。得到索引所在行的内存地址。第四次磁盘 IO:根据地址到数据文件 student.MYD 中获取对应的行记录。流程图:一共 4 次磁盘 IO。主键索引范围查询select * from student where id between 38 and 44;过程如下:第一次磁盘 IO:从根节点检索,将数据块 1 加载到内存,比较 38 < 44,走左边。第二次磁盘 IO:将左边数据块 2 加载到内存,比较 8<37<38,走右边。第三次磁盘 IO:将右边数据块 6 加载到内存,比较 37<38,38=38。得到索引所在行的内存地址。第四次磁盘 IO:根据地址到数据文件 student.MYD 中获取主键 38 对应的行记录。第五次磁盘 IO:将右边数据块 7 加载到内存,比较 38<44=44。得到索引所在行的内存地址。第六次磁盘 IO:根据地址到数据文件 student.MYD 中获取主键 44 对应的行记录。3.2.2 普通索引在 MyISAM 中,辅助索引和主键索引的结构是一样的,没有任何区别,叶子节点的数据存储的都是行记录的磁盘地址。只是主键索引的键值是唯一的,而辅助索引的键值可以重复。查询数据时,由于辅助索引的键值不唯一,可能存在多个拥有相同的记录,所以即使是等值查询,也需要按照范围查询的方式在辅助索引树中检索数据。3.3 索引的使用技巧3.3.1 避免回表上面说了,回表的原因是因为查询结果所需要的数据只在主键索引上有,所以不得不回表。回表必然会影响性能。那怎么避免呢?使用覆盖索引,举个栗子:还是上面的 student ,它的一条 sql 在业务上很常用:select id, name, age from student where name = '二狗2';而 student 表的其他字段使用频率远低于它,在这种情况下,如果我们在建立 name 字段的索引的时候,并不是使用单一索引,而是使用联合索引(name,age)这样的话再执行这个查询语句就可以根据辅助索引查询到的结果获取当前语句的完整数据。这样就有效避免了通过回表再获取 age 的数据。喏,这就是一个典型的用覆盖索引的优化策略减少回表的情况。3.3.2 联合索引的使用联合索引,在建立索引的时候,尽量在多个单列索引上判断下是否可以使用联合索引。联合索引的使用不仅可以节省空间,还可以更容易的使用到索引覆盖。比如上面的 student 表,我就建了 (name,age) 和 age 索引。联合索引的创建原则,在创建联合索引的时候因该把频繁使用的列、区分度高的列放在前面,频繁使用代表索引利用率高,区分度高代表筛选粒度大。也可以在常需要作为查询返回的字段上增加到联合索引中,如果在联合索引上增加一个字段而使用到了覆盖索引,这种情况下应该使用联合索引。联合索引的使用考虑当前是否已经存在多个可以合并的单列索引,如果有,那么将当前多个单列索引创建为一个联合索引。当前索引存在频繁使用作为返回字段的列,这个时候就可以考虑当前列是否可以加入到当前已经存在索引上,使其查询语句可以使用到覆盖索引。3.3.3 索引下推现在我的表数据是这样的:加了一个 sex 列。说到满足最左前缀原则的时候,最左前缀可以用于在索引中定位记录。这时,你可能要问,那些不符合最左前缀的部分,会怎么样呢?我们还是以学生表的联合索引(name,age)为例。如果现在有一个需求:检索出表中 “名字第一个字是二,而且年龄是 38 岁的所有男生”。那么,SQL 语句是这么写的:select * from student where name like '张%' and age=38 and sex='男';根据前缀索引规则,所以这个语句在搜索索引树的时候,只能用 "张",找到三个满足条件的记录(图中红框数据)。当然,这还不错,总比全表扫描要好。然后呢?当然是判断其他条件是否满足。在 MySQL5.6 之前,只能从满足条件的记录 id=18 开始一个个回表。到主键索引上找出数据行,再对比字段而 MySQL 5.6 引入的索引下推优化(index condition pushdown),可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。它的整个执行的流程图是这样的:InnoDB 在(name,age)索引内部就判断了 age 是否等于 38,对于不等于 38 的记录,直接判断并跳过。在我们的这个例子中,只需要对 id=18 和 id=65 这两条记录回表取数据判断,就只需要回表 2 次,这就是所谓的索引下推。
01 索引是什么?索引是一种数据结构,它的出现就是为了提高数据查询的效率,就像一本书的目录。想想一本书几百页,没有目录估计找得够呛的。举个通俗点的例子,我在知乎刷到的,比喻得很妙。我们从小就用的新华字典,里面的声母查询方式就是聚簇索引。偏旁部首就是二级索引 偏旁部首 + 笔画就是联合索引。索引本身也是占用磁盘空间的(想想一本书中的目录也是占用页数的,你就知道了),它主要以文件的形式存在于磁盘中。1.1 索引的优缺点优点提高查询语句的执行效率,减少 IO 操作的次数创建唯一性索引,可以保证数据库表中每一行数据的唯一性加了索引的列会进行排序(一本书的章节顺序不就是按照目录来排嘛),在使用分组和排序子句进行查询时,可以显著减少查询中分组和排序的时间缺点索引需要占物理空间创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增加当对表中的数据进行增删改查是,索引也要动态的维护,这样就降低了数据的更新效率1.2 索引的分类主键索引一种特殊的唯一索引,不允许有空值。(主键约束 = 唯一索引 + 非空值)唯一索引索引列中的值必须是唯一的,但是允许为空值。普通索引MySQL 中的加索引类型,没啥限制。允许空值和重复值,纯粹为了提高查询效率而存在。单列索引没啥好说的,就是索引的列数量只有一个,每个表可以有多个单列索引。组合索引多列值组成一个索引,专门用于组合搜索,其效率大于索引合并。注意,使用它的时候需要遵守最左匹配原则。多个列作为查询条件时,组合索引在工作中很常用。全文索引只能在文本内容,也就是 TEXT、CHAR、VARCHAR 数据类型的列上建全文索引。有人说创建单列索引不就完了吗?考虑一种情况:当这列的内容很长时,用 like 查询就会很慢,这是就适合建全文索引。前缀索引还是只能作用于文本内容,也就是 TEXT、CHAR、VARCHAR 数据类型的列上建前缀索引,它可以指定索引列的长度,它是这样写的:// 在 x_test 的 x_name 列上创建一个长度为 4 的前缀索引 alter table x_test add index(x_name(4));这个长度是根据实际情况来定的。长了太占用空间,短了不起效果。比如:我有个表的 x_name 的第一个字符几乎都是一样的(假设都是 1),如果创建索引的长度 = 1,执行以下查询的时候就可能比原来更糟。因为数据库里面太多第一个字符 = 1 的列了,所以选的时候尽量选择数据开始有差别的长度。SELECT * FROM x_test WHERE x_name = '1892008.205824857823401.800099203178258.8904820949682635656.62526521254';空间索引MySQL 在 5.7 之后的版本支持了空间索引,而且支持 OpenGIS 几何数据模型。MySQL 在空间索引这方面遵循 OpenGIS 几何数据模型规则。02 索引的内存模型实现索引的方式有很多种,这里先介绍下最常见的三种:哈希表、有序数组、二叉树,其中二叉树又分为二叉查找树、平衡二叉树、B 树以及 B+ 树,从而说明为啥 InnDB 选择了 B+ 树?为了方便作图举例我先建个表,建表语句如下:user 有两列,一列是身份证号,还有一列是名称。CREATE TABLE IF NOT EXISTS `user`( `id_card` INT(6) NOT NULL, `name` VARCHAR(100) NOT NULL, PRIMARY KEY ( `id_card` ) )ENGINE=InnoDB DEFAULT CHARSET=utf8;2.1 哈希表HashMap 相信大家都用过,哈希表就是一种以键值对存储数据的结构。在 MySQL 中 key 用于存储索引列,value 就是某行的数据或者是它的磁盘地址。用过 HashMap 的你可能知道了,当多个 key 经过哈希函数换算之后会出现同一个值,这种情况下就会 value 值的结构就是个链表。假设现在让你通过身份证号找名字,这时它的哈希表索引结构是这样的:从上图可知,user2 和 user4 哈希出来的 key 值都是 M,这个时候 value 的值就是个链表。如果你要查 id_card = 66688 的人,步骤是:先将 66688 通过哈希函数算出 M,然后按顺序遍历链表,找到 user2。你可能注意到了上图中四个 id_card 的值并不是递增的,所以增加新 user 时速度会很快,往后追加就好。但又因为不是有序的,做区间查询的速度就会很慢。所以,哈希表结构适用于只有等值查询的场景,不适合范围查询。2.2 有序数组为了解决区间查询速度慢的问题,有序数组应运而生。它的等值和范围查询都很快。还是上面根据身份号找用户的例子,这时候的索引结构是这样的:身份证号递增且不重复从而有以上有序数组,这是如果你要查 id_card = 66666 的用户,用二分法就可以啦,复杂度是 O (log (N))。这数组还支持范围查询,还是用二分查找法,如果你要查区间 [12345,66666] 的用户,只需要二分查找出 id_card 大于等于 12345 且小于 66666 的用户即可。单看查询效率,有序数组简直完美,但是如果我们要新增数据就很很难受了。假设你要新增 id_card = 12346 的用户,那就只能把后面的数据都往后挪一个位置,成本太高了。所以有序数组只适用于存储一些不怎么变的数据,比如一些过去的年份数据。2.3 二叉搜索树二叉搜索树,也称二叉查找树,或二叉排序树。其定义也比较简单,要么是一颗空树,要么就是具有如下性质的二叉树:每个节点只有两个分叉,左子树所有节点值比右子树小,每个节点的左、右子树也是一个小的二叉树,且没有健值相等的节点。说概览有点懵,先上个图。一般的二叉搜索树长这样:之所以设计成二叉有序的结构是因为可以利用二分查找法,它的插入和查找的时间复杂度都是 O (log (N)),但是最坏情况下,它的时间复杂度是 O (n),原因是在插入和删除的时候树没有保持平衡。比如顺拐的二叉树:![顺拐的二叉搜索树所以这种情况下,树的查询时间复杂度都变高,而且也不稳定。2.4 平衡二叉树平衡二叉树也叫 AVL 树,它与二叉查找树的区别在于平衡 **,它任意的左右子树之间的高度差不大于 1**。我做了个对比,如下图:这样就很开心了,根据平衡二叉树的特点。它的查询时间复杂度是 O (log (N)),当然为了维护平衡它更新的时间复杂度也是 O (log (N))。貌似完美?但是还有问题。学过数据结构都知道,时间复杂度与树高相关。你想想假设现在有一颗 100 万节点的平衡二叉树,树高 20。一次查询需要访问 20 个数据块。而根据计算机组成原理得知,从磁盘读一个数据快平均需要 10ms 的寻址时间。PS:索引不止存在内存中,还会写到磁盘上,所以优化的核心在于减少磁盘的 IO 次数。也就是说,对于一个 100 万行的表,如果使用平衡二叉树来存储,单独访问一行可能需要 20 个 10ms 的时间,也就是 0.2s,这很难受了。此外,平衡二叉树不支持快速的范围查询,范围查询时需要从根节点多次遍历,查询效率真心不高。所以,大多数的数据库存储也并不使用平衡二叉树。2.5 B 树上面分析我们知道了,查询慢是因为树高,要多次访问磁盘。为了让一个查询尽量少触及磁盘。我们可以降低树的高度,既然有二叉。那我们多分几个叉,树的高度不就降低了?所以,这时就用到了 B 树(你心里没点吗?哈哈哈)。在 MySQL 的 InnoDB 存储引擎一次 IO 会读取的一页(默认一页 16K)的数据量,而二叉树一次 IO 有效数据量只有 16 字节,空间利用率极低。为了最大化利用一次 IO 空间,一个简单的想法是在每个节点存储多个元素,在每个节点尽可能多的存储数据。每个节点可以存储 1000 个索引(16k/16=1000),这样就将二叉树改造成了多叉树,通过增加树的叉树,将树从高瘦变为矮胖。构建 1 百万条数据,树的高度只需要 2 层就可以(1000*1000=1 百万),也就是说只需要 2 次磁盘 IO 就可以查询到数据。磁盘 IO 次数变少了,查询数据的效率也就提高了。B 树也叫 B- 树,一颗 m 阶(m 表示这棵树最多有多少个分叉)的 B 树。特点是:每个非叶子节点并且非根节点最少有 m/2 个(向上取整),即内部节点的子节点个数最少也有 m/2 个。根节点至少有两个子节点,每个内节点(非叶子节点就是内节点)最多有 m 个分叉。B 树的所有节点都存储数据,一个节点包含多个元素,比如健值和数据,节点中的健值从小到大排序。叶子节点都在同一层,高度一致并且它们之间没有指针相连。3 阶的 B 树结构如下图所示:等值查询在这样的结构下我们找值等于 48 的数据,还是使用二分查找法。它的查询路径是这样的:数据库 1-> 数据块 3-> 数据块 9。一共经过三次磁盘 IO,而同样数据量情况下,用平衡二叉树存储的树高肯定是更高的。它的 IO 次数显然是更高的。所以说 B 树其实是加快了查询效率。范围查询不知道大家注意到没有?B 树的叶子节点,并没有指针相连。意味着如果是范围查询,比如我查 41~ 58 的数据。首先,二分查找法访问:数据块 1-> 数据块 3-> 数据块 9,找到 41;然后再回去从根节点遍历:数据块 1-> 数据块 3-> 数据块 10,找到 58,一共经历了 6 次 IO 查询才算是完成,这样查询的效率就慢了很多。它还存在以下问题:1. 叶子节点无指针相连,所以范围查询增加了磁盘 IO 次数,降低了查询效率。2. 如果 data 存储的是行记录,行的大小随着列数的增多,所占空间会变大。这时,一个页中可存储的数据量就会变少,树相应就会变高,磁盘 IO 次数就会变大。所以说,B 树还有优化的空间。2.6 B+ 树B+ 树其实是从 B 树衍生过来的。它与 B 树有两个区别:B+ 树的非叶子节点不存放数据,只存放健值。B + 树的叶子节点之间存在双向指针相连,而且是双向有序链表它的数据结构如下图所示:由上图得知,B+ 树的数据都存放在叶子节点上。所以每次查询我们都需要检索到叶子节点才能把数据查出来。有人说了,那这不变慢了吗?B 树不一定要检索到叶子节点呀。其实不然,因为 B+ 的非叶子节点不再存储数据。所以它可以存更多的索引,也即理论上 B+ 树的树高会比 B 树更低。从这个角度来说,与其为了非叶子结点上能存储值而选择 B 树,倒不如选择 B+ 树,降低树高。我们通过分析来看看 B+ 树靠不靠谱。等值查询在这样的结构下我们找值等于 48 的数据,还是使用二分查找法。它的查询路径是这样的:数据块 1-> 数据块 3-> 数据块 9。一共经过三次磁盘 IO,这没毛病。范围查询比如我查 41~ 49 的数据。首先二分查找访问:数据库 1-> 数据块 3-> 数据块 8。一样经过了三次磁盘 IO,找到 41 缓存到结果集。但由于叶子节点是个双向有序链表,这个时候只需要往后走。将 49 所在的数据块 9 加载到内存遍历,找到 49,查询结束,只走了 4 次磁盘 IO。这里可以看出对于范围查询来说,相比于 B 树要走一遍老路,B+ 树就显得高效很多。所以,B+ 树中等值和范围查询都支持快速查。这样 MySQL 就选择了 B+ 树作为索引的内存模型。
什么是 SpringBoot ?SpringBoot 跟 Spring 是一脉相承的,本质上是 Spring 的延伸和扩展,它就是为了简化 Spring 项目的构建以及开发的过程而生。举个栗子(我自己的理解,不喜勿喷):如果 Spring 是个汽车引擎;SpringBoot 就是一台汽车,加上油就能开。SpringBoot 有哪些新特性?四个,分别是更快速的构建能力、起步即可依赖、内嵌容器支持以及 Actuator 监控。更快速的构建能力SpringBoot 提供了一堆 Starters 用于快速构建项目,Starters 可以理解为启动器。它包含了一系列可集成到应用中的依赖包,你可以直接在 Pom 引用,而不用到处去找。比如,在 Spring 中创建一个 Web 程序 Pom 配置的依赖项是这样的:<dependency> <groupId>org.springframework</groupId> <artifactId>spring-web</artifactId> <version>xxx</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>xxx</version> </dependency>而在 SpringBoot 中,只需要以下一个依赖就够了:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>当我们添加了 starter 模块以后,项目构建的初期就会把 web 所有依赖项自动添加到项目中。这样的例子还有很多,单元测试依赖、数据库依赖、ORM 依赖等等都有相应的 Starter。常见的 Starter 可以看 SpringBoot 官方文档:https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#using-boot-starter起步即可依赖SpringBoot 在新建项目时即可勾选依赖项,在项目初始化时就把相关依赖加进去,你需要数据库就把数据库相关 starter 加进去,需要单元测试支持,就把单元测试相关 starter 加进去,这样就大大缩短了去查询依赖的时间。如下图:内嵌容器支持Spring Boot 内嵌了 Tomcat、Jetty、Undertow 三种容器,也就是说,以往用 Spring 构建 web 项目我们还要配置 Tomcat 等容器,现在不用了。其默认嵌入的容器是 Tomcat 默认端口是 8080,在我们启动 Spring Boot 项目的时候,在控制台上就能看到如下信息:o.s.b.w.embedded.tomcat.TomcatWebServer :Tomcat started on port(s): 8080 (http) with context pathPS:开发中看到以上信息,就意味着 SpringBoot 项目已启动完成。当然,也可以修改内嵌的容器支持,比如,改成 Jetty :<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <!-- 移处 Tomcat --> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <!-- 换成 jetty 容器 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jetty</artifactId> </dependency>此时再次启动项目,信息如下:o.e.jetty.server.AbstractConnector: Started ServerConnector@53f9009d{HTTP/1.1, (http/1.1)}{0.0.0.0:8080} o.s.b.web.embedded.jetty.JettyWebServerActuator 监控Spring Boot 自带了 Actuator 监控功能,主要用于提供对应用程序监控,以及控制的能力,比如监控应用程序的运行状况,或者内存、线程池、Http 请求统计等,同时还提供了关闭应用程序等功能。Actuator 提供了 19 个接口,接口请求地址和代表含义如下表所示:SpringBoot 的启动流程除了问特性之外,面试官往往还会问 SpringBoot 的启动流程。那么它的启动流程是怎样的呢?来探讨下,项目创建完毕之后,会看到主类 Application 中有这样的代码:SpringApplication.run (Application.class, args)这就是 Spring Boot 程序的入口,那么它的启动流程是怎样的呢?看看源码就知道了:public ConfigurableApplicationContext run(String...args) { // 1.创建并启动计时监控类 StopWatch stopWatch = new StopWatch(); stopWatch.start(); // 2.声明应用上下文对象和异常报告集合 ConfigurableApplicationContext context = null; Collection < SpringBootExceptionReporter > exceptionReporters = new ArrayList(); // 3.设置系统属性 headless 的值 this.configureHeadlessProperty(); // 4.创建所有 Spring 运行监听器并发布应用启动事件 SpringApplicationRunListeners listeners = this.getRunListeners(args); listeners.starting(); Collection exceptionReporters; try { // 5.处理 args 参数 ApplicationArguments applicationArguments = new DefaultApplicationArguments(args); // 6.准备环境 ConfigurableEnvironment environment = this.prepareEnvironment(listeners, applicationArguments); this.configureIgnoreBeanInfo(environment); // 7.创建 Banner 的打印类 Banner printedBanner = this.printBanner(environment); // 8.创建应用上下文 context = this.createApplicationContext(); // 9.实例化异常报告器 exceptionReporters = this.getSpringFactoriesInstances(SpringBootExceptionReporter.class, new Class[] { ConfigurableApplicationContext.class }, context); // 10.准备应用上下文 this.prepareContext(context, environment, listeners, applicationArguments, printedBanner); // 11.刷新应用上下文 this.refreshContext(context); // 12.应用上下文刷新之后的事件的处理 this.afterRefresh(context, applicationArguments); // 13.停止计时监控类 stopWatch.stop(); // 14.输出日志记录执行主类名、时间信息 if (this.logStartupInfo) { (new StartupInfoLogger(this.mainApplicationClass)).logStarted(this.getApplicationLog(), stopWatch); } // 15.发布应用上下文启动完成事件 listeners.started(context); // 16.执行所有 Runner 运行器 this.callRunners(context } catch (Throwable var10 this.handleRunFailure(context, var10, exceptionReporters, listeners throw new IllegalStateException(var10); } try { // 17.发布应用上下文就绪事件 listeners.running(context); // 18.返回应用上下文对象 return context; } catch (Throwable var9) { this.handleRunFailure(context, var9, exceptionReporters, (SpringApplicationRunListeners) null); throw new IllegalStateException(var9); } }从以上源码看出,SpringBoot 的启动一共分了 18 个步骤:1. 创建并启动计时监控类计时器是为了监控并记录 Spring Boot 应用启动的时间的,它会记录当前任务的名称,然后开启计时器。2. 声明应用上下文对象和异常报告集合声明了应用上下文对象和一个异常报告的 ArrayList 集合。3. 设置系统属性 headless 的值设置 Java.awt.headless = true,其中 awt(Abstract Window Toolkit)的含义是抽象窗口工具集。设置为 true 表示运行一个 headless 服务器,可以用它来作一些简单的图像处理。4. 创建所有 Spring 运行监听器并发布应用启动事件获取配置的监听器名称并实例化所有的类。5. 初始化默认应用的参数类声明并创建一个应用参数对象。6. 准备环境创建配置并且绑定环境(通过 property sources 和 profiles 等配置文件)。7. 创建 Banner 的打印类SpringBoot 启动时会打印 Banner 图片,默认的如下所示:当然,你可以修改成自己的女朋友,直接在项目 resource 目录下加个 banner.txt 把你想要呈现的内容粘贴进去即可。喏,下面就是拿我女朋友照片制作的。PS,附上 banner 的在线制作链接:https://www.bootschool.net/ascii.::::. .::::::::. ::::::::::: ..:::::::::::' '::::::::::::' .:::::::::: '::::::::::::::.. ..::::::::::::. ``:::::::::::::::: ::::``:::::::::' .:::. ::::' ':::::' .::::::::. .::::' :::: .:::::::'::::. .:::' ::::: .:::::::::' ':::::. .::' :::::.:::::::::' ':::::. .::' ::::::::::::::' ``::::. ...::: ::::::::::::' ``::. ```` ':. ':::::::::' ::::.. '.:::::' ':'````..8. 创建应用上下文创建 ApplicationContext 上下文对象。9. 实例化异常报告器执行 getSpringFactoriesInstances () 方法获取异常类的名称,并通过反射实例化。10. 准备应用上下文把上面步骤已创建好的对象,设置到 prepareContext 中准备上下文。11. 刷新应用上下文解析配置文件,加载 bean 对象,并启动内置的 web 容器等等。12. 事件处理一些自定义的后置处理操作。13. 停止计时器监控类停止此过程第一步中的程序计时器,并统计任务的执行信息。14. 输出日志信息把相关的记录信息,如类名、时间等信息进行控制台输出。15. 发布应用上下文启动完成事件触发所有 SpringApplicationRunListener 监听器的 started 事件方法。16. 执行所有 Runner 运行器执行所有的 ApplicationRunner 和 CommandLineRunner 运行器。17. 发布应用上下文就绪事件触发所有的 SpringApplicationRunListener 监听器的 running 事件。18. 返回应用上下文对象至此,SpringBoot 启动完成。巨人的肩膀https://kaiwu.lagou.com/course/courseInfo.htm?courseId=59#/detail/pc?id=1762总结这篇聊了聊 Spring 和 SpringBoot 的区别、SpringBoot 的四个特性、最后还从源码角度介绍了 SpringBoot 的启动顺序。
Spring 相信 Java 程序员都很熟悉,甚至于有人说 Java 开发就是面向 Spring 开发。由此可见,Spring 在 Java 领域的地位是举足轻重的。Spring 有很多模块,常用的有 spring-core、spring-beans、spring-aop、spring-context、spring-expression 以及 spring-test 等,模块太多,不可能一次聊完。Bean 的概念在 Spring 中是非常重要的。这篇狗哥先聊聊 Bean 相关的内容。面试中常问 Bean 的注册方式、作用域、同名 Bean、Bean 的生命周期等等问题。Bean 的注册方式Spring 中 Bean 的注册方式有三种:XML 配置文件的注册方式Java 注解的注册方式Java API 的注册方式XML 方式这种方式已经不常用了,原因是维护过于繁琐。<bean id="user" class="com.nasus.spring.beans.User"> <property name="id" value="1"/> <property name="name" value="Spring"/> </bean>如上面代码所示,只需要指定注入的类以及类下定义的属性即可。注解方式用 Java 注解方式现在很常见,基本都是这种方式。注解又分为两种方式:@Component 和 @Bean 方式。@Component 方式注册 Bean,代码如下:@Component public class User { private Integer id; private String name // 忽略其他方法 }@Bean 方式注册 Bean,常与 @Configuration 结合使用。**@Configuration 可理解为 XML 配置里的标签,而 @Bean 可理解为用 XML 配置里面的标签。** 代码如下:@Configuration public class User { @Bean public User user() { return new User(); } // 忽略其他方法 }你说到这里面试官肯定会问 @Component 和 @Bean 二者有啥区别。区别就在于:「如果想将第三方的类变成组件,没有源代码,也就没办法使用 @Component 进行自动配置,这时就可以使用 @Bean (当然,也可以用 XML 方式)。」 比如下面的代码:@Configuration public class WireThirdLibClass { // 假设 ThirdLibClass 是第三方库中的类,我们没源码 @Bean public ThirdLibClass getThirdLibClass() { return new ThirdLibClass(); } }API 方式这种方式用的很少,实现容易出错,代码写起来也繁琐,增加了维护的时间成本。代码如下:public class CustomBeanDefinitionRegistry implements BeanDefinitionRegistryPostProcessor { @Override public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {} @Override public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { RootBeanDefinition personBean = new RootBeanDefinition(User.class); // 新增 Bean registry.registerBeanDefinition("user", userBean); } }Bean 的作用域一共 5 个:作用域描述用法singletonSpring 默认的作用域,单例作用域。表示在 Spring 中只会有一个 Bean 实例默认prototype原型作用域,每次调用 Bean 都会新建一个。多线程场景下常用。类上加 @Scope ("prototype")request该作用域将 bean 的定义限制为 HTTP 请求。只在 web-aware Spring ApplicationContext 的上下文中有效。类上加 @Scope (WebApplicationContext.SCOPE_REQUEST)session该作用域将 bean 的定义限制为 HTTP 会话。只在 web-aware Spring ApplicationContext 的上下文中有效。类上加 @Scope (WebApplicationContext.SCOPE_SESSION)global-session该作用域将 bean 的定义限制为全局 HTTP 会话。只在 web-aware Spring ApplicationContext 的上下文中有效。类上加 @Scope (WebApplicationContext.SCOPE_APPLICATION)怎么解决同名 Bean 的问题?Spring 对同名 Bean 的处理分两种情况:同一个 Spring 配置文件中 Bean 的 id 和 name 是不能够重复的,否则 Spring 容器启动时会报错要是不同配置文件,id 和 name 允许重复。Spring 处理规则是后引入的覆盖前面引用的。所以,我们自己定义的时候尽量使用长命名方式,避免重复。Bean 的生命周期我们知道 getBean () 是 Bean 对象的入口,它属于 BeanFactory 接口,而它的真正实现是 AbstractAutowireCapableBeanFactory 的 createBean () 方法,最终调用的是 doCreateBean (),源码如下:@Override protected Object createBean(String beanName, RootBeanDefinition mbd, @Nullable Object[] args) throws BeanCreationException { if (logger.isTraceEnabled()) { logger.trace("Creating instance of bean '" + beanName + "'"); } RootBeanDefinition mbdToUse = mbd; // 确定并加载 Bean 的 class Class < ? > resolvedClass = resolveBeanClass(mbd, beanName); if (resolvedClass != null && !mbd.hasBeanClass() && mbd.getBeanClassName() != null) { mbdToUse = new RootBeanDefinition(mbd); mbdToUse.setBeanClass(resolvedClass); } // 验证以及准备需要覆盖的方法 try { mbdToUse.prepareMethodOverrides(); } catch (BeanDefinitionValidationException ex) { throw new BeanDefinitionStoreException(mbdToUse.getResourceDescription(), beanName, "Validation of method overrides failed", ex); } try { // 给 BeanPostProcessors 一个机会来返回代理对象来代替真正的 Bean 实例,在这里实现创建代理对象功能 Object bean = resolveBeforeInstantiation(beanName, mbdToUse); if (bean != null) { return bean; } } catch (Throwable ex) { throw new BeanCreationException(mbdToUse.getResourceDescription(), beanName, "BeanPostProcessor before instantiation of bean failed", ex); } try { // 创建 Bean Object beanInstance = doCreateBean(beanName, mbdToUse, args); if (logger.isTraceEnabled()) { logger.trace("Finished creating instance of bean '" + beanName + "'"); } return beanInstance; } catch (BeanCreationException | ImplicitlyAppearedSingletonException ex) { throw ex; } catch (Throwable ex) { throw new BeanCreationException( mbdToUse.getResourceDescription(), beanName, "Unexpected exception during bean creation", ex); } }doCreateBean 方法的源码如下:protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args) throws BeanCreationException { // 实例化 bean,BeanWrapper 对象提供了设置和获取属性值的功能 BeanWrapper instanceWrapper = null; // 如果 RootBeanDefinition 是单例,则移除未完成的 FactoryBean 实例的缓存 if (mbd.isSingleton()) { instanceWrapper = this.factoryBeanInstanceCache.remove(beanName); } if (instanceWrapper == null) { // 创建 bean 实例 instanceWrapper = createBeanInstance(beanName, mbd, args); } // 获取 BeanWrapper 中封装的 Object 对象,其实就是 bean 对象的实例 final Object bean = instanceWrapper.getWrappedInstance(); // 获取 BeanWrapper 中封装 bean 的 Class Class < ? > beanType = instanceWrapper.getWrappedClass(); if (beanType != NullBean.class) { mbd.resolvedTargetType = beanType; } // 应用 MergedBeanDefinitionPostProcessor 后处理器,合并 bean 的定义信息 // Autowire 等注解信息就是在这一步完成预解析,并且将注解需要的信息放入缓存 synchronized(mbd.postProcessingLock) { if (!mbd.postProcessed) { try { applyMergedBeanDefinitionPostProcessors(mbd, beanType, beanName); } catch (Throwable ex) { throw new BeanCreationException(mbd.getResourceDescription(), beanName, "Post-processing of merged bean definition failed", ex); } mbd.postProcessed = true; } } boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences && isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { if (logger.isTraceEnabled()) { logger.trace("Eagerly caching bean '" + beanName + "' to allow for resolving potential circular references"); } // 为了避免循环依赖,在 bean 初始化完成前,就将创建 bean 实例的 ObjectFactory 放入工厂缓存(singletonFactories) addSingletonFactory(beanName, () - > getEarlyBeanReference(beanName, mbd, bean)); } // 对 bean 属性进行填充 Object exposedObject = bean; try { populateBean(beanName, mbd, instanceWrapper); // 调用初始化方法,如 init-method 注入 Aware 对象 exposedObject = initializeBean(beanName, exposedObject, mbd); } catch (Throwable ex) { if (ex instanceof BeanCreationException && beanName.equals(((BeanCreationException) ex).getBeanName())) { throw (BeanCreationException) ex; } else { throw new BeanCreationException( mbd.getResourceDescription(), beanName, "Initialization of bean failed", ex); } } if (earlySingletonExposure) { // 如果存在循环依赖,也就是说该 bean 已经被其他 bean 递归加载过,放入了提早公布的 bean 缓存中 Object earlySingletonReference = getSingleton(beanName, false); if (earlySingletonReference != null) { // 如果 exposedObject 没有在 initializeBean 初始化方法中被增强 if (exposedObject == bean) { exposedObject = earlySingletonReference; } else if (!this.allowRawInjectionDespiteWrapping && hasDependentBean(beanName)) { // 依赖检测 String[] dependentBeans = getDependentBeans(beanName); Set < String > actualDependentBeans = new LinkedHashSet < > (dependentBeans.length); for (String dependentBean: dependentBeans) { if (!removeSingletonIfCreatedForTypeCheckOnly(dependentBean)) { actualDependentBeans.add(dependentBean); } } // 如果 actualDependentBeans 不为空,则表示依赖的 bean 并没有被创建完,即存在循环依赖 if (!actualDependentBeans.isEmpty()) { throw new BeanCurrentlyInCreationException(beanName, "Bean with name '" + beanName + "' has been injected into other beans [" + StringUtils.collectionToCommaDelimitedString(actualDependentBeans) + "] in its raw version as part of a circular reference, but has eventually been " + "wrapped. This means that said other beans do not use the final version of the " + "bean. This is often the result of over-eager type matching - consider using " + "'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example."); } } } } try { // 注册 DisposableBean 以便在销毁时调用 registerDisposableBeanIfNecessary(beanName, bean, mbd); } catch (BeanDefinitionValidationException ex) { throw new BeanCreationException( mbd.getResourceDescription(), beanName, "Invalid destruction signature", ex); } return exposedObject; }从上述源码中,doCreateBean 方法,首先调用 createBeanInstance 方法 对 Bean 进行了实例化,该方法返回 BeanWrapper 对象。而 BeanWrapper 对象是 Spring 中一个基础的 Bean 结构接口,说它是基础接口是因为它连基本的属性都没有。BeanWrapper 接口有一个默认实现类 BeanWrapperImpl,其主要作用是对 Bean 进行填充,比如填充和注入 Bean 的属性等。完成实例化并设置完属性 & 依赖后,调用 Bean 的 initializeBean 初始化方法。初始化第一步是检查当前 Bean 对象是否实现了 BeanNameAware、BeanClassLoaderAware、BeanFactoryAware 等接口,源码如下:private void invokeAwareMethods(final String beanName, final Object bean) { if (bean instanceof Aware) { // 设置 beanName if (bean instanceof BeanNameAware) { ((BeanNameAware) bean).setBeanName(beanName); } // 注入当前 Bean 对象相应的 ClassLoader if (bean instanceof BeanClassLoaderAware) { ClassLoader bcl = getBeanClassLoader(); if (bcl != null) { ((BeanClassLoaderAware) bean).setBeanClassLoader(bcl); } } // 将 BeanFactory 容器注入到当前对象实例,使当前对象拥有 BeanFactory 容器的引用 if (bean instanceof BeanFactoryAware) { ((BeanFactoryAware) bean).setBeanFactory(AbstractAutowireCapableBeanFactory.this); } } }第二步是 BeanPostProcessor 增强处理,它主要对 Spring 容器中的 Bean 实例对象进行扩展,允许 Spring 在初始化 Bean 阶段对其进行定制化修改,比如为其提供代理实现等等。前置处理完事之后,检查和执行 InitializingBean 和 init-method 方法。其中,InitializingBean 是个接口,里面有 afterPropertiesSet 方法。在 Bean 初始化时会判断 bean 是否实现了 InitializingBean,是则调用 afterPropertiesSet 进行初始化;再检查protected void invokeInitMethods(String beanName, final Object bean, @Nullable RootBeanDefinition mbd) throws Throwable { // 判断当前 Bean 是否实现了 InitializingBean,如果是的话需要调用 afterPropertiesSet() boolean isInitializingBean = (bean instanceof InitializingBean); if (isInitializingBean && (mbd == null || !mbd.isExternallyManagedInitMethod("afterPropertiesSet"))) { if (logger.isTraceEnabled()) { logger.trace("Invoking afterPropertiesSet() on bean with name '" + beanName + "'"); } // 安全模式 if (System.getSecurityManager() != null) { try { AccessController.doPrivileged((PrivilegedExceptionAction < Object > )() - > { ((InitializingBean) bean).afterPropertiesSet(); // 属性初始化 return null; }, getAccessControlContext()); } catch (PrivilegedActionException pae) { throw pae.getException(); } } else { // 属性初始化 ((InitializingBean) bean).afterPropertiesSet(); } } // 判断是否指定了 init-method() if (mbd != null && bean.getClass() != NullBean.class) { String initMethodName = mbd.getInitMethodName(); if (StringUtils.hasLength(initMethodName) && !(isInitializingBean && "afterPropertiesSet".equals(initMethodName)) && !mbd.isExternallyManagedInitMethod(initMethodName)) { // 利用反射机制执行指定方法 invokeCustomInitMethod(beanName, bean, mbd); } } }完成以上工作就可以使用 Bean 对象了,在 Spring 容器关闭时执行销毁方法,但是 Spring 不会自动调用,需要我们主动调用。「如果是 BeanFactory 容器,我们需要主动调用 destroySingletons () 方法,通知 BeanFactory 容器去执行相应的销毁方法;如果是 ApplicationContext 容器,我们需要主动调用 registerShutdownHook () 方法,告知 ApplicationContext 容器执行相应的销毁方法」。巨人的肩膀https://kaiwu.lagou.com/course/courseInfo.htm?courseId=59#/detail/pc?id=1762总结这章聊了 Bean 的三种注册方式、五个作用域、以及同名问题的解决方法,最后还通过源码把 Bean 的生命周期走了一遍。关于生命周期的验证,之前在《Spring Bean 的生命周期》一文中写过,有兴趣的小伙伴可以看下。它的生命周期流程图如下:
什么是消息队列?消息队列在日常工作中用得特别多。目前市面上比较常用的 MQ 消息队列中间件有 RabbitMQ、Kafka、RocketMQ 等。根据业务需求,有时还可用 Redis 做轻量的消息队列。它的应用场景有很多,比如秒杀、记录日志等等。秒杀就很常见了,当同一时间有大量的请求进来。如果不适用消息队列,有可能会把服务器打挂。就算不挂也会造成响应超时等问题。有了消息队列,我们可以把请求都放到消息队列里面排队处理。如果长度超过最大可承载数量,那我们选择抛弃当前用户请求。提示客户 "排队中",这样更友好。记录日志也有对应的场景。在没消息队列前,我们是客户端进来请求,顺便记录日志。它是一个同步的行为,这会占用服务器响应的时间。而使用消息队列没我们可以在请求结束时,把日志扔到队列里面,由消费者处理,服务器直接返回请求结果。相信大家都知道,对于一个新的框架、中间件。用起来是非常简单的,看半小时相信你就能用起来了。「但如果让你手写一个简单的消息队列,你能写出来么?」你比较熟悉的消息队列❝狗哥用的 RabbitMQ 比较多,它是一个老牌的开源消息中间件。支持标准的 AMQP(Advanced Message Queuing Protocol,高级消息队列协议),使用 Erlang 语言开发,支持集群部署,和多种客户端语言混合调用,它支持的主流开发语言有以下这些:Java、.NET、Ruby、Python、PHP、JavaScript and Node、Objective-C and Swift、Rust、Scala 以及 Go。❞RabbitMQ 中有三个重要的角色:生产者:消息的创建者,负责创建和推送数据到消息服务器。消费者:消息的接收方,用于处理数据和确认消息。代理:也就是 RabbitMQ 服务本身,它用于扮演 "快递" 的角色,因为它本身并不生产消息,只是扮演了 "快递" 的角色,把消息进行暂存和传递。它的优点是:支持多语言支持持久化,RabbitMQ 支持磁盘持久化功能,保证了消息不会丢失;高并发,RabbitMQ 使用了 Erlang 开发语言,Erlang 是为电话交换机开发的语言,天生自带高并发光环和高可用特性;支持分布式集群,正是因为 Erlang 语言实现的,因此 RabbitMQ 集群部署也非常简单,只需要启动每个节点并使用 --link 把节点加入到集群中即可,并且 RabbitMQ 支持自动选主和自动容灾;支持消息确认,支持消息消费确认(ack)保证了每条消息可以被正常消费;它支持很多插件,比如网页控制台消息管理插件、消息延迟插件等,RabbitMQ 的插件很多并且使用都很方便。下图就是它的工作流程:它一共有四种消息类型:direct(默认类型)模式,此模式为一对一的发送方式,也就是一条消息只会发送给一个消费者;headers 模式,允许你匹配消息的 header 而非路由键(RoutingKey),除此之外 headers 和 direct 的使用完全一致,但因为 headers 匹配的性能很差,几乎不会被用到;fanout 模式,为多播的方式,会把一个消息分发给所有的订阅者;topic 模式,为主题订阅模式,允许使用通配符(#、*)匹配一个或者多个消息,我可以使用 "cn.mq.#" 匹配到多个前缀是 "cn.mq.xxx" 的消息,比如可以匹配到 "cn.mq.rabbit"、"cn.mq.kafka" 等消息。实现一个消息队列首先是「简单版」,必须有三个角色。消费者、生产者以及代理。只需借助 java 的 LinkedList 类即可。import java.util.LinkedList; import java.util.Queue; public class SimpleQueue { // 定义消息队列 private static Queue< String > queue = new LinkedList< >(); public static void main(String[] args) { producer(); // 调用生产者 consumer(); // 调用消费者 } // 生产者 public static void producer() { // 添加消息 queue.add("first message."); queue.add("second message."); queue.add("third message."); } // 消费者 public static void consumer() { while (!queue.isEmpty()) { // 消费消息 System.out.println(queue.poll()); } } }运行结果:可以看出消息是以先进先出的顺序消费的。加下来是「带延迟功能」的消息队列,这就必须需要借助 java 的 DelayQueue 类以及 Delayed 接口了。import java.text.DateFormat; import java.util.Date; import java.util.concurrent.DelayQueue; import java.util.concurrent.Delayed; import java.util.concurrent.TimeUnit; public class SimpleDelayQueue { // 延迟消息队列 private static DelayQueue delayQueue = new DelayQueue(); public static void main(String[] args) throws InterruptedException { producer(); // 调用生产者 consumer(); // 调用消费者 } // 生产者 public static void producer() { // 添加消息 delayQueue.put(new MyDelay(1000, "消息1")); delayQueue.put(new MyDelay(3000, "消息2")); } // 消费者 public static void consumer() throws InterruptedException { System.out.println("开始执行时间:" + DateFormat.getDateTimeInstance().format(new Date())); while (!delayQueue.isEmpty()) { System.out.println(delayQueue.take()); } System.out.println("结束执行时间:" + DateFormat.getDateTimeInstance().format(new Date())); } /** * 自定义延迟队列 */ static class MyDelay implements Delayed { // 延迟截止时间(单位:毫秒) long delayTime = System.currentTimeMillis(); private String msg; public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } /** * 初始化 * @param delayTime 设置延迟执行时间 * @param msg 执行的消息 */ public MyDelay(long delayTime, String msg) { this.delayTime = (this.delayTime + delayTime); this.msg = msg; } // 获取剩余时间 @Override public long getDelay(TimeUnit unit) { return unit.convert(delayTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS); } // 队列里元素的排序依据 @Override public int compareTo(Delayed o) { if (this.getDelay(TimeUnit.MILLISECONDS) > o.getDelay(TimeUnit.MILLISECONDS)) { return 1; } else if (this.getDelay(TimeUnit.MILLISECONDS) < o.getDelay(TimeUnit.MILLISECONDS)) { return -1; } else { return 0; } } @Override public String toString() { return this.msg; } } }运行结果:可以看出消息 1、消息 2 都实现了延迟执行的功能。巨人的肩膀https://kaiwu.lagou.com/course/courseInfo.htm?courseId=59#/detail/pc?id=1762小结本文聊了消息队列的使用场景、还介绍了我最常用的 RabbitMQ 的特性。同时还手动实现了简单版和带延迟功能的消息队列。它在我们工作中还是非常常用的,面试中问得也多。特别是诸如:聊聊你最常用的消息队列?如何手写一个消息队列等问题。
自旋锁 & 非自旋锁什么是自旋?字面意思是 "自我旋转" 。在 Java 中也就是循环的意思,比如 for 循环,while 循环等等。那自旋锁顾名思义就是「线程循环地去获取锁」。非自旋锁,也就是普通锁。获取不到锁,线程就进入阻塞状态。等待 CPU 唤醒,再去获取。自旋锁 & 非自旋锁的执行流程想象以下场景:某线程去获取锁(可能是自旋锁 or 非自旋锁),然而锁现在被其他线程占用了。它两获取锁的执行流程就如下图所示:自旋锁:一直占用 CPU 的时间片去循环获取锁,直到获取到为止。非自旋锁:当前线程进入阻塞,CPU 可以去干别的事情。等待 CPU 唤醒了,线程才去获取非自旋锁。自旋锁有啥好处?阻塞 & 唤醒线程都是需要资源开销的,如果线程要执行的任务并不复杂。这种情况下,切换线程状态带来的开销比线程执行的任务要大。而很多时候,我们的任务往往比较简单,简单到线程都还没来得及切换状态就执行完毕。这时我们选择自旋锁明显是更加明智的。所以,自旋锁的好处就是「用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销」。Java 中的自旋锁在 Java 1.5 版本及以上的并发包中,也就是 java.util.concurrent 的包中,里面的原子类基本都是自旋锁的实现。我们看看做常用的 AtomicInteger 类,它里面有个 getAndIncrement 方法,源码如下:getAndIncrement 也是直接调用 nsafe 的 getAndAddInt 方法,从下面源码可以看出这个方法直接就是做了一个 do-while 的循环。「这个循环就是一个自旋操作,如果在修改过程中遇到了其他线程竞争导致没修改成功的情况,就会 while 循环里进行死循环,直到修改成功为止」。自旋锁有啥坏处?虽然避免了线程切换的开销,但是它在避免线程切换开销的同时也带来了新的开销,因为它需要不停得去尝试获取锁。如果这把锁一直不能被释放,那么这种尝试只是无用的尝试,会白白浪费处理器资源。虽然刚开始自旋锁的开销大于线程切换。但是随着时间一直递增,总会超过线程切换的开销。适用场景是啥?首先我们知道自旋锁的好处就是能减少线程切换状态的开销;坏处就是如果一直旋下去,自旋开销会比线程切换状态的开销大得多。知道优缺点,那我们的适用场景就很简单了:并发不能太高,避免一直自旋不成功线程执行的同步任务不能太复杂,耗时比较短面试官:手写一个可重入的自旋锁呗在面试的时候经常会遇到让你实现一个可重入的自旋锁这种问题,小伙伴们还是得了解思路。为了引入自旋特性,我们使用 AtomicReference 类提供一个可以原子读写的对象引用变量。定义一个加锁方法,如果有其他线程已经获取锁,当前线程将进入自旋,如果还是已经持有锁的线程获取锁,那就是重入。定义一个解锁方法,解锁的话,只有持有锁的线程才能解锁,解锁的逻辑思维将 count-1,如果 count == 0,则是把当前持有锁线程设置为 null,彻底释放锁。源码如下:package com.nasus.thread.lock.Spin; import java.util.concurrent.atomic.AtomicReference; /** * 实现一个可重入的自旋锁 */ public class ReentrantSpinLock { private AtomicReference<Thread> owner = new AtomicReference<>(); //重入次数 private int count = 0; public void lock() { Thread t = Thread.currentThread(); if (t == owner.get()) { ++count; return; } //自旋获取锁 while (!owner.compareAndSet(null, t)) { System.out.println("自旋了"); } } public void unlock() { Thread t = Thread.currentThread(); //只有持有锁的线程才能解锁 if (t == owner.get()) { if (count > 0) { --count; } else { //此处无需CAS操作,因为没有竞争,因为只有线程持有者才能解锁 owner.set(null); } } } public static void main(String[] args) { ReentrantSpinLock spinLock = new ReentrantSpinLock(); Runnable runnable = () -> { System.out.println(Thread.currentThread().getName() + "开始尝试获取自旋锁"); spinLock.lock(); try { System.out.println(Thread.currentThread().getName() + "获取到了自旋锁"); Thread.sleep(4000); } catch (InterruptedException e) { e.printStackTrace(); } finally { spinLock.unlock(); System.out.println(Thread.currentThread().getName() + "释放了了自旋锁"); } }; Thread thread1 = new Thread(runnable); Thread thread2 = new Thread(runnable); thread1.start(); thread2.start(); } }从结果我们可以看出,前面一直打印 "自旋了",说明 CPU 一直在尝试获取锁。PS:如果你们电脑不好的话,在这期间风扇会加速的,因为 CPU 一直在工作。
悲观锁有 & 乐观锁首先,悲观锁与乐观锁是根据操作时是否锁住资源来判别的。悲观锁获取到锁时,必须要锁住资源;乐观锁则不会。一开始两线程争抢锁:悲观锁悲观锁之所以悲观,那是因为它觉得如果不锁住这个资源,别的线程就会来争抢,造成数据结果错误,所以悲观锁为了确保结果的正确性,会在每次获取并修改数据时,都把数据锁住,让其他线程无法访问该数据,这样就可以确保数据内容万无一失,从这点看悲观锁特别稳。下面通过几张图,大概就能明白悲观锁的执行过程了:接上面场景,如果 A 拿到锁,正在操作资源,B 就只能进入等待。直至 A 执行完毕释放锁,CPU 唤醒等待此锁的线程 B。线程 B 获取到了锁,就可以对同步资源进行自己的操作。这就是悲观锁的操作流程。乐观锁乐观锁顾名思义,比较乐观。相比于悲观锁,它是不锁住资源的,因为它觉得自己在操作资源时并不会有其他线程干扰。因此,为了保障数据的正确性。它在操作之前,会先判断在自己操作期间,其他线程是否有操作。如果没有,直接操作;如果有,则根据业务选择报错或者重试。下面来看看,乐观锁的执行过程:乐观锁的这把锁,其实就是依赖的 CAS (compare and swap:比较并交换)算法。所以,它在操作资源之前并不需要获得锁,直接读取资源到自己的工作内存内操作:操作完成,准备更新资源时。就会触发 CAS 算法,判断资源是否被其他线程修改过。没有修改过,直接更新,线程执行完毕。被修改过,根据业务逻辑走下一步,是重试还是报错?典型应用值得注意的是,不管是在 Java 还是数据库中都用到了。悲观锁、乐观锁的概念,只是实现方式稍有不同。下面介绍下 Java 中的悲观、乐观锁:悲观锁:synchronized 关键字和 Lock 接口这两够经典的,synchronized 必须要获取 mintor 锁才能进去操作资源;Lock 接口也是,必须显示调用 lock 才能操作资源。必须取到锁才能进行操作,这就是悲观锁的思想。乐观锁:原子类这类应该很常用,比如用作线程间的计数器。典型如 AtomicInteger 类在进行运算时,就使用了乐观锁的思想。使用 compareAndSet 方法更新数据,更新失败则重试。数据库中的悲观、乐观锁:典型的 select for update 语句,用的就是悲观锁思想。在提交之前不允许第三方来修改该数据。高并发环境吃不消。利用 version 字段实现乐观锁,version 代表这条数据的版本。操作数据不需要获取锁,操作完准备更新时。对比版本号是不是和获取数据时一致?是:更新,否:重新计算,再尝试更新。比如以下的 update 语句:UPDATE people SET name = '狗哥', version = 2 WHERE id = 30624700 AND version = 1使用场景说了这么久,悲观锁乐观锁的区别我知道了。那这两种锁在啥样的场景下使用呢?有人说悲观锁比乐观锁消耗大,因为悲观要锁、乐观不要锁(注意,这里我说不要是实际没锁住资源,它的锁其实是 CAS 算法)。是的,如果并发量很小的情况下,悲观锁确实比乐观锁消耗大。但如果并发量很高,导致乐观锁一直在重试,这时它消耗的资源比固定开销的悲观大,也是说不定的。悲观锁适用于并发写入多,竞争激烈等场景,这些场景下,悲观锁确实会让得不到锁的线程阻塞,但这些开销是固定的。它可以避免后面更新时的无用反复尝试操作,节约开销。乐观锁适用于大部分是读取,少部分是修改的场景,也适合虽然读写都很多,但是并发并不激烈的场景。在这些场景下,乐观锁不加锁的特点能让性能大幅提高。
前面聊了聊 synchronized,今天再聊聊 Lock。Lock 接口是 Java 5 引入的,最常见的实现类是 ReentrantLock、ReadLock、WriteLock,可以起到 “锁” 的作用。PS:篇幅原因,这章不聊实现类,后面再聊,只专注于 Lock 以及它与 synchronized 的区别。Lock 和 synchronized 是 java 中两种最常见的锁,"锁" 是一种工具。它用于控制对共享资源的访问。需要注意的是 Lock 设计的初衷并不是为了取代 synchronized ,而是一种升级。当 synchronized 不合适或者不能满足需求时(后面会说两者区别),Lock 顶上。一般情况下,Lock 同一时间只允许一个线程来访问这个共享资源。但是也有特殊的时候允许并发访问。比如读写锁(ReadWriteLock)里面的读锁(ReadLock)。PS:这就是其中一个 synchronized 不能满足的场景。Lock 的方法如下图所示,Lock 有 5 个方法,1 个条件:public interface Lock { void lock(); void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); Condition newCondition(); }lock 加锁主要有 4 个方法:lock、lockInterruptibly、tryLock、tryLock (long time, TimeUnit unit) 。解锁只有一个 unlock 方法。此外,还有一个线程间通信的条件(Condition)。下面逐一讲解:lockLock 有 4 种加锁方法,其中 lock 是最基础的。Lock 获取锁和释放锁都是显式的,不像 synchronized 是隐式的。所以 synchronized 会在抛异常时自动释放锁,而 Lock 只能是主动释放,加解锁都必须有显式的代码控制。所以就有了以下伪代码:Lock lock = ...; // 代码显式加锁 lock.lock(); try { //获取到了被本锁保护的资源,处理任务 //捕获异常 } finally { //代码显式释放锁 lock.unlock(); }这种 lock 的写法才是最安全的,先获取 lock,然后在 try 中操作资源,最后 finally 中释放锁,以保证绝对释放(这一步非常重要,它防止代码走不到这里,导致跳过了 unlock () 语句,使得这个锁永远不能被释放)。此外,lock () 方法有个缺点就是它不能被中断,一旦陷入死锁,lock () 就会陷入永久等待。所以,一般来说我们会用 tryLock 来代替 lock。tryLocktryLock 顾名思义是尝试获取锁的意思,返回值是 boolean,获取成功返回 true,获取失败返回 false*。使用方法如下:Lock lock = ...; if (lock.tryLock()) { try { //操作资源 } finally { //释放锁 lock.unlock(); } } else { //如果不能获取锁,则做其他事情 }使用 if 判断是否获取锁,成功获取则去操作共享资源,失败则去干别的事(比如,几秒之后重试,或者跳过此任务),最后还是记得要在 finally 中释放锁。tryLock 解决死锁问题想象这样一个场景:比如有两个线程同时调用以下这个方法,传入的 lock1 和 lock2 恰好是相反的。如果第一个线程获取了 lock1,第二个线程获取了 lock2,两个线程都需要获取对方的锁才能工作。如果用 lock 这就很容易陷入死锁,原因前面也说了。这个时候 tryLock 就发挥作用了:其中一个线程尝试获取锁 lock1,获取不到,则去隔段时间重试(这样做的目的在于等另一个获取到锁的线程在这段时间内完成任务,释放锁)。获取到了,则继续获取 lock2 ,获取到就操作共享资源,获取不到则释放 lock1,继续进入重试。public void tryLock(Lock lock1, Lock lock2) throws InterruptedException { while (true) { if (lock1.tryLock()) { try { if (lock2.tryLock()) { try { System.out.println("获取到了两把锁,完成业务逻辑"); return; } finally { lock2.unlock(); } } } finally { lock1.unlock(); } } else { Thread.sleep(new Random().nextInt(1000)); } } }tryLock(long time, TimeUnit unit)这个方法是 tryLock 的重载,区别在于 tryLock (long time, TimeUnit unit) 方法会有一个超时时间。在拿不到锁时会等待指定的时间,在指定时间内获取不到锁返回 false;获取到锁或者等待期间内获取到锁,返回 true。此外,超时之后,它将放弃主动获取锁。它还可以响应中断,抛出 InterruptException,避免死锁的产生。lockInterruptiblylockInterruptibly 去获取锁,获取到了马上返回 true。它非常执拗,如果获取不到锁就会一直尝试获取直到获取到为止,除非当前线程在获取锁期间被中断。可以把它理解为不限时的 tryLock (long time, TimeUnit unit)。public void lockInterruptibly() throws InterruptException { lock.lockInterruptibly(); try { System.out.println("操作资源"); } finally { lock.unlock(); } }unlockunlock 顾名思义就是释放锁。就 ReentrantLock 而言,调用 unlock 方法时,内部会把锁的 “被持有计数器” 减 1,减到 0 代表当前线程已经完全释放这把锁。newCondition()Condition 的用法就不说了,不会的看之前这篇文章:线程之生产者消费者模式。它有两个主要的方法 await 和 signal 分别用于阻塞线程和唤醒线程。对应于 Object 的 wait 和 notify。
synchronized 是 Java 的一个关键字,它能够将代码块 (方法) 锁起来。synchronized 是 互斥锁,同一时间只能有一个线程进入被锁住的代码块(方法)。synchronized 通过监视器(Monitor)实现锁。java 一切皆对象,每个对象都有一个监视器(锁标记),而 synchronized 就是使用对象的监视器来将代码块 (方法) 锁定的!为什么用 Synchronized ?我们加锁的原因是为了线程安全,而线程安全最重要就是保证原子性和可见性。被 Synchronized 修饰的代码块(方法),同一时间只能有一个线程执行,从而保证原子性。synchronized 通过使用监视器,来实现对变量的同步操作,保证了其他线程对变量的可见性。怎么用 Synchronized ?修饰普通同步方法:锁是当前实例对象修饰静态同步方法:锁是当前类的 Class 对象修饰同步代码块:修饰普通同步方法public class BigBigDog { // 修饰普通同步方法,普通方法属于实例对象 // 锁是当前实例对象 BigBigDog 的监视器 public synchronized void testCommon(){ // doSomething } }多个实例对象调用不会阻塞,比如:public class BigBigDog { // 修饰普通同步方法,普通方法属于实例对象 // 锁是当前实例对象 BigBigDog 的监视器 public synchronized void testCommon() { int i = 0; do { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Common function is locked " + i); } while (i++ < 10); } }测试方法:public class Main { public static void main(String[] args) { BigBigDog bigBigDog = new BigBigDog(); BigBigDog bigBigDog1 = new BigBigDog(); new Thread(bigBigDog::testCommon).start(); new Thread(bigBigDog1::testCommon).start(); } }结果:异步运行,因为锁的是实例对象,也就是锁不同,所以并不会阻塞。Common function is locked 0 Common function is locked 0 Common function is locked 1 Common function is locked 1 Common function is locked 2 Common function is locked 2 Common function is locked 3 Common function is locked 3 ···修饰静态同步方法public class BigBigDog { // 修饰静态同步方法,静态方法属于类(粒度比普通方法大) // 锁是类的锁(类的字节码文件对象:BigBigDog.class) public static synchronized void testStatic() { // doSomething } }synchronized 修饰静态方法获取的是类锁 (类的字节码文件对象),synchronized 修饰普通方法获取的是对象锁。也就是说:获取了类锁的线程和获取了对象锁的线程是不冲突的!测试下:public class BigBigDog { // 修饰普通同步方法,普通方法属于实例对象 // 锁是当前实例对象 BigBigDog 的监视器 public synchronized void testCommon() { int i = 0; do { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Common function is locked " + i); } while (i++ < 10); } // 修饰静态同步方法,静态方法属于类(粒度比普通方法大) // 锁是类的锁(类的字节码文件对象:BigBigDog.class) public static synchronized void testStatic() { int i = 0; do { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Static function is locked " + i); } while (i++ < 10); } }public class Main { public static void main(String[] args) { BigBigDog bigBigDog = new BigBigDog(); new Thread(bigBigDog::testCommon).start(); new Thread(BigBigDog::testStatic).start(); } }结果:异步运行,并不冲突。Common function is locked 0 Static function is locked 0 Common function is locked 1 Static function is locked 1 Common function is locked 2 Static function is locked 2 Common function is locked 3 Static function is locked 3修饰同步代码块public class BigBigDog { public void test3() { // 修饰代码块,锁是括号内的对象 // 这里的 this 是当前实例对象 BigBigDog 的监视器 synchronized (this) { // doSomething } } }public class BigBigDog { // 使用 object 的监视器作为锁 private final Object object = new Object(); public void test4() { // 修饰代码块,锁是括号内的对象 // 这里是当前实例对象 object 的监视器 synchronized (object) { // doSomething } } }除了第一种以 this 当前对象的监视器为锁的情况。对于同步代码块,Java 还支持它持有任意对象的锁,比如第二种的 object 。那么这两者有何区别?这两者并无本质区别,但是为了代码的可读性。还是更加建议用第一种(第二种,无缘无故定义一个对象)。Synchronized 的原理有以下代码:test 是静态同步方法,test1 是普通同步方法,test2 则是同步代码块。public class SynchronizedTest { // 修饰静态方法 public static synchronized void test() { // doSomething } // 修饰方法 public synchronized void test1(){ } public void test2(){ // 修饰代码块 synchronized (this){ } } }通过命令看下 synchronized 关键字到底做了什么事情:首先用 cd 命令切换到 SynchronizedTest.java 类所在的路径,然后执行 javac SynchronizedTest.java,于是就会产生一个名为 SynchronizedTest.class 的字节码文件,然后我们执行 javap -c SynchronizedTest.class,就可以看到对应的反汇编内容,如下:Z:\IDEAProject\review\review_java\src\main\java\com\nasus\thread\lock>javac -encoding UTF-8 SynchronizedTest.java Z:\IDEAProject\review\review_java\src\main\java\com\nasus\thread\lock>javap -c SynchronizedTest.class Compiled from "SynchronizedTest.java" public class com.nasus.thread.lock.SynchronizedTest { public com.nasus.thread.lock.SynchronizedTest(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static synchronized void test(); Code: 0: return public synchronized void test1(); Code: 0: return public void test2(); Code: 0: aload_0 1: dup 2: astore_1 3: monitorenter // 监视器进入,获取锁 4: aload_1 5: monitorexit // 监视器退出,释放锁 6: goto 14 9: astore_2 10: aload_1 11: monitorexit // 监视器退出,释放锁 12: aload_2 13: athrow 14: return Exception table: from to target type 4 6 9 any 9 12 9 any }test2 同步代码块解析主要看 test2 同步代码块的反编译内容,可以看出 synchronized 多了 monitorenter 和 monitorexit 指令。把执行 monitorenter 理解为加锁,执行 monitorexit 理解为释放锁,每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为 0。那这里为啥只有一次 monitorenter 却有两次 monitorexit ?JVM 要保证每个 monitorenter 必须有与之对应的 monitorexit,monitorenter 指令被插入到同步代码块的开始位置,而 monitorexit 需要插入到方法正常结束处和异常处两个地方,这样就可以保证抛异常的情况下也能释放锁。执行 monitorenter 的线程尝试获得 monitor 的所有权,会发生以下这三种情况之一:a. 如果该 monitor 的计数为 0,则线程获得该 monitor 并将其计数设置为 1。然后,该线程就是这个 monitor 的所有者。b. 如果线程已经拥有了这个 monitor ,则它将重新进入,并且累加计数。c. 如果其他线程已经拥有了这个 monitor,那个这个线程就会被阻塞,直到这个 monitor 的计数变成为 0,代表这个 monitor 已经被释放了,于是当前这个线程就会再次尝试获取这个 monitor。monitorexitmonitorexit 的作用是将 monitor 的计数器减 1,直到减为 0 为止。代表这个 monitor 已经被释放了,已经没有任何线程拥有它了,也就代表着解锁,所以,其他正在等待这个 monitor 的线程,此时便可以再次尝试获取这个 monitor 的所有权。test1 普通同步方法它并不是依靠 monitorenter 和 monitorexit 指令实现的,从上面的反编译内容可以看到,synchronized 方法和普通方法大部分是一样的,不同在于,这个方法会有一个叫作 ACC_SYNCHRONIZED 的 flag 修饰符,来表明它是同步方法。(在这看不出来需要看 JVM 底层实现)当某个线程要访问某个方法的时候,会首先检查方法是否有 ACC_SYNCHRONIZED 标志,如果有则需要先获得 monitor 锁,然后才能开始执行方法,方法执行之后再释放 monitor 锁。PS:想要进一步深入了解 synchronized 就必须了解 monitor 对象,对象有自己的对象头,存储了很多信息,其中一个信息标示是被哪个线程持有。可以参考这篇博客:@chenssy 大神写的很好,建议拜读下。
Mybatis 是一个开源的轻量级半自动化 ORM 框架,使得面向对象应用程序与关系数据库的映射变得更加容易。MyBatis 使用 xml 描述符或注解将对象与存储过程或SQL 语句相结合。Mybatis 最大优点是应用程序与 Sql 进行解耦,sql 语句是写在 Xml Mapper 文件中。OGNL 表达式在 Mybatis 当中应用非常广泛,其表达式的灵活性使得动态 Sql 功能的非常强大。OGNL 是 Object-Graph Navigation Language 的缩写,代表对象图导航语言。OGNL 是一种 EL 表达式语言,用于设置和获取 Java 对象的属性,并且可以对列表进行投影选择以及执行lambda表达式。Ognl 类提供了许多简便方法用于执行表达式的。Struts2 发布的每个版本都会出现的新的高危可执行漏洞也是因为它使用了灵活的 OGNL 表达式。公司后端采用 Mybatis 作为数据访问层,所使用版本为 3.2.3。线上环境业务系统在运行过程中出现了一个令人困惑的异常, 该异常时而出现时而不出现,构造各种 OGNL 表达式为空等特殊情况均不会重现该异常。具体异常堆栈信息如下:### Error querying database. Cause: org.apache.ibatis.builder.BuilderException: Error evaluating expression 'list != null and list.size() > 0'. Cause: org.apache.ibatis.ognl.MethodFailedException: Method "size" failed for object [1] [java.lang.IllegalAccessException: Class org.apache.ibatis.ognl.OgnlRuntime can not access a member of class java.util.Collections$SingletonList with modifiers "public"] ### Cause: org.apache.ibatis.builder.BuilderException: Error evaluating expression 'list != null and list.size() > 0'. Cause: org.apache.ibatis.ognl.MethodFailedException: Method "size" failed for object [1] [java.lang.IllegalAccessException: Class org.apache.ibatis.ognl.OgnlRuntime can not access a member of class java.util.Collections$SingletonList with modifiers "public"] at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:23) org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:107) at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:98) at cn.com.shaobingmm.MybatisBugTest$2.run(MybatisBugTest.java:88) at java.lang.Thread.run(Thread.java:745) Caused by: org.apache.ibatis.builder.BuilderException: Error evaluating expression 'list != null and list.size() > 0'. Cause: org.apache.ibatis.ognl.MethodFailedException: Method "size" failed for object [1] [java.lang.IllegalAccessException: Class org.apache.ibatis.ognl.OgnlRuntime can not access a member of class java.util.Collections$SingletonList with modifiers "public"] at org.apache.ibatis.scripting.xmltags.OgnlCache.getValue(OgnlCache.java at:47) at org.apache.ibatis.scripting.xmltags.ExpressionEvaluator.evaluateBoolean(ExpressionEvaluator.java:29) at org.apache.ibatis.scripting.xmltags.IfSqlNode.apply(IfSqlNode.java:30) at org.apache.ibatis.scripting.xmltags.MixedSqlNode.apply(MixedSqlNode.java:29) at org.apache.ibatis.scripting.xmltags.TrimSqlNode.apply(TrimSqlNode.java:51) at org.apache.ibatis.scripting.xmltags.MixedSqlNode.apply(MixedSqlNode.java:29) at org.apache.ibatis.scripting.xmltags.DynamicSqlSource.getBoundSql(DynamicSqlSource.java:37) at org.apache.ibatis.mapping.MappedStatement.getBoundSql(MappedStatement.java:275) at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:79) at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:104) ... 3 more Caused by: org.apache.ibatis.ognl.MethodFailedException: Method "size" failed for object [1] [java.lang.IllegalAccessException: Class org.apache.ibatis.ognl.OgnlRuntime can not access a member of class java.util.Collections$SingletonList with modifiers "public"] at org.apache.ibatis.ognl.OgnlRuntime.callAppropriateMethod(OgnlRuntime.java:837) at org.apache.ibatis.ognl.ObjectMethodAccessor.callMethod(ObjectMethodAccessor.java:61) at org.apache.ibatis.ognl.OgnlRuntime.callMethod(OgnlRuntime.java:860) at org.apache.ibatis.ognl.ASTMethod.getValueBody(ASTMethod.java:73) at org.apache.ibatis.ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:170) at org.apache.ibatis.ognl.SimpleNode.getValue(SimpleNode.java:210) at org.apache.ibatis.ognl.ASTChain.getValueBody(ASTChain.java:109) at org.apache.ibatis.ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:170) at org.apache.ibatis.ognl.SimpleNode.getValue(SimpleNode.java:210) at org.apache.ibatis.ognl.ASTGreater.getValueBody(ASTGreater.java:49) at org.apache.ibatis.ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:170) at org.apache.ibatis.ognl.SimpleNode.getValue(SimpleNode.java:210) at org.apache.ibatis.ognl.ASTAnd.getValueBody(ASTAnd.java:56) at org.apache.ibatis.ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:170) at org.apache.ibatis.ognl.SimpleNode.getValue(SimpleNode.java:210) at org.apache.ibatis.ognl.Ognl.getValue(Ognl.java:333) at org.apache.ibatis.ognl.Ognl.getValue(Ognl.java:413) at org.apache.ibatis.ognl.Ognl.getValue(Ognl.java:395) at org.apache.ibatis.scripting.xmltags.OgnlCache.getValue(OgnlCache.java:45) ... 12 moreList 的 size() 方法明显是 public 为何还会出现不可访问的异常。该问题并不是每一次都会出现,经过多次尝试,该异常一直未在测试环境重现。该接口在完整调用链路中的出错次数占总调用次数的比率为 0.01%,无意中联想到并发问题在周期性时间内往往是概率性发生。编写模拟多线程环境并发读取公司列表测试代码:<mapper namespace="CompanyMapper"> <select id="getCompanysByIds"resultType="cn.com.shaobingmm.Company"> select * from company <where> <if test="list != null and list.size() > 0"> and id in <foreach collection="list" item="id" open="(" separator="," close=")">#{id} </foreach> </if> </where> </select> </mapper>多线程并发环境下的压测代码String resource = "mybatis-config.xml"; InputStream in = null; try { in = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(in); final List<Long> ids = Collections.singletonList(1L); final SqlSession session = sqlSessionFactory.openSession(); final CountDownLatch mCountDownLatch = new CountDownLatch(1); for (int i = 0; i < 50; i++) { Thread thread = new Thread(new Runnable() { public void run() { try { mCountDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } for (int k = 0; k < 100; k++) { session.selectList("CompanyMapper.getCompanysByIds", ids); } } }); thread.start(); } mCountDownLatch.countDown(); synchronized (MybatisBugTest.class) { try { MybatisBugTest.class.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } catch (IOException e) { e.printStackTrace(); } catch (Throwable e) { e.printStackTrace(); } finally { if (in != null) try { in.close(); } catch (IOException e) { e.printStackTrace(); } }上诉异常堆栈信息在并发环境下果然重现出现,根据异常信息代码执行至该行代码时发生异常:Caused by: org.apache.ibatis.ognl.MethodFailedException: Method "size" failed for object [1] [java.lang.IllegalAccessException: Class org.apache.ibatis.ognl.OgnlRuntime can not access a member of class java.util.Collections$SingletonList with modifiers "public"] at org.apache.ibatis.ognl.OgnlRuntime.callAppropriateMethod(OgnlRuntime.java:837)异常信息表明OgnlRuntime类不能够访问java.util.Collections的私有成员SingletonList。查看源代码发现能够抛出 MethodFailedException 异常可以锁定在 invokeMethod 方法内部。public static Object callAppropriateMethod(OgnlContext context, Object source, Object target, String methodName, String propertyName, List methods, Object[] args) throws MethodFailedException { Object reason = null; Object[] actualArgs = objectArrayPool.create(args.length); try { Method e = getAppropriateMethod(context, source, target, methodName, propertyName, methods, args, actualArgs); if(e == null || !isMethodAccessible(context, source, e, propertyName)) { StringBuffer buffer = new StringBuffer(); if(args != null) { int i = 0; for(int ilast = args.length - 1; i <= ilast; ++i) { Object arg = args[i]; buffer.append(arg == null?NULL_STRING:arg.getClass().getName()); if(i < ilast) { buffer.append(", "); } } } throw new NoSuchMethodException(methodName + "(" + buffer + ")"); } Object var14 = invokeMethod(target, e, actualArgs); return var14; } catch (NoSuchMethodException var21) { reason = var21; } catch (IllegalAccessException var22) { reason = var22; } catch (InvocationTargetException var23) { reason = var23.getTargetException(); } finally { objectArrayPool.recycle(actualArgs); } throw new MethodFailedException(source, methodName, (Throwable)reason); }invokeMethod 方法代码public static Object invokeMethod(Object target, Method method, Object[] argsArray) throws InvocationTargetException, IllegalAccessException { boolean wasAccessible = true; if(securityManager != null) { try { securityManager.checkPermission(getPermission(method)); } catch (SecurityException var6) { throw new IllegalAccessException("Method [" + method + "] cannot be accessed."); } } if((!Modifier.isPublic(method.getModifiers()) || !Modifier.isPublic(method.getDeclaringClass().getModifiers())) && !(wasAccessible = method.isAccessible())) { method.setAccessible(true); (1) } Object result = method.invoke(target, argsArray); (3) if(!wasAccessible) { method.setAccessible(false); (2) } return result; }问题出现在 method 实际上是一个共享变量,也就是例子中的public int java.util.Collections$SingletonList.size()方法当第一个线程 t1 至 (1) 行代码允许 method 方法可以被调用,第二个线程 t2 执行至 (2) 将 method 的方法设置为不可以访问。接着 t1 又开始执行到 (3) 行的时候就会发生该异常。这是一个很典型的同步问题。Ognl2.7 已经修复了该问题,因为 ognl 源码是直接打包内嵌在 mybatis 包中, mybatis3.3.0 版本中也已经进行了修复升级。(划重点)public static Object invokeMethod(Object target, Method method, Object[] argsArray) throws InvocationTargetException, IllegalAccessException { boolean syncInvoke = false; boolean checkPermission = false; int mHash = method.hashCode(); synchronized(method) { if(_methodAccessCache.get(Integer.valueOf(mHash)) == null || _methodAccessCache.get(Integer.valueOf(mHash)) == Boolean.TRUE) { syncInvoke = true; } if(_securityManager != null && _methodPermCache.get(Integer.valueOf(mHash)) == null || _methodPermCache.get(Integer.valueOf(mHash)) == Boolean.FALSE) { checkPermission = true; } } boolean wasAccessible = true; Object result; if(syncInvoke) { synchronized(method) { if(checkPermission) { try { _securityManager.checkPermission(getPermission(method)); _methodPermCache.put(Integer.valueOf(mHash), Boolean.TRUE); } catch (SecurityException var12) { _methodPermCache.put(Integer.valueOf(mHash), Boolean.FALSE); throw new IllegalAccessException("Method [" + method + "] cannot be accessed."); } } if(Modifier.isPublic(method.getModifiers()) && Modifier.isPublic(method.getDeclaringClass().getModifiers())) { _methodAccessCache.put(Integer.valueOf(mHash), Boolean.FALSE); } else if(!(wasAccessible = method.isAccessible())) { method.setAccessible(true); _methodAccessCache.put(Integer.valueOf(mHash), Boolean.TRUE); } else { _methodAccessCache.put(Integer.valueOf(mHash), Boolean.FALSE); } result = method.invoke(target, argsArray); if(!wasAccessible) { method.setAccessible(false); } } } else { if(checkPermission) { try { _securityManager.checkPermission(getPermission(method)); _methodPermCache.put(Integer.valueOf(mHash), Boolean.TRUE); } catch (SecurityException var11) { _methodPermCache.put(Integer.valueOf(mHash), Boolean.FALSE); throw new IllegalAccessException("Method [" + method + "] cannot be accessed."); } } result = method.invoke(target, argsArray); } return result; }
一、依赖原则假设,在 JavaMavenService2 模块中,log4j 的版本是 1.2.7,在 JavaMavenService1 模块中,它虽然继承于 JavaMavenService2 模块,但是它排除了在 JavaMavenService2 模块中继承 1.2.7 的版本,自己引入了 1.2.9 的 log4j 版本。此时,相对于 WebMavenDemo 而言,log4j.1.2.7.jar 的依赖路径是 JavaMavenService1 >> JavaMavenService2 >> log4j.1.2.7.jar,长度是 3;而 log4j.1.2.9.jar 的依赖路径是 JavaMavenService1 >> log4j.1.2.7.jar 长度是 2。所以 WebMavenDemo 继承的是 JavaMavenService1 模块中的 log 版本,而不是 JavaMavenService2 中的,这叫路径优先原则 (谁路径短用谁)。此外,在路径相同的情况下,这种场景依赖关系发生了变化,WebMavenDemo 项目依赖 Sercive1 和 Service2,它俩是同一个路径,那么谁在 WebMavenDemo 的 pom.xml 中先声明的依赖就用谁的版本。这叫先定义先使用原则。比如:先声明 JavaMavenService1 所以 WebMavenDemo 继承它的 log4j.1.2.9.jar 依赖<!--先声明 JavaMavenService1 所以 WebMavenDemo 继承它的 log4j.1.2.9.jar 依赖--> <dependency> <groupId>com.nasus</groupId> <artifactId>JavaMavenService1</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>com.nasus</groupId> <artifactId>JavaMavenService2</artifactId> <version>1.0.0</version> </dependency>参考:cnblogs.com/hzg110/p/6936101.html二、依赖冲突的原因项目的依赖 service1 和依赖 service2 同时引入了依赖 log4j。这时,如果依赖 log4j 在 service1 和 service2 中的版本不一致就可能导致依赖冲突。如下图:注意,上面我用的是可能,并不是说满足上面的条件就一定会发生依赖冲突。因为 maven 遵循上面提到的两个原则:先定义先使用原则 (路径层级相同情况下)路径优先原则 (谁路径短用谁)2.1 依赖冲突会报什么错?依赖冲突通常两个错:NoClassDefFoundError 或 NoSuchMethodError,逐一讲解下导致这两种错误的原因:以上图依赖关系为例,假设 WebDemo 通过排除 service1 中低版本的依赖,从而继承 service2 中的高版本的依赖。这时,如果 WebDemo 在执行过程中调用 log4j(1.2.7) 有,但是升级到 log4j(1.2.9) 就缺失的类 log,就会导致运行期失败,出现很典型的依赖冲突时的 NoClassDefFoundError 错误。还是以上图依赖关系为例,WebDemo 通过排除 service1 中低版本的依赖,从而继承 service2 中的高版本的依赖。WebDemo 调用了原来 log4j(1.2.7) 中有的方法 log.info(),但升级到 log4j(1.2.7) 后,log.info() 不存在了,就会抛出 NoSuchMethodError 错误.所以说,当存在依赖冲突时,仅指望 maven 的两个原则来解决是不成熟的。不管是路径优先原则还是先定义先使用原则,都有可能造成以上的依赖冲突。那么如何解决它呢?三、解决依赖冲突通过上面的分析我们应该能理解到,解决依赖冲突的核心就是使冲突的依赖版本统一,而且项目不报错。我们可以通过运行 maven 命令:mvn dependency:tree 查看项目的依赖树分析依赖,看那些以来有冲突,还是以上图举例:运行命令之后,查看依赖树的 log4j 依赖就会得到错误提示:(1.2.7 omitted for conflict with 1.2.9)知道了如何查看冲突之后,就是解决冲突。1、尝试升高 service2 的版本使他依赖的 log 版本与 service1 的 log 版本一致,但它可能会导致 service2 不能工作。2、如果 service2 是个旧项目,找遍了也没找到与 service1 版本一致的 log,这时可以尝试拉低 service1 的版本使他依赖的 log 版本与 service2 的 log 版本一致,但可能会导致 service1 不能工作。你可能说了,这又不行,那又不行,怎么办呢?别急,往下看,maven 解决依赖冲突主要用两种方法:排除低版本,直接用高版本最理想的状况就是直接排除低版本,依赖高版本,一般情况下高版本会兼容低版本。如果 service2 并没有调用 log4j.1.2.9 升级所摒弃的方法或类时, 可以使用 <exclusion> 标签,排除掉 service2 中的 log。还是以上图举例:<dependency> <groupId>com.nasus</groupId> <artifactId>service2</artifactId> <version>1.0.0</version> <!-- 排除低版本 log4j --> <exclusions> <exclusion> <groupId>log4j</groupId> <artifactId>log4j</artifactId> </exclusion> </exclusions> </dependency>看着到这里,你可能又说了。如果 service2 有用到 log 升级所摒弃的方法或类;而 service1 又必须用新版本的 log,怎么办?第一,一般情况下,第三方依赖不会出现这种情况。如果出现了,那你就到 maven 中央仓库找下兼容两个版本的依赖。如果找不到,那只能换依赖。第二,如果是自己公司的 jar 出现这种情况,那就是你们的 jar 管理非常混乱。建议重新开发,兼容旧版本。四、使用 Maven Helper 插件解决依赖冲突idea plugin 中搜索 maven helper 插件安装完之后,打开 pom 文件,发现左下角有个 Depandency Analyzer 选项,点击进入选 conflicts 选项,就可以看到当前有冲突的 jar 包,在右边 exclude 掉红色冲突的版本即可。
四、maven 依赖管理maven 通过 pom.xml 来进行依赖管理,我们用它来描述项目的依赖属性。可以把它看作是 maven 项目的地图,它描述了 jar 包的坐标、版本以及依赖关系等。如果不确定你想要引入 jar 的坐标怎么写,可以上 maven 中央仓库查询:maven 中央仓库:https://mvnrepository.com/4.1 maven 坐标maven 的第三方依赖都在 <dependencies> 标签内定义,该标签下的 <dependency> 包裹的内容就是一个 jar 的坐标,如下 pom 就引入了 junit 和 cglib 两个 jar 。下面就说一下每个坐标的标签都代表什么。<dependencies> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.11</version> <scope>test</scope> </dependency> <dependency> <groupId>cglib</groupId> <artifactId>cglib</artifactId> <version>3.3.0</version> </dependency> </dependencies>dependencies在 dependencies 标签中,添加项目需要的 jar 所对应的 maven 坐标。dependency一个 dependency 标签表示一个坐标,也就是一个 jar,在 pom 中引入一个 jar 可以这样写:<dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.11</version> <scope>test</scope> </dependency>groupId公司、团体、个人开发者的唯一标识符,maven 约束它以创建这个项目的组织名称的逆向域名开头,比如开发者的域名是 nasus.com 那他的唯一标识符就是 com.nasus。<!--团体唯一标识符--> <groupId>com.nasus</groupId>artifactId项目唯一标识符,一个组织可能有多个项目,为了方便 maven 引入,maven 约定以项目名称命名该标识符,比如我开发的 maven-test 项目。<!--项目唯一标识符--> <artifactId>maven-test</artifactId>version项目的版本。一个项目,可能会有多个版本。如果是正在开发的项目,我们可以给版本号加上一个 SNAPSHOT,表示这是一个快照版本。什么是快照?对于版本,如果 maven 以前下载过指定的版本文件,比如说 maven-test:1.0,maven 将不会再从仓库下载新的可用的 1.0 文件。若要下载更新的代码,maven-test 的版本需要升到 1.1。快照是一种特殊的版本,指定了某个当前的开发进度的副本。不同于常规的版本,maven 每次构建都会在远程仓库中检查新的快照。我们自己的模块依赖了同事开发的模块,正常来说,同事会每次发布更新代码的快照到仓库中。新建项目的默认版本号就是快照版,比如上面用 maven 命令新建的 maven-test 项目:4.2 依赖范围scopemaven 项目不同的阶段引入到 classpath 中的依赖是不同的,例如,编译时,maven 会将与编译相关的依赖引入 classpath 中,测试时,maven 会将测试相关的的依赖引入到 classpath 中,运行时,maven 会将与运行相关的依赖引入 classpath 中,而依赖范围就是用来控制依赖于这三种 classpath 的关系。 如下图所示:scope 表示依赖的范围,它有 compile(编译阶段)、test(测试阶段)、provided(供应阶段)、runtime(运行阶段)、system(系统阶段)、import(导入阶段) 六个可选值。其中 compile 是默认的。system 和 import 用得少,不详细讲。不同依赖的适用范围不一样,举几个最典型的栗子:范围编译有效测试有效运行时有效打包有效示例compile√√√√spring-coretest×√××junitprovided√√××javax.servlet-apiruntime×√√√JDBC 驱动compile: 编译依赖范围。如果没有指定,就会默认使用该依赖范围。使用此依赖范围的 Maven 依赖,对于编译、供应、测试、运行四种 classpath 都有效。比如 spring-coreprovided: 已提供依赖范围。使用此依赖范围的 Maven 依赖,对于 编译和测试 classpath 有效,但在运行时无效。典型的例子是 servlet-api 编译和测试项目的时候需要该依赖,但在运行项目的时候,由于容器已经提供,就不需要 maven 重复地引入一遍:<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency>test: 单元测试依赖范围,只在测试的时候生效,所以可以设置它的 scope 为 test,这样,当项目打包发布时,单元测试的依赖就不会跟着发布。比如:<dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.11</version> <scope>test</scope> </dependency>runtime: 运行时依赖范围。对于测试和运行 classpath 有效,但在编译主代码时无效。典型的例子是 JDBC 驱动实现,项目主代码的编译只需要 JDK 提供的 JDBC 接口,只有在执行测试或者运行项目的时候才需要实现上述接口的具体 JDBC 驱动。所以,我们使用 JDBD 驱动时,可以定义成以下样例:<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.12</version> <scope>runtime</csope> </dependency>4.3 排除依赖如下 xml,原来的定义中已引入 commons-net 依赖,而 hermes-ftp 中又依赖了 commons-net,为避免版本冲突,我们可以排除 hermes-ftp 中的 commons-net 依赖。<dependency> <groupId>commons-net</groupId> <artifactId>commons-net</artifactId> <version>3.6</version> </dependency> <dependency> <groupId>com.nasus.greece.jupiter</groupId> <artifactId>hermes-ftp</artifactId> <version>1.1.0-SNAPSHOT</version> <!--排除 commons-net 依赖--> <exclusions> <exclusion> <artifactId>commons-net</artifactId> <groupId>commons-net</groupId> </exclusion> </exclusions> </dependency>4.4 依赖传递假设有如下项目关系:WebMavenDemo 项目依赖 JavaMavenService1,JavaMavenService1 项目依赖 JavaMavenService2。pom.xml 文件配置好依赖关系后,必须首先 mvn install 后,依赖的 jar 包才能使用。比如:WebMavenDemo 的 pom.xml 文件想能编译通过,JavaMavenService1 必须 mvn installJavaMavenService 的 pom.xml 文件想能编译通过,JavaMavenService2 必须 mvn install传递性:假设我们现在 JavaMavenService2 增加 spring-core ,那就会发现 WebMavenDemo 和 JavaMavenService1 也会自动的增加了这个 jar 包,这就是依赖的传递性。注意:非 compile 范围的依赖是不能传递的。来源:cnblogs.com/hzg110/p/6936101.html4.5 统一管理依赖版本在上面介绍 pom 文件时,我们讲过 properties 标签,它还有一个作用就是限定依赖的 jar 包版本,它常用在父项目中指定版本号,那么子项目用到该包就避免了版本不一致造成的依赖冲突,它的写法是这样的:五、build 配置maven 打 war 包时,可能需要一些额外的配置,请参看以下 xml 文件:<build> <!-- 项目的名字 --> <finalName>maven-test</finalName> <!-- 描述项目中资源的位置 --> <resources> <!-- 自定义资源1 --> <resource> <!-- 资源目录 --> <directory>src/main/java</directory> <!-- 包括哪些文件参与打包 --> <includes> <include>**/*.xml</include> </includes> <!-- 排除哪些文件不参与打包 --> <excludes> <exclude>**/*.txt</exclude> <exclude>**/*.doc</exclude> </excludes> </resource> </resources> <!-- 设置构建时候的插件 --> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>2.1</version> <configuration> <!-- 源代码编译版本 --> <source>1.8</source> <!-- 目标平台编译版本 --> <target>1.8</target> </configuration> </plugin> <!-- 资源插件(资源的插件) --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-resources-plugin</artifactId> <version>2.1</version> <executions> <execution> <phase>compile</phase> </execution> </executions> <configuration> <encoding>UTF-8</encoding> </configuration> </plugin> <!-- war插件(将项目打成war包) --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-war-plugin</artifactId> <version>2.1</version> <configuration> <!-- war包名字 --> <warName>maven-test</warName> </configuration> </plugin> </plugins> </build>六、使用 idea 搭建 maven 聚合工程这个网上很多资料,不讲了。留个链接。idea 搭建 maven 聚合工程参考:https://www.cnblogs.com/limn/p/9363416.html
一、什么是 Maven?Maven 是一个项目管理工具,它的本质是一个项目对象模型 (POM),体现在配置中就是我们常见的 pom.xml 文件,而这个 pom 文件就是 Maven 的核心,它管理了整个项目的生命周期。它主要做两件事:项目构建:对项目进行编译、打包、测试、部署以及上传到私服仓库等依赖管理:Maven 诞生时就提出了一个仓库的概念,项目中用到的第三方 jar 包,我们在 pom.xml 中配置好依赖即可,Maven 会自动到它的官方中央仓库下载这个 jar 包到我们的本地仓库。中央仓库地址:https://mvnrepository.com/二、为什么要使用 Maven?方便依赖管理:Java 发展至今,生态非常完善。我们在项目中用到什么功能,网上一搜肯定有对应的 jar 包,各种功能就导致了各种 jar 包的引入,这些 jar 包之间可能会有依赖,可能会有版本冲突。而 Maven 的诞生解决了这些问题。构建多模块项目:现在很多项目都是分了多个模块,便于开发、也便于扩展。多模块就意味着模块之间会有各种依赖,我们运行某个模块,可能这个模块依赖了别的模块。而 Maven 的一键构建项目帮我们解决了这个问题。方便移植:以前没 maven 的时代,团队协作要上传、下载一大堆 jar 包导入项目,耗时、费力。而有了 maven ,我们只需要同步一下 pom 文件即可同步 jar 包。这是 maven 解决的第三个问题。三、怎么使用 Maven?3.1 Maven 的安装这个就不讲了,网上很多资料。比如:https://www.cnblogs.com/KyleXu/p/9972042.html3.2 Maven 的配置Maven 的配置比较简单,主要是修改 conf 文件夹下的 setting 文件。配置以下三个仓库:本地仓库项目依赖的 jar 包是需要下载到本地才能用的。本地仓库就是从 maven 私服或者远程仓库下载的 jar 的存储地址,默认是 当前用户名\.m2\repository ,我建议改个好记的地方,后面方便检查包有没下载到本地。打开 setting.xml 搜索 localRepository 修改成自定义的地址。<localRepository>D:\Repository</localRepository>配置的位置,如下图:私服仓库这个仓库的话,一般就是公司内部使用的啦。用来存储公司内部自己的 jar 包。打开 setting.xml 文件搜索 mirrors ,配置公司的镜像地址即可。<mirror> <id>nexus-repos</id> <mirrorOf>*</mirrorOf> <name>Team Nexus Repository</name> <url>http://127.0.0.1:8081/nexus/content/groups/public</url> </mirror>远程仓库远程仓库就是一个 maven 官方维护的,包含大量 jar 包的仓库。这个库默认是 maven 官方的,但是下载非常慢。所以业界典范阿里巴巴也推出了一个国内的镜像,我们一般把远程仓库配成阿里的镜像地址,就可以快速地下载 jar 包啦。和私服仓库一样,远程仓库也是配置在 <mirrors> 标签内。<mirror> <id>alimaven</id> <name>aliyun maven</name> <url>http://maven.aliyun.com/nexus/content/groups/public/</url> <mirrorOf>central</mirrorOf> </mirror>配置的位置,如下图:有人可能问了,配置那么多个仓库。究竟 jar 从哪个下载的呀?都把我搞糊涂了,别急,我花了个流程图,它的查找顺序是这样的:本地不需要网络,优先从本地找;找不到,再去速度较高的内网私服找;然后才是速度稍低的外网远程仓库找。3.3 Maven 的命令常用命令命令含义备注mvn clean清除打包前,清空上一次的包mvn compile编译将 java 代码编译成 class 文件mvn test测试运行单元测试mvn install安装到本地安装到本地仓库,一般是 jar 包mvn package打包一般会在 target 目录下生成包,jar 或 warmvn deploy上传上传到私服,需在 setting.xml 文件配置私服仓库以及账号密码以上就是 maven 常用的命令,要注意的是:很少情况下我们只运行其中一个命令,都是组合运行的。比如打包到本地,打包前得清空原有的包吧?那组合起来就是 mvn clean + mvn install当然,在 IDEA 中开发 maven 项目,我们并不需要手打。只需点击对应命令即可(也可以按住 ctrl 选中多个命令一起运行)总而言之,根据自己的需求来选择打包命令。还有其他的命令请见:maven 详细命令参考:https://www.jianshu.com/p/ee7bc34d7ccc创建 maven 项目现在一般都是配合 idea 新建 maven 项目了,这个命令用得很少,但我们还是得知道一下:生成 maven 项目的原理是,依赖一个插件 maven-archetype-plugin,然后这个插件自带一些 archetype 模版,也可以说成项目的骨架。其中:-DgroupId 和 -DartifactId 填写自己想好的项目坐标,一般 -DgroupId 是公司名的翻转,比如 com.google 而 -DartifactId 就是项目的名称了。最重要的是 -DarchetypeArtifactId,他指定了创建的骨架。mvn archetype:generate -DgroupId=com.nasus -DartifactId=maven-test -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false点进去,只有 src 文件夹和 pom.xml 文件:src 是最重要的目录,代码和测试用例以及资源都是放在这里的,对于 maven 项目而言,pom.xml 也是必不可少的。用 idea 打开的项目结构是这样的:pom.xml 的内容:<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> <!--声明项目描述符遵循哪一个 POM 模型版本,上面的 xsd 规范定义了这个版本,默认就行,不需要修改,不可删除--> <modelVersion>4.0.0</modelVersion> <!--团体唯一标识符--> <groupId>com.nasus</groupId> <!--项目唯一标识符定位这个包--> <artifactId>maven-test</artifactId> <!--打包类型--> <packaging>jar</packaging> <!--打包版本--> <version>1.0-SNAPSHOT</version> <!--包名--> <name>maven-test</name> <!--不用管,删掉也行--> <url>http://maven.apache.org</url> <!--项目需要依赖的 jar 包--> <dependencies> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>3.8.1</version> <scope>test</scope> </dependency> </dependencies> </project>由于篇幅原因,这里就不详细讲具体的 pom.xml 标签了,详细介绍请看:pom 标签介绍参考:https://www.runoob.com/maven/maven-pom.html项目打包到本地仓库由于项目是 java 项目,在打包前,我们要在 pom.xml 中配置项目的 JDK 版本以及 maven 插件版本,在 <dependencies> 标签前加入项目属性配置,完整配置如下:<!--项目属性,在 <dependencies> 前加--> <properties> <!-- JDK编译版本 --> <java.version>1.8</java.version> <!-- 项目编码 --> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <!-- JDK编译版本 --> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <maven.compiler.compilerVersion>1.8</maven.compiler.compilerVersion> </properties> <!--项目需要依赖的 jar 包--> <dependencies> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>3.8.1</version> <scope>test</scope> </dependency> </dependencies>选择命令,这里我选了 clean、compile、package:点击绿色执行按钮,在 target 目录下生成 maven-test-1.0.SNAPSHOT.jar:跳过单元测试在开发中,我们经常需要本地测试,而这时我们是不需要跑单元测试的。所以,我们可以跳过单元测试:选中 test,点击红框按钮即可。手动打 jar 包到本地仓库手动打 jar 包的应用场景是:开发公司旧项目,当找不到依赖的 jar 源码,依赖的 jar 又没有上传到仓库,只有在同事电脑的本地仓库有一个 jar 包时,我们可以直接运行这条命令把 jar 包打到我们电脑本地仓库,愉快的使用起来。mvn install:install-file -Dfile=jar包的路径 -DgroupId=gruopId中的内容 -Dartifact
一、什么是 IO 流?想象一个场景:我们在电脑上编辑文件,可以保存到硬盘上,也可以拷贝到 U 盘中。那这个看似简单的过程,背后其实是数据的传输。数据的传输,也就是数据的流动。既然是流动也就会有方向,有入方向和出方向。举个上传文件的栗子,现在有三个对象,文件、应用程序、上传的目标地址(服务器)。简化的上传文件有两步:应用程序读文件(此为入方向,文件读入到应用程序)应用程序写文件(此为出方向,读完之后写到目标地址)注意这个入和出是相对的,它相对于应用程序而言。如果相对于服务器而言,这个上传文件操作就是入方向,从应用程序读入。Java 中 I/O 操作主要是指使用 java.io 包下的内容,进行输入、输出操作。输入也叫做读取数据,输出也叫做作写出数据。二、IO 流的分类我不认同网络上很多 IO 流的图,他们只是简单的把 io 流分成字节流和字符流。这样的分类也不是说不好,只是太臃肿、难记。先上一张我自己总结的 IO 留的思维导图,我先把它分成了节点流和处理流,节点流是直接接触数据源的,而处理流是出于各种目的在节点流的基础上再套一层的 IO 流。再按照操作类型,分成 8 个小类,然后才是字节、字符分类,最后才是输入、输出的分类。具体可以看以下思维导图 (可能不清晰,有需要的在后台回复 IO 流获取原思维导图)根据数据的流向分为:输入流和输出流。输入流 :把数据从其他设备上读取到内存中的流。输出流 :把数据从内存 中写出到其他设备上的流。根据数据的类型分为:字节流和字符流。字节流 :以字节为单位,读写数据的流。字符流 :以字符为单位,读写数据的流。IO 流要说明白需要好几篇才行,今天我们先复习文件流。2.1 一切皆字节所有的文件(包括图片、音乐、视频),都是字节。所以字节流可以传输任意文件数据。在操作流的时时,无论使用什么样的流对象,底层传输的始终为二进制数据。2.2 什么叫文件流?文件流也就是直接操作文件的流,文件流又分为字节流 (FileInputStream 和 FileOutputStream)和字符流(FileReader 和 FileWriter)。其中字节流可用于操作一切文件,而字符流只能用于操作文本文件。三、使用文件字节流3.1 FileOutputStreamjava.io.FileOutputStream 类继承于 OutputStream 是文件输出流,用于将数据写出到文件。构造方法:可用文件路径构造,也可创建 File 对象之后构造。写出数据示例:/** * Project Name:review_java <br/> * Package Name:com.nasus.io.filestream <br/> * Date:2020/1/5 19:24 <br/> * * @author <a href="turodog@foxmail.com">chenzy</a><br/> */ publicclass FOSWriterStream { public static void main(String[] args) throws IOException { // 使用文件名称创建流对象,构造函数中的 true 表示在原有数据末尾追加续写 FileOutputStream fos = new FileOutputStream("fos.txt", true); // 1、逐个字节写出 fos.write(97); // 97 的 ascll 码是 a fos.write(98); // 98 的 ascll 码是 b fos.write(99); // 99 的 ascll 码是 c // 2、写出一个换行, 换行符号转成数组写出 fos.write("\r\n".getBytes()); // 字符串转换为字节数组 byte[] b = "一个优秀的废人".getBytes(); // 3、写出字节数组数据 fos.write(b); // 4、写出指定长度字节数组数据(不可超过 b 的长度,否则数组越界) fos.write(b, 0, b.length); // 关闭资源 fos.close(); } }3.2 FileInputStreamjava.io.FileInputStream 类继承于 InputStream 是文件输入流,用于将数据从文件读出。构造方法:可用文件路径构造,也可创建 File 对象之后构造。读取数据示例:/** * Project Name:review_java <br/> * Package Name:com.nasus.io.filestream <br/> * Date:2020/1/5 19:31 <br/> * * @author <a href="turodog@foxmail.com">chenzy</a><br/> */ publicclass FISReadStream { public static void main(String[] args) throws IOException { // 1、逐个读取字节 int b; FileInputStream fis1 = new FileInputStream("fis.txt"); // 循环读取 while ((b = fis1.read())!=-1) { System.out.println((char)b); } // 关闭资源 fis1.close(); System.out.println("----华丽丽的分割线----"); // 2、定义字节数组读取 int length; FileInputStream fis2 = new FileInputStream("fis.txt"); // 定义字节数组,作为装字节数据的容器 byte[] bytes = newbyte[1024]; // 循环读取 while ((length = fis2.read(bytes))!=-1) { // 每次读取后,把数组变成字符串打印 System.out.println(new String(bytes, 0, length)); } // 关闭资源 fis2.close(); } }复制文件示例:/** * Project Name:review_java <br/> * Package Name:com.nasus.io.filestream <br/> * Date:2020/1/5 19:43 <br/> * * @author <a href="turodog@foxmail.com">chenzy</a><br/> */ publicclass FileCopyStream { public static void main(String[] args) throws IOException { // 指定数据源 FileInputStream fis = new FileInputStream("Java IO 流.png"); // 指定目的地 FileOutputStream fos = new FileOutputStream("流.png"); // 定义数组 byte[] b = newbyte[1024]; // 定义长度 int len; // 循环读取 while ((len = fis.read(b))!=-1) { // 写出数据 fos.write(b, 0 , len); } // 关闭资源,后开先关,后开先关 fos.close(); fis.close(); } }3.3 为什么字节流处理中文字符时会出现乱码?首先明确一点:一个英文字母占一个字节,一个汉字占两个字节,所以当字节流读取字符流就会出现乱码或者显示不全。所以用字节流操作含有中文字符的文件时,要转换成字符流并指定编码格式才能防止乱码。(这点,后面转换流会复习到)四、使用文件字符流当使用字节流读取文本文件时,可能会有一个小问题。就是遇到中文字符时,可能不会显示完整的字符,那是因为一个中文字符可能占用多个字节存储。所以 Java 提供一些字符流类,以字符为单位读写数据,专门用于处理文本文件。4.1 FileReaderjava.io.FileReader 类继承于 Reader 类,是读取字符文件的便利类。构造时使用系统默认的字符编码和默认字节缓冲区。构造方法:可用文件路径构造,也可创建 File 对象之后构造。字符编码:字节与字符的对应规则。Windows 系统的中文编码默认是 GBK 编码表字节缓冲区:一个字节数组,用来临时存储字节数据。PS:有时候出现乱码,多考虑下是不是编码的原因:字节与字符的规则对不上。读取数据示例:/** * Project Name:review_java <br/> * Package Name:com.nasus.io.filereadwrite <br/> * Date:2020/1/5 20:19 <br/> * * @author <a href="turodog@foxmail.com">chenzy</a><br/> */ publicclass FileRead { public static void main(String[] args) throws IOException { // 1、逐个字符读取 int b = 0; FileReader fileReader1 = new FileReader("read.txt"); // 循环读取 while ((b = fileReader1.read())!=-1) { // 自动提升类型提升为 int 类型,所以用 char 强转 System.out.println((char)b); } // 关闭流 fileReader1.close(); System.out.println("----华丽丽的分割线----"); // 2、利用字符数组,每次读取两个字符 int length = 0; FileReader fileReader2 = new FileReader("read.txt"); char[] charArray = newchar[2]; // 读取数据 while ((length = fileReader2.read(charArray)) != -1) { System.out.println(new String(charArray, 0, length)); } // 关闭流 fileReader2.close(); } }4.2 FileWriterjava.io.FileWriter 类是写出字符到文件的便利类。构造时使用系统默认的字符编码和默认字节缓冲区。构造方法:可用文件路径构造,也可创建 File 对象之后构造。写出数据示例:public static void main(String[] args) throws IOException { // 使用文件名称创建流对象,true 表示在原有数据末尾追加续写 FileWriter fileWriter = new FileWriter("fw.txt", true); // 1、逐个写出字符 fileWriter.write(97); fileWriter.write('C'); fileWriter.write('Z'); fileWriter.write('Y'); // 中文编码表中30000对应一个汉字。 fileWriter.write(30000); // 2、写出字符串 fileWriter.write("是一个"); // 3、写出 Windows 换行 fileWriter.write("\r\n"); // 4、写出字符串数组 // 字符串转换为字节数组 char[] chars = "优秀的废人".toCharArray(); fileWriter.write(chars, 0, chars.length); // 关闭资源,close方法调用之前,数据只是保存到了缓冲区,并未写出到文件中。 fileWriter.close(); }刷新与关闭:因为内置缓冲区的原因,如果不关闭输出流,无法写出字符到文件中。但是关闭的流对象,是无法继续写出数据的。如果我们既想写出数据,又想继续使用流,就需要 flush 方法了。flush :刷新缓冲区,流对象可以继续使用。close: 先刷新缓冲区,然后通知系统释放资源。流对象不可以再被使用了。/** * Project Name:review_java <br/> * Package Name:com.nasus.io.filereadwrite <br/> * Date:2020/1/5 22:25 <br/> * * @author <a href="turodog@foxmail.com">chenzy</a><br/> */ publicclass FileFlushClose { public static void main(String[] args) throws IOException { // 使用文件名称创建流对象 FileWriter fw = new FileWriter("fw.txt"); // 1、通过 flush 写出数据 // 写出第 1 个字符 fw.write('刷'); fw.flush(); // 继续写出第 2 个字符,写出成功 fw.write('新'); fw.flush(); // 2、通过 close 写出数据,流关闭后不可用 // 写出第 1 个字符 fw.write('关'); fw.close(); // 继续写出第 2 个字符,【报错】java.io.IOException: Stream closed fw.write('闭'); fw.close(); } }五、源码地址github 源码地址:https://github.com/turoDog/review_java
Github 是全球最大的开源社区,大到啥程度?国内的 BAT,国外的 Google、Apple、FaceBook 等一线互联网公司都有在上面开源自己的项目造福全人类。在这里你可以免费观摩这些顶级程序员的代码、学习他们的设计思想。因为在上面开源项目的大多都是男性,它同时也被戏称为全球最大同性交友网站。如果你还没有 Github 账号,建议赶紧注册一个,记录自己的学习历程,维护得好的 Github 还有可能成为你的简历亮点。对了,自从被微软收购之后。github 现在还可以建私人仓库啦。废话不多说,今天逛 Github 发现了两个可以生成你 Github 年度报告的项目,它可以知道你 2019 年的 Github 数据,包括但不仅限于 提交的代码次数,新增、修改的代码行数等。第一个地址:https://github.com/guanpengchn/github-annual-report我平时写文章,学习新知识、复习旧知识,都习惯提交到 Github,所以记录还行(就是没啥 Star)。用自己的账号试了下,它的效果是这样的:效果中规中矩,还有其他的就不一一列举了,另外,我还是很勤奋的吧。哈哈哈。另外,顺带提一下,这个项目用的是 Oauth 协议接入 Github 接口,有兴趣的不妨学习下源码。第二个地址:https://github.com/tipsy/profile-summary-for-github这个生成的报告数据粒度更大,也更加直观一点了,它输出了你创建 github 以来每一季度提交代码的次数、使用的语言占比、每个仓库的 star 数以及每个仓库的提交次数。效果是这样:最后写这篇文章的主要目的有两个,一个是告诉一些人有 Github 这么个网站,如果你对业务需求毫无实现头绪,不妨上来看看。二是希望你们好好维护自己的 Github,它能成为你的简历亮点。点击阅读原文,即可生成第二个项目的直观报告,想生成第一个项目的年度报告,请自行复制粘贴原地址,授权访问即可。PS:看到这篇文章的小可爱们,祝你们 2020 牛逼,单身的脱单,有对象的偕老。有工作的年终丰厚,学生们永不挂科。
Jrebel 是什么?JRebel 是一款 JAVA 虚拟机插件,它使得 JAVA 程序员能在不进行重部署的情况下,即时看到代码的改变对一个应用程序带来的影响。JRebel 使你能即时分别看到代码、类和资源的变化,你可以一个个地上传而不是一次性全部部署。当程序员在开发环境中对任何一个类或者资源作出修改的时候,这个变化会直接反应在部署好的应用程序上,从而跳过了构建和部署的过程。简而言之,不管你修改了类还是资源,只需要重新 Build 一下相关的类,改动就直接反映到你的应用程序了。Jrebel 安装打开你的 IntelliJ IDEA 插件市场,搜索 Jrebel ,第一个就是。点击 install 安装,完了之后重启 IDEA。Jrebel 激活重启完之后会弹出如下框提示激活,选中 Team Url ,其中邮箱随便填就行,认证服务地址的格式是 https://jrebel.qekang.com/{GUID} 它需要一个 guid 参数,这个参数需要从 guid 服务器生成。它的地址是 https://www.guidgen.com/,直接打开生成一个 guid ( 不要用下图这个,可能失效),如下图:复制 guid 填充到认证服务地址后面。比如:https://jrebel.qekang.com/7bea5149-69a5-4270-8190-3f049dc8d2d6,填到下图的认证服务地址栏。点击 change license ,激活成功。Jrebel 使用点击 IDEA 左侧边栏边的 Jrebel 选项,配置需要热部署的模块,如下图,直接打上勾就可以。一切准备就绪之后,你会发现工具栏多了如下图的这两个图标:一个是 Jrebel run 模式启动项目,一个是 Jrebel debug 模式启动(一些需要测试的模块,一般使用这个模式),现以 debug 模式启动 xxxx_collect 模块。启动成功。假如,我现在对应用程序的效果不满意,又修改了刚刚勾选的 xxxx_collect 模块下的名为 xxxxFeignClientApi 的 java 类,如下图所示:这是不需要重新启动,只需要重新 build 一下相关类即可,如果改动多的话,直接 build 模块就行。以上就是 Jrebel 的使用教程,贼方便。Jrebel 每年可以省去部署用的时间花费高达 5.25 个星期(Jrebel 官方说的)。
一、配置服务器如下图,点击用户中心如下图,我的已使用过,你们还未使用的提货券,在操作那一列点击使用。选择配置,地域选离你最近的地方,我选的深圳,系统选 centos (搞 java 一般是这个)、64 位、版本 7.7 。完事后立即开通。回到控制台就会看到你的在运行实例了,这就是你买的阿里云服务器。二、关于登录关于登录使用,这里说一下,官方的远程登录使用非常不方便。我习惯于用 xshell 配置公钥,绑定实例登录使用。也推荐使用 xshell2.1 生成用户密钥如下图,点击新建用户密钥生成向导,下一步,输入密码,记住这个密码。一直点下一步,生成了公钥,手动复制公钥之后,保存文件到你的电脑备用(选一个靠谱的路径存放,并记住,别弄得自己电脑目录乱七八糟的),最后点完成(这一步非常重要,记着点)。之后退出这个弹窗。2.2 绑定阿里云服务器如下图,进到控制台,点击密钥对,创建密钥,输入密钥对名称(随便填),在黑框粘贴刚刚你复制的密钥,点确定。之后,如下图操作就行,点击绑定密钥对,选择你的实例,确定。之后,重启你的服务器。2.3 使用 Xshell 登录输入你的阿里云服务器公网 ip ,端口默认 22填写用户名,一般是 root ,点击浏览选择刚刚保存的密钥。填入密码,确定。最后登录成功。详细教程(必看):https://blog.csdn.net/longgeaisisi/article/details/78680180三、安装 Java 三件套什么是 java 三件套?相信老手都懂。就是传说中的 JDK、Mysql 以及 Tomcat。版本分别选了 1.8、5.6 和 8.5 都是目前最主流的版本。别跟我说 java13 出了,我特么学不动,不学了。另外,我这里安装三件套的方式全部采用 tar 方式。3.1 建目录在 root 下新建 soft 文件夹用于存放在本地传送过来的文件mkdir soft // mkdir 新建目录 cd soft // cd 进入目录在 usr 下新建 java 目录,待会把 JDK 安装到这里(没有为什么安装到这里,随你喜欢)。[root@ChenzyDeAliyun soft]# pwd // 显示当前目录路径 /root/soft [root@ChenzyDeAliyun soft]# cd ../../usr // 进到 usr 目录 [root@ChenzyDeAliyun usr]# mkdir java // 新建 java 目录3.2 下载安装包下载 JDK8 如下图,选 linux 64 位版本下载 tomcat下载 mysql3.3 传输文件首先 cd 到 soft 目录,然后像下图这样,点击传输新建文件选择文件,这里以传输 JDK 为例(传输其他文件都一样),把 JDK8 安装包传输到 /root/soft 目录下,如下图。3.4 安装 JDK改变 JDK8 文件权限(777 可读可写权限,不懂的,建议学下 linux ),并从 soft 文件夹复制 JDK8 到 /usr/java 文件夹,[root@ChenzyDeAliyun soft]# chmod 777 jdk-8u231-linux-x64.tar.gz [root@ChenzyDeAliyun soft]# cp jdk-8u231-linux-x64.tar.gz ../../usr/java此时 JDK 已复制到 /usr/java 文件夹,cd 到 /usr/java 文件夹,安装 JDK// 使用 tar -zxvf 解压 jdk [root@ChenzyDeAliyun java]# tar -zxvf jdk-8u231-linux-x64.tar.gz // 编辑配置文件 [root@ChenzyDeAliyun java]# vi /etc/profile // 按键盘字母 “i” ,进入编辑模式之后,将以下内容复制到,文件最尾部,ctrl + c 然后输入 :wq 保存,退出。 #java export JAVA_HOME=/usr/java/jdk1.8.0_231 (注意这里的路径选自己的安装目录) export PATH=$JAVA_HOME/bin:$PATH export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib // 刷新配置文件 [root@ChenzyDeAliyun java]# source /ect/profile // 检查安装情况,打印版本证明安装成功 [root@ChenzyDeAliyun java]# java -version java version "1.8.0_231" Java(TM) SE Runtime Environment (build 1.8.0_231-b11) Java HotSpot(TM) 64-Bit Server VM (build 25.231-b11, mixed mode)3.5 安装 mysql重复安装 JDK 的步骤,改变 mysql 文件权限,复制到 usr 目录,这里的命令不赘述。安装所需环境[root@ChenzyDeAliyun ~]# yum -y install make bison-devel ncures-devel libaio [root@ChenzyDeAliyun ~]# yum -y install libaio libaio-devel [root@ChenzyDeAliyun ~]# yum -y install perl-Data-Dumper [root@ChenzyDeAliyun ~]# yum -y install net-tools [root@ChenzyDeAliyun ~]# yum install bison [root@ChenzyDeAliyun ~]# yum install cmake [root@ChenzyDeAliyun ~]# yum -y install gcc gcc-c++ autoconf automake zlib* libxml* ncurses-devel libmcrypt* libtool* cmake解压安装包,进入相应目录(我安装到 /usr 目录)[root@ChenzyDeAliyun usr]# tar -zxvf mysql-5.6.46.tar.gz [root@ChenzyDeAliyun usr]# cd mysql-5.6.46 # 安装必要的配置 [root@ChenzyDeAliyun mysql-5.6.46]# yum install openssl-devel编译安装 (以下操作需进入 mysql-5.6.46 目录)[root@ChenzyDeAliyun mysql-5.6.46]# cmake \-DCMAKE_INSTALL_PREFIX=/usr/local/mysql -DMYSQL_DATADIR=/usr/local/mysql/data -DSYSCONFDIR=/etc/my.cnf -DWITH_MYISAM_STORAGE_ENGINE=1 -DWITH_INNOBASE_STORAGE_ENGINE=1 -DWITH_MEMORY_STORAGE_ENGINE=1 -DWITH_READLINE=1 -DMYSQL_UNIX_ADDR=/tmp/mysqld.sock -DMYSQL_TCP_PORT=3306 -DENABLED_LOCAL_INFILE=1 -DWITH_PARTITION_STORAGE_ENGINE=1 -DEXTRA_CHARSETS=all -DDEFAULT_CHARSET=utf8 -DDEFAULT_COLLATION=utf8_general_ci配置 mysql# 检查系统是否已经有mysql用户,如果没有则创建 [root@ChenzyDeAliyun mysql-5.6.46]# cat /etc/passwd | grep mysql [root@ChenzyDeAliyun mysql-5.6.46]# cat /etc/group | grep mysql # 创建mysql用户(但是不能使用mysql账号登陆系统) [root@ChenzyDeAliyun mysql-5.6.46]# groupadd mysql -s /sbin/nologin [root@ChenzyDeAliyun mysql-5.6.46]# useradd -g mysql mysql修改权限[root@ChenzyDeAliyun mysql-5.6.46]# chown -R mysql:mysql /usr/local/mysql设置权限切换到 mysql 目录 [root@ChenzyDeAliyun mysql-5.6.46]# cd /usr/local/mysql 这里最后是有个.的大家要注意# 为了安全安装完成后请修改权限给root用户 [root@ChenzyDeAliyun mysql]# chown -R mysql:mysql . 先进行这一步再做如下权限的修改 [root@ChenzyDeAliyun mysql]# scripts/mysql_install_db --user=mysql 将权限设置给root用户,并设置给mysql组, 取消其他用户的读写执行权限,仅留给mysql "rx"读执行权限,其他用户无任何权限 [root@ChenzyDeAliyun mysql]# chown -R root:mysql . 数据库存放目录设置成mysql用户mysql组 [root@ChenzyDeAliyun mysql]# chown -R mysql:mysql ./data 赋予读写执行权限,其他用户权限一律删除仅给mysql用户权限 [root@ChenzyDeAliyun mysql]# chmod -R ug+rwx .将 mysql 的配置文件拷贝到 /etc[root@ChenzyDeAliyun mysql]# cp support-files/my-default.cnf /etc/my.cnf修改 my.cnf 配置[root@ChenzyDeAliyun mysql]# vi /etc/my.cnf添加以下内容[mysql] # 设置mysql客户端默认字符集 default-character-set=utf8 [mysqld] skip-name-resolve #设置3306端口 port = 3306 # 设置mysql的安装目录 basedir=/usr/local/mysql # 设置mysql数据库的数据的存放目录 datadir=/usr/local/mysql/data # 允许最大连接数 max_connections=200 # 服务端使用的字符集默认为8比特编码的latin1字符集 character-set-server=utf8 # 创建新表时将使用的默认存储引擎 default-storage-engine=INNODB lower_case_table_names=1 max_allowed_packet=16M启停 mysql将mysql的启动服务添加到系统服务中 [root@ChenzyDeAliyun mysql]# cp support-files/mysql.server /etc/init.d/mysql 现在可以使用下面的命令启动mysql [root@ChenzyDeAliyun mysql] # service mysql start 停止mysql服务 [root@ChenzyDeAliyun mysql]# service mysql stop 重启mysql服务 [root@ChenzyDeAliyun mysql]# service mysql restart修改 root 用户密码[root@ChenzyDeAliyun mysql]# chkconfig --add mysql 修改密码 cd 切换到 mysql 所在目录 cd /usr/local/mysql 最后设置新的密码即可! [root@ChenzyDeAliyun mysql]# ./bin/mysqladmin -u root password重启 mysql[root@ChenzyDeAliyun mysql]# service mysql restart 输入密码,进入客户端 [root@ChenzyDeAliyun mysql]# cd /usr/local/mysql/bin/ [root@ChenzyDeAliyun bin]# ./mysql -u root -pOver!详细教程https://blog.csdn.net/wplblog/article/details/521792993.6 安装 tomcat重复安装 JDK 的步骤,改变 mysql 文件权限,复制到 usr 目录,这里的命令不赘述。tomcat 的安装启动很简单。解压 [root@ChenzyDeAliyun usr]# tar -zxvf apache-tomcat-8.5.50.tar.gz 进入启动脚本所在目录 [root@ChenzyDeAliyun usr]# cd apache-tomcat-8.5.50 执行脚本启动 [root@ChenzyDeAliyun bin]# ./startup.sh启动成功,默认端口 8080 ,需要修改请自行百度,累死我了(已经写 3 小时了)Using CATALINA_BASE: /usr/apache-tomcat-8.5.50 Using CATALINA_HOME: /usr/apache-tomcat-8.5.50 Using CATALINA_TMPDIR: /usr/apache-tomcat-8.5.50/temp Using JRE_HOME: /usr/java/jdk1.8.0_231 Using CLASSPATH: /usr/apache-tomcat-8.5.50/bin/bootstrap.jar:/usr/apache-tomcat-8.5.50/bin/tomcat-juli.jar Tomcat started.四、连接 Mysql 以及访问 Tomcat做到这里,如果你以为完事了,那只能说你真是 too young too naive 了。云服务器有安全机制,不是所有的端口都能随便访问,我们安装完 mysql 、tomcat 之后想访问,就必须要上云开网络安全组。为啥阿里要搞得这么麻烦?道理很简单,就是你家的门也不能随便让人想进就进的吧?那我们知道在上面的安装中,Mysql 我们用的 3306 端口,tomcat 用的 8080 端口。所以我们要上云服务器,把这两个端口开起来,才能访问。进入网络安全组配置 3306 和 8080 端口配置完成看到这里有人肯定会问了,为啥是入方向?这个方向是相对于服务器来说的,很容易理解,比如,我们从外面(比如我本地电脑)访问阿里云,那对阿里云来说就是有人要进来我家了,在比如,某一天我们需要从阿里云访问别人的服务器。比如,访问另一台服务器的 8080 端口,那对于我的服务器来说,我就要放通自己的出方向 8080 端口。对于别人服务器来说,就要放通入方向 8080 端口。配置完成,tomcat 能访问了。使用 navicat 连接 mysql。以上还不能连接 mysql ,还需要最后一步,配置远程连接。cd 到 bin 目录 [root@ChenzyDeAliyun bin]# cd /usr/local/mysql/bin/ 输入密码 [root@ChenzyDeAliyun bin]# ./mysql -u root -p 进入 mysql 客户端执行以下语句,注意最后的 ; 不能漏 mysql> grant all privileges on *.* to '你的mysql用户名'@'%' identified by '你的mysql密码' with grant option; Query OK, 0 rows affected (0.00 sec)配置完成,连接成功参考链接https://blog.csdn.net/qq_29058883/article/details/84372663以上教程用到的 Xshell 和 Navicat 工具关注公众号:「一个优秀的废人」回复 「阿里云」直接获取。五、谈谈应届生的项目经验之前很多在校的学生都面临一个问题,面试没项目咋办?好方。你去面试肯定要有自己的亮点的吧?没项目就搭建一个个人博客呀,不会?网上一堆教程,照着玩我就不信不会了。再不济,增删改查也要用得溜呀。连增删改查都不溜,面试官干嘛要招你?恰好阿里云双 12 搞活动,新用户购买服务器 89 元 / 年、229 元 / 3 年。买个用来搭建项目(比如个人博客)准备面试、熟悉技术栈、学习 Linux 都可以。不是新用户也没关系,借用家人朋友身份证重新注册新用户(我用了我妹妹的身份证。注意,如果以前注册过阿里云,这次还想享受优惠,请用不一样的手机号,这很重要)活动持续到 12 月 31 日,过了就没了。有需要的复制下面的链接注册购买就是最低价。
1、典型的 Spring 生命周期在传统的 Java 应用中,bean 的生命周期很简单,使用 Java 关键字 new 进行Bean 的实例化,然后该 Bean 就能够使用了。一旦 bean 不再被使用,则由 Java 自动进行垃圾回收,简直不要太简单。相比之下,Spring 管理 Bean 的生命周期就复杂多了,正确理解 Bean 的生命周期非常重要,因为 Spring 对 Bean 的管理可扩展性非常强,下面展示了一个 Bea 的构造过程。以上图片出自 《Spring 实战(第四版)》一书,图片描述了一个经典的 Spring Bean 的生命周期,书中随他的解释如下:1.Spring对bean进行实例化;2.Spring将值和bean的引用注入到bean对应的属性中;3.如果bean实现了BeanNameAware接口,Spring将bean的ID传递给setBean-Name()方法;4.如果bean实现了BeanFactoryAware接口,Spring将调用setBeanFactory()方法,将BeanFactory容器实例传入;5.如果bean实现了ApplicationContextAware接口,Spring将调用setApplicationContext()方法,将bean所在的应用上下文的引用传入进来;6.如果bean实现了BeanPostProcessor接口,Spring将调用它们的post-ProcessBeforeInitialization()方法;7.如果bean实现了InitializingBean接口,Spring将调用它们的after-PropertiesSet()方法。类似地,如果bean使用init-method声明了初始化方法,该方法也会被调用;8.如果bean实现了BeanPostProcessor接口,Spring将调用它们的post-ProcessAfterInitialization()方法;9.此时,bean已经准备就绪,可以被应用程序使用了,它们将一直驻留在应用上下文中,直到该应用上下文被销毁;10.如果bean实现了DisposableBean接口,Spring将调用它的destroy()接口方法。同样,如果bean使用destroy-method声明了销毁方法,该方法也会被调用。2、验证 Spring Bean 周期写了下代码验证以上说法,首先创建一个 Person 类,它就是我们要验证的 Bean ,为方便测试,他实现了 BeanNameAware, BeanFactoryAware,ApplicationContextAware, InitializingBean, DisposableBean。代码如下:package com.nasus.bean; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; import org.springframework.beans.factory.BeanNameAware; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.context.annotation.Scope; /** * Project Name:review_spring <br/> * Package Name:PACKAGE_NAME <br/> * Date:2019/9/1 16:29 <br/> * * @author <a href="turodog@foxmail.com">chenzy</a><br/> */ @Scope("ProtoType") public class Person implements BeanNameAware, BeanFactoryAware, ApplicationContextAware, InitializingBean, DisposableBean { private static final Logger LOGGER = LoggerFactory.getLogger(Person.class); private String name; public Person(){ System.out.println("1、开始实例化 person "); } public String getName() { return name; } public void setName(String name) { this.name = name; System.out.println("2、设置 name 属性"); } @Override public void setBeanName(String beanId) { System.out.println("3、Person 实现了 BeanNameAware 接口,Spring 将 Person 的 " + "ID=" + beanId + "传递给 setBeanName 方法"); } @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { System.out.println("4、Person 实现了 BeanFactoryAware 接口,Spring 调" + "用 setBeanFactory()方法,将 BeanFactory 容器实例传入"); } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { System.out.println("5、Person 实现了 ApplicationContextAware 接口,Spring 调" + "用 setApplicationContext()方法,将 person 所在的应用上下文的" + "引用传入进来"); } @Override public void afterPropertiesSet() throws Exception { System.out.println("8、Person 实现了 InitializingBean 接口,Spring 调用它的" + "afterPropertiesSet()方法。类似地,如果 person 使用 init-" + "method 声明了初始化方法,该方法也会被调用"); } @Override public void destroy() throws Exception { System.out.println("13、Person 实现了 DisposableBean 接口,Spring 调用它的" + "destroy() 接口方法。同样,如果 person 使用 destroy-method 声明" + "了销毁方法,该方法也会被调用"); } /** * xml 中声明的 init-method 方法 */ public void initMethod(){ System.out.println("9、xml 中声明的 init-method 方法"); } /** * xml 中声明的 destroy-method 方法 */ public void destroyMethod(){ System.out.println("14、xml 中声明的 destroy-method 方法"); System.out.println("end---------------destroy-----------------"); } // 自定义初始化方法 @PostConstruct public void springPostConstruct(){ } // 自定义销毁方法 @PreDestroy System.out.println("12、@PreDestory 调用自定义销毁方法"); } @Override protected void finalize() throws Throwable { System.out.println("finalize 方法"); } }除此之外,创建了一个 MyBeanPostProcessor 类继承自 BeanPostProcessor 这个类只关心 Person 初始化前后要做的事情。比如,初始化之前,加载其他 Bean。代码如下:package com.nasus.lifecycle; import com.nasus.bean.Person; import org.springframework.beans.BeansException; import org.springframework.beans.factory.config.BeanPostProcessor; /** * Project Name:review_spring <br/> * Package Name:PACKAGE_NAME <br/> * Date:2019/9/1 16:25 <br/> * * @author <a href="turodog@foxmail.com">chenzy</a><br/> */ public class MyBeanPostProcessor implements BeanPostProcessor { // 容器加载的时候会加载一些其他的 bean,会调用初始化前和初始化后方法 // 这次只关注 Person 的生命周期 public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { if(bean instanceof Person){ System.out.println("6、初始化 Person 之前执行的方法"); } return bean; } public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if(bean instanceof Person){ System.out.println("10、初始化 Person 完成之后执行的方法"); } return bean; } } resource 文件夹下新建一个 bean_lifecycle.xml 文件注入相关 bean ,代码如下: <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd"> <!-- 扫描bean --> <context:component-scan base-package="com.nasus"/> <!-- 实现了用户自定义初始化和销毁方法 --> <bean id="person" class="com.nasus.bean.Person" init-method="initMethod" destroy-method="destroyMethod"> <!-- 注入bean 属性名称 --> <property name="name" value="nasus" /> </bean> <!--引入自定义的BeanPostProcessor--> <bean class="com.nasus.lifecycle.MyBeanPostProcessor"/> </beans>测试类,获取 person 这个 Bean 并使用它,代码如下:import com.nasus.bean.Person; import java.awt.print.Book; import org.junit.Test; import org.springframework.context.ApplicationContext; import org.springframework.context.support.ClassPathXmlApplicationContext; /** * Project Name:review_spring <br/> * Package Name:PACKAGE_NAME <br/> * Date:2019/9/1 16:38 <br/> * * @author <a href="turodog@foxmail.com">chenzy</a><br/> */ public class lifeCycleTest { @Test public void testLifeCycle(){ // 为面试而准备的Bean生命周期加载过程 ApplicationContext context = new ClassPathXmlApplicationContext("bean_lifecycle.xml"); Person person = (Person)context.getBean("person"); // 使用属性 System.out.println("11、实例化完成使用属性:Person name = " + person.getName()); // 关闭容器 ((ClassPathXmlApplicationContext) context).close(); } }lifeCycleTest 方法最后关闭了容器,关闭的同时控制台日志输出如下: 1、开始实例化 person 2、设置 name 属性 3、Person 实现了 BeanNameAware 接口,Spring 将 Person 的 ID=person传递给 setBeanName 方法 4、Person 实现了 BeanFactoryAware 接口,Spring 调用 setBeanFactory()方法,将 BeanFactory 容器实例传入 5、Person 实现了 ApplicationContextAware 接口,Spring 调用 setApplicationContext()方法,将 person 所在的应用上下文的引用传入进来 6、初始化 Person 之前执行的方法 7、@PostConstruct 调用自定义的初始化方法 8、Person 实现了 InitializingBean 接口,Spring 调用它的afterPropertiesSet()方法。类似地,如果 person 使用 init-method 声明了初始化方法,该方法也会被调用 9、xml 中声明的 init-method 方法10、初始化 Person 完成之后执行的方法11、实例化完成使用属性:Person name = nasus12、@PreDestory 调用自定义销毁方法13、Person 实现了 DisposableBean 接口,Spring 调用它的destroy() 接口方法。同样,如果 person 使用 destroy-method 声明了销毁方法,该方法也会被调用14、xml 中声明的 destroy-method 方法end---------------destroy-----------------由以上日志可知,当 person 默认是单例模式时,bean 的生命周期与容器的生命周期一样,容器初始化,bean 也初始化。容器销毁,bean 也被销毁。那如果,bean 是非单例呢?3、在 Bean 实例化完成后,销毁前搞事情有时我们需要在 Bean 属性值 set 好之后和 Bean 销毁之前做一些事情,比如检查 Bean 中某个属性是否被正常的设置好值了。Spring 框架提供了多种方法让我们可以在 Spring Bean 的生命周期中执行 initialization 和 pre-destroy 方法。这些方法我在上面已经测试过了,以上代码实现了多种方法,它是重复,开发中选以下其一即可,比如:在配置文件中指定的 init-method 和 destroy-method 方法实现 InitializingBean 和 DisposableBean 接口使用 @PostConstruct 和 @PreDestroy 注解(墙裂推荐使用)4、多实例模式下的 Bean 生命周期上面测试中的 person 默认是 singleton 的,现在我们将 person 改为 protoType 模式,bean_lifecycle.xml 做如下代码修改,其余类保持不变:<!-- 实现了用户自定义初始化和销毁方法 --> <bean id="person" scope="prototype" class="com.nasus.bean.Person" init-method="initMethod" destroy-method="destroyMethod"> <!-- 注入bean 属性名称 --> <property name="name" value="nasus" /> </bean>此时的日志输出如下:开始实例化 person 设置 name 属性 Person 实现了 BeanNameAware 接口,Spring 将 Person 的 ID=person传递给 setBeanName 方法 Person 实现了 BeanFactoryAware 接口,Spring 调用 setBeanFactory()方法,将 BeanFactory 容器实例传入 Person 实现了 ApplicationContextAware 接口,Spring 调用 setApplicationContext()方法,将 person 所在的应用上下文的引用传入进来 初始化 Person 之前执行的方法 @PostConstruct 调用自定义的初始化方法 Person 实现了 InitializingBean 接口,Spring 调用它的afterPropertiesSet()方法。类似地,如果 person 使用 init-method 声明了初始化方法,该方法也会被调用 xml 中声明的 init-method 方法 初始化 Person 完成之后执行的方法 实例化完成使用属性:Person name = nasus此时,容器关闭,person 对象并没有销毁。原因在于,单实例模式下,bean 的生命周期由容器管理,容器生,bean 生;容器死,bean 死。而在多实例模式下,Spring 就管不了那么多了,bean 的生命周期,交由客户端也就是程序员或者 JVM 来进行管理。5、多实例模式下 Bean 的加载时机首先说说单实例,单实例模式下,bean 在容器加载那一刻起,就已经完成实例化了,证明如下,我启用 debug 模式,在 20 行打了一个断点,而日志却如下所示,说明了 bean 在 19 行,初始化容器的时候,已经完成实例化了。再说多实例模式下,这个模式下,bean 在需要用到 bean 的时候才进行初始化,证明如下,同样执行完 19 行,多实例模式下,控制台一片空白,说明此时的 bean 是未被加载的。debug 走到 23 行时,也就是需要用到 bean 时才被加载了,验证如下。在开发中,我们把这种加载叫做懒加载,它的用处就是减轻程序开销,等到要用时才加载,而不是一上来就加载全部。6、单实例 bean 如何实现延迟加载只需在 xml 中加上 lazy-init 属性为 true 即可。如下,它的加载方式就变成了懒加载。<!-- 实现了用户自定义初始化和销毁方法 --> <bean id="person" lazy-init="true" class="com.nasus.bean.Person" init-method="initMethod" destroy-method="destroyMethod"> <!-- 注入bean 属性名称 --> <property name="name" value="nasus" /> </bean>如果想对所有的默认单例 bean 都应用延迟初始化,可以在根节点 beans 设置 default-lazy-init 属性为 true,如下所示:<beans default-lazy-init="true" …>
java动态代理1、什么是 AOP ?AOP(Aspect Oriented Programming),即面向切面编程,它是 OOP(Object Oriented Programming,面向对象编程)的补充和完善。在开发中,功能点通常分为横向关注点和核心关注点,核心关注点就是业务关注的点,大部分是要给用户看的。而横向关注点是用户不关心,而我们程序又必须实现的,它的特点是横向分布于核心关注点各处,比如日志功能,核心关注点:增删改查都需要实现日志功能。如果用 面向对象编程来实现的话,那增删改查都需要写一遍日志代码,这会造成非常多冗余代码,显然是不合理的。而此时,AOP 应运而生。它统一定义了,何时、何处执行这些横向功能点2、AOP 相关术语要理解 AOP 首先要认识以下相关术语,有这么个场景,我需要给用户模块的增删改查,实现日志功能,我现在通过这个场景来解释以上术语。连接点(joinpoint)被拦截到的点,因为 Spring 只支持方法类型的连接点,所以在 Spring 中连接点指的就是被拦截到的方法。场景中,连接点就是增删改查方法本身。通知(advice)所谓通知指的就是指拦截到连接点之后要执行的代码,通知分为前置、后置、异常、最终、环绕通知五类。1、前置通知(Before):在目标方法被调用之前调用通知功能;2、后置通知(After):在目标方法完成之后调用通知,此时不会关心方法的输出是什么;3、返回通知(After-returning):在目标方法成功执行之后调用通知;4、异常通知(After-throwing):在目标方法抛出异常后调用通知;5、环绕通知(Around):通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行自定义的行为。切点(pointcut)对连接点进行拦截的定义,它会匹配通知所要织入的一个或多个连接点。它的格式是这样的:切面(aspect)类是对物体特征的抽象,切面就是对横切关注点的抽象,它定义了切点和通知。场景中,日志功能就是这个抽象,它定义了你要对拦截方法做什么?切面是通知和切点的结合。通知和切点共同定义了切面的全部内容——它是什么,在何时和何处完成其功能。织入(weave)将切面应用到目标对象并导致代理对象创建的过程引入(introduction)在不修改代码的前提下,引入可以在运行期为类动态地添加一些方法或字段3、注解实现 AOP首先,定义一个加减乘除的接口,代码如下:1public interface ArithmeticCalculator { 2 3 int add(int i, int j); 4 5 int sub(int i, int j); 6 7 int mul(int i, int j); 8 9 int div(int i, int j); 10 11}定义一个实现类,代码如下:1@Component("arithmeticCalculator") 2public class ArithmeticCalculatorImpl implements ArithmeticCalculator { 3 4 public int add(int i, int j) { 5 int result = i + j; 6 return result; 7 } 8 9 public int sub(int i, int j) { 10 int result = i - j; 11 return result; 12 } 13 14 public int mul(int i, int j) { 15 int result = i * j; 16 return result; 17 } 18 19 public int div(int i, int j) { 20 int result = i / j; 21 return result; 22 } 23 24}定义切面,代码如下:1/** 2 * 1. 加入 jar 包 3 * com.springsource.net.sf.cglib-2.2.0.jar 4 * com.springsource.org.aopalliance-1.0.0.jar 5 * com.springsource.org.aspectj.weaver-1.6.8.RELEASE.jar 6 * spring-aspects-4.0.0.RELEASE.jar 7 * 8 * 2. 在 Spring 的配置文件中加入 aop 的命名空间。 9 * 10 * 3. 基于注解的方式来使用 AOP 11 * 3.1 在配置文件中配置自动扫描的包: <context:component-scan base-package="com.atguigu.spring.aop"></context:component-scan> 12 * 3.2 加入使 AspjectJ 注解起作用的配置: <aop:aspectj-autoproxy></aop:aspectj-autoproxy> 13 * 为匹配的类自动生成动态代理对象. 14 * 15 * 4. 编写切面类: 16 * 4.1 一个一般的 Java 类 17 * 4.2 在其中添加要额外实现的功能. 18 * 19 * 5. 配置切面 20 * 5.1 切面必须是 IOC 中的 bean: 实际添加了 @Component 注解 21 * 5.2 声明是一个切面: 添加 @Aspect 22 * 5.3 声明通知: 即额外加入功能对应的方法. 23 * 5.3.1 前置通知: @Before("execution(public int com.atguigu.spring.aop.ArithmeticCalculator.*(int, int))") 24 * @Before 表示在目标方法执行之前执行 @Before 标记的方法的方法体. 25 * @Before 里面的是切入点表达式: 26 * 27 * 6. 在通知中访问连接细节: 可以在通知方法中添加 JoinPoint 类型的参数, 从中可以访问到方法的签名和方法的参数. 28 * 29 * 7. @After 表示后置通知: 在方法执行之后执行的代码. 30 */ 31 32//通过添加 @EnableAspectJAutoProxy 注解声明一个 bean 是一个切面! 33@Component 34@Aspect 35public class LoggingAspect { 36 37 /** 38 * 在方法正常开始前执行的代码 39 * @param joinPoint 40 */ 41 @Before("execution(public int com.nasus.spring.aop.impl.*.*(int, int))") 42 public void beforeMethod(JoinPoint joinPoint){ 43 String methodName = joinPoint.getSignature().getName(); 44 Object [] args = joinPoint.getArgs(); 45 46 System.out.println("The method " + methodName + " begins with " + Arrays.asList(args)); 47 } 48 49 /** 50 * 在方法执行后执行的代码,无论方法是否抛出异常 51 * @param joinPoint 52 */ 53 @After("execution(* com.nasus.spring.aop.impl.*.*(..))") 54 public void afterMethod(JoinPoint joinPoint){ 55 String methodName = joinPoint.getSignature().getName(); 56 System.out.println("The method " + methodName + " ends"); 57 } 58 59 60 /** 61 * 在方法正常结束后执行的代码 62 * 返回通知是可以访问到方法的返回值的 63 * @param joinPoint 64 * @param result 65 */ 66 @AfterReturning(value = "execution(public int com.nasus.spring.aop.impl.*.*(int, int))", 67 returning = "result") 68 public void afterReturning(JoinPoint joinPoint, Object result){ 69 String methodName = joinPoint.getSignature().getName(); 70 System.out.println("The method " + methodName + " ends with " + result); 71 } 72 73 /** 74 * 在目标方法出现异常时,会执行的代码 75 * 可以访问到异常对象,可以指定在出现特定异常时再执行通知代码 76 * @param joinPoint 77 * @param ex 78 */ 79 @AfterThrowing(value = "execution(public int com.nasus.spring.aop.impl.*.*(int, int))", 80 throwing = "ex") 81 public void afterThrowing(JoinPoint joinPoint, Exception ex){ 82 String methodNames = joinPoint.getSignature().getName(); 83 System.out.println("The method " + methodNames + " occurs exception: " + ex); 84 } 85 86 /** 87 * 环绕通知需要携带 ProceedingJoinPoint 类型参数 88 * 环绕通知类似于动态代理的全过程;ProceedingJoinPoint 类型的参数可以决定是否执行目标方法 89 * 且环绕通知必须有返回值,返回值极为目标方法的返回值 90 * @param pjd 91 * @return 92 */ 93 @Around("execution(public int com.nasus.spring.aop.impl.*.*(int, int))") 94 public Object aroundMethod(ProceedingJoinPoint pjd){ 95 96 Object result = null; 97 String methodName = pjd.getSignature().getName(); 98 99 try { 100 // 前置通知 101 System.out.println("The method " + methodName + " begins with " + Arrays.asList(pjd.getArgs())); 102 103 // 执行目标方法 104 result = pjd.proceed(); 105 106 // 返回通知 107 System.out.println("The method " + methodName + " ends with " + result); 108 }catch (Throwable e) { 109 // 异常通知 110 System.out.println("The method " + methodName + " occurs exception: " + e); 111 throw new RuntimeException(e); 112 } 113 114 // 后置通知 115 System.out.println("The method " + methodName + " ends"); 116 117 return result; 118 } 119 120}xml 配置,代码如下:1<?xml version="1.0" encoding="UTF-8"?> 2<beans xmlns="http://www.springframework.org/schema/beans" 3 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 4 xmlns:aop="http://www.springframework.org/schema/aop" 5 xmlns:context="http://www.springframework.org/schema/context" 6 xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd 7 http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-4.0.xsd 8 http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.0.xsd"> 9 10 <!-- 自动扫描的包 --> 11 <context:component-scan base-package="com.nasus.spring.aop.impl"></context:component-scan> 12 13 <!-- 使 AspectJ 的注解起作用 --> 14 <aop:aspectj-autoproxy></aop:aspectj-autoproxy> 15< 16测试方法:1public class Main { 2 3 public static void main(String args[]){ 4 5 // 1、创建 Spring 的 IOC 容器 6 ApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext_aop.xml"); 7 8 // 2、从 IOC 容器中获取 bean 实例 9 ArithmeticCalculator arithmeticCalculator = ctx.getBean(ArithmeticCalculator.class); 10 11 // 3、使用 bean 12 arithmeticCalculator.add(3,6); 13 } 14 15}测试结果:1The method add begins with [3, 6] 2The method add begins with [3, 6] 3The method add ends with 9 4The method add ends 5The method add ends 6The method add ends with 94、xml 实现 AOP关于 xml 的实现方式,网上发现一篇文章写的不错,此处,不再赘述,有兴趣的参考以下链接:https://www.cnblogs.com/hongwz/p/5764917.html5、源码地址https://github.com/turoDog/review_spring
Java 是一门面向对象的语言,在 Java 里面一切都可以看作是一个对象,而 Java 里面所有的对象都默认继承于 Object 类,所以狗哥今天就从源码角度复习了一遍这个类。上图看出 Object 一共有 12 个方法,其中 registerNatives() 是由 C 语言实现的,这个不在研究范围内。1、getClass/** * Returns the runtime class of this {@code Object}. The returned * {@code Class} object is the object that is locked by {@code * static synchronized} methods of the represented class. */ public final native Class<?> getClass();这个方法的作用就是返回某个对象的运行时类,它的返回值是 Class 类型,Class c = obj.getClass();通过对象 c ,我们可以获取该对象的所有成员方法,每个成员方法都是一个 Method 对象;我们也可以获取该对象的所有成员变量,每个成员变量都是一个 Field 对象;同样的,我们也可以获取该对象的构造函数,构造函数则是一个 Constructor 对象。这个方法在反射时会常用到。2、hashCode/** * Returns a hash code value for the object. This method is * supported for the benefit of hash tables such as those provided by * {@link java.util.HashMap}. */ public native int hashCode();这个方法的注释比较长,就不放出来了。注释指出:hashCode 方法返回散列值。返回值默认是由对象的地址转换而来的。同一个对象调用 hashCode 的返回值是相等的。两个对象的 equals 相等,那 hashCode 一定相等。两个对象的 equals 不相等,那 hashCode 也不一定相等。3、equalspublic boolean equals(Object obj) { return (this == obj); }equals 的实现非常简单,它的作用就是比较两个对象是否相等,而比较的依据就是二者的内存地址。除此之外,equals 还遵循以下几个原则:1、自反性:x.equals(x); // true 2、对称性:x.equals(y) == y.equals(x); // true 3、传递性:if (x.equals(y) && y.equals(z)) x.equals(z); // true; 4、一致性,只要对象没有被修改,多次调用 equals() 方法结果不变: x.equals(y) == x.equals(y); // true 5、非空性,对任何不是 null 的对象 x 调用 x.equals(null) 结果都为 false : x.equals(null); // false;为什么要重写 hashcode 和 equals ?这个问题之前分享过旧文:为什么要重写 equals 和 hashCode 方法4、cloneprotected native Object clone() throws CloneNotSupportedException;clone() 是 Object 的 protected 方法,它不是 public,一个类不显式去重写 clone(),其它类就不能直接去调用该类实例的 clone() 方法。此外,Clone 的注释中还提到比较重要的几点:克隆的对象必须要实现 Cloneable 接口并重写 clone 方法,否则会报 CloneNotSupportedException 异常clone() 方法并不是 Cloneable 接口的方法,而是 Object 的一个 protected 方法。Cloneable 接口只是规定,如果一个类没有实现 Cloneable 接口又调用了 clone() 方法,就会抛出 CloneNotSupportedException。浅拷贝:拷贝对象和原始对象的引用类型引用同一个对象。深拷贝:拷贝对象和原始对象的引用类型引用不同对象。关于浅拷贝与深拷贝的详解,请看这篇旧文:Java 深拷贝与浅拷贝5、toStringpublic String toString() { return getClass().getName() + "@" + Integer.toHexString(hashCode()); }这个方法应该没什么好讲的,原生的 toString 方法仅仅返回,对象名 + 它的 hashCode ,但做过开发的都知道,原生的 toString 作用不大。我们需要重写 toString 一般是因为方便调试,需要知道对象的属性值,而不仅仅是 hashCode 。所以,应该像下面这样重写:public class Student { private int age; private String name; // 省略 get、set @Override public String toString() { return "Student{" + "age=" + age + ", name='" + name + '\'' + '}'; } }6、notify 和 waitpublic final native void notify(); public final native void notifyAll();首先是 notify ,注释就不贴出来了,notify 的作用就是随机唤醒在等待队列的某个线程,而 notifyAll 就是唤醒在等待队列的所有线程。public final void wait() throws InterruptedException { wait(0); } public final native void wait(long timeout) throws InterruptedException; public final void wait(long timeout, int nanos) throws InterruptedException { if (timeout < 0) { throw new IllegalArgumentException("timeout value is negative"); } if (nanos < 0 || nanos > 999999) { throw new IllegalArgumentException( "nanosecond timeout value out of range"); } if (nanos > 0) { timeout++; } wait(timeout); }然后是 wait ,wait 的作用是让当前线程进入等待状态,同时,wait() 也会让当前线程释放它所持有的锁。直到其他线程调用此对象的 notify() 方法或 notifyAll() 方法,当前线程被唤醒进入就绪状态。wait(long timeout) (以毫秒为单位)让当前线程处于等待(阻塞)状态,直到其他线程调用此对象的notify() 方法或 notifyAll() 方法,或者超过指定的时间量,当前线程被唤醒进入就绪状态。wait(long timeout, int nanos) 和 wait(long timeout) 功能一样,唯一的区别是这个可以提供更高的精度。总超时时间(以纳秒为单位)计算为 1000000 *timeout+ nanos。By the way ,wait(0,0) 和 wait(0) 效果一样。除此之外,notify 和 wait 的注释中还有这么一段:* <p> * This method should only be called by a thread that is the owner * of this object's monitor. A thread becomes the owner of the * object's monitor in one of three ways: * <ul> * <li>By executing a synchronized instance method of that object. * <li>By executing the body of a {@code synchronized} statement * that synchronizes on the object. * <li>For objects of type {@code Class,} by executing a * synchronized static method of that class. * </ul> * <p>看到这英文,刚过四级的我瑟瑟发抖。以上注释主要就是描述了,notify 和 wait 方法的使用规范。意思就是这二者必须在 synchronized 修饰的同步方法或同步代码中使用。(1) 为什么 wait() 必须在同步 (Synchronized) 方法/代码块中调用?答:调用 wait() 就是释放锁,释放锁的前提是必须要先获得锁,先获得锁才能释放锁。(2) 为什么 notify()、notifyAll() 必须在同步 (Synchronized) 方法/代码块中调用?答:notify()、notifyAll() 是将锁交给含有 wait() 方法的线程,让其继续执行下去,如果自身没有锁,怎么叫把锁交给其他线程呢?(本质是让处于入口队列的线程竞争锁)(3) Thread.sleep() 和 Object.wait() 有什么区别?首先,二者都可以暂停当前线程,释放 CPU 控制权。主要的区别在于 Object.wait()在释放 CPU 同时,释放了对象锁的控制。而 Thread.sleep() 没有对锁释放。换句话说 sleep 就是耍流氓,占着茅坑不拉屎。
Redis 简介Redis 是一个开源的,基于内存的键值数据存储,用作数据库,缓存和消息代理。在实现方面,Key-Value 存储代表 NoSQL 空间中最大和最老的成员之一。Redis 支持数据结构,如字符串,散列,列表,集和带范围查询的有序集。在 spring data redis 的框架,可以很容易地编写,通过提供一个抽象的数据存储使用 Redis 的键值存储的 Spring 应用程序。非关系型数据库,基于内存,存取数据的速度不是关系型数据库所能比拟的redis 是键值对 (key-value) 的数据库数据类型1. 字符串类型 string2. 散列类型 hash3. 列表类型 list4. 集合类型 set5. 有序集合类型 zset其中,因为SpringBoot 约定大于配置的特点,只要我们加入了 spring-data-redis 依赖包并配置 Redis 数据库,SpringBoot 就会帮我们自动配置一个 RedisTemplate ,利用它我们就可以按照以下方式操作对应的数据类型,在下面实战中我将会对这五种数据进行操作。1. redisTemplate.opsForValue(); //操作字符串2. redisTemplate.opsForHash(); //操作hash3. redisTemplate.opsForList(); //操作list4. redisTemplate.opsForSet(); //操作set5. redisTemplate.opsForZSet(); //操作有序set开发环境1. SpringBoot 2.1.6 RELEASE2. Spring-data-redis 2.1.9 RELEASE3. Redis 3.24. IDEA5. JDK86. mysql关于如何安装 Redis 这里不再赘述,请自行搜索引擎搜索解决。pom 依赖<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- https://mvnrepository.com/artifact/com.alibaba/fastjson --> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.58</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>配置文件spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&useSSL=true username: root password: 123456 jpa: hibernate: ddl-auto: update #ddl-auto:设为 create 表示每次都重新建表 show-sql: true redis: host: localhost port: 6379 # Redis数据库索引(默认为0) database: 1 jedis: pool: #连接池最大连接数 max-active: 8 #最小空闲连接 min-idle: 0 #最大阻塞等待时间,负值表示没有限制 max-wait: -1ms #最大空闲连接 max-idle: 8 #连接超时时间(毫秒) timeout: 20ms # 无密码可不写 # password:为什么乱码?/** * 添加字符串 */ @Test public void setString(){ redisTemplate.opsForValue().set(USERKEY,"nasus"); redisTemplate.opsForValue().set(AGEKEY,24); redisTemplate.opsForValue().set(CITYKEY,"清远"); }首先是添加字符串类型的数据。它的运行结果如下:如何解决乱码我们可以看到插入的数据是乱码的,这是因为 SpringBoot 自动配置的这个 RedisTemplate 是没有设置数据读取时的 key 及 value 的序列化方式的。所以,我们要写一个自己的 RedisTemplate 并设置 key 及 value 的序列化方式才可以正常操作 Redis。如下:@Configuration public class RedisConfig { private final RedisTemplate redisTemplate; @Autowired public RedisConfig(RedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } @Bean @SuppressWarnings("unchecked") public RedisTemplate<String, Object> redisTemplate() { RedisSerializer<String> stringSerializer = new StringRedisSerializer(); //RedisSerializer<Object> jsonString = new GenericToStringSerializer<>(Object.class); RedisSerializer<Object> jsonString = new FastJsonRedisSerializer<>(Object.class); // String 的 key 和 hash 的 key 都采用 String 的序列化方式 redisTemplate.setKeySerializer(stringSerializer); redisTemplate.setHashKeySerializer(stringSerializer); // value 都采用 fastjson 的序列化方式 redisTemplate.setValueSerializer(jsonString); redisTemplate.setHashValueSerializer(jsonString); return redisTemplate; } }这时,再次运行上面的单元测试,结果就正常了。操作 List/** * 添加、获取LIST */ @Test public void setList(){ List<Student> students = studentService.findStudentList(); log.info("students size = {}", students.size()); //循环向 studentList 左添加值 students.forEach(value->redisTemplate.opsForList().leftPush(LISTKEY,value)); //向 studentList 右添加值 Student student = new Student(); student.setId(10); student.setAge(24); student.setName("rightPush"); redisTemplate.opsForList().rightPush(LISTKEY,student); // 获取值 log.info("studentList->{}",redisTemplate.opsForList().range(LISTKEY,0,10)); }这里需要说一下,leftpush 和 rightpush 的区别,前者是在 key 对应 list 的头部添加元素,也就是我们常说的后来居上,List下标最大的元素在这个 list 里面处于第一位;而后者则是 在 key 对应 list 的尾部添加元素,刚好和前者相反。获取值,代码这里获取的是 0 到 10 行的数据,控制台打印结果:[{"name":"优秀","id":9,"age":22}, {"name":"冯某华","id":8,"age":25}, {"name":"蓝某城","id":7,"age":25}, {"name":"优秀","id":6,"age":22}, {"name":"冯某华","id":5,"age":25}, {"name":"蓝某城","id":4,"age":25}, {"name":"冯某华","id":3,"age":25}, {"name":"蓝某城","id":2,"age":25}, {"name":"废人","id":1,"age":22}, {"name":"rightPush","id":10,"age":24}]添加 List 结果:这里注意 1 到 9 行的 id 值刚好是相反的,而正常情况下,我从 mysql 数据中查出来的值是这样的:因此,验证了 leftpush 和 rightpush 的区别。操作 set/** * 添加和获取Set */ @Test public void setAndGetSet(){ List<String> usernameList = new ArrayList<>(); usernameList.add("nasus"); usernameList.add("nasus"); usernameList.add("一个优秀的废人"); //循环向添加值 usernameList.forEach(value->redisTemplate.opsForSet().add(SETKEY,value)); log.info("取出usernameSet->{}",redisTemplate.opsForSet().members(SETKEY)); } /** * 删除 Set */ @Test public void delSet(){ redisTemplate.opsForSet().remove(SETKEY,"nasus"); }Redis 的 set 数据结构跟 java 的 hashset 数据结构一样,也是无序且不重复。所以我代码里 add 了两个 nasus 字符串,其实只 add 了一个 nasus 。结果如下:操作 hash分别作了 hash 的添加、删除以及获取,代码如下:这里需要说明一下的是,hash 的 hash 有两个键可以设置,其中第一个是 redis 中的键,而第二个是具体每条数据的 hashkey。/** * 添加 hash */ @Test public void setHash(){ List<Student> students = studentService.findStudentList(); //添加 for (Student student : students){ redisTemplate.opsForHash().put(HASHKEY, student.getId().toString(), student); } } /** * 删除 hash */ @Test public void delHash(){ Student student = studentService.findStudentById(0); // 删除 redisTemplate.opsForHash().delete(HASHKEY,JSON.toJSONString(student)); } /** * 获取 Hash */ @Test public void getHash(){ List<Student> students = redisTemplate.opsForHash().values(HASHKEY); log.info("values = {}", students); }添加 hash 操作结果:获取 hash 操作结果:源码地址https://github.com/turoDog/Demo/tree/master/springboot_redis_demo推荐阅读SpringBoot | 自动配置原理后语如果本文对你哪怕有一丁点帮助,请帮忙点好看。你的好看是我坚持写作的动力。
1、屏蔽百度推广不知道大家注意到没有,有百度搜索的时候,右侧会有一列与关键词无关的热点新闻引导大家去点击分散大家的注意力。而上面说到的这个插件就可以屏蔽右侧推广,还你们一个干净的百度,比如没装插件,它的页面是这样的:使用方式:点击关闭右侧推广按钮,页面会自动刷新这个时候的网页,就变干净了:2、广告净化器作为程序员应该没少逛 CSDN 吧,不知道你们有没有觉得以下的轮训广告很恶心?反正我是受不了的,正看着博客,就被这广告给打扰了。这个插件就可以帮你屏蔽任何网站的广告,甚至于爱奇艺,腾讯视频等广告。你们追电视剧要看的广告,我全都不需要看,如丝般顺滑。话不多说推荐给你们。没插件之前是这样的:使用方法:打开按钮就可以使用后:3、WEB前端助手(FeHelper)作为程序员的大家在开发中肯定会用到很多诸如,JSON 格式化,代码美化,字符串编码,二维码生成等等一大堆工具。这个插件就为这些个功能提供了良好的支持,实在是太良心了。使用方法:下载插件安装即可,简单方便。4、Dark Reader夜深人静浏览网站时保护眼睛的好插件,黑色主题,适用于任何网站。关爱眼睛,就使用 Dark Reader 进行夜间和日间浏览。使用前:使用方法:设置好各种参数,直接点击网站名,看到以下红框的网站域名带个 √ 就是设置成功了。使用后:此时的网站主题就会变成你设置好的黑色:5、Isometric Contributions纯装逼利器,这个插件就是把 Github 的提交记录从二维平面的变成三维立体的。安装前:安装后:除了提交记录变三维立体外,还支持通过设置来统计你的各项数据。6、Octotree大家平时上 github 看项目都是一层一层文件夹点进去,非常繁琐。这款插件就是让你在 Github 上查看代码结构就像在自己开发工具里面一样方便。效果是这样的:7、Infinity 标签页这个插件自由定制 chrome 标签页。开启页面添加时代,无论你浏览那个页面,都能一步将网址添加到标签页中,独创新标签页中谷歌邮件自动提醒功能,还有精美天气,待办事项,历史记录管理,应用程序管理,印象笔记一样的记事应用,高清壁纸,必应,百度,谷歌搜索。反正就是贼好用,我的标签页是这样的:8、Google 翻译这个插件可以在你浏览英文技术文档时,选中翻译成中文,帮助你理解。比如:Spring 官网:点击红框小图标就可以翻译出来:9、一个神奇的网站扩展迷:https://extfans.com/大家都知道 Chrome 插件一般都要科学上网才能下载到,但是自从这个网站出现以后下载插件不再需要折腾了。直接访问搜索名称就可以下载到以上所提到的插件。
门面模式说到日志框架不得不说门面模式。门面模式,其核心为外部与一个子系统的通信必须通过一个统一的外观对象进行,使得子系统更易于使用。用一张图来表示门面模式的结构为:简单来说,该模式就是把一些复杂的流程封装成一个接口供给外部用户更简单的使用。这个模式中,设计到3个角色。 1).门面角色:外观模式的核心。它被客户角色调用,它熟悉子系统的功能。内部根据客户角色的需求预定了几种功能的组合(模块)。 2).子系统(模块)角色:实现了子系统的功能。它对客户角色和 Facade 是未知的。它内部可以有系统内的相互交互,也可以由供外界调用的接口。 3).客户角色:通过调用 Facede 来完成要实现的功能。市面上的日志框架日志门面日志实现JCL(Jakarta Commons Logging)、SLF4j(Simple Logging Facade for Java)、jboss-loggingLog4j 、JUL(java.util.logging) 、Log4j2 、 Logback简单说下,上表的日志门面对应了门面模式中的 Facede 对象,它们只是一个接口层,并不提供日志实现;而日志实现则对应着各个子系统或者模块,日志记录的具体逻辑实现,就写在这些右边的框架里面;那我们的应用程序就相当于客户端。为什么要使用门面模式?试想下我们开发系统的场景,需要用到很多包,而这些包又有自己的日志框架,于是就会出现这样的情况:我们自己的系统中使用了 Logback 这个日志系统,我们的系统使用了 Hibernate,Hibernate 中使用的日志系统为 jboss-logging,我们的系统又使用了 Spring ,Spring 中使用的日志系统为 commons-logging。这样,我们的系统就不得不同时支持并维护 Logback、jboss-logging、commons-logging 三种日志框架,非常不便。解决这个问题的方式就是引入一个接口层,由接口层决定使用哪一种日志系统,而调用端只需要做的事情就是打印日志而不需要关心如何打印日志,而上表的日志门面就是这种接口层。鉴于此,我们选择日志时,就必须从上表左边的日志门面和右边的日志实现各选择一个框架,而 SpringBoot 底层默认选用的就是 SLF4j 和 Logback 来实现日志输出。SLF4j 使用官方文档给出这样一个例子:import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class HelloWorld { public static void main(String[] args) { // HelloWorld.class 就是你要打印的指定类的日志, // 如果你想在其它类中打印,那就把 HelloWorld.class 替换成目标类名.class 即可。 Logger logger = LoggerFactory.getLogger(HelloWorld.class); logger.info("Hello World"); } }为了理解 slf4j 的工作原理,我翻了下它的官方文档,看到这么一张图:简单解释一下,上图 slf4j 有六种用法,一共五种角色,application 不用说,就是我们的系统;SLF4J API 就是日志接口层(门面);蓝色和最下面灰色的就是具体日志实现(子系统);而 Adaptation 就是适配层。解释下,上图第二,第三种用法。其中第二种就是 SpringBoot 的默认用法;而为什么会出现第三种?因为 Log4J 出现得比较早,它根本不知道后面会有 SLF4J 这东西。Log4J 不能直接作为 SLF4J 的日志实现,所以中间就出现了适配层。第四种同理。这里提醒下,每一个日志的实现框架都有自己的配置文件。使用 slf4j 以后,**配置文件还是做成日志实现框架自己本身的配置文件。比如,Logback 就使用 logback.xml、Log4j 就使用 Log4j.xml 文件。如何让系统中所有的日志都统一到 slf4j ?我继续浏览了下官网,看见这么一张图:由上图可以看出,让系统中所有的日志都统一到 slf4j 的做法是:1、将系统中其他日志框架先排除出去2、用中间包来替换原有的日志框架3、我们导入 slf4j 其他的实现SpringBoot 中的日志关系SpringBoot 使用以下依赖实现日志功能:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-logging</artifactId> <version>2.1.3.RELEASE</version> <scope>compile</scope> </dependency>spring-boot-starter-logging 有这么一张关系图:可见,1、SpringBoot2.x 底层也是使用 slf4j+logback 或 log4j 的方式进行日志记录;2、SpringBoot 引入中间替换包把其他的日志都替换成了 slf4j;3、 如果我们要引入其他框架、可以把这个框架的默认日志依赖移除掉。比如 Spring 使用的是 commons-logging 框架,我们可以这样移除。<dependency> <groupId>org.springframework</groupId> <artifactId>spring-core</artifactId> <exclusions> <exclusion> <groupId>commons-logging</groupId> <artifactId>commons-logging</artifactId> </exclusion> </exclusions> </dependency>SpringBoot 能自动适配所有的日志,而且底层使用 slf4j+logback 的方式记录日志,引入其他框架的时候,只需要把这个框架依赖的日志框架排除掉即可。日志使用1、默认配置(以 Log4j 框架为例),SpringBoot 默认帮我们配置好了日志://记录器 Logger logger = LoggerFactory.getLogger(getClass()); @Test public void contextLoads() { //日志的级别; //由低到高 trace<debug<info<warn<error //可以调整输出的日志级别;日志就只会在这个级别以以后的高级别生效 logger.trace("这是trace日志..."); logger.debug("这是debug日志..."); // SpringBoot 默认给我们使用的是 info 级别的,没有指定级别的就用SpringBoot 默认规定的级别;root 级别 logger.info("这是info日志..."); logger.warn("这是warn日志..."); logger.error("这是error日志..."); }2、log4j.properties 修改日志默认配置logging.level.com.nasus=debug #logging.path= # 不指定路径在当前项目下生成 springboot.log 日志 # 可以指定完整的路径; #logging.file=Z:/springboot.log # 在当前磁盘的根路径下创建 spring 文件夹和里面的 log 文件夹;使用 spring.log 作为默认文件 logging.path=/spring/log # 在控制台输出的日志的格式 logging.pattern.console=%d{yyyy-MM-dd} [%thread] %-5level %logger{50} - %msg%n # 指定文件中日志输出的格式 logging.pattern.file=%d{yyyy-MM-dd} === [%thread] === %-5level === %logger{50} ==== %msg%n3、指定配置SpringBoot 会自动加载类路径下对应框架的配置文件,所以我们只需给类路径下放上每个日志框架自己的配置文件即可,SpringBoot 就不会使用默认配置了。框架命名方式Logbacklogback-spring.xml, logback-spring.groovy, logback.xml or logback.groovyLog4j2log4j2-spring.xml or log4j2.xmlJDK (Java Util Logging)`logging.propertieslogback.xml:直接就被日志框架识别了。logback-spring.xml:日志框架就不直接加载日志的配置项,由 SpringBoot 解析日志配置,可以使用 SpringBoot 的高级 Profile 功能。<springProfile name="staging"> <!-- configuration to be enabled when the "staging" profile is active --> 可以指定某段配置只在某个环境下生效 </springProfile>例子 (以 Logback 框架为例):<appender name="stdout" class="ch.qos.logback.core.ConsoleAppender"> <!-- 日志输出格式: %d表示日期时间, %thread表示线程名, %-5level:级别从左显示5个字符宽度 %logger{50} 表示logger名字最长50个字符,否则按照句点分割。 %msg:日志消息, %n是换行符 --> <layout class="ch.qos.logback.classic.PatternLayout"> <!--指定在 dev 环境下,控制台使用该格式输出日志--> <springProfile name="dev"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} ----> [%thread] ---> %-5level %logger{50} - %msg%n</pattern> </springProfile> <!--指定在非 dev 环境下,控制台使用该格式输出日志--> <springProfile name="!dev"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} ==== [%thread] ==== %-5level %logger{50} - %msg%n</pattern> </springProfile> </layout> </appender>如果使用 logback.xml 作为日志配置文件,而不是 logback-spring.xml,还要使用profile 功能,会有以下错误:no applicable action for [springProfile]切换日志框架了解了 SpringBoot 的底层日志依赖关系,我们就可以按照 slf4j 的日志适配图,进行相关的切换。例如,切换成 slf4j+log4j ,可以这样做<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <artifactId>logback-classic</artifactId> <groupId>ch.qos.logback</groupId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-log4j12</artifactId> </dependency>切换成 log4j2 ,就可以这样做。<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <artifactId>spring-boot-starter-logging</artifactId> <groupId>org.springframework.boot</groupId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-log4j2</artifactId> </dependency>最后放上 logback-spring.xml 的详细配置,大家在自己项目可以参考配置。<?xml version="1.0" encoding="UTF-8"?> <!-- scan:当此属性设置为true时,配置文件如果发生改变,将会被重新加载,默认值为true。 scanPeriod:设置监测配置文件是否有修改的时间间隔,如果没有给出时间单位,默认单位是毫秒当scan为true时,此属性生效。默认的时间间隔为1分钟。 debug:当此属性设置为true时,将打印出logback内部日志信息,实时查看logback运行状态。默认值为false。 --> <configuration scan="false" scanPeriod="60 seconds" debug="false"> <!-- 定义日志的根目录 --> <property name="LOG_HOME" value="/app/log" /> <!-- 定义日志文件名称 --> <property name="appName" value="nasus-springboot"></property> <!-- ch.qos.logback.core.ConsoleAppender 表示控制台输出 --> <appender name="stdout" class="ch.qos.logback.core.ConsoleAppender"> <!-- 日志输出格式: %d表示日期时间, %thread表示线程名, %-5level:级别从左显示5个字符宽度 %logger{50} 表示logger名字最长50个字符,否则按照句点分割。 %msg:日志消息, %n是换行符 --> <layout class="ch.qos.logback.classic.PatternLayout"> <springProfile name="dev"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} ----> [%thread] ---> %-5level %logger{50} - %msg%n</pattern> </springProfile> <springProfile name="!dev"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} ==== [%thread] ==== %-5level %logger{50} - %msg%n</pattern> </springProfile> </layout> </appender> <!-- 滚动记录文件,先将日志记录到指定文件,当符合某个条件时,将日志记录到其他文件 --> <appender name="appLogAppender" class="ch.qos.logback.core.rolling.RollingFileAppender"> <!-- 指定日志文件的名称 --> <file>${LOG_HOME}/${appName}.log</file> <!-- 当发生滚动时,决定 RollingFileAppender 的行为,涉及文件移动和重命名 TimeBasedRollingPolicy: 最常用的滚动策略,它根据时间来制定滚动策略,既负责滚动也负责出发滚动。 --> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <!-- 滚动时产生的文件的存放位置及文件名称 %d{yyyy-MM-dd}:按天进行日志滚动 %i:当文件大小超过maxFileSize时,按照i进行文件滚动 --> <fileNamePattern>${LOG_HOME}/${appName}-%d{yyyy-MM-dd}-%i.log</fileNamePattern> <!-- 可选节点,控制保留的归档文件的最大数量,超出数量就删除旧文件。假设设置每天滚动, 且maxHistory是365,则只保存最近365天的文件,删除之前的旧文件。注意,删除旧文件是, 那些为了归档而创建的目录也会被删除。 --> <MaxHistory>365</MaxHistory> <!-- 当日志文件超过maxFileSize指定的大小是,根据上面提到的%i进行日志文件滚动 注意此处配置SizeBasedTriggeringPolicy是无法实现按文件大小进行滚动的,必须配置timeBasedFileNamingAndTriggeringPolicy --> <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> <maxFileSize>100MB</maxFileSize> </timeBasedFileNamingAndTriggeringPolicy> </rollingPolicy> <!-- 日志输出格式: --> <layout class="ch.qos.logback.classic.PatternLayout"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [ %thread ] - [ %-5level ] [ %logger{50} : %line ] - %msg%n</pattern> </layout> </appender> <!-- logger主要用于存放日志对象,也可以定义日志类型、级别 name:表示匹配的logger类型前缀,也就是包的前半部分 level:要记录的日志级别,包括 TRACE < DEBUG < INFO < WARN < ERROR additivity:作用在于children-logger是否使用 rootLogger配置的appender进行输出, false:表示只用当前logger的appender-ref,true: 表示当前logger的appender-ref和rootLogger的appender-ref都有效 --> <!-- hibernate logger --> <logger name="com.nasus" level="debug" /> <!-- Spring framework logger --> <logger name="org.springframework" level="debug" additivity="false"></logger> <!-- root 与 logger 是父子关系,没有特别定义则默认为root,任何一个类只会和一个logger对应, 要么是定义的logger,要么是root,判断的关键在于找到这个logger,然后判断这个logger的appender和level。 --> <root level="info"> <appender-ref ref="stdout" /> <appender-ref ref="appLogAppender" /> </root> </configuration>参考文献http://www.importnew.com/28494.htmlhttps://www.cnblogs.com/lthIU/p/5860607.html后语如果本文对你哪怕有一丁点帮助,请帮忙点好看。你的好看是我坚持写作的动力。
如何使用定义两个对象,一个学生对象,对应着一个老师对象,代码如下:@ConfigurationProperties学生类@Component @ConfigurationProperties(prefix = "student") // 指定配置文件中的 student 属性与这个 bean绑定 public class Student { private String firstName; private String lastName; private Integer age; private String gender; private String city; private Teacher teacher; private List<String> hobbys; private Map<String,Integer> scores; //注意,为了测试必须重写 toString 和 get,set 方法 }老师类public class Teacher { private String name; private Integer age; private String gender; //注意,为了测试必须重写 toString 和 get,set 方法 }测试类@RunWith(SpringRunner.class) @SpringBootTest public class SpringbootValConproDemoApplicationTests { @Autowired private Student student; @Test public void contextLoads() { // 这里为了方便,但工作中千万不能用 System.out System.out.println(student.toString()); } }输出结果Student{firstName='陈', lastName='一个优秀的废人', age=24, gender='男', city='广州', teacher=Teacher{name='eses', age=24, gender='女'}, hobbys=[篮球, 羽毛球, 兵兵球], scores={java=100, Python=99, C=99}}@Value@Value 支持三种取值方式,分别是 字面量、${key}从环境变量、配置文件中获取值以及 #{SpEL}学生类@Component //@ConfigurationProperties(prefix = "student") // 指定配置文件中的 student 属性与这个 bean绑定 public class Student { /** * <bean class="Student"> * <property name="lastName" value="字面量/${key}从环境变量、配置文件中获取值/#{SpEL}"></property> * <bean/> */ @Value("陈") // 字面量 private String firstName; @Value("${student.lastName}") // 从环境变量、配置文件中获取值 private String lastName; @Value("#{12*2}") // #{SpEL} private Integer age; private String gender; private String city; private Teacher teacher; private List<String> hobbys; private Map<String,Integer> scores; //注意,为了测试必须重写 toString 和 get,set 方法 }测试结果Student{firstName='陈', lastName='一个优秀的废人', age=24, gender='null', city='null', teacher=null, hobbys=null, scores=null}区别二者区别@ConfigurationProperties@Value功能批量注入配置文件中的属性一个个指定松散绑定(松散语法)支持不支持SpEL不支持支持JSR303数据校验支持不支持复杂类型封装支持不支持从上表可以看见,@ConfigurationProperties 和 @Value 主要有 5 个不同,其中第一个功能上的不同,上面已经演示过。下面我来介绍下剩下的 4 个不同。松散语法松散语法的意思就是一个属性在配置文件中可以有多个属性名,举个栗子:学生类当中的 firstName 属性,在配置文件中可以叫 firstName、first-name、first_name 以及 FIRST_NAME。 而 @ConfigurationProperties 是支持这种命名的,@Value 不支持。下面以 firstName 为例,测试一下。如下代码:@ConfigurationProperties学生类的 firstName 属性在 yml 文件中被定义为 first_name:student: first_name: 陈 # 学生类的 firstName 属性在 yml 文件中被定义为 first_name lastName: 一个优秀的废人 age: 24 gender: 男 city: 广州 teacher: {name: eses,age: 24,gender: 女} hobbys: [篮球,羽毛球,兵兵球] scores: {java: 100,Python: 99,C++: 99}学生类:@Component @ConfigurationProperties(prefix = "student") // 指定配置文件中的 student 属性与这个 bean绑定 public class Student { /** * <bean class="Student"> * <property name="lastName" value="字面量/${key}从环境变量、配置文件中获取值/#{SpEL}"></property> * <bean/> */ //@Value("陈") // 字面量 private String firstName; //@Value("${student.lastName}") // 从环境变量、配置文件中获取值 private String lastName; //@Value("#{12*2}") // #{SpEL} private Integer age; private String gender; private String city; private Teacher teacher; private List<String> hobbys; private Map<String,Integer> scores; //注意,为了测试必须重写 toString 和 get,set 方法 }测试结果:Student{firstName='陈', lastName='一个优秀的废人', age=24, gender='男', city='广州', teacher=Teacher{name='eses', age=24, gender='女'}, hobbys=[篮球, 羽毛球, 兵兵球], scores={java=100, Python=99, C=99}}@Value学生类:@Component //@ConfigurationProperties(prefix = "student") // 指定配置文件中的 student 属性与这个 bean绑定 public class Student { /** * <bean class="Student"> * <property name="lastName" value="字面量/${key}从环境变量、配置文件中获取值/#{SpEL}"></property> * <bean/> */ //@Value("陈") // 字面量 @Value("${student.firstName}") private String firstName; //@Value("${student.lastName}") // 从环境变量、配置文件中获取值 private String lastName; //@Value("#{12*2}") // #{SpEL} private Integer age; private String gender; private String city; private Teacher teacher; private List<String> hobbys; private Map<String,Integer> scores; //注意,为了测试必须重写 toString 和 get,set 方法 }测试结果:启动报错,找不到 bean。从上面两个测试结果可以看出,使用 @ConfigurationProperties 注解时,yml 中的属性名为 last_name 而学生类中的属性为 lastName 但依然能取到值,而使用 @value 时,使用 lastName 确报错了。证明 @ConfigurationProperties 支持松散语法,@value 不支持。SpELSpEL 使用 #{…} 作为定界符 , 所有在大括号中的字符都将被认为是 SpEL , SpEL 为 bean 的属性进行动态赋值提供了便利。@Value如上述介绍 @Value 注解使用方法时,有这样一段代码:@Value("#{12*2}") // #{SpEL} private Integer age;证明 @Value 是支持 SpEL 表达式的。@ConfigurationProperties由于 yml 中的 # 被当成注释看不到效果。所以我们新建一个 application.properties 文件。把 yml 文件内容注释,我们在 properties 文件中把 age 属性写成如下所示:student.age=#{12*2}把学生类中的 @ConfigurationProperties 注释打开,注释 @value 注解。运行报错, age 属性匹配异常。说明 @ConfigurationProperties 不支持 SpELJSR303 数据校验@Value加入 @Length 校验:@Component @Validated //@ConfigurationProperties(prefix = "student") // 指定配置文件中的 student 属性与这个 bean绑定 public class Student { /** * <bean class="Student"> * <property name="lastName" value="字面量/${key}从环境变量、配置文件中获取值/#{SpEL}"></property> * <bean/> */ //@Value("陈") // 字面量 @Value("${student.first-name}") @Length(min=5, max=20, message="用户名长度必须在5-20之间") private String firstName; //@Value("${student.lastName}") // 从环境变量、配置文件中获取值 private String lastName; //@Value("#{12*2}") // #{SpEL} private Integer age; private String gender; private String city; private Teacher teacher; private List<String> hobbys; private Map<String,Integer> scores; }yaml:student: first_name: 陈测试结果:Student{firstName='陈', lastName='null', age=null, gender='null', city='null', teacher=null, hobbys=null, scores=null}yaml 中的 firstname 长度为 1 。而检验规则规定 5-20 依然能取到属性,说明检验不生效,@Value 不支持 JSR303 数据校验@ConfigurationProperties学生类:@Component @Validated @ConfigurationProperties(prefix = "student") // 指定配置文件中的 student 属性与这个 bean绑定 public class Student { /** * <bean class="Student"> * <property name="lastName" value="字面量/${key}从环境变量、配置文件中获取值/#{SpEL}"></property> * <bean/> */ //@Value("陈") // 字面量 //@Value("${student.first-name}") @Length(min=5, max=20, message="用户名长度必须在5-20之间") private String firstName; //@Value("${student.lastName}") // 从环境变量、配置文件中获取值 private String lastName; //@Value("#{12*2}") // #{SpEL} private Integer age; private String gender; private String city; private Teacher teacher; private List<String> hobbys; private Map<String,Integer> scores; }测试结果:报错[firstName],20,5]; default message [用户名长度必须在5-20之间]校验生效,支持 JSR303 数据校验。复杂类型封装复杂类型封装指的是,在对象以及 map (如学生类中的老师类以及 scores map)等属性中,用 @Value 取是取不到值,比如:@Component //@Validated //@ConfigurationProperties(prefix = "student") // 指定配置文件中的 student 属性与这个 bean绑定 public class Student { /** * <bean class="Student"> * <property name="lastName" value="字面量/${key}从环境变量、配置文件中获取值/#{SpEL}"></property> * <bean/> */ //@Value("陈") // 字面量 //@Value("${student.first-name}") //@Length(min=5, max=20, message="用户名长度必须在5-20之间") private String firstName; //@Value("${student.lastName}") // 从环境变量、配置文件中获取值 private String lastName; //@Value("#{12*2}") // #{SpEL} private Integer age; private String gender; private String city; @Value("${student.teacher}") private Teacher teacher; private List<String> hobbys; @Value("${student.scores}") private Map<String,Integer> scores; }这样取是报错的。而上文介绍 @ConfigurationProperties 和 @Value 的使用方法时已经证实 @ConfigurationProperties 是支持复杂类型封装的。也就是说 yaml 中直接定义 teacher 以及 scores 。 @ConfigurationProperties 依然能取到值。怎么选用?1. 如果说,只是在某个业务逻辑中需要获取一下配置文件中的某项值,使用 @Value;比如,假设现在学生类加多一个属性叫 school 那这个属性对于该校所有学生来说都是一样的,但防止我这套系统到了别的学校就用不了了。那我们可以直接在 yml 中给定 school 属性,用 @Value 获取。当然上述只是举个粗暴的例子,实际开发时,school 属性应该是保存在数据库中的。2. 如果说,专门编写了一个 javaBean 来和配置文件进行映射,我们就直接使用 @ConfigurationProperties。完整代码https://github.com/turoDog/Demo/tree/master/springboot_val_conpro_demo如果觉得对你有帮助,请给个 Star 再走呗,非常感谢。后语如果本文对你哪怕有一丁点帮助,请帮忙点好看。你的好看是我坚持写作的动力。
发现问题在一次项目实践中有实现多级缓存其中有已经包括了 Shiro 的 Cache ,本以为开启 redis 的缓存是一件很简单的事情只需要在启动类上加上 @EnableCaching 注解就会启动缓存管理了,但是问题出现了。重要错误日志截图java.lang.IllegalStateException: @Bean method ShiroConfig.cacheManager called as a bean reference for type [org.apache.shiro.cache.ehcache.EhCacheManager] but overridden by non-compatible bean instance of type [org.springframework.data.redis.cache.RedisCacheManager]. Overriding bean of same name declared in: class path resource [org/springframework/boot/autoconfigure/cache/RedisCacheConfiguration.class]错误日志分析看日志大概就发现一个非法状态异常,我们继续查看接下来的日志有一段非常的重要日志 Overriding bean of same name 翻译过来的意思是帮你重写了一个名字一样的 Bean,我再看看日志里有提到 RedisCacheManager 与我自己实现的 cacheManager 到这里我已经感觉到问题所在了,以下图为 RedisCacheManager 部分实现代码。和我自己的 Shiro 的 cacheManager 实现方法。解决问题有 Spring 基础的大家都应该还记得 Spring 不允许有相同的 Bean 出现。现在问题就在于 Redis 缓存管理器和 Shiro 的缓存管理器重名了,而这二者又是通过 Spring 管理,所以 Spring 读取这二者的时候,产生冲突了。解决问题的方法很简单:在自己实现 EhCacheManager 时把 @Bean 指定一个名字可以像这样 @Bean(name ="ehCacheManager" ),还有其他办法大家可以在想办法实现一下嘿嘿。结语虽然我们都知道 Spring 的报错是非常多的,但是在 Spring 的报错日志中查找问题所在是非常有用的,大部分的错误,日志都会给你反馈。如果本文对你哪怕有一丁点帮助,请帮忙点好看。你的好看是我坚持写作的动力。
MongoDB 简介MongoDB 是由 C++ 编写的非关系型数据库,是一个基于分布式文件存储的开源数据库系统,它将数据存储为一个文档,数据结构由键值 (key=>value) 对组成。MongoDB 文档类似于 JSON 对象。字段值可以包含其他文档,数组及文档数组,非常灵活。存储结构如下:{ "studentId": "201311611405", "age":24, "gender":"男", "name":"一个优秀的废人" }准备工作SpringBoot 2.1.3 RELEASEMongnDB 2.1.3 RELEASEMongoDB 4.0IDEAJDK8创建一个名为 test 的数据库,不会建的。参考菜鸟教程:http://www.runoob.com/mongodb/mongodb-tutorial.html配置数据源spring: data: mongodb: uri: mongodb://localhost:27017/test以上是无密码写法,如果 MongoDB 设置了密码应这样设置:spring: data: mongodb: uri: mongodb://name:password@localhost:27017/testpom 依赖配置<!-- mongodb 依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-mongodb</artifactId> </dependency> <!-- web 依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- lombok 依赖 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- test 依赖(没用到) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>实体类@Data public class Student { @Id private String id; @NotNull private String studentId; private Integer age; private String name; private String gender; }dao 层和 JPA 一样,SpringBoot 同样为开发者准备了一套 Repository ,只需要继承 MongoRepository 传入实体类型以及主键类型即可。@Repository public interface StudentRepository extends MongoRepository<Student, String> { }service 层public interface StudentService { Student addStudent(Student student); void deleteStudent(String id); Student updateStudent(Student student); Student findStudentById(String id); List<Student> findAllStudent(); }实现类:@Service public class StudentServiceImpl implements StudentService { @Autowired private StudentRepository studentRepository; /** * 添加学生信息 * @param student * @return */ @Override @Transactional(rollbackFor = Exception.class) public Student addStudent(Student student) { return studentRepository.save(student); } /** * 根据 id 删除学生信息 * @param id */ @Override public void deleteStudent(String id) { studentRepository.deleteById(id); } /** * 更新学生信息 * @param student * @return */ @Override @Transactional(rollbackFor = Exception.class) public Student updateStudent(Student student) { Student oldStudent = this.findStudentById(student.getId()); if (oldStudent != null){ oldStudent.setStudentId(student.getStudentId()); oldStudent.setAge(student.getAge()); oldStudent.setName(student.getName()); oldStudent.setGender(student.getGender()); return studentRepository.save(oldStudent); } else { return null; } } /** * 根据 id 查询学生信息 * @param id * @return */ @Override public Student findStudentById(String id) { return studentRepository.findById(id).get(); } /** * 查询学生信息列表 * @return */ @Override public List<Student> findAllStudent() { return studentRepository.findAll(); } }controller 层@RestController @RequestMapping("/student") public class StudentController { @Autowired private StudentService studentService; @PostMapping("/add") public Student addStudent(@RequestBody Student student){ return studentService.addStudent(student); } @PutMapping("/update") public Student updateStudent(@RequestBody Student student){ return studentService.updateStudent(student); } @GetMapping("/{id}") public Student findStudentById(@PathVariable("id") String id){ return studentService.findStudentById(id); } @DeleteMapping("/{id}") public void deleteStudentById(@PathVariable("id") String id){ studentService.deleteStudent(id); } @GetMapping("/list") public List<Student> findAllStudent(){ return studentService.findAllStudent(); } }测试结果Postman 测试已经全部通过,这里仅展示了保存操作。这里推荐一个数据库可视化工具 Robo 3T 。下载地址:https://robomongo.org/download完整代码https://github.com/turoDog/Demo/tree/master/springboot_mongodb_demo如果觉得对你有帮助,请给个 Star 再走呗,非常感谢。后语如果本文对你哪怕有一丁点帮助,请帮忙点好看。你的好看是我坚持写作的动力。
准备工作Spring Boot 2.1.3 RELEASESpring Security 2.1.3 RELEASEIDEAJDK8pom 依赖因聊天室涉及到用户相关,所以在上一篇基础上引入 Spring Security 2.1.3 RELEASE 依赖<!-- Spring Security 依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>Spring Security 的配置虽说涉及到 Spring Security ,但鉴于篇幅有限,这里只对这个项目相关的部分进行介绍,具体的 Spring Security 教程,后面会出。这里的 Spring Security 配置很简单,具体就是设置登录路径、设置安全资源以及在内存中创建用户和密码,密码需要注意加密,这里使用 BCrypt 加密算法在用户登录时对密码进行加密。 代码注释很详细,不多说。package com.nasus.websocket.config; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @Configuration // 开启Spring Security的功能 @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter{ @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() // 设置 SpringSecurity 对 / 和 "/login" 路径不拦截 .mvcMatchers("/","/login").permitAll() .anyRequest().authenticated() .and() .formLogin() // 设置 Spring Security 的登录页面访问路径为/login .loginPage("/login") // 登录成功后转向 /chat 路径 .defaultSuccessUrl("/chat") .permitAll() .and() .logout() .permitAll(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() // 在内存中分配两个用户 nasus 和 chenzy ,用户名和密码一致 // BCryptPasswordEncoder() 是 Spring security 5.0 中新增的加密方式 // 登陆时用 BCrypt 加密方式对用户密码进行处理。 .passwordEncoder(new BCryptPasswordEncoder()) .withUser("nasus") // 保证用户登录时使用 bcrypt 对密码进行处理再与内存中的密码比对 .password(new BCryptPasswordEncoder().encode("nasus")).roles("USER") .and() // 登陆时用 BCrypt 加密方式对用户密码进行处理。 .passwordEncoder(new BCryptPasswordEncoder()) .withUser("chenzy") // 保证用户登录时使用 bcrypt 对密码进行处理再与内存中的密码比对 .password(new BCryptPasswordEncoder().encode("chenzy")).roles("USER"); } @Override public void configure(WebSecurity web) throws Exception { // /resource/static 目录下的静态资源,Spring Security 不拦截 web.ignoring().antMatchers("/resource/static**"); } }WebSocket 的配置在上一篇的基础上另外注册一个名为 "/endpointChat" 的节点,以供用户订阅,只有订阅了该节点的用户才能接收到消息;然后,再增加一个名为 "/queue" 消息代理。@Configuration // @EnableWebSocketMessageBroker 注解用于开启使用 STOMP 协议来传输基于代理(MessageBroker)的消息,这时候控制器(controller) // 开始支持@MessageMapping,就像是使用 @requestMapping 一样。 @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { //注册一个名为 /endpointNasus 的 Stomp 节点(endpoint),并指定使用 SockJS 协议。 registry.addEndpoint("/endpointNasus").withSockJS(); //注册一个名为 /endpointChat 的 Stomp 节点(endpoint),并指定使用 SockJS 协议。 registry.addEndpoint("/endpointChat").withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 广播式配置名为 /nasus 消息代理 , 这个消息代理必须和 controller 中的 @SendTo 配置的地址前缀一样或者全匹配 // 点对点增加一个 /queue 消息代理 registry.enableSimpleBroker("/queue","/nasus/getResponse"); } }控制器 controller指定发送消息的格式以及模板。详情见,代码注释。@Autowired //使用 SimpMessagingTemplate 向浏览器发送信息 private SimpMessagingTemplate messagingTemplate; @MessageMapping("/chat") public void handleChat(Principal principal,String msg){ // 在 SpringMVC 中,可以直接在参数中获得 principal,principal 中包含当前用户信息 if (principal.getName().equals("nasus")){ // 硬编码,如果发送人是 nasus 则接收人是 chenzy 反之也成立。 // 通过 messageingTemplate.convertAndSendToUser 方法向用户发送信息,参数一是接收消息用户,参数二是浏览器订阅地址,参数三是消息本身 messagingTemplate.convertAndSendToUser("chenzy", "/queue/notifications",principal.getName()+"-send:" + msg); } else { messagingTemplate.convertAndSendToUser("nasus", "/queue/notifications",principal.getName()+"-send:" + msg); } }登录页面<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3"> <meta charset="UTF-8" /> <head> <title>登陆页面</title> </head> <body> <div th:if="${param.error}"> 无效的账号和密码 </div> <div th:if="${param.logout}"> 你已注销 </div> <form th:action="@{/login}" method="post"> <div><label> 账号 : <input type="text" name="username"/> </label></div> <div><label> 密码: <input type="password" name="password"/> </label></div> <div><input type="submit" value="登陆"/></div> </form> </body> </html>聊天页面<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <meta charset="UTF-8" /> <head> <title>Home</title> <script th:src="@{sockjs.min.js}"></script> <script th:src="@{stomp.min.js}"></script> <script th:src="@{jquery.js}"></script> </head> <body> <p> 聊天室 </p> <form id="nasusForm"> <textarea rows="4" cols="60" name="text"></textarea> <input type="submit"/> </form> <script th:inline="javascript"> $('#nasusForm').submit(function(e){ e.preventDefault(); var text = $('#nasusForm').find('textarea[name="text"]').val(); sendSpittle(text); }); // 连接 SockJs 的 endpoint 名称为 "/endpointChat" var sock = new SockJS("/endpointChat"); var stomp = Stomp.over(sock); stomp.connect('guest', 'guest', function(frame) { // 订阅 /user/queue/notifications 发送的消息,这里与在控制器的 // messagingTemplate.convertAndSendToUser 中订阅的地址保持一致 // 这里多了 /user 前缀,是必须的,使用了 /user 才会把消息发送到指定用户 stomp.subscribe("/user/queue/notifications", handleNotification); }); function handleNotification(message) { $('#output').append("<b>Received: " + message.body + "</b><br/>") } function sendSpittle(text) { stomp.send("/chat", {}, text); } $('#stop').click(function() {sock.close()}); </script> <div id="output"></div> </body> </html>页面控制器 controller@Controller public class ViewController { @GetMapping("/nasus") public String getView(){ return "nasus"; } @GetMapping("/login") public String getLoginView(){ return "login"; } @GetMapping("/chat") public String getChatView(){ return "chat"; } }测试预期结果应该是:两个用户登录系统,可以互相发送消息。但是同一个浏览器的用户会话的 session 是共享的,这里需要在 Chrome 浏览器再添加一个用户。具体操作在 Chrome 的 设置-->管理用户-->添加用户:两个用户分别访问 http://localhost:8080/login 登录系统,跳转至聊天界面:相互发送消息:完整代码https://github.com/turoDog/Demo/tree/master/springboot_websocket_demo如果觉得对你有帮助,请给个 Star 再走呗,非常感谢。后语 如果本文对你哪怕有一丁点帮助,请帮忙点好看。你的好看是我坚持写作 的动力。
什么是 WebSocket ?WebSocket 为浏览器和服务器提供了双工异步通信的功能,即浏览器可以向服务器发送信息,反之也成立。WebSocket 是通过一个 socket 来实现双工异步通信能力的,但直接使用 WebSocket ( 或者 SockJS:WebSocket 协议的模拟,增加了当前浏览器不支持使用 WebSocket 的兼容支持) 协议开发程序显得十分繁琐,所以使用它的子协议 STOMP。STOMP 协议简介它是高级的流文本定向消息协议,是一种为 MOM (Message Oriented Middleware,面向消息的中间件) 设计的简单文本协议。它提供了一个可互操作的连接格式,允许 STOMP 客户端与任意 STOMP 消息代理 (Broker) 进行交互,类似于 OpenWire (一种二进制协议)。由于其设计简单,很容易开发客户端,因此在多种语言和多种平台上得到广泛应用。其中最流行的 STOMP 消息代理是 Apache ActiveMQ。STOMP 协议使用一个基于 (frame) 的格式来定义消息,与 Http 的 request 和 response 类似 。广播接下来,实现一个广播消息的 demo。即服务端有消息时,将消息发送给所有连接了当前 endpoint 的浏览器。准备工作SpringBoot 2.1.3IDEAJDK8Pom 依赖配置<dependencies> <!-- thymeleaf 模板引擎 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- web 启动类 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- WebSocket 依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <!-- test 单元测试 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>代码注释很详细,不多说。配置 WebSocket实现 WebSocketMessageBrokerConfigurer 接口,注册一个 STOMP 节点,配置一个广播消息代理@Configuration // @EnableWebSocketMessageBroker注解用于开启使用STOMP协议来传输基于代理(MessageBroker)的消息,这时候控制器(controller) // 开始支持@MessageMapping,就像是使用@requestMapping一样。 @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { //注册一个 Stomp 的节点(endpoint),并指定使用 SockJS 协议。 registry.addEndpoint("/endpointNasus").withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 广播式配置名为 /nasus 消息代理 , 这个消息代理必须和 controller 中的 @SendTo 配置的地址前缀一样或者全匹配 registry.enableSimpleBroker("/nasus"); } }消息类客户端发送给服务器:public class Client2ServerMessage { private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } }服务器发送给客户端:public class Server2ClientMessage { private String responseMessage; public Server2ClientMessage(String responseMessage) { this.responseMessage = responseMessage; } public String getResponseMessage() { return responseMessage; } public void setResponseMessage(String responseMessage) { this.responseMessage = responseMessage; } }演示控制器代码@RestController public class WebSocketController { @MessageMapping("/hello") // @MessageMapping 和 @RequestMapping 功能类似,浏览器向服务器发起消息,映射到该地址。 @SendTo("/nasus/getResponse") // 如果服务器接受到了消息,就会对订阅了 @SendTo 括号中的地址的浏览器发送消息。 public Server2ClientMessage say(Client2ServerMessage message) throws Exception { Thread.sleep(3000); return new Server2ClientMessage("Hello," + message.getName() + "!"); } }引入 STOMP 脚本将 stomp.min.js (STOMP 客户端脚本) 和 sockJS.min.js (sockJS 客户端脚本) 以及 Jquery 放在 resource 文件夹的 static 目录下。演示页面<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8" /> <title>Spring Boot+WebSocket+广播式</title> </head> <body onload="disconnect()"> <noscript><h2 style="color: #ff0000">貌似你的浏览器不支持websocket</h2></noscript> <div> <div> <button id="connect" onclick="connect();">连接</button> <button id="disconnect" disabled="disabled" onclick="disconnect();">断开连接</button> </div> <div id="conversationDiv"> <label>输入你的名字</label><input type="text" id="name" /> <button id="sendName" onclick="sendName();">发送</button> <p id="response"></p> </div> </div> <script th:src="@{sockjs.min.js}"></script> <script th:src="@{stomp.min.js}"></script> <script th:src="@{jquery.js}"></script> <script type="text/javascript"> var stompClient = null; function setConnected(connected) { document.getElementById('connect').disabled = connected; document.getElementById('disconnect').disabled = !connected; document.getElementById('conversationDiv').style.visibility = connected ? 'visible' : 'hidden'; $('#response').html(); } function connect() { // 连接 SockJs 的 endpoint 名称为 "/endpointNasus" var socket = new SockJS('/endpointNasus'); // 使用 STOMP 子协议的 WebSocket 客户端 stompClient = Stomp.over(socket); stompClient.connect({}, function(frame) { setConnected(true); console.log('Connected: ' + frame); // 通过 stompClient.subscribe 订阅 /nasus/getResponse 目标发送的信息,对应控制器的 SendTo 定义 stompClient.subscribe('/nasus/getResponse', function(respnose){ // 展示返回的信息,只要订阅了 /nasus/getResponse 目标,都可以接收到服务端返回的信息 showResponse(JSON.parse(respnose.body).responseMessage); }); }); } function disconnect() { // 断开连接 if (stompClient != null) { stompClient.disconnect(); } setConnected(false); console.log("Disconnected"); } function sendName() { // 向服务端发送消息 var name = $('#name').val(); // 通过 stompClient.send 向 /hello (服务端)发送信息,对应控制器 @MessageMapping 中的定义 stompClient.send("/hello", {}, JSON.stringify({ 'name': name })); } function showResponse(message) { // 接收返回的消息 var response = $("#response"); response.html(message); } </script> </body> </html>页面 Controller注意,这里使用的是 @Controller 注解,用于匹配 html 前缀,加载页面。@Controller public class ViewController { @GetMapping("/nasus") public String getView(){ return "nasus"; } }测试结果打开三个窗口访问 http://localhost:8080/nasus ,初始页面长这样:三个页面全部点连接,点击连接订阅 endpoint ,如下图:在第一个页面,输入名字,点发送 ,如下图:在第一个页面发送消息,等待 3 秒,结果是 3 个页面都接受到了服务端返回的信息,广播成功。源码下载:https://github.com/turoDog/Demo/tree/master/springboot_websocket_demo如果觉得对你有帮助,请给个 Star 再走呗,非常感谢。后语如果本文对你哪怕有一丁点帮助,请帮忙点好看。你的好看是我坚持写作的动力。
老项目的服务端校验@RestController @RequestMapping("/student") public class ValidateOneController { @GetMapping("/id") public Student findStudentById(Integer id){ if(id == null){ logger.error("id 不能为空!"); throw new NullPointerException("id 不能为空"); } return studentService.findStudentById(id); } }看以上代码,就一个的校验就如此麻烦。那我们是否有好的统一校验方法呢?鉴于 SpringBoot 无所不能。答案当然是有的。其中,Bean Validator 和 Hibernate Validator 就是两套用于验证的框架,二者都遵循 JSR-303 ,可以混着用,鉴于二者的某些 Validator 注解有差别,例如 @Length 在 Bean Validator 中是没有的,所以这里我选择混合用。JSR-303JSR-303 是JAVA EE 6 中的一项子规范,叫做 Bean Validation,Hibernate Validator 是 Bean Validation 的参考实现, Hibernate Validator 提供了 JSR 303 规范中所有内置 Constraint(约束) 的实现,除此之外还有一些附加的 Constraint 。这些 Constraint (约束) 全都通过注解的方式实现,请看下面两个表。Bean Validation 中内置的约束:注解作用@Null被注解参数必须为空@NotNull被注解参数不能为空@AssertTrue被注解参数必须为 True@AssertFalse被注解参数必须为 False@Min(value)被注解参数必须是数字,且其值必须大于等于 value@Max(value)被注解参数必须是数字,且其值必须小于等于 value@DecimaMin(value)被注解参数必须是数字,且其值必须大于等于 value@DecimaMax(value)被注解参数必须是数字,且其值必须小于等于 value@Size(max, min)被注解参数大小必须在指定范围内@Past被注解参数必须是一个过去的日期@Future被注解参数必须是一个将来的日期@Pattern(value)被注解参数必须符合指定的正则表达式@Digits(integer, fraction)被注解参数必须是数字,且其值必须在可接受范围内@NotBlank被注解参数的值不为空(不为 null、去除首位空格后长度为 0),不同于 @NotEmpty,@NotBlank 只应用于字符串且在比较时会去除字符串的空格Hibernate Validator 附加的约束:注解作用@NotEmpty被注解参数的值不为 null 且不为空(字符串长度不为0、集合大小不为0)@Email被注解参数必须是电子邮箱地址@Length被注解的字符串长度必须在指定范围内@Range被注解的参数必须在指定范围内准备工作SpringBoot 2.1.3IDEAJDK8Pom 文件依赖<!-- web 启动类 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- test 单元测试类 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!-- lombok 依赖用于简化 bean --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>实体类用于测试,加入了参数校验规则。@Data @AllArgsConstructor @NoArgsConstructor public class Student { private Integer id; @NotBlank(message = "学生名字不能为空") @Length(min = 2, max = 10, message = "name 长度必须在 {min} - {max} 之间") private String name; @NotNull(message = "年龄不允许为空") @Min(value = 0, message = "年龄不能低于 {value} 岁") private Integer age; }Controller 层写了两个方法,一个用于校验普通参数,一个用于校验对象@Validated //开启数据校验,添加在类上用于校验方法,添加在方法参数中用于校验参数对象。(添加在方法上无效) @RestController @RequestMapping("/student") public class ValidateOneController { /** * 普通参数校验 * @param name * @return */ @GetMapping("/name") public String findStudentByName(@NotBlank(message = "学生名字不能为空") @Length(min = 2, max = 10, message = "name 长度必须在 {min} - {max} 之间")String name){ return "success"; } /** * 对象校验 * @param student * @return */ @PostMapping("/add") public String addStudent(@Validated @RequestBody Student student){ return "success"; } }Postman 测试校验普通参数测试结果:下图可以看见,我没有在 http://localhost:8080/student/name 地址后添加 name 参数,传到后台马上就校验出异常了。而这个异常信息就是我定义的校验异常信息。校验对象测试结果:结果有点长:下图可以看见,我访问 http://localhost:8080/student/add 传入了参数对象,但对象是不能通过校验规则的,比如 age 参数为负数,name 参数长度太大,传到后台马上就校验出异常了。而这个异常信息就是我定义的校验异常信息。完整代码https://github.com/turoDog/Demo/tree/master/springboot_validateone_demo如果觉得对你有帮助,请给个 Star 再走呗,非常感谢。后语如果本文对你哪怕有一丁点帮助,请帮忙点好看。你的好看是我坚持写作的动力。
准备工作SpringBoot 2.1.3IDEAJDK 8依赖配置<dependencies> <!-- JPA 依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- web 依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- mysql 连接类 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- lombok 依赖 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- 单元测试依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>配置文件spring: # 数据库相关 datasource: driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&useSSL=true username: root password: 123456 jpa: hibernate: ddl-auto: update #ddl-auto:设为 create 表示每次都重新建表 show-sql: true返回的消息类public class Message<T> implements Serializable { /** * 状态码 */ private Integer code; /** * 返回信息 */ private String message; /** * 返回的数据类 */ private T data; /** * 时间 */ private Long time; // getter、setter 以及 构造方法略。。。 }工具类用于处理返回的数据以及信息类,代码注释很详细不说了。public class MessageUtil { /** * 成功并返回数据实体类 * @param o * @param <E> * @return */ public static <E>Message<E> ok(E o){ return new Message<>(200, "success", o, new Date().getTime()); } /** * 成功,但无数据实体类返回 * @return */ public static <E>Message<E> ok(){ return new Message<>(200, "success", null, new Date().getTime()); } /** * 失败,有自定义异常返回 * @param code * @param msg * @return */ public static <E>Message<E> error(Integer code,String msg){ return new Message<>(code, msg, null, new Date().getTime()); } }自定义异常通过继承 RuntimeException ,声明 code 用于定义不同类型的自定义异常。主要是用于异常拦截出获取 code 并将 code 设置到消息类中返回。public class CustomException extends RuntimeException{ /** * 状态码 */ private Integer code; public Integer getCode() { return code; } public void setCode(Integer code) { this.code = code; } public CustomException(Integer code, String message){ super(message); this.code = code; } }异常拦截类通过加入 @RestControllerAdvice 来声明该类可拦截 Controller 请求,同时在 handle方法加入 @ExceptionHandler 并在该注解中指定要拦截的异常类。@RestControllerAdvice // 控制器增强处理(返回 JSON 格式数据),添加了这个注解的类能被 classpath 扫描自动发现 public class ExceptionHandle { @ExceptionHandler(value = Exception.class) // 捕获 Controller 中抛出的指定类型的异常,也可以指定其他异常 public <E>Message<E> handler(Exception exception){ if (exception instanceof CustomException){ CustomException customException = (CustomException) exception; return MessageUtil.error(customException.getCode(), customException.getMessage()); } else { return MessageUtil.error(120, "异常信息:" + exception.getMessage()); } } }这里只对自定义异常以及未知异常进行处理,如果你在某方法中明确知道可能会抛出某个异常,可以加多一个特定的处理。比如说你明确知道该方法可能抛出 NullPointException 可以追加 NullPointException 的处理:if (exception instanceof CustomException){ CustomException customException = (CustomException) exception; return MessageUtil.error(customException.getCode(), customException.getMessage()); } else if (exception instanceof NullPointException ){ return MessageUtil.error(500, "空指针异常信!"); } else { return MessageUtil.error(120, "异常信息:" + exception.getMessage()); }controller 层@RestController @RequestMapping("/student") public class StudentController { @Autowired private StudentService studentService; @GetMapping("/{id}") public Message<Student> findStudentById(@PathVariable("id") Integer id){ if (id < 0){ //测试自定义错误 throw new CustomException(110, "参数不能是负数!"); } else if (id == 0){ //硬编码,为了测试 Integer i = 1/id; return null; } else { Student student = studentService.findStudentById(id); return MessageUtil.ok(student); } } }完整代码https://github.com/turoDog/Demo/tree/master/springboot_exception_demo如果觉得对你有帮助,请给个 Star 再走呗,非常感谢。Postman 测试访问 http://localhost:8080/student/5 测试正常返回数据结果。访问 http://localhost:8080/student/0 测试未知异常的结果。访问 http://localhost:8080/student/-11 测试自定义异常的结果。后语写完看了下表,已是凌晨一点。 如果本文对你哪怕有一丁点帮助,请帮忙点好看。你的好看是我坚持写作的动力。
准备工作SpringBoot 2.1.3IDEAJDK 8创建表CREATE TABLE `student` ( `id` int(32) NOT NULL AUTO_INCREMENT, `student_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '学号', `name` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '姓名', `age` int(11) NULL DEFAULT NULL COMMENT '年龄', `city` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '所在城市', `dormitory` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '宿舍', `major` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '专业', PRIMARY KEY (`id`) USING BTREE )ENGINE=INNODB AUTO_INCREMENT=1000 DEFAULT CHARSET=utf8;引入依赖<dependencies> <!-- jdbc 连接驱动 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <!-- web 启动类 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- mybatis 依赖 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.0.0</version> </dependency> <!-- druid 数据库连接池 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.1.14</version> </dependency> <!-- Mysql 连接类 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.47</version> <scope>runtime</scope> </dependency> <!-- 分页插件 --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.2.5</version> </dependency> <!-- test 依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <!-- springboot maven 插件 --> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> <!-- mybatis generator 自动生成代码插件 --> <plugin> <groupId>org.mybatis.generator</groupId> <artifactId>mybatis-generator-maven-plugin</artifactId> <version>1.3.2</version> <configuration> <configurationFile>${basedir}/src/main/resources/generator/generatorConfig.xml</configurationFile> <overwrite>true</overwrite> <verbose>true</verbose> </configuration> </plugin> </plugins> </build>代码解释很详细了,但这里提一嘴,mybatis generator 插件用于自动生成代码,pagehelper 插件用于物理分页。项目配置server: port: 8080 spring: datasource: name: test url: jdbc:mysql://127.0.0.1:3306/test username: root password: 123456 #druid相关配置 type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.jdbc.Driver filters: stat maxActive: 20 initialSize: 1 maxWait: 60000 minIdle: 1 timeBetweenEvictionRunsMillis: 60000 minEvictableIdleTimeMillis: 300000 validationQuery: select 'x' testWhileIdle: true testOnBorrow: false testOnReturn: false poolPreparedStatements: true maxOpenPreparedStatements: 20 ## 该配置节点为独立的节点,有很多同学容易将这个配置放在spring的节点下,导致配置无法被识别 mybatis: mapper-locations: classpath:mapping/*.xml #注意:一定要对应mapper映射xml文件的所在路径 type-aliases-package: com.nasus.mybatisxml.model # 注意:对应实体类的路径 #pagehelper分页插件 pagehelper: helperDialect: mysql reasonable: true supportMethodsArguments: true params: count=countSqlmybatis generator 配置文件这里要注意,配置 pom.xml 中 generator 插件所对应的配置文件时,在 Pom.xml 加入这一句,说明 generator 插件所对应的配置文件所对应的配置文件路径。这里已经在 Pom 中配置了,请见上面的 Pom 配置。${basedir}/src/main/resources/generator/generatorConfig.xmlgeneratorConfig.xml :<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE generatorConfiguration PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN" "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd"> <generatorConfiguration> <!-- 数据库驱动:选择你的本地硬盘上面的数据库驱动包--> <classPathEntry location="D:\repository\mysql\mysql-connector-java\5.1.47\mysql-connector-java-5.1.47.jar"/> <context id="DB2Tables" targetRuntime="MyBatis3"> <commentGenerator> <property name="suppressDate" value="true"/> <!-- 是否去除自动生成的注释 true:是 : false:否 --> <property name="suppressAllComments" value="true"/> </commentGenerator> <!--数据库链接URL,用户名、密码 --> <jdbcConnection driverClass="com.mysql.jdbc.Driver" connectionURL="jdbc:mysql://127.0.0.1/test" userId="root" password="123456"> </jdbcConnection> <javaTypeResolver> <property name="forceBigDecimals" value="false"/> </javaTypeResolver> <!-- 生成模型的包名和位置--> <javaModelGenerator targetPackage="com.nasus.mybatisxml.model" targetProject="src/main/java"> <property name="enableSubPackages" value="true"/> <property name="trimStrings" value="true"/> </javaModelGenerator> <!-- 生成映射文件的包名和位置--> <sqlMapGenerator targetPackage="mapping" targetProject="src/main/resources"> <property name="enableSubPackages" value="true"/> </sqlMapGenerator> <!-- 生成DAO的包名和位置--> <javaClientGenerator type="XMLMAPPER" targetPackage="com.nasus.mybatisxml.mapper" targetProject="src/main/java"> <property name="enableSubPackages" value="true"/> </javaClientGenerator> <!-- 要生成的表 tableName是数据库中的表名或视图名 domainObjectName是实体类名--> <table tableName="student" domainObjectName="Student" enableCountByExample="false" enableUpdateByExample="false" enableDeleteByExample="false" enableSelectByExample="false" selectByExampleQueryId="false"></table> </context> </generatorConfiguration>代码注释很详细,不多说。生成代码过程第一步:选择编辑配置第二步:选择添加 Maven 配置第三步:添加命令 mybatis-generator:generate -e 点击确定第四步:运行该配置,生成代码特别注意!!!同一张表一定不要运行多次,因为 mapper 的映射文件中会生成多次的代码,导致报错,切记。如要运行多次,请把上次生成的 mapper 映射文件代码删除再运行。第五步:检查生成结果遇到的问题请参照别人写好的遇到问题的解决方法,其中我就遇到数据库时区不对以及只生成 Insert 方法这两个问题。都是看以下这篇文章解决的:https://blog.csdn.net/exalgentle/article/details/80844294生成的代码1、实体类:Student.javapackage com.nasus.mybatisxml.model; public class Student { private Long id; private Integer age; private String city; private String dormitory; private String major; private String name; private Long studentId; // 省略 get 和 set 方法 }2、mapper 接口:StudentMapper.javapackage com.nasus.mybatisxml.mapper; import com.nasus.mybatisxml.model.Student; import java.util.List; import org.apache.ibatis.annotations.Mapper; @Mapper public interface StudentMapper { int deleteByPrimaryKey(Long id); int insert(Student record); int insertSelective(Student record); Student selectByPrimaryKey(Long id); // 我添加的方法,相应的要在映射文件中添加此方法 List<Student> selectStudents(); int updateByPrimaryKeySelective(Student record); int updateByPrimaryKey(Student record); }3、映射文件:StudentMapper.xml<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > <mapper namespace="com.nasus.mybatisxml.mapper.StudentMapper" > <resultMap id="BaseResultMap" type="com.nasus.mybatisxml.model.Student" > <id column="id" property="id" jdbcType="BIGINT" /> <result column="age" property="age" jdbcType="INTEGER" /> <result column="city" property="city" jdbcType="VARCHAR" /> <result column="dormitory" property="dormitory" jdbcType="VARCHAR" /> <result column="major" property="major" jdbcType="VARCHAR" /> <result column="name" property="name" jdbcType="VARCHAR" /> <result column="student_id" property="studentId" jdbcType="BIGINT" /> </resultMap> <sql id="Base_Column_List" > id, age, city, dormitory, major, name, student_id </sql> <select id="selectByPrimaryKey" resultMap="BaseResultMap" parameterType="java.lang.Long" > select <include refid="Base_Column_List" /> from student where id = #{id,jdbcType=BIGINT} </select> <delete id="deleteByPrimaryKey" parameterType="java.lang.Long" > delete from student where id = #{id,jdbcType=BIGINT} </delete> <insert id="insert" parameterType="com.nasus.mybatisxml.model.Student" > insert into student (id, age, city, dormitory, major, name, student_id) values (#{id,jdbcType=BIGINT}, #{age,jdbcType=INTEGER}, #{city,jdbcType=VARCHAR}, #{dormitory,jdbcType=VARCHAR}, #{major,jdbcType=VARCHAR}, #{name,jdbcType=VARCHAR}, #{studentId,jdbcType=BIGINT}) </insert> <insert id="insertSelective" parameterType="com.nasus.mybatisxml.model.Student" > insert into student <trim prefix="(" suffix=")" suffixOverrides="," > <if test="id != null" > id, </if> <if test="age != null" > age, </if> <if test="city != null" > city, </if> <if test="dormitory != null" > dormitory, </if> <if test="major != null" > major, </if> <if test="name != null" > name, </if> <if test="studentId != null" > student_id, </if> </trim> <trim prefix="values (" suffix=")" suffixOverrides="," > <if test="id != null" > #{id,jdbcType=BIGINT}, </if> <if test="age != null" > #{age,jdbcType=INTEGER}, </if> <if test="city != null" > #{city,jdbcType=VARCHAR}, </if> <if test="dormitory != null" > #{dormitory,jdbcType=VARCHAR}, </if> <if test="major != null" > #{major,jdbcType=VARCHAR}, </if> <if test="name != null" > #{name,jdbcType=VARCHAR}, </if> <if test="studentId != null" > #{studentId,jdbcType=BIGINT}, </if> </trim> </insert> <update id="updateByPrimaryKeySelective" parameterType="com.nasus.mybatisxml.model.Student" > update student <set > <if test="age != null" > age = #{age,jdbcType=INTEGER}, </if> <if test="city != null" > city = #{city,jdbcType=VARCHAR}, </if> <if test="dormitory != null" > dormitory = #{dormitory,jdbcType=VARCHAR}, </if> <if test="major != null" > major = #{major,jdbcType=VARCHAR}, </if> <if test="name != null" > name = #{name,jdbcType=VARCHAR}, </if> <if test="studentId != null" > student_id = #{studentId,jdbcType=BIGINT}, </if> </set> where id = #{id,jdbcType=BIGINT} </update> <update id="updateByPrimaryKey" parameterType="com.nasus.mybatisxml.model.Student" > update student set age = #{age,jdbcType=INTEGER}, city = #{city,jdbcType=VARCHAR}, dormitory = #{dormitory,jdbcType=VARCHAR}, major = #{major,jdbcType=VARCHAR}, name = #{name,jdbcType=VARCHAR}, student_id = #{studentId,jdbcType=BIGINT} where id = #{id,jdbcType=BIGINT} </update> <!-- 我添加的方法 --> <select id="selectStudents" resultMap="BaseResultMap"> SELECT <include refid="Base_Column_List" /> from student </select> </mapper>serviec 层1、接口:public interface StudentService { int addStudent(Student student); Student findStudentById(Long id); PageInfo<Student> findAllStudent(int pageNum, int pageSize); }2、实现类@Service public class StudentServiceImpl implements StudentService{ //会报错,不影响 @Resource private StudentMapper studentMapper; /** * 添加学生信息 * @param student * @return */ @Override public int addStudent(Student student) { return studentMapper.insert(student); } /** * 根据 id 查询学生信息 * @param id * @return */ @Override public Student findStudentById(Long id) { return studentMapper.selectByPrimaryKey(id); } /** * 查询所有学生信息并分页 * @param pageNum * @param pageSize * @return */ @Override public PageInfo<Student> findAllStudent(int pageNum, int pageSize) { //将参数传给这个方法就可以实现物理分页了,非常简单。 PageHelper.startPage(pageNum, pageSize); List<Student> studentList = studentMapper.selectStudents(); PageInfo result = new PageInfo(studentList); return result; } }controller 层@RestController @RequestMapping("/student") public class StudentController { @Autowired private StudentService studentService; @GetMapping("/{id}") public Student findStidentById(@PathVariable("id") Long id){ return studentService.findStudentById(id); } @PostMapping("/add") public int insertStudent(@RequestBody Student student){ return studentService.addStudent(student); } @GetMapping("/list") public PageInfo<Student> findStudentList(@RequestParam(name = "pageNum", required = false, defaultValue = "1") int pageNum, @RequestParam(name = "pageSize", required = false, defaultValue = "10") int pageSize){ return studentService.findAllStudent(pageNum,pageSize); } }启动类@SpringBootApplication @MapperScan("com.nasus.mybatisxml.mapper") // 扫描 mapper 接口,必须加上 public class MybatisxmlApplication { public static void main(String[] args) { SpringApplication.run(MybatisxmlApplication.class, args); } }提一嘴,@MapperScan("com.nasus.mybatisxml.mappe") 这个注解非常的关键,这个对应了项目中 mapper(dao) 所对应的包路径,必须加上,否则会导致异常。Postman 测试1、插入方法:2、根据 id 查询方法:3、分页查询方法:源码下载https://github.com/turoDog/Demo/tree/master/springboot_mybatisxml_demo帮忙点个 star 可好?后语如果本文对你哪怕有一丁点帮助,请帮忙点好看。你的好看是我坚持写作的动力。
以下为官方图片以及示例代码和注释 :首先参照官方文档创建指定数据库CREATE TABLE `demo_jpa` ( `id` int(11) NOT NULL AUTO_INCREMENT, `first_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `last_name` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, `sex` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, `email` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, `age` int(12) NOT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;示例代码及注释<参照以上顺序>/** * @Author: EvilSay * @Date: 2019/2/25 16:15 */ public interface DemoJpaRepositories extends JpaRepository<DemoJpa,Integer> { //根据firstName与LastName查找(两者必须在数据库有) DemoJpa findByFirstNameAndLastName(String firstName, String lastName); //根据firstName或LastName查找(两者其一有就行) DemoJpa findByLastNameOrFirstName(String lastName,String firstName); //根据firstName查找它是否存在数据库里<类似与以下关键字> //DemoJpa findByFirstName(String firstName); DemoJpa findByFirstNameIs(String firstName); //在Age数值age到age2之间的数据 List<DemoJpa> findByAgeBetween(Integer age, Integer age2); //小于指定age数值之间的数据 List<DemoJpa> findByAgeLessThan(Integer age); //小于等于指定age数值的数据 List<DemoJpa> findByAgeLessThanEqual(Integer age); //大于指定age数值之间的数据 List<DemoJpa> findByAgeGreaterThan(Integer age); //大于或等于指定age数值之间的数据 List<DemoJpa> findByAgeGreaterThanEqual(Integer age); //在指定age数值之前的数据类似关键字<LessThan> List<DemoJpa> findByAgeAfter(Integer age); //在指定age数值之后的数据类似关键字<GreaterThan> List<DemoJpa> findByAgeBefore(Integer age); //返回age字段为空的数据 List<DemoJpa> findByAgeIsNull(); //返回age字段不为空的数据 List<DemoJpa> findByAgeNotNull(); /** * 该关键字我一度以为是类似数据库的模糊查询, * 但是我去官方文档看到它里面并没有通配符。 * 所以我觉得它类似 * DemoJpa findByFirstName(String firstName); * @see https://docs.spring.io/spring-data/jpa/docs/2.1.5.RELEASE/reference/html/#jpa.repositories */ DemoJpa findByFirstNameLike(String firstName); //同上 List<DemoJpa> findByFirstNameNotLike(String firstName); //查找数据库中指定类似的名字(如:输入一个名字"M" Jpa会返回多个包含M开头的名字的数据源)<类似数据库模糊查询> List<DemoJpa> findByFirstNameStartingWith(String firstName); //查找数据库中指定不类似的名字(同上) List<DemoJpa> findByFirstNameEndingWith(String firstName); //查找包含的指定数据源(这个与以上两个字段不同的地方在与它必须输入完整的数据才可以查询) List<DemoJpa> findByFirstNameContaining(String firstName); //根据age选取所有的数据源并按照LastName进行升序排序 List<DemoJpa> findByAgeOrderByLastName(Integer age); //返回不是指定age的所有数据 List<DemoJpa> findByAgeNot(Integer age); //查找包含多个指定age返回的数据 List<DemoJpa> findByAgeIn(List<Integer> age); }单元测试<已经全部通过>@SpringBootTest @RunWith(SpringRunner.class) @Slf4j public class DemoJpaRepositoriesTest { @Autowired private DemoJpaRepositories repositories; @Test public void findByFirstNameAndLastName() { DemoJpa demoJpa = repositories.findByFirstNameAndLastName("May", "Eden"); Assert.assertEquals(demoJpa.getFirstName(),"May"); } @Test public void findByLastNameOrFirstName() { DemoJpa demoJpa = repositories.findByLastNameOrFirstName("Geordie", "Eden"); Assert.assertNotEquals(demoJpa.getLastName(),"Eden"); } @Test public void findByFirstNameIs() { DemoJpa demoJpa = repositories.findByFirstNameIs("amy"); Assert.assertNull(demoJpa); } @Test public void findByAgeBetween() { List<DemoJpa> demoJpaList = repositories.findByAgeBetween(15, 17); Assert.assertEquals(3,demoJpaList.size()); } @Test public void findByAgeLessThan() { List<DemoJpa> demoJpaList = repositories.findByAgeLessThan(17); Assert.assertEquals(2,demoJpaList.size()); } @Test public void findByAgeLessThanEqual() { List<DemoJpa> demoJpaList = repositories.findByAgeLessThanEqual(17); Assert.assertEquals(3,demoJpaList.size()); } @Test public void findByAgeGreaterThan() { List<DemoJpa> demoJpaList = repositories.findByAgeGreaterThan(17); Assert.assertEquals(2,demoJpaList.size()); } @Test public void findByAgeGreaterThanEqual() { List<DemoJpa> demoJpaList = repositories.findByAgeGreaterThanEqual(17); Assert.assertEquals(3,demoJpaList.size()); } @Test public void findByAgeAfter() { List<DemoJpa> demoJpaList = repositories.findByAgeAfter(17); Assert.assertEquals(2,demoJpaList.size()); } @Test public void findByAgeBefore() { List<DemoJpa> demoJpaList = repositories.findByAgeBefore(17); Assert.assertEquals(2,demoJpaList.size()); } @Test public void findByAgeIsNull() { List<DemoJpa> demoJpaList = repositories.findByAgeIsNull(); Assert.assertEquals(0,demoJpaList.size()); } @Test public void findByAgeNotNull() { List<DemoJpa> demoJpaList = repositories.findByAgeNotNull(); Assert.assertEquals(5,demoJpaList.size()); } @Test public void findByFirstNameLike() { DemoJpa demoJpa = repositories.findByFirstNameLike("May"); Assert.assertNotNull(demoJpa); } @Test public void findByFirstNameNotLike() { } @Test public void findByFirstNameStartingWith() { List<DemoJpa> demoJpaList = repositories.findByFirstNameStartingWith("May"); Assert.assertEquals(2,demoJpaList.size()); } @Test public void findByFirstNameEndingWith() { List<DemoJpa> demoJpaList = repositories.findByFirstNameEndingWith("Evil"); Assert.assertEquals(0,demoJpaList.size()); } @Test public void findByFirstNameContaining() { List<DemoJpa> demoJpaList = repositories.findByFirstNameContaining("hack"); Assert.assertEquals(0,demoJpaList.size()); } @Test public void findByAgeOrderByLastName() { List<DemoJpa> demoJpaList = repositories.findByAgeOrderByLastName(18); for (DemoJpa demoJpaL : demoJpaList){ log.info("数据结果"+demoJpaL.toString()); } } @Test public void findByAgeNot() { List<DemoJpa> demoJpaList = repositories.findByAgeNot(20); Assert.assertEquals(5,demoJpaList.size()); } @Test public void findByAgeIn() { List<DemoJpa> demoJpaList = repositories.findByAgeIn(Arrays.asList(15, 16)); Assert.assertEquals(2,demoJpaList.size()); } }后语如果本文对你哪怕有一丁点帮助,请帮忙点好看。你的好看是我坚持写作的动力。
什么是模板引擎?Thymeleaf 是一种模板语言。那模板语言或模板引擎是什么?常见的模板语言都包含以下几个概念:数据(Data)、模板(Template)、模板引擎(Template Engine)和结果文档(Result Documents)。数据数据是信息的表现形式和载体,可以是符号、文字、数字、语音、图像、视频等。数据和信息是不可分离的,数据是信息的表达,信息是数据的内涵。数据本身没有意义,数据只有对实体行为产生影响时才成为信息。模板模板,是一个蓝图,即一个与类型无关的类。编译器在使用模板时,会根据模板实参对模板进行实例化,得到一个与类型相关的类。模板引擎模板引擎(这里特指用于Web开发的模板引擎)是为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,用于网站的模板引擎就会生成一个标准的HTML文档。结果文档一种特定格式的文档,比如用于网站的模板引擎就会生成一个标准的HTML文档。模板语言用途广泛,常见的用途如下:页面渲染文档生成代码生成所有 “数据+模板=文本” 的应用场景Thymeleaf 简介Thymeleaf 是一个 Java 类库,它是一个 xml/xhtml/html5 的模板引擎,可以作为 MVC 的 web 应用的 View 层。Thymeleaf 还提供了额外的模块与 SpringMVC 集成,所以我们可以使用 Thymeleaf 完全替代 JSP 。Thymeleaf 语法博客资料:http://www.cnblogs.com/nuoyiamy/p/5591559.html官方文档:http://www.thymeleaf.org/documentation.htmlSpringBoot 整合 Thymeleaf下面使用 SpringBoot 整合 Thymeleaf 开发一个简陋版的学生信息管理系统。1、准备工作IDEAJDK1.8SpringBoot2.1.32、pom.xml 主要依赖<dependencies> <!-- JPA 数据访问 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- thymeleaf 模板引擎 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- web 启动类 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- mysql 数据库连接类 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> </dependencies>3、application.yaml 文件配置spring: # 数据库相关 datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&useSSL=true username: root password: 123456 # jpa 相关 jpa: hibernate: ddl-auto: update # ddl-auto: 第一次启动项目设为 create 表示每次都重新建表,之后设置为 update show-sql: true4、实体类@Data @Entity @AllArgsConstructor @NoArgsConstructor public class Student { @Id @GeneratedValue /** * 主键 */ private Long id; /** * 主键 */ private Long studentId; /** * 姓名 */ private String name; /** * 年龄 */ private Integer age; /** * 专业 */ private String major; /** * 宿舍 */ private String dormitory; /** * 籍贯 */ private String city; /*@Temporal(TemporalType.TIMESTAMP)//将时间戳,转换成年月日时分秒的日期格式 @Column(name = "create_time",insertable = false, updatable=false, columnDefinition = "timestamp default current_timestamp comment '注册时间'") private Date createDate; @Temporal(TemporalType.TIMESTAMP)//将时间戳,转换成年月日时分秒的日期格式 @Column(name = "update_time",insertable = false, updatable=true, columnDefinition = "timestamp default current_timestamp comment '修改时间'") private Date updateDate;*/ }5、dao 层@Repository public interface StudentRepository extends JpaRepository<Student, Long> { }6、service 层public interface StudentService { List<Student> findStudentList(); Student findStudentById(Long id); Student saveStudent(Student student); Student updateStudent(Student student); void deleteStudentById(Long id); }实现类:@Service public class StudentServiceImpl implements StudentService { @Autowired private StudentRepository studentRepository; /** * 查询所有学生信息列表 * @return */ @Override public List<Student> findStudentList() { Sort sort = new Sort(Direction.ASC,"id"); return studentRepository.findAll(sort); } /** * 根据 id 查询单个学生信息 * @param id * @return */ @Override public Student findStudentById(Long id) { return studentRepository.findById(id).get(); } /** * 保存学生信息 * @param student * @return */ @Override public Student saveStudent(Student student) { return studentRepository.save(student); } /** * 更新学生信息 * @param student * @return */ @Override public Student updateStudent(Student student) { return studentRepository.save(student); } /** * 根据 id 删除学生信息 * @param id * @return */ @Override public void deleteStudentById(Long id) { studentRepository.deleteById(id); } }7、controller 层 (Thymeleaf) 使用controller 层将 view 指向 Thymeleaf:@Controller @RequestMapping("/student") public class StudentController { @Autowired private StudentService studentService; /** * 获取学生信息列表 * @param map * @return */ @GetMapping("/list") public String findStudentList(ModelMap map) { map.addAttribute("studentList",studentService.findStudentList()); return "studentList"; } /** * 获取保存 student 表单 */ @GetMapping(value = "/create") public String createStudentForm(ModelMap map) { map.addAttribute("student", new Student()); map.addAttribute("action", "create"); return "studentForm"; } /** * 保存学生信息 * @param student * @return */ @PostMapping(value = "/create") public String saveStudent(@ModelAttribute Student student) { studentService.saveStudent(student); return "redirect:/student/list"; } /** * 根据 id 获取 student 表单,编辑后提交更新 * @param id * @param map * @return */ @GetMapping(value = "/update/{id}") public String edit(@PathVariable Long id, ModelMap map) { map.addAttribute("student", studentService.findStudentById(id)); map.addAttribute("action", "update"); return "studentForm"; } /** * 更新学生信息 * @param student * @return */ @PostMapping(value = "/update") public String updateStudent(@ModelAttribute Student student) { studentService.updateStudent(student); return "redirect:/student/list"; } /** * 删除学生信息 * @param id * @return */ @GetMapping(value = "/delete/{id}") public String deleteStudentById(@PathVariable Long id) { studentService.deleteStudentById(id); return "redirect:/student/list"; } }简单说下,ModelMap 对象来进行数据绑定到视图。return 字符串,该字符串对应的目录在 resources/templates 下的模板名字。 @ModelAttribute 注解是用来获取页面 Form 表单提交的数据,并绑定到 Student 数据对象。8、studentForm 表单定义了一个 Form 表单用于注册或修改学生信息。<form th:action="@{/student/{action}(action=${action})}" method="post" class="form-horizontal"> <div class="form-group"> <label for="student_Id" class="col-sm-2 control-label">学号:</label> <div class="col-xs-4"> <input type="text" class="form-control" id="student_Id" name="name" th:value="${student.studentId}" th:field="*{student.studentId}"/> </div> </div> <div class="form-group"> <label for="student_name" class="col-sm-2 control-label">姓名:</label> <div class="col-xs-4"> <input type="text" class="form-control" id="student_name" name="name" th:value="${student.name}" th:field="*{student.name}"/> </div> </div> <div class="form-group"> <label for="student_age" class="col-sm-2 control-label">年龄:</label> <div class="col-xs-4"> <input type="text" class="form-control" id="student_age" name="name" th:value="${student.age}" th:field="*{student.age}"/> </div> </div> <div class="form-group"> <label for="student_major" class="col-sm-2 control-label">专业:</label> <div class="col-xs-4"> <input type="text" class="form-control" id="student_major" name="name" th:value="${student.major}" th:field="*{student.major}"/> </div> </div> <div class="form-group"> <label for="student_dormitory" class="col-sm-2 control-label">宿舍:</label> <div class="col-xs-4"> <input type="text" class="form-control" id="student_dormitory" name="name" th:value="${student.dormitory}" th:field="*{student.dormitory}"/> </div> </div> <div class="form-group"> <label for="student_city" class="col-sm-2 control-label">籍贯:</label> <div class="col-xs-4"> <input type="text" class="form-control" id="student_city" name="writer" th:value="${student.city}" th:field="*{student.city}"/> </div> </div> <div class="form-group"> <div class="col-sm-offset-3 col-sm-10"> <input class="btn btn-primary" type="submit" value="提交"/>&nbsp;&nbsp; <input class="btn" type="button" value="返回" onclick="history.back()"/> </div> </div> </form>9、studentList 学生列表用于展示学生信息:<table class="table table-hover table-condensed"> <legend> <strong>学生信息列表</strong> </legend> <thead> <tr> <th>学号</th> <th>姓名</th> <th>年龄</th> <th>专业</th> <th>宿舍</th> <th>籍贯</th> <th>管理</th> </tr> </thead> <tbody> <tr th:each="student : ${studentList}"> <th scope="row" th:text="${student.studentId}"></th> <td><a th:href="@{/student/update/{studentId}(studentId=${student.id})}" th:text="${student.name}"></a></td> <td th:text="${student.age}"></td> <td th:text="${student.major}"></td> <td th:text="${student.dormitory}"></td> <td th:text="${student.city}"></td> <td><a class="btn btn-danger" th:href="@{/student/delete/{studentId}(studentId=${student.id})}">删除</a></td> </tr> </tbody> </table>页面效果列表页面:点击按钮可注册学生信息注册/修改学生信息页面:点提交保存学生信息到数据库并返回列表页面有数据的列表页面:点击名字跳到注册/修改页面可修改学生信息,点击删除可删除学生信息源码下载https://github.com/turoDog/Demo/tree/master/springboot_thymeleaf_demo后语如果本文对你哪怕有一丁点帮助,请帮忙点好看。你的好看是我坚持写作的动力。
2022年05月