Java业务开发常见错误100例(代码篇-2)
11丨空值处理:分不清楚的null和恼人的空指针
- 业务代码中 5 种最容易出现空指针异常的写法
- 参数值是 Integer 等包装类型,使用时因为自动拆箱出现了空指针异常;
- 字符串比较出现空指针异常;
- 诸如 ConcurrentHashMap 这样的容器不支持 Key 和 Value 为 null,强行 put null 的Key 或 Value 会出现空指针异常;
- A 对象包含了 B,在通过 A 对象的字段获得 B 之后,没有对字段判空就级联调用 B 的方法出现空指针异常;
- 方法或远程服务返回的 List 不是空而是 null,没有进行判空就直接调用 List 的方法出现空指针异常;
- 通过 Optional 配合 Stream 可以避免大多数冗长的 if-else 判空逻辑,实现一行代码优雅判空。另外,要定位和修复空指针异常,除了可以通过增加日志进行排查外,在生产上使用 Arthas 来查看方法的调用栈和入参会更快捷。
- POJO 中字段的 null 定位,从服务端的角度往往很难分清楚,到底是客户端希望忽略这个字段还是有意传了 null,因此我们尝试用 Optional类来区分 null 的定位。同时,为避免把空值更新到数据库中,可以实现动态 SQL,只更新必要的字段。
- 数据库字段允许保存 null,会进一步增加出错的可能性和复杂度。会有 NULL、空字符串和字符串 null 三种状态
- MySQL sum 函数、count 函数,以及 NULL 值条件可能踩的坑
- sum 函数没统计到任何记录时,会返回 null 而不是 0,可以使用 IFNULL函数把 null 转换为 0;
- count 字段不统计 null 值,COUNT(*) 才是统计所有记录数量的正确方式。
- =NULL 并不是判断条件而是赋值,对 NULL 进行判断只能使用 IS NULL 或者 IS NOT NULL。
大多数业务应用都采用的三层架构:
文章图片
业务性质上异常可能分为业务异常和系统异常两大类:
对于自定义的业务异常:以 Warn 级别的日志记录异常以及当前 URL、执行方法等信息后,提取异常中的错误码和消息等信息,转换为合适的 API 包装体返回给 API 调用方;
对于无法处理的系统异常:以 Error 级别的日志记录异常和上下文信息(比如 URL、参数、用户 ID)后,转换为普适的“服务器忙,请稍后再试”异常信息,同样以 API 包装体返回给调用方。
- Repository 层出现异常或许可以忽略,或许可以降级,或许需要转化为一个友好的异常。如果一律捕获异常仅记录日志,很可能业务逻辑已经出错,而用户和程序本身完全感知不到。
- Service 层往往涉及数据库事务,出现异常同样不适合捕获,否则事务无法自动回滚。此外 Service 层涉及业务逻辑,有些业务逻辑执行中遇到业务异常,可能需要在异常后转入分支业务流程。如果业务异常都被框架捕获了,业务功能就会不正常。
- 如果下层异常上升到 Controller 层还是无法处理的话,Controller 层往往会给予用户友好提示,或是根据每一个 API 的异常表返回指定的异常类型,同样无法对所有异常一视同仁。
- 错误打log姿势:
- 捕获了异常后直接生吞
- 没有生吞,但是丢弃异常的原始信息
@GetMapping("wrong1") public void wrong1(){ try { readFile(); } catch (IOException e) { //wrong1:原始异常信息丢失 //throw new RuntimeException("系统忙请稍后再试"); //wrong2:只保留了异常消息,栈没有记录 //log.error("文件读取错误, {}", e.getMessage()); //throw new RuntimeException("系统忙请稍后再试"); //correct1: log.error("文件读取错误", e); throw new RuntimeException("系统忙请稍后再试"); //correct2: throw new RuntimeException("系统忙请稍后再试", e); } }
- 抛出异常时不指定任何消息
throw new RuntimeException();
- 如果你捕获了异常打算处理的话,除了通过日志正确记录异常原始信息外,通常还有
三种处理模式:
- 转换,即转换新的异常抛出。对于新抛出的异常,最好具有特定的分类和明确的异常消息,而不是随便抛一个无关或没有任何信息的异常,并最好通过 cause 关联老异常。
- 重试,即重试之前的操作。比如远程调用服务端过载超时的情况,盲目重试会让问题更
严重,需要考虑当前情况是否适合重试。 - 恢复,即尝试进行降级处理,或使用默认值来替代原始数据。
- 小心 finally 中的异常
- 虽然 try 中的逻辑出现了异常,但却被 finally
中的异常覆盖了
@GetMapping("wrong") public void wrong() { try { log.info("try"); //异常丢失 throw new RuntimeException("try"); } finally { // wrong //log.info("finally"); //throw new RuntimeException("finally"); //correct log.info("finally"); try { throw new RuntimeException("finally"); } catch (Exception ex) { log.error("finally", ex); } } }
- 可以改为 try-with-resources 模式
@GetMapping("useresourceright") public void useresourceright() throws Exception { try (TestResource testResource = new TestResource()){ testResource.read(); } }
- 虽然 try 中的逻辑出现了异常,但却被 finally
- 千万别把异常定义为静态变量,务必确保异常是每次 new 出来的。否则可能会引起栈信息的错乱。
- 确保正确处理了线程池中任务的异常:
- 如果任务通过 execute 提交,那么出现异常会导致线程退出,大量的异常会导致线程重复创建引起性能问题,我们应该尽可能确保任务不出异常,同时设置默认的未捕获异常处理程序来兜底;
- 如果任务通过 submit 提交意味着我们关心任务的执行结果,应该通过拿到的 Future 调用其 get 方法来获得任务运行结果和可能出现的异常,否则异常可能就被生吞了。
记录日志引起的坑,容易出错主要在于三个方面:
- 日志框架的兼容问题
- 日志文件配置复杂且容易出错
- 日志记录本身就有些误区,比如没考虑到日志内容获取的代价、胡乱使用日志级别等
Java 体系的日志框架,确实非常多,而不同的类库,还可能选择使用不同的日志框架。这样一来,日志的统一管理就变得非常困难。为了解决这个问题,就有了 SLF4J((SimpleLogging Facade For Java))。而一般我们也都是使用SLF4J去管理:
文章图片
SLF4J 实现了三种功能:
- 提供了统一的日志门面 API,即图中紫色部分,实现了中立的日志记录 API。
- 提供桥接功能,即图中蓝色部分,用来把各种日志框架的 API(图中绿色部分)桥接到SLF4J API。这样一来,即便你的程序中使用了各种日志 API 记录日志,最终都可以桥接到 SLF4J 门面 API。
- 提供适配功能,即图中红色部分,可以实现 SLF4J API 和实际日志框架(图中灰色部分)的绑定。SLF4J 只是日志标准,我们还是需要一个实际的日志框架。日志框架本身没有实现 SLF4J API,所以需要有一个前置转换。Logback 就是按照 SLF4J API 标准实现的,因此不需要绑定模块做转换。
- 如果程序启动时出现 SLF4J 的错误提示,那很可能是配置出现了问题,可以使用 Maven 的 dependency:tree 命令梳理依赖关系
- Logback 是 Java 最常用的日志框架,其配置比较复杂,你可以参考官方文档中关于Appender、Layout、Filter 的配置,切记不要随意从其他地方复制别人的配置,避免出现错误或与当前需求不符。
- 使用异步日志解决性能问题,是用空间换时间。但空间毕竟有限,当空间满了之后,我们要考虑是阻塞等待,还是丢弃日志。如果更希望不丢弃重要日志,那么选择阻塞等待;如果更希望程序不要因为日志记录而阻塞,那么就需要丢弃日志。
- 使用日志占位符,而不是 字符串拼接
- 如果需要读写字符流,那么需要确保文件中字符的字符集和字符流的字符集是一致
的,否则可能产生乱码。
- 不指定Charset(程序会自动以当前机器的默认字符集来读取文件的)
char[] chars = new char[10]; String content = ""; try (FileReader fileReader = new FileReader("hello.txt")) { int count; while ((count = fileReader.read(chars)) != -1) { content += new String(chars, 0, count); } } log.info("result:{}", content);
- 指定Charset
Files.write(Paths.get("hello2.txt"), "你好hi".getBytes(Charsets.UTF_8)); byte[] content = Files.readAllBytes(Paths.get("hello2.txt")); log.info("bytes:{}",Hex.encodeHexString(content));
- 不指定Charset(程序会自动以当前机器的默认字符集来读取文件的)
- 使用 Files 类的一些流式处理操作,注意使用 try-with-resources 包装 Stream,确保底层文件资源可以释放,避免产生 too many open files 的问题。
- 不使用 try-with-resources(后台不会关闭进程,而是会一直新开一个进程,直到无法再开新的线程)
LongAdder longAdder = new LongAdder(); IntStream.rangeClosed(1, 1000000).forEach(i -> { try { Files.lines(Paths.get("demo.txt")).forEach(line -> longAdder.increment } catch (IOException e) { e.printStackTrace(); } }); log.info("total : {}", longAdder.longValue());
- 使用 try-with-resources
LongAdder longAdder = new LongAdder(); IntStream.rangeClosed(1, 1000000).forEach(i -> { try (Stream lines = Files.lines(Paths.get("demo.txt"))) { lines.forEach(line -> longAdder.increment()); } catch (IOException e) { e.printStackTrace(); } }); log.info("total : {}", longAdder.longValue());
- 不使用 try-with-resources(后台不会关闭进程,而是会一直新开一个进程,直到无法再开新的线程)
- 进行文件字节流操作的时候,一般情况下不考虑进行逐字节操作,使用缓冲区进行批量读写减少 IO 次数,性能会好很多。一般可以考虑直接使用缓冲输入输出流BufferedXXXStream,追求极限性能的话可以考虑使用 FileChannel 进行流转发。
- 不对数据进行处理,直接把原文件数据写入目标文件;
private static void perByteOperation() throws IOException { try (FileInputStream fileInputStream = new FileInputStream("src.txt"); FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) int i; while ((i = fileInputStream.read()) != -1) { fileOutputStream.write(i); } } }
复制一个 35MB 的文件,耗时 190 秒
- 改良后,使用 100 字节作为缓冲区
private static void bufferOperationWith100Buffer() throws IOException { try (FileInputStream fileInputStream = new FileInputStream("src.txt"); FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) byte[] buffer = new byte[100]; int len = 0; while ((len = fileInputStream.read(buffer)) != -1) { fileOutputStream.write(buffer, 0, len); } } }
复制一个 35MB 的文件,耗时 26秒
(可以看到,在进行文件 IO 处理的时候,使用合适的缓冲区可以明显提高性能)
- 使用BufferedXXXStream,其内部实现了一个默认 8KB 大小的缓冲区(但是,在使用BufferedInputStream 和 BufferedOutputStream 时,还是建议大家再使用一个缓冲进行读写,不要因为它们实现了内部缓冲就进行逐字节的操作)。
这里我直接贴出三种方式,具体代码 放在code repository里,可自行翻阅:
Java业务开发常见错误100例
- 直接使用 BufferedInputStream 和 BufferedOutputStream;
- 额外使用一个 8KB 缓冲,使用 BufferedInputStream 和 BufferedOutputStream;
- 直接使用 FileInputStream 和 FileOutputStream,再使用一个 8KB 的缓冲。
最后,三者的性能分别是 1.4、110 毫秒 和 110 毫秒 - 使用FileChannel,速度最快,可达 50 毫秒,比最原始的190秒,快了足足 数千倍
- 不对数据进行处理,直接把原文件数据写入目标文件;
15丨序列化:一来一回你还是原来的你吗?
基于Redis 和 Web API 的入参和出参两个场景,介绍 序列化和反序列化时需要避开的几个坑
- 要确保序列化和反序列化算法的一致性。因为,不同序列化算法输出必定不同,要正确处理序列化后的数据就要使用相同的反序列化算法。
- Jackson 有大量的序列化和反序列化特性,可以用来微调序列化和反序列化的细节。需要注意的是,如果自定义 ObjectMapper 的 Bean,小心不要和 Spring Boot 自动配置的 Bean 冲突。
- 在调试序列化反序列化问题时,我们一定要捋清楚三点:是哪个组件在做序列化反序列化、整个过程有几次序列化反序列化,以及目前到底是序列化还是反序列化
- 对于反序列化默认情况下,框架调用的是无参构造方法,如果要调用自定义的有参构造方法,那么需要告知框架如何调用。更合理的方式是,对于需要序列化的 POJO 考虑尽量不要自定义构造方法。
- 枚举不建议定义在 DTO 中跨服务传输,因为会有版本问题,并且涉及序列化反序列化时会很复杂,容易出错。因此,只建议在程序内部使用枚举。
Java Date系列已成为遗留产品,新的Java8中的时间新特性,已经可以全面替换旧的了,旧的不仅可读性差、易用性差、使用起来冗余繁琐,还有线程安全问题,所以也强烈建议大家使用JDK8的。除了好用之外,二者有区别的地方还在于:
java.util.Date 类是因为使用 UTC 表示,所以没有时区概念,本质是时间戳;
而 LocalDateTime,严格上可以认为是一个日期时间的表示,而不是一时间点。
文章图片
- 初始化时间: (例子:2019 年 12 月 31 日 11 点 12 分 13秒)
- jdk8之前:
Date date = new Date(2019 - 1900 , 11, 31, 11, 12, 13);
有国际化需求,需要使用到Calendar类
jdk8之前: 年应该是和 1900 的差值,月应该是从0 到 11 而不是从 1 到 12。
- jdk8后:
LocalDateTime date = LocalDateTime.of(2019, 12, 31, 11, 12, 13);
- jdk8之前:
- 时区问题:处理好时间和时区问题首先就是要正确保存日期时间。这里有两种保存方式:
- 以 UTC 保存,保存的时间没有时区属性,是不涉及时区时间差问题的世界统一
时间,前面我们说过,Date类就是存得是UTC的时间戳, - 以字面量保存,比如年 / 月 / 日 时: 分: 秒,一定要同时保存时区信息。
- Date存得是UTC时间戳,不同时区服务器读出的时间是不一样的,例如:拿 2020-01-02 22:00:00,这个时间作为例子,分别按照默认程序时区,和指定NewYork时区,输出 解析后的时间
String stringDate = "2020-01-02 22:00:00"; SimpleDateFormat inputFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); Date date1 = inputFormat.parse(stringDate); System.out.println(date1 + ":" + date1.getTime()); inputFormat.setTimeZone(TimeZone.getTimeZone("America/New_York")); Date date2 = inputFormat.parse(stringDate); System.out.println(date2 + ":" + date2.getTime());
输出:(发现相差13个小时,所以说,如果你的公司服务器有跨时区的,)
Thu Jan 02 22:00:00 CST 2020:1577973600000
解决方案:务必指定 存和读的 时区是一致的。存的时候,需要使用正确的当前时区来保存,这样 UTC 时间才会正确;读的时候,也只有正确设置本地时区,才能把 UTC 时间转换为正确的当地时间。
Fri Jan 03 11:00:00 CST 2020:1578020400000
- 更好的方案:Java 8 推出了新的时间日期类 ZoneId、ZoneOffset、LocalDateTime、ZonedDateTime
和 DateTimeFormatter,处理时区问题更简单清晰。
LocalDateTime 不带有时区属性,所以命名为本地时区的日期时间;
我们拿上海、纽约和东京,举个例子: 依旧是 2020-01-02 22:00:00这个time
而 ZonedDateTime=LocalDateTime+ZoneId,具有时区属性。
因此,LocalDateTime 只能认为是一个时间表示,ZonedDateTime 才是一个有效的时间
- 代码:
String stringDate = "2020-01-02 22:00:00"; ZoneId timeZoneSH = ZoneId.of("Asia/Shanghai"); ZoneId timeZoneNY = ZoneId.of("America/New_York"); ZoneId timeZoneJST = ZoneOffset.ofHours(9); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); ZonedDateTime date = ZonedDateTime.of(LocalDateTime.parse(stringDate, dateTimeFormatter), timeZoneJST); DateTimeFormatter outputFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z"); System.out.println(timeZoneSH.getId() + outputFormat.withZone(timeZoneSH).format(date)); System.out.println(timeZoneNY.getId() + outputFormat.withZone(timeZoneNY).format(date)); System.out.println(timeZoneJST.getId() + outputFormat.withZone(timeZoneJST).format(date));
- 输出:
Asia/Shanghai2020-01-02 21:00:00 +0800
America/New_York2020-01-02 08:00:00 -0500
+09:002020-01-02 22:00:00 +0900 - 结论:要正确处理国际化时间问题,推荐使用 Java 8 的日期时间类,即使用 ZonedDateTime 保存时间,然后使用设置了 ZoneId 的 DateTimeFormatter 配合ZonedDateTime 进行时间格式化得到本地时间表示。这样的划分十分清晰、细化,也不容易出错。
- 代码:
- 以 UTC 保存,保存的时间没有时区属性,是不涉及时区时间差问题的世界统一
- 日期时间格式化和解析
- Date - ”YYYY-MM-dd 著名Bug“--提前跨年,”这明明是一个 2019 年的日期,怎么使用 SimpleDateFormat 格式化后就提前跨年了“。
- 例如:初始化一个 Calendar,设置日期时间为 2019 年 12 月 29 日,使用大写的 YYYY 来初始化 SimpleDateFormat。但最后输出的,却是 2020 年 12 月 29 日,好家伙,直接多了一年!
- 其原因在于:开发人员混淆了 SimpleDateFormat 的各种格式化模式。JDK的文档中有说明:小写 y 是年,而大写 Y 是 week year,也就是所在的周属于哪一年。
- 而按照当前 zh_CN 区域来说,2020 年第一周的条件是,从周日开始的完整 7 天,2020 年包含 1 天即可。显然,2019 年 12 月 29 日周日到 2020 年 1 月 4 日周六是 2020 年第一周,得出的 weekyear 就是 2020 年。但是如果你把时区换成France,就不会有问题。
- Date - 定义的 static 的 SimpleDateFormat 可能会出现线程安全问题
- Date - 当需要解析的字符串和格式不匹配的时候,SimpleDateFormat 表现得很宽容,例如:
使用 yyyyMM 来解析 20160901,它居然不报错,但是结果是:
2091 年 1 月 1 日,原因在于:把0901 当成了月份,相当于 75 年,无语子。。。 - 相比旧的Date,新的JDK8 Date就没有这些问题,也不用管是 YYYY 还是 yyyy,DateTimeFormatter要是线程安全的。
- Date - ”YYYY-MM-dd 著名Bug“--提前跨年,”这明明是一个 2019 年的日期,怎么使用 SimpleDateFormat 格式化后就提前跨年了“。
- 日期时间的计算:
- 日期时间的计算,一个很多开发常踩的坑。有人直接使用时间戳进行时间计算,比如希望得到当前时间之后 30 天的时间,会这么写代码:直接把 newDate().getTime 方法得到的时间戳加 30 天对应的毫秒数,也就是 30 天 1000 毫秒 3600 秒 *24 小时。但是会发现 结果根本不对
其原因在于 int发生溢出,修复方式就是把 30 改为 30L
但还是很繁琐,且容易出错,所以jdk8之前,更推荐使用 Calendar - jdk8后,日期时间类型,可以直接进行各种计算,更加简洁、方便和强大。
但 计算两个日期差时可能会踩坑,
Period.between 得到了两个 LocalDate 的差,返回的是两个日期差几年零几月零几天。如果希望得知两个日期之间差几天,直接调用Period 的 getDays() 方法得到的只是最后的“零几天”,而不是算总的间隔天数。
- 日期时间的计算,一个很多开发常踩的坑。有人直接使用时间戳进行时间计算,比如希望得到当前时间之后 30 天的时间,会这么写代码:直接把 newDate().getTime 方法得到的时间戳加 30 天对应的毫秒数,也就是 30 天 1000 毫秒 3600 秒 *24 小时。但是会发现 结果根本不对
通常而言,Java 程序的 OOM有如下几种可能:
- 程序确实需要超出 JVM 配置的内存上限的内存。不管是程序实现的不合理,还是因为各种框架对数据的重复处理、加工和转换,相同的数据在内存中不一定只占用一份空间。针对内存量使用超大的业务逻辑,比如缓存逻辑、文件上传下载和导出逻辑,我们在做容量评估时,可能还需要实际做一下 Dump,而不是进行简单的假设。
- 出现内存泄露,其实就是我们认为没有用的对象最终会被 GC,但却没有。GC 并不会回收强引用对象,我们可能经常在程序中定义一些容器作为缓存,但如果容器中的数据无限增长,要特别小心最终会导致 OOM。使用 WeakHashMap 是解决这个问题的好办法,但值得注意的是,如果强引用的 Value 有引用 Key,也无法回收 Entry。
- 不合理的资源需求配置,在业务量小的时候可能不会出现问题,但业务量一大可能很快就会撑爆内存。比如,随意配置 Tomcat 的 max-http-header-size 参数,会导致一个请求使用过多的内存,请求量大的时候出现 OOM。在进行参数配置的时候,我们要认识到,很多限制类参数限制的是背后资源的使用,资源始终是有限的,需要根据实际需求来合理设置参数。
18丨当反射、注解和泛型遇到OOP时,会有哪些坑?
【Java业务开发常见错误100例(代码篇-2)】虽然我们日常业务项目中几乎都是增删改查,用到反射、注解和泛型这些高级特性的机会少之又少,没啥好学的。但是,只有学好、用好这些高级特性,才能开发出更简洁易读的代码,而且几乎所有的框架都使用了这三大高级特性。比如,要减少重复代码,就得用到反射和注解。
- 反射调用方法并不是通过调用时的传参确定方法重载,而是在获取方法的时候通过方法名和参数类型来确定的。遇到方法有包装类型和基本类型重载的时候,所以需要特别注意这一点。
- 反射获取类成员,需要注意 getXXX 和 getDeclaredXXX 方法的区别,其中 XXX 包括 Methods、Fields、Constructors、Annotations。
- 泛型因为类型擦除会导致泛型方法 T 占位符被替换为 Object,子类如果使用具体类型覆盖父类实现,编译器会生成桥接方法。这样既满足子类方法重写父类方法的定义,又满足子类实现的方法有具体的类型。使用反射来获取方法清单时,所以需要特别注意这一点。
- 自定义注解可以通过标记元注解 @Inherited 实现注解的继承,不过这只适用于类。如果要继承定义在接口或方法上的注解,可以使用 Spring 的工具类AnnotatedElementUtils,并注意各种 getXXX 方法和 findXXX 方法的区别。
- 让 Spring 容器管理对象,要考虑对象默认的 Scope 单例是否适合,对于有状态的类型,单例可能产生内存泄露问题。
- 如果要为单例的 Bean 注入 Prototype 的 Bean,绝不是仅仅修改 Scope 属性这么简单。由于单例的 Bean 在容器启动时就会完成一次性初始化。最简单的解决方案是,把Prototype 的 Bean 设置为通过代理注入,也就是设置 proxyMode 属性为TARGET_CLASS。
- 如果一组相同类型的 Bean 是有顺序的,需要明确使用 @Order 注解来设置顺序。可以再回顾下,两个不同优先级切面中 @Before、@After 和 @Around 三种增强的执行顺序,是什么样的。
- Spring Cloud 会使用Spring Boot 的特性,根据当前引入包的情况做各种自动装配。如果我们要扩展 Spring 的组件,那么只有清晰了解 Spring 自动装配的运作方式,才能鉴别运行时对象在 Spring 容器中的情况,不能想当然认为代码中能看到的所有 Spring 的类都是 Bean。
- 对于配置优先级的案例,分析配置源优先级时,如果我们以为看到PropertySourcesPropertyResolver 就看到了真相,后续进行扩展开发时就可能会踩坑。我们一定要注意,分析 Spring 源码时,你看到的表象不一定是实际运行时的情况,还需要借助日志或调试工具来理清整个过程。如果没有调试工具,可以借助Arthas,来分析代码调用路径。
推荐阅读
- Java业务开发常见错误100例(代码篇-1)
- 数据库开发|通过栗子来学习MySQL高级知识点(学习,复习,面试都可)
- 程序员|redis常见数据结构以及使用场景分析,附面试题答案
- 程序员|备战三个月,Redis面试复习大纲在手面试不慌
- 程序员|Java体系化进阶学习图谱(java面试一般多长时间)
- 微信App支付(JAVA端)
- Java|TCP的三次握手和四次挥手以及TCP和UDP协议的区别
- Java|Alibab流出的这本“MQ技术手册”,看完我愣住了!
- 面试|Alibaba十年开发架构师,心血浇筑“MQ技术手册”